Compare commits

..

No commits in common. "gh-pages" and "main" have entirely different histories.

424 changed files with 24970 additions and 12377 deletions

53
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,53 @@
name: Bug report
description: Something in a plugin is broken or behaving wrong
labels: ["bug"]
body:
- type: dropdown
id: plugin
attributes:
label: Which plugin?
options:
- Staff Tags
- RoleColorEverywhere
- PronounDB
- Message Snippets
- Reminders
- Not sure / something else
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you expect, and what did you actually get?
placeholder: "I tried /snippet welcome and nothing was sent to the channel."
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce
placeholder: |
1. Enable the plugin
2. Run /snippet welcome
3. ...
validations:
required: false
- type: input
id: discord-version
attributes:
label: Discord app version (Settings → About)
placeholder: "e.g. 338.4"
validations:
required: false
- type: textarea
id: logs
attributes:
label: Anything in the Revenge debugger/logs?
description: Settings → General → Debugger, if you have it enabled
validations:
required: false

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Core Revenge app issue (not a plugin)
url: https://github.com/revenge-mod/revenge-bundle/issues
about: If the problem is with Revenge itself rather than one of these plugins, report it there instead.

View file

@ -0,0 +1,29 @@
name: Feature request / new plugin idea
description: Something you want added to an existing plugin, or a new plugin entirely
labels: ["enhancement"]
body:
- type: dropdown
id: kind
attributes:
label: What kind of request is this?
options:
- New feature for an existing plugin
- A brand new plugin idea
validations:
required: true
- type: input
id: plugin
attributes:
label: Which plugin? (leave blank if this is a new plugin idea)
placeholder: "e.g. Staff Tags"
validations:
required: false
- type: textarea
id: description
attributes:
label: What do you want, and why?
description: The more concrete, the better - what would you actually do with it?
validations:
required: true

View file

@ -0,0 +1,55 @@
name: A plugin stopped working after a Discord update
description: The most common failure mode here - Discord renamed/removed something a plugin depends on
labels: ["bug", "discord-update"]
body:
- type: markdown
attributes:
value: |
Discord's internals change without notice, and plugins in this ecosystem patch those internals
directly - this is the single most common way something breaks here. Reports like this are
genuinely useful, thank you.
- type: dropdown
id: plugin
attributes:
label: Which plugin?
options:
- Staff Tags
- RoleColorEverywhere
- PronounDB
- Message Snippets
- Reminders
validations:
required: true
- type: dropdown
id: surface
attributes:
label: What stopped showing up / working?
description: Pick whichever's closest - helps narrow down which patch broke
options:
- Chat messages
- Member list
- Profile popout
- Channel header
- Typing indicator
- Voice channel
- A slash command
- Not sure
validations:
required: false
- type: input
id: discord-version
attributes:
label: Discord app version (Settings → About)
placeholder: "e.g. 338.4"
validations:
required: true
- type: textarea
id: worked-before
attributes:
label: Did this work before, and if so, roughly when did it stop?
validations:
required: false

34
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,34 @@
name: Build and deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Prepare environment
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Install deps
run: npm install
- name: Build plugin(s) and site
run: npm run build
- name: Install API deps
working-directory: api
run: npm install
- name: Deploy to Cloudflare Workers
working-directory: api
env:
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
run: npx wrangler deploy

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
dist
.wrangler
.superpowers/

View file

736
404.html
View file

@ -1,736 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>revenge-plugins</title>
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f5f6fb;
--bg-elevated: #ffffff;
--bg-sunken: #eceef6;
--border: #e1e4ef;
--text: #14151f;
--text-muted: #62667a;
--accent: #5865f2;
--accent-text: #ffffff;
--chip-bg: #ececf7;
--chip-bg-active: #5865f2;
--chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec;
--badge-new-text: #1a7f4b;
--badge-revived-bg: #eef0ff;
--badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0;
--badge-rebuilt-text: #b5680a;
--danger: #f23f42;
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
code, .mono { font-family: var(--mono); }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
a { color: inherit; }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
padding: 64px 0 40px;
}
.hero-mesh {
position: absolute;
inset: -20%;
z-index: 0;
opacity: 0.5;
background:
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
filter: blur(40px);
animation: drift 22s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
from { transform: translate3d(0,0,0) scale(1); }
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
}
.hero-spotlight {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
transition: opacity 0.25s ease;
}
.hero:hover .hero-spotlight { opacity: 1; }
.hero-inner { position: relative; z-index: 1; }
.mark {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 18px;
}
.mark svg { width: 22px; height: 22px; display: block; }
h1 {
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 14px;
letter-spacing: -0.02em;
font-weight: 800;
}
h1 .grad {
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
.hero-links a {
font-size: 14px; font-weight: 600; text-decoration: none;
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
}
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.kbd-hint {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-muted); font-weight: 600;
}
.kbd-hint kbd {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
}
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
/* ---------- Controls ---------- */
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
.controls.stuck { border-bottom-color: var(--border); }
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
.search {
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); font-size: 15px;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
main { padding: 24px 0 64px; }
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
/* ---------- Grid / cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 20px 18px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
cursor: pointer;
overflow: hidden;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--card-accent, var(--accent));
}
.card:hover, .card:focus-visible {
transform: translateY(-3px);
border-color: var(--card-accent, var(--accent));
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
outline: none;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
/* ---------- Detail view ---------- */
.detail { display: none; }
.detail.active { display: block; }
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
.detail-banner {
position: relative;
overflow: hidden;
padding: 48px 0 36px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
var(--bg);
}
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
}
.back-link:hover { color: var(--text); }
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.detail-actions button, .detail-actions a {
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
}
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
.detail-section { margin-bottom: 32px; }
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cmd-row:last-child { border-bottom: none; }
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.side-card.warn h5 { color: var(--danger); }
.authors-line { font-size: 13px; color: var(--text-muted); }
/* ---------- Command palette ---------- */
.palette-overlay {
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
}
.palette-overlay.open { display: flex; }
.palette {
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
}
.palette input {
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.palette input:focus { outline: none; }
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
.palette-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
.palette-item .name { font-weight: 600; }
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<header class="hero" id="hero">
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
<div class="wrap hero-inner">
<div class="mark">
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
Revenge Plugins
</div>
<h1>Discord, <span class="grad">your way.</span></h1>
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
<div class="hero-links">
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
</div>
<div class="stats" id="stats" aria-hidden="true"></div>
</div>
</header>
<div class="wrap grid-view" id="gridView">
<div class="controls" id="controls">
<div class="search-row">
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
<span class="kbd-hint"><kbd>&#8984;</kbd><kbd>K</kbd></span>
</div>
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
</div>
<main>
<p class="count" id="count"></p>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>No plugins match your search.</div>
</main>
</div>
<div class="detail" id="detailView"></div>
<footer>
<div class="wrap">
<p>Built with <a href="https://github.com/everestmcarthur/revenge-plugins">revenge-plugins</a> · not affiliated with Discord or the Revenge project · install links point to this site</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="palette-overlay" id="paletteOverlay">
<div class="palette">
<input id="paletteInput" type="text" placeholder="Jump to a plugin…" aria-label="Jump to a plugin">
<div class="palette-results" id="paletteResults"></div>
</div>
</div>
<script>
(function () {
var STATUS_LABEL = { new: "New", revived: "Revived", rebuilt: "Rebuilt" };
var state = { plugins: [], query: "", category: "All" };
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var count = document.getElementById("count");
var chipsEl = document.getElementById("categoryChips");
var searchEl = document.getElementById("search");
var toastEl = document.getElementById("toast");
var statsEl = document.getElementById("stats");
var heroEl = document.getElementById("hero");
var controlsEl = document.getElementById("controls");
var gridViewEl = document.getElementById("gridView");
var detailViewEl = document.getElementById("detailView");
var paletteOverlay = document.getElementById("paletteOverlay");
var paletteInput = document.getElementById("paletteInput");
var paletteResults = document.getElementById("paletteResults");
var CHECK_SVG = '<svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { toastEl.classList.remove("show"); }, 1600);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
function installButton(url, extraStyle) {
var btn = document.createElement("button");
btn.className = "install";
btn.textContent = "Install";
if (extraStyle) btn.setAttribute("style", extraStyle);
btn.addEventListener("click", function (e) {
e.stopPropagation();
copyToClipboard(url).then(function () {
var original = btn.textContent;
btn.textContent = "Copied!";
showToast("Install link copied - paste it into Revenge's plugin browser");
setTimeout(function () { btn.textContent = original; }, 1400);
});
});
return btn;
}
function countUp(el, target) {
var start = 0;
var duration = 700;
var startTime = null;
function step(ts) {
if (!startTime) startTime = ts;
var progress = Math.min((ts - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function renderStats() {
var categories = Array.from(new Set(state.plugins.map(function (p) { return p.category; })));
var revivedCount = state.plugins.filter(function (p) { return p.status !== "new"; }).length;
var stats = [
{ value: state.plugins.length, label: "plugins" },
{ value: categories.length, label: "categories" },
{ value: revivedCount, label: "revived or rebuilt" }
];
statsEl.innerHTML = stats.map(function (s, i) {
return '<div><div class="stat-value" id="stat-' + i + '">0</div><div class="stat-label">' + s.label + '</div></div>';
}).join("");
stats.forEach(function (s, i) {
countUp(document.getElementById("stat-" + i), s.value);
});
}
/* ---------- Grid ---------- */
function render() {
var q = state.query.trim().toLowerCase();
var filtered = state.plugins.filter(function (p) {
var matchesCategory = state.category === "All" || p.category === state.category;
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
var matchesQuery = !q || haystack.indexOf(q) !== -1;
return matchesCategory && matchesQuery;
});
count.textContent = filtered.length + " plugin" + (filtered.length === 1 ? "" : "s");
grid.innerHTML = "";
empty.hidden = filtered.length !== 0;
filtered.forEach(function (p) {
var card = document.createElement("button");
card.type = "button";
card.className = "card";
card.style.setProperty("--card-accent", p.accent);
card.innerHTML =
'<div class="card-top">' +
'<h3>' + escapeHtml(p.name) + '</h3>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="category">' + escapeHtml(p.category) + '</div>' +
'<p class="tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
(p.features && p.features.length ? '<div class="feature-count">' + CHECK_SVG.replace("<svg ", '<svg style="width:13px;height:13px" ') + ' ' + p.features.length + ' feature' + (p.features.length === 1 ? "" : "s") + '</div>' : '') +
'<div class="cta">View details &rarr;</div>';
card.addEventListener("click", function () {
navigateToPlugin(p.id);
});
grid.appendChild(card);
});
}
function renderChips() {
var categories = ["All"].concat(
Array.from(new Set(state.plugins.map(function (p) { return p.category; }))).sort()
);
chipsEl.innerHTML = "";
categories.forEach(function (cat) {
var chip = document.createElement("button");
chip.className = "chip";
chip.textContent = cat;
chip.setAttribute("aria-pressed", String(cat === state.category));
chip.addEventListener("click", function () {
state.category = cat;
Array.from(chipsEl.children).forEach(function (c) {
c.setAttribute("aria-pressed", String(c === chip));
});
render();
});
chipsEl.appendChild(chip);
});
}
searchEl.addEventListener("input", function (e) {
state.query = e.target.value;
render();
});
window.addEventListener("scroll", function () {
controlsEl.classList.toggle("stuck", window.scrollY > 8);
});
var heroSpotlight = document.getElementById("heroSpotlight");
heroEl.addEventListener("mousemove", function (e) {
var rect = heroEl.getBoundingClientRect();
heroSpotlight.style.setProperty("--sx", (e.clientX - rect.left) + "px");
heroSpotlight.style.setProperty("--sy", (e.clientY - rect.top) + "px");
});
/* ---------- Detail view ---------- */
function renderDetail(p) {
var featuresHtml = (p.features || []).map(function (f) {
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
}).join("");
var commandsHtml = (p.commands && p.commands.length)
? '<div class="detail-section"><h4>Commands</h4><div class="cmd-table">' +
p.commands.map(function (c) {
return '<div class="cmd-row"><code>' + escapeHtml(c.cmd) + '</code><span>' + escapeHtml(c.desc) + '</span></div>';
}).join("") +
'</div></div>'
: "";
var howItWorksHtml = p.howItWorks
? '<div class="detail-section"><h4>How it works</h4><p>' + escapeHtml(p.howItWorks) + '</p></div>'
: "";
var featuresSection = featuresHtml
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
: "";
detailViewEl.innerHTML =
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div class="wrap">' +
'<button class="back-link" id="backLink">&larr; All plugins</button>' +
'<div class="detail-top">' +
'<h2>' + escapeHtml(p.name) + '</h2>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="detail-category">' + escapeHtml(p.category) + '</div>' +
'<p class="detail-tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
'<div class="detail-actions" id="detailActions"></div>' +
'</div>' +
'</div>' +
'<div class="wrap">' +
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
'<div>' +
(p.note ? '<div class="side-card"><h5>Backstory</h5><p>' + escapeHtml(p.note) + '</p></div>' : "") +
(p.limitations ? '<div class="side-card warn"><h5>Known limitations</h5><p>' + escapeHtml(p.limitations) + '</p></div>' : "") +
(p.authors && p.authors.length ? '<div class="side-card"><h5>Author</h5><p class="authors-line">' + escapeHtml(p.authors.join(", ")) + '</p></div>' : "") +
'</div>' +
'</div>' +
'</div>';
var actions = document.getElementById("detailActions");
actions.appendChild(installButton(p.installUrl));
var sourceLink = document.createElement("a");
sourceLink.href = p.sourceUrl;
sourceLink.target = "_blank";
sourceLink.rel = "noopener";
sourceLink.textContent = "Source";
actions.appendChild(sourceLink);
var issueLink = document.createElement("a");
issueLink.href = p.issueUrl;
issueLink.target = "_blank";
issueLink.rel = "noopener";
issueLink.textContent = "Report issue";
actions.appendChild(issueLink);
document.getElementById("backLink").addEventListener("click", function () {
history.pushState(null, "", "/");
showGrid();
});
}
function showDetail(id) {
var plugin = state.plugins.filter(function (p) { return p.id === id; })[0];
if (!plugin) { showGrid(); return; }
renderDetail(plugin);
heroEl.classList.add("hidden");
controlsEl.classList.add("hidden");
gridViewEl.classList.add("hidden");
detailViewEl.classList.add("active");
window.scrollTo(0, 0);
document.title = plugin.name + " · revenge-plugins";
}
function showGrid() {
heroEl.classList.remove("hidden");
controlsEl.classList.remove("hidden");
gridViewEl.classList.remove("hidden");
detailViewEl.classList.remove("active");
document.title = "revenge-plugins";
}
/**
* Each plugin has a real static page at /<id>/ (a copy of this same file), so a direct visit or
* refresh works without any server-side routing - this just avoids a full reload when navigating
* from within an already-loaded page.
*/
function navigateToPlugin(id) {
history.pushState(null, "", "/" + id + "/");
showDetail(id);
}
function routeFromLocation() {
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (match && match[1]) {
showDetail(match[1]);
} else {
showGrid();
}
}
window.addEventListener("popstate", routeFromLocation);
/* ---------- Command palette ---------- */
var paletteIndex = 0;
var paletteMatches = [];
function openPalette() {
paletteOverlay.classList.add("open");
paletteInput.value = "";
renderPalette("");
setTimeout(function () { paletteInput.focus(); }, 0);
}
function closePalette() {
paletteOverlay.classList.remove("open");
}
function renderPalette(q) {
q = q.trim().toLowerCase();
paletteMatches = state.plugins.filter(function (p) {
return !q || (p.name + " " + p.category).toLowerCase().indexOf(q) !== -1;
});
paletteIndex = 0;
if (!paletteMatches.length) {
paletteResults.innerHTML = '<div class="palette-empty">No matches</div>';
return;
}
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '" style="--item-accent:' + p.accent + '">' +
'<span class="dot"></span><span class="name">' + escapeHtml(p.name) + '</span><span class="cat">' + escapeHtml(p.category) + '</span>' +
'</div>';
}).join("");
Array.from(paletteResults.children).forEach(function (el, i) {
el.addEventListener("click", function () { selectPaletteItem(i); });
});
}
function updatePaletteSelection() {
Array.from(paletteResults.children).forEach(function (el, i) {
el.setAttribute("aria-selected", String(i === paletteIndex));
});
var active = paletteResults.children[paletteIndex];
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectPaletteItem(i) {
var plugin = paletteMatches[i];
if (!plugin) return;
closePalette();
navigateToPlugin(plugin.id);
}
paletteInput.addEventListener("input", function (e) { renderPalette(e.target.value); });
paletteOverlay.addEventListener("click", function (e) { if (e.target === paletteOverlay) closePalette(); });
paletteInput.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, paletteMatches.length - 1); updatePaletteSelection(); }
else if (e.key === "ArrowUp") { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); updatePaletteSelection(); }
else if (e.key === "Enter") { e.preventDefault(); selectPaletteItem(paletteIndex); }
else if (e.key === "Escape") { closePalette(); }
});
document.addEventListener("keydown", function (e) {
var isTyping = document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA");
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openPalette();
return;
}
if (e.key === "/" && !isTyping) {
e.preventDefault();
openPalette();
return;
}
if (e.key === "Escape" && paletteOverlay.classList.contains("open")) {
closePalette();
}
});
/* ---------- Boot ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json")
.then(function (r) { return r.json(); })
.then(function (data) {
state.plugins = data;
renderStats();
renderChips();
render();
routeFromLocation();
})
.catch(function () {
count.textContent = "";
empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing.";
});
})();
</script>
</body>
</html>

105
README.md Normal file
View file

@ -0,0 +1,105 @@
# revenge-plugins
A collection of plugins for [Revenge](https://github.com/revenge-mod/revenge-bundle), the Discord Android client mod.
**Browse them at [rp.jarviscli.dev](https://rp.jarviscli.dev)** - searchable, with a full detail page per plugin.
## Installing a plugin
Each plugin has two URLs:
- `rp.jarviscli.dev/<plugin>/` - a real page describing the plugin (features, commands, how it works)
- `rp.jarviscli.dev/<plugin>/install/` - the actual install link you paste into Revenge
To install: open Discord with Revenge installed, go to **Settings → Plugins**, tap `+`, and paste the
`/install/` link.
## Plugins
### Moderation & appearance
| Plugin | Status | Description | Install link |
| --- | --- | --- | --- |
| Staff Tags | Rebuilt | OWNER/ADMIN/STAFF/MOD-style tags next to members, computed from real server permissions. Per-tag text, colors, gradients, and visibility. | `https://rp.jarviscli.dev/staff-tags/install/` |
| Custom User Tags | New | Create your own tags (text + color) and assign them to specific users, independent of server permissions. Long-press a name to manage one, or add by user ID in settings. | `https://rp.jarviscli.dev/custom-user-tags/install/` |
| Radial Status | Revived | Replaces the small presence dot on an avatar with a colored ring instead - one color per status, adjustable size and thickness. Fork of [AngelW0lf's Radial Status](https://github.com/Angelix1/MP/tree/master/angel/radialstatus), BSD-3-Clause - dropped a phone-home mechanism that could silently uninstall the plugin for specific user IDs, and replaced non-functional size/thickness controls with real ones. | `https://rp.jarviscli.dev/radial-status/install/` |
| RoleColorEverywhere | Revived | A member's top role color shown in mentions, the typing indicator, voice channel names, member list role headers, and optionally message text. | `https://rp.jarviscli.dev/role-color-everywhere/install/` |
| PronounDB | Revived | Shows a user's pronouns in their profile, if they've set them at pronoundb.org (nothing to configure in the plugin itself). | `https://rp.jarviscli.dev/pronoun-db/install/` |
| FakeProfileThemesAndEffects | Revived | Profile theming and profile effects without Nitro. Fork of [shipwr3ckd's FPTE](https://github.com/shipwr3ckd/FPTE) - fixed a crash on every profile-editor open, confirmed via on-device diagnostics. | `https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/` |
| BetterTimestamps | Revived | Calendar-relative, fully relative, ISO 8601, or a custom moment.js timestamp format, for both messages and day dividers. | `https://rp.jarviscli.dev/better-timestamps/install/` |
| CopyRoleColor | Revived | Long-press a role pill to copy its color as hex. | `https://rp.jarviscli.dev/copy-role-color/install/` |
| ServerDrawer | Revived | Replaces the Quest Dock with an expandable server grid - DMs, servers, folders, one-tap create/join, and mark-all-as-read, all in one drawer, with the left server rail hidden. Fork of [kmmiio99o's ServerDrawer](https://github.com/kmmiio99o/vd-plugins/tree/main/plugins/ServerDrawer) - fixed an unguarded lookup that could silently skip every patch after it, guild icons that never loaded, and drawer taps that didn't navigate. Folder options adapted from [fres621's BetterFolders](https://github.com/fres621/vendetta-plugins/tree/master/plugins/BetterFolders), rebuilt against the drawer's own folders since BetterFolders' target component no longer exists. | `https://rp.jarviscli.dev/server-drawer/install/` |
| YouBar+ | Revived | Adds DM and/or Settings buttons to the YouBar. Fork of [Purple-EyeZ's YouBar+](https://github.com/Purple-EyeZ/RevengePlugins/tree/main/plugins/YouBarPlus) - fixed a startup race condition where the button patch could silently never apply for an entire session. | `https://rp.jarviscli.dev/you-bar-plus/install/` |
### Productivity
| Plugin | Status | Description | Install link |
| --- | --- | --- | --- |
| Message Snippets | New | Save reusable text and send it with `/snippet <name>`. Manage snippets in-app or with `/snippet-save`, `/snippet-delete`, `/snippet-list`. | `https://rp.jarviscli.dev/message-snippets/install/` |
| Reminders | New | `/remind 20m Walk the dog` - fires while Discord is running. Can't wake the app from fully closed (no native notification access from a JS plugin). | `https://rp.jarviscli.dev/reminders/install/` |
| Urban Dictionary | Revived | `/urban <term>` - posts the top Urban Dictionary result to the channel. Built on the stable command API, no UI patching. | `https://rp.jarviscli.dev/urban-dictionary/install/` |
| Reverse Image Search | New | `/reverse-image-search <image-url>` - opens the image in Google Lens, Yandex, or TinEye. | `https://rp.jarviscli.dev/reverse-image-search/install/` |
### Utility
| Plugin | Status | Description | Install link |
| --- | --- | --- | --- |
| 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, colorful channels, an expanded dev-tools/Developer Mode, a full Message Logger, and more - independently toggleable modules. Fork of [nexpid's NexxUtils](https://github.com/nexpid/RevengePlugins/tree/main/src/plugins/nexxutils), CC-BY 4.0 - fixed a build bug that crashed the settings screen and silently dropped every module's patches. | `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` (GPLv3, forked from
bwlok's ViewRaw), `plugins/rose-utils` (CC-BY 4.0, forked from nexpid's NexxUtils), and `plugins/radial-status`
(BSD-3-Clause, forked from AngelW0lf's Radial Status) - the rest of the repo isn't under any of those licenses.
## Why some of these broke before, and what's different now
Both Staff Tags and RoleColorEverywhere previously failed completely (not just partially) when a single internal
Discord component got renamed - one bad lookup threw during setup, which crashed the plugin's `onLoad` before it
could apply any of its other patches. Every rebuilt/revived plugin in this repo isolates each patch surface behind
its own guard, so if Discord changes one internal again, only that specific surface goes quiet instead of taking
the whole plugin down. PronounDB's break was simpler: its old data source was a PronounDB API version that Discord
had nothing to do with - PronounDB itself shut it down (HTTP 410) - so it's been migrated to their current v2 API.
## Shared plugin library
`shared/` holds code every plugin can use - `@shared/lib/patcher` (the crash-isolation helper described
above), `@shared/lib/color`, and reusable settings UI (`SettingsScaffold`, `NoteBox`, `ColorInput`,
`ListSection`, `PrimaryButton`). Plugins are still installed and run independently by users, so this isn't
a runtime dependency - Rollup inlines whatever a plugin imports from `@shared/*` straight into that
plugin's own bundle at build time. Import it like any other module:
```ts
import { applyPatches } from "@shared/lib/patcher";
import ColorInput from "@shared/ui/ColorInput";
```
## Building locally
```sh
npm install
npm run build
```
This runs both `build.mjs` (compiles every plugin in `plugins/*` into `dist/<id>/install/`) and
`scripts/generate-site.mjs` (builds `dist/plugins-data.json` from those manifests + `site/meta.json`,
and writes the website - a homepage at `dist/index.html` and a real page per plugin at `dist/<id>/index.html`).
## Releasing an update
1. Edit a plugin under `plugins/<id>/src/`.
2. `npm run build` locally and sanity-check the output in `dist/`.
3. `git add -A && git commit -m "..." && git push origin main`.
4. GitHub Actions rebuilds and redeploys to `rp.jarviscli.dev` automatically (usually under a minute) -
check progress at the repo's **Actions** tab.
There's no manual deploy step - anything merged to `main` goes live.
## Contributing
Issues and PRs are welcome, especially if a Discord update breaks a lookup a plugin relies on - `findByName` /
`findByProps` targets can and do change without notice.

1537
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

12
api/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "revenge-plugins-api",
"private": true,
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"migrate": "wrangler d1 execute revenge-plugins-overlay --remote --file=./schema.sql"
},
"devDependencies": {
"wrangler": "^4.0.0"
}
}

28
api/schema.sql Normal file
View file

@ -0,0 +1,28 @@
-- Every displayed field can now be overridden here, not just status/broken/tags - editing anything
-- from the admin plugin should show up on the live site immediately, no git push needed. A row can
-- also stand entirely on its own (is_draft = 1): a plugin that doesn't exist in the git repo at all,
-- created straight from the admin plugin for quick testing, with its own manifest/JS served directly
-- from here instead of from the built dist/ output.
DROP TABLE IF EXISTS overlay;
CREATE TABLE overlay (
id TEXT PRIMARY KEY,
name TEXT,
description TEXT,
category TEXT,
status TEXT,
accent TEXT,
tagline TEXT,
note TEXT,
how_it_works TEXT,
features TEXT, -- JSON array of strings
commands TEXT, -- JSON array of {cmd, desc}
limitations TEXT,
authors TEXT, -- JSON array of strings
broken_reason TEXT,
broken_since TEXT,
tags TEXT NOT NULL DEFAULT '[]',
is_draft INTEGER NOT NULL DEFAULT 0,
manifest TEXT, -- full manifest.json content - only used when is_draft = 1
main_js TEXT, -- index.js source - only used when is_draft = 1
updated_at TEXT NOT NULL
);

374
api/src/index.ts Normal file
View file

@ -0,0 +1,374 @@
export interface Env {
ASSETS: Fetcher;
DB: D1Database;
OVERLAY_CACHE: KVNamespace;
HUB: DurableObjectNamespace;
ADMIN_TOKEN: string;
}
// Single global room every browser tab connects to - broadcasts "something changed, refetch" to
// every open tab the moment an admin write succeeds, instead of tabs only picking it up on their
// next natural fetch. Uses the Hibernatable WebSockets API (acceptWebSocket/getWebSockets) so the
// object doesn't have to stay pinned in memory (and billed) between messages - it wakes on a new
// connection, a client event, or an incoming /broadcast call, and can go back to sleep otherwise.
export class OverlayHub {
constructor(private state: DurableObjectState, private env: Env) {}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/broadcast") {
const body = await request.text();
for (const ws of this.state.getWebSockets()) {
try { ws.send(body); } catch { /* dead socket, ignore */ }
}
return new Response("ok");
}
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("expected websocket", { status: 426 });
}
const pair = new WebSocketPair();
const [client, server] = Object.values(pair);
this.state.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage() { /* clients never need to send anything, ignore */ }
async webSocketClose(ws: WebSocket) {
try { ws.close(); } catch { /* already closing */ }
}
}
async function broadcast(env: Env, message: unknown) {
const stub = env.HUB.get(env.HUB.idFromName("global"));
await stub.fetch("https://internal/broadcast", { method: "POST", body: JSON.stringify(message) });
}
interface Command {
cmd: string;
desc: string;
}
// Every field is optional - a PUT only needs to send what it wants to change, and whatever isn't
// sent falls back to the git-tracked base (for a plugin that exists in git) or a sane default (for
// a brand-new draft-only plugin, which has no base to fall back to at all).
interface OverlayEntry {
name?: string;
description?: string;
category?: string;
status?: string | null;
accent?: string;
tagline?: string;
note?: string;
howItWorks?: string;
features?: string[];
commands?: Command[];
limitations?: string;
authors?: string[];
broken?: { reason: string; since?: string } | null;
tags?: string[];
draft?: boolean;
mainJs?: string; // raw plugin source - only meaningful for a draft, ignored otherwise
}
interface Row {
id: string;
name: string | null;
description: string | null;
category: string | null;
status: string | null;
accent: string | null;
tagline: string | null;
note: string | null;
how_it_works: string | null;
features: string | null;
commands: string | null;
limitations: string | null;
authors: string | null;
broken_reason: string | null;
broken_since: string | null;
tags: string;
is_draft: number;
manifest: string | null;
main_js: string | null;
}
// Short TTL, not a long-lived cache - the whole point of this overlay is that a change from a phone
// should be visible almost immediately, not after a long edge-cache window.
const CACHE_KEY = "overlay:v2";
const CACHE_TTL_SECONDS = 60; // Workers KV's enforced minimum
const CORS_HEADERS = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET,HEAD,PUT,DELETE,OPTIONS,PATCH",
"access-control-allow-headers": "authorization,content-type",
"access-control-max-age": "86400",
"vary": "Origin",
};
function withCors(response: Response): Response {
const headers = new Headers(response.headers);
for (const [k, v] of Object.entries(CORS_HEADERS)) {
headers.set(k, v);
}
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
}
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), {
status,
headers: { "content-type": "application/json", ...CORS_HEADERS },
});
}
function rowToOverlay(row: Row): OverlayEntry & { draft: boolean } {
return {
name: row.name ?? undefined,
description: row.description ?? undefined,
category: row.category ?? undefined,
status: row.status ?? undefined,
accent: row.accent ?? undefined,
tagline: row.tagline ?? undefined,
note: row.note ?? undefined,
howItWorks: row.how_it_works ?? undefined,
features: row.features ? JSON.parse(row.features) : undefined,
commands: row.commands ? JSON.parse(row.commands) : undefined,
limitations: row.limitations ?? undefined,
authors: row.authors ? JSON.parse(row.authors) : undefined,
broken: row.broken_reason ? { reason: row.broken_reason, since: row.broken_since ?? undefined } : null,
tags: row.tags ? JSON.parse(row.tags) : [],
draft: !!row.is_draft,
};
}
async function readAll(db: D1Database): Promise<Record<string, OverlayEntry & { draft: boolean }>> {
const { results } = await db.prepare("SELECT * FROM overlay").all<Row>();
const out: Record<string, OverlayEntry & { draft: boolean }> = {};
for (const row of results) out[row.id] = rowToOverlay(row);
return out;
}
// Shared by every read path (GET /api/overlay, GET /plugins-data.json, and install-file serving for
// draft plugins) so a change from the admin plugin shows up everywhere on the next request, not just
// on the raw overlay endpoint.
async function getOverlay(env: Env): Promise<Record<string, OverlayEntry & { draft: boolean }>> {
const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json");
if (cached) return cached as Record<string, OverlayEntry & { draft: boolean }>;
const data = await readAll(env.DB);
await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS });
return data;
}
async function sha256Hex(text: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
const DEFAULT_MAIN_JS = `export default {\n onLoad: () => {},\n onUnload: () => {}\n};\n`;
export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
return await handle(request, env);
} catch (e) {
console.error(e);
return json({ error: "internal" }, 500);
}
},
};
async function handle(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
return new Response(null, { headers: CORS_HEADERS });
}
// Live-update channel - the site connects here once on load; every successful overlay write
// broadcasts through it so an already-open tab updates without the visitor doing anything.
if (url.pathname === "/ws") {
const stub = env.HUB.get(env.HUB.idFromName("global"));
return stub.fetch(request);
}
// The site's actual data file - computed fresh on every request by merging the git-tracked base
// (plugins-base.json, written by generate-site.mjs at build time) with the live overlay, so any
// change from the admin plugin is visible immediately, with no rebuild. Draft-only plugins (no
// git base at all) are appended as their own entries.
if (url.pathname === "/plugins-data.json") {
const baseRes = await env.ASSETS.fetch(new URL("/plugins-base.json", request.url));
const base = baseRes.ok ? await baseRes.json<any[]>() : [];
const overlay = await getOverlay(env);
const baseIds = new Set(base.map((p) => p.id));
const merged = base.map((p) => applyOverride(p, overlay[p.id]));
const draftOnly = Object.entries(overlay)
.filter(([id, o]) => o.draft && !baseIds.has(id))
.map(([id, o]) => synthesizeDraft(id, o));
return json([...merged, ...draftOnly]);
}
// Dynamic install files for draft-only plugins (created via PUT /api/overlay/:id with draft:true
// and mainJs). Checked against D1 *first*, not by probing the static asset's response status -
// with SPA fallback enabled (see wrangler.toml's not_found_handling), a missing asset no longer
// 404s, it serves index.html with a 200, so that status can't be used to detect "not found" here.
// A real git-tracked plugin has no row here, so this always falls through to ASSETS for it.
const installMatch = url.pathname.match(/^\/([\w-]+)\/install\/(manifest\.json|index\.js)$/);
if (installMatch) {
const [, id, file] = installMatch;
const row = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1 AND is_draft = 1")
.bind(id).first<{ manifest: string; main_js: string }>();
if (row) {
if (file === "manifest.json") return json(JSON.parse(row.manifest));
return new Response(row.main_js, { headers: { "content-type": "application/javascript", ...CORS_HEADERS } });
}
return withCors(await env.ASSETS.fetch(request));
}
if (!url.pathname.startsWith("/api/")) {
return withCors(await env.ASSETS.fetch(request));
}
// GET /api/overlay - public read, used by the admin plugin/site to show current values before
// editing.
if (url.pathname === "/api/overlay" && request.method === "GET") {
return json(await getOverlay(env));
}
const match = url.pathname.match(/^\/api\/overlay\/([\w-]+)$/);
if (match) {
const id = match[1];
const auth = request.headers.get("authorization");
if (auth !== `Bearer ${env.ADMIN_TOKEN}`) {
return json({ error: "unauthorized" }, 401);
}
if (request.method === "PUT") {
const body = await request.json<OverlayEntry>().catch(() => null);
if (!body) return json({ error: "invalid body" }, 400);
const existing = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1").bind(id).first<{ manifest: string; main_js: string }>();
let manifest = existing?.manifest ?? null;
let mainJs = existing?.main_js ?? null;
if (body.draft) {
mainJs = body.mainJs ?? mainJs ?? DEFAULT_MAIN_JS;
const manifestObj = {
name: body.name ?? id,
description: body.description ?? "",
authors: (body.authors ?? []).map((n) => ({ name: n })),
main: "index.js",
hash: await sha256Hex(mainJs),
};
manifest = JSON.stringify(manifestObj);
}
await env.DB.prepare(
`INSERT INTO overlay (
id, name, description, category, status, accent, tagline, note, how_it_works,
features, commands, limitations, authors, broken_reason, broken_since, tags,
is_draft, manifest, main_js, updated_at
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name, description = excluded.description, category = excluded.category,
status = excluded.status, accent = excluded.accent, tagline = excluded.tagline,
note = excluded.note, how_it_works = excluded.how_it_works, features = excluded.features,
commands = excluded.commands, limitations = excluded.limitations, authors = excluded.authors,
broken_reason = excluded.broken_reason, broken_since = excluded.broken_since, tags = excluded.tags,
is_draft = excluded.is_draft, manifest = excluded.manifest, main_js = excluded.main_js,
updated_at = excluded.updated_at`,
).bind(
id,
body.name ?? null,
body.description ?? null,
body.category ?? null,
body.status ?? null,
body.accent ?? null,
body.tagline ?? null,
body.note ?? null,
body.howItWorks ?? null,
body.features ? JSON.stringify(body.features) : null,
body.commands ? JSON.stringify(body.commands) : null,
body.limitations ?? null,
body.authors ? JSON.stringify(body.authors) : null,
body.broken?.reason ?? null,
body.broken ? (body.broken.since ?? new Date().toISOString()) : null,
JSON.stringify(body.tags ?? []),
body.draft ? 1 : 0,
manifest,
mainJs,
new Date().toISOString(),
).run();
await env.OVERLAY_CACHE.delete(CACHE_KEY);
await broadcast(env, { type: "overlay-updated", id });
return json({ ok: true });
}
if (request.method === "DELETE") {
await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run();
await env.OVERLAY_CACHE.delete(CACHE_KEY);
await broadcast(env, { type: "overlay-updated", id });
return json({ ok: true });
}
}
return json({ error: "not found" }, 404);
}
function applyOverride(base: any, over: (OverlayEntry & { draft: boolean }) | undefined) {
if (!over) return { ...base, broken: null, tags: [], hasOverride: false, isDraft: false };
return {
...base,
hasOverride: true,
isDraft: false,
name: over.name ?? base.name,
description: over.description ?? base.description,
category: over.category ?? base.category,
status: over.status ?? base.status,
accent: over.accent ?? base.accent,
tagline: over.tagline ?? base.tagline,
note: over.note ?? base.note,
howItWorks: over.howItWorks ?? base.howItWorks,
features: over.features ?? base.features,
commands: over.commands ?? base.commands,
limitations: over.limitations ?? base.limitations,
authors: over.authors ?? base.authors,
broken: over.broken ?? null,
tags: over.tags ?? [],
};
}
function synthesizeDraft(id: string, over: OverlayEntry & { draft: boolean }) {
const base = "https://rp.jarviscli.dev";
return {
id,
isDraft: true,
hasOverride: true,
name: over.name ?? id,
description: over.description ?? "",
authors: over.authors ?? [],
category: over.category ?? "Other",
status: over.status ?? "new",
broken: over.broken ?? null,
tags: over.tags ?? [],
accent: over.accent ?? "#5865f2",
tagline: over.tagline ?? over.description ?? "",
note: over.note ?? "Draft plugin - created from the admin plugin, not tracked in git.",
howItWorks: over.howItWorks ?? "",
features: over.features ?? [],
commands: over.commands ?? [],
limitations: over.limitations ?? "",
pageUrl: `${base}/${id}/`,
installUrl: `${base}/${id}/install/`,
sourceUrl: "https://github.com/everestmcarthur/revenge-plugins",
issueUrl: `https://github.com/everestmcarthur/revenge-plugins/issues/new`,
};
}

25
api/wrangler.toml Normal file
View file

@ -0,0 +1,25 @@
name = "revenge-plugins"
main = "src/index.ts"
compatibility_date = "2024-11-01"
[assets]
directory = "../dist"
binding = "ASSETS"
not_found_handling = "single-page-application"
[[d1_databases]]
binding = "DB"
database_name = "revenge-plugins-overlay"
database_id = "639fef3c-6583-4128-adbd-b5f5f4761483"
[[kv_namespaces]]
binding = "OVERLAY_CACHE"
id = "e56d183bdcd64f5facade1c3fe04eb08"
[[durable_objects.bindings]]
name = "HUB"
class_name = "OverlayHub"
[[migrations]]
tag = "v1"
new_sqlite_classes = ["OverlayHub"]

View file

@ -1,736 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>revenge-plugins</title>
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f5f6fb;
--bg-elevated: #ffffff;
--bg-sunken: #eceef6;
--border: #e1e4ef;
--text: #14151f;
--text-muted: #62667a;
--accent: #5865f2;
--accent-text: #ffffff;
--chip-bg: #ececf7;
--chip-bg-active: #5865f2;
--chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec;
--badge-new-text: #1a7f4b;
--badge-revived-bg: #eef0ff;
--badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0;
--badge-rebuilt-text: #b5680a;
--danger: #f23f42;
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
code, .mono { font-family: var(--mono); }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
a { color: inherit; }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
padding: 64px 0 40px;
}
.hero-mesh {
position: absolute;
inset: -20%;
z-index: 0;
opacity: 0.5;
background:
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
filter: blur(40px);
animation: drift 22s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
from { transform: translate3d(0,0,0) scale(1); }
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
}
.hero-spotlight {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
transition: opacity 0.25s ease;
}
.hero:hover .hero-spotlight { opacity: 1; }
.hero-inner { position: relative; z-index: 1; }
.mark {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 18px;
}
.mark svg { width: 22px; height: 22px; display: block; }
h1 {
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 14px;
letter-spacing: -0.02em;
font-weight: 800;
}
h1 .grad {
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
.hero-links a {
font-size: 14px; font-weight: 600; text-decoration: none;
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
}
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.kbd-hint {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-muted); font-weight: 600;
}
.kbd-hint kbd {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
}
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
/* ---------- Controls ---------- */
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
.controls.stuck { border-bottom-color: var(--border); }
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
.search {
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); font-size: 15px;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
main { padding: 24px 0 64px; }
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
/* ---------- Grid / cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 20px 18px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
cursor: pointer;
overflow: hidden;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--card-accent, var(--accent));
}
.card:hover, .card:focus-visible {
transform: translateY(-3px);
border-color: var(--card-accent, var(--accent));
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
outline: none;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
/* ---------- Detail view ---------- */
.detail { display: none; }
.detail.active { display: block; }
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
.detail-banner {
position: relative;
overflow: hidden;
padding: 48px 0 36px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
var(--bg);
}
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
}
.back-link:hover { color: var(--text); }
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.detail-actions button, .detail-actions a {
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
}
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
.detail-section { margin-bottom: 32px; }
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cmd-row:last-child { border-bottom: none; }
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.side-card.warn h5 { color: var(--danger); }
.authors-line { font-size: 13px; color: var(--text-muted); }
/* ---------- Command palette ---------- */
.palette-overlay {
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
}
.palette-overlay.open { display: flex; }
.palette {
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
}
.palette input {
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.palette input:focus { outline: none; }
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
.palette-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
.palette-item .name { font-weight: 600; }
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<header class="hero" id="hero">
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
<div class="wrap hero-inner">
<div class="mark">
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
Revenge Plugins
</div>
<h1>Discord, <span class="grad">your way.</span></h1>
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
<div class="hero-links">
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
</div>
<div class="stats" id="stats" aria-hidden="true"></div>
</div>
</header>
<div class="wrap grid-view" id="gridView">
<div class="controls" id="controls">
<div class="search-row">
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
<span class="kbd-hint"><kbd>&#8984;</kbd><kbd>K</kbd></span>
</div>
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
</div>
<main>
<p class="count" id="count"></p>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>No plugins match your search.</div>
</main>
</div>
<div class="detail" id="detailView"></div>
<footer>
<div class="wrap">
<p>Built with <a href="https://github.com/everestmcarthur/revenge-plugins">revenge-plugins</a> · not affiliated with Discord or the Revenge project · install links point to this site</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="palette-overlay" id="paletteOverlay">
<div class="palette">
<input id="paletteInput" type="text" placeholder="Jump to a plugin…" aria-label="Jump to a plugin">
<div class="palette-results" id="paletteResults"></div>
</div>
</div>
<script>
(function () {
var STATUS_LABEL = { new: "New", revived: "Revived", rebuilt: "Rebuilt" };
var state = { plugins: [], query: "", category: "All" };
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var count = document.getElementById("count");
var chipsEl = document.getElementById("categoryChips");
var searchEl = document.getElementById("search");
var toastEl = document.getElementById("toast");
var statsEl = document.getElementById("stats");
var heroEl = document.getElementById("hero");
var controlsEl = document.getElementById("controls");
var gridViewEl = document.getElementById("gridView");
var detailViewEl = document.getElementById("detailView");
var paletteOverlay = document.getElementById("paletteOverlay");
var paletteInput = document.getElementById("paletteInput");
var paletteResults = document.getElementById("paletteResults");
var CHECK_SVG = '<svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { toastEl.classList.remove("show"); }, 1600);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
function installButton(url, extraStyle) {
var btn = document.createElement("button");
btn.className = "install";
btn.textContent = "Install";
if (extraStyle) btn.setAttribute("style", extraStyle);
btn.addEventListener("click", function (e) {
e.stopPropagation();
copyToClipboard(url).then(function () {
var original = btn.textContent;
btn.textContent = "Copied!";
showToast("Install link copied - paste it into Revenge's plugin browser");
setTimeout(function () { btn.textContent = original; }, 1400);
});
});
return btn;
}
function countUp(el, target) {
var start = 0;
var duration = 700;
var startTime = null;
function step(ts) {
if (!startTime) startTime = ts;
var progress = Math.min((ts - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function renderStats() {
var categories = Array.from(new Set(state.plugins.map(function (p) { return p.category; })));
var revivedCount = state.plugins.filter(function (p) { return p.status !== "new"; }).length;
var stats = [
{ value: state.plugins.length, label: "plugins" },
{ value: categories.length, label: "categories" },
{ value: revivedCount, label: "revived or rebuilt" }
];
statsEl.innerHTML = stats.map(function (s, i) {
return '<div><div class="stat-value" id="stat-' + i + '">0</div><div class="stat-label">' + s.label + '</div></div>';
}).join("");
stats.forEach(function (s, i) {
countUp(document.getElementById("stat-" + i), s.value);
});
}
/* ---------- Grid ---------- */
function render() {
var q = state.query.trim().toLowerCase();
var filtered = state.plugins.filter(function (p) {
var matchesCategory = state.category === "All" || p.category === state.category;
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
var matchesQuery = !q || haystack.indexOf(q) !== -1;
return matchesCategory && matchesQuery;
});
count.textContent = filtered.length + " plugin" + (filtered.length === 1 ? "" : "s");
grid.innerHTML = "";
empty.hidden = filtered.length !== 0;
filtered.forEach(function (p) {
var card = document.createElement("button");
card.type = "button";
card.className = "card";
card.style.setProperty("--card-accent", p.accent);
card.innerHTML =
'<div class="card-top">' +
'<h3>' + escapeHtml(p.name) + '</h3>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="category">' + escapeHtml(p.category) + '</div>' +
'<p class="tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
(p.features && p.features.length ? '<div class="feature-count">' + CHECK_SVG.replace("<svg ", '<svg style="width:13px;height:13px" ') + ' ' + p.features.length + ' feature' + (p.features.length === 1 ? "" : "s") + '</div>' : '') +
'<div class="cta">View details &rarr;</div>';
card.addEventListener("click", function () {
navigateToPlugin(p.id);
});
grid.appendChild(card);
});
}
function renderChips() {
var categories = ["All"].concat(
Array.from(new Set(state.plugins.map(function (p) { return p.category; }))).sort()
);
chipsEl.innerHTML = "";
categories.forEach(function (cat) {
var chip = document.createElement("button");
chip.className = "chip";
chip.textContent = cat;
chip.setAttribute("aria-pressed", String(cat === state.category));
chip.addEventListener("click", function () {
state.category = cat;
Array.from(chipsEl.children).forEach(function (c) {
c.setAttribute("aria-pressed", String(c === chip));
});
render();
});
chipsEl.appendChild(chip);
});
}
searchEl.addEventListener("input", function (e) {
state.query = e.target.value;
render();
});
window.addEventListener("scroll", function () {
controlsEl.classList.toggle("stuck", window.scrollY > 8);
});
var heroSpotlight = document.getElementById("heroSpotlight");
heroEl.addEventListener("mousemove", function (e) {
var rect = heroEl.getBoundingClientRect();
heroSpotlight.style.setProperty("--sx", (e.clientX - rect.left) + "px");
heroSpotlight.style.setProperty("--sy", (e.clientY - rect.top) + "px");
});
/* ---------- Detail view ---------- */
function renderDetail(p) {
var featuresHtml = (p.features || []).map(function (f) {
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
}).join("");
var commandsHtml = (p.commands && p.commands.length)
? '<div class="detail-section"><h4>Commands</h4><div class="cmd-table">' +
p.commands.map(function (c) {
return '<div class="cmd-row"><code>' + escapeHtml(c.cmd) + '</code><span>' + escapeHtml(c.desc) + '</span></div>';
}).join("") +
'</div></div>'
: "";
var howItWorksHtml = p.howItWorks
? '<div class="detail-section"><h4>How it works</h4><p>' + escapeHtml(p.howItWorks) + '</p></div>'
: "";
var featuresSection = featuresHtml
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
: "";
detailViewEl.innerHTML =
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div class="wrap">' +
'<button class="back-link" id="backLink">&larr; All plugins</button>' +
'<div class="detail-top">' +
'<h2>' + escapeHtml(p.name) + '</h2>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="detail-category">' + escapeHtml(p.category) + '</div>' +
'<p class="detail-tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
'<div class="detail-actions" id="detailActions"></div>' +
'</div>' +
'</div>' +
'<div class="wrap">' +
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
'<div>' +
(p.note ? '<div class="side-card"><h5>Backstory</h5><p>' + escapeHtml(p.note) + '</p></div>' : "") +
(p.limitations ? '<div class="side-card warn"><h5>Known limitations</h5><p>' + escapeHtml(p.limitations) + '</p></div>' : "") +
(p.authors && p.authors.length ? '<div class="side-card"><h5>Author</h5><p class="authors-line">' + escapeHtml(p.authors.join(", ")) + '</p></div>' : "") +
'</div>' +
'</div>' +
'</div>';
var actions = document.getElementById("detailActions");
actions.appendChild(installButton(p.installUrl));
var sourceLink = document.createElement("a");
sourceLink.href = p.sourceUrl;
sourceLink.target = "_blank";
sourceLink.rel = "noopener";
sourceLink.textContent = "Source";
actions.appendChild(sourceLink);
var issueLink = document.createElement("a");
issueLink.href = p.issueUrl;
issueLink.target = "_blank";
issueLink.rel = "noopener";
issueLink.textContent = "Report issue";
actions.appendChild(issueLink);
document.getElementById("backLink").addEventListener("click", function () {
history.pushState(null, "", "/");
showGrid();
});
}
function showDetail(id) {
var plugin = state.plugins.filter(function (p) { return p.id === id; })[0];
if (!plugin) { showGrid(); return; }
renderDetail(plugin);
heroEl.classList.add("hidden");
controlsEl.classList.add("hidden");
gridViewEl.classList.add("hidden");
detailViewEl.classList.add("active");
window.scrollTo(0, 0);
document.title = plugin.name + " · revenge-plugins";
}
function showGrid() {
heroEl.classList.remove("hidden");
controlsEl.classList.remove("hidden");
gridViewEl.classList.remove("hidden");
detailViewEl.classList.remove("active");
document.title = "revenge-plugins";
}
/**
* Each plugin has a real static page at /<id>/ (a copy of this same file), so a direct visit or
* refresh works without any server-side routing - this just avoids a full reload when navigating
* from within an already-loaded page.
*/
function navigateToPlugin(id) {
history.pushState(null, "", "/" + id + "/");
showDetail(id);
}
function routeFromLocation() {
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (match && match[1]) {
showDetail(match[1]);
} else {
showGrid();
}
}
window.addEventListener("popstate", routeFromLocation);
/* ---------- Command palette ---------- */
var paletteIndex = 0;
var paletteMatches = [];
function openPalette() {
paletteOverlay.classList.add("open");
paletteInput.value = "";
renderPalette("");
setTimeout(function () { paletteInput.focus(); }, 0);
}
function closePalette() {
paletteOverlay.classList.remove("open");
}
function renderPalette(q) {
q = q.trim().toLowerCase();
paletteMatches = state.plugins.filter(function (p) {
return !q || (p.name + " " + p.category).toLowerCase().indexOf(q) !== -1;
});
paletteIndex = 0;
if (!paletteMatches.length) {
paletteResults.innerHTML = '<div class="palette-empty">No matches</div>';
return;
}
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '" style="--item-accent:' + p.accent + '">' +
'<span class="dot"></span><span class="name">' + escapeHtml(p.name) + '</span><span class="cat">' + escapeHtml(p.category) + '</span>' +
'</div>';
}).join("");
Array.from(paletteResults.children).forEach(function (el, i) {
el.addEventListener("click", function () { selectPaletteItem(i); });
});
}
function updatePaletteSelection() {
Array.from(paletteResults.children).forEach(function (el, i) {
el.setAttribute("aria-selected", String(i === paletteIndex));
});
var active = paletteResults.children[paletteIndex];
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectPaletteItem(i) {
var plugin = paletteMatches[i];
if (!plugin) return;
closePalette();
navigateToPlugin(plugin.id);
}
paletteInput.addEventListener("input", function (e) { renderPalette(e.target.value); });
paletteOverlay.addEventListener("click", function (e) { if (e.target === paletteOverlay) closePalette(); });
paletteInput.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, paletteMatches.length - 1); updatePaletteSelection(); }
else if (e.key === "ArrowUp") { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); updatePaletteSelection(); }
else if (e.key === "Enter") { e.preventDefault(); selectPaletteItem(paletteIndex); }
else if (e.key === "Escape") { closePalette(); }
});
document.addEventListener("keydown", function (e) {
var isTyping = document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA");
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openPalette();
return;
}
if (e.key === "/" && !isTyping) {
e.preventDefault();
openPalette();
return;
}
if (e.key === "Escape" && paletteOverlay.classList.contains("open")) {
closePalette();
}
});
/* ---------- Boot ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json")
.then(function (r) { return r.json(); })
.then(function (data) {
state.plugins = data;
renderStats();
renderChips();
render();
routeFromLocation();
})
.catch(function () {
count.textContent = "";
empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing.";
});
})();
</script>
</body>
</html>

View file

@ -1 +0,0 @@
(function(o,g,r,y,a,i,h,p){"use strict";function v(e,t,s,n){try{return s()}catch(c){return n.error(`[${e}] Failed to apply the "${t}" patch, that surface will be skipped: ${c}`),function(){}}}function M(e,t,s){const n=Object.entries(s).map(function([c,C]){return v(e,c,C,t)});return function(){return n.forEach(function(c){return c()})}}function u(e,t=r.storage.selected){try{switch(t){case"calendar":return e.calendar();case"relative":return e.fromNow();case"iso":return e.toISOString();case"custom":return e.format(r.storage.customFormat||"dddd, MMMM Do YYYY, h:mm:ss a");default:return e.calendar()}}catch{return e?.calendar?.()??String(e)}}const l=y.findByName("RowManager",!1);function R(){if(!l?.prototype?.generate)return function(){};const e=[];return e.push(i.before("generate",l.prototype,function([t]){try{t.rowType===1?(r.storage.separateMessages&&(t.isFirst=!0),t.message.__customTimestamp=u(t.message.timestamp)):t.rowType==="day"&&(t.text=u(a.moment(t.text,"LL")))}catch{}})),e.push(i.after("generate",l.prototype,function([t],s){try{if(t.rowType!==1)return;t.message?.__customTimestamp&&s?.message?.state==="SENT"&&s.message.timestamp&&(s.message.timestamp=t.message.__customTimestamp)}catch{}})),function(){return e.forEach(function(t){return t()})}}const{ScrollView:b,View:w}=a.ReactNative;function E({children:e}){return a.React.createElement(b,{style:{flex:1}},e,a.React.createElement(w,{style:{height:24}}))}const{Text:F}=a.ReactNative,{FormSection:m,FormRow:k,FormDivider:Y,FormSwitchRow:S,FormInput:T}=p.Forms,d=[{label:"Calendar",key:"calendar"},{label:"Relative",key:"relative"},{label:"ISO 8601",key:"iso"},{label:"Custom",key:"custom"}];function _(){return h.useProxy(r.storage),a.React.createElement(E,null,a.React.createElement(m,{title:"Format"},d.map(function(e,t){const s=r.storage.selected===e.key;let n="";try{n=u(a.moment(),e.key)}catch{n=""}return a.React.createElement(a.React.Fragment,{key:e.key},a.React.createElement(k,{label:e.label,subLabel:n,trailing:s?a.React.createElement(F,{style:{fontSize:16}},"\u2713"):void 0,onPress:function(){r.storage.selected=e.key}}),e.key==="custom"&&s&&a.React.createElement(T,{title:"Custom format (moment.js tokens)",placeholder:"dddd, MMMM Do YYYY, h:mm:ss a",value:r.storage.customFormat,onChange:function(c){r.storage.customFormat=c}}),t!==d.length-1&&a.React.createElement(Y,null))})),a.React.createElement(m,{title:"Layout"},a.React.createElement(S,{label:"Always show name & avatar",subLabel:"Shows the username, avatar, and timestamp on every message instead of grouping consecutive messages",value:!!r.storage.separateMessages,onValueChange:function(e){r.storage.separateMessages=e}})))}let f=function(){};var L={onLoad:function(){r.storage.selected??="calendar",r.storage.customFormat??="dddd, MMMM Do YYYY, h:mm:ss a",r.storage.separateMessages??=!1,f=M("BetterTimestamps",g.logger,{"message & day timestamps":R})},onUnload:function(){return f()},settings:_};return o.default=L,Object.defineProperty(o,"__esModule",{value:!0}),o})({},vendetta,vendetta.plugin,vendetta.metro,vendetta.metro.common,vendetta.patcher,vendetta.storage,vendetta.ui.components);

View file

@ -1 +0,0 @@
{"name":"BetterTimestamps","description":"Replace message and day-divider timestamps with calendar-relative, fully relative, ISO 8601, or a custom moment.js format.","authors":[{"name":"everestmcarthur"},{"name":"nexpid"}],"main":"index.js","hash":"6ec03be5d13360e6caf33cf37895f04ce8cb9e549440a4ec60a79c10c582f2a3"}

4
blacklist.json Normal file
View file

@ -0,0 +1,4 @@
{
"updated_at": "2026-09-07T19:51:24.372Z",
"list": ["997993767091052654"]
}

123
build.mjs Normal file
View file

@ -0,0 +1,123 @@
import { readFile, writeFile, readdir, copyFile } from "fs/promises";
import { extname, resolve as resolvePath } from "path";
import { createHash } from "crypto";
import { rollup } from "rollup";
import alias from "@rollup/plugin-alias";
import url from "@rollup/plugin-url";
import esbuild from "rollup-plugin-esbuild";
import commonjs from "@rollup/plugin-commonjs";
import nodeResolve from "@rollup/plugin-node-resolve";
import swc from "@swc/core";
const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"];
// Plugins are each built into an independent, self-contained bundle (users install them one at a time),
// so they can't import from each other at runtime - but they CAN share source at build time. This alias
// lets any plugin `import { x } from "@shared/..."` and have it inlined straight into its own bundle.
/** @type import("rollup").InputPluginOption */
const plugins = [
alias({
entries: [
{ find: "@shared", replacement: resolvePath("./shared") },
// FPTE's own internal aliases, namespaced to avoid colliding with any other plugin
// that might want a generic "@lib"/"@ui" of its own.
{ find: "@fpte/lib", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/lib") },
{ find: "@fpte/ui", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/ui") },
{ find: "@fpte/patches", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/patches") },
],
}),
nodeResolve({ extensions: [".mjs", ".js", ".json", ".node", ".ts", ".tsx"] }),
commonjs(),
url({
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],
limit: 0,
}),
{
name: "swc",
async transform(code, id) {
const ext = extname(id);
if (!extensions.includes(ext)) return null;
const ts = ext.includes("ts");
const tsx = ts ? ext.endsWith("x") : undefined;
const jsx = !ts ? ext.endsWith("x") : undefined;
const result = await swc.transform(code, {
filename: id,
jsc: {
externalHelpers: true,
parser: {
syntax: ts ? "typescript" : "ecmascript",
tsx,
jsx,
},
},
env: {
targets: "defaults",
include: [
"transform-classes",
"transform-arrow-functions",
],
},
});
return result.code;
},
},
esbuild({ minify: true }),
];
// Each plugin's actual install target (manifest.json + index.js) lives at /<id>/install/, so that
// /<id>/ itself is free to be a real, bookmarkable HTML page describing the plugin (see generate-site.mjs).
for (const plug of await readdir("./plugins")) {
const manifest = JSON.parse(await readFile(`./plugins/${plug}/manifest.json`));
const outPath = `./dist/${plug}/install/index.js`;
try {
const bundle = await rollup({
input: `./plugins/${plug}/${manifest.main}`,
onwarn: () => {},
// Most plugins only ever import React/ReactNative via @vendetta/metro/common, which
// resolves to nothing on disk and so is treated as external automatically - but a
// couple of ported plugins import the bare "react"/"react-native" packages directly
// (like their own original build configs expected), and those DO exist in
// node_modules, so without this they'd actually get bundled - including react-native's
// real source, which contains Flow syntax this toolchain can't parse.
external: ["react", "react-native"],
plugins,
});
await bundle.write({
file: outPath,
globals(id) {
if (id.startsWith("@vendetta")) return id.substring(1).replace(/\//g, ".");
const map = {
react: "window.React",
"react-native": "vendetta.metro.common.ReactNative",
};
return map[id] || null;
},
format: "iife",
compact: true,
exports: "named",
});
await bundle.close();
const toHash = await readFile(outPath);
manifest.hash = createHash("sha256").update(toHash).digest("hex");
manifest.main = "index.js";
await writeFile(`./dist/${plug}/install/manifest.json`, JSON.stringify(manifest));
await writeFile(`./dist/${plug}/manifest.json`, JSON.stringify(manifest));
await copyFile(outPath, `./dist/${plug}/index.js`);
console.log(`Successfully built ${manifest.name}!`);
} catch (e) {
console.error(`Failed to build ${plug}...`, e);
process.exit(1);
}
}
try {
await copyFile("./blacklist.json", "./dist/blacklist.json");
} catch (_) {}

View file

@ -1 +0,0 @@
(function(c,l,i,p,s,d,f,y){"use strict";function g(o,a,t,r){try{return t()}catch(e){return r.error(`[${o}] Failed to apply the "${a}" patch, that surface will be skipped: ${e}`),function(){}}}function h(o,a,t){const r=Object.entries(t).map(function([e,n]){return g(o,e,n,a)});return function(){return r.forEach(function(e){return e()})}}function b(){const o=i.findByName("RolePill",!1)??i.findByName("ThemedRolePill",!1);return o?p.after("default",o,function(a,t){try{if(!t?.props?.onPress)return;const r=s.findInReactTree(t,function(n){return n?.props?.roleColor}),e=s.findInReactTree(t,function(n){return n?.props?.style?.[0]?.borderRadius&&typeof n?.props?.style?.[1]?.backgroundColor=="string"&&n.props.style[1].backgroundColor.startsWith("#")})?.props?.style?.[1]?.backgroundColor??r?.props?.roleColor;if(!e)return;t.props.onLongPress=function(){d.clipboard.setString(e),y.showToast("Copied role color to clipboard",f.getAssetIDByName("ic_message_copy"))}}catch{}}):function(){}}let u=function(){};var m={onLoad:function(){u=h("CopyRoleColor",l.logger,{"role pill long-press":b})},onUnload:function(){return u()}};return c.default=m,Object.defineProperty(c,"__esModule",{value:!0}),c})({},vendetta,vendetta.metro,vendetta.patcher,vendetta.utils,vendetta.metro.common,vendetta.ui.assets,vendetta.ui.toasts);

View file

@ -1 +0,0 @@
{"name":"CopyRoleColor","description":"Long-press a role pill (member list, role list) to copy its color as hex.","authors":[{"name":"everestmcarthur"},{"name":"MSMA"}],"main":"index.js","hash":"34828c5d47ad8a234fd22550bb2ec927132c5f8e800a4ec2a4ef48a01af9ca56"}

View file

@ -1,736 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>revenge-plugins</title>
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f5f6fb;
--bg-elevated: #ffffff;
--bg-sunken: #eceef6;
--border: #e1e4ef;
--text: #14151f;
--text-muted: #62667a;
--accent: #5865f2;
--accent-text: #ffffff;
--chip-bg: #ececf7;
--chip-bg-active: #5865f2;
--chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec;
--badge-new-text: #1a7f4b;
--badge-revived-bg: #eef0ff;
--badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0;
--badge-rebuilt-text: #b5680a;
--danger: #f23f42;
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
code, .mono { font-family: var(--mono); }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
a { color: inherit; }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
padding: 64px 0 40px;
}
.hero-mesh {
position: absolute;
inset: -20%;
z-index: 0;
opacity: 0.5;
background:
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
filter: blur(40px);
animation: drift 22s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
from { transform: translate3d(0,0,0) scale(1); }
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
}
.hero-spotlight {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
transition: opacity 0.25s ease;
}
.hero:hover .hero-spotlight { opacity: 1; }
.hero-inner { position: relative; z-index: 1; }
.mark {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 18px;
}
.mark svg { width: 22px; height: 22px; display: block; }
h1 {
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 14px;
letter-spacing: -0.02em;
font-weight: 800;
}
h1 .grad {
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
.hero-links a {
font-size: 14px; font-weight: 600; text-decoration: none;
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
}
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.kbd-hint {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-muted); font-weight: 600;
}
.kbd-hint kbd {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
}
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
/* ---------- Controls ---------- */
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
.controls.stuck { border-bottom-color: var(--border); }
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
.search {
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); font-size: 15px;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
main { padding: 24px 0 64px; }
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
/* ---------- Grid / cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 20px 18px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
cursor: pointer;
overflow: hidden;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--card-accent, var(--accent));
}
.card:hover, .card:focus-visible {
transform: translateY(-3px);
border-color: var(--card-accent, var(--accent));
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
outline: none;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
/* ---------- Detail view ---------- */
.detail { display: none; }
.detail.active { display: block; }
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
.detail-banner {
position: relative;
overflow: hidden;
padding: 48px 0 36px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
var(--bg);
}
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
}
.back-link:hover { color: var(--text); }
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.detail-actions button, .detail-actions a {
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
}
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
.detail-section { margin-bottom: 32px; }
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cmd-row:last-child { border-bottom: none; }
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.side-card.warn h5 { color: var(--danger); }
.authors-line { font-size: 13px; color: var(--text-muted); }
/* ---------- Command palette ---------- */
.palette-overlay {
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
}
.palette-overlay.open { display: flex; }
.palette {
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
}
.palette input {
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.palette input:focus { outline: none; }
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
.palette-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
.palette-item .name { font-weight: 600; }
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<header class="hero" id="hero">
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
<div class="wrap hero-inner">
<div class="mark">
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
Revenge Plugins
</div>
<h1>Discord, <span class="grad">your way.</span></h1>
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
<div class="hero-links">
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
</div>
<div class="stats" id="stats" aria-hidden="true"></div>
</div>
</header>
<div class="wrap grid-view" id="gridView">
<div class="controls" id="controls">
<div class="search-row">
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
<span class="kbd-hint"><kbd>&#8984;</kbd><kbd>K</kbd></span>
</div>
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
</div>
<main>
<p class="count" id="count"></p>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>No plugins match your search.</div>
</main>
</div>
<div class="detail" id="detailView"></div>
<footer>
<div class="wrap">
<p>Built with <a href="https://github.com/everestmcarthur/revenge-plugins">revenge-plugins</a> · not affiliated with Discord or the Revenge project · install links point to this site</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="palette-overlay" id="paletteOverlay">
<div class="palette">
<input id="paletteInput" type="text" placeholder="Jump to a plugin…" aria-label="Jump to a plugin">
<div class="palette-results" id="paletteResults"></div>
</div>
</div>
<script>
(function () {
var STATUS_LABEL = { new: "New", revived: "Revived", rebuilt: "Rebuilt" };
var state = { plugins: [], query: "", category: "All" };
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var count = document.getElementById("count");
var chipsEl = document.getElementById("categoryChips");
var searchEl = document.getElementById("search");
var toastEl = document.getElementById("toast");
var statsEl = document.getElementById("stats");
var heroEl = document.getElementById("hero");
var controlsEl = document.getElementById("controls");
var gridViewEl = document.getElementById("gridView");
var detailViewEl = document.getElementById("detailView");
var paletteOverlay = document.getElementById("paletteOverlay");
var paletteInput = document.getElementById("paletteInput");
var paletteResults = document.getElementById("paletteResults");
var CHECK_SVG = '<svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { toastEl.classList.remove("show"); }, 1600);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
function installButton(url, extraStyle) {
var btn = document.createElement("button");
btn.className = "install";
btn.textContent = "Install";
if (extraStyle) btn.setAttribute("style", extraStyle);
btn.addEventListener("click", function (e) {
e.stopPropagation();
copyToClipboard(url).then(function () {
var original = btn.textContent;
btn.textContent = "Copied!";
showToast("Install link copied - paste it into Revenge's plugin browser");
setTimeout(function () { btn.textContent = original; }, 1400);
});
});
return btn;
}
function countUp(el, target) {
var start = 0;
var duration = 700;
var startTime = null;
function step(ts) {
if (!startTime) startTime = ts;
var progress = Math.min((ts - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function renderStats() {
var categories = Array.from(new Set(state.plugins.map(function (p) { return p.category; })));
var revivedCount = state.plugins.filter(function (p) { return p.status !== "new"; }).length;
var stats = [
{ value: state.plugins.length, label: "plugins" },
{ value: categories.length, label: "categories" },
{ value: revivedCount, label: "revived or rebuilt" }
];
statsEl.innerHTML = stats.map(function (s, i) {
return '<div><div class="stat-value" id="stat-' + i + '">0</div><div class="stat-label">' + s.label + '</div></div>';
}).join("");
stats.forEach(function (s, i) {
countUp(document.getElementById("stat-" + i), s.value);
});
}
/* ---------- Grid ---------- */
function render() {
var q = state.query.trim().toLowerCase();
var filtered = state.plugins.filter(function (p) {
var matchesCategory = state.category === "All" || p.category === state.category;
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
var matchesQuery = !q || haystack.indexOf(q) !== -1;
return matchesCategory && matchesQuery;
});
count.textContent = filtered.length + " plugin" + (filtered.length === 1 ? "" : "s");
grid.innerHTML = "";
empty.hidden = filtered.length !== 0;
filtered.forEach(function (p) {
var card = document.createElement("button");
card.type = "button";
card.className = "card";
card.style.setProperty("--card-accent", p.accent);
card.innerHTML =
'<div class="card-top">' +
'<h3>' + escapeHtml(p.name) + '</h3>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="category">' + escapeHtml(p.category) + '</div>' +
'<p class="tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
(p.features && p.features.length ? '<div class="feature-count">' + CHECK_SVG.replace("<svg ", '<svg style="width:13px;height:13px" ') + ' ' + p.features.length + ' feature' + (p.features.length === 1 ? "" : "s") + '</div>' : '') +
'<div class="cta">View details &rarr;</div>';
card.addEventListener("click", function () {
navigateToPlugin(p.id);
});
grid.appendChild(card);
});
}
function renderChips() {
var categories = ["All"].concat(
Array.from(new Set(state.plugins.map(function (p) { return p.category; }))).sort()
);
chipsEl.innerHTML = "";
categories.forEach(function (cat) {
var chip = document.createElement("button");
chip.className = "chip";
chip.textContent = cat;
chip.setAttribute("aria-pressed", String(cat === state.category));
chip.addEventListener("click", function () {
state.category = cat;
Array.from(chipsEl.children).forEach(function (c) {
c.setAttribute("aria-pressed", String(c === chip));
});
render();
});
chipsEl.appendChild(chip);
});
}
searchEl.addEventListener("input", function (e) {
state.query = e.target.value;
render();
});
window.addEventListener("scroll", function () {
controlsEl.classList.toggle("stuck", window.scrollY > 8);
});
var heroSpotlight = document.getElementById("heroSpotlight");
heroEl.addEventListener("mousemove", function (e) {
var rect = heroEl.getBoundingClientRect();
heroSpotlight.style.setProperty("--sx", (e.clientX - rect.left) + "px");
heroSpotlight.style.setProperty("--sy", (e.clientY - rect.top) + "px");
});
/* ---------- Detail view ---------- */
function renderDetail(p) {
var featuresHtml = (p.features || []).map(function (f) {
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
}).join("");
var commandsHtml = (p.commands && p.commands.length)
? '<div class="detail-section"><h4>Commands</h4><div class="cmd-table">' +
p.commands.map(function (c) {
return '<div class="cmd-row"><code>' + escapeHtml(c.cmd) + '</code><span>' + escapeHtml(c.desc) + '</span></div>';
}).join("") +
'</div></div>'
: "";
var howItWorksHtml = p.howItWorks
? '<div class="detail-section"><h4>How it works</h4><p>' + escapeHtml(p.howItWorks) + '</p></div>'
: "";
var featuresSection = featuresHtml
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
: "";
detailViewEl.innerHTML =
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div class="wrap">' +
'<button class="back-link" id="backLink">&larr; All plugins</button>' +
'<div class="detail-top">' +
'<h2>' + escapeHtml(p.name) + '</h2>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="detail-category">' + escapeHtml(p.category) + '</div>' +
'<p class="detail-tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
'<div class="detail-actions" id="detailActions"></div>' +
'</div>' +
'</div>' +
'<div class="wrap">' +
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
'<div>' +
(p.note ? '<div class="side-card"><h5>Backstory</h5><p>' + escapeHtml(p.note) + '</p></div>' : "") +
(p.limitations ? '<div class="side-card warn"><h5>Known limitations</h5><p>' + escapeHtml(p.limitations) + '</p></div>' : "") +
(p.authors && p.authors.length ? '<div class="side-card"><h5>Author</h5><p class="authors-line">' + escapeHtml(p.authors.join(", ")) + '</p></div>' : "") +
'</div>' +
'</div>' +
'</div>';
var actions = document.getElementById("detailActions");
actions.appendChild(installButton(p.installUrl));
var sourceLink = document.createElement("a");
sourceLink.href = p.sourceUrl;
sourceLink.target = "_blank";
sourceLink.rel = "noopener";
sourceLink.textContent = "Source";
actions.appendChild(sourceLink);
var issueLink = document.createElement("a");
issueLink.href = p.issueUrl;
issueLink.target = "_blank";
issueLink.rel = "noopener";
issueLink.textContent = "Report issue";
actions.appendChild(issueLink);
document.getElementById("backLink").addEventListener("click", function () {
history.pushState(null, "", "/");
showGrid();
});
}
function showDetail(id) {
var plugin = state.plugins.filter(function (p) { return p.id === id; })[0];
if (!plugin) { showGrid(); return; }
renderDetail(plugin);
heroEl.classList.add("hidden");
controlsEl.classList.add("hidden");
gridViewEl.classList.add("hidden");
detailViewEl.classList.add("active");
window.scrollTo(0, 0);
document.title = plugin.name + " · revenge-plugins";
}
function showGrid() {
heroEl.classList.remove("hidden");
controlsEl.classList.remove("hidden");
gridViewEl.classList.remove("hidden");
detailViewEl.classList.remove("active");
document.title = "revenge-plugins";
}
/**
* Each plugin has a real static page at /<id>/ (a copy of this same file), so a direct visit or
* refresh works without any server-side routing - this just avoids a full reload when navigating
* from within an already-loaded page.
*/
function navigateToPlugin(id) {
history.pushState(null, "", "/" + id + "/");
showDetail(id);
}
function routeFromLocation() {
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (match && match[1]) {
showDetail(match[1]);
} else {
showGrid();
}
}
window.addEventListener("popstate", routeFromLocation);
/* ---------- Command palette ---------- */
var paletteIndex = 0;
var paletteMatches = [];
function openPalette() {
paletteOverlay.classList.add("open");
paletteInput.value = "";
renderPalette("");
setTimeout(function () { paletteInput.focus(); }, 0);
}
function closePalette() {
paletteOverlay.classList.remove("open");
}
function renderPalette(q) {
q = q.trim().toLowerCase();
paletteMatches = state.plugins.filter(function (p) {
return !q || (p.name + " " + p.category).toLowerCase().indexOf(q) !== -1;
});
paletteIndex = 0;
if (!paletteMatches.length) {
paletteResults.innerHTML = '<div class="palette-empty">No matches</div>';
return;
}
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '" style="--item-accent:' + p.accent + '">' +
'<span class="dot"></span><span class="name">' + escapeHtml(p.name) + '</span><span class="cat">' + escapeHtml(p.category) + '</span>' +
'</div>';
}).join("");
Array.from(paletteResults.children).forEach(function (el, i) {
el.addEventListener("click", function () { selectPaletteItem(i); });
});
}
function updatePaletteSelection() {
Array.from(paletteResults.children).forEach(function (el, i) {
el.setAttribute("aria-selected", String(i === paletteIndex));
});
var active = paletteResults.children[paletteIndex];
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectPaletteItem(i) {
var plugin = paletteMatches[i];
if (!plugin) return;
closePalette();
navigateToPlugin(plugin.id);
}
paletteInput.addEventListener("input", function (e) { renderPalette(e.target.value); });
paletteOverlay.addEventListener("click", function (e) { if (e.target === paletteOverlay) closePalette(); });
paletteInput.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, paletteMatches.length - 1); updatePaletteSelection(); }
else if (e.key === "ArrowUp") { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); updatePaletteSelection(); }
else if (e.key === "Enter") { e.preventDefault(); selectPaletteItem(paletteIndex); }
else if (e.key === "Escape") { closePalette(); }
});
document.addEventListener("keydown", function (e) {
var isTyping = document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA");
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openPalette();
return;
}
if (e.key === "/" && !isTyping) {
e.preventDefault();
openPalette();
return;
}
if (e.key === "Escape" && paletteOverlay.classList.contains("open")) {
closePalette();
}
});
/* ---------- Boot ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json")
.then(function (r) { return r.json(); })
.then(function (data) {
state.plugins = data;
renderStats();
renderChips();
render();
routeFromLocation();
})
.catch(function () {
count.textContent = "";
empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing.";
});
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"name":"FakeProfileThemesAndEffects","description":"Profile theming and profile effects without Nitro, by hiding the color/effect data in your About Me using invisible zero-width characters.","authors":[{"name":"everestmcarthur"},{"name":"ryan"},{"name":"シグマ siguma"}],"main":"index.js","vendetta":{"icon":"ic_profile_24px"},"hash":"3710bf5a7c5a53fe220a27708c6c0fc65ea3297fd10589feb4e1ba2851d0067f"}

View file

@ -1,736 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>revenge-plugins</title>
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f5f6fb;
--bg-elevated: #ffffff;
--bg-sunken: #eceef6;
--border: #e1e4ef;
--text: #14151f;
--text-muted: #62667a;
--accent: #5865f2;
--accent-text: #ffffff;
--chip-bg: #ececf7;
--chip-bg-active: #5865f2;
--chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec;
--badge-new-text: #1a7f4b;
--badge-revived-bg: #eef0ff;
--badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0;
--badge-rebuilt-text: #b5680a;
--danger: #f23f42;
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
code, .mono { font-family: var(--mono); }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
a { color: inherit; }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
padding: 64px 0 40px;
}
.hero-mesh {
position: absolute;
inset: -20%;
z-index: 0;
opacity: 0.5;
background:
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
filter: blur(40px);
animation: drift 22s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
from { transform: translate3d(0,0,0) scale(1); }
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
}
.hero-spotlight {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
transition: opacity 0.25s ease;
}
.hero:hover .hero-spotlight { opacity: 1; }
.hero-inner { position: relative; z-index: 1; }
.mark {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 18px;
}
.mark svg { width: 22px; height: 22px; display: block; }
h1 {
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 14px;
letter-spacing: -0.02em;
font-weight: 800;
}
h1 .grad {
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
.hero-links a {
font-size: 14px; font-weight: 600; text-decoration: none;
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
}
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.kbd-hint {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-muted); font-weight: 600;
}
.kbd-hint kbd {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
}
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
/* ---------- Controls ---------- */
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
.controls.stuck { border-bottom-color: var(--border); }
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
.search {
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); font-size: 15px;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
main { padding: 24px 0 64px; }
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
/* ---------- Grid / cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 20px 18px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
cursor: pointer;
overflow: hidden;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--card-accent, var(--accent));
}
.card:hover, .card:focus-visible {
transform: translateY(-3px);
border-color: var(--card-accent, var(--accent));
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
outline: none;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
/* ---------- Detail view ---------- */
.detail { display: none; }
.detail.active { display: block; }
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
.detail-banner {
position: relative;
overflow: hidden;
padding: 48px 0 36px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
var(--bg);
}
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
}
.back-link:hover { color: var(--text); }
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.detail-actions button, .detail-actions a {
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
}
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
.detail-section { margin-bottom: 32px; }
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cmd-row:last-child { border-bottom: none; }
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.side-card.warn h5 { color: var(--danger); }
.authors-line { font-size: 13px; color: var(--text-muted); }
/* ---------- Command palette ---------- */
.palette-overlay {
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
}
.palette-overlay.open { display: flex; }
.palette {
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
}
.palette input {
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.palette input:focus { outline: none; }
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
.palette-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
.palette-item .name { font-weight: 600; }
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<header class="hero" id="hero">
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
<div class="wrap hero-inner">
<div class="mark">
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
Revenge Plugins
</div>
<h1>Discord, <span class="grad">your way.</span></h1>
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
<div class="hero-links">
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
</div>
<div class="stats" id="stats" aria-hidden="true"></div>
</div>
</header>
<div class="wrap grid-view" id="gridView">
<div class="controls" id="controls">
<div class="search-row">
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
<span class="kbd-hint"><kbd>&#8984;</kbd><kbd>K</kbd></span>
</div>
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
</div>
<main>
<p class="count" id="count"></p>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>No plugins match your search.</div>
</main>
</div>
<div class="detail" id="detailView"></div>
<footer>
<div class="wrap">
<p>Built with <a href="https://github.com/everestmcarthur/revenge-plugins">revenge-plugins</a> · not affiliated with Discord or the Revenge project · install links point to this site</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="palette-overlay" id="paletteOverlay">
<div class="palette">
<input id="paletteInput" type="text" placeholder="Jump to a plugin…" aria-label="Jump to a plugin">
<div class="palette-results" id="paletteResults"></div>
</div>
</div>
<script>
(function () {
var STATUS_LABEL = { new: "New", revived: "Revived", rebuilt: "Rebuilt" };
var state = { plugins: [], query: "", category: "All" };
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var count = document.getElementById("count");
var chipsEl = document.getElementById("categoryChips");
var searchEl = document.getElementById("search");
var toastEl = document.getElementById("toast");
var statsEl = document.getElementById("stats");
var heroEl = document.getElementById("hero");
var controlsEl = document.getElementById("controls");
var gridViewEl = document.getElementById("gridView");
var detailViewEl = document.getElementById("detailView");
var paletteOverlay = document.getElementById("paletteOverlay");
var paletteInput = document.getElementById("paletteInput");
var paletteResults = document.getElementById("paletteResults");
var CHECK_SVG = '<svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { toastEl.classList.remove("show"); }, 1600);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
function installButton(url, extraStyle) {
var btn = document.createElement("button");
btn.className = "install";
btn.textContent = "Install";
if (extraStyle) btn.setAttribute("style", extraStyle);
btn.addEventListener("click", function (e) {
e.stopPropagation();
copyToClipboard(url).then(function () {
var original = btn.textContent;
btn.textContent = "Copied!";
showToast("Install link copied - paste it into Revenge's plugin browser");
setTimeout(function () { btn.textContent = original; }, 1400);
});
});
return btn;
}
function countUp(el, target) {
var start = 0;
var duration = 700;
var startTime = null;
function step(ts) {
if (!startTime) startTime = ts;
var progress = Math.min((ts - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function renderStats() {
var categories = Array.from(new Set(state.plugins.map(function (p) { return p.category; })));
var revivedCount = state.plugins.filter(function (p) { return p.status !== "new"; }).length;
var stats = [
{ value: state.plugins.length, label: "plugins" },
{ value: categories.length, label: "categories" },
{ value: revivedCount, label: "revived or rebuilt" }
];
statsEl.innerHTML = stats.map(function (s, i) {
return '<div><div class="stat-value" id="stat-' + i + '">0</div><div class="stat-label">' + s.label + '</div></div>';
}).join("");
stats.forEach(function (s, i) {
countUp(document.getElementById("stat-" + i), s.value);
});
}
/* ---------- Grid ---------- */
function render() {
var q = state.query.trim().toLowerCase();
var filtered = state.plugins.filter(function (p) {
var matchesCategory = state.category === "All" || p.category === state.category;
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
var matchesQuery = !q || haystack.indexOf(q) !== -1;
return matchesCategory && matchesQuery;
});
count.textContent = filtered.length + " plugin" + (filtered.length === 1 ? "" : "s");
grid.innerHTML = "";
empty.hidden = filtered.length !== 0;
filtered.forEach(function (p) {
var card = document.createElement("button");
card.type = "button";
card.className = "card";
card.style.setProperty("--card-accent", p.accent);
card.innerHTML =
'<div class="card-top">' +
'<h3>' + escapeHtml(p.name) + '</h3>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="category">' + escapeHtml(p.category) + '</div>' +
'<p class="tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
(p.features && p.features.length ? '<div class="feature-count">' + CHECK_SVG.replace("<svg ", '<svg style="width:13px;height:13px" ') + ' ' + p.features.length + ' feature' + (p.features.length === 1 ? "" : "s") + '</div>' : '') +
'<div class="cta">View details &rarr;</div>';
card.addEventListener("click", function () {
navigateToPlugin(p.id);
});
grid.appendChild(card);
});
}
function renderChips() {
var categories = ["All"].concat(
Array.from(new Set(state.plugins.map(function (p) { return p.category; }))).sort()
);
chipsEl.innerHTML = "";
categories.forEach(function (cat) {
var chip = document.createElement("button");
chip.className = "chip";
chip.textContent = cat;
chip.setAttribute("aria-pressed", String(cat === state.category));
chip.addEventListener("click", function () {
state.category = cat;
Array.from(chipsEl.children).forEach(function (c) {
c.setAttribute("aria-pressed", String(c === chip));
});
render();
});
chipsEl.appendChild(chip);
});
}
searchEl.addEventListener("input", function (e) {
state.query = e.target.value;
render();
});
window.addEventListener("scroll", function () {
controlsEl.classList.toggle("stuck", window.scrollY > 8);
});
var heroSpotlight = document.getElementById("heroSpotlight");
heroEl.addEventListener("mousemove", function (e) {
var rect = heroEl.getBoundingClientRect();
heroSpotlight.style.setProperty("--sx", (e.clientX - rect.left) + "px");
heroSpotlight.style.setProperty("--sy", (e.clientY - rect.top) + "px");
});
/* ---------- Detail view ---------- */
function renderDetail(p) {
var featuresHtml = (p.features || []).map(function (f) {
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
}).join("");
var commandsHtml = (p.commands && p.commands.length)
? '<div class="detail-section"><h4>Commands</h4><div class="cmd-table">' +
p.commands.map(function (c) {
return '<div class="cmd-row"><code>' + escapeHtml(c.cmd) + '</code><span>' + escapeHtml(c.desc) + '</span></div>';
}).join("") +
'</div></div>'
: "";
var howItWorksHtml = p.howItWorks
? '<div class="detail-section"><h4>How it works</h4><p>' + escapeHtml(p.howItWorks) + '</p></div>'
: "";
var featuresSection = featuresHtml
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
: "";
detailViewEl.innerHTML =
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div class="wrap">' +
'<button class="back-link" id="backLink">&larr; All plugins</button>' +
'<div class="detail-top">' +
'<h2>' + escapeHtml(p.name) + '</h2>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="detail-category">' + escapeHtml(p.category) + '</div>' +
'<p class="detail-tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
'<div class="detail-actions" id="detailActions"></div>' +
'</div>' +
'</div>' +
'<div class="wrap">' +
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
'<div>' +
(p.note ? '<div class="side-card"><h5>Backstory</h5><p>' + escapeHtml(p.note) + '</p></div>' : "") +
(p.limitations ? '<div class="side-card warn"><h5>Known limitations</h5><p>' + escapeHtml(p.limitations) + '</p></div>' : "") +
(p.authors && p.authors.length ? '<div class="side-card"><h5>Author</h5><p class="authors-line">' + escapeHtml(p.authors.join(", ")) + '</p></div>' : "") +
'</div>' +
'</div>' +
'</div>';
var actions = document.getElementById("detailActions");
actions.appendChild(installButton(p.installUrl));
var sourceLink = document.createElement("a");
sourceLink.href = p.sourceUrl;
sourceLink.target = "_blank";
sourceLink.rel = "noopener";
sourceLink.textContent = "Source";
actions.appendChild(sourceLink);
var issueLink = document.createElement("a");
issueLink.href = p.issueUrl;
issueLink.target = "_blank";
issueLink.rel = "noopener";
issueLink.textContent = "Report issue";
actions.appendChild(issueLink);
document.getElementById("backLink").addEventListener("click", function () {
history.pushState(null, "", "/");
showGrid();
});
}
function showDetail(id) {
var plugin = state.plugins.filter(function (p) { return p.id === id; })[0];
if (!plugin) { showGrid(); return; }
renderDetail(plugin);
heroEl.classList.add("hidden");
controlsEl.classList.add("hidden");
gridViewEl.classList.add("hidden");
detailViewEl.classList.add("active");
window.scrollTo(0, 0);
document.title = plugin.name + " · revenge-plugins";
}
function showGrid() {
heroEl.classList.remove("hidden");
controlsEl.classList.remove("hidden");
gridViewEl.classList.remove("hidden");
detailViewEl.classList.remove("active");
document.title = "revenge-plugins";
}
/**
* Each plugin has a real static page at /<id>/ (a copy of this same file), so a direct visit or
* refresh works without any server-side routing - this just avoids a full reload when navigating
* from within an already-loaded page.
*/
function navigateToPlugin(id) {
history.pushState(null, "", "/" + id + "/");
showDetail(id);
}
function routeFromLocation() {
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (match && match[1]) {
showDetail(match[1]);
} else {
showGrid();
}
}
window.addEventListener("popstate", routeFromLocation);
/* ---------- Command palette ---------- */
var paletteIndex = 0;
var paletteMatches = [];
function openPalette() {
paletteOverlay.classList.add("open");
paletteInput.value = "";
renderPalette("");
setTimeout(function () { paletteInput.focus(); }, 0);
}
function closePalette() {
paletteOverlay.classList.remove("open");
}
function renderPalette(q) {
q = q.trim().toLowerCase();
paletteMatches = state.plugins.filter(function (p) {
return !q || (p.name + " " + p.category).toLowerCase().indexOf(q) !== -1;
});
paletteIndex = 0;
if (!paletteMatches.length) {
paletteResults.innerHTML = '<div class="palette-empty">No matches</div>';
return;
}
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '" style="--item-accent:' + p.accent + '">' +
'<span class="dot"></span><span class="name">' + escapeHtml(p.name) + '</span><span class="cat">' + escapeHtml(p.category) + '</span>' +
'</div>';
}).join("");
Array.from(paletteResults.children).forEach(function (el, i) {
el.addEventListener("click", function () { selectPaletteItem(i); });
});
}
function updatePaletteSelection() {
Array.from(paletteResults.children).forEach(function (el, i) {
el.setAttribute("aria-selected", String(i === paletteIndex));
});
var active = paletteResults.children[paletteIndex];
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectPaletteItem(i) {
var plugin = paletteMatches[i];
if (!plugin) return;
closePalette();
navigateToPlugin(plugin.id);
}
paletteInput.addEventListener("input", function (e) { renderPalette(e.target.value); });
paletteOverlay.addEventListener("click", function (e) { if (e.target === paletteOverlay) closePalette(); });
paletteInput.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, paletteMatches.length - 1); updatePaletteSelection(); }
else if (e.key === "ArrowUp") { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); updatePaletteSelection(); }
else if (e.key === "Enter") { e.preventDefault(); selectPaletteItem(paletteIndex); }
else if (e.key === "Escape") { closePalette(); }
});
document.addEventListener("keydown", function (e) {
var isTyping = document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA");
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openPalette();
return;
}
if (e.key === "/" && !isTyping) {
e.preventDefault();
openPalette();
return;
}
if (e.key === "Escape" && paletteOverlay.classList.contains("open")) {
closePalette();
}
});
/* ---------- Boot ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json")
.then(function (r) { return r.json(); })
.then(function (data) {
state.plugins = data;
renderStats();
renderChips();
render();
routeFromLocation();
})
.catch(function () {
count.textContent = "";
empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing.";
});
})();
</script>
</body>
</html>

View file

@ -1,736 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>revenge-plugins</title>
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f5f6fb;
--bg-elevated: #ffffff;
--bg-sunken: #eceef6;
--border: #e1e4ef;
--text: #14151f;
--text-muted: #62667a;
--accent: #5865f2;
--accent-text: #ffffff;
--chip-bg: #ececf7;
--chip-bg-active: #5865f2;
--chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec;
--badge-new-text: #1a7f4b;
--badge-revived-bg: #eef0ff;
--badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0;
--badge-rebuilt-text: #b5680a;
--danger: #f23f42;
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
code, .mono { font-family: var(--mono); }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
a { color: inherit; }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
padding: 64px 0 40px;
}
.hero-mesh {
position: absolute;
inset: -20%;
z-index: 0;
opacity: 0.5;
background:
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
filter: blur(40px);
animation: drift 22s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
from { transform: translate3d(0,0,0) scale(1); }
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
}
.hero-spotlight {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
transition: opacity 0.25s ease;
}
.hero:hover .hero-spotlight { opacity: 1; }
.hero-inner { position: relative; z-index: 1; }
.mark {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 18px;
}
.mark svg { width: 22px; height: 22px; display: block; }
h1 {
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 14px;
letter-spacing: -0.02em;
font-weight: 800;
}
h1 .grad {
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
.hero-links a {
font-size: 14px; font-weight: 600; text-decoration: none;
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
}
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.kbd-hint {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-muted); font-weight: 600;
}
.kbd-hint kbd {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
}
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
/* ---------- Controls ---------- */
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
.controls.stuck { border-bottom-color: var(--border); }
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
.search {
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); font-size: 15px;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
main { padding: 24px 0 64px; }
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
/* ---------- Grid / cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 20px 18px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
cursor: pointer;
overflow: hidden;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--card-accent, var(--accent));
}
.card:hover, .card:focus-visible {
transform: translateY(-3px);
border-color: var(--card-accent, var(--accent));
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
outline: none;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
/* ---------- Detail view ---------- */
.detail { display: none; }
.detail.active { display: block; }
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
.detail-banner {
position: relative;
overflow: hidden;
padding: 48px 0 36px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
var(--bg);
}
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
}
.back-link:hover { color: var(--text); }
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.detail-actions button, .detail-actions a {
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
}
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
.detail-section { margin-bottom: 32px; }
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cmd-row:last-child { border-bottom: none; }
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.side-card.warn h5 { color: var(--danger); }
.authors-line { font-size: 13px; color: var(--text-muted); }
/* ---------- Command palette ---------- */
.palette-overlay {
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
}
.palette-overlay.open { display: flex; }
.palette {
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
}
.palette input {
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.palette input:focus { outline: none; }
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
.palette-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
.palette-item .name { font-weight: 600; }
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<header class="hero" id="hero">
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
<div class="wrap hero-inner">
<div class="mark">
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
Revenge Plugins
</div>
<h1>Discord, <span class="grad">your way.</span></h1>
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
<div class="hero-links">
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
</div>
<div class="stats" id="stats" aria-hidden="true"></div>
</div>
</header>
<div class="wrap grid-view" id="gridView">
<div class="controls" id="controls">
<div class="search-row">
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
<span class="kbd-hint"><kbd>&#8984;</kbd><kbd>K</kbd></span>
</div>
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
</div>
<main>
<p class="count" id="count"></p>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>No plugins match your search.</div>
</main>
</div>
<div class="detail" id="detailView"></div>
<footer>
<div class="wrap">
<p>Built with <a href="https://github.com/everestmcarthur/revenge-plugins">revenge-plugins</a> · not affiliated with Discord or the Revenge project · install links point to this site</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="palette-overlay" id="paletteOverlay">
<div class="palette">
<input id="paletteInput" type="text" placeholder="Jump to a plugin…" aria-label="Jump to a plugin">
<div class="palette-results" id="paletteResults"></div>
</div>
</div>
<script>
(function () {
var STATUS_LABEL = { new: "New", revived: "Revived", rebuilt: "Rebuilt" };
var state = { plugins: [], query: "", category: "All" };
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var count = document.getElementById("count");
var chipsEl = document.getElementById("categoryChips");
var searchEl = document.getElementById("search");
var toastEl = document.getElementById("toast");
var statsEl = document.getElementById("stats");
var heroEl = document.getElementById("hero");
var controlsEl = document.getElementById("controls");
var gridViewEl = document.getElementById("gridView");
var detailViewEl = document.getElementById("detailView");
var paletteOverlay = document.getElementById("paletteOverlay");
var paletteInput = document.getElementById("paletteInput");
var paletteResults = document.getElementById("paletteResults");
var CHECK_SVG = '<svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { toastEl.classList.remove("show"); }, 1600);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
function installButton(url, extraStyle) {
var btn = document.createElement("button");
btn.className = "install";
btn.textContent = "Install";
if (extraStyle) btn.setAttribute("style", extraStyle);
btn.addEventListener("click", function (e) {
e.stopPropagation();
copyToClipboard(url).then(function () {
var original = btn.textContent;
btn.textContent = "Copied!";
showToast("Install link copied - paste it into Revenge's plugin browser");
setTimeout(function () { btn.textContent = original; }, 1400);
});
});
return btn;
}
function countUp(el, target) {
var start = 0;
var duration = 700;
var startTime = null;
function step(ts) {
if (!startTime) startTime = ts;
var progress = Math.min((ts - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function renderStats() {
var categories = Array.from(new Set(state.plugins.map(function (p) { return p.category; })));
var revivedCount = state.plugins.filter(function (p) { return p.status !== "new"; }).length;
var stats = [
{ value: state.plugins.length, label: "plugins" },
{ value: categories.length, label: "categories" },
{ value: revivedCount, label: "revived or rebuilt" }
];
statsEl.innerHTML = stats.map(function (s, i) {
return '<div><div class="stat-value" id="stat-' + i + '">0</div><div class="stat-label">' + s.label + '</div></div>';
}).join("");
stats.forEach(function (s, i) {
countUp(document.getElementById("stat-" + i), s.value);
});
}
/* ---------- Grid ---------- */
function render() {
var q = state.query.trim().toLowerCase();
var filtered = state.plugins.filter(function (p) {
var matchesCategory = state.category === "All" || p.category === state.category;
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
var matchesQuery = !q || haystack.indexOf(q) !== -1;
return matchesCategory && matchesQuery;
});
count.textContent = filtered.length + " plugin" + (filtered.length === 1 ? "" : "s");
grid.innerHTML = "";
empty.hidden = filtered.length !== 0;
filtered.forEach(function (p) {
var card = document.createElement("button");
card.type = "button";
card.className = "card";
card.style.setProperty("--card-accent", p.accent);
card.innerHTML =
'<div class="card-top">' +
'<h3>' + escapeHtml(p.name) + '</h3>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="category">' + escapeHtml(p.category) + '</div>' +
'<p class="tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
(p.features && p.features.length ? '<div class="feature-count">' + CHECK_SVG.replace("<svg ", '<svg style="width:13px;height:13px" ') + ' ' + p.features.length + ' feature' + (p.features.length === 1 ? "" : "s") + '</div>' : '') +
'<div class="cta">View details &rarr;</div>';
card.addEventListener("click", function () {
navigateToPlugin(p.id);
});
grid.appendChild(card);
});
}
function renderChips() {
var categories = ["All"].concat(
Array.from(new Set(state.plugins.map(function (p) { return p.category; }))).sort()
);
chipsEl.innerHTML = "";
categories.forEach(function (cat) {
var chip = document.createElement("button");
chip.className = "chip";
chip.textContent = cat;
chip.setAttribute("aria-pressed", String(cat === state.category));
chip.addEventListener("click", function () {
state.category = cat;
Array.from(chipsEl.children).forEach(function (c) {
c.setAttribute("aria-pressed", String(c === chip));
});
render();
});
chipsEl.appendChild(chip);
});
}
searchEl.addEventListener("input", function (e) {
state.query = e.target.value;
render();
});
window.addEventListener("scroll", function () {
controlsEl.classList.toggle("stuck", window.scrollY > 8);
});
var heroSpotlight = document.getElementById("heroSpotlight");
heroEl.addEventListener("mousemove", function (e) {
var rect = heroEl.getBoundingClientRect();
heroSpotlight.style.setProperty("--sx", (e.clientX - rect.left) + "px");
heroSpotlight.style.setProperty("--sy", (e.clientY - rect.top) + "px");
});
/* ---------- Detail view ---------- */
function renderDetail(p) {
var featuresHtml = (p.features || []).map(function (f) {
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
}).join("");
var commandsHtml = (p.commands && p.commands.length)
? '<div class="detail-section"><h4>Commands</h4><div class="cmd-table">' +
p.commands.map(function (c) {
return '<div class="cmd-row"><code>' + escapeHtml(c.cmd) + '</code><span>' + escapeHtml(c.desc) + '</span></div>';
}).join("") +
'</div></div>'
: "";
var howItWorksHtml = p.howItWorks
? '<div class="detail-section"><h4>How it works</h4><p>' + escapeHtml(p.howItWorks) + '</p></div>'
: "";
var featuresSection = featuresHtml
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
: "";
detailViewEl.innerHTML =
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div class="wrap">' +
'<button class="back-link" id="backLink">&larr; All plugins</button>' +
'<div class="detail-top">' +
'<h2>' + escapeHtml(p.name) + '</h2>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="detail-category">' + escapeHtml(p.category) + '</div>' +
'<p class="detail-tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
'<div class="detail-actions" id="detailActions"></div>' +
'</div>' +
'</div>' +
'<div class="wrap">' +
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
'<div>' +
(p.note ? '<div class="side-card"><h5>Backstory</h5><p>' + escapeHtml(p.note) + '</p></div>' : "") +
(p.limitations ? '<div class="side-card warn"><h5>Known limitations</h5><p>' + escapeHtml(p.limitations) + '</p></div>' : "") +
(p.authors && p.authors.length ? '<div class="side-card"><h5>Author</h5><p class="authors-line">' + escapeHtml(p.authors.join(", ")) + '</p></div>' : "") +
'</div>' +
'</div>' +
'</div>';
var actions = document.getElementById("detailActions");
actions.appendChild(installButton(p.installUrl));
var sourceLink = document.createElement("a");
sourceLink.href = p.sourceUrl;
sourceLink.target = "_blank";
sourceLink.rel = "noopener";
sourceLink.textContent = "Source";
actions.appendChild(sourceLink);
var issueLink = document.createElement("a");
issueLink.href = p.issueUrl;
issueLink.target = "_blank";
issueLink.rel = "noopener";
issueLink.textContent = "Report issue";
actions.appendChild(issueLink);
document.getElementById("backLink").addEventListener("click", function () {
history.pushState(null, "", "/");
showGrid();
});
}
function showDetail(id) {
var plugin = state.plugins.filter(function (p) { return p.id === id; })[0];
if (!plugin) { showGrid(); return; }
renderDetail(plugin);
heroEl.classList.add("hidden");
controlsEl.classList.add("hidden");
gridViewEl.classList.add("hidden");
detailViewEl.classList.add("active");
window.scrollTo(0, 0);
document.title = plugin.name + " · revenge-plugins";
}
function showGrid() {
heroEl.classList.remove("hidden");
controlsEl.classList.remove("hidden");
gridViewEl.classList.remove("hidden");
detailViewEl.classList.remove("active");
document.title = "revenge-plugins";
}
/**
* Each plugin has a real static page at /<id>/ (a copy of this same file), so a direct visit or
* refresh works without any server-side routing - this just avoids a full reload when navigating
* from within an already-loaded page.
*/
function navigateToPlugin(id) {
history.pushState(null, "", "/" + id + "/");
showDetail(id);
}
function routeFromLocation() {
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (match && match[1]) {
showDetail(match[1]);
} else {
showGrid();
}
}
window.addEventListener("popstate", routeFromLocation);
/* ---------- Command palette ---------- */
var paletteIndex = 0;
var paletteMatches = [];
function openPalette() {
paletteOverlay.classList.add("open");
paletteInput.value = "";
renderPalette("");
setTimeout(function () { paletteInput.focus(); }, 0);
}
function closePalette() {
paletteOverlay.classList.remove("open");
}
function renderPalette(q) {
q = q.trim().toLowerCase();
paletteMatches = state.plugins.filter(function (p) {
return !q || (p.name + " " + p.category).toLowerCase().indexOf(q) !== -1;
});
paletteIndex = 0;
if (!paletteMatches.length) {
paletteResults.innerHTML = '<div class="palette-empty">No matches</div>';
return;
}
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '" style="--item-accent:' + p.accent + '">' +
'<span class="dot"></span><span class="name">' + escapeHtml(p.name) + '</span><span class="cat">' + escapeHtml(p.category) + '</span>' +
'</div>';
}).join("");
Array.from(paletteResults.children).forEach(function (el, i) {
el.addEventListener("click", function () { selectPaletteItem(i); });
});
}
function updatePaletteSelection() {
Array.from(paletteResults.children).forEach(function (el, i) {
el.setAttribute("aria-selected", String(i === paletteIndex));
});
var active = paletteResults.children[paletteIndex];
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectPaletteItem(i) {
var plugin = paletteMatches[i];
if (!plugin) return;
closePalette();
navigateToPlugin(plugin.id);
}
paletteInput.addEventListener("input", function (e) { renderPalette(e.target.value); });
paletteOverlay.addEventListener("click", function (e) { if (e.target === paletteOverlay) closePalette(); });
paletteInput.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, paletteMatches.length - 1); updatePaletteSelection(); }
else if (e.key === "ArrowUp") { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); updatePaletteSelection(); }
else if (e.key === "Enter") { e.preventDefault(); selectPaletteItem(paletteIndex); }
else if (e.key === "Escape") { closePalette(); }
});
document.addEventListener("keydown", function (e) {
var isTyping = document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA");
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openPalette();
return;
}
if (e.key === "/" && !isTyping) {
e.preventDefault();
openPalette();
return;
}
if (e.key === "Escape" && paletteOverlay.classList.contains("open")) {
closePalette();
}
});
/* ---------- Boot ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json")
.then(function (r) { return r.json(); })
.then(function (data) {
state.plugins = data;
renderStats();
renderChips();
render();
routeFromLocation();
})
.catch(function () {
count.textContent = "";
empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing.";
});
})();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View file

@ -1 +0,0 @@
{"name":"Key Inspector","description":"Plugin-dev toolkit: on-device module search (incl. a cache-safe raw mode), a Flux event logger, a component render/props logger, an eval console, and full API/color-token dumps - for diagnosing and building plugins without needing a computer.","authors":[{"name":"everestmcarthur"}],"main":"index.js","hash":"55a7be951b7aa5f16d664bf02517120f95648b1eef4f792ac3f03c9b345fc30d"}

View file

@ -1,736 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>revenge-plugins</title>
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
<style>
:root {
--bg: #f5f6fb;
--bg-elevated: #ffffff;
--bg-sunken: #eceef6;
--border: #e1e4ef;
--text: #14151f;
--text-muted: #62667a;
--accent: #5865f2;
--accent-text: #ffffff;
--chip-bg: #ececf7;
--chip-bg-active: #5865f2;
--chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec;
--badge-new-text: #1a7f4b;
--badge-revived-bg: #eef0ff;
--badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0;
--badge-rebuilt-text: #b5680a;
--danger: #f23f42;
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
}
:root[data-theme="light"] {
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
-webkit-font-smoothing: antialiased;
}
code, .mono { font-family: var(--mono); }
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
a { color: inherit; }
/* ---------- Hero ---------- */
.hero {
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border);
padding: 64px 0 40px;
}
.hero-mesh {
position: absolute;
inset: -20%;
z-index: 0;
opacity: 0.5;
background:
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
filter: blur(40px);
animation: drift 22s ease-in-out infinite alternate;
pointer-events: none;
}
@keyframes drift {
from { transform: translate3d(0,0,0) scale(1); }
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
}
.hero-spotlight {
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
transition: opacity 0.25s ease;
}
.hero:hover .hero-spotlight { opacity: 1; }
.hero-inner { position: relative; z-index: 1; }
.mark {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 13px;
font-weight: 700;
color: var(--accent);
letter-spacing: 0.06em;
text-transform: uppercase;
margin-bottom: 18px;
}
.mark svg { width: 22px; height: 22px; display: block; }
h1 {
font-size: clamp(32px, 5vw, 46px);
line-height: 1.08;
margin: 0 0 14px;
letter-spacing: -0.02em;
font-weight: 800;
}
h1 .grad {
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
.hero-links a {
font-size: 14px; font-weight: 600; text-decoration: none;
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
}
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
.kbd-hint {
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; color: var(--text-muted); font-weight: 600;
}
.kbd-hint kbd {
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
}
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
/* ---------- Controls ---------- */
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
.controls.stuck { border-bottom-color: var(--border); }
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
.search {
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); font-size: 15px;
}
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
main { padding: 24px 0 64px; }
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
/* ---------- Grid / cards ---------- */
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
.card {
position: relative;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 20px 18px;
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
cursor: pointer;
overflow: hidden;
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
text-align: left;
font: inherit;
color: inherit;
width: 100%;
}
.card::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--card-accent, var(--accent));
}
.card:hover, .card:focus-visible {
transform: translateY(-3px);
border-color: var(--card-accent, var(--accent));
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
outline: none;
}
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
/* ---------- Detail view ---------- */
.detail { display: none; }
.detail.active { display: block; }
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
.detail-banner {
position: relative;
overflow: hidden;
padding: 48px 0 36px;
border-bottom: 1px solid var(--border);
background:
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
var(--bg);
}
.back-link {
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
}
.back-link:hover { color: var(--text); }
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
.detail-actions button, .detail-actions a {
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
}
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
.detail-section { margin-bottom: 32px; }
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
.cmd-row:last-child { border-bottom: none; }
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
.side-card.warn h5 { color: var(--danger); }
.authors-line { font-size: 13px; color: var(--text-muted); }
/* ---------- Command palette ---------- */
.palette-overlay {
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
}
.palette-overlay.open { display: flex; }
.palette {
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
}
.palette input {
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
padding: 18px 20px; border-bottom: 1px solid var(--border);
}
.palette input:focus { outline: none; }
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
.palette-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
}
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
.palette-item .name { font-weight: 600; }
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
.toast {
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
</style>
</head>
<body>
<header class="hero" id="hero">
<div class="hero-mesh" aria-hidden="true"></div>
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
<div class="wrap hero-inner">
<div class="mark">
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
Revenge Plugins
</div>
<h1>Discord, <span class="grad">your way.</span></h1>
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
<div class="hero-links">
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
</div>
<div class="stats" id="stats" aria-hidden="true"></div>
</div>
</header>
<div class="wrap grid-view" id="gridView">
<div class="controls" id="controls">
<div class="search-row">
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
<span class="kbd-hint"><kbd>&#8984;</kbd><kbd>K</kbd></span>
</div>
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
</div>
<main>
<p class="count" id="count"></p>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>No plugins match your search.</div>
</main>
</div>
<div class="detail" id="detailView"></div>
<footer>
<div class="wrap">
<p>Built with <a href="https://github.com/everestmcarthur/revenge-plugins">revenge-plugins</a> · not affiliated with Discord or the Revenge project · install links point to this site</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<div class="palette-overlay" id="paletteOverlay">
<div class="palette">
<input id="paletteInput" type="text" placeholder="Jump to a plugin…" aria-label="Jump to a plugin">
<div class="palette-results" id="paletteResults"></div>
</div>
</div>
<script>
(function () {
var STATUS_LABEL = { new: "New", revived: "Revived", rebuilt: "Rebuilt" };
var state = { plugins: [], query: "", category: "All" };
var grid = document.getElementById("grid");
var empty = document.getElementById("empty");
var count = document.getElementById("count");
var chipsEl = document.getElementById("categoryChips");
var searchEl = document.getElementById("search");
var toastEl = document.getElementById("toast");
var statsEl = document.getElementById("stats");
var heroEl = document.getElementById("hero");
var controlsEl = document.getElementById("controls");
var gridViewEl = document.getElementById("gridView");
var detailViewEl = document.getElementById("detailView");
var paletteOverlay = document.getElementById("paletteOverlay");
var paletteInput = document.getElementById("paletteInput");
var paletteResults = document.getElementById("paletteResults");
var CHECK_SVG = '<svg viewBox="0 0 20 20" fill="none"><path d="M4 10.5l4 4 8-9" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { toastEl.classList.remove("show"); }, 1600);
}
function copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
var ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
return Promise.resolve();
}
function installButton(url, extraStyle) {
var btn = document.createElement("button");
btn.className = "install";
btn.textContent = "Install";
if (extraStyle) btn.setAttribute("style", extraStyle);
btn.addEventListener("click", function (e) {
e.stopPropagation();
copyToClipboard(url).then(function () {
var original = btn.textContent;
btn.textContent = "Copied!";
showToast("Install link copied - paste it into Revenge's plugin browser");
setTimeout(function () { btn.textContent = original; }, 1400);
});
});
return btn;
}
function countUp(el, target) {
var start = 0;
var duration = 700;
var startTime = null;
function step(ts) {
if (!startTime) startTime = ts;
var progress = Math.min((ts - startTime) / duration, 1);
var eased = 1 - Math.pow(1 - progress, 3);
el.textContent = Math.round(start + (target - start) * eased);
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
function renderStats() {
var categories = Array.from(new Set(state.plugins.map(function (p) { return p.category; })));
var revivedCount = state.plugins.filter(function (p) { return p.status !== "new"; }).length;
var stats = [
{ value: state.plugins.length, label: "plugins" },
{ value: categories.length, label: "categories" },
{ value: revivedCount, label: "revived or rebuilt" }
];
statsEl.innerHTML = stats.map(function (s, i) {
return '<div><div class="stat-value" id="stat-' + i + '">0</div><div class="stat-label">' + s.label + '</div></div>';
}).join("");
stats.forEach(function (s, i) {
countUp(document.getElementById("stat-" + i), s.value);
});
}
/* ---------- Grid ---------- */
function render() {
var q = state.query.trim().toLowerCase();
var filtered = state.plugins.filter(function (p) {
var matchesCategory = state.category === "All" || p.category === state.category;
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
var matchesQuery = !q || haystack.indexOf(q) !== -1;
return matchesCategory && matchesQuery;
});
count.textContent = filtered.length + " plugin" + (filtered.length === 1 ? "" : "s");
grid.innerHTML = "";
empty.hidden = filtered.length !== 0;
filtered.forEach(function (p) {
var card = document.createElement("button");
card.type = "button";
card.className = "card";
card.style.setProperty("--card-accent", p.accent);
card.innerHTML =
'<div class="card-top">' +
'<h3>' + escapeHtml(p.name) + '</h3>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="category">' + escapeHtml(p.category) + '</div>' +
'<p class="tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
(p.features && p.features.length ? '<div class="feature-count">' + CHECK_SVG.replace("<svg ", '<svg style="width:13px;height:13px" ') + ' ' + p.features.length + ' feature' + (p.features.length === 1 ? "" : "s") + '</div>' : '') +
'<div class="cta">View details &rarr;</div>';
card.addEventListener("click", function () {
navigateToPlugin(p.id);
});
grid.appendChild(card);
});
}
function renderChips() {
var categories = ["All"].concat(
Array.from(new Set(state.plugins.map(function (p) { return p.category; }))).sort()
);
chipsEl.innerHTML = "";
categories.forEach(function (cat) {
var chip = document.createElement("button");
chip.className = "chip";
chip.textContent = cat;
chip.setAttribute("aria-pressed", String(cat === state.category));
chip.addEventListener("click", function () {
state.category = cat;
Array.from(chipsEl.children).forEach(function (c) {
c.setAttribute("aria-pressed", String(c === chip));
});
render();
});
chipsEl.appendChild(chip);
});
}
searchEl.addEventListener("input", function (e) {
state.query = e.target.value;
render();
});
window.addEventListener("scroll", function () {
controlsEl.classList.toggle("stuck", window.scrollY > 8);
});
var heroSpotlight = document.getElementById("heroSpotlight");
heroEl.addEventListener("mousemove", function (e) {
var rect = heroEl.getBoundingClientRect();
heroSpotlight.style.setProperty("--sx", (e.clientX - rect.left) + "px");
heroSpotlight.style.setProperty("--sy", (e.clientY - rect.top) + "px");
});
/* ---------- Detail view ---------- */
function renderDetail(p) {
var featuresHtml = (p.features || []).map(function (f) {
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
}).join("");
var commandsHtml = (p.commands && p.commands.length)
? '<div class="detail-section"><h4>Commands</h4><div class="cmd-table">' +
p.commands.map(function (c) {
return '<div class="cmd-row"><code>' + escapeHtml(c.cmd) + '</code><span>' + escapeHtml(c.desc) + '</span></div>';
}).join("") +
'</div></div>'
: "";
var howItWorksHtml = p.howItWorks
? '<div class="detail-section"><h4>How it works</h4><p>' + escapeHtml(p.howItWorks) + '</p></div>'
: "";
var featuresSection = featuresHtml
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
: "";
detailViewEl.innerHTML =
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div class="wrap">' +
'<button class="back-link" id="backLink">&larr; All plugins</button>' +
'<div class="detail-top">' +
'<h2>' + escapeHtml(p.name) + '</h2>' +
'<span class="badge ' + p.status + '">' + (STATUS_LABEL[p.status] || p.status) + '</span>' +
'</div>' +
'<div class="detail-category">' + escapeHtml(p.category) + '</div>' +
'<p class="detail-tagline">' + escapeHtml(p.tagline || p.description) + '</p>' +
'<div class="detail-actions" id="detailActions"></div>' +
'</div>' +
'</div>' +
'<div class="wrap">' +
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
'<div>' +
(p.note ? '<div class="side-card"><h5>Backstory</h5><p>' + escapeHtml(p.note) + '</p></div>' : "") +
(p.limitations ? '<div class="side-card warn"><h5>Known limitations</h5><p>' + escapeHtml(p.limitations) + '</p></div>' : "") +
(p.authors && p.authors.length ? '<div class="side-card"><h5>Author</h5><p class="authors-line">' + escapeHtml(p.authors.join(", ")) + '</p></div>' : "") +
'</div>' +
'</div>' +
'</div>';
var actions = document.getElementById("detailActions");
actions.appendChild(installButton(p.installUrl));
var sourceLink = document.createElement("a");
sourceLink.href = p.sourceUrl;
sourceLink.target = "_blank";
sourceLink.rel = "noopener";
sourceLink.textContent = "Source";
actions.appendChild(sourceLink);
var issueLink = document.createElement("a");
issueLink.href = p.issueUrl;
issueLink.target = "_blank";
issueLink.rel = "noopener";
issueLink.textContent = "Report issue";
actions.appendChild(issueLink);
document.getElementById("backLink").addEventListener("click", function () {
history.pushState(null, "", "/");
showGrid();
});
}
function showDetail(id) {
var plugin = state.plugins.filter(function (p) { return p.id === id; })[0];
if (!plugin) { showGrid(); return; }
renderDetail(plugin);
heroEl.classList.add("hidden");
controlsEl.classList.add("hidden");
gridViewEl.classList.add("hidden");
detailViewEl.classList.add("active");
window.scrollTo(0, 0);
document.title = plugin.name + " · revenge-plugins";
}
function showGrid() {
heroEl.classList.remove("hidden");
controlsEl.classList.remove("hidden");
gridViewEl.classList.remove("hidden");
detailViewEl.classList.remove("active");
document.title = "revenge-plugins";
}
/**
* Each plugin has a real static page at /<id>/ (a copy of this same file), so a direct visit or
* refresh works without any server-side routing - this just avoids a full reload when navigating
* from within an already-loaded page.
*/
function navigateToPlugin(id) {
history.pushState(null, "", "/" + id + "/");
showDetail(id);
}
function routeFromLocation() {
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
if (match && match[1]) {
showDetail(match[1]);
} else {
showGrid();
}
}
window.addEventListener("popstate", routeFromLocation);
/* ---------- Command palette ---------- */
var paletteIndex = 0;
var paletteMatches = [];
function openPalette() {
paletteOverlay.classList.add("open");
paletteInput.value = "";
renderPalette("");
setTimeout(function () { paletteInput.focus(); }, 0);
}
function closePalette() {
paletteOverlay.classList.remove("open");
}
function renderPalette(q) {
q = q.trim().toLowerCase();
paletteMatches = state.plugins.filter(function (p) {
return !q || (p.name + " " + p.category).toLowerCase().indexOf(q) !== -1;
});
paletteIndex = 0;
if (!paletteMatches.length) {
paletteResults.innerHTML = '<div class="palette-empty">No matches</div>';
return;
}
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '" style="--item-accent:' + p.accent + '">' +
'<span class="dot"></span><span class="name">' + escapeHtml(p.name) + '</span><span class="cat">' + escapeHtml(p.category) + '</span>' +
'</div>';
}).join("");
Array.from(paletteResults.children).forEach(function (el, i) {
el.addEventListener("click", function () { selectPaletteItem(i); });
});
}
function updatePaletteSelection() {
Array.from(paletteResults.children).forEach(function (el, i) {
el.setAttribute("aria-selected", String(i === paletteIndex));
});
var active = paletteResults.children[paletteIndex];
if (active) active.scrollIntoView({ block: "nearest" });
}
function selectPaletteItem(i) {
var plugin = paletteMatches[i];
if (!plugin) return;
closePalette();
navigateToPlugin(plugin.id);
}
paletteInput.addEventListener("input", function (e) { renderPalette(e.target.value); });
paletteOverlay.addEventListener("click", function (e) { if (e.target === paletteOverlay) closePalette(); });
paletteInput.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); paletteIndex = Math.min(paletteIndex + 1, paletteMatches.length - 1); updatePaletteSelection(); }
else if (e.key === "ArrowUp") { e.preventDefault(); paletteIndex = Math.max(paletteIndex - 1, 0); updatePaletteSelection(); }
else if (e.key === "Enter") { e.preventDefault(); selectPaletteItem(paletteIndex); }
else if (e.key === "Escape") { closePalette(); }
});
document.addEventListener("keydown", function (e) {
var isTyping = document.activeElement && (document.activeElement.tagName === "INPUT" || document.activeElement.tagName === "TEXTAREA");
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
openPalette();
return;
}
if (e.key === "/" && !isTyping) {
e.preventDefault();
openPalette();
return;
}
if (e.key === "Escape" && paletteOverlay.classList.contains("open")) {
closePalette();
}
});
/* ---------- Boot ---------- */
fetch("https://rp.jarviscli.dev/plugins-data.json")
.then(function (r) { return r.json(); })
.then(function (data) {
state.plugins = data;
renderStats();
renderChips();
render();
routeFromLocation();
})
.catch(function () {
count.textContent = "";
empty.hidden = false;
empty.textContent = "Couldn't load the plugin list - try refreshing.";
});
})();
</script>
</body>
</html>

View file

@ -1 +0,0 @@
(function(f,p,S,o,v,e,g,y){"use strict";function d(){return v.storage.snippets??={},v.storage.snippets}function h(t,i){d()[t.trim()]=i}function m(t){delete d()[t]}const u=3,R=S.findByProps("sendMessage","sendBotMessage");function x(){const t=p.registerCommand({name:"snippet",description:"Send a saved text snippet",options:[{name:"name",description:"Snippet name",type:u,required:!0}],execute:function(r,s){const l=r.find(function(H){return H.name==="name"})?.value,c=l?d()[l]:void 0;if(!c){o.showToast(`No snippet named "${l}"`,void 0);return}if(!R?.sendMessage){o.showToast("Couldn't send the snippet - message action not found",void 0);return}R.sendMessage(s.channel.id,{content:c})}}),i=p.registerCommand({name:"snippet-save",description:"Save a text snippet",options:[{name:"name",description:"Snippet name",type:u,required:!0},{name:"text",description:"Snippet content",type:u,required:!0}],execute:function(r){const s=r.find(function(c){return c.name==="name"})?.value,l=r.find(function(c){return c.name==="text"})?.value;!s||!l||(h(s,l),o.showToast(`Saved snippet "${s}"`,void 0))}}),n=p.registerCommand({name:"snippet-delete",description:"Delete a saved text snippet",options:[{name:"name",description:"Snippet name",type:u,required:!0}],execute:function(r){const s=r.find(function(l){return l.name==="name"})?.value;s&&(m(s),o.showToast(`Deleted snippet "${s}"`,void 0))}}),a=p.registerCommand({name:"snippet-list",description:"List your saved snippet names",options:[],execute:function(){const r=Object.keys(d());o.showToast(r.length?r.join(", "):"No snippets saved yet",void 0)}});return[t,i,n,a]}const{ScrollView:T,View:w}=e.ReactNative;function $({children:t}){return e.React.createElement(T,{style:{flex:1}},t,e.React.createElement(w,{style:{height:24}}))}const{View:P,Text:C}=e.ReactNative;function F({children:t,style:i}){return e.React.createElement(P,{style:[{marginHorizontal:16,marginVertical:8,padding:10,borderRadius:8,borderWidth:1,borderColor:"rgba(128,128,128,0.25)"},i]},e.React.createElement(C,{style:{fontSize:12.5,lineHeight:18,opacity:.75}},t))}const{Text:N}=e.ReactNative,{FormSection:k,FormRow:L}=y.Forms;function M({title:t,items:i,emptyText:n}){return e.React.createElement(k,{title:t},i.length===0?e.React.createElement(F,null,n):i.map(function(a){return e.React.createElement(L,{key:a.key,label:a.label,subLabel:a.subLabel,onPress:a.onPress,trailing:e.React.createElement(N,{style:{color:"#F23F42",fontSize:20,fontWeight:"700"}},"\xD7")})}))}const{TouchableOpacity:V,Text:j}=e.ReactNative;function q({label:t,onPress:i,disabled:n,style:a}){return e.React.createElement(V,{disabled:n,onPress:i,style:[{backgroundColor:"#5865F2",borderRadius:8,padding:10,alignItems:"center",opacity:n?.5:1},a]},e.React.createElement(j,{style:{color:"white",fontWeight:"600"}},t))}const{View:z}=e.ReactNative,{FormSection:D,FormInput:b}=y.Forms;function O(){const[t,i]=e.React.useState(""),[n,a]=e.React.useState(""),r=!!t.trim()&&!!n.trim();return e.React.createElement(z,{style:{paddingHorizontal:16,paddingBottom:12}},e.React.createElement(b,{title:"Name",placeholder:"e.g. rules",value:t,onChange:i}),e.React.createElement(b,{title:"Text",placeholder:"What /snippet should send",value:n,onChange:a,multiline:!0}),e.React.createElement(q,{label:"Save snippet",disabled:!r,style:{marginTop:8},onPress:function(){h(t.trim(),n),o.showToast(`Saved snippet "${t.trim()}"`,void 0),i(""),a("")}}))}function W(){g.useProxy(v.storage);const t=d();g.useProxy(t);const i=Object.keys(t);return e.React.createElement($,null,e.React.createElement(D,{title:"Add a snippet"},e.React.createElement(O,null)),e.React.createElement(M,{title:"Saved snippets",emptyText:"None yet. Add one above, then send it anywhere with /snippet name.",items:i.map(function(n){return{key:n,label:n,subLabel:`${t[n].slice(0,60)}${t[n].length>60?"\u2026":""} \u2022 Tap to delete`,onPress:function(){m(n),o.showToast(`Deleted snippet "${n}"`,void 0)}}})}))}let E=[];var B={onLoad:function(){E=x()},onUnload:function(){return E.forEach(function(t){return t()})},settings:W};return f.default=B,Object.defineProperty(f,"__esModule",{value:!0}),f})({},vendetta.commands,vendetta.metro,vendetta.ui.toasts,vendetta.plugin,vendetta.metro.common,vendetta.storage,vendetta.ui.components);

View file

@ -1 +0,0 @@
{"name":"Message Snippets","description":"Save reusable text and send it with /snippet <name>. Manage snippets from the plugin settings, or with /snippet-save, /snippet-delete, /snippet-list.","authors":[{"name":"everestmcarthur"}],"main":"index.js","hash":"dd26ccf27afd50aa4f8859528640793b37fa4bf1e5f443ffd58f687bb260cf25"}

4467
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

22
package.json Normal file
View file

@ -0,0 +1,22 @@
{
"name": "revenge-plugins",
"private": true,
"scripts": {
"build": "node build.mjs && node scripts/generate-site.mjs",
"serve": "node serve.mjs"
},
"dependencies": {
"@swc/helpers": "^0.5.17"
},
"devDependencies": {
"@rollup/plugin-alias": "^4.0.4",
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-url": "latest",
"@swc/core": "^1.3.35",
"esbuild": "^0.16.14",
"rollup": "^3.9.1",
"rollup-plugin-esbuild": "^5.0.0",
"vendetta-types": "latest"
}
}

View file

@ -0,0 +1,12 @@
{
"name": "Antied Zero",
"description": "Lightweight, zero-config message logger that keeps deleted messages and edit history visible.",
"authors": [
{ "name": "Angelw0lf" },
{ "name": "Rosie" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_edit_24px"
}
}

View file

@ -0,0 +1,45 @@
import { id } from "@vendetta/plugin";
import { showToast } from "@vendetta/ui/toasts";
import { stopPlugin } from "@vendetta/plugins";
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
import { patchFluxDispatch } from "./patches/fluxDispatch";
import { patchActionSheet } from "./patches/actionSheet";
import { patchSelfEdit } from "./patches/selfEdit";
import Settings from "./ui/Settings";
const isEnabledRef = { current: false };
const deletedMessageMap = new Map<string, any>();
const cleanups: (() => void)[] = [];
export default {
onLoad: async () => {
if (enforceBlacklistOnLoad(id)) return;
isEnabledRef.current = true;
try {
cleanups.push(patchFluxDispatch(deletedMessageMap, isEnabledRef));
cleanups.push(patchActionSheet(isEnabledRef));
cleanups.push(patchSelfEdit(isEnabledRef));
} catch (err) {
console.error("[ANTIED ZERO] Crash on load:", err);
showToast("[ANTIED ZERO] Failed to load. Check debug log.");
stopPlugin(id);
}
},
onUnload: () => {
isEnabledRef.current = false;
for (const fn of cleanups) {
try {
fn();
} catch (_) {}
}
cleanups.length = 0;
deletedMessageMap.clear();
},
settings: Settings,
};

View file

@ -0,0 +1,87 @@
import { before, after } from "@vendetta/patcher";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { findInReactTree } from "@vendetta/utils";
import { React, FluxDispatcher } from "@vendetta/metro/common";
import { showToast } from "@vendetta/ui/toasts";
import { findByProps } from "@vendetta/metro";
const ActionSheet = findByProps("openLazy", "hideActionSheet");
const MessageStore = findByProps("getMessage", "getMessages");
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
const ChannelMessages = findByProps("_channelMessages");
const { ActionSheetRow } = findByProps("ActionSheetRow") || {};
export const regexEscaper = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export function patchActionSheet(isEnabledRef: { current: boolean }): () => void {
if (!ActionSheet?.openLazy || !ActionSheetRow) return () => {};
return before("openLazy", ActionSheet, ([component, args, actionMessage]: any[]) => {
if (!isEnabledRef.current) return;
try {
const message = actionMessage?.message;
if (args !== "MessageLongPressActionSheet" || !message) return;
component.then((instance: any) => {
const unpatch = after("default", instance, (_: any, comp: any) => {
try {
React.useEffect(() => () => { unpatch(); }, []);
const isReplyBtn = (a: any) => a?.props?.label?.toLowerCase?.() === "reply";
const buttons = findInReactTree(comp, (c: any) => c?.find?.(isReplyBtn));
if (!buttons) return comp;
const position = Math.max(buttons.findIndex(isReplyBtn), buttons.length - 1);
let originalMessage = null;
if (message?.channel_id && message?.id) {
originalMessage = MessageStore?.getMessage?.(message.channel_id, message.id) ||
ChannelMessages?.get?.(message.channel_id)?.get?.(message.id);
}
if (!originalMessage) return comp;
const editedTag = "`[ EDITED ]`";
const separator = new RegExp(regexEscaper(editedTag), "gmi");
if (separator.test(message.content)) {
buttons.splice(
position || 1,
0,
React.createElement(ActionSheetRow, {
label: "Remove Edit History",
subLabel: "Antied Zero",
icon: React.createElement(ActionSheetRow.Icon, {
source: getAssetIDByName("ic_edit_24px") || getAssetIDByName("ic_message_edit"),
}),
onPress: () => {
const regexPattern = new RegExp(`(?:\\s${regexEscaper(editedTag)}\\n{2})`, "gm");
const lats = message?.content?.split(regexPattern);
const targetMessage = lats?.[lats.length - 1] ?? message.content;
FluxDispatcher.dispatch({
type: "MESSAGE_UPDATE",
message: {
...message,
message_reference: message?.message_reference || message?.messageReference || null,
content: `${targetMessage}`,
guild_id: ChannelStore?.getChannel?.(originalMessage.channel_id)?.guild_id,
},
otherPluginBypass: true,
});
ActionSheet.hideActionSheet();
showToast("Edit history removed", getAssetIDByName("ic_edit_24px"));
},
})
);
}
} catch (e) {
console.error("[ANTIED ZERO] ActionSheet component error:", e);
}
});
});
} catch (e) {
console.error("[ANTIED ZERO] ActionSheet patch error:", e);
}
});
}

View file

@ -0,0 +1,106 @@
import { before } from "@vendetta/patcher";
import { findByProps } from "@vendetta/metro";
import { FluxDispatcher } from "@vendetta/metro/common";
import { recordToGateway } from "@shared/lib/recordToGateway";
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
const ChannelMessages = findByProps("_channelMessages");
const MessageStore = findByProps("getMessage", "getMessages");
export function patchFluxDispatch(deletedMessageMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
return before("dispatch", FluxDispatcher, (args: any[]) => {
if (!isEnabledRef.current) return;
try {
const ev = args[0];
if (!ev || !ev.type) return;
/* =========================================================
MESSAGE_DELETE
==========================================================*/
if (ev.type === "MESSAGE_DELETE") {
if (ev.otherPluginBypass || ev.manualDelete) return;
const orig = ChannelMessages?.get?.(ev.channelId)?.get?.(ev.id) || MessageStore?.getMessage?.(ev.channelId, ev.id) || deletedMessageMap.get(ev.id)?.original;
if (!orig?.author?.id || !orig.author.username) return;
// Ephemeral dismiss from bots check
if (orig.author.bot && (orig.flags === 64 || (orig.flags & 64) === 64)) return;
if (orig.author.bot) return;
// Empty check
if (!orig.content && !orig.attachments?.length && !orig.embeds?.length) return;
if (deletedMessageMap.has(ev.id)) {
ev.type = "MESSAGE_UPDATE";
ev.channelId = orig.channel_id || ev.channelId;
ev.message = {
id: ev.id,
channel_id: orig.channel_id || ev.channelId,
flags: 64,
was_deleted: true,
};
return args;
}
const guildId = ChannelStore?.getChannel?.(orig.channel_id || ev.channelId)?.guild_id;
const gatewayOrig = recordToGateway(orig);
ev.message = {
...gatewayOrig,
content: orig.content,
channel_id: orig.channel_id || ev.channelId,
guild_id: guildId,
flags: 64,
was_deleted: true,
message_reference: orig?.message_reference || orig?.messageReference || null,
};
ev.type = "MESSAGE_UPDATE";
ev.channelId = orig.channel_id || ev.channelId;
ev.optimistic = false;
ev.sendMessageOptions = {};
ev.isPushNotification = false;
deletedMessageMap.set(ev.id, { message: args, original: orig });
return args;
}
/* =========================================================
MESSAGE_UPDATE
==========================================================*/
if (ev.type === "MESSAGE_UPDATE") {
if (ev.otherPluginBypass) return;
const msg = ev.message;
if (!msg || msg.author?.bot) return;
// Only handle real content edits with a valid edited_timestamp
if (!msg.edited_timestamp || msg.edited_timestamp === "invalid_timestamp") return;
const chId = msg.channel_id || ev.channelId;
const id = msg.id || ev.id;
if (!chId || !id) return;
const orig = MessageStore?.getMessage?.(chId, id) || ChannelMessages?.get?.(chId)?.get?.(id);
if (!orig?.author?.id || !orig.author.username) return;
if (!orig.content || !msg.content || msg.content === orig.content) return;
const prefix = "`[ EDITED ]`\n\n";
const gatewayOrig = recordToGateway(orig);
ev.message = {
...gatewayOrig,
...msg,
content: `${orig.content} ${prefix}${msg.content}`,
guild_id: ChannelStore?.getChannel?.(chId)?.guild_id ?? msg.guild_id,
edited_timestamp: "invalid_timestamp",
message_reference: msg?.message_reference || orig?.messageReference || null,
};
return args;
}
} catch (e) {
console.error("[ANTIED ZERO] Flux error:", e);
}
});
}

View file

@ -0,0 +1,23 @@
import { before } from "@vendetta/patcher";
import { findByProps } from "@vendetta/metro";
import { regexEscaper } from "./actionSheet";
const Message = findByProps("sendMessage", "startEditMessage");
export function patchSelfEdit(isEnabledRef: { current: boolean }): () => void {
if (!Message?.startEditMessage) return () => {};
return before("startEditMessage", Message, (args: any[]) => {
if (!isEnabledRef.current || !args?.length) return;
const edited = "`[ EDITED ]`";
const dan = regexEscaper(edited);
const regexPattern = new RegExp(`(?:\\s${dan}\\n{2})`, "gm");
const msg = args[2];
if (typeof msg === "string") {
const lats = msg.split(regexPattern);
args[2] = lats[lats.length - 1];
}
});
}

View file

@ -0,0 +1,16 @@
import { React } from "@vendetta/metro/common";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
import { TableRowGroup } from "@shared/ui/table";
export default function Settings() {
return (
<SettingsScaffold>
<TableRowGroup title="Antied Zero">
<NoteBox>
Antied Zero is active and running in zero-config mode. Deleted messages are automatically retained with ephemeral flags, and edit history is tracked in-line.
</NoteBox>
</TableRowGroup>
</SettingsScaffold>
);
}

View file

@ -0,0 +1,12 @@
{
"name": "Cute Moodle (Antied)",
"description": "Keeps deleted messages and edit history visible in chat with customizable highlights, indicators, and actionsheet management.",
"authors": [
{ "name": "Angelw0lf" },
{ "name": "Rosie" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_edit_24px"
}
}

148
plugins/antied/src/index.ts Normal file
View file

@ -0,0 +1,148 @@
import { storage, id } from "@vendetta/plugin";
import { findByProps } from "@vendetta/metro";
import { FluxDispatcher } from "@vendetta/metro/common";
import { stopPlugin } from "@vendetta/plugins";
import { showToast } from "@vendetta/ui/toasts";
import { makeDefaults } from "@shared/lib/utility";
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
import { patchFluxDispatch } from "./patches/fluxDispatch";
import { patchUpdateRows } from "./patches/updateRows";
import { patchActionSheet } from "./patches/actionSheet";
import { patchSelfEdit } from "./patches/selfEdit";
import { patchContextMenu } from "./patches/contextMenu";
import { patchMessageRecord } from "./patches/messageRecord";
import Settings from "./ui/Settings";
import type { AntiedStorage } from "./lib/types";
const ChannelMessages = findByProps("_channelMessages");
const isEnabledRef = { current: false };
const deletedMessageMap = new Map<string, any>();
const cleanups: (() => void)[] = [];
let intervalPurge: any = null;
const KEEP_NEWEST = 10;
const DELETE_EACH_CYCLE = 140;
function initStorage() {
makeDefaults(storage, {
setting: {
colorpick: false,
customize: false,
ingorelist: false,
patches: false,
text: false,
timestamp: false,
},
switches: {
customizeable: false,
enableMD: true,
enableMU: true,
useBackgroundColor: false,
useSemRawColors: false,
ignoreBots: false,
ignoreOwnMessages: false,
minimalistic: true,
alwaysAdd: false,
darkMode: true,
removeDismissButton: false,
addTimestampForEdits: false,
timestampStyle: "R",
useEphemeralForDeleted: true,
overrideIndicator: false,
useIndicatorForDeleted: false,
useCustomPluginName: false,
},
colors: {
textColor: "#E40303",
backgroundColor: "#FF2C2F",
backgroundColorAlpha: "33",
gutterColor: "#FF2C2F",
gutterColorAlpha: "CC",
semRawColorPrefix: "semanticColors.TEXT_BRAND",
},
inputs: {
deletedMessageBuffer: "This message is deleted",
editedMessageBuffer: "`[ EDITED ]`",
historyToast: "[ANTI ED] History Removed",
ignoredUserList: [],
customPluginName: "ANTIED",
customIndicator: "",
},
misc: {
timestampPos: "BEFORE",
editHistoryIcon: "ic_edit_24px",
},
debug: false,
debugUpdateRows: false,
});
}
export default {
onLoad: async () => {
initStorage();
if (enforceBlacklistOnLoad(id)) return;
isEnabledRef.current = true;
try {
cleanups.push(patchMessageRecord(isEnabledRef));
cleanups.push(patchFluxDispatch(deletedMessageMap, isEnabledRef));
cleanups.push(patchUpdateRows(deletedMessageMap, isEnabledRef));
cleanups.push(patchActionSheet(deletedMessageMap, isEnabledRef));
cleanups.push(patchSelfEdit(isEnabledRef));
cleanups.push(patchContextMenu(isEnabledRef));
} catch (err) {
console.error("[ANTIED] Crash on load:", err);
showToast("[ANTIED] Failed to load patches. Check debug log.");
stopPlugin(id);
return;
}
intervalPurge = setInterval(() => {
if (deletedMessageMap.size <= KEEP_NEWEST) return;
const toDelete = Math.min(DELETE_EACH_CYCLE, deletedMessageMap.size - KEEP_NEWEST);
let i = 0;
for (const key of deletedMessageMap.keys()) {
deletedMessageMap.delete(key);
if (++i >= toDelete) break;
}
}, 15 * 60 * 1000);
},
onUnload: () => {
isEnabledRef.current = false;
if (intervalPurge) clearInterval(intervalPurge);
for (const fn of cleanups) {
try {
fn();
} catch (_) {}
}
cleanups.length = 0;
// Revert faked messages
if (ChannelMessages?._channelMessages) {
for (const channelId in ChannelMessages._channelMessages) {
const arr = ChannelMessages._channelMessages[channelId]?._array;
if (!Array.isArray(arr)) continue;
for (const message of arr) {
if (message?.was_deleted) {
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
id: message.id,
channelId: message.channel_id || channelId,
otherPluginBypass: true,
});
}
}
}
}
deletedMessageMap.clear();
},
settings: Settings,
};

View file

@ -0,0 +1,51 @@
export interface AntiedStorage {
setting: {
colorpick: boolean;
customize: boolean;
ingorelist: boolean;
patches: boolean;
text: boolean;
timestamp: boolean;
};
switches: {
customizeable: boolean;
enableMD: boolean;
enableMU: boolean;
useBackgroundColor: boolean;
useSemRawColors: boolean;
ignoreBots: boolean;
ignoreOwnMessages: boolean;
minimalistic: boolean;
alwaysAdd: boolean;
darkMode: boolean;
removeDismissButton: boolean;
addTimestampForEdits: boolean;
timestampStyle: "t" | "T" | "d" | "D" | "f" | "F" | "R";
useEphemeralForDeleted: boolean;
overrideIndicator: boolean;
useIndicatorForDeleted: boolean;
useCustomPluginName: boolean;
};
colors: {
textColor: string;
backgroundColor: string;
backgroundColorAlpha: string;
gutterColor: string;
gutterColorAlpha: string;
semRawColorPrefix: string;
};
inputs: {
deletedMessageBuffer: string;
editedMessageBuffer: string;
historyToast: string;
ignoredUserList: { id: string; username: string }[];
customPluginName: string;
customIndicator: string;
};
misc: {
timestampPos: "BEFORE" | "AFTER";
editHistoryIcon: string;
};
debug: boolean;
debugUpdateRows: boolean;
}

View file

@ -0,0 +1,126 @@
import { before, after } from "@vendetta/patcher";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { findInReactTree } from "@vendetta/utils";
import { React, FluxDispatcher } from "@vendetta/metro/common";
import { showToast } from "@vendetta/ui/toasts";
import { storage } from "@vendetta/plugin";
import { findByProps } from "@vendetta/metro";
import type { AntiedStorage } from "../lib/types";
const ActionSheet = findByProps("openLazy", "hideActionSheet");
const MessageStore = findByProps("getMessage", "getMessages");
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
const ChannelMessages = findByProps("_channelMessages");
const { ActionSheetRow } = findByProps("ActionSheetRow") || {};
export const regexEscaper = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export function patchActionSheet(deletedMessageMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
if (!ActionSheet?.openLazy || !ActionSheetRow) return () => {};
return before("openLazy", ActionSheet, ([component, args, actionMessage]: any[]) => {
if (!isEnabledRef.current) return;
try {
const message = actionMessage?.message;
if (args !== "MessageLongPressActionSheet" || !message) return;
component.then((instance: any) => {
const unpatch = after("default", instance, (_: any, comp: any) => {
try {
React.useEffect(() => () => { unpatch(); }, []);
const isReplyBtn = (a: any) => a?.props?.label?.toLowerCase?.() === "reply";
const buttons = findInReactTree(comp, (c: any) => c?.find?.(isReplyBtn));
if (!buttons) return comp;
const position = Math.max(buttons.findIndex(isReplyBtn), buttons.length - 1);
let originalMessage = null;
if (message?.channel_id && message?.id) {
originalMessage = MessageStore?.getMessage?.(message.channel_id, message.id) ||
ChannelMessages?.get?.(message.channel_id)?.get?.(message.id);
}
if (!originalMessage) return comp;
const cfg = storage as AntiedStorage;
const escapedBuffer = regexEscaper(cfg.inputs?.editedMessageBuffer || "`[ EDITED ]`");
const separator = new RegExp(escapedBuffer, "gmi");
const hasBuffer = separator.test(message.content);
if (hasBuffer) {
const targetPos = position || 1;
buttons.splice(
targetPos,
0,
React.createElement(ActionSheetRow, {
label: "Remove Edit History",
subLabel: "Cute Moodle (Antied)",
icon: React.createElement(ActionSheetRow.Icon, {
source: getAssetIDByName("ic_edit_24px") || getAssetIDByName("ic_message_edit"),
}),
onPress: () => {
const regexPattern = new RegExp(
`(?:(?:\\s${escapedBuffer}(\\s\\(<t:\\d+:[tTdDfFR]>\\))?\\n{2})|(?:(?:\\s\\(<t:\\d+:[tTdDfFR]>\\) ${escapedBuffer}\\n{2})))`,
"gm"
);
const lats = message?.content?.split(regexPattern);
const targetMessage = lats?.[lats.length - 1] ?? message.content;
FluxDispatcher.dispatch({
type: "MESSAGE_UPDATE",
message: {
...message,
message_reference: message?.message_reference || message?.messageReference || null,
content: `${targetMessage}`,
guild_id: ChannelStore?.getChannel?.(originalMessage.channel_id)?.guild_id,
},
otherPluginBypass: true,
});
ActionSheet.hideActionSheet();
if (cfg.inputs?.historyToast) {
showToast(cfg.inputs.historyToast, getAssetIDByName(cfg.misc?.editHistoryIcon || "ic_edit_24px"));
}
},
})
);
}
if (!cfg.switches?.useEphemeralForDeleted && deletedMessageMap.has(message.id)) {
const targetPos = position || 1;
buttons.splice(
targetPos,
0,
React.createElement(ActionSheetRow, {
label: "Remove Deleted Message",
subLabel: "Cute Moodle (Antied)",
isDestructive: true,
icon: React.createElement(ActionSheetRow.Icon, {
source: getAssetIDByName("ic_edit_24px") || getAssetIDByName("TrashIcon"),
}),
onPress: () => {
FluxDispatcher.dispatch({
type: "MESSAGE_DELETE",
guildId: ChannelStore?.getChannel?.(originalMessage.channel_id)?.guild_id,
id: message?.id,
channelId: message?.channel_id,
otherPluginBypass: true,
});
ActionSheet.hideActionSheet();
showToast("[ANTIED] Message Removed", getAssetIDByName("ic_edit_24px"));
},
})
);
}
} catch (e) {
console.error("[ANTIED] ActionSheet component error:", e);
}
});
});
} catch (e) {
console.error("[ANTIED] ActionSheet patch error:", e);
}
});
}

View file

@ -0,0 +1,71 @@
import { after } from "@vendetta/patcher";
import { findByName, findByStoreName } from "@vendetta/metro";
import { storage } from "@vendetta/plugin";
import { findInReactTree } from "@vendetta/utils";
import { showToast } from "@vendetta/ui/toasts";
import type { AntiedStorage } from "../lib/types";
const PROFILE_MENU_NAMES = ["UserProfileOverflowMenu", "BotUserProfileOverflowMenu"];
const UserStore = findByStoreName("UserStore");
function getMainItems(ret: any): any[] | null {
let items = ret?.props?.items;
if (Array.isArray(items) && Array.isArray(items[0])) return items[0];
items = ret?.props?.children?.props?.items;
if (Array.isArray(items) && Array.isArray(items[0])) return items[0];
const node = findInReactTree(ret, (n: any) => Array.isArray(n?.props?.items) && Array.isArray(n.props.items[0]));
return node?.props?.items?.[0] ?? null;
}
export function patchContextMenu(isEnabledRef: { current: boolean }): () => void {
const unpatches: (() => void)[] = [];
for (const name of PROFILE_MENU_NAMES) {
const mod = findByName(name, false);
if (!mod) continue;
unpatches.push(
after("default", mod, (args: any[], ret: any) => {
if (!isEnabledRef.current) return;
try {
const props = args[0] ?? {};
const userId = props.user?.id || props.userId;
if (!userId) return;
const items = getMainItems(ret);
if (!items) return;
if (items.some((item: any) => item?.label?.includes("(Antied)"))) return;
const cfg = storage as AntiedStorage;
cfg.inputs ??= {} as any;
cfg.inputs.ignoredUserList ??= [];
const isIgnored = cfg.inputs.ignoredUserList.some((u) => u.id === userId);
const user = UserStore?.getUser?.(userId);
const username = user?.username || userId;
items.push({
label: isIgnored ? "Unignore User (Antied)" : "Ignore User (Antied)",
isDestructive: !isIgnored,
action: () => {
if (isIgnored) {
cfg.inputs.ignoredUserList = cfg.inputs.ignoredUserList.filter((u) => u.id !== userId);
showToast(`Unignored ${username} in Antied`);
} else {
cfg.inputs.ignoredUserList.push({ id: userId, username });
showToast(`Ignored ${username} in Antied`);
}
},
});
} catch (e) {
console.error("[ANTIED] Context menu error:", e);
}
})
);
}
return () => {
for (const fn of unpatches) fn();
};
}

View file

@ -0,0 +1,175 @@
import { before } from "@vendetta/patcher";
import { findByProps, findByStoreName } from "@vendetta/metro";
import { storage } from "@vendetta/plugin";
import { FluxDispatcher } from "@vendetta/metro/common";
import { recordToGateway } from "@shared/lib/recordToGateway";
import type { AntiedStorage } from "../lib/types";
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
const ChannelMessages = findByProps("_channelMessages");
const MessageStore = findByProps("getMessage", "getMessages");
const UserStore = findByStoreName("UserStore");
const AuthStore = findByStoreName("AuthenticationStore") || findByProps("getToken");
function getCurrentUserId(): string | undefined {
return UserStore?.getCurrentUser?.()?.id || AuthStore?.getId?.() || AuthStore?.getCurrentUser?.()?.id;
}
const now = () => Date.now();
const tsStyle = () => {
const s = (storage as AntiedStorage).switches?.timestampStyle;
return s && "tTdDfFR".includes(s) ? s : "R";
};
export function patchFluxDispatch(deletedMessageMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
return before("dispatch", FluxDispatcher, (args: any[]) => {
if (!isEnabledRef.current) return;
try {
const ev = args[0];
if (!ev || !ev.type) return;
const cfg = storage as AntiedStorage;
const currentUserId = getCurrentUserId();
/* =========================================================
MESSAGE_DELETE
==========================================================*/
if (ev.type === "MESSAGE_DELETE") {
if (!cfg.switches?.enableMD || ev.otherPluginBypass || ev.manualDelete) return;
const orig = ChannelMessages?.get?.(ev.channelId)?.get?.(ev.id) || MessageStore?.getMessage?.(ev.channelId, ev.id) || deletedMessageMap.get(ev.id)?.original;
if (!orig?.author?.id || !orig.author.username) return;
// Ephemeral message dismiss check
if (orig?.author?.bot && (orig?.flags === 64 || (orig?.flags & 64) === 64)) return;
// Empty message check
if (!orig.content && !orig.attachments?.length && !orig.embeds?.length && !orig.components?.length) return;
// Bot check
if (cfg.switches.ignoreBots && (orig.author.bot || orig.author.isNonUserBot?.())) return;
// Own message check
if (cfg.switches.ignoreOwnMessages && orig.author.id === currentUserId) return;
// Ignored users list check
if (cfg.inputs?.ignoredUserList?.length) {
if (cfg.inputs.ignoredUserList.some((u) => u.id === orig.author.id || u.username === orig.author.username)) return;
}
if (deletedMessageMap.has(ev.id)) {
ev.type = "MESSAGE_UPDATE";
ev.channelId = orig.channel_id || ev.channelId;
ev.message = {
id: ev.id,
channel_id: orig.channel_id || ev.channelId,
was_deleted: true,
};
return args;
}
const guildId = ChannelStore?.getChannel?.(orig.channel_id || ev.channelId)?.guild_id;
const gatewayOrig = recordToGateway(orig);
ev.message = {
...gatewayOrig,
content: orig.content,
channel_id: orig.channel_id || ev.channelId,
guild_id: guildId,
was_deleted: true,
message_reference: orig?.message_reference || orig?.messageReference || null,
};
ev.type = "MESSAGE_UPDATE";
ev.channelId = orig.channel_id || ev.channelId;
ev.optimistic = false;
ev.sendMessageOptions = {};
ev.isPushNotification = false;
deletedMessageMap.set(ev.id, { message: args, original: orig });
return args;
}
/* =========================================================
MESSAGE_DELETE_BULK
==========================================================*/
if (ev.type === "MESSAGE_DELETE_BULK") {
if (!cfg.switches?.enableMD || ev.otherPluginBypass || !Array.isArray(ev.ids)) return;
for (const id of ev.ids) {
const orig = ChannelMessages?.get?.(ev.channelId)?.get?.(id) || MessageStore?.getMessage?.(ev.channelId, id);
if (!orig?.author?.id) continue;
if (cfg.switches.ignoreBots && (orig.author.bot || orig.author.isNonUserBot?.())) continue;
if (cfg.switches.ignoreOwnMessages && orig.author.id === currentUserId) continue;
deletedMessageMap.set(id, { original: orig });
const gatewayOrig = recordToGateway(orig);
FluxDispatcher.dispatch({
type: "MESSAGE_UPDATE",
channelId: ev.channelId,
message: {
...gatewayOrig,
was_deleted: true,
},
otherPluginBypass: true,
});
}
return;
}
/* =========================================================
MESSAGE_UPDATE
==========================================================*/
if (ev.type === "MESSAGE_UPDATE") {
if (!cfg.switches?.enableMU || ev.otherPluginBypass) return;
const msg = ev.message;
if (!msg) return;
// A MESSAGE_UPDATE is ONLY a real content edit if edited_timestamp is present and not synthetic
if (!msg.edited_timestamp || msg.edited_timestamp === "invalid_timestamp") return;
const chId = msg.channel_id || ev.channelId;
const id = msg.id || ev.id;
if (!chId || !id) return;
const orig = MessageStore?.getMessage?.(chId, id) || ChannelMessages?.get?.(chId)?.get?.(id);
if (!orig?.author?.id) return;
if (cfg.switches.ignoreBots && (orig.author.bot || orig.author.isNonUserBot?.())) return;
if (cfg.switches.ignoreOwnMessages && orig.author.id === currentUserId) return;
if (!orig.content || !msg.content || msg.content === orig.content) return;
if (cfg.inputs?.ignoredUserList?.length) {
if (cfg.inputs.ignoredUserList.some((u) => u.id === orig.author.id || u.username === orig.author.username)) return;
}
const editedTag = cfg.inputs?.editedMessageBuffer || "`[ EDITED ]`";
if (orig.content.includes(editedTag) && orig.content.endsWith(msg.content)) return;
const time = cfg.switches?.addTimestampForEdits ? `(<t:${Math.floor(now() / 1000)}:${tsStyle()}>)` : null;
const tsPos = cfg.misc?.timestampPos === "BEFORE";
let prefix = editedTag;
prefix = time ? (tsPos ? `${time} ${prefix}\n\n` : `${prefix} ${time}\n\n`) : `${prefix}\n\n`;
const gatewayOrig = recordToGateway(orig);
ev.message = {
...gatewayOrig,
...msg,
content: `${orig.content} ${prefix}${msg.content}`,
guild_id: ChannelStore?.getChannel?.(chId)?.guild_id ?? msg.guild_id,
edited_timestamp: "invalid_timestamp",
message_reference: msg?.message_reference || orig?.messageReference || null,
};
return args;
}
} catch (e: any) {
console.error("[ANTIED] Flux dispatch error:", e?.message ?? e);
}
});
}

View file

@ -0,0 +1,40 @@
import { after } from "@vendetta/patcher";
import { findByProps } from "@vendetta/metro";
const MessageRecordUtils = findByProps("updateMessageRecord", "createMessageRecord");
export function patchMessageRecord(isEnabledRef: { current: boolean }): () => void {
if (!MessageRecordUtils) return () => {};
const cleanups: (() => void)[] = [];
cleanups.push(
after("createMessageRecord", MessageRecordUtils, function ([message], record) {
if (isEnabledRef.current && (message?.was_deleted || record?.was_deleted)) {
if (record) record.was_deleted = true;
}
})
);
cleanups.push(
after("updateMessageRecord", MessageRecordUtils, function ([oldRecord, newRecord], record) {
if (isEnabledRef.current && (oldRecord?.was_deleted || newRecord?.was_deleted)) {
if (record) record.was_deleted = true;
}
})
);
if (typeof MessageRecordUtils.updateServerMessage === "function") {
cleanups.push(
after("updateServerMessage", MessageRecordUtils, function ([oldRecord, newRecord], record) {
if (isEnabledRef.current && (oldRecord?.was_deleted || newRecord?.was_deleted)) {
if (record) record.was_deleted = true;
}
})
);
}
return () => {
for (const fn of cleanups) fn();
};
}

View file

@ -0,0 +1,30 @@
import { before } from "@vendetta/patcher";
import { findByProps } from "@vendetta/metro";
import { storage } from "@vendetta/plugin";
import { regexEscaper } from "./actionSheet";
import type { AntiedStorage } from "../lib/types";
const Message = findByProps("sendMessage", "startEditMessage");
export function patchSelfEdit(isEnabledRef: { current: boolean }): () => void {
if (!Message?.startEditMessage) return () => {};
return before("startEditMessage", Message, (args: any[]) => {
if (!isEnabledRef.current || !args?.length) return;
const cfg = storage as AntiedStorage;
const edited = cfg.inputs?.editedMessageBuffer || "`[ EDITED ]`";
const dan = regexEscaper(edited);
const regexPattern = new RegExp(
`(?:(?:\\s${dan}(\\s\\(<t:\\d+:[tTdDfFR]>\\))?\\n{2})|(?:(?:\\s\\(<t:\\d+:[tTdDfFR]>\\) ${dan}\\n{2})))`,
"gm"
);
const msg = args[2];
if (typeof msg === "string") {
const lats = msg.split(regexPattern);
args[2] = lats[lats.length - 1];
}
});
}

View file

@ -0,0 +1,114 @@
import { ReactNative } from "@vendetta/metro/common";
import { before, after } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { findByProps, findByName } from "@vendetta/metro";
import { rawFind } from "@shared/lib/rawFind";
import { waitFor } from "@shared/lib/waitFor";
import type { AntiedStorage } from "../lib/types";
const toHex = (v: string | undefined, fallback: string) => {
const s = String(v || "").trim();
const hex = s.startsWith("#") ? s.slice(1) : s;
return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex.toUpperCase()}` : fallback;
};
function handleRow(row: any, deletedMessagesMap: Map<string, any>) {
if (!row || row.type !== 1) return;
const msg = row.message;
if (!msg || (!deletedMessagesMap.has(msg.id) && !msg.was_deleted)) return;
const cfg = storage as AntiedStorage;
const {
colors: { textColor, backgroundColor, backgroundColorAlpha, gutterColor, gutterColorAlpha },
switches: { useBackgroundColor, minimalistic },
inputs: { deletedMessageBuffer },
} = cfg;
msg.edited = deletedMessageBuffer || "This message is deleted";
if (!minimalistic) {
msg.textColor = ReactNative.processColor(toHex(textColor, "#E40303"));
}
if (!minimalistic && useBackgroundColor) {
row.backgroundHighlight = {
backgroundColor: ReactNative.processColor(toHex(backgroundColor, "#FF2C2F") + backgroundColorAlpha),
gutterColor: ReactNative.processColor(toHex(gutterColor, "#FF2C2F") + gutterColorAlpha),
};
}
}
function isNativeUpdateRows(m: any): boolean {
return typeof m?.updateRows === "function" && m.updateRows.toString().includes("[native code]");
}
export function patchUpdateRows(deletedMessagesMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
const cleanups: (() => void)[] = [];
const applyHook = (target: any) => {
cleanups.push(
before("updateRows", target, (args: any[]) => {
if (!isEnabledRef.current || !args?.length) return;
if (!deletedMessagesMap.size) return;
const raw = args[1];
if (!raw) return;
if (typeof raw === "string") {
try {
const rows = JSON.parse(raw);
let mutated = false;
for (const row of rows) {
if (row?.type === 1 && row?.message && (deletedMessagesMap.has(row.message.id) || row.message.was_deleted)) {
handleRow(row, deletedMessagesMap);
mutated = true;
}
}
if (mutated) {
args[1] = JSON.stringify(rows);
return args;
}
} catch {
return;
}
} else if (Array.isArray(raw)) {
for (const row of raw) handleRow(row, deletedMessagesMap);
} else if (raw && typeof raw === "object" && Array.isArray(raw.rows)) {
for (const row of raw.rows) handleRow(row, deletedMessagesMap);
}
})
);
};
const { NativeModules } = ReactNative;
const DCDChatManager = NativeModules?.DCDChatManager;
if (DCDChatManager?.updateRows) {
applyHook(DCDChatManager);
}
const nativeChat = rawFind<any>(isNativeUpdateRows) || findByProps("updateRows", "getConstants") || findByProps("updateRows");
if (nativeChat && nativeChat !== DCDChatManager) {
applyHook(nativeChat);
} else if (!DCDChatManager) {
const handle = waitFor(
() => rawFind<any>(isNativeUpdateRows) || findByProps("updateRows"),
(target) => applyHook(target)
);
cleanups.push(() => handle.cancel());
}
const RowManager = findByName("RowManager", false) || findByProps("RowManager")?.RowManager;
if (RowManager?.prototype?.generate) {
cleanups.push(
after("generate", RowManager.prototype, (_args: any[], rowObj: any) => {
if (!isEnabledRef.current || !deletedMessagesMap.size) return;
const row = rowObj?.row || rowObj;
handleRow(row, deletedMessagesMap);
})
);
}
return () => {
for (const fn of cleanups) fn();
};
}

View file

@ -0,0 +1,169 @@
import { React, ReactNative as RN } from "@vendetta/metro/common";
import { findByStoreName } from "@vendetta/metro";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import NoteBox from "@shared/ui/NoteBox";
import ColorInput from "@shared/ui/ColorInput";
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
import type { AntiedStorage } from "../lib/types";
const UserStore = findByStoreName("UserStore");
const { View, Text, TouchableOpacity, Image } = RN;
export default function Settings() {
useProxy(storage);
const cfg = storage as AntiedStorage;
cfg.switches ??= {} as any;
cfg.inputs ??= {} as any;
cfg.colors ??= {} as any;
cfg.misc ??= {} as any;
const ignoredUsers = cfg.inputs.ignoredUserList || [];
const handleRemoveUser = (id: string) => {
cfg.inputs.ignoredUserList = ignoredUsers.filter((u) => u.id !== id);
showToast("User removed from ignore list", getAssetIDByName("Check"));
};
return (
<SettingsScaffold>
<NoteBox>
Cute Moodle (Antied) keeps deleted messages and edit history visible in your chat with customizable highlights, indicators, and long-press actionsheet tools.
</NoteBox>
<TableRowGroup title="General Logger">
<TableSwitchRow
label="Log deleted messages"
subLabel="Keep deleted messages visible in chat"
value={!!cfg.switches.enableMD}
onValueChange={(v: boolean) => {
cfg.switches.enableMD = v;
}}
/>
<TableSwitchRow
label="Log edited messages"
subLabel="Track edits and original message contents"
value={!!cfg.switches.enableMU}
onValueChange={(v: boolean) => {
cfg.switches.enableMU = v;
}}
/>
<TableSwitchRow
label="Ignore bots"
subLabel="Do not log messages or edits from bot accounts"
value={!!cfg.switches.ignoreBots}
onValueChange={(v: boolean) => {
cfg.switches.ignoreBots = v;
}}
/>
<TableSwitchRow
label="Ignore your own messages"
subLabel="Do not log your own deletions and edits"
value={!!cfg.switches.ignoreOwnMessages}
onValueChange={(v: boolean) => {
cfg.switches.ignoreOwnMessages = v;
}}
/>
</TableRowGroup>
<TableRowGroup title="Styling & Appearance">
<TableSwitchRow
label="Minimalistic mode"
subLabel="Show simple indicators without heavy colored highlights"
value={!!cfg.switches.minimalistic}
onValueChange={(v: boolean) => {
cfg.switches.minimalistic = v;
}}
/>
<TableSwitchRow
label="Use background highlight"
subLabel="Apply colored tint to deleted message backgrounds"
value={!!cfg.switches.useBackgroundColor}
onValueChange={(v: boolean) => {
cfg.switches.useBackgroundColor = v;
}}
/>
{!cfg.switches.minimalistic && (
<>
<ColorInput
label="Text color"
value={cfg.colors.textColor || "#E40303"}
defaultValue="#E40303"
onChange={(hex: string) => {
cfg.colors.textColor = hex;
}}
/>
{cfg.switches.useBackgroundColor && (
<ColorInput
label="Background highlight color"
value={cfg.colors.backgroundColor || "#FF2C2F"}
defaultValue="#FF2C2F"
onChange={(hex: string) => {
cfg.colors.backgroundColor = hex;
}}
/>
)}
</>
)}
<TableSwitchRow
label="Add timestamp for edits"
subLabel="Include relative timestamp on edited versions"
value={!!cfg.switches.addTimestampForEdits}
onValueChange={(v: boolean) => {
cfg.switches.addTimestampForEdits = v;
}}
/>
</TableRowGroup>
<TableRowGroup title="Custom Buffers">
<TextInput
label="Deleted indicator text"
value={cfg.inputs.deletedMessageBuffer || "This message is deleted"}
onChange={(v: string) => {
cfg.inputs.deletedMessageBuffer = v;
}}
/>
<TextInput
label="Edited prefix tag"
value={cfg.inputs.editedMessageBuffer || "`[ EDITED ]`"}
onChange={(v: string) => {
cfg.inputs.editedMessageBuffer = v;
}}
/>
</TableRowGroup>
<TableRowGroup title={`Ignored Users (${ignoredUsers.length})`}>
{ignoredUsers.length === 0 ? (
<View style={{ padding: 16 }}>
<Text style={{ opacity: 0.6, fontSize: 13, color: "#96989d" }}>No users currently ignored.</Text>
</View>
) : (
ignoredUsers.map((u) => {
const user = UserStore?.getUser?.(u.id);
const name = user?.username ? `@${user.username}` : u.username || u.id;
return (
<TableRow
key={u.id}
label={name}
subLabel={`ID: ${u.id}`}
trailing={
<TouchableOpacity onPress={() => handleRemoveUser(u.id)}>
<Image
source={getAssetIDByName("TrashIcon") || getAssetIDByName("ic_trash_24px")}
style={{ width: 20, height: 20, tintColor: "#ff4d4d" }}
/>
</TouchableOpacity>
}
/>
);
})
)}
</TableRowGroup>
</SettingsScaffold>
);
}

View file

@ -0,0 +1,9 @@
{
"name": "BetterTimestamps",
"description": "Replace message and day-divider timestamps with calendar-relative, fully relative, ISO 8601, or a custom moment.js format.",
"authors": [
{ "name": "Rosie" },
{ "name": "nexpid" }
],
"main": "src/index.ts"
}

View file

@ -0,0 +1,26 @@
import { logger } from "@vendetta";
import { storage, id } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
import patchRowManager from "./patches/rowManager";
import patchTimestamp from "./patches/timestamp";
import Settings from "./ui/Settings";
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
if (enforceBlacklistOnLoad(id)) return;
storage.selected ??= "calendar";
storage.customFormat ??= "dddd, MMMM Do YYYY, h:mm:ss a";
storage.separateMessages ??= false;
unpatchAll = applyPatches("BetterTimestamps", logger, {
"message & day timestamps": patchRowManager,
"message timestamp component": patchTimestamp
});
},
onUnload: () => unpatchAll(),
settings: Settings
};

View file

@ -0,0 +1,26 @@
import { moment } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
export type TimestampMode = "calendar" | "relative" | "custom" | "iso";
export default function renderTimestamp(timestamp: any, mode: TimestampMode = storage.selected): string {
try {
if (
storage.hideDateIfToday &&
(mode === "calendar" || mode === "custom") &&
typeof timestamp.isSame === "function" &&
timestamp.isSame(moment(), "day")
) {
return timestamp.format("LT");
}
switch (mode) {
case "calendar": return timestamp.calendar();
case "relative": return timestamp.fromNow();
case "iso": return timestamp.toISOString();
case "custom": return timestamp.format(storage.customFormat || "dddd, MMMM Do YYYY, h:mm:ss a");
default: return timestamp.calendar();
}
} catch {
return timestamp?.calendar?.() ?? String(timestamp);
}
}

View file

@ -0,0 +1,74 @@
import { moment } from "@vendetta/metro/common";
import { after, before } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { waitFor } from "@shared/lib/waitFor";
import { rawFindByName } from "@shared/lib/rawFind";
import renderTimestamp from "../lib/renderTimestamp";
function wrapTimestamp(original: any): any {
const getFormatted = (..._args: any[]) => renderTimestamp(original);
if (typeof Proxy === "undefined") return getFormatted();
return new Proxy(original, {
get(target, prop) {
if (prop === "format" || prop === "calendar" || prop === "fromNow" || prop === "toISOString" || prop === "toString" || prop === "toJSON") {
return getFormatted;
}
const value = target[prop];
return typeof value === "function" ? value.bind(target) : value;
}
});
}
function parseTimestamp(value: any): any {
if (value && typeof value.format === "function") return value;
return moment(value);
}
// findByName caches a negative result permanently if it runs before RowManager registers -
// waitFor + a raw lookup retries until it's actually there.
export default function patchRowManager(): () => void {
const patches: (() => void)[] = [];
const handle = waitFor(
() => {
const RowManager = rawFindByName<any>("RowManager");
return RowManager?.prototype?.generate ? RowManager : undefined;
},
(RowManager) => {
patches.push(before("generate", RowManager.prototype, ([row]: any[]) => {
try {
if (row.rowType === 1) {
if (storage.separateMessages) row.isFirst = true;
const parsed = parseTimestamp(row.message.timestamp);
row.message.__customTimestamp = wrapTimestamp(parsed);
} else if (row.rowType === "day") {
const parsed = moment(row.text, "LL");
if (storage.hideDateIfToday && parsed.isValid() && parsed.isSame(moment(), "day")) {
row.text = "Today";
} else {
row.text = renderTimestamp(parsed);
}
}
} catch {
// Leave this row's timestamp untouched.
}
}));
patches.push(after("generate", RowManager.prototype, ([row]: any[], result: any) => {
try {
if (row.rowType !== 1) return;
if (row.message?.__customTimestamp && result?.message?.timestamp) {
result.message.timestamp = row.message.__customTimestamp;
}
} catch {
// Leave the default timestamp.
}
}));
}
);
return () => {
handle.cancel();
patches.forEach((p) => p());
};
}

View file

@ -0,0 +1,71 @@
import { moment } from "@vendetta/metro/common";
import { before } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { waitFor } from "@shared/lib/waitFor";
import { rawFind } from "@shared/lib/rawFind";
import renderTimestamp from "../lib/renderTimestamp";
function wrapTimestamp(original: any): any {
const getFormatted = (..._args: any[]) => renderTimestamp(original);
if (typeof Proxy === "undefined") return getFormatted();
return new Proxy(original, {
get(target, prop) {
if (prop === "format" || prop === "calendar" || prop === "fromNow" || prop === "toISOString" || prop === "toString" || prop === "toJSON") {
return getFormatted;
}
const value = target[prop];
return typeof value === "function" ? value.bind(target) : value;
}
});
}
function parseTimestamp(value: any): any {
if (value && typeof value.format === "function") return value;
return moment(value);
}
function findTimestampModule(): any {
return rawFind<any>((m) => {
if (typeof m !== "object" || m == null) return false;
return Object.values(m).some((v: any) => {
if (typeof v === "string") return v.includes("MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL");
if (typeof v === "function") {
const s = v.toString();
return s.includes("MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL") || s.includes("MESSAGE_CREATED_TIMESTAMP_A11Y_LABEL");
}
return false;
});
});
}
export default function patchTimestamp(): () => void {
const patches: (() => void)[] = [];
const handle = waitFor(
() => {
const module = findTimestampModule();
return module?.default ? module : undefined;
},
(module) => {
patches.push(before("default", module, ([props]: any[]) => {
try {
if (props?.timestamp != null) {
const parsed = parseTimestamp(props.timestamp);
props.timestamp = wrapTimestamp(parsed);
}
if (props?.editedTimestamp != null) {
const parsed = parseTimestamp(props.editedTimestamp);
props.editedTimestamp = wrapTimestamp(parsed);
}
} catch {
// Leave timestamps untouched.
}
}));
}
);
return () => {
handle.cancel();
patches.forEach((p) => p());
};
}

View file

@ -0,0 +1,65 @@
import { React, ReactNative, moment } from "@vendetta/metro/common";
import { storage } from "@vendetta/plugin";
import { useProxy } from "@vendetta/storage";
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import { resolveSemanticColorSafe } from "@shared/lib/color";
import renderTimestamp, { TimestampMode } from "../lib/renderTimestamp";
const { Text } = ReactNative;
const MODES: { label: string; key: TimestampMode }[] = [
{ label: "Calendar", key: "calendar" },
{ label: "Relative", key: "relative" },
{ label: "ISO 8601", key: "iso" },
{ label: "Custom", key: "custom" }
];
export default function Settings() {
useProxy(storage);
return (
<SettingsScaffold>
<TableRowGroup title="Format">
{MODES.map((mode) => {
const selected = storage.selected === mode.key;
let preview = "";
try { preview = renderTimestamp(moment(), mode.key); } catch { preview = ""; }
return (
<React.Fragment key={mode.key}>
<TableRow
label={mode.label}
subLabel={preview}
trailing={selected ? <Text style={{ fontSize: 16, color: resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1") }}>✓</Text> : undefined}
onPress={() => { storage.selected = mode.key; }}
/>
{mode.key === "custom" && selected && (
<TextInput
label="Custom format (moment.js tokens)"
placeholder="dddd, MMMM Do YYYY, h:mm:ss a"
value={storage.customFormat}
onChange={(v: string) => { storage.customFormat = v; }}
/>
)}
</React.Fragment>
);
})}
</TableRowGroup>
<TableRowGroup title="Layout">
<TableSwitchRow
label="Hide date if today"
subLabel="Show time only for today's messages and just 'Today' for today's divider. Calendar & Custom formats only."
value={!!storage.hideDateIfToday}
onValueChange={(v: boolean) => { storage.hideDateIfToday = v; }}
/>
<TableSwitchRow
label="Always show name & avatar"
subLabel="Shows the username, avatar, and timestamp on every message instead of grouping consecutive messages"
value={!!storage.separateMessages}
onValueChange={(v: boolean) => { storage.separateMessages = v; }}
/>
</TableRowGroup>
</SettingsScaffold>
);
}

View file

@ -0,0 +1,9 @@
{
"name": "CopyRoleColor",
"description": "Long-press a role pill (member list, role list) to copy its color as hex.",
"authors": [
{ "name": "Rosie" },
{ "name": "MSMA" }
],
"main": "src/index.ts"
}

View file

@ -0,0 +1,133 @@
import { logger } from "@vendetta";
import { findByName, findByStoreName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import { clipboard } from "@vendetta/metro/common";
import { getAssetIDByName } from "@vendetta/ui/assets";
import { showToast } from "@vendetta/ui/toasts";
import { applyPatches } from "@shared/lib/patcher";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
// A gradient role's real colors live at role.colorStrings ({primaryColor, secondaryColor,
// tertiaryColor}), not the flattened colorString - copying that exact JSON back into Discord's own
// role color picker recreates the gradient. A solid-color role only has primaryColor set, so this
// falls back to the plain hex for those, keeping the existing single-color behavior unchanged.
function formatReadable(colorStrings: any): string {
const lines = [`Primary: ${colorStrings.primaryColor}`, `Secondary: ${colorStrings.secondaryColor}`];
if (colorStrings.tertiaryColor) lines.push(`Tertiary: ${colorStrings.tertiaryColor}`);
return lines.join("\n");
}
// Clipboard only holds one value, so a gradient can't be copied both ways at once - instead, a
// second long-press on the same role within this window swaps the JSON for the readable text.
const GRADIENT_TOGGLE_WINDOW_MS = 3000;
let lastGradientCopy: { key: string; time: number } | null = null;
function copyRoleColor(colorStrings: any, fallbackColor?: string | null) {
const isGradient = !!(colorStrings?.primaryColor && colorStrings?.secondaryColor);
if (!isGradient) {
const value = colorStrings?.primaryColor ?? fallbackColor;
if (!value) return;
clipboard.setString(value);
showToast("Copied role color to clipboard", getAssetIDByName("ic_message_copy"));
lastGradientCopy = null;
return;
}
const key = JSON.stringify(colorStrings);
const now = Date.now();
if (lastGradientCopy?.key === key && now - lastGradientCopy.time < GRADIENT_TOGGLE_WINDOW_MS) {
clipboard.setString(formatReadable(colorStrings));
showToast("Copied readable colors to clipboard", getAssetIDByName("ic_message_copy"));
lastGradientCopy = null;
} else {
clipboard.setString(key);
showToast("Copied gradient JSON - press again for readable text", getAssetIDByName("ic_message_copy"));
lastGradientCopy = { key, time: now };
}
}
function patchRolePill(): () => void {
// "RolePill" on current builds, "ThemedRolePill" on older ones.
const RolePillComponent = findByName("RolePill", false) ?? findByName("ThemedRolePill", false);
if (!RolePillComponent) return () => {};
const GuildRoleStore = findByStoreName("GuildRoleStore");
return after("default", RolePillComponent, (_args: any[], res: any) => {
try {
if (!res?.props?.onPress) return;
const verifiedIcon = findInReactTree(res, (m) => m?.props?.roleColor);
const roleIcon = findInReactTree(
res,
(m) => m?.props?.style?.[0]?.borderRadius && typeof m?.props?.style?.[1]?.backgroundColor === "string" && m.props.style[1].backgroundColor.startsWith("#"),
);
const color = roleIcon?.props?.style?.[1]?.backgroundColor ?? verifiedIcon?.props?.roleColor;
if (!color) return;
// The pill only gives us a flattened color from its rendered style - fetching the real
// role record (if guildId/roleId are present on it) is what actually exposes the gradient.
const guildId = res.props?.guildId;
const roleId = res.props?.roleId ?? res.props?.role?.id;
const role = guildId && roleId ? GuildRoleStore?.getRole?.(guildId, roleId) : null;
res.props.onLongPress = () => copyRoleColor(role?.colorStrings, color);
} catch {
// Leave the role pill without a long-press handler.
}
});
}
// Covers role pills in a full profile's Roles section (UserProfileRolesCard -> RolesList ->
// RoleItem, none of which are top-level exports, hence createElementIntercept).
function patchProfileRoleItem(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
registerTypeDetector(
"copy-role-color-role-item",
(type) => typeof type === "function" && type.name === "RoleItem",
(RoleItem: any) => {
const PatchedRoleItem = (props: any) => {
const ret = RoleItem(props);
try {
const colorStrings = props?.role?.colorStrings;
const fallback = props?.role?.colorString;
if ((colorStrings?.primaryColor || fallback) && ret && typeof ret === "object") {
ret.props = {
...ret.props,
onLongPress: () => copyRoleColor(colorStrings, fallback)
};
}
} catch {
// Leave the role item without a long-press handler.
}
return ret;
};
registerIntercept(RoleItem, PatchedRoleItem);
}
);
return () => cleanups.forEach((fn) => fn());
}
import { id } from "@vendetta/plugin";
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
if (enforceBlacklistOnLoad(id)) return;
unpatchAll = applyPatches("CopyRoleColor", logger, {
"role pill long-press": patchRolePill,
"profile role item long-press": patchProfileRoleItem
});
},
onUnload: () => unpatchAll()
};

View file

@ -0,0 +1,8 @@
{
"name": "Custom User Tags",
"description": "Create your own tags (text, color, and icon) and assign them to specific users - long-press a name in the member list or profile popout to manage one, or add one by user ID in settings. Includes a color picker with hex/RGB support and a library of custom icons.",
"authors": [
{ "name": "Rosie" }
],
"main": "src/index.ts"
}

View file

@ -0,0 +1,30 @@
import { logger } from "@vendetta";
import { storage, id } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
import patchChat from "./patches/chat";
import patchName from "./patches/name";
import patchDetails from "./patches/details";
import patchProfile from "./patches/profile";
import patchTag from "./patches/tag";
import Settings from "./ui/Settings";
let unpatchAll: () => void = () => {};
export default {
onLoad: () => {
if (enforceBlacklistOnLoad(id)) return;
storage.tags ??= {};
unpatchAll = applyPatches("Custom User Tags", logger, {
tag: patchTag,
chat: patchChat,
name: patchName,
details: patchDetails,
profile: patchProfile
});
},
onUnload: () => unpatchAll(),
settings: Settings
};

View file

@ -0,0 +1,100 @@
import { findByProps } from "@vendetta/metro";
export interface IconDef {
id: string;
name: string;
fallback: string;
path?: string;
viewBox?: string;
svg?: string;
}
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
export const MAX_CUSTOM_SVG_LENGTH = 20000;
export function isValidCustomSvg(markup: string | undefined): boolean {
const trimmed = markup?.trim();
if (!trimmed || trimmed.length > MAX_CUSTOM_SVG_LENGTH || !/^<svg/i.test(trimmed)) return false;
try {
svgModule?.parse?.(trimmed);
return true;
} catch {
return false;
}
}
export const ICONS: IconDef[] = [
{ id: "none", name: "No icon", path: "", fallback: "" },
{ id: "star", name: "Star", path: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z", fallback: "★" },
{ id: "heart", name: "Heart", path: "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z", fallback: "♥" },
{ id: "crown", name: "Crown", path: "M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm14 3c0 .6-.4 1-1 1H6c-.6 0-1-.4-1-1v-1h14v1z", fallback: "♛" },
{ id: "gem", name: "Gem", path: "M12 2l8 5-8 15-8-15 8-5z", fallback: "💎" },
{ id: "shield", name: "Shield", path: "M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z", fallback: "🛡" },
{ id: "bolt", name: "Bolt", path: "M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 11.07 6 13.5 6c.46 0 .85.33.96.79L15 10h3.5c.49 0 .64.32.45.66-.19.34-.05.08-.07.12C17.52 14.06 15.07 19 13 19l-1 2z", fallback: "⚡" },
{ id: "flame", name: "Flame", path: "M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73 0-2.15.74-4.8.74-4.8C5.26 3.12 2 7.05 2 12c0 5.52 4.48 10 10 10s10-4.48 10-10c0-4.95-3.26-8.88-7.5-11.33z", fallback: "🔥" },
{ id: "check", name: "Check", path: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z", fallback: "✓" },
{ id: "cross", name: "Cross", path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z", fallback: "✕" },
{ id: "bell", name: "Bell", path: "M18 16v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z", fallback: "🔔" },
{ id: "pin", name: "Pin", path: "M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z", fallback: "📌" },
{ id: "lock", name: "Lock", path: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z", fallback: "🔒" },
{ id: "key", name: "Key", path: "M12.65 10a5.998 5.998 0 0 0-6.88-3.88c-2.29.46-4.15 2.29-4.63 4.58A6.006 6.006 0 0 0 7 18a5.99 5.99 0 0 0 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z", fallback: "🔑" },
{ id: "eye", name: "Eye", path: "M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z", fallback: "👁" },
{ id: "flag", name: "Flag", path: "M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z", fallback: "🚩" },
{ id: "tag", name: "Tag", path: "M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z", fallback: "🏷" },
{ id: "badge", name: "Badge", path: "M20.5 6c-2.61.7-5.67 1-8.5 1s-5.89-.3-8.5-1L3 8c1.86.5 4 .83 6 1v13h2v-6h2v6h2V9c2-.17 4.14-.5 6-1l-.5-2zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z", fallback: "🎖" },
{ id: "robot", name: "Robot", path: "M20 9V7c0-1.1-.9-2-2-2h-3.2c-.5-1.9-2.2-3.3-4.2-3.3S7.9 3.1 7.4 5H4.2C3 5 2 6 2 7.2v9.6C2 18 3 19 4.2 19h.2v1c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1h8v1c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1h.2c1.2 0 2.2-1 2.2-2.2V9h-2zm-4 7H8v-2h8v2zm0-4H8V9h8v3z", fallback: "🤖" },
{ id: "moon", name: "Moon", path: "M9 2c-1.05 0-2.05.16-3 .46 1.69 1.23 2.8 3.24 2.8 5.54 0 3.87-3.13 7-7 7-1.04 0-2.02-.23-2.9-.64C2.24 18.24 6.36 22 11.5 22c5.5 0 10-4.5 10-10S17 2 11.5 2c-.83 0-1.63.1-2.39.29C9.32 2.1 9.16 2 9 2z", fallback: "🌙" },
{ id: "sun", name: "Sun", path: "M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79 1.42-1.41zM4 10.5H1v2h3v-2zm13-6.9l-1.41 1.41 1.79 1.79 1.42-1.41-1.8-1.79zM20 10.5v2h3v-2h-3zm-8-5c-3.87 0-7 3.13-7 7s3.13 7 7 7 7-3.13 7-7-3.13-7-7-7zm0 12c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm-4 6.91v3h2v-3h-2zm6 0h-2v3h2v-3z", fallback: "☀" },
{ id: "cloud", name: "Cloud", path: "M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z", fallback: "☁" },
{ id: "music", name: "Music", path: "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z", fallback: "🎵" },
{ id: "mic", name: "Mic", path: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zM17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z", fallback: "🎤" },
{ id: "gamepad", name: "Gamepad", path: "M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10 7H8v3H6v-3H3v-2h3V8h2v3h3v2zm4.5 2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4-3c-.83 0-1.5-.67-1.5-1.5S18.67 9 19.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z", fallback: "🎮" },
{ id: "diamond", name: "Diamond", path: "M19 3H5L2 9l10 12L22 9l-3-6zM7.43 9.14 9.18 5h2.14l1.75 4.14H7.43zm5.47 0h3.9l1.75-4.14h-2.14l-1.75 4.14zM12 17.5l-3.14-4.36h6.28L12 17.5z", fallback: "♦" },
{ id: "spade", name: "Spade", path: "M12 2C9 7 4 9.91 4 14c0 2.76 1.79 5.02 4.28 5.83L7 23h10l-1.28-3.17C18.21 19.02 20 16.76 20 14c0-4.09-5-7-8-12z", fallback: "♠" },
{ id: "anchor", name: "Anchor", path: "M19 13v6c0 1.1-.9 2-2 2H7c-1.1 0-2-.9-2-2v-6c0-1.1.9-2 2-2h2.2c.55-1.84 2.2-3.15 4.05-3.19.23-1.22 1.12-2.22 2.3-2.5V3h2v2.11c1.18.28 2.07 1.28 2.3 2.5 1.85.04 3.5 1.35 4.05 3.19H17c1.1 0 2 .9 2 2z", fallback: "⚓" },
{ id: "feather", name: "Feather", path: "M20.41 3c-2.04 0-3.92.64-5.48 1.72-.94.66-1.72 1.47-2.32 2.4C11.92 6.41 10.56 6 9.12 6c-2.97 0-5.69 1.66-7.05 4.3l13.93 13.93C18.64 22.59 20.3 19.87 20.3 17c0-1.44-.41-2.8-1.12-3.95.93-.6 1.74-1.38 2.4-2.32C22.66 9.85 23.31 7.97 23.31 6c0-.33-.02-.65-.06-.97l-2.84-2.03z", fallback: "🪶" },
{ id: "leaf", name: "Leaf", path: "M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.48.17.98.3 1.34.3C19 20 22 3 22 3c-1 2-3 5-5 5z", fallback: "🍃" },
{ id: "flower", name: "Flower", path: "M12 22c4.97 0 9-4.03 9-9-4.97 0-9-4.03-9-9-4.97 0-9 4.03-9 9 0 4.97 4.03 9 9 9zm0-16c3.87 0 7 3.13 7 7s-3.13 7-7 7-7-3.13-7-7 3.13-7 7-7z", fallback: "❀" },
{ id: "snowflake", name: "Snowflake", path: "M22 11h-2.65l1.76-1.76-1.41-1.41L17.24 9.88V7h-2v2.88l-2.47-2.47-1.41 1.41L13.35 11H11V8.35l1.76-1.76-1.41-1.41L9.88 6.76V4h-2v2.76L5.41 4.18 4 5.59 5.76 7.35H3v2h2.65l-1.76 1.76 1.41 1.41L6.76 11.12V14h2v-2.88l2.47 2.47 1.41-1.41L10.65 11H13v2.65l-1.76 1.76 1.41 1.41 2.47-2.47V14h2v-2.88l2.47 2.47 1.41-1.41L19.35 13H22v-2z", fallback: "❄" },
{ id: "zap", name: "Zap", path: "M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 11.07 6 13.5 6c.46 0 .85.33.96.79L15 10h3.5c.49 0 .64.32.45.66-.19.34-.05.08-.07.12C17.52 14.06 15.07 19 13 19l-1 2z", fallback: "⚡" },
{ id: "planet", name: "Planet", path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z", fallback: "🪐" },
{ id: "rocket", name: "Rocket", path: "M12 2.5s-4.5 2-6.5 6c-2 4-1 9-1 9s3.5-1 5.5-3v4c0 1.66 1.34 3 3 3s3-1.34 3-3v-4c2 2 5.5 3 5.5 3s1-5-1-9c-2-4-6.5-6-6.5-6zm0 3c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z", fallback: "🚀" },
{ id: "ghost", name: "Ghost", path: "M12 2C7.58 2 4 5.58 4 10v11c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-4h10v4c0 .55.45 1 1 1h1c.55 0 1-.45 1-1V10c0-4.42-3.58-8-8-8zm0 3c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z", fallback: "👻" },
{ id: "skull", name: "Skull", path: "M12 2c-4.42 0-8 3.58-8 8 0 2.64 1.28 4.99 3.27 6.46L8 21h8l.73-4.54C18.72 14.99 20 12.64 20 10c0-4.42-3.58-8-8-8z", fallback: "💀" },
{ id: "paw", name: "Paw", path: "M17.5 10.5c.83 0 1.5-.67 1.5-1.5S18.33 7.5 17.5 7.5 16 8.17 16 9s.67 1.5 1.5 1.5zm-11 0c.83 0 1.5-.67 1.5-1.5S7.33 7.5 6.5 7.5 5 8.17 5 9s.67 1.5 1.5 1.5zm5.5 4c-2.28 0-4.32 1.23-5.42 3.05.73 1.34 2.15 2.25 3.79 2.25 1.45 0 2.73-.75 3.48-1.89.37.58.83 1.09 1.37 1.5 1.02.76 2.27 1.18 3.56 1.18 1.46 0 2.8-.52 3.85-1.38C23.2 16.6 20.5 14.5 17.5 14.5c-.83 0-1.61.18-2.32.49-.73-.32-1.53-.49-2.38-.49z", fallback: "🐾" },
{ id: "pizza", name: "Pizza", path: "M12 2C8.43 2 5.23 3.54 3.1 6.04 5.34 15.38 12 22 12 22s6.66-6.62 8.9-15.96C18.77 3.54 15.57 2 12 2zm0 15c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm0-5c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z", fallback: "🍕" },
{ id: "coffee", name: "Coffee", path: "M18.5 3H6c-1.1 0-2 .9-2 2v5.71c0 3.83 2.95 7.04 6.78 7.29 3.96.26 7.22-3.06 7.22-7v-1h.5c1.93 0 3.5-1.57 3.5-3.5S20.43 3 18.5 3z", fallback: "☕" },
{ id: "cake", name: "Cake", path: "M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-7 9c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z", fallback: "🎂" },
{ id: "gift", name: "Gift", path: "M20 6h-2.18c.11-.31.18-.65.18-1 0-1.66-1.34-3-3-3-1.05 0-1.96.63-2.4 1.5-.44-.87-1.35-1.5-2.4-1.5-1.66 0-3 1.34-3 3 0 .35.07.69.18 1H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-5-2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm-7 2h14v2H8V6zm0 4h14v8H8v-8z", fallback: "🎁" },
{ id: "trophy", name: "Trophy", path: "M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h2v2H7v2h10v-2h-2v-2h2c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2z", fallback: "🏆" },
{ id: "medal", name: "Medal", path: "M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h2v10h2V10h8v10h2V10h2c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM4 6h4v2H4V6zm16 0h-4v2h4V6z", fallback: "🏅" },
{ id: "lightbulb", name: "Lightbulb", path: "M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z", fallback: "💡" },
{ id: "battery", name: "Battery", path: "M15.67 4H14V2h-4v2H8.33C7.6 4 7 4.6 7 5.33v15.33C7 21.4 7.6 22 8.33 22h7.33c.74 0 1.34-.6 1.34-1.33V5.33C17 4.6 16.4 4 15.67 4z", fallback: "🔋" },
{ id: "wifi", name: "Wifi", path: "M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3a4.237 4.237 0 0 0-6 0zm-4-4l2 2a7.074 7.074 0 0 1 10 0l2-2C15.14 9.14 8.87 9.14 5 13z", fallback: "📶" },
{ id: "code", name: "Code", path: "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z", fallback: "</>" },
{ id: "terminal", name: "Terminal", path: "M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM7.5 15l4.5-3-4.5-3v6zM13 15h5v-2h-5v2z", fallback: "⌘" },
{ id: "bug", name: "Bug", path: "M19 8h-1.81a5.98 5.98 0 0 0-1.19-2.56l1.5-1.5-1.41-1.41-1.5 1.5A5.96 5.96 0 0 0 13 3.06V1h-2v2.06A5.96 5.96 0 0 0 8.91 4.03l-1.5-1.5-1.41 1.41 1.5 1.5A5.98 5.98 0 0 0 6.81 8H5v2h1.09c-.05.33-.09.66-.09 1v1H5v2h1v1c0 .34.04.67.09 1H5v2h1.81c1.04 2.95 3.86 5 7.19 5s6.15-2.05 7.19-5H19v-2h-1.09c.05-.33.09-.66.09-1v-1h1v-2h-1v-1c0-.34-.04-.67-.09-1H19V8zm-4 9c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z", fallback: "🐛" },
{ id: "search", name: "Search", path: "M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z", fallback: "🔍" },
{ id: "home", name: "Home", path: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", fallback: "⌂" },
{ id: "mail", name: "Mail", path: "M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z", fallback: "✉" },
{ id: "phone", name: "Phone", path: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z", fallback: "📞" },
{ id: "pen", name: "Pen", path: "M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z", fallback: "✎" },
{ id: "trash", name: "Trash", path: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z", fallback: "🗑" },
{ id: "clock", name: "Clock", path: "M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v7l5.25 3.15.75-1.23-4.5-2.67z", fallback: "🕐" },
{ id: "calendar", name: "Calendar", path: "M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm0 4H7v2h2v-2zm0-8H7v2h2V6zm4 0h-2v2h2V6zm0 8h-2v2h2v-2zm0-4h-2v2h2v-2z", fallback: "📅" },
{ id: "globe", name: "Globe", path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z", fallback: "🌐" },
{ id: "cog", name: "Cog", path: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.42h-3.84a.484.484 0 0 0-.48.42l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.488.488 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z", fallback: "⚙" },
{ id: "megaphone", name: "Megaphone", path: "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z", fallback: "📣" },
{ id: "cart", name: "Cart", path: "M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zM1 2v2h2l3.6 7.59-1.35 2.45C4.52 15.37 5.48 17 7 17h12v-2H7.42c-.14 0-.25-.11-.21-.26l.04-.15L8.1 13h7.45c.75 0 1.41-.41 1.75-1.03l3.58-6.49A1.003 1.003 0 0 0 20 4H5.21l-.94-2H1zm16 16c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2z", fallback: "🛒" },
{ id: "basket", name: "Basket", path: "M17.21 9l-4.38-6.56a.993.993 0 0 0-1.66 0L6.79 9H2v2h3.05l1.65 8.33A2.01 2.01 0 0 0 8.68 21h6.64c.97 0 1.81-.71 1.96-1.67L18.95 11H22V9h-4.79zM10 5.74 13.76 10H6.24L10 5.74z", fallback: "🧺" },
{ id: "umbrella", name: "Umbrella", path: "M12 2c-5.33 4.55-8 8.48-8 11.8 0 4.98 3.8 8.2 8 8.2s8-3.22 8-8.2C20 10.48 17.33 6.55 12 2zm0 18c-2.21 0-4.39-1.79-4.39-4.38 0-2.22 1.4-4.38 4.39-4.38s4.39 2.16 4.39 4.38c0 2.59-2.18 4.38-4.39 4.38z", fallback: "☂" },
{ id: "anchor2", name: "Anchor 2", path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z", fallback: "◎" }
];
export const ICONS_BY_ID: Record<string, IconDef> = Object.fromEntries(ICONS.map((i) => [i.id, i]));
export function getIcon(id: string | undefined): IconDef | undefined {
if (!id || id === "none") return undefined;
return ICONS_BY_ID[id];
}

View file

@ -0,0 +1,37 @@
import { chroma } from "@vendetta/metro/common";
import { rawColors } from "@vendetta/ui";
import { isValidHex } from "@shared/lib/color";
import { getUserTag, UserTag } from "./tags";
import { getIcon, isValidCustomSvg, IconDef } from "./icons";
export interface ResolvedTag {
text: string;
textColor: any;
backgroundColor: string;
icon?: IconDef;
iconColor: any;
}
function resolveIcon(tag: UserTag): IconDef | undefined {
if (tag.customSvg && isValidCustomSvg(tag.customSvg)) {
return { id: "custom", name: "Custom", fallback: tag.customSvgFallback?.trim() || "", svg: tag.customSvg.trim() };
}
return getIcon(tag.icon);
}
export default function resolveTag(userId: string | undefined): ResolvedTag | undefined {
const tag = getUserTag(userId);
if ((!tag?.text && !tag?.icon && !tag?.customSvg) || !isValidHex(tag.color)) return undefined;
const backgroundColor = tag.color;
const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500;
const icon = resolveIcon(tag);
return {
text: tag.iconOnly && icon ? "" : tag.text || "",
textColor,
backgroundColor,
icon,
iconColor: textColor
};
}

View file

@ -0,0 +1,28 @@
import { storage } from "@vendetta/plugin";
export interface UserTag {
text: string;
color: string;
icon?: string;
customSvg?: string;
customSvgFallback?: string;
iconOnly?: boolean;
}
export function allTags(): Record<string, UserTag> {
storage.tags ??= {};
return storage.tags;
}
export function getUserTag(userId: string | undefined): UserTag | undefined {
if (!userId) return undefined;
return allTags()[userId];
}
export function setUserTag(userId: string, tag: UserTag): void {
allTags()[userId] = tag;
}
export function removeUserTag(userId: string): void {
delete allTags()[userId];
}

View file

@ -0,0 +1,36 @@
import { findByName } from "@vendetta/metro";
import { ReactNative, chroma } from "@vendetta/metro/common";
import { after } from "@vendetta/patcher";
import resolveTag from "../lib/resolveTag";
const getTagProperties = findByName("getTagProperties", false);
// Chat message tags are rendered from plain data (not a patchable element tree), so this can only
// ever produce a solid-color tag here - same platform limitation Staff Tags documents for the same
// reason.
export default () => {
if (!getTagProperties) return () => {};
return after("default", getTagProperties, ([{ message }], ret) => {
if (ret?.tagType || ret?.__revengeCustomTag) return;
const tag = resolveTag(message?.author?.id);
if (!tag) return;
const tagText = tag.icon
? tag.text
? `${tag.icon.fallback} ${tag.text}`
: tag.icon.fallback
: tag.text;
return {
...ret,
tagText,
tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined,
tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()),
tagVerified: false,
tagType: undefined,
__revengeCustomTag: true
};
});
};

View file

@ -0,0 +1,84 @@
import { findByProps, findByTypeNameAll } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import resolveTag from "../lib/resolveTag";
import openTagEditor from "../ui/TagEditorAlert";
const TagModule = findByProps("getBotLabel");
// Covers member list rows and the profile popout, plus the long-press hook for assigning a tag
// (no confirmed way to hook Discord's own user context menu).
const rowPatch = ([{ user }]: any[], res: any) => {
try {
if (user?.id && res?.props) {
const existingLongPress = res.props.onLongPress;
res.props.onLongPress = (...args: any[]) => {
existingLongPress?.(...args);
openTagEditor(user.id, user.globalName || user.username || user.id);
};
}
const label = res?.props?.label;
const nameContainer = findInReactTree(
label,
(c) =>
Array.isArray(c?.props?.children) &&
c.props.children.some(
(ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string"
)
);
if (!nameContainer) return;
if (findInReactTree(nameContainer, (c) => c?.props?.__revengeCustomTag)) return;
const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types);
if (existingTag && existingTag.props?.type !== 0) return;
const tag = resolveTag(user?.id);
if (!tag) return;
if (existingTag) {
Object.assign(existingTag.props, {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: false,
__revengeCustomTag: true
});
return;
}
if (!Array.isArray(nameContainer.props.children)) {
nameContainer.props.children = [nameContainer.props.children];
}
if (TagModule) {
nameContainer.props.children.push(
<TagModule.default
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
__revengeCustomTag={true}
/>
);
}
} catch {
// Never let one bad lookup take down the whole member list.
}
};
export default () => {
const patches: (() => void)[] = [];
const rows = findByTypeNameAll("UserRow") ?? [];
rows.forEach((UserRow: any) => patches.push(after("type", UserRow, rowPatch)));
return () => patches.forEach((unpatch) => unpatch());
};

View file

@ -0,0 +1,59 @@
import { findByProps } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import resolveTag from "../lib/resolveTag";
const TagModule = findByProps("getBotLabel");
// findByName("DisplayName") doesn't survive minification - findByProps looks at the property key
// instead.
const DisplayNameModule = findByProps("DisplayName") as any;
export default () => {
if (!DisplayNameModule?.DisplayName) return () => {};
return after("DisplayName", DisplayNameModule, ([{ user }]: any[], ret: any) => {
try {
if (findInReactTree(ret, (c) => c?.props?.__revengeCustomTag)) return;
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
if (tagComponent && tagComponent.props?.type !== 0) return;
const tag = resolveTag(user?.id);
if (!tag) return;
if (tagComponent) {
tagComponent.props = {
type: 0,
text: tag.text,
textColor: tag.textColor,
backgroundColor: tag.backgroundColor,
icon: tag.icon,
iconColor: tag.iconColor,
verified: false,
__revengeCustomTag: true
};
return;
}
const row = findInReactTree(ret, (c) => c?.props?.style?.flexDirection === "row");
if (!Array.isArray(row?.props?.children) || !TagModule) return;
row.props.children.push(
<TagModule.default
style={{ marginLeft: 0 }}
type={0}
text={tag.text}
textColor={tag.textColor}
backgroundColor={tag.backgroundColor}
icon={tag.icon}
iconColor={tag.iconColor}
verified={false}
__revengeCustomTag={true}
/>
);
} catch {
// Never let one bad lookup take down every name row on screen.
}
});
};

View file

@ -0,0 +1,48 @@
import { findInReactTree } from "@vendetta/utils";
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
import resolveTag from "../lib/resolveTag";
import CustomTag from "../ui/Tag";
// Same approach as Staff Tags' patches/profile.tsx: UserProfilePrimaryInfo builds the name row but
// isn't a top-level export, so we grab it via createElementIntercept. Unlike Staff Tags, resolveTag
// only needs a userId, no guild context, so we don't bother reading guildId here.
export default function patchProfile(): () => void {
const cleanups: (() => void)[] = [];
patchCreateElement(cleanups);
registerTypeDetector(
"custom-user-tags-profile-primary-info",
(type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo",
(UserProfilePrimaryInfo: any) => {
const PatchedUserProfilePrimaryInfo = (props: any) => {
const ret = UserProfilePrimaryInfo(props);
try {
const userId: string | undefined = props?.user?.id;
if (!userId) return ret;
const tag = resolveTag(userId);
if (!tag) return ret;
const row = findInReactTree(
ret,
(c: any) =>
Array.isArray(c?.props?.children) &&
c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns")
);
if (!Array.isArray(row?.props?.children)) return ret;
row.props.children.push(<CustomTag tag={tag} />);
} catch {
// Skip - a broken profile tag beats a crashed profile screen.
}
return ret;
};
registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
}
);
return () => cleanups.forEach((fn) => fn());
}

View file

@ -0,0 +1,33 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { findByProps } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { findInReactTree } from "@vendetta/utils";
import Icon from "../ui/Icon";
const Tag = findByProps("getBotLabel");
const { View, Text } = ReactNative;
// Discord's Tag component ignores custom text/color props unless pushed into the already-rendered
// tree directly.
export default () => {
if (!Tag) return () => {};
return after("default", Tag, ([{ text, textColor, backgroundColor, icon, iconColor }], ret) => {
const label = findInReactTree(ret, (c) => typeof c?.props?.children === "string");
if (!label) return;
if (text != null) label.props.children = text;
if (textColor) label.props.style?.push?.({ color: textColor });
if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor });
if (icon?.path || icon?.svg) {
const style = label.props.style ?? {};
ret.props.children = (
<View style={{ flexDirection: "row", alignItems: "center" }}>
<Icon icon={icon} size={12} color={iconColor ?? textColor} style={{ marginRight: text ? 4 : 0 }} />
<Text style={style}>{text != null ? text : label.props.children}</Text>
</View>
);
}
});
};

View file

@ -0,0 +1,264 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { isValidHex, normalizeHex } from "@shared/lib/color";
import { resolveSemanticColorSafe } from "@shared/lib/color";
const { View, Text, TextInput, TouchableOpacity } = ReactNative;
interface ColorInputProps {
title: string;
value?: string;
placeholder?: string;
onChange: (value: string) => void;
}
const PRESETS = [
"#F23F42", "#F0B232", "#FEE75C", "#23A55A",
"#1ABC9C", "#5865F2", "#9B59B6", "#EB459E",
"#99AAB5", "#FFFFFF",
"#FF6B6B", "#FF9F43", "#FDCB6E", "#00B894",
"#00CEC9", "#0984E3", "#6C5CE7", "#FD79A8",
"#A29BFE", "#DFE6E9", "#636E72", "#2D3436",
"#E17055", "#D63031", "#74B9FF", "#A8E6CF",
"#FDFFAB", "#FFB7B2", "#FF8B94", "#C7CEEA",
"#FF0000", "#00FF00", "#0000FF", "#FFFF00",
"#00FFFF", "#FF00FF", "#000000", "#808080"
];
function clamp(n: number, min: number, max: number) {
return Math.max(min, Math.min(max, Math.round(n)));
}
function hexToRgb(hex: string): [number, number, number] {
const full = normalizeHex(hex).slice(1);
return [
parseInt(full.slice(0, 2), 16),
parseInt(full.slice(2, 4), 16),
parseInt(full.slice(4, 6), 16)
];
}
function rgbToHex(r: number, g: number, b: number): string {
return "#" + [r, g, b].map((n) => clamp(n, 0, 255).toString(16).padStart(2, "0")).join("");
}
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const d = max - min;
let h = 0;
if (d !== 0) {
if (max === r) h = ((g - b) / d + 6) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h *= 60;
}
return [h, max === 0 ? 0 : d / max, max];
}
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
const c = v * s;
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
const m = v - c;
let [r, g, b] = [0, 0, 0];
if (h < 60) [r, g, b] = [c, x, 0];
else if (h < 120) [r, g, b] = [x, c, 0];
else if (h < 180) [r, g, b] = [0, c, x];
else if (h < 240) [r, g, b] = [0, x, c];
else if (h < 300) [r, g, b] = [x, 0, c];
else [r, g, b] = [c, 0, x];
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
}
function rainbow(count: number): string[] {
return Array.from({ length: count }, (_, i) => rgbToHex(...hsvToRgb((i * 360) / count, 1, 1)));
}
function shades(base: string, count: number): string[] {
const [h, s, v] = rgbToHsv(...hexToRgb(base));
return Array.from({ length: count }, (_, i) => rgbToHex(...hsvToRgb(h, s, 0.2 + (0.8 * i) / (count - 1))));
}
function randomHex(): string {
return rgbToHex(Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256));
}
export default function ColorInput({ title, value, placeholder, onChange }: ColorInputProps) {
const normalized = value ? normalizeHex(value) : "";
const valid = !value || isValidHex(value);
const swatchColor = isValidHex(value) ? normalized : (isValidHex(placeholder) ? normalizeHex(placeholder) : "#5865F2");
const [r, g, b] = hexToRgb(swatchColor);
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
const updateHex = (text: string) => {
const t = text.trim().toLowerCase();
if (!t) {
onChange("");
return;
}
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(t)) {
onChange(t.toUpperCase());
} else {
onChange(text);
}
};
const updateRgb = (component: "r" | "g" | "b", raw: string) => {
const num = parseInt(raw, 10);
if (Number.isNaN(num)) return;
const next = [r, g, b];
if (component === "r") next[0] = num;
else if (component === "g") next[1] = num;
else next[2] = num;
onChange(rgbToHex(...next as [number, number, number]));
};
const hueColors = rainbow(12);
const shadeColors = shades(swatchColor, 6);
return (
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<View style={{ flexDirection: "row", alignItems: "center", marginBottom: 8 }}>
<View
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: swatchColor,
marginRight: 10,
borderWidth: 1,
borderColor: "rgba(128,128,128,0.35)"
}}
/>
<Text style={{ fontSize: 15, fontWeight: "600", color: textColor }}>{title}</Text>
</View>
<TextInput
value={value ?? ""}
placeholder={placeholder ?? "#5865F2"}
onChangeText={updateHex}
autoCapitalize="none"
autoCorrect={false}
placeholderTextColor="rgba(128,128,128,0.6)"
style={{
borderWidth: 1,
borderColor: valid ? "rgba(128,128,128,0.35)" : "#F23F42",
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
fontSize: 14,
marginBottom: 6,
color: textColor
}}
/>
{valid && (
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 8 }}>
{(["r", "g", "b"] as const).map((c, i) => (
<View key={c} style={{ flex: 1, marginHorizontal: c === "g" ? 8 : 0 }}>
<Text style={{ fontSize: 11, color: textColor, opacity: 0.7, textTransform: "uppercase", marginBottom: 2 }}>{c}</Text>
<TextInput
value={String([r, g, b][i])}
keyboardType="numeric"
onChangeText={(v: string) => updateRgb(c, v)}
style={{
borderWidth: 1,
borderColor: "rgba(128,128,128,0.35)",
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 6,
fontSize: 13,
color: textColor
}}
/>
</View>
))}
</View>
)}
{valid && (
<View style={{ flexDirection: "row", flexWrap: "wrap", marginBottom: 4 }}>
{shadeColors.map((color) => (
<TouchableOpacity
key={color}
onPress={() => onChange(color.toUpperCase())}
style={{
width: 28,
height: 28,
borderRadius: 6,
backgroundColor: color,
marginRight: 6,
marginBottom: 6,
borderWidth: value?.toLowerCase() === color.toLowerCase() ? 2 : 1,
borderColor: value?.toLowerCase() === color.toLowerCase() ? "#ffffff" : "rgba(128,128,128,0.35)"
}}
/>
))}
</View>
)}
{valid && (
<View style={{ flexDirection: "row", flexWrap: "wrap", marginBottom: 4 }}>
{hueColors.map((color) => (
<TouchableOpacity
key={color}
onPress={() => onChange(color.toUpperCase())}
style={{
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: color,
marginRight: 6,
marginBottom: 6,
borderWidth: value?.toLowerCase() === color.toLowerCase() ? 2 : 1,
borderColor: value?.toLowerCase() === color.toLowerCase() ? "#ffffff" : "rgba(128,128,128,0.35)"
}}
/>
))}
</View>
)}
<TouchableOpacity
onPress={() => onChange(randomHex())}
style={{
paddingVertical: 8,
paddingHorizontal: 10,
borderRadius: 8,
backgroundColor: "rgba(88,101,242,0.2)",
alignItems: "center",
marginBottom: 8
}}
>
<Text style={{ color: "#5865F2", fontWeight: "600" }}>Random color</Text>
</TouchableOpacity>
<View style={{ flexDirection: "row", flexWrap: "wrap" }}>
{PRESETS.map((preset) => {
const active = value?.toLowerCase() === preset.toLowerCase();
return (
<TouchableOpacity
key={preset}
onPress={() => onChange(preset)}
style={{
width: 24,
height: 24,
borderRadius: 6,
backgroundColor: preset,
marginRight: 8,
marginBottom: 8,
borderWidth: active ? 2 : 1,
borderColor: active ? "#ffffff" : "rgba(128,128,128,0.35)"
}}
/>
);
})}
</View>
{!valid && (
<Text style={{ color: "#F23F42", marginTop: 2, fontSize: 12 }}>
Invalid hex color, e.g. #5865F2
</Text>
)}
</View>
);
}

View file

@ -0,0 +1,28 @@
import { React } from "@vendetta/metro/common";
import { findByProps } from "@vendetta/metro";
import { IconDef } from "../lib/icons";
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
const Svg = svgModule?.Svg ?? (() => null);
const Path = svgModule?.Path ?? (() => null);
const SvgXml = svgModule?.SvgXml ?? (() => null);
interface IconProps {
icon: IconDef;
size?: number;
color?: string;
style?: any;
}
export default function Icon({ icon, size = 12, color = "#fff", style }: IconProps) {
// Custom SVGs keep their own authored colors - no fill override, unlike preset icons below.
if (icon.svg) return <SvgXml xml={icon.svg} width={size} height={size} style={style} />;
if (!icon.path) return null;
const viewBox = icon.viewBox ?? "0 0 24 24";
return (
<Svg width={size} height={size} viewBox={viewBox} fill={color} style={style}>
<Path d={icon.path} />
</Svg>
);
}

View file

@ -0,0 +1,57 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { resolveSemanticColorSafe } from "@shared/lib/color";
import { ICONS } from "../lib/icons";
import Icon from "./Icon";
const { View, Text, TouchableOpacity, ScrollView } = ReactNative;
interface IconPickerProps {
title?: string;
value?: string;
onChange: (value: string) => void;
color?: string;
}
export default function IconPicker({ title = "Icon", value, onChange, color = "#5865F2" }: IconPickerProps) {
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
const iconColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
return (
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
<Text style={{ fontSize: 15, fontWeight: "600", color: textColor, marginBottom: 8 }}>{title}</Text>
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 8 }}>
<View style={{ flexDirection: "row" }}>
{ICONS.map((icon) => {
const active = value === icon.id;
return (
<TouchableOpacity
key={icon.id}
onPress={() => onChange(icon.id)}
style={{
width: 40,
height: 40,
borderRadius: 8,
alignItems: "center",
justifyContent: "center",
marginRight: 8,
backgroundColor: active ? color : "rgba(128,128,128,0.15)",
borderWidth: active ? 2 : 1,
borderColor: active ? "#ffffff" : "rgba(128,128,128,0.35)"
}}
>
{icon.path ? (
<Icon icon={icon} size={20} color={active ? "#ffffff" : iconColor} />
) : (
<Text style={{ fontSize: 12, color: active ? "#ffffff" : iconColor }}>—</Text>
)}
</TouchableOpacity>
);
})}
</View>
</ScrollView>
<Text style={{ fontSize: 13, color: textColor, opacity: 0.7 }}>
{ICONS.find((i) => i.id === value)?.name ?? "No icon"}
</Text>
</View>
);
}

View file

@ -0,0 +1,70 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { useProxy } from "@vendetta/storage";
import { storage } from "@vendetta/plugin";
import { TableRowGroup, TextInput } from "@shared/ui/table";
import SettingsScaffold from "@shared/ui/SettingsScaffold";
import ListSection from "@shared/ui/ListSection";
import PrimaryButton from "@shared/ui/PrimaryButton";
import NoteBox from "@shared/ui/NoteBox";
import { allTags } from "../lib/tags";
import { getIcon } from "../lib/icons";
import openTagEditor from "./TagEditorAlert";
const { View } = ReactNative;
// Opens the same rich editor used for long-press tagging (full SVG/icon-only support) instead of
// keeping a second, simpler add-tag form here that would drift out of sync with it.
function AddTagForm() {
const [userId, setUserId] = React.useState("");
return (
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
<TextInput
label="User ID"
placeholder="long-press a user in Discord's own menu and Copy ID"
value={userId}
onChange={setUserId}
/>
<PrimaryButton
label="Edit tag"
disabled={!userId.trim()}
style={{ marginTop: 8 }}
onPress={() => openTagEditor(userId.trim(), userId.trim())}
/>
</View>
);
}
export default function Settings() {
useProxy(storage);
const tags = allTags();
useProxy(tags);
const userIds = Object.keys(tags);
return (
<SettingsScaffold>
<NoteBox>
Long-press a name in the member list or profile popout to tag someone directly, or add
one here by user ID - turn on Developer Mode (Discord Settings → Advanced) to get a
"Copy ID" option when you long-press a user elsewhere.
</NoteBox>
<TableRowGroup title="Add a tag">
<AddTagForm />
</TableRowGroup>
<ListSection
title="Tagged users"
emptyText="No one's tagged yet."
items={userIds.map((id) => {
const icon = getIcon(tags[id].icon)?.fallback || tags[id].customSvgFallback;
const label = (icon ? `${icon} ${tags[id].text}` : tags[id].text).trim() || id;
return {
key: id,
label,
subLabel: `${id} • Tap to edit`,
onPress: () => openTagEditor(id, tags[id].text || id)
};
})}
/>
</SettingsScaffold>
);
}

View file

@ -0,0 +1,29 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { ResolvedTag } from "../lib/resolveTag";
import Icon from "./Icon";
const { View, Text } = ReactNative;
const baseStyle = {
flexDirection: "row",
alignItems: "center",
borderRadius: 4,
paddingHorizontal: 4,
paddingVertical: 2,
marginLeft: 4,
overflow: "hidden"
};
interface TagProps {
tag: ResolvedTag;
style?: any;
}
export default function CustomTag({ tag, style }: TagProps) {
return (
<View style={[baseStyle, { backgroundColor: tag.backgroundColor }, style]}>
{tag.icon && (tag.icon.path || tag.icon.svg) && <Icon icon={tag.icon} size={12} color={tag.iconColor} style={{ marginRight: tag.text ? 4 : 0 }} />}
{!!tag.text && <Text style={{ color: tag.textColor, fontSize: 11, fontWeight: "700" }}>{tag.text}</Text>}
</View>
);
}

View file

@ -0,0 +1,136 @@
import { React, ReactNative } from "@vendetta/metro/common";
import { findByProps } from "@vendetta/metro";
import { TableSwitchRow, TextInput } from "@shared/ui/table";
import { showToast } from "@vendetta/ui/toasts";
import ColorInput from "./ColorInput";
import IconPicker from "./IconPicker";
import { getUserTag, setUserTag, removeUserTag } from "../lib/tags";
import { isValidCustomSvg, MAX_CUSTOM_SVG_LENGTH } from "../lib/icons";
const { View } = ReactNative;
const ALERT_KEY = "custom-user-tags-editor";
// Looked up lazily (not at module scope) since this repo hasn't needed Discord's native alert system
// before - safer to resolve it right when actually opening the editor than to risk an early miss at
// plugin-load time getting permanently cached.
function alertParts() {
const alerts = findByProps("openAlert", "dismissAlert") as any;
const modal = findByProps("AlertModal", "AlertActions") as any;
return {
openAlert: alerts?.openAlert,
dismissAlert: alerts?.dismissAlert,
AlertModal: modal?.AlertModal,
AlertActions: modal?.AlertActions,
AlertActionButton: modal?.AlertActionButton
};
}
function TagEditor({ userId, username }: { userId: string; username: string }) {
const existing = getUserTag(userId);
const [text, setText] = React.useState(existing?.text ?? "");
const [color, setColor] = React.useState(existing?.color ?? "#5865F2");
const [icon, setIcon] = React.useState(existing?.icon ?? "none");
const [svg, setSvg] = React.useState(existing?.customSvg ?? "");
const [svgFallback, setSvgFallback] = React.useState(existing?.customSvgFallback ?? "");
const [iconOnly, setIconOnly] = React.useState(!!existing?.iconOnly);
const { dismissAlert, AlertModal, AlertActions, AlertActionButton } = alertParts();
if (!AlertModal || !AlertActions || !AlertActionButton) return null;
const hasIcon = icon !== "none" || !!svg.trim();
const save = () => {
if (svg.trim() && !isValidCustomSvg(svg)) {
showToast("Invalid SVG markup", undefined);
return;
}
if (!text.trim() && !hasIcon) {
showToast("Enter some tag text or choose an icon first", undefined);
return;
}
if (iconOnly && !hasIcon) {
showToast("Select an icon before enabling icon-only", undefined);
return;
}
setUserTag(userId, {
text: text.trim(),
color,
icon: svg.trim() ? undefined : icon === "none" ? undefined : icon,
customSvg: svg.trim() || undefined,
customSvgFallback: svgFallback.trim() || undefined,
iconOnly
});
showToast(`Tagged ${username}`, undefined);
dismissAlert?.(ALERT_KEY);
};
const remove = () => {
removeUserTag(userId);
showToast(`Removed ${username}'s tag`, undefined);
dismissAlert?.(ALERT_KEY);
};
return (
<AlertModal
title={`Tag ${username}`}
content={
<View>
<TextInput label="Tag text" placeholder="e.g. FRIEND" value={text} onChange={setText} />
<IconPicker
title="Icon"
value={icon}
onChange={(v: string) => {
setIcon(v);
if (v !== "none") setSvg("");
}}
color={color}
/>
<TextInput
label="Custom SVG"
placeholder="<svg>...</svg>"
value={svg}
multiline
maxLength={MAX_CUSTOM_SVG_LENGTH}
onChange={(v: string) => {
setSvg(v);
if (v) setIcon("none");
}}
/>
{!!svg && (
<TextInput
label="Fallback text for chat"
placeholder="Icons can't render in chat, only member list & profile"
value={svgFallback}
onChange={setSvgFallback}
/>
)}
<TableSwitchRow
label="Icon only"
subLabel="Hide the text label, show just the icon"
value={iconOnly}
disabled={!hasIcon}
onValueChange={setIconOnly}
/>
<ColorInput title="Color" value={color} onChange={setColor} />
</View>
}
actions={
<AlertActions>
<AlertActionButton text="Save" variant="primary" onPress={save} />
{existing && <AlertActionButton text="Remove tag" variant="secondary" onPress={remove} />}
<AlertActionButton text="Cancel" variant="secondary" />
</AlertActions>
}
/>
);
}
export default function openTagEditor(userId: string, username: string): void {
const { openAlert } = alertParts();
if (!openAlert) {
showToast("Couldn't open the tag editor - alert system not found", undefined);
return;
}
openAlert(ALERT_KEY, <TagEditor userId={userId} username={username} />);
}

View file

@ -0,0 +1,116 @@
CC0 1.0 Universal
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator and
subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for the
purpose of contributing to a commons of creative, cultural and scientific
works ("Commons") that the public can reliably and without fear of later
claims of infringement build upon, modify, incorporate in other works, reuse
and redistribute as freely as possible in any form whatsoever and for any
purposes, including without limitation commercial purposes. These owners may
contribute to the Commons to promote the ideal of a free culture and the
further production of creative, cultural and scientific works, or to gain
reputation or greater distribution for their Work in part through the use and
efforts of others.
For these and/or other purposes and motivations, and without any expectation
of additional consideration or compensation, the person associating CC0 with a
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
and publicly distribute the Work under its terms, with knowledge of his or her
Copyright and Related Rights in the Work and the meaning and intended legal
effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not limited
to, the following:
i. the right to reproduce, adapt, distribute, perform, display, communicate,
and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or likeness
depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data in
a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation thereof,
including any amended or successor version of such directive); and
vii. other similar, equivalent or corresponding rights throughout the world
based on applicable law or treaty, and any national implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention of,
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
and Related Rights and associated claims and causes of action, whether now
known or unknown (including existing as well as future claims and causes of
action), in the Work (i) in all territories worldwide, (ii) for the maximum
duration provided by applicable law or treaty (including future time
extensions), (iii) in any current or future medium and for any number of
copies, and (iv) for any purpose whatsoever, including without limitation
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
the Waiver for the benefit of each member of the public at large and to the
detriment of Affirmer's heirs and successors, fully intending that such Waiver
shall not be subject to revocation, rescission, cancellation, termination, or
any other legal or equitable action to disrupt the quiet enjoyment of the Work
by the public as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason be
judged legally invalid or ineffective under applicable law, then the Waiver
shall be preserved to the maximum extent permitted taking into account
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
is so judged Affirmer hereby grants to each affected person a royalty-free,
non transferable, non sublicensable, non exclusive, irrevocable and
unconditional license to exercise Affirmer's Copyright and Related Rights in
the Work (i) in all territories worldwide, (ii) for the maximum duration
provided by applicable law or treaty (including future time extensions), (iii)
in any current or future medium and for any number of copies, and (iv) for any
purpose whatsoever, including without limitation commercial, advertising or
promotional purposes (the "License"). The License shall be deemed effective as
of the date CC0 was applied by Affirmer to the Work. Should any part of the
License for any reason be judged legally invalid or ineffective under
applicable law, such partial invalidity or ineffectiveness shall not
invalidate the remainder of the License, and in such case Affirmer hereby
affirms that he or she will not (i) exercise any of his or her remaining
Copyright and Related Rights in the Work or (ii) assert any associated claims
and causes of action with respect to the Work, in either case contrary to
Affirmer's express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or warranties
of any kind concerning the Work, express, implied, statutory or otherwise,
including without limitation warranties of title, merchantability, fitness
for a particular purpose, non infringement, or the absence of latent or
other defects, accuracy, or the present or absence of errors, whether or not
discoverable, all to the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without limitation
any person's Copyright and Related Rights in the Work. Further, Affirmer
disclaims responsibility for obtaining any necessary consents, permissions
or other rights required for any use of the Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to this
CC0 or use of the Work.
For more information, please see
<http://creativecommons.org/publicdomain/zero/1.0/>

View file

@ -0,0 +1,13 @@
{
"name": "FakeProfileThemesAndEffects",
"description": "Profile theming and profile effects without Nitro, by hiding the color/effect data in your About Me using invisible zero-width characters.",
"authors": [
{ "name": "Rosie" },
{ "name": "ryan" },
{ "name": "シグマ siguma" }
],
"main": "src/index.ts",
"vendetta": {
"icon": "ic_profile_24px"
}
}

View file

@ -0,0 +1,60 @@
import { logger } from "@vendetta";
import { storage, id } from "@vendetta/plugin";
import { applyPatches } from "@shared/lib/patcher";
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
import { FluxDispatcher } from "@fpte/lib/flux";
import { UserProfileStore, UserStore } from "@fpte/lib/stores";
import {
patchGetPurchase,
patchGetUserProfile,
patchUseProfileEffectSections,
patchUseProfileTheme,
patchUserProfileEditForm,
patchNitroUpsellCard
} from "@fpte/patches";
import { Settings } from "@fpte/ui/pages";
/** Updates the profile theme and effect used by YouScreen and BottomTabBar. */
function updateProfileThemeAndEffect() {
const user = UserStore.getCurrentUser();
if (!user) return;
const user_profile = UserProfileStore.getUserProfile(user.id);
if (!user_profile) return;
FluxDispatcher.dispatch({
type: "USER_PROFILE_FETCH_SUCCESS",
user,
user_profile,
connected_accounts: user_profile.connectedAccounts
});
}
let unpatchAll: () => void = () => {};
export default {
onLoad() {
if (enforceBlacklistOnLoad(id)) return;
// The "real" picker chains Discord's own hooks onto this fiber outside React's normal
// reconciliation, risking a "Rendered more/fewer hooks" crash - default to the safer,
// self-contained fallback picker instead.
storage.forceFallbackEffectPicker ??= true;
unpatchAll = applyPatches("FakeProfileThemesAndEffects", logger, {
"purchase check": patchGetPurchase,
"user profile data": patchGetUserProfile,
"profile effect sections": () => {
const unpatches = patchUseProfileEffectSections();
return () => unpatches.forEach((u) => u());
},
"profile theme": patchUseProfileTheme,
"profile edit form": patchUserProfileEditForm,
"nitro upsell card": patchNitroUpsellCard
});
updateProfileThemeAndEffect();
},
onUnload() {
unpatchAll();
updateProfileThemeAndEffect();
},
settings: Settings
};

View file

@ -0,0 +1,4 @@
import type { FluxDispatcher as $FluxDispatcher } from "@vencord/discord-types";
import { FluxDispatcher as Dispatcher } from "@vendetta/metro/common";
export const FluxDispatcher = Dispatcher as $FluxDispatcher;

View file

@ -0,0 +1,138 @@
/* eslint-disable @stylistic/brace-style */
const DELIMITER_CODEPOINT = 0x200B; // zero-width space
const DELIMITER = String.fromCodePoint(DELIMITER_CODEPOINT);
const RADIX = 0x1000; // count of default-ignorable codepoints in the SSP plane
const STARTING_CODEPOINT = 0xE0000; // first codepoint in the SSP plane
const ENDING_CODEPOINT = STARTING_CODEPOINT + RADIX - 1;
/** Legacy format: `[#primary,#accent]`, each codepoint offset by +STARTING_CODEPOINT. */
export function encodeColorsLegacy(primary: number, accent: number) {
return String.fromCodePoint(...[...`[#${primary.toString(16)},#${accent.toString(16)}]`]
.map(c => c.codePointAt(0)! + STARTING_CODEPOINT));
}
/** @returns [primary, accent] colors, or -1 for either if not found. */
export function decodeColorsLegacy(str: string): [primaryColor: number, accentColor: number] {
const [primary, accent] = str.matchAll(/(?<=#)[\dA-Fa-f]{1,6}/g);
return [primary ? parseInt(primary[0], 16) : -1, accent ? parseInt(accent[0], 16) : -1];
}
/** Base-RADIX encoding of a 24-bit color, each codepoint offset by +STARTING_CODEPOINT. */
export function encodeColor(color: number) {
if (color === 0) return String.fromCodePoint(STARTING_CODEPOINT);
let str = "";
for (; color > 0; color = Math.trunc(color / RADIX))
str = String.fromCodePoint(color % RADIX + STARTING_CODEPOINT) + str;
return str;
}
/** @returns the decoded 24-bit color; -1 if str is empty, -2 if over 0xFFFFFF. */
export function decodeColor(str: string) {
if (str === "") return -1;
let color = 0;
for (let i = 0; i < str.length; i++) {
if (color > 0xFFF_FFF) return -2;
color += str.codePointAt(i)! * RADIX ** (str.length - 1 - i);
}
return color;
}
/** Base-RADIX encoding of an effect ID, each codepoint offset by +STARTING_CODEPOINT. */
export function encodeEffect(id: bigint) {
if (id === 0n) return String.fromCodePoint(STARTING_CODEPOINT);
let str = "";
for (; id > 0n; id /= BigInt(RADIX))
str = String.fromCodePoint(Number(id % BigInt(RADIX)) + STARTING_CODEPOINT) + str;
return str;
}
/** @returns the decoded effect ID; -1n if str is empty, -2n if over the max effect ID. */
export function decodeEffect(str: string) {
if (str === "") return -1n;
let id = 0n;
for (let i = 0; i < str.length; i++) {
if (id >= 10_000_000_000_000_000_000n) return -2n;
id += BigInt(str.codePointAt(i)!) * BigInt(RADIX) ** BigInt(str.length - 1 - i);
}
return id;
}
/**
* Builds an FPTE string from primary/accent colors (negative = unset) and an effect ID (empty =
* unset). In legacy mode, colors are legacy-encoded and both are always included (accent falls
* back to primary, or vice versa); otherwise a shared/unset color is omitted to save space.
*/
export function buildFPTE(primary: number, accent: number, effect: string, legacy: boolean) {
let fpte = "";
if (legacy) {
if (primary >= 0) {
fpte = accent >= 0 ? encodeColorsLegacy(primary, accent) : encodeColorsLegacy(primary, primary);
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
if (accent >= 0) {
fpte = encodeColorsLegacy(accent, accent);
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
}
else if (primary >= 0) {
fpte = encodeColor(primary);
if (accent >= 0 && primary !== accent) {
fpte += DELIMITER + encodeColor(accent);
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
}
else if (accent >= 0) fpte = encodeColor(accent);
// Only one (or no) color was written above, so the effect needs a double delimiter to mark
// the skipped slot.
if (effect)
fpte += DELIMITER + DELIMITER + encodeEffect(BigInt(effect));
return fpte;
}
/** Extracts the delimiter-separated values of the first FPTE substring in a string. */
export function extractFPTE(str: string) {
const fpte: [maybePrimaryOrLegacy: string, maybeAccentOrEffect: string, maybeEffect: string] = ["", "", ""];
let i = 0;
for (const char of str) {
const cp = char.codePointAt(0)!;
if (cp === DELIMITER_CODEPOINT) {
if (i >= 2) break;
i++;
}
else if (cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT)
fpte[i]! += String.fromCodePoint(cp - STARTING_CODEPOINT);
else if (i > 0 || fpte[0]) break;
}
return fpte;
}
/** Detects an FPTE substring by the presence of FPTE codepoints or the delimiter. */
export function hasFPTE(str: string): boolean {
for (const char of str) {
const cp = char.codePointAt(0)!;
if ((cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT) || cp === DELIMITER_CODEPOINT) {
return true;
}
}
return false;
}
export function stripFPTE(str: string) {
return [...str]
.filter(ch => {
const cp = ch.codePointAt(0)!;
return (cp < STARTING_CODEPOINT || cp > ENDING_CODEPOINT) && cp !== DELIMITER_CODEPOINT;
})
.join("")
.trim();
}

View file

@ -0,0 +1,12 @@
import { findByProps } from "@vendetta/metro";
// Must use `as` or else `undefined` is lost due to a TS bug
const hapticsModule = findByProps("triggerHapticFeedback") as Record<string, any> | undefined;
export type FeebackKey = "IMPACT_LIGHT" | "IMPACT_MEDIUM" | "IMPACT_HEAVY" | "NOTIFICATION_ERROR" | "DRAG_AND_DROP_START" | "DRAG_AND_DROP_END" | "DRAG_AND_DROP_MOVE";
export const HapticFeebackTypes: Record<FeebackKey, number>
= hapticsModule?.HapticFeedbackTypes ?? {};
export const triggerHapticFeedback: (feedbackType?: number | undefined) => void
= hapticsModule?.triggerHapticFeedback ?? (() => undefined);

View file

@ -0,0 +1,171 @@
import type React from "react";
import { isIterable, isNonNullObject, type OmitCallSignature } from "@fpte/lib/utils";
export namespace RN {
export type Fragment<P extends object = object> = Element<P, symbol>;
export interface ConsumerProps<T = unknown> {
children: (value: T) => Node;
}
export interface ProviderProps<T = unknown> {
value: T;
children?: Node;
}
export type ConsumerType<T = unknown> = React.Context<T> & OmitCallSignature<React.NamedExoticComponent<ConsumerProps<T>>>;
export interface ProviderType<T = unknown> extends OmitCallSignature<React.ProviderExoticComponent<ProviderProps<T>>> {
_context: React.Context<T>;
}
export interface ForwardRefType<T = unknown, P extends object = object> extends OmitCallSignature<React.ForwardRefExoticComponent<P>> {
render: React.ForwardRefRenderFunction<T, P>;
}
export interface MemoType<T extends ElementType<any> = ElementType> extends OmitCallSignature<React.NamedExoticComponent<ComponentPropsWithRef<T>>> {
readonly type: T;
}
export interface Component<P, S> extends Omit<React.Component<P, S>, "render"> {
render: () => Node;
}
export interface ComponentClass<P extends object = object, S = React.ComponentState> extends OmitCallSignature<React.ComponentClass<P, S>> {
new (props: P, context?: any): Component<P, S>;
}
export interface FunctionComponent<P extends object = object> extends OmitCallSignature<React.FunctionComponent<P>> {
(props: P, context?: any): Node;
}
export type ComponentType<P extends object = object> = ComponentClass<P> | FunctionComponent<P>;
export type ElementType<P extends object = object> = ComponentType<P> | symbol | ConsumerType | ProviderType | ForwardRefType<unknown, P> | MemoType<ElementType<P>>;
export interface Element<P extends object = object, T extends ElementType = ElementType> {
type: T;
props: P;
key: React.Key | null;
}
export type Node = Iterable<Node> | Element | string | number | boolean | null | undefined;
export type PropsWithChildren<P extends object = object> = P & { children: Node; };
export type ComponentProps<T extends ElementType> = T extends ElementType<infer P> ? P : object;
export type ComponentPropsWithRef<T extends ElementType> =
T extends React.ComponentClass<infer P>
? React.PropsWithoutRef<P> & React.RefAttributes<InstanceType<T>>
: React.PropsWithRef<ComponentProps<T>>;
}
export function isElement(arg: RN.Node): arg is RN.Element {
return isNonNullObject(arg) && "type" in arg;
}
export function isElementWithChildren<T extends RN.Element>(arg: T | RN.Element<RN.PropsWithChildren>): arg is RN.Element<RN.PropsWithChildren> {
return isNonNullObject(arg.props) && "children" in arg.props;
}
export function isProviderType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ProviderType {
return "_context" in arg;
}
export function isForwardRefType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ForwardRefType {
return "render" in arg;
}
export function isMemoType(arg: Exclude<RN.ElementType, symbol>): arg is RN.MemoType {
return "type" in arg;
}
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/** @see {@link https://github.com/facebook/react-native/blob/d724007364c4315e2cabace0fc6eae6e6212431d/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js#L3130-L3212} */
export function getComponentNameFromType(type: RN.ElementType) {
if (typeof type === "symbol")
return Symbol.keyFor(type) || null;
if (typeof type === "function")
return type.displayName || type.name || null;
// Host components (View, Text, ...) are plain strings, not objects - isProviderType and the
// checks after it all use the `in` operator, which throws on a string primitive rather than
// returning false. This is the exact same bug class as isElementWithChildren's props: null
// crash, just a second, separate instance in this same file.
if (typeof type !== "object" || type === null)
return null;
if (isProviderType(type))
return type._context.displayName || null;
if (type.displayName)
return type.displayName;
if (isForwardRefType(type))
return type.render.displayName || type.render.name || null;
if (isMemoType(type))
return getComponentNameFromType(type.type);
return null;
}
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
export function findElementInTree<T extends RN.Element>(
tree: RN.Node,
filter: (element: RN.Element) => element is T,
maxDepth?: number | undefined
): T | null;
export function findElementInTree(
tree: RN.Node,
filter: (element: RN.Element) => unknown,
maxDepth?: number | undefined
): RN.Element | null;
export function findElementInTree(
tree: RN.Node,
filter: (element: RN.Element) => unknown,
maxDepth = 200
): RN.Element | null {
if (isNonNullObject(tree)) {
if (isIterable(tree)) {
if (maxDepth > 0) {
for (const node of tree) {
const foundElement = findElementInTree(node, filter, maxDepth - 1);
if (foundElement) return foundElement;
}
}
} else {
if (filter(tree)) return tree;
if (isElementWithChildren(tree))
return findElementInTree(tree.props.children, filter, maxDepth - 1);
}
}
return null;
}
export function findParentInTree<T extends RN.Node>(
tree: RN.Node,
filter: (children: RN.Node) => children is T,
maxDepth?: number | undefined
): RN.Element<{ children: T; }> | null;
export function findParentInTree(
tree: RN.Node,
filter: (children: RN.Node) => unknown,
maxDepth?: number | undefined
): RN.Element<RN.PropsWithChildren> | null;
export function findParentInTree(
tree: RN.Node,
filter: (children: RN.Node) => unknown,
maxDepth = 200
): RN.Element<RN.PropsWithChildren> | null {
if (isNonNullObject(tree)) {
if (isIterable(tree)) {
if (maxDepth > 0) {
for (const node of tree) {
const foundParent = findParentInTree(node, filter, maxDepth - 1);
if (foundParent) return foundParent;
}
}
} else if (isElementWithChildren(tree)) {
if (filter(tree.props.children)) return tree;
return findParentInTree(tree.props.children, filter, maxDepth - 1);
}
}
return null;
}

View file

@ -0,0 +1,25 @@
import type { ImmutableRecord } from "@vencord/discord-types";
import { findByName } from "@vendetta/metro";
export const ProfileEffectRecord: typeof $ProfileEffectRecord = findByName("ProfileEffectRecord");
export type ProfileEffectRecordOwnProperties = Pick<$ProfileEffectRecord, "id" | "skuId" | "type">;
export type ProfileEffectProperties = Omit<ProfileEffectRecordOwnProperties, "type">;
declare class $ProfileEffectRecord extends ImmutableRecord<ProfileEffectRecordOwnProperties> {
constructor(profileEffectProperties: ProfileEffectProperties);
static fromServer(profileEffectFromServer: { id: string; sku_id: string; }): $ProfileEffectRecord;
id: string;
skuId: string;
type: CollectiblesItemType.PROFILE_EFFECT;
}
export const enum CollectiblesItemType {
AVATAR_DECORATION = 0,
PROFILE_EFFECT = 1,
NONE = 100,
BUNDLE = 1_000,
}

View file

@ -0,0 +1,55 @@
import type { ExtractAction, FluxAction, FluxStore } from "@vencord/discord-types";
import { findByStoreName } from "@vendetta/metro";
import type tinycolor from "tinycolor2";
export const CollectiblesPurchaseStore: $CollectiblesPurchaseStore
= findByStoreName("CollectiblesPurchaseStore");
export type CollectiblesPurchaseStoreAction = ExtractAction<FluxAction, "COLLECTIBLES_CLAIM" | "COLLECTIBLES_CLAIM_FAILURE" | "COLLECTIBLES_CLAIM_SUCCESS" | "COLLECTIBLES_PURCHASES_FETCH" | "COLLECTIBLES_PURCHASES_FETCH_FAILURE" | "COLLECTIBLES_PURCHASES_FETCH_SUCCESS" | "LOGOUT">;
declare class $CollectiblesPurchaseStore extends FluxStore<CollectiblesPurchaseStoreAction> {
static displayName: "CollectiblesPurchaseStore";
get claimError(): Error | undefined;
get fetchError(): Error | undefined;
getPurchase(skuId: string | null | undefined): Purchase | undefined;
get isClaiming(): boolean;
get isFetching(): boolean;
get purchases(): Map<string, Purchase>;
}
export interface Purchase {
banner: string;
categorySkuId: string;
items: {
asset: string;
id: string;
label: string;
skuId: string;
type: number;
}[];
name: string;
premiumType: number | null;
prices: {
countryPrices: {
countryCode: string;
prices: {
amount: number;
currency: string;
exponent: number;
}[];
};
}[];
purchaseType: number;
purchasedAt: Date;
skuId: string;
storeListingId: string;
styles: {
backgroundColors: [typeof tinycolor, typeof tinycolor];
buttonColors: [typeof tinycolor, typeof tinycolor];
confettiColors: (typeof tinycolor)[];
};
summary: string;
type: number;
unpublishedAt: Date | null;
}

View file

@ -0,0 +1,72 @@
import type { ExtractAction, FluxAction, FluxStore } from "@vencord/discord-types";
import { findByProps, findByStoreName } from "@vendetta/metro";
import { lazy } from "@shared/lib/lazy";
import type { CollectiblesItemType } from "@fpte/lib/records";
// There's no dedicated ProfileEffectStore anymore - effects are served from the same shop/
// collectibles catalog used for avatar decorations and nameplates. getProfileEffectsFromCategories
// returns ProfileEffectConfig objects directly, not the old {id, skuId, config} shape this file's
// ProfileEffect type expects, so the adapter below rebuilds that shape.
const getShopEffectsUtil = lazy(() => findByProps("getProfileEffectsFromCategories") as
{ getProfileEffectsFromCategories(categories: unknown): ProfileEffectConfig[] } | undefined);
const getCategoryStore = lazy(() => findByStoreName("CollectiblesCategoryStore") as
{ categories: unknown } | undefined);
export const getProfileEffectStore = lazy((): { profileEffects: ProfileEffect[] } | undefined => {
const util = getShopEffectsUtil();
const categoryStore = getCategoryStore();
if (!util || !categoryStore) return undefined;
const configs = util.getProfileEffectsFromCategories(categoryStore.categories) ?? [];
return {
profileEffects: configs.map((config): ProfileEffect => ({ id: config.skuId, skuId: config.skuId, config })),
};
});
export type ProfileEffectStoreAction = ExtractAction<FluxAction, "LOGOUT" | "PROFILE_EFFECTS_SET_TRY_IT_OUT" | "USER_PROFILE_EFFECTS_FETCH" | "USER_PROFILE_EFFECTS_FETCH_FAILURE" | "USER_PROFILE_EFFECTS_FETCH_SUCCESS">;
declare class $ProfileEffectStore extends FluxStore<ProfileEffectStoreAction> {
static displayName: "ProfileEffectStore";
canFetch(): boolean;
get fetchError(): Error | undefined;
getProfileEffectById(effectId: string): ProfileEffect | undefined;
hasFetched(): boolean;
get isFetching(): boolean;
get profileEffects(): ProfileEffect[];
get tryItOutId(): string | null;
}
export interface ProfileEffect {
config: ProfileEffectConfig;
id: string;
skuId: string;
}
export interface ProfileEffectConfig {
accessibilityLabel: string;
animationType: number;
description: string;
effects: {
duartion: number;
height: number;
loop: boolean;
loopDelay: number;
position: {
x: number;
y: number;
};
src: string;
start: number;
width: number;
zIndex: number;
}[];
id: string;
reducedMotionSrc: string;
sku_id: string;
staticFrameSrc?: string;
thumbnailPreviewSrc: string;
title: string;
type: CollectiblesItemType.PROFILE_EFFECT;
}

View file

@ -0,0 +1,12 @@
import type {
UserProfileStore as $UserProfileStore,
UserStore as $UserStore
} from "@vencord/discord-types";
import { findByStoreName } from "@vendetta/metro";
export * from "./CollectiblesPurchaseStore";
export * from "./ProfileEffectStore";
export const UserProfileStore: $UserProfileStore = findByStoreName("UserProfileStore");
export const UserStore: $UserStore = findByStoreName("UserStore");

View file

@ -0,0 +1,17 @@
declare const emptyObjectSymbol: unique symbol;
export interface EmptyObject {
[emptyObjectSymbol]?: never;
}
export type OmitCallSignature<T> = Pick<T, keyof T>;
export type StringDict<V extends string> = { [K in V as Uppercase<K>]: V };
export function isIterable<T extends object, V = any>(arg: T | Iterable<V>): arg is Iterable<V> {
return typeof (arg as Iterable<V>)[Symbol.iterator] === "function";
}
export function isNonNullObject<T>(arg: T | T & object): arg is T & object {
return arg !== null && typeof arg === "object";
}

View file

@ -0,0 +1,7 @@
export { patchGetPurchase } from "./patchGetPurchase";
export { patchGetUserProfile } from "./patchGetUserProfile";
//export { patchGuildProfileEditForm } from "./patchGuildProfileEditForm";
export { patchUseProfileEffectSections } from "./patchUseProfileEffectSections";
export { patchUseProfileTheme } from "./patchUseProfileTheme";
export { patchUserProfileEditForm } from "./patchUserProfileEditForm";
export { patchNitroUpsellCard } from "./patchNitroUpsellCard";

View file

@ -0,0 +1,12 @@
import { instead } from "@vendetta/patcher";
import { CollectiblesPurchaseStore } from "@fpte/lib/stores";
import { previewUserId } from "@fpte/patches/patchUseProfileTheme";
export const patchGetPurchase = () => instead(
"getPurchase",
CollectiblesPurchaseStore,
(args: unknown[], origFunc: (...args: any[]) => unknown) => previewUserId
? { purchasedAt: new Date() }
: origFunc(args)
);

View file

@ -0,0 +1,60 @@
import type { UserProfile } from "@vencord/discord-types";
import { after } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import { decodeColor, decodeColorsLegacy, decodeEffect, extractFPTE } from "@fpte/lib/fpte";
import { UserProfileStore } from "@fpte/lib/stores";
function updateProfileThemeColors(profile: UserProfile<false>, primary: number, accent: number) {
if (primary > -1) {
profile.themeColors = [primary, accent > -1 ? accent : primary];
profile.premiumType = 2;
} else if (accent > -1) {
profile.themeColors = [accent, accent];
profile.premiumType = 2;
}
}
function updateProfileEffectId(profile: UserProfile<false>, id: bigint) {
if (id > -1n) {
profile.profileEffectId = profile.profileEffectID = id.toString();
profile.premiumType = 2;
}
}
export const patchGetUserProfile = () => after("getUserProfile", UserProfileStore, (_args: unknown[], profile: UserProfile | undefined) => {
if (!profile || profile.profileFetchFailed) return profile;
if (storage.prioritizeNitro) {
if (profile.themeColors) {
if (!(profile.profileEffectId || profile.profileEffectID)) {
const fpte = extractFPTE(profile.bio);
if (decodeColor(fpte[0]) === -2)
updateProfileEffectId(profile, decodeEffect(fpte[1]));
else
updateProfileEffectId(profile, decodeEffect(fpte[2]));
}
return profile;
} else if (profile.profileEffectId || profile.profileEffectID) {
const fpte = extractFPTE(profile.bio);
const primaryColor = decodeColor(fpte[0]);
if (primaryColor === -2)
updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0]));
else
updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1]));
return profile;
}
}
const fpte = extractFPTE(profile.bio);
const primaryColor = decodeColor(fpte[0]);
if (primaryColor === -2) {
updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0]));
updateProfileEffectId(profile, decodeEffect(fpte[1]));
} else {
updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1]));
updateProfileEffectId(profile, decodeEffect(fpte[2]));
}
return profile;
});

View file

@ -0,0 +1,40 @@
import { findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import React from "react";
import { findParentInTree, getComponentNameFromType, isElement, type RN } from "@fpte/lib/reactNativeRenderTree";
import { Builder } from "@fpte/ui/components";
const funcParent = findByName("GuildProfileEditForm", false);
export const patchGuildProfileEditForm = () => after("default", funcParent, (_args: unknown[], tree: RN.Node) => {
if (storage.hideBuilder) return tree;
console.log("[GuildProfileEditForm] Render tree:", tree);
console.log("[GuildProfileEditForm] Direct children:", tree?.props?.children);
let guildId: string | undefined;
const parent = findParentInTree(tree, (children): children is RN.Node[] =>
Array.isArray(children) && children.some(child => {
if (isElement(child)) {
const name = getComponentNameFromType(child.type);
console.log("[GuildProfileEditForm] Child component:", name, child.props);
if (name === "EditGuildIdentityBio") {
guildId = (child.props as any).displayProfile?.guildId;
return true;
}
}
return false;
})
);
if (parent) {
console.log("[GuildProfileEditForm] Found parent:", parent);
parent.props.children.splice(2, 0, <Builder guildId={guildId} />);
} else {
console.warn("[GuildProfileEditForm] Parent not found.");
}
return tree;
});

View file

@ -0,0 +1,42 @@
import { findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { isElement, getComponentNameFromType, type RN } from "@fpte/lib/reactNativeRenderTree";
const funcParent = findByName("UserProfileEditForm", false);
function removeNitroCards(node: RN.Node): RN.Node | null {
if (!node) return null;
if (
isElement(node) &&
getComponentNameFromType(node.type) === "UserProfilePremiumUpsellCard"
) {
return null;
}
if (node.props?.children) {
let children = node.props.children;
if (!Array.isArray(children)) children = [children];
const filteredChildren = children
.map(removeNitroCards)
.filter((child): child is RN.Node => child !== null);
node.props.children = filteredChildren.length === 1 ? filteredChildren[0] : filteredChildren;
}
return node;
}
export const patchNitroUpsellCard = () => {
if (!funcParent) return () => {};
return after("default", funcParent, (_args: unknown[], tree: RN.Node) => {
try {
return removeNitroCards(tree) || tree;
} catch {
// One broken upsell-card removal shouldn't crash the whole profile edit screen.
return tree;
}
});
};

View file

@ -0,0 +1,37 @@
import { findByPropsAll } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { ProfileEffectRecord } from "@fpte/lib/records";
import { type ProfileEffect, getProfileEffectStore } from "@fpte/lib/stores";
import { previewUserId } from "@fpte/patches/patchUseProfileTheme";
const useSectionModules = findByPropsAll("NONE_ITEM");
let prevProfileEffects: ProfileEffect[];
let prevSections: Record<string, any>[];
export const patchUseProfileEffectSections = () => useSectionModules.map(
sectionModule => after("default", sectionModule, (_args: unknown[], origSections: typeof prevSections) => {
try {
const profileEffectStore = getProfileEffectStore();
if (previewUserId && profileEffectStore && origSections?.[0]?.items) {
const currProfileEffects = profileEffectStore.profileEffects;
if (prevProfileEffects !== currProfileEffects) {
origSections.splice(1);
origSections[0]!.items.splice(1);
profileEffectStore.profileEffects.forEach(effect => {
origSections[0]!.items.push(new ProfileEffectRecord(effect.config));
});
prevSections = origSections;
} else if (prevSections) {
origSections = prevSections;
}
}
} catch {
// A broken section rewrite shouldn't crash the whole effect-browsing screen - let
// Discord's own sections render unmodified instead.
}
return origSections;
})
);

View file

@ -0,0 +1,144 @@
import type { DisplayProfile, ProfileThemeColors, UserRecord } from "@vencord/discord-types";
import { findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { useState } from "react";
import { FluxDispatcher } from "@fpte/lib/flux";
import type { ProfileEffectConfig } from "@fpte/lib/stores";
import { getProfileTheme, type Theme } from "@fpte/ui/color";
function updatePreview() {
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" });
}
let showPreview = true;
export function useShowPreview(initialState: typeof showPreview) {
const [state, setState] = useState(() => showPreview = initialState);
return [
state,
(preview: typeof showPreview) => {
setState(showPreview = preview);
updatePreview();
}
] as const;
}
let primaryColor: number | null = null;
export function usePrimaryColor(initialState: typeof primaryColor) {
const [state, setState] = useState(() => primaryColor = initialState);
return [
state,
(color: typeof primaryColor) => {
setState(primaryColor = color);
if (showPreview) updatePreview();
}
] as const;
}
let accentColor: number | null = null;
export function useAccentColor(initialState: typeof accentColor) {
const [state, setState] = useState(() => accentColor = initialState);
return [
state,
(color: typeof accentColor) => {
setState(accentColor = color);
if (showPreview) updatePreview();
}
] as const;
}
// TEMP
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let profileEffect: ProfileEffectConfig | null = null;
export function useProfileEffect(initialState: typeof profileEffect) {
const [state, setState] = useState(() => profileEffect = initialState);
return [
state,
(effect: typeof profileEffect) => {
setState(profileEffect = effect);
if (showPreview) updatePreview();
}
] as const;
}
export let previewUserId: string | undefined;
export function setPreviewUserId(userId: typeof previewUserId) {
previewUserId = userId;
}
export const patchUseProfileTheme = (() => {
let funcParent = findByName("useProfileTheme", false);
if (funcParent)
return () => after(
"default",
funcParent,
(([options]: [UseProfileThemeOptions], profileTheme: ProfileTheme) => {
const { user } = options;
if (
(user != null && user.id === previewUserId
|| "pendingThemeColors" in options)
&& showPreview
) {
if (primaryColor !== null) {
profileTheme.theme = getProfileTheme(primaryColor);
profileTheme.primaryColor = primaryColor;
profileTheme.secondaryColor = accentColor ?? primaryColor;
} else if (accentColor !== null) {
profileTheme.theme = getProfileTheme(accentColor);
profileTheme.primaryColor = accentColor;
profileTheme.secondaryColor = accentColor;
}
}
return profileTheme;
}) as any
);
funcParent = findByName("useProfileThemeColors", false);
if (funcParent)
return () => after(
"default",
funcParent,
(([user, _displayProfile, previewProps]: UseProfileThemeColorsArgs, origRet: PartialProfileThemeColors) => {
if (
(user != null && user.id === previewUserId
|| previewProps)
&& showPreview
) {
if (primaryColor !== null)
return [primaryColor, accentColor ?? primaryColor];
if (accentColor !== null)
return [accentColor, accentColor];
}
return origRet;
}) as any
);
return () => () => true;
})();
interface UseProfileThemeOptions {
user?: UserRecord | null | undefined;
displayProfile?: DisplayProfile | null | undefined;
pendingThemeColors: ProfileThemeColors | undefined;
pendingAvatar?: UserRecord["avatar"] | undefined;
isPreview?: boolean | null | undefined;
}
interface ProfileTheme {
theme: Theme;
primaryColor: number | null;
secondaryColor: number | null;
}
type UseProfileThemeColorsArgs = [
user: UserRecord | null | undefined,
displayProfile: DisplayProfile | null | undefined,
previewProps?: {
pendingThemeColors: ProfileThemeColors | undefined;
pendingAvatar?: UserRecord["avatar"] | undefined;
isPreview?: boolean;
} | undefined
];
type PartialProfileThemeColors = [primaryColor: number | null, accentColor: number | null];

View file

@ -0,0 +1,45 @@
import { findByName } from "@vendetta/metro";
import { after } from "@vendetta/patcher";
import { storage } from "@vendetta/plugin";
import React from "react";
import { findParentInTree, getComponentNameFromType, isElement, type RN } from "@fpte/lib/reactNativeRenderTree";
import { Builder } from "@fpte/ui/components";
const funcParent = findByName("UserProfileEditForm", false);
export const patchUserProfileEditForm = () => {
if (!funcParent) return () => {};
return after("default", funcParent, (_args: unknown[], tree: RN.Node) => {
try {
if (storage.hideBuilder) return tree;
const parent = findParentInTree(tree, (children): children is RN.Node[] =>
Array.isArray(children) &&
children.some(child =>
isElement(child) &&
getComponentNameFromType(child.type) === "UserProfileEditFormTextField"
)
);
if (parent) {
const index = parent.props.children.reduce<number[]>((acc, child, i) => {
if (
isElement(child) &&
getComponentNameFromType(child.type) === "UserProfileEditFormTextField"
) acc.push(i);
return acc;
}, []);
if (index.length >= 3) {
parent.props.children.splice(index[2] + 1, 0, <Builder />);
}
}
} catch {
// One broken edit-form render shouldn't crash the whole profile edit screen.
}
return tree;
});
};

View file

@ -0,0 +1,110 @@
import type { UserRecord } from "@vencord/discord-types";
import { findByName } from "@vendetta/metro";
import { storage } from "@vendetta/plugin";
import React, { type ReactElement, type ReactNode, useMemo } from "react";
import { findElementInTree, findParentInTree, getComponentNameFromType, type RN } from "@fpte/lib/reactNativeRenderTree";
import { ProfileEffectRecord } from "@fpte/lib/records";
import type { ProfileEffect, ProfileEffectConfig } from "@fpte/lib/stores";
import { setPreviewUserId } from "@fpte/patches/patchUseProfileTheme";
import { FallbackEffectPickerActionSheet } from "@fpte/ui/actionSheets";
import { ThemeContextProvider, useThemeContext } from "@fpte/ui/color";
export interface EffectPickerActionSheetProps {
effects: ProfileEffect[];
onSelect: (effect: ProfileEffectConfig | null) => void;
user: UserRecord;
currentEffectId?: string | undefined;
}
const EffectPicker: RN.FunctionComponent | undefined = findByName("EditProfileEffectActionSheet");
let lastGoodTree: RN.Node;
function PatchedEffectPickerActionSheet(props: EffectPickerActionSheetProps) {
const { currentEffectId, effects, onSelect, user } = props;
// Must be checked before EffectPicker is called below - that call chains Discord's own hooks
// onto this fiber, so checking the setting after the call still pays the crash it's meant to
// avoid. forceFallbackEffectPicker doesn't change while mounted, so this early return is safe.
if (storage.forceFallbackEffectPicker)
return <FallbackEffectPickerActionSheet {...props} />;
const tree = EffectPicker!({ user });
const themeContext = useThemeContext();
// Must be built from effect.config, not our {id, skuId, config} wrapper - the wrapper leaves
// everything but skuId/type undefined.
const effectRecords = useMemo(() => effects.map(effect => ({ items: new ProfileEffectRecord(effect.config) })), [effects]);
let isLegacyEffectPicker = false;
const effectPickerInner: RN.Element<any> | null = findElementInTree(tree, element => {
if (getComponentNameFromType(element.type) === "EditProfileEffectInner")
return true;
if (
"profileEffects" in element.props
&& "selectedProfileEffect" in element.props
&& typeof (element.props as any).setSelectedProfileEffect === "function"
) return isLegacyEffectPicker = true;
return false;
});
if (!effectPickerInner) {
if (lastGoodTree) return lastGoodTree as ReactElement;
return <FallbackEffectPickerActionSheet {...props} />;
}
const applyButton: RN.Element<any> | null = findElementInTree(tree, element => getComponentNameFromType(element.type) === "Button");
if (!applyButton) {
if (lastGoodTree) return lastGoodTree as ReactElement;
return <FallbackEffectPickerActionSheet {...props} />;
}
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (isLegacyEffectPicker) {
if (effectPickerInner.props.selectedProfileEffect === undefined)
effectPickerInner.props.setSelectedProfileEffect(props.currentEffectId
? { id: props.currentEffectId }
: null);
effectPickerInner.props.profileEffects = props.effects;
const profilePreview = findParentInTree(tree, children =>
Array.isArray(children) && children.some(child =>
getComponentNameFromType(child.type) === "DisplayBanner"));
if (profilePreview) {
const baseProvider = tree as RN.Element<any>;
profilePreview.props.children = (
<ThemeContextProvider
theme={baseProvider.props.theme}
primaryColor={baseProvider.props.primaryColor}
secondaryColor={baseProvider.props.secondaryColor}
children={profilePreview.props.children as ReactNode}
/>
);
baseProvider.props.theme = themeContext.theme;
baseProvider.props.primaryColor = themeContext.primaryColor;
baseProvider.props.secondaryColor = themeContext.secondaryColor;
}
} else {
if (effectPickerInner.props.selectedProfileEffect === undefined)
effectPickerInner.props.setSelectedProfileEffect(currentEffectId
? new ProfileEffectRecord({ skuId: currentEffectId } as any)
: null);
effectPickerInner.props.purchases = effectRecords;
}
setPreviewUserId(user.id);
applyButton.props.onPress = () => {
setPreviewUserId(undefined);
// selectedProfileEffect has no .id field, only .skuId.
onSelect(effects.find(effect => effect.skuId === effectPickerInner.props.selectedProfileEffect?.skuId)?.config ?? null);
};
return (lastGoodTree = tree) as ReactElement;
}
export const EffectPickerActionSheet = EffectPicker
? PatchedEffectPickerActionSheet
: FallbackEffectPickerActionSheet;

View file

@ -0,0 +1,221 @@
import React, { type ReactNode, useContext, useMemo, useState } from "react";
import { View, type ViewStyle } from "react-native";
import { HapticFeebackTypes, triggerHapticFeedback } from "@fpte/lib/haptics";
import type { ProfileEffect } from "@fpte/lib/stores";
import { BottomSheet, BottomSheetScrollView, type EffectPickerActionSheetProps } from "@fpte/ui/actionSheets";
import { IMG_NONE } from "@fpte/ui/assets";
import { resolveSemanticColorSafe, useThemeContext } from "@fpte/ui/color";
import { Button, FlashList, Icon, PressableOpacity, StaticEffect, Text } from "@fpte/ui/components";
import { Radius, SafeAreaContext, Spacing, useWindowDimensions } from "@fpte/ui/length";
const ROW_SIZE = 3;
// lodash isn't a dependency here - an unresolved import threw "lodash is not defined" before
// onLoad ever ran. Only one function was used from it, so it's inlined here instead.
function chunk<T>(arr: T[], size: number): T[][] {
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
return result;
}
interface ItemProps {
label: string;
isSelected: boolean;
size: number;
colors: [bgColor: string, itemColor: string, selectedColor: string];
onPress: () => void;
style?: ViewStyle | undefined;
children: Exclude<ReactNode, number | string>;
}
function Item({ label, isSelected, size, colors, onPress, style, children }: ItemProps) {
const [bgColor, itemColor, selectedColor] = colors;
return (
<PressableOpacity
accessibilityLabel={label}
accessibilityRole="button"
accessibilityState={{ selected: isSelected }}
disabled={isSelected}
onPress={() => {
triggerHapticFeedback(HapticFeebackTypes.IMPACT_LIGHT);
onPress();
}}
style={[
{
height: size,
width: size,
overflow: "hidden",
backgroundColor: itemColor,
borderColor: bgColor,
borderRadius: Radius.sm,
borderWidth: 2
},
isSelected && { borderColor: selectedColor },
style
]}
>
{children}
</PressableOpacity>
);
}
export type FallbackEffectPickerActionSheetProps = Pick<EffectPickerActionSheetProps, "currentEffectId" | "effects" | "onSelect">;
export function FallbackEffectPickerActionSheet({ currentEffectId, effects, onSelect }: FallbackEffectPickerActionSheetProps) {
const [selectedId, setSelectedId] = useState(currentEffectId);
const [itemSize, setItemSize] = useState(0);
const { theme } = useThemeContext();
const colors: ItemProps["colors"] = useMemo(() => [
resolveSemanticColorSafe(theme, ["BACKGROUND_PRIMARY", "BACKGROUND_BASE_LOWER", "BACKGROUND_BASE_LOW"], "#1E1F22"),
resolveSemanticColorSafe(theme, ["BACKGROUND_FLOATING", "MOBILE_FLOATINGBAR_BACKGROUND", "BACKGROUND_BASE_LOWER"], "#2B2D31"),
resolveSemanticColorSafe(theme, ["BUTTON_OUTLINE_BRAND_BORDER_ACTIVE", "BORDER_FOCUS"], "#5865F2")
], [theme]);
const windowDimensions = useWindowDimensions();
const safeArea = useContext(SafeAreaContext);
const effectRows = useMemo(() => {
const effectChunks: (ProfileEffect | null | undefined)[][] = chunk([null, ...effects], ROW_SIZE);
const lastChunk = effectChunks[effectChunks.length - 1]!;
while (lastChunk.length < 3) lastChunk.push(undefined);
return effectChunks;
}, [effects]);
return (
<BottomSheet
transparentHeader={true}
scrollable={true}
startExpanded={true}
startHeight={windowDimensions.height - safeArea.top}
>
<BottomSheetScrollView
scrollsToTop={false}
>
<View
style={{
flex: 1,
flexDirection: "column",
alignItems: "center",
paddingBottom: 88
}}
>
<Text
variant="redesign/heading-18/bold"
color="header-primary"
style={{ margin: Spacing.PX_16 }}
>
{currentEffectId ? "Change Effect" : "Add Profile Effect"}
</Text>
<View
style={{
width: "72%",
minHeight: 38
}}
>
<Text
variant="heading-md/bold"
color="header-primary"
style={{ textAlign: "center" }}
>
{effects.find(effect => effect.id === selectedId)?.config.title ?? "None"}
</Text>
</View>
<View
style={{
flex: 1,
width: "92%",
marginTop: 3
}}
>
<FlashList
accessibilityLabel="Profile Effect Selection Section"
numColumns={1}
estimatedItemSize={98}
ItemSeparatorComponent={() => <View style={{ height: Spacing.PX_16 }} />}
contentContainerStyle={{ paddingHorizontal: Spacing.PX_4 }}
data={effectRows}
extraData={selectedId}
renderItem={({ item }) => (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: Spacing.PX_16
}}
>
{item.map(effect => effect
? (
<Item
label={effect.config.accessibilityLabel}
isSelected={effect.id === selectedId}
size={itemSize}
colors={colors}
onPress={() => { setSelectedId(effect.id); }}
>
<StaticEffect effect={effect.config} />
</Item>
)
: effect === null
? (
<Item
label="None"
isSelected={!selectedId}
size={itemSize}
colors={colors}
onPress={() => { setSelectedId(undefined); }}
style={{
alignItems: "center",
justifyContent: "center"
}}
>
<Icon
source={IMG_NONE}
size={Icon.Sizes.LARGE}
/>
<Text
variant="text-sm/medium"
color="header-primary"
style={{ marginTop: Spacing.PX_4 }}
>
None
</Text>
</Item>
)
: (
<View
style={{
width: itemSize,
height: itemSize
}}
/>
)
)}
</View>
)}
onLayout={event => { setItemSize((event.nativeEvent.layout.width - 64) / ROW_SIZE); }}
/>
</View>
</View>
</BottomSheetScrollView>
<Button
text="Apply"
textStyle={{ fontSize: 16 }}
onPress={() => { onSelect(effects.find(effect => effect.id === selectedId)?.config ?? null); }}
style={{
position: "absolute",
right: 0,
bottom: 0,
left: 0,
height: 48,
marginHorizontal: 36,
marginBottom: Spacing.PX_48,
borderRadius: Radius.round
}}
/>
</BottomSheet>
);
}

View file

@ -0,0 +1,29 @@
import { findByProps } from "@vendetta/metro";
import type { ComponentType, ReactNode } from "react";
export * from "./EffectPickerActionSheet";
export * from "./FallbackEffectPickerActionSheet";
export * from "./showColorPicker";
export * from "./showEffectPicker";
export const BottomSheet: ComponentType<Record<string, any>>
= (findByProps("BottomSheet") as Record<string, any> | undefined)?.BottomSheet
?? (findByProps("ActionSheet") as Record<string, any> | undefined)?.ActionSheet
?? (() => { throw new Error("FakeProfileThemesAndEffects threw an error to avoid an otherwise-inevitable, unrecoverable freeze."); });
export const BottomSheetScrollView: ComponentType<any>
= (findByProps("BottomSheetScrollView") as any)?.BottomSheetScrollView ?? (() => null);
// Must use `as` or else `undefined` is lost due to a TS bug
const actionSheetModule = findByProps("showActionSheet") as Record<string, any> | undefined;
export const showActionSheet: (props: {
content: ReactNode;
key: string;
impressionName?: string | undefined;
impressionProperties?: object | undefined;
backdropKind?: string | undefined;
}) => void = actionSheetModule?.showActionSheet ?? (() => undefined);
export const hideActionSheet: (key?: string | undefined) => void
= actionSheetModule?.default?.hideActionSheet ?? (() => undefined);

View file

@ -0,0 +1,12 @@
import { findByName } from "@vendetta/metro";
const $showColorPicker = findByName("showCustomColorPickerActionSheet") ?? (() => undefined);
export function showColorPicker(props: {
color?: number | null | undefined;
onSelect?: ((color: number) => void) | undefined;
suggestedColors?: string[] | undefined;
}) {
props.color ??= 0;
return $showColorPicker(props);
}

View file

@ -0,0 +1,47 @@
import React from "react";
import { FluxDispatcher } from "@fpte/lib/flux";
import { type ProfileEffectConfig, getProfileEffectStore, UserStore } from "@fpte/lib/stores";
import { setPreviewUserId } from "@fpte/patches/patchUseProfileTheme";
import { EffectPickerActionSheet, hideActionSheet, showActionSheet } from "@fpte/ui/actionSheets";
const SHEET_KEY = "__FPTE__";
export function showEffectPicker(
onSelect: (effect: ProfileEffectConfig | null) => void,
currentEffectId?: string | undefined
) {
// Called directly from a native onPress - an uncaught throw here is fatal to the whole app
// (surfaces as AndroidRuntime FATAL EXCEPTION, not a recoverable React error screen).
try {
const user = UserStore.getCurrentUser();
const profileEffectStore = getProfileEffectStore();
if (!user || !profileEffectStore) return;
function onClose(action: any) {
if (action.key === SHEET_KEY) {
FluxDispatcher.unsubscribe("HIDE_ACTION_SHEET", onClose);
setPreviewUserId(undefined);
}
}
FluxDispatcher.subscribe("HIDE_ACTION_SHEET", onClose);
showActionSheet({
content: (
<EffectPickerActionSheet
effects={profileEffectStore.profileEffects}
onSelect={effect => {
onSelect(effect);
hideActionSheet(SHEET_KEY);
}}
user={user}
currentEffectId={currentEffectId}
/>
),
key: SHEET_KEY
});
} catch {
// Nothing sensible to fall back to for a picker that couldn't even open - just don't
// crash the app over it.
}
}

View file

@ -0,0 +1,17 @@
import { findByProps } from "@vendetta/metro";
import { getAssetIDByName } from "@vendetta/ui/assets";
import type { ImageSourcePropType } from "react-native";
export const IMG_NONE = getAssetIDByName("img_none");
export const PROFILE_EFFECT_WH_RATIO: number
= (findByProps("DEFAULT_PROFILE_EFFECT_WH_RATIO") as Record<string, any> | undefined)?.DEFAULT_PROFILE_EFFECT_WH_RATIO
?? 45 / 88;
export const SAMPLE_PROFILE_SMALL: ImageSourcePropType
= (getAssetIDByName("sample-profile-small") as number | undefined)
?? { uri: "https://discordapp.com/assets/f328a6f8209d4f1f5022.png" };
export const TOAST_TYPE_COPY = getAssetIDByName("toast_copy_link");
export const TOAST_TYPE_FAILURE = getAssetIDByName("Small");

View file

@ -0,0 +1,73 @@
import { find, findByProps } from "@vendetta/metro";
import { semanticColors as _semanticColors } from "@vendetta/ui";
import type { ComponentType, PropsWithChildren } from "react";
import type { EmptyObject } from "@fpte/lib/utils";
export const semanticColors: Record<string, EmptyObject> = _semanticColors;
export const resolveSemanticColor: (theme: Theme, semanticColor: EmptyObject) => string
= find(m => m.default?.internal?.resolveSemanticColor)?.default.internal.resolveSemanticColor
?? find(m => m.meta?.resolveSemanticColor)?.meta.resolveSemanticColor
?? (() => undefined);
// Discord renames semantic color tokens across versions - tries each candidate name in order and
// never throws.
export function resolveSemanticColorSafe(theme: Theme, tokenNames: string[], fallbackHex: string): string {
for (const name of tokenNames) {
const token = semanticColors?.[name];
if (!token) continue;
try {
const resolved = resolveSemanticColor(theme, token);
if (resolved) return resolved;
} catch {
// try the next candidate
}
}
return fallbackHex;
}
export const useAvatarColors: (
avatarUrl: string,
fillerColor: string,
desaturateColors?: boolean | undefined /* = true */
) => string[]
= (findByProps("useAvatarColors") as Record<string, any> | undefined)?.useAvatarColors
?? (() => undefined);
export type Theme = "dark" | "light" | "midnight" | "darker";
// Unguarded, this crashed the whole plugin bundle at load time - a property access on a failed
// findByProps throws while the module is being evaluated, before onLoad is ever reached.
export const getProfileTheme: <T extends number | null | undefined>(primaryColor: T) => T extends number ? Theme : null
= (findByProps("getProfileTheme") as Record<string, any> | undefined)?.getProfileTheme
?? ((primaryColor: any) => (primaryColor == null ? null : "dark")) as any;
export interface ThemeContext {
theme: Theme;
primaryColor: number | null;
secondaryColor: number | null;
gradient: {
id: number;
theme: Theme;
colors: {
stop: number;
token: string;
}[];
angle: number;
midpointPercentage: number;
getName: () => string;
} | null;
flags: number;
key: string;
}
export const useThemeContext: () => ThemeContext
= (findByProps("useThemeContext") as Record<string, any> | undefined)?.useThemeContext
?? (() => ({}));
export type ThemeContextProviderProps = PropsWithChildren<Partial<ThemeContext>>;
export const ThemeContextProvider: ComponentType<ThemeContextProviderProps>
= (findByProps("ThemeContextProvider") as Record<string, any> | undefined)?.ThemeContextProvider
?? (({ children }) => children);

Some files were not shown because too many files have changed in this diff Show more