Compare commits
No commits in common. "main" and "gh-pages" have entirely different histories.
424 changed files with 12382 additions and 24975 deletions
53
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
53
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -1,53 +0,0 @@
|
|||
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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
|
|
@ -1,5 +0,0 @@
|
|||
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.
|
||||
29
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
29
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -1,29 +0,0 @@
|
|||
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
|
||||
55
.github/ISSUE_TEMPLATE/plugin_broken.yml
vendored
55
.github/ISSUE_TEMPLATE/plugin_broken.yml
vendored
|
|
@ -1,55 +0,0 @@
|
|||
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
34
.github/workflows/deploy.yml
vendored
|
|
@ -1,34 +0,0 @@
|
|||
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
5
.gitignore
vendored
|
|
@ -1,5 +0,0 @@
|
|||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
|
||||
.superpowers/
|
||||
0
.nojekyll
Normal file
0
.nojekyll
Normal file
736
404.html
Normal file
736
404.html
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
<!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>⌘</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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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">← 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
105
README.md
|
|
@ -1,105 +0,0 @@
|
|||
# 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
1537
api/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
-- 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
374
api/src/index.ts
|
|
@ -1,374 +0,0 @@
|
|||
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`,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -5,175 +5,209 @@
|
|||
<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%3Crect width='32' height='32' rx='4' fill='%230a0a0c'/%3E%3Ctext x='6' y='23' font-family='monospace' font-size='19' fill='%2339e881'%3E%3E_%3C/text%3E%3C/svg%3E">
|
||||
<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: #f4f5f2;
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceee9;
|
||||
--border: #d7dad2;
|
||||
--text: #14150f;
|
||||
--text-muted: #62675c;
|
||||
--text-dim: #8a8f80;
|
||||
--accent: #0d8a4e;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--danger: #c0392b;
|
||||
--danger-bg: #fbe9e7;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 15, 0.05);
|
||||
--mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--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: #0a0a0c; --bg-elevated: #111213; --bg-sunken: #0d0e0f; --border: #24262a;
|
||||
--text: #e4e6e1; --text-muted: #8b9088; --text-dim: #5c6058; --accent: #39e881; --accent-text: #05170d;
|
||||
--danger: #ff6b5e; --danger-bg: #241211;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4);
|
||||
--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: #0a0a0c; --bg-elevated: #111213; --bg-sunken: #0d0e0f; --border: #24262a;
|
||||
--text: #e4e6e1; --text-muted: #8b9088; --text-dim: #5c6058; --accent: #39e881; --accent-text: #05170d;
|
||||
--danger: #ff6b5e; --danger-bg: #241211;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4); color-scheme: 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: #f4f5f2; --bg-elevated: #ffffff; --bg-sunken: #eceee9; --border: #d7dad2;
|
||||
--text: #14150f; --text-muted: #62675c; --text-dim: #8a8f80; --accent: #0d8a4e; --accent-text: #ffffff;
|
||||
--danger: #c0392b; --danger-bg: #fbe9e7;
|
||||
--shadow: 0 1px 2px rgba(20,21,15,0.05); color-scheme: 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:
|
||||
repeating-linear-gradient(0deg, color-mix(in srgb, var(--text) 2%, transparent) 0px, transparent 1px, transparent 2px),
|
||||
var(--bg);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 14px;
|
||||
}
|
||||
.wrap { max-width: 980px; margin: 0 auto; padding: 0 20px; }
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Header ---------- */
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 40px 0 28px;
|
||||
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: 8px;
|
||||
font-size: 12.5px;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 22px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark::before { content: ">"; }
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(22px, 3.4vw, 30px);
|
||||
line-height: 1.3;
|
||||
margin: 0 0 6px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad { color: var(--accent); }
|
||||
.hero p.sub {
|
||||
color: var(--text-muted);
|
||||
font-size: 13.5px;
|
||||
max-width: 620px;
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.65;
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub::before { content: "# "; color: var(--text-dim); }
|
||||
.hero-links { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 26px; }
|
||||
.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: 12.5px; font-weight: 600; text-decoration: none;
|
||||
padding: 7px 12px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
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: var(--accent); }
|
||||
.hero-links a:hover { border-color: var(--accent); }
|
||||
.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: 12px; color: var(--text-dim); font-weight: 600;
|
||||
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-radius: 3px; padding: 2px 5px;
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 28px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 20px; font-weight: 700; font-variant-numeric: tabular-nums; color: var(--accent); }
|
||||
.stats .stat-label { font-size: 11.5px; color: var(--text-dim); }
|
||||
.stats .stat-value::before { content: "["; color: var(--text-dim); }
|
||||
.stats .stat-value::after { content: "]"; color: var(--text-dim); }
|
||||
.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: 14px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.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; align-items: center; gap: 10px; margin-bottom: 10px; border: 1px solid var(--border); background: var(--bg-elevated); padding: 0 12px; }
|
||||
.search-row::before { content: "$"; color: var(--accent); font-weight: 700; }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 10px 8px; border: none;
|
||||
background: transparent; color: var(--text); font-size: 13.5px; font-family: var(--mono);
|
||||
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: none; }
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.chip { font-size: 12px; font-weight: 600; padding: 6px 10px; background: var(--bg-sunken); color: var(--text-muted); border: 1px solid var(--border); cursor: pointer; }
|
||||
.chip::before { content: "#"; margin-right: 3px; color: var(--text-dim); }
|
||||
.chip[aria-pressed="true"] { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
.chip[aria-pressed="true"]::before { color: var(--accent-text); opacity: 0.7; }
|
||||
.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: 20px 0 56px; }
|
||||
.count { color: var(--text-dim); font-size: 12px; margin: 0 0 14px; }
|
||||
.count::before { content: "> "; }
|
||||
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(290px, 1fr)); gap: 12px; }
|
||||
.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);
|
||||
padding: 16px 16px 14px;
|
||||
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%;
|
||||
transition: border-color 0.12s ease;
|
||||
}
|
||||
.card.is-broken { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
border-color: var(--accent);
|
||||
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: 6px; }
|
||||
.card h3 { font-size: 14.5px; margin: 0; font-weight: 700; }
|
||||
.badge { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; padding: 2px 6px; white-space: nowrap; flex-shrink: 0; border: 1px solid currentColor; }
|
||||
.badge.new { color: var(--accent); }
|
||||
.badge.revived { color: #6a8dff; }
|
||||
.badge.rebuilt { color: #e6a53a; }
|
||||
.badge.broken { color: var(--danger); background: var(--danger-bg); border-color: var(--danger); }
|
||||
.card .category { font-size: 10.5px; color: var(--text-dim); font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 8px; }
|
||||
.card p.tagline { font-size: 12.5px; color: var(--text-muted); line-height: 1.6; margin: 0 0 10px; flex-grow: 1; }
|
||||
.card .taglist { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
|
||||
.card .taglist span { font-size: 10px; color: var(--text-dim); border: 1px solid var(--border); padding: 1px 5px; }
|
||||
.card .cta { font-size: 11.5px; font-weight: 700; color: var(--accent); }
|
||||
.card .cta::after { content: " ->"; }
|
||||
.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: 48px 20px; color: var(--text-dim); font-size: 13px; }
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 24px 0 40px; }
|
||||
footer p { color: var(--text-dim); font-size: 12px; margin: 0; }
|
||||
footer p::before { content: "# "; }
|
||||
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; }
|
||||
|
|
@ -181,83 +215,82 @@
|
|||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
padding: 36px 0 28px;
|
||||
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: 12px; font-weight: 700;
|
||||
color: var(--text-dim); text-decoration: none; margin-bottom: 18px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
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::before { content: "< "; }
|
||||
.back-link:hover { color: var(--accent); }
|
||||
.detail-top { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.detail-top h2 { font-size: clamp(20px, 3vw, 26px); margin: 0; font-weight: 700; }
|
||||
.detail-category { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-dim); margin-bottom: 12px; }
|
||||
.detail-tagline { font-size: 14px; color: var(--text-muted); max-width: 660px; line-height: 1.6; margin: 0 0 18px; }
|
||||
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 18px; }
|
||||
.detail-tags span { font-size: 11px; color: var(--text-dim); border: 1px solid var(--border); padding: 2px 7px; }
|
||||
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.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: 12.5px; font-weight: 700; padding: 9px 14px; border: 1px solid var(--border);
|
||||
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(--accent); color: var(--accent-text); border-color: var(--accent); }
|
||||
.detail-actions a:hover, .detail-actions button:hover { border-color: var(--accent); }
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 28px 0 56px; display: grid; grid-template-columns: 2fr 1fr; gap: 32px; }
|
||||
.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: 28px; }
|
||||
.detail-section h4 { font-size: 11.5px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-dim); margin: 0 0 12px; }
|
||||
.detail-section h4::before { content: "// "; }
|
||||
.detail-section p { font-size: 13.5px; line-height: 1.7; color: var(--text); margin: 0 0 12px; white-space: pre-line; }
|
||||
.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: 8px; }
|
||||
.feature-list li { display: flex; gap: 8px; font-size: 13px; line-height: 1.55; }
|
||||
.feature-list li::before { content: "+"; color: var(--accent); font-weight: 700; flex-shrink: 0; }
|
||||
.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); }
|
||||
.cmd-row { padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.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: 12.5px; font-weight: 700; color: var(--accent); margin-bottom: 3px; }
|
||||
.cmd-row span { font-size: 12px; color: var(--text-muted); line-height: 1.5; }
|
||||
.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); padding: 14px; margin-bottom: 14px; }
|
||||
.side-card h5 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-dim); margin: 0 0 8px; }
|
||||
.side-card h5::before { content: "// "; }
|
||||
.side-card p { font-size: 12.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
|
||||
.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: 12px; color: var(--text-muted); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(5,6,5,0.6); backdrop-filter: blur(2px);
|
||||
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);
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
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: 15px; font-family: var(--mono);
|
||||
padding: 16px 18px; border-bottom: 1px solid var(--border);
|
||||
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: 6px; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 9px 10px; cursor: pointer; font-size: 13px;
|
||||
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: 6px; height: 6px; background: var(--accent); flex-shrink: 0; }
|
||||
.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-dim); font-size: 11px; margin-left: auto; }
|
||||
.palette-empty { padding: 18px; text-align: center; color: var(--text-dim); font-size: 12.5px; }
|
||||
.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: 12.5px; font-weight: 700; font-family: var(--mono);
|
||||
padding: 9px 14px; opacity: 0; pointer-events: none;
|
||||
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); }
|
||||
|
|
@ -266,10 +299,15 @@
|
|||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="wrap">
|
||||
<div class="mark">revenge-plugins</div>
|
||||
<h1>Discord, <span class="grad">rebuilt on your terms.</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, net-new where nothing existed yet, confirmed against decompiled source and live device testing, not guessed.</p>
|
||||
<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>
|
||||
|
|
@ -282,7 +320,7 @@
|
|||
<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">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</kbd><kbd>K</kbd></span>
|
||||
</div>
|
||||
<div class="chips" id="categoryChips" role="group" aria-label="Filter by category"></div>
|
||||
|
|
@ -299,7 +337,7 @@
|
|||
|
||||
<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>
|
||||
<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>
|
||||
|
||||
|
|
@ -332,6 +370,8 @@
|
|||
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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
||||
|
|
@ -379,7 +419,7 @@
|
|||
|
||||
function countUp(el, target) {
|
||||
var start = 0;
|
||||
var duration = 500;
|
||||
var duration = 700;
|
||||
var startTime = null;
|
||||
function step(ts) {
|
||||
if (!startTime) startTime = ts;
|
||||
|
|
@ -394,13 +434,11 @@
|
|||
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 brokenCount = state.plugins.filter(function (p) { return p.broken; }).length;
|
||||
var stats = [
|
||||
{ value: state.plugins.length, label: "plugins" },
|
||||
{ value: categories.length, label: "categories" },
|
||||
{ value: revivedCount, label: "revived or rebuilt" }
|
||||
];
|
||||
if (brokenCount > 0) stats.push({ value: brokenCount, label: "currently broken" });
|
||||
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("");
|
||||
|
|
@ -415,7 +453,7 @@
|
|||
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 || "") + " " + (p.tags || []).join(" ")).toLowerCase();
|
||||
var haystack = (p.name + " " + p.description + " " + p.category + " " + (p.tagline || "")).toLowerCase();
|
||||
var matchesQuery = !q || haystack.indexOf(q) !== -1;
|
||||
return matchesCategory && matchesQuery;
|
||||
});
|
||||
|
|
@ -427,16 +465,17 @@
|
|||
filtered.forEach(function (p) {
|
||||
var card = document.createElement("button");
|
||||
card.type = "button";
|
||||
card.className = "card" + (p.broken ? " is-broken" : "");
|
||||
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.broken ? "broken" : p.status) + '">' + (p.broken ? "Broken" : (STATUS_LABEL[p.status] || p.status)) + '</span>' +
|
||||
'<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.broken ? p.broken.reason : (p.tagline || p.description)) + '</p>' +
|
||||
(p.tags && p.tags.length ? '<div class="taglist">' + p.tags.map(function (t) { return '<span>' + escapeHtml(t) + '</span>'; }).join("") + '</div>' : '') +
|
||||
'<div class="cta">view details</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 →</div>';
|
||||
|
||||
card.addEventListener("click", function () {
|
||||
navigateToPlugin(p.id);
|
||||
|
|
@ -476,11 +515,18 @@
|
|||
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>' + escapeHtml(f) + '</li>';
|
||||
return '<li>' + CHECK_SVG + '<span>' + escapeHtml(f) + '</span></li>';
|
||||
}).join("");
|
||||
|
||||
var commandsHtml = (p.commands && p.commands.length)
|
||||
|
|
@ -499,33 +545,23 @@
|
|||
? '<div class="detail-section"><h4>Features</h4><ul class="feature-list">' + featuresHtml + '</ul></div>'
|
||||
: "";
|
||||
|
||||
var tagsHtml = (p.tags && p.tags.length)
|
||||
? '<div class="detail-tags">' + p.tags.map(function (t) { return '<span>' + escapeHtml(t) + '</span>'; }).join("") + '</div>'
|
||||
: "";
|
||||
|
||||
var brokenNote = p.broken
|
||||
? '<div class="side-card warn"><h5>Currently broken</h5><p>' + escapeHtml(p.broken.reason) + '</p></div>'
|
||||
: "";
|
||||
|
||||
detailViewEl.innerHTML =
|
||||
'<div class="detail-banner">' +
|
||||
'<div class="detail-banner" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
|
||||
'<div class="wrap">' +
|
||||
'<button class="back-link" id="backLink">all plugins</button>' +
|
||||
'<button class="back-link" id="backLink">← All plugins</button>' +
|
||||
'<div class="detail-top">' +
|
||||
'<h2>' + escapeHtml(p.name) + '</h2>' +
|
||||
'<span class="badge ' + (p.broken ? "broken" : p.status) + '">' + (p.broken ? "Broken" : (STATUS_LABEL[p.status] || p.status)) + '</span>' +
|
||||
'<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>' +
|
||||
tagsHtml +
|
||||
'<div class="detail-actions" id="detailActions"></div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="wrap">' +
|
||||
'<div class="detail-body">' +
|
||||
'<div class="detail-body" style="--page-accent: ' + escapeHtml(p.accent) + '">' +
|
||||
'<div>' + howItWorksHtml + featuresSection + commandsHtml + '</div>' +
|
||||
'<div>' +
|
||||
brokenNote +
|
||||
(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>' : "") +
|
||||
|
|
@ -625,7 +661,7 @@
|
|||
}
|
||||
|
||||
paletteResults.innerHTML = paletteMatches.map(function (p, i) {
|
||||
return '<div class="palette-item" data-idx="' + i + '" aria-selected="' + (i === 0) + '">' +
|
||||
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("");
|
||||
|
|
@ -678,64 +714,22 @@
|
|||
}
|
||||
});
|
||||
|
||||
/* ---------- Boot + live updates ---------- */
|
||||
/* ---------- Boot ---------- */
|
||||
|
||||
function reloadPlugins(isInitial) {
|
||||
return fetch("https://rp.jarviscli.dev/plugins-data.json", { cache: "no-store" })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
state.plugins = data;
|
||||
renderStats();
|
||||
renderChips();
|
||||
render();
|
||||
|
||||
var match = location.pathname.match(/^\/([\w-]+)\/?$/);
|
||||
if (isInitial) {
|
||||
routeFromLocation();
|
||||
} else if (match && match[1]) {
|
||||
// Re-render the open detail page too, in case what changed is the plugin being viewed
|
||||
// right now (e.g. it just got marked broken).
|
||||
showDetail(match[1]);
|
||||
showToast("Updated");
|
||||
} else {
|
||||
showToast("Updated");
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (!isInitial) return;
|
||||
count.textContent = "";
|
||||
empty.hidden = false;
|
||||
empty.textContent = "Couldn't load the plugin list - try refreshing.";
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One shared WebSocket room (api/src/index.ts's OverlayHub Durable Object) that every open tab
|
||||
* connects to - any successful admin write broadcasts through it, so a change from the admin
|
||||
* plugin shows up here without needing a manual refresh. Reconnects with backoff if it drops
|
||||
* (Cloudflare recycling the connection, a network blip, etc) rather than just giving up.
|
||||
*/
|
||||
var liveRetryDelay = 2000;
|
||||
function connectLive() {
|
||||
var proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
var ws;
|
||||
try {
|
||||
ws = new WebSocket(proto + "//" + location.host + "/ws");
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
|
||||
ws.addEventListener("open", function () { liveRetryDelay = 2000; });
|
||||
ws.addEventListener("message", function () { reloadPlugins(false); });
|
||||
ws.addEventListener("close", function () {
|
||||
setTimeout(connectLive, liveRetryDelay);
|
||||
liveRetryDelay = Math.min(liveRetryDelay * 1.5, 30000);
|
||||
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.";
|
||||
});
|
||||
ws.addEventListener("error", function () { ws.close(); });
|
||||
}
|
||||
|
||||
reloadPlugins(true);
|
||||
connectLive();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
1
better-timestamps/install/index.js
Normal file
1
better-timestamps/install/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(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);
|
||||
1
better-timestamps/install/manifest.json
Normal file
1
better-timestamps/install/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
{
|
||||
"updated_at": "2026-09-07T19:51:24.372Z",
|
||||
"list": ["997993767091052654"]
|
||||
}
|
||||
123
build.mjs
123
build.mjs
|
|
@ -1,123 +0,0 @@
|
|||
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 (_) {}
|
||||
736
copy-role-color/index.html
Normal file
736
copy-role-color/index.html
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
<!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>⌘</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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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">← 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>
|
||||
1
copy-role-color/install/index.js
Normal file
1
copy-role-color/install/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(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);
|
||||
1
copy-role-color/install/manifest.json
Normal file
1
copy-role-color/install/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
736
fake-profile-themes-and-effects/index.html
Normal file
736
fake-profile-themes-and-effects/index.html
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
<!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>⌘</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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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">← 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>
|
||||
1
fake-profile-themes-and-effects/install/index.js
Normal file
1
fake-profile-themes-and-effects/install/index.js
Normal file
File diff suppressed because one or more lines are too long
1
fake-profile-themes-and-effects/install/manifest.json
Normal file
1
fake-profile-themes-and-effects/install/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
736
index.html
Normal file
736
index.html
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
<!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>⌘</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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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">← 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>
|
||||
736
key-inspector/index.html
Normal file
736
key-inspector/index.html
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
<!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>⌘</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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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">← 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>
|
||||
32
key-inspector/install/index.js
Normal file
32
key-inspector/install/index.js
Normal file
File diff suppressed because one or more lines are too long
1
key-inspector/install/manifest.json
Normal file
1
key-inspector/install/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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"}
|
||||
736
message-snippets/index.html
Normal file
736
message-snippets/index.html
Normal file
|
|
@ -0,0 +1,736 @@
|
|||
<!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>⌘</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 { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →</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">← 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>
|
||||
1
message-snippets/install/index.js
Normal file
1
message-snippets/install/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
(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);
|
||||
1
message-snippets/install/manifest.json
Normal file
1
message-snippets/install/manifest.json
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"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
4467
package-lock.json
generated
File diff suppressed because it is too large
Load diff
22
package.json
22
package.json
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,71 +1,115 @@
|
|||
{
|
||||
"rose-plugs": {
|
||||
"category": "Utility",
|
||||
"status": "new",
|
||||
"accent": "#F47FFF",
|
||||
"tagline": "A home of Rosie's own in Settings, with one-tap installs for anything not installed yet.",
|
||||
"note": "",
|
||||
"howItWorks": "Adds a \"Rosie's Plugs\" section to Discord's Settings, separate from Revenge's own Plugins list. Its first entry, \"Plugins,\" is a quick-install browser pulling live from this site's own plugin catalog, grouped by category, with a one-tap button per row that installs or removes it directly. Every other Rosie-authored plugin that's currently installed gets a direct settings shortcut listed right below it, and RosePlugs' own settings live in that same section too.",
|
||||
[
|
||||
{
|
||||
"id": "fake-profile-themes-and-effects",
|
||||
"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": [
|
||||
"everestmcarthur",
|
||||
"ryan",
|
||||
"シグマ siguma"
|
||||
],
|
||||
"category": "Identity",
|
||||
"status": "revived",
|
||||
"accent": "#EB459E",
|
||||
"tagline": "Profile theming and profile effects without Nitro.",
|
||||
"note": "Fork of shipwr3ckd's FPTE. Previously crashed every time you opened the profile editor - confirmed via on-device diagnostics that several color tokens it referenced (HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, TEXT_NORMAL) no longer exist on current Discord builds. Rebuilt with a fallback chain of real, currently-valid token names instead of a single hardcoded one. A second, separate bug (an unguarded lookup that crashed the whole plugin bundle before it could even load) is also fixed.",
|
||||
"howItWorks": "Encodes your chosen primary color, accent color, and profile effect into invisible zero-width characters appended to your About Me - other people running a compatible plugin (this one, or the original) decode and render it as if you had real Nitro profile theming, without Discord's servers ever knowing. The builder UI for picking colors/effects lives right in the profile editor. Only the two files that were actually confirmed crashing were investigated and fixed; the rest of this plugin's original patches (Nitro upsell card, purchase checks, profile data fetching) are carried over as-is, now with crash isolation so any other latent issue only disables that one patch instead of the whole plugin.",
|
||||
"features": [
|
||||
"New top-level \"Rosie's Plugs\" settings section, positioned right below Revenge's own",
|
||||
"One-tap install/uninstall browser for every plugin in this catalog, grouped by category",
|
||||
"Direct settings shortcuts for each installed Rosie-authored plugin - no digging through Revenge's own Plugins list"
|
||||
"Pick a primary color, accent color, and profile effect from the profile editor",
|
||||
"No Nitro required - works purely by encoding data into your bio",
|
||||
"Crash isolation per patch, so one broken surface doesn't take down the rest"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Only surfaces plugins whose manifest lists \"Rosie\" as an author - a plugin credited to someone else (like Raiden's Themes, credited to the original theme's creator) won't be picked up automatically."
|
||||
"limitations": "Only the confirmed profile-editor crash was root-caused and fixed with real on-device data. The plugin's other patches (Nitro upsell card, purchase/profile data fetching) weren't individually re-audited - crash isolation means a problem there disables just that patch rather than the whole plugin, but hasn't been proven bug-free the way the color-resolution fix has.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/fake-profile-themes-and-effects/",
|
||||
"installUrl": "https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/fake-profile-themes-and-effects",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BFakeProfileThemesAndEffects%5D%20"
|
||||
},
|
||||
"radial-status": {
|
||||
{
|
||||
"id": "pronoun-db",
|
||||
"name": "PronounDB",
|
||||
"description": "Shows a user's pronouns in their profile, if they've set them at pronoundb.org - see the plugin settings for details.",
|
||||
"authors": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"category": "Identity",
|
||||
"status": "revived",
|
||||
"accent": "#9B59B6",
|
||||
"tagline": "Shows a user's pronouns in their profile, pulled from pronoundb.org.",
|
||||
"note": "Previously broken because PronounDB shut down the API it used (HTTP 410) - migrated to their current v2 API. Also fixed the profile-page lookup itself: Discord renamed the top-level profile component to UserProfileContent and the bio card to UserProfileAboutMeCard (confirmed against decompiled current-build source), so both the new and old names are checked. Requires people to have set pronouns at pronoundb.org; nothing to configure in the plugin itself.",
|
||||
"howItWorks": "PronounDB is a separate, independent service (not run by Discord or Revenge) where people manually set their own pronouns once and link the profile to their Discord account. This plugin adds a \"Pronouns\" section near the bio on anyone's profile who has done that. Lookups are batched and cached client-side, so opening a profile for the first time may take a moment while it fetches, and re-opening the same profile later is instant.",
|
||||
"features": [
|
||||
"Adds a native-feeling \"Pronouns\" section to profiles",
|
||||
"Batches and caches lookups so scrolling through many profiles doesn't spam requests",
|
||||
"Nothing to configure - no API key, no toggle"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Only shows pronouns for people who have actually created a PronounDB profile and linked their Discord account. If nobody you interact with has set one, you'll never see the section - that's expected, not a bug.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/pronoun-db/",
|
||||
"installUrl": "https://rp.jarviscli.dev/pronoun-db/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/pronoun-db",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BPronounDB%5D%20"
|
||||
},
|
||||
{
|
||||
"id": "better-timestamps",
|
||||
"name": "BetterTimestamps",
|
||||
"description": "Replace message and day-divider timestamps with calendar-relative, fully relative, ISO 8601, or a custom moment.js format.",
|
||||
"authors": [
|
||||
"everestmcarthur",
|
||||
"nexpid"
|
||||
],
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#23A55A",
|
||||
"tagline": "A colored ring around the avatar instead of a presence dot - similar to Platform Indicator, but for status.",
|
||||
"note": "Fork of AngelW0lf's Radial Status (BSD-3-Clause, see LICENSE). Upstream had a phone-home mechanism - it fetched a remote blacklist on every load and could silently uninstall itself for specific user IDs the original author chose - dropped entirely here, no network requests at all. Upstream only ever matched one exact avatar size (member list-style rows), missing YouBar, the profile screen, and DM lists entirely. On-device capture across all of those contexts confirmed the real presence-carrying wrapper sizes (24, 32, 40, 50, 60, 80px) all share the same shape - square, half-width border radius, exactly 5 children - so this fork matches that confirmed whitelist instead of either upstream's single size or an earlier overly-broad generalization that briefly caused visible corruption elsewhere on-device. Also switched the ring's size growth from a percentage multiplier to a fixed pixel amount: a flat multiplier applied the same 30% growth everywhere, which meant a much bigger absolute size increase on large avatars (confirmed on-device as noticeably oversized on YouBar) than on small ones - fixed-px growth keeps the ring the same absolute thickness regardless of avatar size. Upstream's settings screen had commented-out, non-functional size/thickness inputs and an unfinished joke placeholder button - both removed, with a real working thickness control in their place.",
|
||||
"howItWorks": "Hides the small colored dot Discord shows on an avatar for online/idle/DND/offline, and draws a colored ring around the whole avatar instead - similar in spirit to a platform indicator, but for presence status. Each status's color is independently configurable (including offline, which Discord doesn't normally show a dot for at all), along with the ring's thickness. A status left with no color configured just keeps showing the normal dot.",
|
||||
"accent": "#EB459E",
|
||||
"tagline": "Calendar-relative, fully relative, ISO 8601, or your own custom timestamp format.",
|
||||
"note": "Previously broken (unguarded RowManager lookup). Rebuilt with the same crash-isolation pattern as the rest of this repo.",
|
||||
"howItWorks": "Replaces both message timestamps and the \"day divider\" text in chat with your choice of format: calendar-relative (\"Yesterday at 3:04 PM\"), fully relative (\"2 hours ago\"), ISO 8601, or a fully custom moment.js format string. There's also a toggle to always show the name/avatar/timestamp on every message instead of Discord's default grouping of consecutive messages.",
|
||||
"features": [
|
||||
"Colored ring around the avatar instead of the small presence dot",
|
||||
"Independent color per status: online, idle, do not disturb, offline",
|
||||
"Adjustable ring thickness, sized consistently across every avatar context",
|
||||
"No network requests - upstream's remote blacklist/kill-switch mechanism was removed entirely"
|
||||
"Four built-in formats plus a custom moment.js format string",
|
||||
"Live preview of each format in the settings screen",
|
||||
"Applies to both message timestamps and day dividers",
|
||||
"Optional: always show name/avatar/timestamp per message"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Matches a whitelist of avatar sizes confirmed on-device across YouBar, the profile screen, member lists, and DM lists - not a fully generic circular-wrapper match, since an earlier attempt at that briefly caused visible corruption on unrelated UI elements. A future Discord update introducing a new avatar size in one of these contexts (or elsewhere) wouldn't be picked up until re-confirmed and added to the whitelist."
|
||||
"limitations": "",
|
||||
"pageUrl": "https://rp.jarviscli.dev/better-timestamps/",
|
||||
"installUrl": "https://rp.jarviscli.dev/better-timestamps/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/better-timestamps",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BBetterTimestamps%5D%20"
|
||||
},
|
||||
"custom-user-tags": {
|
||||
{
|
||||
"id": "copy-role-color",
|
||||
"name": "CopyRoleColor",
|
||||
"description": "Long-press a role pill (member list, role list) to copy its color as hex.",
|
||||
"authors": [
|
||||
"everestmcarthur",
|
||||
"MSMA"
|
||||
],
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "new",
|
||||
"accent": "#F0637E",
|
||||
"tagline": "Create your own tags and assign them to specific users - not tied to server permissions.",
|
||||
"note": "Didn't exist in the Revenge/Vendetta plugin ecosystem before. Modeled directly on Staff Tags' proven 4-surface architecture (the chat message tag, the name row in chat/member list/profile, and the native Tag component itself), keyed by user ID instead of permission tier - the same code, resolving a completely different kind of tag.",
|
||||
"howItWorks": "Long-press a name in the member list or a profile popout to open a small editor: type whatever text you want and pick a color, and that becomes their tag - showing next to their name in chat, the member list, and the profile popout, exactly like a Staff Tags tag would. There's no confirmed way in this repo yet to hook Discord's own native long-press menu for a user, so this attaches its own long-press handler directly to the row instead (chaining whatever Discord's own row already does, never replacing it). Tags can also be added from the plugin's own settings screen by pasting a user ID directly, for tagging someone who isn't visible on screen right now.",
|
||||
"status": "revived",
|
||||
"accent": "#1ABC9C",
|
||||
"tagline": "Long-press a role pill to copy its color as hex.",
|
||||
"note": "Previously broken - the component was renamed from ThemedRolePill to just RolePill (confirmed against decompiled current-build Discord source), so the old lookup never found anything. Now checks both names, with defensive checks throughout so a shape mismatch never crashes.",
|
||||
"howItWorks": "Long-press a role pill - in the member list, a role list, wherever Discord renders one as its own element - and the color gets copied to your clipboard as a hex code. Useful for designers, bot builders, or staff matching embed colors to a role.",
|
||||
"features": [
|
||||
"Long-press a member list row or profile popout to tag someone",
|
||||
"Custom tag text and a full color picker, not limited to preset tiers",
|
||||
"Also shows in chat messages next to the author's name",
|
||||
"Add/remove tags by user ID from the settings screen",
|
||||
"Per-user, not per-permission - works for anyone regardless of their server roles"
|
||||
"Works on any rendered role pill, not just one specific screen",
|
||||
"Copies a ready-to-paste hex code (e.g. #5865F2)"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Chat message tags are built from plain data passed to Discord's native row renderer, not a patchable React element, so they always render as a solid color there - same platform limitation Staff Tags documents. Long-press to open the editor is a plugin-attached handler, not a native Discord menu item, since no confirmed way to add one exists yet."
|
||||
"limitations": "Depends on Discord's internal role pill component keeping a similar shape - guarded so a mismatch just means no long-press handler gets added, not a crash.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/copy-role-color/",
|
||||
"installUrl": "https://rp.jarviscli.dev/copy-role-color/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/copy-role-color",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BCopyRoleColor%5D%20"
|
||||
},
|
||||
"staff-tags": {
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "rebuilt",
|
||||
"accent": "#F23F42",
|
||||
"tagline": "OWNER/ADMIN/STAFF/MOD tags, computed from real permissions - not role names.",
|
||||
"note": "Previously broken. Rebuilt with per-patch crash isolation, plus new customization: per-tag text, colors, gradients, and visibility.",
|
||||
"howItWorks": "Staff Tags looks at what a member can actually do in a server - Administrator, Manage Server, Manage Messages, Kick/Ban, and so on - and shows a small colored tag next to their name based on the highest-matching tier. It doesn't care what a role is named, so a role called \"Cool Person\" with Manage Messages still shows MOD. Tags appear in chat messages, the member list, the profile popout, and channel headers.\n\nThe old version of this plugin patched four separate Discord UI surfaces, and if Discord renamed even one internal component, the whole plugin's setup would throw and every tag everywhere would silently disappear. This rebuild wraps each of the four patches in its own guard, so a single broken surface degrades gracefully instead of taking the other three down with it.",
|
||||
"features": [
|
||||
"Seven built-in tiers: Webhook, Owner, Admin, Staff, Mod, VC Mod, Chat Mod",
|
||||
"Per-tag show/hide toggle",
|
||||
"Custom tag text (rename \"MOD\" to whatever you want)",
|
||||
"Custom solid colors with a swatch picker",
|
||||
"Gradient tags in the member list and profile (chat tags stay solid - platform limitation, explained below)",
|
||||
"\"Use top role color\" fallback when no custom color is set"
|
||||
{
|
||||
"id": "role-color-everywhere",
|
||||
"name": "RoleColorEverywhere",
|
||||
"description": "Displays a member's top role color in more places: mentions, the typing indicator, voice channel participant names, member list role headers, and optionally message text.",
|
||||
"authors": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Gradient colors only render in the member list and profile popout. Chat message tags are built from plain data passed to Discord's native row renderer, not a patchable React element, so there's no element to swap in a gradient view - they always fall back to a solid color there."
|
||||
},
|
||||
"role-color-everywhere": {
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#5865F2",
|
||||
|
|
@ -81,24 +125,106 @@
|
|||
"Every surface can be toggled independently"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "The member list header coloring works by patching a generic, unnamed internal component (there's no dedicated \"RoleHeader\" component to target), which makes it the single most fragile surface in this plugin. If a future Discord update breaks it, the other four surfaces are unaffected."
|
||||
"limitations": "The member list header coloring works by patching a generic, unnamed internal component (there's no dedicated \"RoleHeader\" component to target), which makes it the single most fragile surface in this plugin. If a future Discord update breaks it, the other four surfaces are unaffected.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/role-color-everywhere/",
|
||||
"installUrl": "https://rp.jarviscli.dev/role-color-everywhere/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/role-color-everywhere",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BRoleColorEverywhere%5D%20"
|
||||
},
|
||||
"pronoun-db": {
|
||||
"category": "Identity",
|
||||
{
|
||||
"id": "server-drawer",
|
||||
"name": "ServerDrawer",
|
||||
"description": "Replaces the Quest Dock with a server grid drawer above the YouBar - tap to expand, long-press a server for its context menu, plus a one-tap create/join button.",
|
||||
"authors": [
|
||||
"everestmcarthur",
|
||||
"kmmiio99o",
|
||||
"fres"
|
||||
],
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#9B59B6",
|
||||
"tagline": "Shows a user's pronouns in their profile, pulled from pronoundb.org.",
|
||||
"note": "Previously broken because PronounDB shut down the API it used (HTTP 410) - migrated to their current v2 API. Also fixed the profile-page lookup itself: Discord renamed the top-level profile component to UserProfileContent and the bio card to UserProfileAboutMeCard (confirmed against decompiled current-build source), so both the new and old names are checked. Requires people to have set pronouns at pronoundb.org; nothing to configure in the plugin itself.",
|
||||
"howItWorks": "PronounDB is a separate, independent service (not run by Discord or Revenge) where people manually set their own pronouns once and link the profile to their Discord account. This plugin adds a \"Pronouns\" section near the bio on anyone's profile who has done that. Lookups are batched and cached client-side, so opening a profile for the first time may take a moment while it fetches, and re-opening the same profile later is instant.",
|
||||
"accent": "#F97316",
|
||||
"tagline": "An expandable server grid with DMs, smarter folders, and mark-all-read built in.",
|
||||
"note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface. Folder options (auto-collapse, hide preview icons) are adapted from fres621's BetterFolders - its icon-hiding half patched a \"GuildContainer\" component that no longer exists anywhere in current Discord builds (confirmed against decompiled source), and only applied to the native server rail anyway, which this plugin already replaces - so it's been rebuilt as a feature of the drawer's own folders instead. Two more bugs fixed after real device testing: guild icons that never loaded (GuildStore lookups weren't reactive to guild data streaming in after connect, so most servers just showed a bare unread dot with no icon) and taps in the drawer not navigating anywhere (several lookups - transitionToGuild, the create/join action, the root nav ref - were resolved once at plugin-load time, before Discord had necessarily required those modules yet, same class of bug as YouBar+'s startup race). Both fixed; DMs also moved from a leftover sidebar icon into the drawer itself, and a mark-all-servers-as-read button was added. Two more rounds after that: the Quest Dock hijack (10 separate internal lookups just to force it to render) used to be attempted once at onLoad, so if any single one of them wasn't registered in Metro yet the drawer could just silently not show up for the whole session - it now retries whatever hasn't landed on a fast 200ms interval until everything's applied. And mark-all-read was rebuilt to match Discord's own markGuildsAsRead action exactly (getSelectableChannelIds + getVocalChannelIds per guild, confirmed against decompiled source) instead of a less certain guild-enumeration method. The left server rail hiding was also fixed properly this round: the rail's root component has no discoverable name at runtime at all (confirmed against decompiled source - it's a bare default export, and unlike memo-wrapped components with an explicit displayName, a plain function's own .name doesn't survive production minification), so every name-based lookup for it was guaranteed to fail. Now intercepted by its outermost View's literal nativeID (\"guilds-bar-view\", confirmed unique in the bundle) instead. The long-press context menu on a server icon had the exact same problem (getGuildsBarGuildMenuItems is also a bare default export) - replaced with a small menu of actions built from APIs this repo has already confirmed work (mark this server as read, copy server ID) instead of a lookup that could never succeed. The same caching bug affected several of ServerDrawer's own retry-based lookups (the Quest Dock hijack's fast-retry loop, and every findByProps call wrapped in the lazy() helper for navigation/action lookups) - all switched to a raw, uncached module scan for the specific calls that get retried, since a retried cached lookup was silently a no-op after its first failure.",
|
||||
"howItWorks": "Hijacks the Quest Dock - the strip Discord normally uses for daily quests, right above the tab bar - and puts a grid of your servers there instead. Tap it to expand, tap a server to jump to it, long-press for the usual context menu (mark as read, notification settings, leave, etc). A + button opens Discord's own create/join-server modal. Optionally hides the left server rail down to a single DMs icon, since your servers now live in the drawer instead.",
|
||||
"features": [
|
||||
"Adds a native-feeling \"Pronouns\" section to profiles",
|
||||
"Batches and caches lookups so scrolling through many profiles doesn't spam requests",
|
||||
"Nothing to configure - no API key, no toggle"
|
||||
"Tap-to-expand server grid with folders, unread badges, and mention counts",
|
||||
"Long-press a server for its real context menu, not a rebuilt one",
|
||||
"One-tap create/join through Discord's native modal",
|
||||
"Optional: collapse the left server rail to a single DMs icon",
|
||||
"Toggle unread badges independently",
|
||||
"Auto-collapse other open folders when you open a new one",
|
||||
"Optional plain folder icon instead of the 4-server preview when collapsed",
|
||||
"DMs tile in the drawer instead of a separate sidebar icon",
|
||||
"Mark every channel in every server as read in one tap"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Only shows pronouns for people who have actually created a PronounDB profile and linked their Discord account. If nobody you interact with has set one, you'll never see the section - that's expected, not a bug."
|
||||
"limitations": "Depends on several internal Quest Dock lookups (11 in total) - each is individually guarded, so a future Discord rename degrades one surface at a time instead of breaking the whole plugin. Drawer position (left/right/top) is still planned but not in this build yet - it currently only replaces the Quest Dock's native bottom position.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/server-drawer/",
|
||||
"installUrl": "https://rp.jarviscli.dev/server-drawer/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/server-drawer",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BServerDrawer%5D%20"
|
||||
},
|
||||
"message-snippets": {
|
||||
{
|
||||
"id": "staff-tags",
|
||||
"name": "Staff Tags",
|
||||
"description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.",
|
||||
"authors": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "rebuilt",
|
||||
"accent": "#F23F42",
|
||||
"tagline": "OWNER/ADMIN/STAFF/MOD tags, computed from real permissions - not role names.",
|
||||
"note": "Previously broken. Rebuilt with per-patch crash isolation, plus new customization: per-tag text, colors, gradients, and visibility.",
|
||||
"howItWorks": "Staff Tags looks at what a member can actually do in a server - Administrator, Manage Server, Manage Messages, Kick/Ban, and so on - and shows a small colored tag next to their name based on the highest-matching tier. It doesn't care what a role is named, so a role called \"Cool Person\" with Manage Messages still shows MOD. Tags appear in chat messages, the member list, the profile popout, and channel headers.\n\nThe old version of this plugin patched four separate Discord UI surfaces, and if Discord renamed even one internal component, the whole plugin's setup would throw and every tag everywhere would silently disappear. This rebuild wraps each of the four patches in its own guard, so a single broken surface degrades gracefully instead of taking the other three down with it.",
|
||||
"features": [
|
||||
"Seven built-in tiers: Webhook, Owner, Admin, Staff, Mod, VC Mod, Chat Mod",
|
||||
"Per-tag show/hide toggle",
|
||||
"Custom tag text (rename \"MOD\" to whatever you want)",
|
||||
"Custom solid colors with a swatch picker",
|
||||
"Gradient tags in the member list and profile (chat tags stay solid - platform limitation, explained below)",
|
||||
"\"Use top role color\" fallback when no custom color is set"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Gradient colors only render in the member list and profile popout. Chat message tags are built from plain data passed to Discord's native row renderer, not a patchable React element, so there's no element to swap in a gradient view - they always fall back to a solid color there.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/staff-tags/",
|
||||
"installUrl": "https://rp.jarviscli.dev/staff-tags/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/staff-tags",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BStaff%20Tags%5D%20"
|
||||
},
|
||||
{
|
||||
"id": "you-bar-plus",
|
||||
"name": "YouBar+",
|
||||
"description": "Adds Settings and DM buttons to the YouBar. (May require a restart after enabling.)",
|
||||
"authors": [
|
||||
"everestmcarthur",
|
||||
"Purple_Ξye™"
|
||||
],
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#5865F2",
|
||||
"tagline": "Adds DM and/or Settings buttons to the YouBar.",
|
||||
"note": "Fork of Purple-EyeZ's YouBar+. Fixed a startup race condition where the button patch could silently never apply for an entire session - the original did a single one-shot lookup for YouBarNotificationsButton at onLoad and gave up permanently if it wasn't registered in Metro yet, which restarting Discord only fixed by luck. Now retries until the component shows up. A second, deeper bug surfaced after that fix shipped: Revenge's own module finder permanently caches a \"not found\" result per search and never rescans, even after the target registers later - confirmed by reading Revenge's own source. That made the retry loop a no-op whenever the very first attempt happened too early, which is exactly the scenario it existed to handle. Now uses a raw, uncached module scan specifically for the retried lookup.",
|
||||
"howItWorks": "The YouBar is the row near your avatar/status at the top of the app. This plugin patches its notification button area to also render a DM button and/or a Settings button, each independently toggleable. DM jumps straight to your DMs; Settings opens user settings.",
|
||||
"features": [
|
||||
"Independent toggles for the DM and Settings buttons",
|
||||
"Settings changes apply immediately, no restart needed",
|
||||
"Startup race condition fixed - polls for the target component instead of a single one-shot lookup"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Depends on YouBarNotificationsButton keeping a similar render shape - guarded so a mismatch just means no extra buttons, not a crash.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/you-bar-plus/",
|
||||
"installUrl": "https://rp.jarviscli.dev/you-bar-plus/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/you-bar-plus",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BYouBar%2B%5D%20"
|
||||
},
|
||||
{
|
||||
"id": "message-snippets",
|
||||
"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": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"category": "Productivity",
|
||||
"status": "new",
|
||||
"accent": "#23A55A",
|
||||
|
|
@ -129,9 +255,19 @@
|
|||
"desc": "Shows your saved snippet names as a toast (not posted publicly)"
|
||||
}
|
||||
],
|
||||
"limitations": ""
|
||||
"limitations": "",
|
||||
"pageUrl": "https://rp.jarviscli.dev/message-snippets/",
|
||||
"installUrl": "https://rp.jarviscli.dev/message-snippets/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/message-snippets",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BMessage%20Snippets%5D%20"
|
||||
},
|
||||
"reminders": {
|
||||
{
|
||||
"id": "reminders",
|
||||
"name": "Reminders",
|
||||
"description": "Set quick reminders with /remind <time> <text>, e.g. /remind 20m Walk the dog. Fires while Discord is running in the foreground or background - it can't wake the app up if it's been fully closed.",
|
||||
"authors": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"category": "Productivity",
|
||||
"status": "new",
|
||||
"accent": "#F0B232",
|
||||
|
|
@ -149,91 +285,19 @@
|
|||
"desc": "e.g. /remind 20m Walk the dog - fires an in-app alert when it's due"
|
||||
}
|
||||
],
|
||||
"limitations": "Can't wake Discord up from fully closed - that needs a native OS-level scheduled notification, which isn't something a JS-only plugin has access to. If you force-close the app, pending reminders resume checking once you reopen it, but won't have fired while it was closed."
|
||||
"limitations": "Can't wake Discord up from fully closed - that needs a native OS-level scheduled notification, which isn't something a JS-only plugin has access to. If you force-close the app, pending reminders resume checking once you reopen it, but won't have fired while it was closed.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/reminders/",
|
||||
"installUrl": "https://rp.jarviscli.dev/reminders/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/reminders",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BReminders%5D%20"
|
||||
},
|
||||
"view-raw": {
|
||||
"category": "Utility",
|
||||
"status": "revived",
|
||||
"accent": "#38BDF8",
|
||||
"tagline": "See a message's raw JSON - searchable, syntax-highlighted, and copyable.",
|
||||
"note": "Fork of bwlok's ViewRaw (GPLv3 - see the plugin's own LICENSE file). Fixed a crash that hit every message long-press, then rebuilt the viewer with search and syntax highlighting on top.",
|
||||
"howItWorks": "Long-press any message and pick \"View Raw\" to see exactly what Discord sent for it - every field, not just the rendered text. The original plugin crashed the long-press menu on every message because it assumed a specific internal icon shape without checking it first; this fork guards that assumption and everything else in the same patch. The viewer itself was rebuilt: a search box highlights matching lines (dimming the rest, not hiding them, so you keep the structure for context), values are color-coded by type, and there are one-tap copy buttons for either just the message content or the full JSON.",
|
||||
"features": [
|
||||
"Search box highlights matching keys/values across the whole payload",
|
||||
"Syntax-colored JSON (strings, numbers, booleans, null all render differently)",
|
||||
"Separate \"Copy Content\" and \"Copy JSON\" buttons",
|
||||
"Crash fixed: no longer breaks the message long-press menu"
|
||||
{
|
||||
"id": "reverse-image-search",
|
||||
"name": "Reverse Image Search",
|
||||
"description": "/reverse-image-search <image-url> - open an image link in Google Lens, Yandex, or TinEye.",
|
||||
"authors": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Licensed GPLv3 (inherited from the original), not the same terms as the rest of this repo - see plugins/view-raw/LICENSE. The long-press button relies on detecting Discord's internal action-sheet layout (not an official API) - the plugin's settings screen shows diagnostics if it ever stops appearing."
|
||||
},
|
||||
"key-inspector": {
|
||||
"category": "Utility",
|
||||
"status": "new",
|
||||
"accent": "#57F287",
|
||||
"tagline": "One-tap diagnostic scan of every internal key this repo's plugins depend on - copy the report, no computer needed.",
|
||||
"note": "Built to support fixing other broken plugins in this ecosystem entirely from a phone: most crashes come from a plugin assuming an internal key/component still exists after a Discord update, and this lets anyone check on-device and share the exact result.",
|
||||
"howItWorks": "The main feature is a full scan: one button runs every internal lookup (findByProps, findByName, findByStoreName, specific semanticColors tokens) that this repo's plugins actually depend on, all in one pass, and copies a complete found/missing report plus the full current semanticColors and rawColors key lists to your clipboard. There's also a manual search section for anything not already in the scan list - type prop names or a component name and copy what findByProps/findByName returns for it. Nothing leaves your device; everything only touches your clipboard.",
|
||||
"features": [
|
||||
"Full scan: every known lookup, checked in one tap, with a copyable report",
|
||||
"Full Vendetta API tree: every key path Revenge exposes to plugins, 3 levels deep, in one copy",
|
||||
"Manual findByProps(...) / findByName(...) search for anything else",
|
||||
"Includes the complete semanticColors and rawColors key lists",
|
||||
"No network requests, no data collection - clipboard only"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "The full-scan list only covers lookups this repo's plugins currently use - it's meant to grow as more plugins get added or fixed. The API tree covers Revenge's own plugin API (window.vendetta), not Discord's raw internal module registry, which is far too large to dump wholesale - use the search tools for that."
|
||||
},
|
||||
"better-timestamps": {
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#EB459E",
|
||||
"tagline": "Calendar-relative, fully relative, ISO 8601, or your own custom timestamp format.",
|
||||
"note": "Previously broken (unguarded RowManager lookup). Rebuilt with the same crash-isolation pattern as the rest of this repo.",
|
||||
"howItWorks": "Replaces both message timestamps and the \"day divider\" text in chat with your choice of format: calendar-relative (\"Yesterday at 3:04 PM\"), fully relative (\"2 hours ago\"), ISO 8601, or a fully custom moment.js format string. There's also a toggle to always show the name/avatar/timestamp on every message instead of Discord's default grouping of consecutive messages.",
|
||||
"features": [
|
||||
"Four built-in formats plus a custom moment.js format string",
|
||||
"Live preview of each format in the settings screen",
|
||||
"Applies to both message timestamps and day dividers",
|
||||
"Optional: always show name/avatar/timestamp per message"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": ""
|
||||
},
|
||||
"copy-role-color": {
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#1ABC9C",
|
||||
"tagline": "Long-press a role pill to copy its color as hex.",
|
||||
"note": "Previously broken - the component was renamed from ThemedRolePill to just RolePill (confirmed against decompiled current-build Discord source), so the old lookup never found anything. Now checks both names, with defensive checks throughout so a shape mismatch never crashes.",
|
||||
"howItWorks": "Long-press a role pill - in the member list, a role list, wherever Discord renders one as its own element - and the color gets copied to your clipboard as a hex code. Useful for designers, bot builders, or staff matching embed colors to a role.",
|
||||
"features": [
|
||||
"Works on any rendered role pill, not just one specific screen",
|
||||
"Copies a ready-to-paste hex code (e.g. #5865F2)"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Depends on Discord's internal role pill component keeping a similar shape - guarded so a mismatch just means no long-press handler gets added, not a crash."
|
||||
},
|
||||
"urban-dictionary": {
|
||||
"category": "Productivity",
|
||||
"status": "revived",
|
||||
"accent": "#F0B232",
|
||||
"tagline": "Look up a definition without leaving Discord.",
|
||||
"note": "Previously broken (dead proxy host). Rebuilt from scratch on the stable command API - no UI patching involved.",
|
||||
"howItWorks": "Runs your term against Urban Dictionary's public API and posts the top result - definition, example, and the up/downvote count - directly into the channel. Built entirely on the same stable command API as Message Snippets and Reminders, so it doesn't carry the UI-patching fragility that most of this repo's revived plugins have to guard against.",
|
||||
"features": [
|
||||
"Posts the definition, an example, and vote counts",
|
||||
"Truncates long entries so they stay readable in chat"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"cmd": "/urban <term>",
|
||||
"desc": "Looks up a term and posts the top Urban Dictionary result"
|
||||
}
|
||||
],
|
||||
"limitations": "Urban Dictionary's community submissions are what they are - results aren't moderated by this plugin."
|
||||
},
|
||||
"reverse-image-search": {
|
||||
"category": "Productivity",
|
||||
"status": "new",
|
||||
"accent": "#5865F2",
|
||||
|
|
@ -250,77 +314,125 @@
|
|||
"desc": "Opens the image in your browser's reverse image search"
|
||||
}
|
||||
],
|
||||
"limitations": "Needs a direct image URL, not a Discord attachment reference - use \"Copy Media Link\" first."
|
||||
"limitations": "Needs a direct image URL, not a Discord attachment reference - use \"Copy Media Link\" first.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/reverse-image-search/",
|
||||
"installUrl": "https://rp.jarviscli.dev/reverse-image-search/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/reverse-image-search",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BReverse%20Image%20Search%5D%20"
|
||||
},
|
||||
"fake-profile-themes-and-effects": {
|
||||
"category": "Identity",
|
||||
{
|
||||
"id": "urban-dictionary",
|
||||
"name": "Urban Dictionary",
|
||||
"description": "/urban <term> - look up a definition from Urban Dictionary and send it to the channel.",
|
||||
"authors": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"category": "Productivity",
|
||||
"status": "revived",
|
||||
"accent": "#EB459E",
|
||||
"tagline": "Profile theming and profile effects without Nitro.",
|
||||
"note": "Fork of shipwr3ckd's FPTE. Previously crashed every time you opened the profile editor - confirmed via on-device diagnostics that several color tokens it referenced (HEADER_SECONDARY, BACKGROUND_PRIMARY, BACKGROUND_FLOATING, TEXT_NORMAL) no longer exist on current Discord builds. Rebuilt with a fallback chain of real, currently-valid token names instead of a single hardcoded one. A second, separate bug (an unguarded lookup that crashed the whole plugin bundle before it could even load) is also fixed.",
|
||||
"howItWorks": "Encodes your chosen primary color, accent color, and profile effect into invisible zero-width characters appended to your About Me - other people running a compatible plugin (this one, or the original) decode and render it as if you had real Nitro profile theming, without Discord's servers ever knowing. The builder UI for picking colors/effects lives right in the profile editor. Only the two files that were actually confirmed crashing were investigated and fixed; the rest of this plugin's original patches (Nitro upsell card, purchase checks, profile data fetching) are carried over as-is, now with crash isolation so any other latent issue only disables that one patch instead of the whole plugin.",
|
||||
"accent": "#F0B232",
|
||||
"tagline": "Look up a definition without leaving Discord.",
|
||||
"note": "Previously broken (dead proxy host). Rebuilt from scratch on the stable command API - no UI patching involved.",
|
||||
"howItWorks": "Runs your term against Urban Dictionary's public API and posts the top result - definition, example, and the up/downvote count - directly into the channel. Built entirely on the same stable command API as Message Snippets and Reminders, so it doesn't carry the UI-patching fragility that most of this repo's revived plugins have to guard against.",
|
||||
"features": [
|
||||
"Pick a primary color, accent color, and profile effect from the profile editor",
|
||||
"No Nitro required - works purely by encoding data into your bio",
|
||||
"Crash isolation per patch, so one broken surface doesn't take down the rest"
|
||||
"Posts the definition, an example, and vote counts",
|
||||
"Truncates long entries so they stay readable in chat"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"cmd": "/urban <term>",
|
||||
"desc": "Looks up a term and posts the top Urban Dictionary result"
|
||||
}
|
||||
],
|
||||
"limitations": "Urban Dictionary's community submissions are what they are - results aren't moderated by this plugin.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/urban-dictionary/",
|
||||
"installUrl": "https://rp.jarviscli.dev/urban-dictionary/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/urban-dictionary",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BUrban%20Dictionary%5D%20"
|
||||
},
|
||||
{
|
||||
"id": "key-inspector",
|
||||
"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": [
|
||||
"everestmcarthur"
|
||||
],
|
||||
"category": "Utility",
|
||||
"status": "new",
|
||||
"accent": "#57F287",
|
||||
"tagline": "One-tap diagnostic scan of every internal key this repo's plugins depend on - copy the report, no computer needed.",
|
||||
"note": "Built to support fixing other broken plugins in this ecosystem entirely from a phone: most crashes come from a plugin assuming an internal key/component still exists after a Discord update, and this lets anyone check on-device and share the exact result.",
|
||||
"howItWorks": "The main feature is a full scan: one button runs every internal lookup (findByProps, findByName, findByStoreName, specific semanticColors tokens) that this repo's plugins actually depend on, all in one pass, and copies a complete found/missing report plus the full current semanticColors and rawColors key lists to your clipboard. There's also a manual search section for anything not already in the scan list - type prop names or a component name and copy what findByProps/findByName returns for it. Nothing leaves your device; everything only touches your clipboard.",
|
||||
"features": [
|
||||
"Full scan: every known lookup, checked in one tap, with a copyable report",
|
||||
"Full Vendetta API tree: every key path Revenge exposes to plugins, 3 levels deep, in one copy",
|
||||
"Manual findByProps(...) / findByName(...) search for anything else",
|
||||
"Includes the complete semanticColors and rawColors key lists",
|
||||
"No network requests, no data collection - clipboard only"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Only the confirmed profile-editor crash was root-caused and fixed with real on-device data. The plugin's other patches (Nitro upsell card, purchase/profile data fetching) weren't individually re-audited - crash isolation means a problem there disables just that patch rather than the whole plugin, but hasn't been proven bug-free the way the color-resolution fix has."
|
||||
"limitations": "The full-scan list only covers lookups this repo's plugins currently use - it's meant to grow as more plugins get added or fixed. The API tree covers Revenge's own plugin API (window.vendetta), not Discord's raw internal module registry, which is far too large to dump wholesale - use the search tools for that.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/key-inspector/",
|
||||
"installUrl": "https://rp.jarviscli.dev/key-inspector/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/key-inspector",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BKey%20Inspector%5D%20"
|
||||
},
|
||||
"rose-utils": {
|
||||
{
|
||||
"id": "rose-utils",
|
||||
"name": "RoseUtils",
|
||||
"description": "A small collection of independent utilities - Spotify listen-along, a Tenor download fix, colorful channel icons, and more. Fork of nexpid's NexxUtils.",
|
||||
"authors": [
|
||||
"everestmcarthur",
|
||||
"nexpid"
|
||||
],
|
||||
"category": "Utility",
|
||||
"status": "revived",
|
||||
"accent": "#F0637E",
|
||||
"tagline": "Spotify listen-along, colorful channels, a full Message Logger, and more - independently toggleable modules.",
|
||||
"note": "Fork of nexpid's NexxUtils. Originally revived by fixing a class-vs-function-component crash in Spotify listen-along and a build bug that silently dropped every module's own error/patch tracking. Since then: Developer Mode was expanded into a general dev-tools module (message and server info copy-ID tools too), the Tenor GIF fix was dropped (no longer needed), and the settings screen gained search, mass enable/disable, and collapsible categories as the module count grew. Newest addition: Message Logger, a full deleted/edited message logger with a searchable on-device viewer, persisted across restarts.",
|
||||
"howItWorks": "Each utility is its own independently toggleable module, with its own settings shown once enabled: Spotify listen-along buttons on activities, a Spotify listen-along invite button in the attachment menu, a \"minimod\" showing member timeouts in chat, colorful/NSFW-tagged channel icons, an expanded Developer Mode (keeps Discord's own toggle on, adds copy-ID rows for slash-command interactions and activity/game IDs), extra message long-press copy-ID tools (webhook ID, nonce, attachment IDs, replied-to message ID, debug info), server info/friends-in-server tools, a snowflake ID timestamp decoder, and Message Logger. The settings screen has a search box, per-category collapse, and bulk enable/disable, since the module count has grown past what a flat list handles well. Every module tracks its own errors and shows a one-tap \"copy report\" row if something goes wrong.",
|
||||
"tagline": "Spotify listen-along, a Tenor fix, colorful channels, and more - now actually working.",
|
||||
"note": "Fork of nexpid's NexxUtils. Fixed the reported \"render is not a function in SpotifyPlayButton\" crash - Discord converted it from a class component to a function component, confirmed against decompiled current-build source, so patching .prototype.render no longer worked. Also fixed six other lookups across the plugin that had the same unguarded pattern that crashed FPTE's whole bundle at load time - any one of them failing would have taken the entire plugin down, not just one module. A more serious bug was found and fixed after real device testing: opening RoseUtils' own settings screen crashed outright with \"Cannot convert undefined value to object\". Root cause, confirmed by reading the compiled bundle directly: this repo's SWC build config lowers class syntax to plain functions for compatibility, but doesn't also handle the separate class-fields transform, so every field initializer on the internal Module class (errors, patches, started, listeners) was silently dropped from the compiled output - every module was missing all four. Fixed by assigning them in the constructor body instead, which every build target handles the same way. Also added a new module: Enforce Developer Mode, which keeps Discord's Developer Mode switched on since it tends to reset itself on Revenge. Minimod's timeout indicator was fixed too: GuildMemberStore.getCommunicationDisabledUserMap doesn't exist anymore (confirmed against decompiled source) - it now reads getMember(guildId, userId).communicationDisabledUntil per member instead, which does. Enforce Developer Mode also gained a 5-second poll as a backup to the settings-change listener, so it can't stay off for long no matter what actually reset it.",
|
||||
"howItWorks": "Each utility is its own independently toggleable module: Spotify listen-along buttons on activities, a fix for Tenor GIFs downloading as videos, an invite-to-listen-along button in the chat attachment menu, a fun \"minimod\" that shows member timeouts in chat, and colorful/NSFW-tagged channel icons. Every module tracks its own errors and shows a one-tap \"copy report\" row if something goes wrong, so a problem in one module is easy to report without digging through logs. The original's Material-Design component reskin (Switch/Toast/Alert) wasn't ported - it depends on react-native-reanimated, which isn't part of this build, and it was already disabled by default upstream due to known issues.",
|
||||
"features": [
|
||||
"Spotify listen-along buttons on activities, plus an invite button in the attachment menu",
|
||||
"Message Logger: deleted/edited message log, persisted on-device, searchable viewer, ignore lists, keyword filters, per-channel/total/age limits",
|
||||
"Spotify listen-along buttons on activities (fixed)",
|
||||
"Tenor GIF download fix",
|
||||
"Send-a-Spotify-invite button in the attachment menu",
|
||||
"Minimod: shows member timeouts in chat",
|
||||
"Colorful, NSFW-tagged channel icons",
|
||||
"Expanded Developer Mode with extra copy-ID tools (commands, activities, webhooks, nonces, attachments, replies, server info)",
|
||||
"Snowflake ID timestamp decoder",
|
||||
"Per-module error tracking with one-tap copy for bug reports",
|
||||
"Settings screen: search, collapsible categories, bulk enable/disable"
|
||||
"Enforce Developer Mode - keeps it on since it resets itself on Revenge-based clients"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "The original's Material-Design component reskin isn't included (needs react-native-reanimated, not part of this build). Message Logger can only log messages Discord's own client has actually cached locally - it can't recover a deleted message it never saw, and attachment files themselves aren't re-hosted after Discord's CDN removes them (only the URL/filename/metadata are kept)."
|
||||
"limitations": "The Material-Design component reskin from the original isn't included (needs react-native-reanimated). Each module was fixed and defensively guarded, but only the specific reported crash was verified against live decompiled source - the rest are guarded, not individually confirmed working on-device.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/rose-utils/",
|
||||
"installUrl": "https://rp.jarviscli.dev/rose-utils/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/rose-utils",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BRoseUtils%5D%20"
|
||||
},
|
||||
"server-drawer": {
|
||||
"category": "Moderation & Appearance",
|
||||
{
|
||||
"id": "view-raw",
|
||||
"name": "ViewRaw",
|
||||
"description": "View a message's raw JSON data - searchable, syntax-highlighted, and copyable. Fork of bwlok's ViewRaw with a crash fix and a nicer viewer.",
|
||||
"authors": [
|
||||
"everestmcarthur",
|
||||
"sapphire",
|
||||
"Vendicated",
|
||||
"Bwlok"
|
||||
],
|
||||
"category": "Utility",
|
||||
"status": "revived",
|
||||
"accent": "#F97316",
|
||||
"tagline": "An expandable server grid with DMs, smarter folders, and mark-all-read built in.",
|
||||
"note": "Fork of kmmiio99o's ServerDrawer. One lookup (useMobileQuestDock) was missing an optional-chaining guard - if that lookup came back empty, the whole onLoad threw and every patch after it in the sequence never got applied, not just that one surface. Fixed, plus every patch call is now wrapped so a single bad lookup only skips its own surface. Folder options (auto-collapse, hide preview icons) are adapted from fres621's BetterFolders - its icon-hiding half patched a \"GuildContainer\" component that no longer exists anywhere in current Discord builds (confirmed against decompiled source), and only applied to the native server rail anyway, which this plugin already replaces - so it's been rebuilt as a feature of the drawer's own folders instead. Two more bugs fixed after real device testing: guild icons that never loaded (GuildStore lookups weren't reactive to guild data streaming in after connect, so most servers just showed a bare unread dot with no icon) and taps in the drawer not navigating anywhere (several lookups - transitionToGuild, the create/join action, the root nav ref - were resolved once at plugin-load time, before Discord had necessarily required those modules yet, same class of bug as YouBar+'s startup race). Both fixed; DMs also moved from a leftover sidebar icon into the drawer itself, and a mark-all-servers-as-read button was added. Two more rounds after that: the Quest Dock hijack (10 separate internal lookups just to force it to render) used to be attempted once at onLoad, so if any single one of them wasn't registered in Metro yet the drawer could just silently not show up for the whole session - it now retries whatever hasn't landed on a fast 200ms interval until everything's applied. And mark-all-read was rebuilt to match Discord's own markGuildsAsRead action exactly (getSelectableChannelIds + getVocalChannelIds per guild, confirmed against decompiled source) instead of a less certain guild-enumeration method. The left server rail hiding was also fixed properly this round: the rail's root component has no discoverable name at runtime at all (confirmed against decompiled source - it's a bare default export, and unlike memo-wrapped components with an explicit displayName, a plain function's own .name doesn't survive production minification), so every name-based lookup for it was guaranteed to fail. Now intercepted by its outermost View's literal nativeID (\"guilds-bar-view\", confirmed unique in the bundle) instead. The long-press context menu on a server icon had the exact same problem (getGuildsBarGuildMenuItems is also a bare default export) - replaced with a small menu of actions built from APIs this repo has already confirmed work (mark this server as read, copy server ID) instead of a lookup that could never succeed. The same caching bug affected several of ServerDrawer's own retry-based lookups (the Quest Dock hijack's fast-retry loop, and every findByProps call wrapped in the lazy() helper for navigation/action lookups) - all switched to a raw, uncached module scan for the specific calls that get retried, since a retried cached lookup was silently a no-op after its first failure.",
|
||||
"howItWorks": "Hijacks the Quest Dock - the strip Discord normally uses for daily quests, right above the tab bar - and puts a grid of your servers there instead. Tap it to expand, tap a server to jump to it, long-press for the usual context menu (mark as read, notification settings, leave, etc). A + button opens Discord's own create/join-server modal. Optionally hides the left server rail down to a single DMs icon, since your servers now live in the drawer instead.",
|
||||
"accent": "#38BDF8",
|
||||
"tagline": "See a message's raw JSON - searchable, syntax-highlighted, and copyable.",
|
||||
"note": "Fork of bwlok's ViewRaw (GPLv3 - see the plugin's own LICENSE file). Fixed a crash that hit every message long-press, then rebuilt the viewer with search and syntax highlighting on top.",
|
||||
"howItWorks": "Long-press any message and pick \"View Raw\" to see exactly what Discord sent for it - every field, not just the rendered text. The original plugin crashed the long-press menu on every message because it assumed a specific internal icon shape without checking it first; this fork guards that assumption and everything else in the same patch. The viewer itself was rebuilt: a search box highlights matching lines (dimming the rest, not hiding them, so you keep the structure for context), values are color-coded by type, and there are one-tap copy buttons for either just the message content or the full JSON.",
|
||||
"features": [
|
||||
"Tap-to-expand server grid with folders, unread badges, and mention counts",
|
||||
"Long-press a server for its real context menu, not a rebuilt one",
|
||||
"One-tap create/join through Discord's native modal",
|
||||
"Optional: collapse the left server rail to a single DMs icon",
|
||||
"Toggle unread badges independently",
|
||||
"Auto-collapse other open folders when you open a new one",
|
||||
"Optional plain folder icon instead of the 4-server preview when collapsed",
|
||||
"DMs tile in the drawer instead of a separate sidebar icon",
|
||||
"Mark every channel in every server as read in one tap"
|
||||
"Search box highlights matching keys/values across the whole payload",
|
||||
"Syntax-colored JSON (strings, numbers, booleans, null all render differently)",
|
||||
"Separate \"Copy Content\" and \"Copy JSON\" buttons",
|
||||
"Crash fixed: no longer breaks the message long-press menu"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Depends on several internal Quest Dock lookups (11 in total) - each is individually guarded, so a future Discord rename degrades one surface at a time instead of breaking the whole plugin. Drawer position (left/right/top) is still planned but not in this build yet - it currently only replaces the Quest Dock's native bottom position."
|
||||
},
|
||||
"you-bar-plus": {
|
||||
"category": "Moderation & Appearance",
|
||||
"status": "revived",
|
||||
"accent": "#5865F2",
|
||||
"tagline": "Adds DM and/or Settings buttons to the YouBar.",
|
||||
"note": "Fork of Purple-EyeZ's YouBar+. Fixed a startup race condition where the button patch could silently never apply for an entire session - the original did a single one-shot lookup for YouBarNotificationsButton at onLoad and gave up permanently if it wasn't registered in Metro yet, which restarting Discord only fixed by luck. Now retries until the component shows up. A second, deeper bug surfaced after that fix shipped: Revenge's own module finder permanently caches a \"not found\" result per search and never rescans, even after the target registers later - confirmed by reading Revenge's own source. That made the retry loop a no-op whenever the very first attempt happened too early, which is exactly the scenario it existed to handle. Now uses a raw, uncached module scan specifically for the retried lookup.",
|
||||
"howItWorks": "The YouBar is the row near your avatar/status at the top of the app. This plugin patches its notification button area to also render a DM button and/or a Settings button, each independently toggleable. DM jumps straight to your DMs; Settings opens user settings.",
|
||||
"features": [
|
||||
"Independent toggles for the DM and Settings buttons",
|
||||
"Settings changes apply immediately, no restart needed",
|
||||
"Startup race condition fixed - polls for the target component instead of a single one-shot lookup"
|
||||
],
|
||||
"commands": [],
|
||||
"limitations": "Depends on YouBarNotificationsButton keeping a similar render shape - guarded so a mismatch just means no extra buttons, not a crash."
|
||||
"limitations": "Licensed GPLv3 (inherited from the original), not the same terms as the rest of this repo - see plugins/view-raw/LICENSE. The long-press button relies on detecting Discord's internal action-sheet layout (not an official API) - the plugin's settings screen shows diagnostics if it ever stops appearing.",
|
||||
"pageUrl": "https://rp.jarviscli.dev/view-raw/",
|
||||
"installUrl": "https://rp.jarviscli.dev/view-raw/install/",
|
||||
"sourceUrl": "https://github.com/everestmcarthur/revenge-plugins/tree/main/plugins/view-raw",
|
||||
"issueUrl": "https://github.com/everestmcarthur/revenge-plugins/issues/new?title=%5BViewRaw%5D%20"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
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,
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,169 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +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": "Rosie" },
|
||||
{ "name": "nexpid" }
|
||||
],
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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
|
||||
};
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
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());
|
||||
};
|
||||
}
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
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());
|
||||
};
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
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()
|
||||
};
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"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"
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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
|
||||
};
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
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];
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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
|
||||
};
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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];
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
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
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
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());
|
||||
};
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
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.
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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());
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -1,264 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
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} />);
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
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/>
|
||||
|
|
@ -1,13 +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": "Rosie" },
|
||||
{ "name": "ryan" },
|
||||
{ "name": "シグマ siguma" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_profile_24px"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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
|
||||
};
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
import type { FluxDispatcher as $FluxDispatcher } from "@vencord/discord-types";
|
||||
import { FluxDispatcher as Dispatcher } from "@vendetta/metro/common";
|
||||
|
||||
export const FluxDispatcher = Dispatcher as $FluxDispatcher;
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/* 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();
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
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,
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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");
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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";
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
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";
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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)
|
||||
);
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
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;
|
||||
});
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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;
|
||||
});
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
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;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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;
|
||||
})
|
||||
);
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
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];
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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;
|
||||
});
|
||||
};
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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);
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue