Compare commits
No commits in common. "gh-pages" and "main" have entirely different histories.
424 changed files with 24970 additions and 12377 deletions
53
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
53
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
name: Bug report
|
||||
description: Something in a plugin is broken or behaving wrong
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: plugin
|
||||
attributes:
|
||||
label: Which plugin?
|
||||
options:
|
||||
- Staff Tags
|
||||
- RoleColorEverywhere
|
||||
- PronounDB
|
||||
- Message Snippets
|
||||
- Reminders
|
||||
- Not sure / something else
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: What did you expect, and what did you actually get?
|
||||
placeholder: "I tried /snippet welcome and nothing was sent to the channel."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: repro
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Enable the plugin
|
||||
2. Run /snippet welcome
|
||||
3. ...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: discord-version
|
||||
attributes:
|
||||
label: Discord app version (Settings → About)
|
||||
placeholder: "e.g. 338.4"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Anything in the Revenge debugger/logs?
|
||||
description: Settings → General → Debugger, if you have it enabled
|
||||
validations:
|
||||
required: false
|
||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Core Revenge app issue (not a plugin)
|
||||
url: https://github.com/revenge-mod/revenge-bundle/issues
|
||||
about: If the problem is with Revenge itself rather than one of these plugins, report it there instead.
|
||||
29
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
29
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
name: Feature request / new plugin idea
|
||||
description: Something you want added to an existing plugin, or a new plugin entirely
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: dropdown
|
||||
id: kind
|
||||
attributes:
|
||||
label: What kind of request is this?
|
||||
options:
|
||||
- New feature for an existing plugin
|
||||
- A brand new plugin idea
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: plugin
|
||||
attributes:
|
||||
label: Which plugin? (leave blank if this is a new plugin idea)
|
||||
placeholder: "e.g. Staff Tags"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What do you want, and why?
|
||||
description: The more concrete, the better - what would you actually do with it?
|
||||
validations:
|
||||
required: true
|
||||
55
.github/ISSUE_TEMPLATE/plugin_broken.yml
vendored
Normal file
55
.github/ISSUE_TEMPLATE/plugin_broken.yml
vendored
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
name: A plugin stopped working after a Discord update
|
||||
description: The most common failure mode here - Discord renamed/removed something a plugin depends on
|
||||
labels: ["bug", "discord-update"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Discord's internals change without notice, and plugins in this ecosystem patch those internals
|
||||
directly - this is the single most common way something breaks here. Reports like this are
|
||||
genuinely useful, thank you.
|
||||
|
||||
- type: dropdown
|
||||
id: plugin
|
||||
attributes:
|
||||
label: Which plugin?
|
||||
options:
|
||||
- Staff Tags
|
||||
- RoleColorEverywhere
|
||||
- PronounDB
|
||||
- Message Snippets
|
||||
- Reminders
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: surface
|
||||
attributes:
|
||||
label: What stopped showing up / working?
|
||||
description: Pick whichever's closest - helps narrow down which patch broke
|
||||
options:
|
||||
- Chat messages
|
||||
- Member list
|
||||
- Profile popout
|
||||
- Channel header
|
||||
- Typing indicator
|
||||
- Voice channel
|
||||
- A slash command
|
||||
- Not sure
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: discord-version
|
||||
attributes:
|
||||
label: Discord app version (Settings → About)
|
||||
placeholder: "e.g. 338.4"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: worked-before
|
||||
attributes:
|
||||
label: Did this work before, and if so, roughly when did it stop?
|
||||
validations:
|
||||
required: false
|
||||
34
.github/workflows/deploy.yml
vendored
Normal file
34
.github/workflows/deploy.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
name: Build and deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install deps
|
||||
run: npm install
|
||||
|
||||
- name: Build plugin(s) and site
|
||||
run: npm run build
|
||||
|
||||
- name: Install API deps
|
||||
working-directory: api
|
||||
run: npm install
|
||||
|
||||
- name: Deploy to Cloudflare Workers
|
||||
working-directory: api
|
||||
env:
|
||||
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
|
||||
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
|
||||
run: npx wrangler deploy
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.wrangler
|
||||
|
||||
.superpowers/
|
||||
736
404.html
736
404.html
|
|
@ -1,736 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>revenge-plugins</title>
|
||||
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--chip-bg: #ececf7;
|
||||
--chip-bg-active: #5865f2;
|
||||
--chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec;
|
||||
--badge-new-text: #1a7f4b;
|
||||
--badge-revived-bg: #eef0ff;
|
||||
--badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0;
|
||||
--badge-rebuilt-text: #b5680a;
|
||||
--danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
|
||||
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
|
||||
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0 40px;
|
||||
}
|
||||
.hero-mesh {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
background:
|
||||
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
|
||||
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
|
||||
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
|
||||
filter: blur(40px);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes drift {
|
||||
from { transform: translate3d(0,0,0) scale(1); }
|
||||
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
|
||||
}
|
||||
.hero-spotlight {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
|
||||
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.hero:hover .hero-spotlight { opacity: 1; }
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
|
||||
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.hero-links a {
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
}
|
||||
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
.kbd-hint {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-muted); font-weight: 600;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.controls.stuck { border-bottom-color: var(--border); }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
|
||||
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
|
||||
/* ---------- Grid / cards ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
|
||||
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
|
||||
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
|
||||
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
|
||||
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
|
||||
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
|
||||
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
|
||||
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
|
||||
/* ---------- Detail view ---------- */
|
||||
.detail { display: none; }
|
||||
.detail.active { display: block; }
|
||||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48px 0 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
|
||||
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
|
||||
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.detail-actions button, .detail-actions a {
|
||||
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
|
||||
.detail-section { margin-bottom: 32px; }
|
||||
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
|
||||
|
||||
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
|
||||
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
|
||||
|
||||
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.cmd-row:last-child { border-bottom: none; }
|
||||
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
|
||||
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
|
||||
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.side-card.warn h5 { color: var(--danger); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
|
||||
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
|
||||
}
|
||||
.palette-overlay.open { display: flex; }
|
||||
.palette {
|
||||
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
}
|
||||
.palette input {
|
||||
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.palette input:focus { outline: none; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
|
||||
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
|
||||
.palette-item .name { font-weight: 600; }
|
||||
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
||||
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
|
||||
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
|
||||
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="hero-mesh" aria-hidden="true"></div>
|
||||
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
|
||||
Revenge Plugins
|
||||
</div>
|
||||
<h1>Discord, <span class="grad">your way.</span></h1>
|
||||
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
|
||||
<div class="hero-links">
|
||||
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
|
||||
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
|
||||
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
|
||||
</div>
|
||||
<div class="stats" id="stats" aria-hidden="true"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap grid-view" id="gridView">
|
||||
<div class="controls" id="controls">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</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
Normal file
105
README.md
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
# revenge-plugins
|
||||
|
||||
A collection of plugins for [Revenge](https://github.com/revenge-mod/revenge-bundle), the Discord Android client mod.
|
||||
|
||||
**Browse them at [rp.jarviscli.dev](https://rp.jarviscli.dev)** - searchable, with a full detail page per plugin.
|
||||
|
||||
## Installing a plugin
|
||||
|
||||
Each plugin has two URLs:
|
||||
|
||||
- `rp.jarviscli.dev/<plugin>/` - a real page describing the plugin (features, commands, how it works)
|
||||
- `rp.jarviscli.dev/<plugin>/install/` - the actual install link you paste into Revenge
|
||||
|
||||
To install: open Discord with Revenge installed, go to **Settings → Plugins**, tap `+`, and paste the
|
||||
`/install/` link.
|
||||
|
||||
## Plugins
|
||||
|
||||
### Moderation & appearance
|
||||
|
||||
| Plugin | Status | Description | Install link |
|
||||
| --- | --- | --- | --- |
|
||||
| Staff Tags | Rebuilt | OWNER/ADMIN/STAFF/MOD-style tags next to members, computed from real server permissions. Per-tag text, colors, gradients, and visibility. | `https://rp.jarviscli.dev/staff-tags/install/` |
|
||||
| Custom User Tags | New | Create your own tags (text + color) and assign them to specific users, independent of server permissions. Long-press a name to manage one, or add by user ID in settings. | `https://rp.jarviscli.dev/custom-user-tags/install/` |
|
||||
| Radial Status | Revived | Replaces the small presence dot on an avatar with a colored ring instead - one color per status, adjustable size and thickness. Fork of [AngelW0lf's Radial Status](https://github.com/Angelix1/MP/tree/master/angel/radialstatus), BSD-3-Clause - dropped a phone-home mechanism that could silently uninstall the plugin for specific user IDs, and replaced non-functional size/thickness controls with real ones. | `https://rp.jarviscli.dev/radial-status/install/` |
|
||||
| RoleColorEverywhere | Revived | A member's top role color shown in mentions, the typing indicator, voice channel names, member list role headers, and optionally message text. | `https://rp.jarviscli.dev/role-color-everywhere/install/` |
|
||||
| PronounDB | Revived | Shows a user's pronouns in their profile, if they've set them at pronoundb.org (nothing to configure in the plugin itself). | `https://rp.jarviscli.dev/pronoun-db/install/` |
|
||||
| FakeProfileThemesAndEffects | Revived | Profile theming and profile effects without Nitro. Fork of [shipwr3ckd's FPTE](https://github.com/shipwr3ckd/FPTE) - fixed a crash on every profile-editor open, confirmed via on-device diagnostics. | `https://rp.jarviscli.dev/fake-profile-themes-and-effects/install/` |
|
||||
| BetterTimestamps | Revived | Calendar-relative, fully relative, ISO 8601, or a custom moment.js timestamp format, for both messages and day dividers. | `https://rp.jarviscli.dev/better-timestamps/install/` |
|
||||
| CopyRoleColor | Revived | Long-press a role pill to copy its color as hex. | `https://rp.jarviscli.dev/copy-role-color/install/` |
|
||||
| ServerDrawer | Revived | Replaces the Quest Dock with an expandable server grid - DMs, servers, folders, one-tap create/join, and mark-all-as-read, all in one drawer, with the left server rail hidden. Fork of [kmmiio99o's ServerDrawer](https://github.com/kmmiio99o/vd-plugins/tree/main/plugins/ServerDrawer) - fixed an unguarded lookup that could silently skip every patch after it, guild icons that never loaded, and drawer taps that didn't navigate. Folder options adapted from [fres621's BetterFolders](https://github.com/fres621/vendetta-plugins/tree/master/plugins/BetterFolders), rebuilt against the drawer's own folders since BetterFolders' target component no longer exists. | `https://rp.jarviscli.dev/server-drawer/install/` |
|
||||
| YouBar+ | Revived | Adds DM and/or Settings buttons to the YouBar. Fork of [Purple-EyeZ's YouBar+](https://github.com/Purple-EyeZ/RevengePlugins/tree/main/plugins/YouBarPlus) - fixed a startup race condition where the button patch could silently never apply for an entire session. | `https://rp.jarviscli.dev/you-bar-plus/install/` |
|
||||
|
||||
### Productivity
|
||||
|
||||
| Plugin | Status | Description | Install link |
|
||||
| --- | --- | --- | --- |
|
||||
| Message Snippets | New | Save reusable text and send it with `/snippet <name>`. Manage snippets in-app or with `/snippet-save`, `/snippet-delete`, `/snippet-list`. | `https://rp.jarviscli.dev/message-snippets/install/` |
|
||||
| Reminders | New | `/remind 20m Walk the dog` - fires while Discord is running. Can't wake the app from fully closed (no native notification access from a JS plugin). | `https://rp.jarviscli.dev/reminders/install/` |
|
||||
| Urban Dictionary | Revived | `/urban <term>` - posts the top Urban Dictionary result to the channel. Built on the stable command API, no UI patching. | `https://rp.jarviscli.dev/urban-dictionary/install/` |
|
||||
| Reverse Image Search | New | `/reverse-image-search <image-url>` - opens the image in Google Lens, Yandex, or TinEye. | `https://rp.jarviscli.dev/reverse-image-search/install/` |
|
||||
|
||||
### Utility
|
||||
|
||||
| Plugin | Status | Description | Install link |
|
||||
| --- | --- | --- | --- |
|
||||
| ViewRaw | Revived | See a message's raw JSON via long-press - searchable, syntax-highlighted, and copyable. Fork of [bwlok's ViewRaw](https://github.com/bwlok/revenge-plugins/tree/master/plugins/ViewRaw), GPLv3. | `https://rp.jarviscli.dev/view-raw/install/` |
|
||||
| Key Inspector | New | One-tap scan of every internal key this repo's plugins depend on, plus a full dump of Revenge's plugin API tree - for diagnosing crashes entirely from a phone. | `https://rp.jarviscli.dev/key-inspector/install/` |
|
||||
| RoseUtils | Revived | Spotify listen-along, colorful channels, an expanded dev-tools/Developer Mode, a full Message Logger, and more - independently toggleable modules. Fork of [nexpid's NexxUtils](https://github.com/nexpid/RevengePlugins/tree/main/src/plugins/nexxutils), CC-BY 4.0 - fixed a build bug that crashed the settings screen and silently dropped every module's patches. | `https://rp.jarviscli.dev/rose-utils/install/` |
|
||||
|
||||
"Revived" means the plugin previously existed elsewhere, stopped working after a Discord/API update, and has been rebuilt here.
|
||||
"Rebuilt" means it's a from-scratch reimplementation of a previously-broken plugin with expanded features. "New" means
|
||||
it didn't exist anywhere in the Revenge/Vendetta plugin ecosystem before.
|
||||
|
||||
**Licensing note:** everything in this repo is original work except `plugins/view-raw` (GPLv3, forked from
|
||||
bwlok's ViewRaw), `plugins/rose-utils` (CC-BY 4.0, forked from nexpid's NexxUtils), and `plugins/radial-status`
|
||||
(BSD-3-Clause, forked from AngelW0lf's Radial Status) - the rest of the repo isn't under any of those licenses.
|
||||
|
||||
## Why some of these broke before, and what's different now
|
||||
|
||||
Both Staff Tags and RoleColorEverywhere previously failed completely (not just partially) when a single internal
|
||||
Discord component got renamed - one bad lookup threw during setup, which crashed the plugin's `onLoad` before it
|
||||
could apply any of its other patches. Every rebuilt/revived plugin in this repo isolates each patch surface behind
|
||||
its own guard, so if Discord changes one internal again, only that specific surface goes quiet instead of taking
|
||||
the whole plugin down. PronounDB's break was simpler: its old data source was a PronounDB API version that Discord
|
||||
had nothing to do with - PronounDB itself shut it down (HTTP 410) - so it's been migrated to their current v2 API.
|
||||
|
||||
## Shared plugin library
|
||||
|
||||
`shared/` holds code every plugin can use - `@shared/lib/patcher` (the crash-isolation helper described
|
||||
above), `@shared/lib/color`, and reusable settings UI (`SettingsScaffold`, `NoteBox`, `ColorInput`,
|
||||
`ListSection`, `PrimaryButton`). Plugins are still installed and run independently by users, so this isn't
|
||||
a runtime dependency - Rollup inlines whatever a plugin imports from `@shared/*` straight into that
|
||||
plugin's own bundle at build time. Import it like any other module:
|
||||
|
||||
```ts
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
import ColorInput from "@shared/ui/ColorInput";
|
||||
```
|
||||
|
||||
## Building locally
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
This runs both `build.mjs` (compiles every plugin in `plugins/*` into `dist/<id>/install/`) and
|
||||
`scripts/generate-site.mjs` (builds `dist/plugins-data.json` from those manifests + `site/meta.json`,
|
||||
and writes the website - a homepage at `dist/index.html` and a real page per plugin at `dist/<id>/index.html`).
|
||||
|
||||
## Releasing an update
|
||||
|
||||
1. Edit a plugin under `plugins/<id>/src/`.
|
||||
2. `npm run build` locally and sanity-check the output in `dist/`.
|
||||
3. `git add -A && git commit -m "..." && git push origin main`.
|
||||
4. GitHub Actions rebuilds and redeploys to `rp.jarviscli.dev` automatically (usually under a minute) -
|
||||
check progress at the repo's **Actions** tab.
|
||||
|
||||
There's no manual deploy step - anything merged to `main` goes live.
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and PRs are welcome, especially if a Discord update breaks a lookup a plugin relies on - `findByName` /
|
||||
`findByProps` targets can and do change without notice.
|
||||
1537
api/package-lock.json
generated
Normal file
1537
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
12
api/package.json
Normal file
12
api/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "revenge-plugins-api",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "wrangler dev",
|
||||
"deploy": "wrangler deploy",
|
||||
"migrate": "wrangler d1 execute revenge-plugins-overlay --remote --file=./schema.sql"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.0.0"
|
||||
}
|
||||
}
|
||||
28
api/schema.sql
Normal file
28
api/schema.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
-- Every displayed field can now be overridden here, not just status/broken/tags - editing anything
|
||||
-- from the admin plugin should show up on the live site immediately, no git push needed. A row can
|
||||
-- also stand entirely on its own (is_draft = 1): a plugin that doesn't exist in the git repo at all,
|
||||
-- created straight from the admin plugin for quick testing, with its own manifest/JS served directly
|
||||
-- from here instead of from the built dist/ output.
|
||||
DROP TABLE IF EXISTS overlay;
|
||||
CREATE TABLE overlay (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
category TEXT,
|
||||
status TEXT,
|
||||
accent TEXT,
|
||||
tagline TEXT,
|
||||
note TEXT,
|
||||
how_it_works TEXT,
|
||||
features TEXT, -- JSON array of strings
|
||||
commands TEXT, -- JSON array of {cmd, desc}
|
||||
limitations TEXT,
|
||||
authors TEXT, -- JSON array of strings
|
||||
broken_reason TEXT,
|
||||
broken_since TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_draft INTEGER NOT NULL DEFAULT 0,
|
||||
manifest TEXT, -- full manifest.json content - only used when is_draft = 1
|
||||
main_js TEXT, -- index.js source - only used when is_draft = 1
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
374
api/src/index.ts
Normal file
374
api/src/index.ts
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
export interface Env {
|
||||
ASSETS: Fetcher;
|
||||
DB: D1Database;
|
||||
OVERLAY_CACHE: KVNamespace;
|
||||
HUB: DurableObjectNamespace;
|
||||
ADMIN_TOKEN: string;
|
||||
}
|
||||
|
||||
// Single global room every browser tab connects to - broadcasts "something changed, refetch" to
|
||||
// every open tab the moment an admin write succeeds, instead of tabs only picking it up on their
|
||||
// next natural fetch. Uses the Hibernatable WebSockets API (acceptWebSocket/getWebSockets) so the
|
||||
// object doesn't have to stay pinned in memory (and billed) between messages - it wakes on a new
|
||||
// connection, a client event, or an incoming /broadcast call, and can go back to sleep otherwise.
|
||||
export class OverlayHub {
|
||||
constructor(private state: DurableObjectState, private env: Env) {}
|
||||
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/broadcast") {
|
||||
const body = await request.text();
|
||||
for (const ws of this.state.getWebSockets()) {
|
||||
try { ws.send(body); } catch { /* dead socket, ignore */ }
|
||||
}
|
||||
return new Response("ok");
|
||||
}
|
||||
|
||||
if (request.headers.get("Upgrade") !== "websocket") {
|
||||
return new Response("expected websocket", { status: 426 });
|
||||
}
|
||||
|
||||
const pair = new WebSocketPair();
|
||||
const [client, server] = Object.values(pair);
|
||||
this.state.acceptWebSocket(server);
|
||||
return new Response(null, { status: 101, webSocket: client });
|
||||
}
|
||||
|
||||
async webSocketMessage() { /* clients never need to send anything, ignore */ }
|
||||
|
||||
async webSocketClose(ws: WebSocket) {
|
||||
try { ws.close(); } catch { /* already closing */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function broadcast(env: Env, message: unknown) {
|
||||
const stub = env.HUB.get(env.HUB.idFromName("global"));
|
||||
await stub.fetch("https://internal/broadcast", { method: "POST", body: JSON.stringify(message) });
|
||||
}
|
||||
|
||||
interface Command {
|
||||
cmd: string;
|
||||
desc: string;
|
||||
}
|
||||
|
||||
// Every field is optional - a PUT only needs to send what it wants to change, and whatever isn't
|
||||
// sent falls back to the git-tracked base (for a plugin that exists in git) or a sane default (for
|
||||
// a brand-new draft-only plugin, which has no base to fall back to at all).
|
||||
interface OverlayEntry {
|
||||
name?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
status?: string | null;
|
||||
accent?: string;
|
||||
tagline?: string;
|
||||
note?: string;
|
||||
howItWorks?: string;
|
||||
features?: string[];
|
||||
commands?: Command[];
|
||||
limitations?: string;
|
||||
authors?: string[];
|
||||
broken?: { reason: string; since?: string } | null;
|
||||
tags?: string[];
|
||||
draft?: boolean;
|
||||
mainJs?: string; // raw plugin source - only meaningful for a draft, ignored otherwise
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
status: string | null;
|
||||
accent: string | null;
|
||||
tagline: string | null;
|
||||
note: string | null;
|
||||
how_it_works: string | null;
|
||||
features: string | null;
|
||||
commands: string | null;
|
||||
limitations: string | null;
|
||||
authors: string | null;
|
||||
broken_reason: string | null;
|
||||
broken_since: string | null;
|
||||
tags: string;
|
||||
is_draft: number;
|
||||
manifest: string | null;
|
||||
main_js: string | null;
|
||||
}
|
||||
|
||||
// Short TTL, not a long-lived cache - the whole point of this overlay is that a change from a phone
|
||||
// should be visible almost immediately, not after a long edge-cache window.
|
||||
const CACHE_KEY = "overlay:v2";
|
||||
const CACHE_TTL_SECONDS = 60; // Workers KV's enforced minimum
|
||||
|
||||
const CORS_HEADERS = {
|
||||
"access-control-allow-origin": "*",
|
||||
"access-control-allow-methods": "GET,HEAD,PUT,DELETE,OPTIONS,PATCH",
|
||||
"access-control-allow-headers": "authorization,content-type",
|
||||
"access-control-max-age": "86400",
|
||||
"vary": "Origin",
|
||||
};
|
||||
|
||||
function withCors(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
for (const [k, v] of Object.entries(CORS_HEADERS)) {
|
||||
headers.set(k, v);
|
||||
}
|
||||
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
||||
}
|
||||
|
||||
function json(data: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "content-type": "application/json", ...CORS_HEADERS },
|
||||
});
|
||||
}
|
||||
|
||||
function rowToOverlay(row: Row): OverlayEntry & { draft: boolean } {
|
||||
return {
|
||||
name: row.name ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
category: row.category ?? undefined,
|
||||
status: row.status ?? undefined,
|
||||
accent: row.accent ?? undefined,
|
||||
tagline: row.tagline ?? undefined,
|
||||
note: row.note ?? undefined,
|
||||
howItWorks: row.how_it_works ?? undefined,
|
||||
features: row.features ? JSON.parse(row.features) : undefined,
|
||||
commands: row.commands ? JSON.parse(row.commands) : undefined,
|
||||
limitations: row.limitations ?? undefined,
|
||||
authors: row.authors ? JSON.parse(row.authors) : undefined,
|
||||
broken: row.broken_reason ? { reason: row.broken_reason, since: row.broken_since ?? undefined } : null,
|
||||
tags: row.tags ? JSON.parse(row.tags) : [],
|
||||
draft: !!row.is_draft,
|
||||
};
|
||||
}
|
||||
|
||||
async function readAll(db: D1Database): Promise<Record<string, OverlayEntry & { draft: boolean }>> {
|
||||
const { results } = await db.prepare("SELECT * FROM overlay").all<Row>();
|
||||
const out: Record<string, OverlayEntry & { draft: boolean }> = {};
|
||||
for (const row of results) out[row.id] = rowToOverlay(row);
|
||||
return out;
|
||||
}
|
||||
|
||||
// Shared by every read path (GET /api/overlay, GET /plugins-data.json, and install-file serving for
|
||||
// draft plugins) so a change from the admin plugin shows up everywhere on the next request, not just
|
||||
// on the raw overlay endpoint.
|
||||
async function getOverlay(env: Env): Promise<Record<string, OverlayEntry & { draft: boolean }>> {
|
||||
const cached = await env.OVERLAY_CACHE.get(CACHE_KEY, "json");
|
||||
if (cached) return cached as Record<string, OverlayEntry & { draft: boolean }>;
|
||||
|
||||
const data = await readAll(env.DB);
|
||||
await env.OVERLAY_CACHE.put(CACHE_KEY, JSON.stringify(data), { expirationTtl: CACHE_TTL_SECONDS });
|
||||
return data;
|
||||
}
|
||||
|
||||
async function sha256Hex(text: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
const DEFAULT_MAIN_JS = `export default {\n onLoad: () => {},\n onUnload: () => {}\n};\n`;
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
return await handle(request, env);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return json({ error: "internal" }, 500);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function handle(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, { headers: CORS_HEADERS });
|
||||
}
|
||||
|
||||
// Live-update channel - the site connects here once on load; every successful overlay write
|
||||
// broadcasts through it so an already-open tab updates without the visitor doing anything.
|
||||
if (url.pathname === "/ws") {
|
||||
const stub = env.HUB.get(env.HUB.idFromName("global"));
|
||||
return stub.fetch(request);
|
||||
}
|
||||
|
||||
// The site's actual data file - computed fresh on every request by merging the git-tracked base
|
||||
// (plugins-base.json, written by generate-site.mjs at build time) with the live overlay, so any
|
||||
// change from the admin plugin is visible immediately, with no rebuild. Draft-only plugins (no
|
||||
// git base at all) are appended as their own entries.
|
||||
if (url.pathname === "/plugins-data.json") {
|
||||
const baseRes = await env.ASSETS.fetch(new URL("/plugins-base.json", request.url));
|
||||
const base = baseRes.ok ? await baseRes.json<any[]>() : [];
|
||||
const overlay = await getOverlay(env);
|
||||
const baseIds = new Set(base.map((p) => p.id));
|
||||
|
||||
const merged = base.map((p) => applyOverride(p, overlay[p.id]));
|
||||
const draftOnly = Object.entries(overlay)
|
||||
.filter(([id, o]) => o.draft && !baseIds.has(id))
|
||||
.map(([id, o]) => synthesizeDraft(id, o));
|
||||
|
||||
return json([...merged, ...draftOnly]);
|
||||
}
|
||||
|
||||
// Dynamic install files for draft-only plugins (created via PUT /api/overlay/:id with draft:true
|
||||
// and mainJs). Checked against D1 *first*, not by probing the static asset's response status -
|
||||
// with SPA fallback enabled (see wrangler.toml's not_found_handling), a missing asset no longer
|
||||
// 404s, it serves index.html with a 200, so that status can't be used to detect "not found" here.
|
||||
// A real git-tracked plugin has no row here, so this always falls through to ASSETS for it.
|
||||
const installMatch = url.pathname.match(/^\/([\w-]+)\/install\/(manifest\.json|index\.js)$/);
|
||||
if (installMatch) {
|
||||
const [, id, file] = installMatch;
|
||||
const row = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1 AND is_draft = 1")
|
||||
.bind(id).first<{ manifest: string; main_js: string }>();
|
||||
|
||||
if (row) {
|
||||
if (file === "manifest.json") return json(JSON.parse(row.manifest));
|
||||
return new Response(row.main_js, { headers: { "content-type": "application/javascript", ...CORS_HEADERS } });
|
||||
}
|
||||
|
||||
return withCors(await env.ASSETS.fetch(request));
|
||||
}
|
||||
|
||||
if (!url.pathname.startsWith("/api/")) {
|
||||
return withCors(await env.ASSETS.fetch(request));
|
||||
}
|
||||
|
||||
// GET /api/overlay - public read, used by the admin plugin/site to show current values before
|
||||
// editing.
|
||||
if (url.pathname === "/api/overlay" && request.method === "GET") {
|
||||
return json(await getOverlay(env));
|
||||
}
|
||||
|
||||
const match = url.pathname.match(/^\/api\/overlay\/([\w-]+)$/);
|
||||
if (match) {
|
||||
const id = match[1];
|
||||
const auth = request.headers.get("authorization");
|
||||
if (auth !== `Bearer ${env.ADMIN_TOKEN}`) {
|
||||
return json({ error: "unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (request.method === "PUT") {
|
||||
const body = await request.json<OverlayEntry>().catch(() => null);
|
||||
if (!body) return json({ error: "invalid body" }, 400);
|
||||
|
||||
const existing = await env.DB.prepare("SELECT manifest, main_js FROM overlay WHERE id = ?1").bind(id).first<{ manifest: string; main_js: string }>();
|
||||
|
||||
let manifest = existing?.manifest ?? null;
|
||||
let mainJs = existing?.main_js ?? null;
|
||||
if (body.draft) {
|
||||
mainJs = body.mainJs ?? mainJs ?? DEFAULT_MAIN_JS;
|
||||
const manifestObj = {
|
||||
name: body.name ?? id,
|
||||
description: body.description ?? "",
|
||||
authors: (body.authors ?? []).map((n) => ({ name: n })),
|
||||
main: "index.js",
|
||||
hash: await sha256Hex(mainJs),
|
||||
};
|
||||
manifest = JSON.stringify(manifestObj);
|
||||
}
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO overlay (
|
||||
id, name, description, category, status, accent, tagline, note, how_it_works,
|
||||
features, commands, limitations, authors, broken_reason, broken_since, tags,
|
||||
is_draft, manifest, main_js, updated_at
|
||||
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, description = excluded.description, category = excluded.category,
|
||||
status = excluded.status, accent = excluded.accent, tagline = excluded.tagline,
|
||||
note = excluded.note, how_it_works = excluded.how_it_works, features = excluded.features,
|
||||
commands = excluded.commands, limitations = excluded.limitations, authors = excluded.authors,
|
||||
broken_reason = excluded.broken_reason, broken_since = excluded.broken_since, tags = excluded.tags,
|
||||
is_draft = excluded.is_draft, manifest = excluded.manifest, main_js = excluded.main_js,
|
||||
updated_at = excluded.updated_at`,
|
||||
).bind(
|
||||
id,
|
||||
body.name ?? null,
|
||||
body.description ?? null,
|
||||
body.category ?? null,
|
||||
body.status ?? null,
|
||||
body.accent ?? null,
|
||||
body.tagline ?? null,
|
||||
body.note ?? null,
|
||||
body.howItWorks ?? null,
|
||||
body.features ? JSON.stringify(body.features) : null,
|
||||
body.commands ? JSON.stringify(body.commands) : null,
|
||||
body.limitations ?? null,
|
||||
body.authors ? JSON.stringify(body.authors) : null,
|
||||
body.broken?.reason ?? null,
|
||||
body.broken ? (body.broken.since ?? new Date().toISOString()) : null,
|
||||
JSON.stringify(body.tags ?? []),
|
||||
body.draft ? 1 : 0,
|
||||
manifest,
|
||||
mainJs,
|
||||
new Date().toISOString(),
|
||||
).run();
|
||||
|
||||
await env.OVERLAY_CACHE.delete(CACHE_KEY);
|
||||
await broadcast(env, { type: "overlay-updated", id });
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
if (request.method === "DELETE") {
|
||||
await env.DB.prepare("DELETE FROM overlay WHERE id = ?1").bind(id).run();
|
||||
await env.OVERLAY_CACHE.delete(CACHE_KEY);
|
||||
await broadcast(env, { type: "overlay-updated", id });
|
||||
return json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
return json({ error: "not found" }, 404);
|
||||
}
|
||||
|
||||
function applyOverride(base: any, over: (OverlayEntry & { draft: boolean }) | undefined) {
|
||||
if (!over) return { ...base, broken: null, tags: [], hasOverride: false, isDraft: false };
|
||||
return {
|
||||
...base,
|
||||
hasOverride: true,
|
||||
isDraft: false,
|
||||
name: over.name ?? base.name,
|
||||
description: over.description ?? base.description,
|
||||
category: over.category ?? base.category,
|
||||
status: over.status ?? base.status,
|
||||
accent: over.accent ?? base.accent,
|
||||
tagline: over.tagline ?? base.tagline,
|
||||
note: over.note ?? base.note,
|
||||
howItWorks: over.howItWorks ?? base.howItWorks,
|
||||
features: over.features ?? base.features,
|
||||
commands: over.commands ?? base.commands,
|
||||
limitations: over.limitations ?? base.limitations,
|
||||
authors: over.authors ?? base.authors,
|
||||
broken: over.broken ?? null,
|
||||
tags: over.tags ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function synthesizeDraft(id: string, over: OverlayEntry & { draft: boolean }) {
|
||||
const base = "https://rp.jarviscli.dev";
|
||||
return {
|
||||
id,
|
||||
isDraft: true,
|
||||
hasOverride: true,
|
||||
name: over.name ?? id,
|
||||
description: over.description ?? "",
|
||||
authors: over.authors ?? [],
|
||||
category: over.category ?? "Other",
|
||||
status: over.status ?? "new",
|
||||
broken: over.broken ?? null,
|
||||
tags: over.tags ?? [],
|
||||
accent: over.accent ?? "#5865f2",
|
||||
tagline: over.tagline ?? over.description ?? "",
|
||||
note: over.note ?? "Draft plugin - created from the admin plugin, not tracked in git.",
|
||||
howItWorks: over.howItWorks ?? "",
|
||||
features: over.features ?? [],
|
||||
commands: over.commands ?? [],
|
||||
limitations: over.limitations ?? "",
|
||||
pageUrl: `${base}/${id}/`,
|
||||
installUrl: `${base}/${id}/install/`,
|
||||
sourceUrl: "https://github.com/everestmcarthur/revenge-plugins",
|
||||
issueUrl: `https://github.com/everestmcarthur/revenge-plugins/issues/new`,
|
||||
};
|
||||
}
|
||||
25
api/wrangler.toml
Normal file
25
api/wrangler.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name = "revenge-plugins"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-11-01"
|
||||
|
||||
[assets]
|
||||
directory = "../dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "revenge-plugins-overlay"
|
||||
database_id = "639fef3c-6583-4128-adbd-b5f5f4761483"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "OVERLAY_CACHE"
|
||||
id = "e56d183bdcd64f5facade1c3fe04eb08"
|
||||
|
||||
[[durable_objects.bindings]]
|
||||
name = "HUB"
|
||||
class_name = "OverlayHub"
|
||||
|
||||
[[migrations]]
|
||||
tag = "v1"
|
||||
new_sqlite_classes = ["OverlayHub"]
|
||||
|
|
@ -1,736 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>revenge-plugins</title>
|
||||
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--chip-bg: #ececf7;
|
||||
--chip-bg-active: #5865f2;
|
||||
--chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec;
|
||||
--badge-new-text: #1a7f4b;
|
||||
--badge-revived-bg: #eef0ff;
|
||||
--badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0;
|
||||
--badge-rebuilt-text: #b5680a;
|
||||
--danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
|
||||
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
|
||||
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0 40px;
|
||||
}
|
||||
.hero-mesh {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
background:
|
||||
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
|
||||
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
|
||||
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
|
||||
filter: blur(40px);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes drift {
|
||||
from { transform: translate3d(0,0,0) scale(1); }
|
||||
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
|
||||
}
|
||||
.hero-spotlight {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
|
||||
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.hero:hover .hero-spotlight { opacity: 1; }
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
|
||||
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.hero-links a {
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
}
|
||||
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
.kbd-hint {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-muted); font-weight: 600;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.controls.stuck { border-bottom-color: var(--border); }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
|
||||
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
|
||||
/* ---------- Grid / cards ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
|
||||
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
|
||||
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
|
||||
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
|
||||
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
|
||||
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
|
||||
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
|
||||
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
|
||||
/* ---------- Detail view ---------- */
|
||||
.detail { display: none; }
|
||||
.detail.active { display: block; }
|
||||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48px 0 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
|
||||
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
|
||||
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.detail-actions button, .detail-actions a {
|
||||
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
|
||||
.detail-section { margin-bottom: 32px; }
|
||||
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
|
||||
|
||||
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
|
||||
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
|
||||
|
||||
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.cmd-row:last-child { border-bottom: none; }
|
||||
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
|
||||
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
|
||||
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.side-card.warn h5 { color: var(--danger); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
|
||||
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
|
||||
}
|
||||
.palette-overlay.open { display: flex; }
|
||||
.palette {
|
||||
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
}
|
||||
.palette input {
|
||||
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.palette input:focus { outline: none; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
|
||||
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
|
||||
.palette-item .name { font-weight: 600; }
|
||||
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
||||
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
|
||||
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
|
||||
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="hero-mesh" aria-hidden="true"></div>
|
||||
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
|
||||
Revenge Plugins
|
||||
</div>
|
||||
<h1>Discord, <span class="grad">your way.</span></h1>
|
||||
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
|
||||
<div class="hero-links">
|
||||
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
|
||||
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
|
||||
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
|
||||
</div>
|
||||
<div class="stats" id="stats" aria-hidden="true"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap grid-view" id="gridView">
|
||||
<div class="controls" id="controls">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</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 +0,0 @@
|
|||
(function(o,g,r,y,a,i,h,p){"use strict";function v(e,t,s,n){try{return s()}catch(c){return n.error(`[${e}] Failed to apply the "${t}" patch, that surface will be skipped: ${c}`),function(){}}}function M(e,t,s){const n=Object.entries(s).map(function([c,C]){return v(e,c,C,t)});return function(){return n.forEach(function(c){return c()})}}function u(e,t=r.storage.selected){try{switch(t){case"calendar":return e.calendar();case"relative":return e.fromNow();case"iso":return e.toISOString();case"custom":return e.format(r.storage.customFormat||"dddd, MMMM Do YYYY, h:mm:ss a");default:return e.calendar()}}catch{return e?.calendar?.()??String(e)}}const l=y.findByName("RowManager",!1);function R(){if(!l?.prototype?.generate)return function(){};const e=[];return e.push(i.before("generate",l.prototype,function([t]){try{t.rowType===1?(r.storage.separateMessages&&(t.isFirst=!0),t.message.__customTimestamp=u(t.message.timestamp)):t.rowType==="day"&&(t.text=u(a.moment(t.text,"LL")))}catch{}})),e.push(i.after("generate",l.prototype,function([t],s){try{if(t.rowType!==1)return;t.message?.__customTimestamp&&s?.message?.state==="SENT"&&s.message.timestamp&&(s.message.timestamp=t.message.__customTimestamp)}catch{}})),function(){return e.forEach(function(t){return t()})}}const{ScrollView:b,View:w}=a.ReactNative;function E({children:e}){return a.React.createElement(b,{style:{flex:1}},e,a.React.createElement(w,{style:{height:24}}))}const{Text:F}=a.ReactNative,{FormSection:m,FormRow:k,FormDivider:Y,FormSwitchRow:S,FormInput:T}=p.Forms,d=[{label:"Calendar",key:"calendar"},{label:"Relative",key:"relative"},{label:"ISO 8601",key:"iso"},{label:"Custom",key:"custom"}];function _(){return h.useProxy(r.storage),a.React.createElement(E,null,a.React.createElement(m,{title:"Format"},d.map(function(e,t){const s=r.storage.selected===e.key;let n="";try{n=u(a.moment(),e.key)}catch{n=""}return a.React.createElement(a.React.Fragment,{key:e.key},a.React.createElement(k,{label:e.label,subLabel:n,trailing:s?a.React.createElement(F,{style:{fontSize:16}},"\u2713"):void 0,onPress:function(){r.storage.selected=e.key}}),e.key==="custom"&&s&&a.React.createElement(T,{title:"Custom format (moment.js tokens)",placeholder:"dddd, MMMM Do YYYY, h:mm:ss a",value:r.storage.customFormat,onChange:function(c){r.storage.customFormat=c}}),t!==d.length-1&&a.React.createElement(Y,null))})),a.React.createElement(m,{title:"Layout"},a.React.createElement(S,{label:"Always show name & avatar",subLabel:"Shows the username, avatar, and timestamp on every message instead of grouping consecutive messages",value:!!r.storage.separateMessages,onValueChange:function(e){r.storage.separateMessages=e}})))}let f=function(){};var L={onLoad:function(){r.storage.selected??="calendar",r.storage.customFormat??="dddd, MMMM Do YYYY, h:mm:ss a",r.storage.separateMessages??=!1,f=M("BetterTimestamps",g.logger,{"message & day timestamps":R})},onUnload:function(){return f()},settings:_};return o.default=L,Object.defineProperty(o,"__esModule",{value:!0}),o})({},vendetta,vendetta.plugin,vendetta.metro,vendetta.metro.common,vendetta.patcher,vendetta.storage,vendetta.ui.components);
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"name":"BetterTimestamps","description":"Replace message and day-divider timestamps with calendar-relative, fully relative, ISO 8601, or a custom moment.js format.","authors":[{"name":"everestmcarthur"},{"name":"nexpid"}],"main":"index.js","hash":"6ec03be5d13360e6caf33cf37895f04ce8cb9e549440a4ec60a79c10c582f2a3"}
|
||||
4
blacklist.json
Normal file
4
blacklist.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"updated_at": "2026-09-07T19:51:24.372Z",
|
||||
"list": ["997993767091052654"]
|
||||
}
|
||||
123
build.mjs
Normal file
123
build.mjs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import { readFile, writeFile, readdir, copyFile } from "fs/promises";
|
||||
import { extname, resolve as resolvePath } from "path";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
import { rollup } from "rollup";
|
||||
import alias from "@rollup/plugin-alias";
|
||||
import url from "@rollup/plugin-url";
|
||||
import esbuild from "rollup-plugin-esbuild";
|
||||
import commonjs from "@rollup/plugin-commonjs";
|
||||
import nodeResolve from "@rollup/plugin-node-resolve";
|
||||
import swc from "@swc/core";
|
||||
|
||||
const extensions = [".js", ".jsx", ".mjs", ".ts", ".tsx", ".cts", ".mts"];
|
||||
|
||||
// Plugins are each built into an independent, self-contained bundle (users install them one at a time),
|
||||
// so they can't import from each other at runtime - but they CAN share source at build time. This alias
|
||||
// lets any plugin `import { x } from "@shared/..."` and have it inlined straight into its own bundle.
|
||||
/** @type import("rollup").InputPluginOption */
|
||||
const plugins = [
|
||||
alias({
|
||||
entries: [
|
||||
{ find: "@shared", replacement: resolvePath("./shared") },
|
||||
// FPTE's own internal aliases, namespaced to avoid colliding with any other plugin
|
||||
// that might want a generic "@lib"/"@ui" of its own.
|
||||
{ find: "@fpte/lib", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/lib") },
|
||||
{ find: "@fpte/ui", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/ui") },
|
||||
{ find: "@fpte/patches", replacement: resolvePath("./plugins/fake-profile-themes-and-effects/src/patches") },
|
||||
],
|
||||
}),
|
||||
nodeResolve({ extensions: [".mjs", ".js", ".json", ".node", ".ts", ".tsx"] }),
|
||||
commonjs(),
|
||||
url({
|
||||
include: ["**/*.svg", "**/*.png", "**/*.jpg", "**/*.gif"],
|
||||
limit: 0,
|
||||
}),
|
||||
{
|
||||
name: "swc",
|
||||
async transform(code, id) {
|
||||
const ext = extname(id);
|
||||
if (!extensions.includes(ext)) return null;
|
||||
|
||||
const ts = ext.includes("ts");
|
||||
const tsx = ts ? ext.endsWith("x") : undefined;
|
||||
const jsx = !ts ? ext.endsWith("x") : undefined;
|
||||
|
||||
const result = await swc.transform(code, {
|
||||
filename: id,
|
||||
jsc: {
|
||||
externalHelpers: true,
|
||||
parser: {
|
||||
syntax: ts ? "typescript" : "ecmascript",
|
||||
tsx,
|
||||
jsx,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
targets: "defaults",
|
||||
include: [
|
||||
"transform-classes",
|
||||
"transform-arrow-functions",
|
||||
],
|
||||
},
|
||||
});
|
||||
return result.code;
|
||||
},
|
||||
},
|
||||
esbuild({ minify: true }),
|
||||
];
|
||||
|
||||
// Each plugin's actual install target (manifest.json + index.js) lives at /<id>/install/, so that
|
||||
// /<id>/ itself is free to be a real, bookmarkable HTML page describing the plugin (see generate-site.mjs).
|
||||
for (const plug of await readdir("./plugins")) {
|
||||
const manifest = JSON.parse(await readFile(`./plugins/${plug}/manifest.json`));
|
||||
const outPath = `./dist/${plug}/install/index.js`;
|
||||
|
||||
try {
|
||||
const bundle = await rollup({
|
||||
input: `./plugins/${plug}/${manifest.main}`,
|
||||
onwarn: () => {},
|
||||
// Most plugins only ever import React/ReactNative via @vendetta/metro/common, which
|
||||
// resolves to nothing on disk and so is treated as external automatically - but a
|
||||
// couple of ported plugins import the bare "react"/"react-native" packages directly
|
||||
// (like their own original build configs expected), and those DO exist in
|
||||
// node_modules, so without this they'd actually get bundled - including react-native's
|
||||
// real source, which contains Flow syntax this toolchain can't parse.
|
||||
external: ["react", "react-native"],
|
||||
plugins,
|
||||
});
|
||||
|
||||
await bundle.write({
|
||||
file: outPath,
|
||||
globals(id) {
|
||||
if (id.startsWith("@vendetta")) return id.substring(1).replace(/\//g, ".");
|
||||
const map = {
|
||||
react: "window.React",
|
||||
"react-native": "vendetta.metro.common.ReactNative",
|
||||
};
|
||||
|
||||
return map[id] || null;
|
||||
},
|
||||
format: "iife",
|
||||
compact: true,
|
||||
exports: "named",
|
||||
});
|
||||
await bundle.close();
|
||||
|
||||
const toHash = await readFile(outPath);
|
||||
manifest.hash = createHash("sha256").update(toHash).digest("hex");
|
||||
manifest.main = "index.js";
|
||||
await writeFile(`./dist/${plug}/install/manifest.json`, JSON.stringify(manifest));
|
||||
await writeFile(`./dist/${plug}/manifest.json`, JSON.stringify(manifest));
|
||||
await copyFile(outPath, `./dist/${plug}/index.js`);
|
||||
|
||||
console.log(`Successfully built ${manifest.name}!`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to build ${plug}...`, e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await copyFile("./blacklist.json", "./dist/blacklist.json");
|
||||
} catch (_) {}
|
||||
|
|
@ -1 +0,0 @@
|
|||
(function(c,l,i,p,s,d,f,y){"use strict";function g(o,a,t,r){try{return t()}catch(e){return r.error(`[${o}] Failed to apply the "${a}" patch, that surface will be skipped: ${e}`),function(){}}}function h(o,a,t){const r=Object.entries(t).map(function([e,n]){return g(o,e,n,a)});return function(){return r.forEach(function(e){return e()})}}function b(){const o=i.findByName("RolePill",!1)??i.findByName("ThemedRolePill",!1);return o?p.after("default",o,function(a,t){try{if(!t?.props?.onPress)return;const r=s.findInReactTree(t,function(n){return n?.props?.roleColor}),e=s.findInReactTree(t,function(n){return n?.props?.style?.[0]?.borderRadius&&typeof n?.props?.style?.[1]?.backgroundColor=="string"&&n.props.style[1].backgroundColor.startsWith("#")})?.props?.style?.[1]?.backgroundColor??r?.props?.roleColor;if(!e)return;t.props.onLongPress=function(){d.clipboard.setString(e),y.showToast("Copied role color to clipboard",f.getAssetIDByName("ic_message_copy"))}}catch{}}):function(){}}let u=function(){};var m={onLoad:function(){u=h("CopyRoleColor",l.logger,{"role pill long-press":b})},onUnload:function(){return u()}};return c.default=m,Object.defineProperty(c,"__esModule",{value:!0}),c})({},vendetta,vendetta.metro,vendetta.patcher,vendetta.utils,vendetta.metro.common,vendetta.ui.assets,vendetta.ui.toasts);
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"name":"CopyRoleColor","description":"Long-press a role pill (member list, role list) to copy its color as hex.","authors":[{"name":"everestmcarthur"},{"name":"MSMA"}],"main":"index.js","hash":"34828c5d47ad8a234fd22550bb2ec927132c5f8e800a4ec2a4ef48a01af9ca56"}
|
||||
|
|
@ -1,736 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>revenge-plugins</title>
|
||||
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--chip-bg: #ececf7;
|
||||
--chip-bg-active: #5865f2;
|
||||
--chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec;
|
||||
--badge-new-text: #1a7f4b;
|
||||
--badge-revived-bg: #eef0ff;
|
||||
--badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0;
|
||||
--badge-rebuilt-text: #b5680a;
|
||||
--danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
|
||||
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
|
||||
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0 40px;
|
||||
}
|
||||
.hero-mesh {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
background:
|
||||
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
|
||||
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
|
||||
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
|
||||
filter: blur(40px);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes drift {
|
||||
from { transform: translate3d(0,0,0) scale(1); }
|
||||
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
|
||||
}
|
||||
.hero-spotlight {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
|
||||
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.hero:hover .hero-spotlight { opacity: 1; }
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
|
||||
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.hero-links a {
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
}
|
||||
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
.kbd-hint {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-muted); font-weight: 600;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.controls.stuck { border-bottom-color: var(--border); }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
|
||||
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
|
||||
/* ---------- Grid / cards ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
|
||||
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
|
||||
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
|
||||
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
|
||||
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
|
||||
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
|
||||
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
|
||||
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
|
||||
/* ---------- Detail view ---------- */
|
||||
.detail { display: none; }
|
||||
.detail.active { display: block; }
|
||||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48px 0 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
|
||||
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
|
||||
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.detail-actions button, .detail-actions a {
|
||||
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
|
||||
.detail-section { margin-bottom: 32px; }
|
||||
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
|
||||
|
||||
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
|
||||
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
|
||||
|
||||
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.cmd-row:last-child { border-bottom: none; }
|
||||
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
|
||||
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
|
||||
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.side-card.warn h5 { color: var(--danger); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
|
||||
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
|
||||
}
|
||||
.palette-overlay.open { display: flex; }
|
||||
.palette {
|
||||
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
}
|
||||
.palette input {
|
||||
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.palette input:focus { outline: none; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
|
||||
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
|
||||
.palette-item .name { font-weight: 600; }
|
||||
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
||||
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
|
||||
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
|
||||
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="hero-mesh" aria-hidden="true"></div>
|
||||
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
|
||||
Revenge Plugins
|
||||
</div>
|
||||
<h1>Discord, <span class="grad">your way.</span></h1>
|
||||
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
|
||||
<div class="hero-links">
|
||||
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
|
||||
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
|
||||
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
|
||||
</div>
|
||||
<div class="stats" id="stats" aria-hidden="true"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap grid-view" id="gridView">
|
||||
<div class="controls" id="controls">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</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>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
{"name":"FakeProfileThemesAndEffects","description":"Profile theming and profile effects without Nitro, by hiding the color/effect data in your About Me using invisible zero-width characters.","authors":[{"name":"everestmcarthur"},{"name":"ryan"},{"name":"シグマ siguma"}],"main":"index.js","vendetta":{"icon":"ic_profile_24px"},"hash":"3710bf5a7c5a53fe220a27708c6c0fc65ea3297fd10589feb4e1ba2851d0067f"}
|
||||
736
index.html
736
index.html
|
|
@ -1,736 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>revenge-plugins</title>
|
||||
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--chip-bg: #ececf7;
|
||||
--chip-bg-active: #5865f2;
|
||||
--chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec;
|
||||
--badge-new-text: #1a7f4b;
|
||||
--badge-revived-bg: #eef0ff;
|
||||
--badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0;
|
||||
--badge-rebuilt-text: #b5680a;
|
||||
--danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
|
||||
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
|
||||
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0 40px;
|
||||
}
|
||||
.hero-mesh {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
background:
|
||||
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
|
||||
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
|
||||
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
|
||||
filter: blur(40px);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes drift {
|
||||
from { transform: translate3d(0,0,0) scale(1); }
|
||||
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
|
||||
}
|
||||
.hero-spotlight {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
|
||||
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.hero:hover .hero-spotlight { opacity: 1; }
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
|
||||
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.hero-links a {
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
}
|
||||
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
.kbd-hint {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-muted); font-weight: 600;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.controls.stuck { border-bottom-color: var(--border); }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
|
||||
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
|
||||
/* ---------- Grid / cards ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
|
||||
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
|
||||
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
|
||||
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
|
||||
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
|
||||
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
|
||||
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
|
||||
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
|
||||
/* ---------- Detail view ---------- */
|
||||
.detail { display: none; }
|
||||
.detail.active { display: block; }
|
||||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48px 0 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
|
||||
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
|
||||
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.detail-actions button, .detail-actions a {
|
||||
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
|
||||
.detail-section { margin-bottom: 32px; }
|
||||
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
|
||||
|
||||
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
|
||||
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
|
||||
|
||||
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.cmd-row:last-child { border-bottom: none; }
|
||||
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
|
||||
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
|
||||
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.side-card.warn h5 { color: var(--danger); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
|
||||
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
|
||||
}
|
||||
.palette-overlay.open { display: flex; }
|
||||
.palette {
|
||||
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
}
|
||||
.palette input {
|
||||
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.palette input:focus { outline: none; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
|
||||
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
|
||||
.palette-item .name { font-weight: 600; }
|
||||
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
||||
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
|
||||
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
|
||||
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="hero-mesh" aria-hidden="true"></div>
|
||||
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
|
||||
Revenge Plugins
|
||||
</div>
|
||||
<h1>Discord, <span class="grad">your way.</span></h1>
|
||||
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
|
||||
<div class="hero-links">
|
||||
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
|
||||
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
|
||||
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
|
||||
</div>
|
||||
<div class="stats" id="stats" aria-hidden="true"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap grid-view" id="gridView">
|
||||
<div class="controls" id="controls">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</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,736 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>revenge-plugins</title>
|
||||
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--chip-bg: #ececf7;
|
||||
--chip-bg-active: #5865f2;
|
||||
--chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec;
|
||||
--badge-new-text: #1a7f4b;
|
||||
--badge-revived-bg: #eef0ff;
|
||||
--badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0;
|
||||
--badge-rebuilt-text: #b5680a;
|
||||
--danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
|
||||
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
|
||||
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0 40px;
|
||||
}
|
||||
.hero-mesh {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
background:
|
||||
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
|
||||
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
|
||||
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
|
||||
filter: blur(40px);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes drift {
|
||||
from { transform: translate3d(0,0,0) scale(1); }
|
||||
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
|
||||
}
|
||||
.hero-spotlight {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
|
||||
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.hero:hover .hero-spotlight { opacity: 1; }
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
|
||||
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.hero-links a {
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
}
|
||||
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
.kbd-hint {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-muted); font-weight: 600;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.controls.stuck { border-bottom-color: var(--border); }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
|
||||
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
|
||||
/* ---------- Grid / cards ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
|
||||
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
|
||||
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
|
||||
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
|
||||
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
|
||||
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
|
||||
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
|
||||
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
|
||||
/* ---------- Detail view ---------- */
|
||||
.detail { display: none; }
|
||||
.detail.active { display: block; }
|
||||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48px 0 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
|
||||
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
|
||||
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.detail-actions button, .detail-actions a {
|
||||
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
|
||||
.detail-section { margin-bottom: 32px; }
|
||||
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
|
||||
|
||||
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
|
||||
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
|
||||
|
||||
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.cmd-row:last-child { border-bottom: none; }
|
||||
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
|
||||
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
|
||||
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.side-card.warn h5 { color: var(--danger); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
|
||||
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
|
||||
}
|
||||
.palette-overlay.open { display: flex; }
|
||||
.palette {
|
||||
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
}
|
||||
.palette input {
|
||||
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.palette input:focus { outline: none; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
|
||||
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
|
||||
.palette-item .name { font-weight: 600; }
|
||||
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
||||
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
|
||||
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
|
||||
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="hero-mesh" aria-hidden="true"></div>
|
||||
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
|
||||
Revenge Plugins
|
||||
</div>
|
||||
<h1>Discord, <span class="grad">your way.</span></h1>
|
||||
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
|
||||
<div class="hero-links">
|
||||
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
|
||||
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
|
||||
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
|
||||
</div>
|
||||
<div class="stats" id="stats" aria-hidden="true"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap grid-view" id="gridView">
|
||||
<div class="controls" id="controls">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</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>
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1 +0,0 @@
|
|||
{"name":"Key Inspector","description":"Plugin-dev toolkit: on-device module search (incl. a cache-safe raw mode), a Flux event logger, a component render/props logger, an eval console, and full API/color-token dumps - for diagnosing and building plugins without needing a computer.","authors":[{"name":"everestmcarthur"}],"main":"index.js","hash":"55a7be951b7aa5f16d664bf02517120f95648b1eef4f792ac3f03c9b345fc30d"}
|
||||
|
|
@ -1,736 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>revenge-plugins</title>
|
||||
<meta name="description" content="Plugins for Revenge, the Discord Android client mod.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%235865f2'/%3E%3Cstop offset='1' stop-color='%239b59b6'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cpath d='M9 12l7-5 7 5v9l-7 5-7-5z' fill='none' stroke='%23fff' stroke-width='2'/%3E%3Ccircle cx='16' cy='16' r='3' fill='%23fff'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f5f6fb;
|
||||
--bg-elevated: #ffffff;
|
||||
--bg-sunken: #eceef6;
|
||||
--border: #e1e4ef;
|
||||
--text: #14151f;
|
||||
--text-muted: #62667a;
|
||||
--accent: #5865f2;
|
||||
--accent-text: #ffffff;
|
||||
--chip-bg: #ececf7;
|
||||
--chip-bg-active: #5865f2;
|
||||
--chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec;
|
||||
--badge-new-text: #1a7f4b;
|
||||
--badge-revived-bg: #eef0ff;
|
||||
--badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0;
|
||||
--badge-rebuilt-text: #b5680a;
|
||||
--danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20, 21, 31, 0.06), 0 10px 30px rgba(20, 21, 31, 0.06);
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5);
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #0d0e14; --bg-elevated: #171821; --bg-sunken: #12131b; --border: #262837;
|
||||
--text: #eef0f9; --text-muted: #9297ab; --accent: #838cff; --accent-text: #0d0e14;
|
||||
--chip-bg: #1e2030; --chip-bg-active: #838cff; --chip-text-active: #0d0e14;
|
||||
--badge-new-bg: #0f2a1c; --badge-new-text: #5fd493; --badge-revived-bg: #1c1f3a; --badge-revived-text: #a6adff;
|
||||
--badge-rebuilt-bg: #33240c; --badge-rebuilt-text: #f0b849; --danger: #ff6b6e;
|
||||
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.5); color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f5f6fb; --bg-elevated: #ffffff; --bg-sunken: #eceef6; --border: #e1e4ef;
|
||||
--text: #14151f; --text-muted: #62667a; --accent: #5865f2; --accent-text: #ffffff;
|
||||
--chip-bg: #ececf7; --chip-bg-active: #5865f2; --chip-text-active: #ffffff;
|
||||
--badge-new-bg: #e6f6ec; --badge-new-text: #1a7f4b; --badge-revived-bg: #eef0ff; --badge-revived-text: #4752c4;
|
||||
--badge-rebuilt-bg: #fff3e0; --badge-rebuilt-text: #b5680a; --danger: #f23f42;
|
||||
--shadow: 0 1px 2px rgba(20,21,31,0.06), 0 10px 30px rgba(20,21,31,0.06); color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
code, .mono { font-family: var(--mono); }
|
||||
.wrap { max-width: 1040px; margin: 0 auto; padding: 0 20px; }
|
||||
a { color: inherit; }
|
||||
|
||||
/* ---------- Hero ---------- */
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 64px 0 40px;
|
||||
}
|
||||
.hero-mesh {
|
||||
position: absolute;
|
||||
inset: -20%;
|
||||
z-index: 0;
|
||||
opacity: 0.5;
|
||||
background:
|
||||
radial-gradient(38% 45% at 12% 8%, color-mix(in srgb, var(--accent) 55%, transparent), transparent 70%),
|
||||
radial-gradient(32% 38% at 85% 15%, #9b59b688, transparent 70%),
|
||||
radial-gradient(30% 40% at 70% 85%, #23a55a55, transparent 70%);
|
||||
filter: blur(40px);
|
||||
animation: drift 22s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes drift {
|
||||
from { transform: translate3d(0,0,0) scale(1); }
|
||||
to { transform: translate3d(-2%, 2%, 0) scale(1.08); }
|
||||
}
|
||||
.hero-spotlight {
|
||||
position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0;
|
||||
background: radial-gradient(280px circle at var(--sx, 50%) var(--sy, 50%), color-mix(in srgb, var(--accent) 22%, transparent), transparent 70%);
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
.hero:hover .hero-spotlight { opacity: 1; }
|
||||
.hero-inner { position: relative; z-index: 1; }
|
||||
.mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.mark svg { width: 22px; height: 22px; display: block; }
|
||||
h1 {
|
||||
font-size: clamp(32px, 5vw, 46px);
|
||||
line-height: 1.08;
|
||||
margin: 0 0 14px;
|
||||
letter-spacing: -0.02em;
|
||||
font-weight: 800;
|
||||
}
|
||||
h1 .grad {
|
||||
background: linear-gradient(100deg, var(--accent), #9b59b6 60%, #23a55a);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
.hero p.sub { color: var(--text-muted); font-size: 17px; max-width: 640px; margin: 0 0 24px; line-height: 1.55; }
|
||||
.hero-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 32px; }
|
||||
.hero-links a {
|
||||
font-size: 14px; font-weight: 600; text-decoration: none;
|
||||
padding: 9px 15px; border-radius: 9px; border: 1px solid var(--border); background: var(--bg-elevated);
|
||||
}
|
||||
.hero-links a.primary { background: var(--accent); color: var(--accent-text); border-color: transparent; }
|
||||
.kbd-hint {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 13px; color: var(--text-muted); font-weight: 600;
|
||||
}
|
||||
.kbd-hint kbd {
|
||||
font-family: var(--mono); font-size: 11px; background: var(--bg-sunken);
|
||||
border: 1px solid var(--border); border-bottom-width: 2px; border-radius: 5px; padding: 2px 6px;
|
||||
}
|
||||
.stats { display: flex; gap: 32px; flex-wrap: wrap; }
|
||||
.stats .stat-value { font-size: 28px; font-weight: 800; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.stats .stat-label { font-size: 12.5px; color: var(--text-muted); font-weight: 600; }
|
||||
|
||||
/* ---------- Controls ---------- */
|
||||
.controls { position: sticky; top: 0; background: var(--bg); padding: 16px 0; z-index: 5; border-bottom: 1px solid transparent; transition: border-color 0.15s; }
|
||||
.controls.stuck { border-bottom-color: var(--border); }
|
||||
.search-row { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
.search {
|
||||
flex: 1; padding: 12px 14px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); font-size: 15px;
|
||||
}
|
||||
.search:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.chip { font-size: 13px; font-weight: 600; padding: 7px 13px; border-radius: 999px; background: var(--chip-bg); color: var(--text-muted); border: none; cursor: pointer; }
|
||||
.chip[aria-pressed="true"] { background: var(--chip-bg-active); color: var(--chip-text-active); }
|
||||
|
||||
main { padding: 24px 0 64px; }
|
||||
.count { color: var(--text-muted); font-size: 13px; margin: 0 0 16px; }
|
||||
|
||||
/* ---------- Grid / cards ---------- */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 20px 20px 18px;
|
||||
box-shadow: var(--shadow);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
.card::before {
|
||||
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
|
||||
background: var(--card-accent, var(--accent));
|
||||
}
|
||||
.card:hover, .card:focus-visible {
|
||||
transform: translateY(-3px);
|
||||
border-color: var(--card-accent, var(--accent));
|
||||
box-shadow: 0 1px 2px rgba(20,21,31,0.06), 0 16px 32px -8px color-mix(in srgb, var(--card-accent, var(--accent)) 35%, transparent);
|
||||
outline: none;
|
||||
}
|
||||
.card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
|
||||
.card h3 { font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.badge { font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 3px 8px; border-radius: 999px; white-space: nowrap; flex-shrink: 0; }
|
||||
.badge.new { background: var(--badge-new-bg); color: var(--badge-new-text); }
|
||||
.badge.revived { background: var(--badge-revived-bg); color: var(--badge-revived-text); }
|
||||
.badge.rebuilt { background: var(--badge-rebuilt-bg); color: var(--badge-rebuilt-text); }
|
||||
.card .category { font-size: 11.5px; color: var(--card-accent, var(--accent)); font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; margin-bottom: 10px; }
|
||||
.card p.tagline { font-size: 14px; color: var(--text-muted); line-height: 1.55; margin: 0 0 14px; flex-grow: 1; }
|
||||
.card .feature-count { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; display: flex; align-items: center; gap: 6px; }
|
||||
.card .feature-count svg { width: 13px; height: 13px; opacity: 0.7; }
|
||||
.card .cta { font-size: 13px; font-weight: 700; color: var(--card-accent, var(--accent)); display: flex; align-items: center; gap: 4px; }
|
||||
|
||||
.empty { text-align: center; padding: 60px 20px; color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); padding: 28px 0 44px; }
|
||||
footer p { color: var(--text-muted); font-size: 13px; margin: 0; }
|
||||
|
||||
/* ---------- Detail view ---------- */
|
||||
.detail { display: none; }
|
||||
.detail.active { display: block; }
|
||||
.grid-view.hidden, .hero.hidden, .controls.hidden { display: none; }
|
||||
|
||||
.detail-banner {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 48px 0 36px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
radial-gradient(60% 100% at 0% 0%, color-mix(in srgb, var(--page-accent, var(--accent)) 22%, transparent), transparent),
|
||||
var(--bg);
|
||||
}
|
||||
.back-link {
|
||||
display: inline-flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 700;
|
||||
color: var(--text-muted); text-decoration: none; margin-bottom: 20px; background: none; border: none; cursor: pointer; padding: 0; font-family: inherit;
|
||||
}
|
||||
.back-link:hover { color: var(--text); }
|
||||
.detail-top { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.detail-top h2 { font-size: clamp(28px, 4vw, 38px); margin: 0; letter-spacing: -0.02em; font-weight: 800; }
|
||||
.detail-category { font-size: 12.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--page-accent, var(--accent)); margin-bottom: 14px; }
|
||||
.detail-tagline { font-size: 17px; color: var(--text-muted); max-width: 680px; line-height: 1.55; margin: 0 0 22px; }
|
||||
.detail-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.detail-actions button, .detail-actions a {
|
||||
font-size: 14px; font-weight: 700; padding: 10px 16px; border-radius: 10px; border: 1px solid var(--border);
|
||||
background: var(--bg-elevated); color: var(--text); text-decoration: none; cursor: pointer;
|
||||
}
|
||||
.detail-actions button.install { background: var(--page-accent, var(--accent)); color: var(--accent-text); border-color: transparent; }
|
||||
|
||||
.detail-body { padding: 36px 0 64px; display: grid; grid-template-columns: 2fr 1fr; gap: 40px; }
|
||||
@media (max-width: 720px) { .detail-body { grid-template-columns: 1fr; } }
|
||||
.detail-section { margin-bottom: 32px; }
|
||||
.detail-section h4 { font-size: 13px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 14px; }
|
||||
.detail-section p { font-size: 15px; line-height: 1.7; color: var(--text); margin: 0 0 14px; white-space: pre-line; }
|
||||
|
||||
.feature-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.feature-list li { display: flex; gap: 10px; font-size: 14.5px; line-height: 1.5; }
|
||||
.feature-list svg { flex-shrink: 0; width: 18px; height: 18px; margin-top: 1px; color: var(--page-accent, var(--accent)); }
|
||||
|
||||
.cmd-table { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.cmd-row { padding: 12px 14px; border-bottom: 1px solid var(--border); }
|
||||
.cmd-row:last-child { border-bottom: none; }
|
||||
.cmd-row code { display: block; font-size: 13.5px; font-weight: 700; color: var(--page-accent, var(--accent)); margin-bottom: 4px; }
|
||||
.cmd-row span { font-size: 13px; color: var(--text-muted); line-height: 1.5; }
|
||||
|
||||
.side-card { background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; }
|
||||
.side-card h5 { font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); margin: 0 0 10px; }
|
||||
.side-card p { font-size: 13.5px; line-height: 1.55; margin: 0; color: var(--text); }
|
||||
.side-card.warn { border-color: color-mix(in srgb, var(--danger) 40%, var(--border)); }
|
||||
.side-card.warn h5 { color: var(--danger); }
|
||||
.authors-line { font-size: 13px; color: var(--text-muted); }
|
||||
|
||||
/* ---------- Command palette ---------- */
|
||||
.palette-overlay {
|
||||
position: fixed; inset: 0; background: rgba(10,10,16,0.55); backdrop-filter: blur(2px);
|
||||
display: none; align-items: flex-start; justify-content: center; padding-top: 12vh; z-index: 100;
|
||||
}
|
||||
.palette-overlay.open { display: flex; }
|
||||
.palette {
|
||||
width: min(560px, 92vw); background: var(--bg-elevated); border: 1px solid var(--border);
|
||||
border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,0.4); overflow: hidden;
|
||||
}
|
||||
.palette input {
|
||||
width: 100%; border: none; background: transparent; color: var(--text); font-size: 17px;
|
||||
padding: 18px 20px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.palette input:focus { outline: none; }
|
||||
.palette-results { max-height: 50vh; overflow-y: auto; padding: 8px; }
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 8px; cursor: pointer; font-size: 14px;
|
||||
}
|
||||
.palette-item[aria-selected="true"] { background: var(--bg-sunken); }
|
||||
.palette-item .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--item-accent, var(--accent)); flex-shrink: 0; }
|
||||
.palette-item .name { font-weight: 600; }
|
||||
.palette-item .cat { color: var(--text-muted); font-size: 12px; margin-left: auto; }
|
||||
.palette-empty { padding: 20px; text-align: center; color: var(--text-muted); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(0);
|
||||
background: var(--text); color: var(--bg); font-size: 13px; font-weight: 700;
|
||||
padding: 10px 16px; border-radius: 999px; opacity: 0; pointer-events: none;
|
||||
transition: opacity 0.2s ease, transform 0.2s ease; z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-8px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="hero" id="hero">
|
||||
<div class="hero-mesh" aria-hidden="true"></div>
|
||||
<div class="hero-spotlight" id="heroSpotlight" aria-hidden="true"></div>
|
||||
<div class="wrap hero-inner">
|
||||
<div class="mark">
|
||||
<svg viewBox="0 0 32 32" fill="none"><path d="M9 12l7-5 7 5v9l-7 5-7-5z" stroke="currentColor" stroke-width="2"/><circle cx="16" cy="16" r="3" fill="currentColor"/></svg>
|
||||
Revenge Plugins
|
||||
</div>
|
||||
<h1>Discord, <span class="grad">your way.</span></h1>
|
||||
<p class="sub">A hand-built collection of plugins for Revenge, the Discord Android client mod - rebuilt from scratch where the old ecosystem broke, and net-new where nothing existed yet.</p>
|
||||
<div class="hero-links">
|
||||
<a class="primary" href="https://github.com/everestmcarthur/revenge-plugins">GitHub repo</a>
|
||||
<a href="https://github.com/revenge-mod/revenge-bundle">About Revenge</a>
|
||||
<a href="https://github.com/everestmcarthur/revenge-plugins/issues/new">Report an issue</a>
|
||||
</div>
|
||||
<div class="stats" id="stats" aria-hidden="true"></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap grid-view" id="gridView">
|
||||
<div class="controls" id="controls">
|
||||
<div class="search-row">
|
||||
<input id="search" class="search" type="search" placeholder="Search plugins… (or press / )" aria-label="Search plugins">
|
||||
<span class="kbd-hint"><kbd>⌘</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 +0,0 @@
|
|||
(function(f,p,S,o,v,e,g,y){"use strict";function d(){return v.storage.snippets??={},v.storage.snippets}function h(t,i){d()[t.trim()]=i}function m(t){delete d()[t]}const u=3,R=S.findByProps("sendMessage","sendBotMessage");function x(){const t=p.registerCommand({name:"snippet",description:"Send a saved text snippet",options:[{name:"name",description:"Snippet name",type:u,required:!0}],execute:function(r,s){const l=r.find(function(H){return H.name==="name"})?.value,c=l?d()[l]:void 0;if(!c){o.showToast(`No snippet named "${l}"`,void 0);return}if(!R?.sendMessage){o.showToast("Couldn't send the snippet - message action not found",void 0);return}R.sendMessage(s.channel.id,{content:c})}}),i=p.registerCommand({name:"snippet-save",description:"Save a text snippet",options:[{name:"name",description:"Snippet name",type:u,required:!0},{name:"text",description:"Snippet content",type:u,required:!0}],execute:function(r){const s=r.find(function(c){return c.name==="name"})?.value,l=r.find(function(c){return c.name==="text"})?.value;!s||!l||(h(s,l),o.showToast(`Saved snippet "${s}"`,void 0))}}),n=p.registerCommand({name:"snippet-delete",description:"Delete a saved text snippet",options:[{name:"name",description:"Snippet name",type:u,required:!0}],execute:function(r){const s=r.find(function(l){return l.name==="name"})?.value;s&&(m(s),o.showToast(`Deleted snippet "${s}"`,void 0))}}),a=p.registerCommand({name:"snippet-list",description:"List your saved snippet names",options:[],execute:function(){const r=Object.keys(d());o.showToast(r.length?r.join(", "):"No snippets saved yet",void 0)}});return[t,i,n,a]}const{ScrollView:T,View:w}=e.ReactNative;function $({children:t}){return e.React.createElement(T,{style:{flex:1}},t,e.React.createElement(w,{style:{height:24}}))}const{View:P,Text:C}=e.ReactNative;function F({children:t,style:i}){return e.React.createElement(P,{style:[{marginHorizontal:16,marginVertical:8,padding:10,borderRadius:8,borderWidth:1,borderColor:"rgba(128,128,128,0.25)"},i]},e.React.createElement(C,{style:{fontSize:12.5,lineHeight:18,opacity:.75}},t))}const{Text:N}=e.ReactNative,{FormSection:k,FormRow:L}=y.Forms;function M({title:t,items:i,emptyText:n}){return e.React.createElement(k,{title:t},i.length===0?e.React.createElement(F,null,n):i.map(function(a){return e.React.createElement(L,{key:a.key,label:a.label,subLabel:a.subLabel,onPress:a.onPress,trailing:e.React.createElement(N,{style:{color:"#F23F42",fontSize:20,fontWeight:"700"}},"\xD7")})}))}const{TouchableOpacity:V,Text:j}=e.ReactNative;function q({label:t,onPress:i,disabled:n,style:a}){return e.React.createElement(V,{disabled:n,onPress:i,style:[{backgroundColor:"#5865F2",borderRadius:8,padding:10,alignItems:"center",opacity:n?.5:1},a]},e.React.createElement(j,{style:{color:"white",fontWeight:"600"}},t))}const{View:z}=e.ReactNative,{FormSection:D,FormInput:b}=y.Forms;function O(){const[t,i]=e.React.useState(""),[n,a]=e.React.useState(""),r=!!t.trim()&&!!n.trim();return e.React.createElement(z,{style:{paddingHorizontal:16,paddingBottom:12}},e.React.createElement(b,{title:"Name",placeholder:"e.g. rules",value:t,onChange:i}),e.React.createElement(b,{title:"Text",placeholder:"What /snippet should send",value:n,onChange:a,multiline:!0}),e.React.createElement(q,{label:"Save snippet",disabled:!r,style:{marginTop:8},onPress:function(){h(t.trim(),n),o.showToast(`Saved snippet "${t.trim()}"`,void 0),i(""),a("")}}))}function W(){g.useProxy(v.storage);const t=d();g.useProxy(t);const i=Object.keys(t);return e.React.createElement($,null,e.React.createElement(D,{title:"Add a snippet"},e.React.createElement(O,null)),e.React.createElement(M,{title:"Saved snippets",emptyText:"None yet. Add one above, then send it anywhere with /snippet name.",items:i.map(function(n){return{key:n,label:n,subLabel:`${t[n].slice(0,60)}${t[n].length>60?"\u2026":""} \u2022 Tap to delete`,onPress:function(){m(n),o.showToast(`Deleted snippet "${n}"`,void 0)}}})}))}let E=[];var B={onLoad:function(){E=x()},onUnload:function(){return E.forEach(function(t){return t()})},settings:W};return f.default=B,Object.defineProperty(f,"__esModule",{value:!0}),f})({},vendetta.commands,vendetta.metro,vendetta.ui.toasts,vendetta.plugin,vendetta.metro.common,vendetta.storage,vendetta.ui.components);
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"name":"Message Snippets","description":"Save reusable text and send it with /snippet <name>. Manage snippets from the plugin settings, or with /snippet-save, /snippet-delete, /snippet-list.","authors":[{"name":"everestmcarthur"}],"main":"index.js","hash":"dd26ccf27afd50aa4f8859528640793b37fa4bf1e5f443ffd58f687bb260cf25"}
|
||||
4467
package-lock.json
generated
Normal file
4467
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
Normal file
22
package.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "revenge-plugins",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node build.mjs && node scripts/generate-site.mjs",
|
||||
"serve": "node serve.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-alias": "^4.0.4",
|
||||
"@rollup/plugin-commonjs": "^24.0.0",
|
||||
"@rollup/plugin-node-resolve": "^15.0.1",
|
||||
"@rollup/plugin-url": "latest",
|
||||
"@swc/core": "^1.3.35",
|
||||
"esbuild": "^0.16.14",
|
||||
"rollup": "^3.9.1",
|
||||
"rollup-plugin-esbuild": "^5.0.0",
|
||||
"vendetta-types": "latest"
|
||||
}
|
||||
}
|
||||
12
plugins/antied-zero/manifest.json
Normal file
12
plugins/antied-zero/manifest.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "Antied Zero",
|
||||
"description": "Lightweight, zero-config message logger that keeps deleted messages and edit history visible.",
|
||||
"authors": [
|
||||
{ "name": "Angelw0lf" },
|
||||
{ "name": "Rosie" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_edit_24px"
|
||||
}
|
||||
}
|
||||
45
plugins/antied-zero/src/index.ts
Normal file
45
plugins/antied-zero/src/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { id } from "@vendetta/plugin";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { stopPlugin } from "@vendetta/plugins";
|
||||
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
|
||||
|
||||
import { patchFluxDispatch } from "./patches/fluxDispatch";
|
||||
import { patchActionSheet } from "./patches/actionSheet";
|
||||
import { patchSelfEdit } from "./patches/selfEdit";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
const isEnabledRef = { current: false };
|
||||
const deletedMessageMap = new Map<string, any>();
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
export default {
|
||||
onLoad: async () => {
|
||||
if (enforceBlacklistOnLoad(id)) return;
|
||||
|
||||
isEnabledRef.current = true;
|
||||
|
||||
try {
|
||||
cleanups.push(patchFluxDispatch(deletedMessageMap, isEnabledRef));
|
||||
cleanups.push(patchActionSheet(isEnabledRef));
|
||||
cleanups.push(patchSelfEdit(isEnabledRef));
|
||||
} catch (err) {
|
||||
console.error("[ANTIED ZERO] Crash on load:", err);
|
||||
showToast("[ANTIED ZERO] Failed to load. Check debug log.");
|
||||
stopPlugin(id);
|
||||
}
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
isEnabledRef.current = false;
|
||||
|
||||
for (const fn of cleanups) {
|
||||
try {
|
||||
fn();
|
||||
} catch (_) {}
|
||||
}
|
||||
cleanups.length = 0;
|
||||
deletedMessageMap.clear();
|
||||
},
|
||||
|
||||
settings: Settings,
|
||||
};
|
||||
87
plugins/antied-zero/src/patches/actionSheet.tsx
Normal file
87
plugins/antied-zero/src/patches/actionSheet.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { before, after } from "@vendetta/patcher";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { React, FluxDispatcher } from "@vendetta/metro/common";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const ActionSheet = findByProps("openLazy", "hideActionSheet");
|
||||
const MessageStore = findByProps("getMessage", "getMessages");
|
||||
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
|
||||
const ChannelMessages = findByProps("_channelMessages");
|
||||
const { ActionSheetRow } = findByProps("ActionSheetRow") || {};
|
||||
|
||||
export const regexEscaper = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export function patchActionSheet(isEnabledRef: { current: boolean }): () => void {
|
||||
if (!ActionSheet?.openLazy || !ActionSheetRow) return () => {};
|
||||
|
||||
return before("openLazy", ActionSheet, ([component, args, actionMessage]: any[]) => {
|
||||
if (!isEnabledRef.current) return;
|
||||
|
||||
try {
|
||||
const message = actionMessage?.message;
|
||||
if (args !== "MessageLongPressActionSheet" || !message) return;
|
||||
|
||||
component.then((instance: any) => {
|
||||
const unpatch = after("default", instance, (_: any, comp: any) => {
|
||||
try {
|
||||
React.useEffect(() => () => { unpatch(); }, []);
|
||||
|
||||
const isReplyBtn = (a: any) => a?.props?.label?.toLowerCase?.() === "reply";
|
||||
const buttons = findInReactTree(comp, (c: any) => c?.find?.(isReplyBtn));
|
||||
if (!buttons) return comp;
|
||||
|
||||
const position = Math.max(buttons.findIndex(isReplyBtn), buttons.length - 1);
|
||||
|
||||
let originalMessage = null;
|
||||
if (message?.channel_id && message?.id) {
|
||||
originalMessage = MessageStore?.getMessage?.(message.channel_id, message.id) ||
|
||||
ChannelMessages?.get?.(message.channel_id)?.get?.(message.id);
|
||||
}
|
||||
if (!originalMessage) return comp;
|
||||
|
||||
const editedTag = "`[ EDITED ]`";
|
||||
const separator = new RegExp(regexEscaper(editedTag), "gmi");
|
||||
if (separator.test(message.content)) {
|
||||
buttons.splice(
|
||||
position || 1,
|
||||
0,
|
||||
React.createElement(ActionSheetRow, {
|
||||
label: "Remove Edit History",
|
||||
subLabel: "Antied Zero",
|
||||
icon: React.createElement(ActionSheetRow.Icon, {
|
||||
source: getAssetIDByName("ic_edit_24px") || getAssetIDByName("ic_message_edit"),
|
||||
}),
|
||||
onPress: () => {
|
||||
const regexPattern = new RegExp(`(?:\\s${regexEscaper(editedTag)}\\n{2})`, "gm");
|
||||
const lats = message?.content?.split(regexPattern);
|
||||
const targetMessage = lats?.[lats.length - 1] ?? message.content;
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_UPDATE",
|
||||
message: {
|
||||
...message,
|
||||
message_reference: message?.message_reference || message?.messageReference || null,
|
||||
content: `${targetMessage}`,
|
||||
guild_id: ChannelStore?.getChannel?.(originalMessage.channel_id)?.guild_id,
|
||||
},
|
||||
otherPluginBypass: true,
|
||||
});
|
||||
|
||||
ActionSheet.hideActionSheet();
|
||||
showToast("Edit history removed", getAssetIDByName("ic_edit_24px"));
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ANTIED ZERO] ActionSheet component error:", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[ANTIED ZERO] ActionSheet patch error:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
106
plugins/antied-zero/src/patches/fluxDispatch.ts
Normal file
106
plugins/antied-zero/src/patches/fluxDispatch.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { before } from "@vendetta/patcher";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||
import { recordToGateway } from "@shared/lib/recordToGateway";
|
||||
|
||||
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
|
||||
const ChannelMessages = findByProps("_channelMessages");
|
||||
const MessageStore = findByProps("getMessage", "getMessages");
|
||||
|
||||
export function patchFluxDispatch(deletedMessageMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
|
||||
return before("dispatch", FluxDispatcher, (args: any[]) => {
|
||||
if (!isEnabledRef.current) return;
|
||||
|
||||
try {
|
||||
const ev = args[0];
|
||||
if (!ev || !ev.type) return;
|
||||
|
||||
/* =========================================================
|
||||
MESSAGE_DELETE
|
||||
==========================================================*/
|
||||
if (ev.type === "MESSAGE_DELETE") {
|
||||
if (ev.otherPluginBypass || ev.manualDelete) return;
|
||||
|
||||
const orig = ChannelMessages?.get?.(ev.channelId)?.get?.(ev.id) || MessageStore?.getMessage?.(ev.channelId, ev.id) || deletedMessageMap.get(ev.id)?.original;
|
||||
if (!orig?.author?.id || !orig.author.username) return;
|
||||
|
||||
// Ephemeral dismiss from bots check
|
||||
if (orig.author.bot && (orig.flags === 64 || (orig.flags & 64) === 64)) return;
|
||||
if (orig.author.bot) return;
|
||||
|
||||
// Empty check
|
||||
if (!orig.content && !orig.attachments?.length && !orig.embeds?.length) return;
|
||||
|
||||
if (deletedMessageMap.has(ev.id)) {
|
||||
ev.type = "MESSAGE_UPDATE";
|
||||
ev.channelId = orig.channel_id || ev.channelId;
|
||||
ev.message = {
|
||||
id: ev.id,
|
||||
channel_id: orig.channel_id || ev.channelId,
|
||||
flags: 64,
|
||||
was_deleted: true,
|
||||
};
|
||||
return args;
|
||||
}
|
||||
|
||||
const guildId = ChannelStore?.getChannel?.(orig.channel_id || ev.channelId)?.guild_id;
|
||||
const gatewayOrig = recordToGateway(orig);
|
||||
|
||||
ev.message = {
|
||||
...gatewayOrig,
|
||||
content: orig.content,
|
||||
channel_id: orig.channel_id || ev.channelId,
|
||||
guild_id: guildId,
|
||||
flags: 64,
|
||||
was_deleted: true,
|
||||
message_reference: orig?.message_reference || orig?.messageReference || null,
|
||||
};
|
||||
|
||||
ev.type = "MESSAGE_UPDATE";
|
||||
ev.channelId = orig.channel_id || ev.channelId;
|
||||
ev.optimistic = false;
|
||||
ev.sendMessageOptions = {};
|
||||
ev.isPushNotification = false;
|
||||
|
||||
deletedMessageMap.set(ev.id, { message: args, original: orig });
|
||||
return args;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MESSAGE_UPDATE
|
||||
==========================================================*/
|
||||
if (ev.type === "MESSAGE_UPDATE") {
|
||||
if (ev.otherPluginBypass) return;
|
||||
const msg = ev.message;
|
||||
if (!msg || msg.author?.bot) return;
|
||||
|
||||
// Only handle real content edits with a valid edited_timestamp
|
||||
if (!msg.edited_timestamp || msg.edited_timestamp === "invalid_timestamp") return;
|
||||
|
||||
const chId = msg.channel_id || ev.channelId;
|
||||
const id = msg.id || ev.id;
|
||||
if (!chId || !id) return;
|
||||
|
||||
const orig = MessageStore?.getMessage?.(chId, id) || ChannelMessages?.get?.(chId)?.get?.(id);
|
||||
if (!orig?.author?.id || !orig.author.username) return;
|
||||
if (!orig.content || !msg.content || msg.content === orig.content) return;
|
||||
|
||||
const prefix = "`[ EDITED ]`\n\n";
|
||||
const gatewayOrig = recordToGateway(orig);
|
||||
|
||||
ev.message = {
|
||||
...gatewayOrig,
|
||||
...msg,
|
||||
content: `${orig.content} ${prefix}${msg.content}`,
|
||||
guild_id: ChannelStore?.getChannel?.(chId)?.guild_id ?? msg.guild_id,
|
||||
edited_timestamp: "invalid_timestamp",
|
||||
message_reference: msg?.message_reference || orig?.messageReference || null,
|
||||
};
|
||||
|
||||
return args;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ANTIED ZERO] Flux error:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
23
plugins/antied-zero/src/patches/selfEdit.ts
Normal file
23
plugins/antied-zero/src/patches/selfEdit.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { before } from "@vendetta/patcher";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { regexEscaper } from "./actionSheet";
|
||||
|
||||
const Message = findByProps("sendMessage", "startEditMessage");
|
||||
|
||||
export function patchSelfEdit(isEnabledRef: { current: boolean }): () => void {
|
||||
if (!Message?.startEditMessage) return () => {};
|
||||
|
||||
return before("startEditMessage", Message, (args: any[]) => {
|
||||
if (!isEnabledRef.current || !args?.length) return;
|
||||
|
||||
const edited = "`[ EDITED ]`";
|
||||
const dan = regexEscaper(edited);
|
||||
const regexPattern = new RegExp(`(?:\\s${dan}\\n{2})`, "gm");
|
||||
|
||||
const msg = args[2];
|
||||
if (typeof msg === "string") {
|
||||
const lats = msg.split(regexPattern);
|
||||
args[2] = lats[lats.length - 1];
|
||||
}
|
||||
});
|
||||
}
|
||||
16
plugins/antied-zero/src/ui/Settings.tsx
Normal file
16
plugins/antied-zero/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import { TableRowGroup } from "@shared/ui/table";
|
||||
|
||||
export default function Settings() {
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<TableRowGroup title="Antied Zero">
|
||||
<NoteBox>
|
||||
Antied Zero is active and running in zero-config mode. Deleted messages are automatically retained with ephemeral flags, and edit history is tracked in-line.
|
||||
</NoteBox>
|
||||
</TableRowGroup>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
12
plugins/antied/manifest.json
Normal file
12
plugins/antied/manifest.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "Cute Moodle (Antied)",
|
||||
"description": "Keeps deleted messages and edit history visible in chat with customizable highlights, indicators, and actionsheet management.",
|
||||
"authors": [
|
||||
{ "name": "Angelw0lf" },
|
||||
{ "name": "Rosie" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_edit_24px"
|
||||
}
|
||||
}
|
||||
148
plugins/antied/src/index.ts
Normal file
148
plugins/antied/src/index.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import { storage, id } from "@vendetta/plugin";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||
import { stopPlugin } from "@vendetta/plugins";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { makeDefaults } from "@shared/lib/utility";
|
||||
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
|
||||
|
||||
import { patchFluxDispatch } from "./patches/fluxDispatch";
|
||||
import { patchUpdateRows } from "./patches/updateRows";
|
||||
import { patchActionSheet } from "./patches/actionSheet";
|
||||
import { patchSelfEdit } from "./patches/selfEdit";
|
||||
import { patchContextMenu } from "./patches/contextMenu";
|
||||
import { patchMessageRecord } from "./patches/messageRecord";
|
||||
import Settings from "./ui/Settings";
|
||||
import type { AntiedStorage } from "./lib/types";
|
||||
|
||||
const ChannelMessages = findByProps("_channelMessages");
|
||||
|
||||
const isEnabledRef = { current: false };
|
||||
const deletedMessageMap = new Map<string, any>();
|
||||
const cleanups: (() => void)[] = [];
|
||||
let intervalPurge: any = null;
|
||||
|
||||
const KEEP_NEWEST = 10;
|
||||
const DELETE_EACH_CYCLE = 140;
|
||||
|
||||
function initStorage() {
|
||||
makeDefaults(storage, {
|
||||
setting: {
|
||||
colorpick: false,
|
||||
customize: false,
|
||||
ingorelist: false,
|
||||
patches: false,
|
||||
text: false,
|
||||
timestamp: false,
|
||||
},
|
||||
switches: {
|
||||
customizeable: false,
|
||||
enableMD: true,
|
||||
enableMU: true,
|
||||
useBackgroundColor: false,
|
||||
useSemRawColors: false,
|
||||
ignoreBots: false,
|
||||
ignoreOwnMessages: false,
|
||||
minimalistic: true,
|
||||
alwaysAdd: false,
|
||||
darkMode: true,
|
||||
removeDismissButton: false,
|
||||
addTimestampForEdits: false,
|
||||
timestampStyle: "R",
|
||||
useEphemeralForDeleted: true,
|
||||
overrideIndicator: false,
|
||||
useIndicatorForDeleted: false,
|
||||
useCustomPluginName: false,
|
||||
},
|
||||
colors: {
|
||||
textColor: "#E40303",
|
||||
backgroundColor: "#FF2C2F",
|
||||
backgroundColorAlpha: "33",
|
||||
gutterColor: "#FF2C2F",
|
||||
gutterColorAlpha: "CC",
|
||||
semRawColorPrefix: "semanticColors.TEXT_BRAND",
|
||||
},
|
||||
inputs: {
|
||||
deletedMessageBuffer: "This message is deleted",
|
||||
editedMessageBuffer: "`[ EDITED ]`",
|
||||
historyToast: "[ANTI ED] History Removed",
|
||||
ignoredUserList: [],
|
||||
customPluginName: "ANTIED",
|
||||
customIndicator: "",
|
||||
},
|
||||
misc: {
|
||||
timestampPos: "BEFORE",
|
||||
editHistoryIcon: "ic_edit_24px",
|
||||
},
|
||||
debug: false,
|
||||
debugUpdateRows: false,
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
onLoad: async () => {
|
||||
initStorage();
|
||||
|
||||
if (enforceBlacklistOnLoad(id)) return;
|
||||
|
||||
isEnabledRef.current = true;
|
||||
|
||||
try {
|
||||
cleanups.push(patchMessageRecord(isEnabledRef));
|
||||
cleanups.push(patchFluxDispatch(deletedMessageMap, isEnabledRef));
|
||||
cleanups.push(patchUpdateRows(deletedMessageMap, isEnabledRef));
|
||||
cleanups.push(patchActionSheet(deletedMessageMap, isEnabledRef));
|
||||
cleanups.push(patchSelfEdit(isEnabledRef));
|
||||
cleanups.push(patchContextMenu(isEnabledRef));
|
||||
} catch (err) {
|
||||
console.error("[ANTIED] Crash on load:", err);
|
||||
showToast("[ANTIED] Failed to load patches. Check debug log.");
|
||||
stopPlugin(id);
|
||||
return;
|
||||
}
|
||||
|
||||
intervalPurge = setInterval(() => {
|
||||
if (deletedMessageMap.size <= KEEP_NEWEST) return;
|
||||
const toDelete = Math.min(DELETE_EACH_CYCLE, deletedMessageMap.size - KEEP_NEWEST);
|
||||
let i = 0;
|
||||
for (const key of deletedMessageMap.keys()) {
|
||||
deletedMessageMap.delete(key);
|
||||
if (++i >= toDelete) break;
|
||||
}
|
||||
}, 15 * 60 * 1000);
|
||||
},
|
||||
|
||||
onUnload: () => {
|
||||
isEnabledRef.current = false;
|
||||
if (intervalPurge) clearInterval(intervalPurge);
|
||||
|
||||
for (const fn of cleanups) {
|
||||
try {
|
||||
fn();
|
||||
} catch (_) {}
|
||||
}
|
||||
cleanups.length = 0;
|
||||
|
||||
// Revert faked messages
|
||||
if (ChannelMessages?._channelMessages) {
|
||||
for (const channelId in ChannelMessages._channelMessages) {
|
||||
const arr = ChannelMessages._channelMessages[channelId]?._array;
|
||||
if (!Array.isArray(arr)) continue;
|
||||
for (const message of arr) {
|
||||
if (message?.was_deleted) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_DELETE",
|
||||
id: message.id,
|
||||
channelId: message.channel_id || channelId,
|
||||
otherPluginBypass: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deletedMessageMap.clear();
|
||||
},
|
||||
|
||||
settings: Settings,
|
||||
};
|
||||
51
plugins/antied/src/lib/types.ts
Normal file
51
plugins/antied/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
export interface AntiedStorage {
|
||||
setting: {
|
||||
colorpick: boolean;
|
||||
customize: boolean;
|
||||
ingorelist: boolean;
|
||||
patches: boolean;
|
||||
text: boolean;
|
||||
timestamp: boolean;
|
||||
};
|
||||
switches: {
|
||||
customizeable: boolean;
|
||||
enableMD: boolean;
|
||||
enableMU: boolean;
|
||||
useBackgroundColor: boolean;
|
||||
useSemRawColors: boolean;
|
||||
ignoreBots: boolean;
|
||||
ignoreOwnMessages: boolean;
|
||||
minimalistic: boolean;
|
||||
alwaysAdd: boolean;
|
||||
darkMode: boolean;
|
||||
removeDismissButton: boolean;
|
||||
addTimestampForEdits: boolean;
|
||||
timestampStyle: "t" | "T" | "d" | "D" | "f" | "F" | "R";
|
||||
useEphemeralForDeleted: boolean;
|
||||
overrideIndicator: boolean;
|
||||
useIndicatorForDeleted: boolean;
|
||||
useCustomPluginName: boolean;
|
||||
};
|
||||
colors: {
|
||||
textColor: string;
|
||||
backgroundColor: string;
|
||||
backgroundColorAlpha: string;
|
||||
gutterColor: string;
|
||||
gutterColorAlpha: string;
|
||||
semRawColorPrefix: string;
|
||||
};
|
||||
inputs: {
|
||||
deletedMessageBuffer: string;
|
||||
editedMessageBuffer: string;
|
||||
historyToast: string;
|
||||
ignoredUserList: { id: string; username: string }[];
|
||||
customPluginName: string;
|
||||
customIndicator: string;
|
||||
};
|
||||
misc: {
|
||||
timestampPos: "BEFORE" | "AFTER";
|
||||
editHistoryIcon: string;
|
||||
};
|
||||
debug: boolean;
|
||||
debugUpdateRows: boolean;
|
||||
}
|
||||
126
plugins/antied/src/patches/actionSheet.tsx
Normal file
126
plugins/antied/src/patches/actionSheet.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { before, after } from "@vendetta/patcher";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { React, FluxDispatcher } from "@vendetta/metro/common";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import type { AntiedStorage } from "../lib/types";
|
||||
|
||||
const ActionSheet = findByProps("openLazy", "hideActionSheet");
|
||||
const MessageStore = findByProps("getMessage", "getMessages");
|
||||
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
|
||||
const ChannelMessages = findByProps("_channelMessages");
|
||||
const { ActionSheetRow } = findByProps("ActionSheetRow") || {};
|
||||
|
||||
export const regexEscaper = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
export function patchActionSheet(deletedMessageMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
|
||||
if (!ActionSheet?.openLazy || !ActionSheetRow) return () => {};
|
||||
|
||||
return before("openLazy", ActionSheet, ([component, args, actionMessage]: any[]) => {
|
||||
if (!isEnabledRef.current) return;
|
||||
|
||||
try {
|
||||
const message = actionMessage?.message;
|
||||
if (args !== "MessageLongPressActionSheet" || !message) return;
|
||||
|
||||
component.then((instance: any) => {
|
||||
const unpatch = after("default", instance, (_: any, comp: any) => {
|
||||
try {
|
||||
React.useEffect(() => () => { unpatch(); }, []);
|
||||
|
||||
const isReplyBtn = (a: any) => a?.props?.label?.toLowerCase?.() === "reply";
|
||||
const buttons = findInReactTree(comp, (c: any) => c?.find?.(isReplyBtn));
|
||||
if (!buttons) return comp;
|
||||
|
||||
const position = Math.max(buttons.findIndex(isReplyBtn), buttons.length - 1);
|
||||
|
||||
let originalMessage = null;
|
||||
if (message?.channel_id && message?.id) {
|
||||
originalMessage = MessageStore?.getMessage?.(message.channel_id, message.id) ||
|
||||
ChannelMessages?.get?.(message.channel_id)?.get?.(message.id);
|
||||
}
|
||||
if (!originalMessage) return comp;
|
||||
|
||||
const cfg = storage as AntiedStorage;
|
||||
const escapedBuffer = regexEscaper(cfg.inputs?.editedMessageBuffer || "`[ EDITED ]`");
|
||||
const separator = new RegExp(escapedBuffer, "gmi");
|
||||
const hasBuffer = separator.test(message.content);
|
||||
|
||||
if (hasBuffer) {
|
||||
const targetPos = position || 1;
|
||||
buttons.splice(
|
||||
targetPos,
|
||||
0,
|
||||
React.createElement(ActionSheetRow, {
|
||||
label: "Remove Edit History",
|
||||
subLabel: "Cute Moodle (Antied)",
|
||||
icon: React.createElement(ActionSheetRow.Icon, {
|
||||
source: getAssetIDByName("ic_edit_24px") || getAssetIDByName("ic_message_edit"),
|
||||
}),
|
||||
onPress: () => {
|
||||
const regexPattern = new RegExp(
|
||||
`(?:(?:\\s${escapedBuffer}(\\s\\(<t:\\d+:[tTdDfFR]>\\))?\\n{2})|(?:(?:\\s\\(<t:\\d+:[tTdDfFR]>\\) ${escapedBuffer}\\n{2})))`,
|
||||
"gm"
|
||||
);
|
||||
const lats = message?.content?.split(regexPattern);
|
||||
const targetMessage = lats?.[lats.length - 1] ?? message.content;
|
||||
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_UPDATE",
|
||||
message: {
|
||||
...message,
|
||||
message_reference: message?.message_reference || message?.messageReference || null,
|
||||
content: `${targetMessage}`,
|
||||
guild_id: ChannelStore?.getChannel?.(originalMessage.channel_id)?.guild_id,
|
||||
},
|
||||
otherPluginBypass: true,
|
||||
});
|
||||
|
||||
ActionSheet.hideActionSheet();
|
||||
if (cfg.inputs?.historyToast) {
|
||||
showToast(cfg.inputs.historyToast, getAssetIDByName(cfg.misc?.editHistoryIcon || "ic_edit_24px"));
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (!cfg.switches?.useEphemeralForDeleted && deletedMessageMap.has(message.id)) {
|
||||
const targetPos = position || 1;
|
||||
buttons.splice(
|
||||
targetPos,
|
||||
0,
|
||||
React.createElement(ActionSheetRow, {
|
||||
label: "Remove Deleted Message",
|
||||
subLabel: "Cute Moodle (Antied)",
|
||||
isDestructive: true,
|
||||
icon: React.createElement(ActionSheetRow.Icon, {
|
||||
source: getAssetIDByName("ic_edit_24px") || getAssetIDByName("TrashIcon"),
|
||||
}),
|
||||
onPress: () => {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_DELETE",
|
||||
guildId: ChannelStore?.getChannel?.(originalMessage.channel_id)?.guild_id,
|
||||
id: message?.id,
|
||||
channelId: message?.channel_id,
|
||||
otherPluginBypass: true,
|
||||
});
|
||||
|
||||
ActionSheet.hideActionSheet();
|
||||
showToast("[ANTIED] Message Removed", getAssetIDByName("ic_edit_24px"));
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ANTIED] ActionSheet component error:", e);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[ANTIED] ActionSheet patch error:", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
71
plugins/antied/src/patches/contextMenu.ts
Normal file
71
plugins/antied/src/patches/contextMenu.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { after } from "@vendetta/patcher";
|
||||
import { findByName, findByStoreName } from "@vendetta/metro";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import type { AntiedStorage } from "../lib/types";
|
||||
|
||||
const PROFILE_MENU_NAMES = ["UserProfileOverflowMenu", "BotUserProfileOverflowMenu"];
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
|
||||
function getMainItems(ret: any): any[] | null {
|
||||
let items = ret?.props?.items;
|
||||
if (Array.isArray(items) && Array.isArray(items[0])) return items[0];
|
||||
items = ret?.props?.children?.props?.items;
|
||||
if (Array.isArray(items) && Array.isArray(items[0])) return items[0];
|
||||
const node = findInReactTree(ret, (n: any) => Array.isArray(n?.props?.items) && Array.isArray(n.props.items[0]));
|
||||
return node?.props?.items?.[0] ?? null;
|
||||
}
|
||||
|
||||
export function patchContextMenu(isEnabledRef: { current: boolean }): () => void {
|
||||
const unpatches: (() => void)[] = [];
|
||||
|
||||
for (const name of PROFILE_MENU_NAMES) {
|
||||
const mod = findByName(name, false);
|
||||
if (!mod) continue;
|
||||
|
||||
unpatches.push(
|
||||
after("default", mod, (args: any[], ret: any) => {
|
||||
if (!isEnabledRef.current) return;
|
||||
try {
|
||||
const props = args[0] ?? {};
|
||||
const userId = props.user?.id || props.userId;
|
||||
if (!userId) return;
|
||||
|
||||
const items = getMainItems(ret);
|
||||
if (!items) return;
|
||||
|
||||
if (items.some((item: any) => item?.label?.includes("(Antied)"))) return;
|
||||
|
||||
const cfg = storage as AntiedStorage;
|
||||
cfg.inputs ??= {} as any;
|
||||
cfg.inputs.ignoredUserList ??= [];
|
||||
|
||||
const isIgnored = cfg.inputs.ignoredUserList.some((u) => u.id === userId);
|
||||
const user = UserStore?.getUser?.(userId);
|
||||
const username = user?.username || userId;
|
||||
|
||||
items.push({
|
||||
label: isIgnored ? "Unignore User (Antied)" : "Ignore User (Antied)",
|
||||
isDestructive: !isIgnored,
|
||||
action: () => {
|
||||
if (isIgnored) {
|
||||
cfg.inputs.ignoredUserList = cfg.inputs.ignoredUserList.filter((u) => u.id !== userId);
|
||||
showToast(`Unignored ${username} in Antied`);
|
||||
} else {
|
||||
cfg.inputs.ignoredUserList.push({ id: userId, username });
|
||||
showToast(`Ignored ${username} in Antied`);
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[ANTIED] Context menu error:", e);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of unpatches) fn();
|
||||
};
|
||||
}
|
||||
175
plugins/antied/src/patches/fluxDispatch.ts
Normal file
175
plugins/antied/src/patches/fluxDispatch.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import { before } from "@vendetta/patcher";
|
||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { FluxDispatcher } from "@vendetta/metro/common";
|
||||
import { recordToGateway } from "@shared/lib/recordToGateway";
|
||||
import type { AntiedStorage } from "../lib/types";
|
||||
|
||||
const ChannelStore = findByProps("getChannel", "getDMFromUserId");
|
||||
const ChannelMessages = findByProps("_channelMessages");
|
||||
const MessageStore = findByProps("getMessage", "getMessages");
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
const AuthStore = findByStoreName("AuthenticationStore") || findByProps("getToken");
|
||||
|
||||
function getCurrentUserId(): string | undefined {
|
||||
return UserStore?.getCurrentUser?.()?.id || AuthStore?.getId?.() || AuthStore?.getCurrentUser?.()?.id;
|
||||
}
|
||||
|
||||
const now = () => Date.now();
|
||||
const tsStyle = () => {
|
||||
const s = (storage as AntiedStorage).switches?.timestampStyle;
|
||||
return s && "tTdDfFR".includes(s) ? s : "R";
|
||||
};
|
||||
|
||||
export function patchFluxDispatch(deletedMessageMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
|
||||
return before("dispatch", FluxDispatcher, (args: any[]) => {
|
||||
if (!isEnabledRef.current) return;
|
||||
|
||||
try {
|
||||
const ev = args[0];
|
||||
if (!ev || !ev.type) return;
|
||||
|
||||
const cfg = storage as AntiedStorage;
|
||||
const currentUserId = getCurrentUserId();
|
||||
|
||||
/* =========================================================
|
||||
MESSAGE_DELETE
|
||||
==========================================================*/
|
||||
if (ev.type === "MESSAGE_DELETE") {
|
||||
if (!cfg.switches?.enableMD || ev.otherPluginBypass || ev.manualDelete) return;
|
||||
|
||||
const orig = ChannelMessages?.get?.(ev.channelId)?.get?.(ev.id) || MessageStore?.getMessage?.(ev.channelId, ev.id) || deletedMessageMap.get(ev.id)?.original;
|
||||
if (!orig?.author?.id || !orig.author.username) return;
|
||||
|
||||
// Ephemeral message dismiss check
|
||||
if (orig?.author?.bot && (orig?.flags === 64 || (orig?.flags & 64) === 64)) return;
|
||||
|
||||
// Empty message check
|
||||
if (!orig.content && !orig.attachments?.length && !orig.embeds?.length && !orig.components?.length) return;
|
||||
|
||||
// Bot check
|
||||
if (cfg.switches.ignoreBots && (orig.author.bot || orig.author.isNonUserBot?.())) return;
|
||||
|
||||
// Own message check
|
||||
if (cfg.switches.ignoreOwnMessages && orig.author.id === currentUserId) return;
|
||||
|
||||
// Ignored users list check
|
||||
if (cfg.inputs?.ignoredUserList?.length) {
|
||||
if (cfg.inputs.ignoredUserList.some((u) => u.id === orig.author.id || u.username === orig.author.username)) return;
|
||||
}
|
||||
|
||||
if (deletedMessageMap.has(ev.id)) {
|
||||
ev.type = "MESSAGE_UPDATE";
|
||||
ev.channelId = orig.channel_id || ev.channelId;
|
||||
ev.message = {
|
||||
id: ev.id,
|
||||
channel_id: orig.channel_id || ev.channelId,
|
||||
was_deleted: true,
|
||||
};
|
||||
return args;
|
||||
}
|
||||
|
||||
const guildId = ChannelStore?.getChannel?.(orig.channel_id || ev.channelId)?.guild_id;
|
||||
const gatewayOrig = recordToGateway(orig);
|
||||
|
||||
ev.message = {
|
||||
...gatewayOrig,
|
||||
content: orig.content,
|
||||
channel_id: orig.channel_id || ev.channelId,
|
||||
guild_id: guildId,
|
||||
was_deleted: true,
|
||||
message_reference: orig?.message_reference || orig?.messageReference || null,
|
||||
};
|
||||
|
||||
ev.type = "MESSAGE_UPDATE";
|
||||
ev.channelId = orig.channel_id || ev.channelId;
|
||||
ev.optimistic = false;
|
||||
ev.sendMessageOptions = {};
|
||||
ev.isPushNotification = false;
|
||||
|
||||
deletedMessageMap.set(ev.id, { message: args, original: orig });
|
||||
return args;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MESSAGE_DELETE_BULK
|
||||
==========================================================*/
|
||||
if (ev.type === "MESSAGE_DELETE_BULK") {
|
||||
if (!cfg.switches?.enableMD || ev.otherPluginBypass || !Array.isArray(ev.ids)) return;
|
||||
|
||||
for (const id of ev.ids) {
|
||||
const orig = ChannelMessages?.get?.(ev.channelId)?.get?.(id) || MessageStore?.getMessage?.(ev.channelId, id);
|
||||
if (!orig?.author?.id) continue;
|
||||
if (cfg.switches.ignoreBots && (orig.author.bot || orig.author.isNonUserBot?.())) continue;
|
||||
if (cfg.switches.ignoreOwnMessages && orig.author.id === currentUserId) continue;
|
||||
|
||||
deletedMessageMap.set(id, { original: orig });
|
||||
|
||||
const gatewayOrig = recordToGateway(orig);
|
||||
FluxDispatcher.dispatch({
|
||||
type: "MESSAGE_UPDATE",
|
||||
channelId: ev.channelId,
|
||||
message: {
|
||||
...gatewayOrig,
|
||||
was_deleted: true,
|
||||
},
|
||||
otherPluginBypass: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/* =========================================================
|
||||
MESSAGE_UPDATE
|
||||
==========================================================*/
|
||||
if (ev.type === "MESSAGE_UPDATE") {
|
||||
if (!cfg.switches?.enableMU || ev.otherPluginBypass) return;
|
||||
const msg = ev.message;
|
||||
if (!msg) return;
|
||||
|
||||
// A MESSAGE_UPDATE is ONLY a real content edit if edited_timestamp is present and not synthetic
|
||||
if (!msg.edited_timestamp || msg.edited_timestamp === "invalid_timestamp") return;
|
||||
|
||||
const chId = msg.channel_id || ev.channelId;
|
||||
const id = msg.id || ev.id;
|
||||
if (!chId || !id) return;
|
||||
|
||||
const orig = MessageStore?.getMessage?.(chId, id) || ChannelMessages?.get?.(chId)?.get?.(id);
|
||||
if (!orig?.author?.id) return;
|
||||
|
||||
if (cfg.switches.ignoreBots && (orig.author.bot || orig.author.isNonUserBot?.())) return;
|
||||
if (cfg.switches.ignoreOwnMessages && orig.author.id === currentUserId) return;
|
||||
|
||||
if (!orig.content || !msg.content || msg.content === orig.content) return;
|
||||
|
||||
if (cfg.inputs?.ignoredUserList?.length) {
|
||||
if (cfg.inputs.ignoredUserList.some((u) => u.id === orig.author.id || u.username === orig.author.username)) return;
|
||||
}
|
||||
|
||||
const editedTag = cfg.inputs?.editedMessageBuffer || "`[ EDITED ]`";
|
||||
if (orig.content.includes(editedTag) && orig.content.endsWith(msg.content)) return;
|
||||
|
||||
const time = cfg.switches?.addTimestampForEdits ? `(<t:${Math.floor(now() / 1000)}:${tsStyle()}>)` : null;
|
||||
const tsPos = cfg.misc?.timestampPos === "BEFORE";
|
||||
|
||||
let prefix = editedTag;
|
||||
prefix = time ? (tsPos ? `${time} ${prefix}\n\n` : `${prefix} ${time}\n\n`) : `${prefix}\n\n`;
|
||||
|
||||
const gatewayOrig = recordToGateway(orig);
|
||||
|
||||
ev.message = {
|
||||
...gatewayOrig,
|
||||
...msg,
|
||||
content: `${orig.content} ${prefix}${msg.content}`,
|
||||
guild_id: ChannelStore?.getChannel?.(chId)?.guild_id ?? msg.guild_id,
|
||||
edited_timestamp: "invalid_timestamp",
|
||||
message_reference: msg?.message_reference || orig?.messageReference || null,
|
||||
};
|
||||
|
||||
return args;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error("[ANTIED] Flux dispatch error:", e?.message ?? e);
|
||||
}
|
||||
});
|
||||
}
|
||||
40
plugins/antied/src/patches/messageRecord.ts
Normal file
40
plugins/antied/src/patches/messageRecord.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { after } from "@vendetta/patcher";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
const MessageRecordUtils = findByProps("updateMessageRecord", "createMessageRecord");
|
||||
|
||||
export function patchMessageRecord(isEnabledRef: { current: boolean }): () => void {
|
||||
if (!MessageRecordUtils) return () => {};
|
||||
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
cleanups.push(
|
||||
after("createMessageRecord", MessageRecordUtils, function ([message], record) {
|
||||
if (isEnabledRef.current && (message?.was_deleted || record?.was_deleted)) {
|
||||
if (record) record.was_deleted = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
after("updateMessageRecord", MessageRecordUtils, function ([oldRecord, newRecord], record) {
|
||||
if (isEnabledRef.current && (oldRecord?.was_deleted || newRecord?.was_deleted)) {
|
||||
if (record) record.was_deleted = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
if (typeof MessageRecordUtils.updateServerMessage === "function") {
|
||||
cleanups.push(
|
||||
after("updateServerMessage", MessageRecordUtils, function ([oldRecord, newRecord], record) {
|
||||
if (isEnabledRef.current && (oldRecord?.was_deleted || newRecord?.was_deleted)) {
|
||||
if (record) record.was_deleted = true;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of cleanups) fn();
|
||||
};
|
||||
}
|
||||
30
plugins/antied/src/patches/selfEdit.ts
Normal file
30
plugins/antied/src/patches/selfEdit.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { before } from "@vendetta/patcher";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { regexEscaper } from "./actionSheet";
|
||||
import type { AntiedStorage } from "../lib/types";
|
||||
|
||||
const Message = findByProps("sendMessage", "startEditMessage");
|
||||
|
||||
export function patchSelfEdit(isEnabledRef: { current: boolean }): () => void {
|
||||
if (!Message?.startEditMessage) return () => {};
|
||||
|
||||
return before("startEditMessage", Message, (args: any[]) => {
|
||||
if (!isEnabledRef.current || !args?.length) return;
|
||||
|
||||
const cfg = storage as AntiedStorage;
|
||||
const edited = cfg.inputs?.editedMessageBuffer || "`[ EDITED ]`";
|
||||
const dan = regexEscaper(edited);
|
||||
|
||||
const regexPattern = new RegExp(
|
||||
`(?:(?:\\s${dan}(\\s\\(<t:\\d+:[tTdDfFR]>\\))?\\n{2})|(?:(?:\\s\\(<t:\\d+:[tTdDfFR]>\\) ${dan}\\n{2})))`,
|
||||
"gm"
|
||||
);
|
||||
|
||||
const msg = args[2];
|
||||
if (typeof msg === "string") {
|
||||
const lats = msg.split(regexPattern);
|
||||
args[2] = lats[lats.length - 1];
|
||||
}
|
||||
});
|
||||
}
|
||||
114
plugins/antied/src/patches/updateRows.ts
Normal file
114
plugins/antied/src/patches/updateRows.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { ReactNative } from "@vendetta/metro/common";
|
||||
import { before, after } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { findByProps, findByName } from "@vendetta/metro";
|
||||
import { rawFind } from "@shared/lib/rawFind";
|
||||
import { waitFor } from "@shared/lib/waitFor";
|
||||
import type { AntiedStorage } from "../lib/types";
|
||||
|
||||
const toHex = (v: string | undefined, fallback: string) => {
|
||||
const s = String(v || "").trim();
|
||||
const hex = s.startsWith("#") ? s.slice(1) : s;
|
||||
return /^[0-9a-fA-F]{6}$/.test(hex) ? `#${hex.toUpperCase()}` : fallback;
|
||||
};
|
||||
|
||||
function handleRow(row: any, deletedMessagesMap: Map<string, any>) {
|
||||
if (!row || row.type !== 1) return;
|
||||
const msg = row.message;
|
||||
if (!msg || (!deletedMessagesMap.has(msg.id) && !msg.was_deleted)) return;
|
||||
|
||||
const cfg = storage as AntiedStorage;
|
||||
const {
|
||||
colors: { textColor, backgroundColor, backgroundColorAlpha, gutterColor, gutterColorAlpha },
|
||||
switches: { useBackgroundColor, minimalistic },
|
||||
inputs: { deletedMessageBuffer },
|
||||
} = cfg;
|
||||
|
||||
msg.edited = deletedMessageBuffer || "This message is deleted";
|
||||
|
||||
if (!minimalistic) {
|
||||
msg.textColor = ReactNative.processColor(toHex(textColor, "#E40303"));
|
||||
}
|
||||
|
||||
if (!minimalistic && useBackgroundColor) {
|
||||
row.backgroundHighlight = {
|
||||
backgroundColor: ReactNative.processColor(toHex(backgroundColor, "#FF2C2F") + backgroundColorAlpha),
|
||||
gutterColor: ReactNative.processColor(toHex(gutterColor, "#FF2C2F") + gutterColorAlpha),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function isNativeUpdateRows(m: any): boolean {
|
||||
return typeof m?.updateRows === "function" && m.updateRows.toString().includes("[native code]");
|
||||
}
|
||||
|
||||
export function patchUpdateRows(deletedMessagesMap: Map<string, any>, isEnabledRef: { current: boolean }): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
|
||||
const applyHook = (target: any) => {
|
||||
cleanups.push(
|
||||
before("updateRows", target, (args: any[]) => {
|
||||
if (!isEnabledRef.current || !args?.length) return;
|
||||
if (!deletedMessagesMap.size) return;
|
||||
|
||||
const raw = args[1];
|
||||
if (!raw) return;
|
||||
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const rows = JSON.parse(raw);
|
||||
let mutated = false;
|
||||
for (const row of rows) {
|
||||
if (row?.type === 1 && row?.message && (deletedMessagesMap.has(row.message.id) || row.message.was_deleted)) {
|
||||
handleRow(row, deletedMessagesMap);
|
||||
mutated = true;
|
||||
}
|
||||
}
|
||||
if (mutated) {
|
||||
args[1] = JSON.stringify(rows);
|
||||
return args;
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
} else if (Array.isArray(raw)) {
|
||||
for (const row of raw) handleRow(row, deletedMessagesMap);
|
||||
} else if (raw && typeof raw === "object" && Array.isArray(raw.rows)) {
|
||||
for (const row of raw.rows) handleRow(row, deletedMessagesMap);
|
||||
}
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const { NativeModules } = ReactNative;
|
||||
const DCDChatManager = NativeModules?.DCDChatManager;
|
||||
if (DCDChatManager?.updateRows) {
|
||||
applyHook(DCDChatManager);
|
||||
}
|
||||
|
||||
const nativeChat = rawFind<any>(isNativeUpdateRows) || findByProps("updateRows", "getConstants") || findByProps("updateRows");
|
||||
if (nativeChat && nativeChat !== DCDChatManager) {
|
||||
applyHook(nativeChat);
|
||||
} else if (!DCDChatManager) {
|
||||
const handle = waitFor(
|
||||
() => rawFind<any>(isNativeUpdateRows) || findByProps("updateRows"),
|
||||
(target) => applyHook(target)
|
||||
);
|
||||
cleanups.push(() => handle.cancel());
|
||||
}
|
||||
|
||||
const RowManager = findByName("RowManager", false) || findByProps("RowManager")?.RowManager;
|
||||
if (RowManager?.prototype?.generate) {
|
||||
cleanups.push(
|
||||
after("generate", RowManager.prototype, (_args: any[], rowObj: any) => {
|
||||
if (!isEnabledRef.current || !deletedMessagesMap.size) return;
|
||||
const row = rowObj?.row || rowObj;
|
||||
handleRow(row, deletedMessagesMap);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const fn of cleanups) fn();
|
||||
};
|
||||
}
|
||||
169
plugins/antied/src/ui/Settings.tsx
Normal file
169
plugins/antied/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { React, ReactNative as RN } from "@vendetta/metro/common";
|
||||
import { findByStoreName } from "@vendetta/metro";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import ColorInput from "@shared/ui/ColorInput";
|
||||
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
|
||||
import type { AntiedStorage } from "../lib/types";
|
||||
|
||||
const UserStore = findByStoreName("UserStore");
|
||||
const { View, Text, TouchableOpacity, Image } = RN;
|
||||
|
||||
export default function Settings() {
|
||||
useProxy(storage);
|
||||
const cfg = storage as AntiedStorage;
|
||||
|
||||
cfg.switches ??= {} as any;
|
||||
cfg.inputs ??= {} as any;
|
||||
cfg.colors ??= {} as any;
|
||||
cfg.misc ??= {} as any;
|
||||
|
||||
const ignoredUsers = cfg.inputs.ignoredUserList || [];
|
||||
|
||||
const handleRemoveUser = (id: string) => {
|
||||
cfg.inputs.ignoredUserList = ignoredUsers.filter((u) => u.id !== id);
|
||||
showToast("User removed from ignore list", getAssetIDByName("Check"));
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
Cute Moodle (Antied) keeps deleted messages and edit history visible in your chat with customizable highlights, indicators, and long-press actionsheet tools.
|
||||
</NoteBox>
|
||||
|
||||
<TableRowGroup title="General Logger">
|
||||
<TableSwitchRow
|
||||
label="Log deleted messages"
|
||||
subLabel="Keep deleted messages visible in chat"
|
||||
value={!!cfg.switches.enableMD}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.enableMD = v;
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Log edited messages"
|
||||
subLabel="Track edits and original message contents"
|
||||
value={!!cfg.switches.enableMU}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.enableMU = v;
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore bots"
|
||||
subLabel="Do not log messages or edits from bot accounts"
|
||||
value={!!cfg.switches.ignoreBots}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.ignoreBots = v;
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Ignore your own messages"
|
||||
subLabel="Do not log your own deletions and edits"
|
||||
value={!!cfg.switches.ignoreOwnMessages}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.ignoreOwnMessages = v;
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Styling & Appearance">
|
||||
<TableSwitchRow
|
||||
label="Minimalistic mode"
|
||||
subLabel="Show simple indicators without heavy colored highlights"
|
||||
value={!!cfg.switches.minimalistic}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.minimalistic = v;
|
||||
}}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Use background highlight"
|
||||
subLabel="Apply colored tint to deleted message backgrounds"
|
||||
value={!!cfg.switches.useBackgroundColor}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.useBackgroundColor = v;
|
||||
}}
|
||||
/>
|
||||
{!cfg.switches.minimalistic && (
|
||||
<>
|
||||
<ColorInput
|
||||
label="Text color"
|
||||
value={cfg.colors.textColor || "#E40303"}
|
||||
defaultValue="#E40303"
|
||||
onChange={(hex: string) => {
|
||||
cfg.colors.textColor = hex;
|
||||
}}
|
||||
/>
|
||||
{cfg.switches.useBackgroundColor && (
|
||||
<ColorInput
|
||||
label="Background highlight color"
|
||||
value={cfg.colors.backgroundColor || "#FF2C2F"}
|
||||
defaultValue="#FF2C2F"
|
||||
onChange={(hex: string) => {
|
||||
cfg.colors.backgroundColor = hex;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<TableSwitchRow
|
||||
label="Add timestamp for edits"
|
||||
subLabel="Include relative timestamp on edited versions"
|
||||
value={!!cfg.switches.addTimestampForEdits}
|
||||
onValueChange={(v: boolean) => {
|
||||
cfg.switches.addTimestampForEdits = v;
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title="Custom Buffers">
|
||||
<TextInput
|
||||
label="Deleted indicator text"
|
||||
value={cfg.inputs.deletedMessageBuffer || "This message is deleted"}
|
||||
onChange={(v: string) => {
|
||||
cfg.inputs.deletedMessageBuffer = v;
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
label="Edited prefix tag"
|
||||
value={cfg.inputs.editedMessageBuffer || "`[ EDITED ]`"}
|
||||
onChange={(v: string) => {
|
||||
cfg.inputs.editedMessageBuffer = v;
|
||||
}}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
|
||||
<TableRowGroup title={`Ignored Users (${ignoredUsers.length})`}>
|
||||
{ignoredUsers.length === 0 ? (
|
||||
<View style={{ padding: 16 }}>
|
||||
<Text style={{ opacity: 0.6, fontSize: 13, color: "#96989d" }}>No users currently ignored.</Text>
|
||||
</View>
|
||||
) : (
|
||||
ignoredUsers.map((u) => {
|
||||
const user = UserStore?.getUser?.(u.id);
|
||||
const name = user?.username ? `@${user.username}` : u.username || u.id;
|
||||
return (
|
||||
<TableRow
|
||||
key={u.id}
|
||||
label={name}
|
||||
subLabel={`ID: ${u.id}`}
|
||||
trailing={
|
||||
<TouchableOpacity onPress={() => handleRemoveUser(u.id)}>
|
||||
<Image
|
||||
source={getAssetIDByName("TrashIcon") || getAssetIDByName("ic_trash_24px")}
|
||||
style={{ width: 20, height: 20, tintColor: "#ff4d4d" }}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableRowGroup>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
9
plugins/better-timestamps/manifest.json
Normal file
9
plugins/better-timestamps/manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "BetterTimestamps",
|
||||
"description": "Replace message and day-divider timestamps with calendar-relative, fully relative, ISO 8601, or a custom moment.js format.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" },
|
||||
{ "name": "nexpid" }
|
||||
],
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
26
plugins/better-timestamps/src/index.ts
Normal file
26
plugins/better-timestamps/src/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { storage, id } from "@vendetta/plugin";
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
|
||||
import patchRowManager from "./patches/rowManager";
|
||||
import patchTimestamp from "./patches/timestamp";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
let unpatchAll: () => void = () => {};
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
if (enforceBlacklistOnLoad(id)) return;
|
||||
|
||||
storage.selected ??= "calendar";
|
||||
storage.customFormat ??= "dddd, MMMM Do YYYY, h:mm:ss a";
|
||||
storage.separateMessages ??= false;
|
||||
|
||||
unpatchAll = applyPatches("BetterTimestamps", logger, {
|
||||
"message & day timestamps": patchRowManager,
|
||||
"message timestamp component": patchTimestamp
|
||||
});
|
||||
},
|
||||
onUnload: () => unpatchAll(),
|
||||
settings: Settings
|
||||
};
|
||||
26
plugins/better-timestamps/src/lib/renderTimestamp.ts
Normal file
26
plugins/better-timestamps/src/lib/renderTimestamp.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { moment } from "@vendetta/metro/common";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
|
||||
export type TimestampMode = "calendar" | "relative" | "custom" | "iso";
|
||||
|
||||
export default function renderTimestamp(timestamp: any, mode: TimestampMode = storage.selected): string {
|
||||
try {
|
||||
if (
|
||||
storage.hideDateIfToday &&
|
||||
(mode === "calendar" || mode === "custom") &&
|
||||
typeof timestamp.isSame === "function" &&
|
||||
timestamp.isSame(moment(), "day")
|
||||
) {
|
||||
return timestamp.format("LT");
|
||||
}
|
||||
switch (mode) {
|
||||
case "calendar": return timestamp.calendar();
|
||||
case "relative": return timestamp.fromNow();
|
||||
case "iso": return timestamp.toISOString();
|
||||
case "custom": return timestamp.format(storage.customFormat || "dddd, MMMM Do YYYY, h:mm:ss a");
|
||||
default: return timestamp.calendar();
|
||||
}
|
||||
} catch {
|
||||
return timestamp?.calendar?.() ?? String(timestamp);
|
||||
}
|
||||
}
|
||||
74
plugins/better-timestamps/src/patches/rowManager.ts
Normal file
74
plugins/better-timestamps/src/patches/rowManager.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { moment } from "@vendetta/metro/common";
|
||||
import { after, before } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { waitFor } from "@shared/lib/waitFor";
|
||||
import { rawFindByName } from "@shared/lib/rawFind";
|
||||
import renderTimestamp from "../lib/renderTimestamp";
|
||||
|
||||
function wrapTimestamp(original: any): any {
|
||||
const getFormatted = (..._args: any[]) => renderTimestamp(original);
|
||||
if (typeof Proxy === "undefined") return getFormatted();
|
||||
return new Proxy(original, {
|
||||
get(target, prop) {
|
||||
if (prop === "format" || prop === "calendar" || prop === "fromNow" || prop === "toISOString" || prop === "toString" || prop === "toJSON") {
|
||||
return getFormatted;
|
||||
}
|
||||
const value = target[prop];
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseTimestamp(value: any): any {
|
||||
if (value && typeof value.format === "function") return value;
|
||||
return moment(value);
|
||||
}
|
||||
|
||||
// findByName caches a negative result permanently if it runs before RowManager registers -
|
||||
// waitFor + a raw lookup retries until it's actually there.
|
||||
export default function patchRowManager(): () => void {
|
||||
const patches: (() => void)[] = [];
|
||||
|
||||
const handle = waitFor(
|
||||
() => {
|
||||
const RowManager = rawFindByName<any>("RowManager");
|
||||
return RowManager?.prototype?.generate ? RowManager : undefined;
|
||||
},
|
||||
(RowManager) => {
|
||||
patches.push(before("generate", RowManager.prototype, ([row]: any[]) => {
|
||||
try {
|
||||
if (row.rowType === 1) {
|
||||
if (storage.separateMessages) row.isFirst = true;
|
||||
const parsed = parseTimestamp(row.message.timestamp);
|
||||
row.message.__customTimestamp = wrapTimestamp(parsed);
|
||||
} else if (row.rowType === "day") {
|
||||
const parsed = moment(row.text, "LL");
|
||||
if (storage.hideDateIfToday && parsed.isValid() && parsed.isSame(moment(), "day")) {
|
||||
row.text = "Today";
|
||||
} else {
|
||||
row.text = renderTimestamp(parsed);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Leave this row's timestamp untouched.
|
||||
}
|
||||
}));
|
||||
|
||||
patches.push(after("generate", RowManager.prototype, ([row]: any[], result: any) => {
|
||||
try {
|
||||
if (row.rowType !== 1) return;
|
||||
if (row.message?.__customTimestamp && result?.message?.timestamp) {
|
||||
result.message.timestamp = row.message.__customTimestamp;
|
||||
}
|
||||
} catch {
|
||||
// Leave the default timestamp.
|
||||
}
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
handle.cancel();
|
||||
patches.forEach((p) => p());
|
||||
};
|
||||
}
|
||||
71
plugins/better-timestamps/src/patches/timestamp.ts
Normal file
71
plugins/better-timestamps/src/patches/timestamp.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { moment } from "@vendetta/metro/common";
|
||||
import { before } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { waitFor } from "@shared/lib/waitFor";
|
||||
import { rawFind } from "@shared/lib/rawFind";
|
||||
import renderTimestamp from "../lib/renderTimestamp";
|
||||
|
||||
function wrapTimestamp(original: any): any {
|
||||
const getFormatted = (..._args: any[]) => renderTimestamp(original);
|
||||
if (typeof Proxy === "undefined") return getFormatted();
|
||||
return new Proxy(original, {
|
||||
get(target, prop) {
|
||||
if (prop === "format" || prop === "calendar" || prop === "fromNow" || prop === "toISOString" || prop === "toString" || prop === "toJSON") {
|
||||
return getFormatted;
|
||||
}
|
||||
const value = target[prop];
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseTimestamp(value: any): any {
|
||||
if (value && typeof value.format === "function") return value;
|
||||
return moment(value);
|
||||
}
|
||||
|
||||
function findTimestampModule(): any {
|
||||
return rawFind<any>((m) => {
|
||||
if (typeof m !== "object" || m == null) return false;
|
||||
return Object.values(m).some((v: any) => {
|
||||
if (typeof v === "string") return v.includes("MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL");
|
||||
if (typeof v === "function") {
|
||||
const s = v.toString();
|
||||
return s.includes("MESSAGE_EDITED_TIMESTAMP_A11Y_LABEL") || s.includes("MESSAGE_CREATED_TIMESTAMP_A11Y_LABEL");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default function patchTimestamp(): () => void {
|
||||
const patches: (() => void)[] = [];
|
||||
|
||||
const handle = waitFor(
|
||||
() => {
|
||||
const module = findTimestampModule();
|
||||
return module?.default ? module : undefined;
|
||||
},
|
||||
(module) => {
|
||||
patches.push(before("default", module, ([props]: any[]) => {
|
||||
try {
|
||||
if (props?.timestamp != null) {
|
||||
const parsed = parseTimestamp(props.timestamp);
|
||||
props.timestamp = wrapTimestamp(parsed);
|
||||
}
|
||||
if (props?.editedTimestamp != null) {
|
||||
const parsed = parseTimestamp(props.editedTimestamp);
|
||||
props.editedTimestamp = wrapTimestamp(parsed);
|
||||
}
|
||||
} catch {
|
||||
// Leave timestamps untouched.
|
||||
}
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
handle.cancel();
|
||||
patches.forEach((p) => p());
|
||||
};
|
||||
}
|
||||
65
plugins/better-timestamps/src/ui/Settings.tsx
Normal file
65
plugins/better-timestamps/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { React, ReactNative, moment } from "@vendetta/metro/common";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { TableRow, TableRowGroup, TableSwitchRow, TextInput } from "@shared/ui/table";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import { resolveSemanticColorSafe } from "@shared/lib/color";
|
||||
import renderTimestamp, { TimestampMode } from "../lib/renderTimestamp";
|
||||
|
||||
const { Text } = ReactNative;
|
||||
|
||||
const MODES: { label: string; key: TimestampMode }[] = [
|
||||
{ label: "Calendar", key: "calendar" },
|
||||
{ label: "Relative", key: "relative" },
|
||||
{ label: "ISO 8601", key: "iso" },
|
||||
{ label: "Custom", key: "custom" }
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
useProxy(storage);
|
||||
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<TableRowGroup title="Format">
|
||||
{MODES.map((mode) => {
|
||||
const selected = storage.selected === mode.key;
|
||||
let preview = "";
|
||||
try { preview = renderTimestamp(moment(), mode.key); } catch { preview = ""; }
|
||||
|
||||
return (
|
||||
<React.Fragment key={mode.key}>
|
||||
<TableRow
|
||||
label={mode.label}
|
||||
subLabel={preview}
|
||||
trailing={selected ? <Text style={{ fontSize: 16, color: resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1") }}>✓</Text> : undefined}
|
||||
onPress={() => { storage.selected = mode.key; }}
|
||||
/>
|
||||
{mode.key === "custom" && selected && (
|
||||
<TextInput
|
||||
label="Custom format (moment.js tokens)"
|
||||
placeholder="dddd, MMMM Do YYYY, h:mm:ss a"
|
||||
value={storage.customFormat}
|
||||
onChange={(v: string) => { storage.customFormat = v; }}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableRowGroup>
|
||||
<TableRowGroup title="Layout">
|
||||
<TableSwitchRow
|
||||
label="Hide date if today"
|
||||
subLabel="Show time only for today's messages and just 'Today' for today's divider. Calendar & Custom formats only."
|
||||
value={!!storage.hideDateIfToday}
|
||||
onValueChange={(v: boolean) => { storage.hideDateIfToday = v; }}
|
||||
/>
|
||||
<TableSwitchRow
|
||||
label="Always show name & avatar"
|
||||
subLabel="Shows the username, avatar, and timestamp on every message instead of grouping consecutive messages"
|
||||
value={!!storage.separateMessages}
|
||||
onValueChange={(v: boolean) => { storage.separateMessages = v; }}
|
||||
/>
|
||||
</TableRowGroup>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
9
plugins/copy-role-color/manifest.json
Normal file
9
plugins/copy-role-color/manifest.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "CopyRoleColor",
|
||||
"description": "Long-press a role pill (member list, role list) to copy its color as hex.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" },
|
||||
{ "name": "MSMA" }
|
||||
],
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
133
plugins/copy-role-color/src/index.ts
Normal file
133
plugins/copy-role-color/src/index.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { findByName, findByStoreName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { clipboard } from "@vendetta/metro/common";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
|
||||
// A gradient role's real colors live at role.colorStrings ({primaryColor, secondaryColor,
|
||||
// tertiaryColor}), not the flattened colorString - copying that exact JSON back into Discord's own
|
||||
// role color picker recreates the gradient. A solid-color role only has primaryColor set, so this
|
||||
// falls back to the plain hex for those, keeping the existing single-color behavior unchanged.
|
||||
function formatReadable(colorStrings: any): string {
|
||||
const lines = [`Primary: ${colorStrings.primaryColor}`, `Secondary: ${colorStrings.secondaryColor}`];
|
||||
if (colorStrings.tertiaryColor) lines.push(`Tertiary: ${colorStrings.tertiaryColor}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Clipboard only holds one value, so a gradient can't be copied both ways at once - instead, a
|
||||
// second long-press on the same role within this window swaps the JSON for the readable text.
|
||||
const GRADIENT_TOGGLE_WINDOW_MS = 3000;
|
||||
let lastGradientCopy: { key: string; time: number } | null = null;
|
||||
|
||||
function copyRoleColor(colorStrings: any, fallbackColor?: string | null) {
|
||||
const isGradient = !!(colorStrings?.primaryColor && colorStrings?.secondaryColor);
|
||||
if (!isGradient) {
|
||||
const value = colorStrings?.primaryColor ?? fallbackColor;
|
||||
if (!value) return;
|
||||
clipboard.setString(value);
|
||||
showToast("Copied role color to clipboard", getAssetIDByName("ic_message_copy"));
|
||||
lastGradientCopy = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const key = JSON.stringify(colorStrings);
|
||||
const now = Date.now();
|
||||
if (lastGradientCopy?.key === key && now - lastGradientCopy.time < GRADIENT_TOGGLE_WINDOW_MS) {
|
||||
clipboard.setString(formatReadable(colorStrings));
|
||||
showToast("Copied readable colors to clipboard", getAssetIDByName("ic_message_copy"));
|
||||
lastGradientCopy = null;
|
||||
} else {
|
||||
clipboard.setString(key);
|
||||
showToast("Copied gradient JSON - press again for readable text", getAssetIDByName("ic_message_copy"));
|
||||
lastGradientCopy = { key, time: now };
|
||||
}
|
||||
}
|
||||
|
||||
function patchRolePill(): () => void {
|
||||
// "RolePill" on current builds, "ThemedRolePill" on older ones.
|
||||
const RolePillComponent = findByName("RolePill", false) ?? findByName("ThemedRolePill", false);
|
||||
if (!RolePillComponent) return () => {};
|
||||
|
||||
const GuildRoleStore = findByStoreName("GuildRoleStore");
|
||||
|
||||
return after("default", RolePillComponent, (_args: any[], res: any) => {
|
||||
try {
|
||||
if (!res?.props?.onPress) return;
|
||||
|
||||
const verifiedIcon = findInReactTree(res, (m) => m?.props?.roleColor);
|
||||
const roleIcon = findInReactTree(
|
||||
res,
|
||||
(m) => m?.props?.style?.[0]?.borderRadius && typeof m?.props?.style?.[1]?.backgroundColor === "string" && m.props.style[1].backgroundColor.startsWith("#"),
|
||||
);
|
||||
const color = roleIcon?.props?.style?.[1]?.backgroundColor ?? verifiedIcon?.props?.roleColor;
|
||||
if (!color) return;
|
||||
|
||||
// The pill only gives us a flattened color from its rendered style - fetching the real
|
||||
// role record (if guildId/roleId are present on it) is what actually exposes the gradient.
|
||||
const guildId = res.props?.guildId;
|
||||
const roleId = res.props?.roleId ?? res.props?.role?.id;
|
||||
const role = guildId && roleId ? GuildRoleStore?.getRole?.(guildId, roleId) : null;
|
||||
|
||||
res.props.onLongPress = () => copyRoleColor(role?.colorStrings, color);
|
||||
} catch {
|
||||
// Leave the role pill without a long-press handler.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Covers role pills in a full profile's Roles section (UserProfileRolesCard -> RolesList ->
|
||||
// RoleItem, none of which are top-level exports, hence createElementIntercept).
|
||||
function patchProfileRoleItem(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
||||
registerTypeDetector(
|
||||
"copy-role-color-role-item",
|
||||
(type) => typeof type === "function" && type.name === "RoleItem",
|
||||
(RoleItem: any) => {
|
||||
const PatchedRoleItem = (props: any) => {
|
||||
const ret = RoleItem(props);
|
||||
|
||||
try {
|
||||
const colorStrings = props?.role?.colorStrings;
|
||||
const fallback = props?.role?.colorString;
|
||||
if ((colorStrings?.primaryColor || fallback) && ret && typeof ret === "object") {
|
||||
ret.props = {
|
||||
...ret.props,
|
||||
onLongPress: () => copyRoleColor(colorStrings, fallback)
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Leave the role item without a long-press handler.
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
registerIntercept(RoleItem, PatchedRoleItem);
|
||||
}
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
import { id } from "@vendetta/plugin";
|
||||
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
|
||||
|
||||
let unpatchAll: () => void = () => {};
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
if (enforceBlacklistOnLoad(id)) return;
|
||||
|
||||
unpatchAll = applyPatches("CopyRoleColor", logger, {
|
||||
"role pill long-press": patchRolePill,
|
||||
"profile role item long-press": patchProfileRoleItem
|
||||
});
|
||||
},
|
||||
onUnload: () => unpatchAll()
|
||||
};
|
||||
8
plugins/custom-user-tags/manifest.json
Normal file
8
plugins/custom-user-tags/manifest.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "Custom User Tags",
|
||||
"description": "Create your own tags (text, color, and icon) and assign them to specific users - long-press a name in the member list or profile popout to manage one, or add one by user ID in settings. Includes a color picker with hex/RGB support and a library of custom icons.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" }
|
||||
],
|
||||
"main": "src/index.ts"
|
||||
}
|
||||
30
plugins/custom-user-tags/src/index.ts
Normal file
30
plugins/custom-user-tags/src/index.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { storage, id } from "@vendetta/plugin";
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
|
||||
import patchChat from "./patches/chat";
|
||||
import patchName from "./patches/name";
|
||||
import patchDetails from "./patches/details";
|
||||
import patchProfile from "./patches/profile";
|
||||
import patchTag from "./patches/tag";
|
||||
import Settings from "./ui/Settings";
|
||||
|
||||
let unpatchAll: () => void = () => {};
|
||||
|
||||
export default {
|
||||
onLoad: () => {
|
||||
if (enforceBlacklistOnLoad(id)) return;
|
||||
|
||||
storage.tags ??= {};
|
||||
|
||||
unpatchAll = applyPatches("Custom User Tags", logger, {
|
||||
tag: patchTag,
|
||||
chat: patchChat,
|
||||
name: patchName,
|
||||
details: patchDetails,
|
||||
profile: patchProfile
|
||||
});
|
||||
},
|
||||
onUnload: () => unpatchAll(),
|
||||
settings: Settings
|
||||
};
|
||||
100
plugins/custom-user-tags/src/lib/icons.ts
Normal file
100
plugins/custom-user-tags/src/lib/icons.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
export interface IconDef {
|
||||
id: string;
|
||||
name: string;
|
||||
fallback: string;
|
||||
path?: string;
|
||||
viewBox?: string;
|
||||
svg?: string;
|
||||
}
|
||||
|
||||
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
|
||||
|
||||
export const MAX_CUSTOM_SVG_LENGTH = 20000;
|
||||
|
||||
export function isValidCustomSvg(markup: string | undefined): boolean {
|
||||
const trimmed = markup?.trim();
|
||||
if (!trimmed || trimmed.length > MAX_CUSTOM_SVG_LENGTH || !/^<svg/i.test(trimmed)) return false;
|
||||
try {
|
||||
svgModule?.parse?.(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ICONS: IconDef[] = [
|
||||
{ id: "none", name: "No icon", path: "", fallback: "" },
|
||||
{ id: "star", name: "Star", path: "M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z", fallback: "★" },
|
||||
{ id: "heart", name: "Heart", path: "M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z", fallback: "♥" },
|
||||
{ id: "crown", name: "Crown", path: "M5 16L3 5l5.5 5L12 4l3.5 6L21 5l-2 11H5zm14 3c0 .6-.4 1-1 1H6c-.6 0-1-.4-1-1v-1h14v1z", fallback: "♛" },
|
||||
{ id: "gem", name: "Gem", path: "M12 2l8 5-8 15-8-15 8-5z", fallback: "💎" },
|
||||
{ id: "shield", name: "Shield", path: "M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z", fallback: "🛡" },
|
||||
{ id: "bolt", name: "Bolt", path: "M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 11.07 6 13.5 6c.46 0 .85.33.96.79L15 10h3.5c.49 0 .64.32.45.66-.19.34-.05.08-.07.12C17.52 14.06 15.07 19 13 19l-1 2z", fallback: "⚡" },
|
||||
{ id: "flame", name: "Flame", path: "M13.5.67s.74 2.65.74 4.8c0 2.06-1.35 3.73-3.41 3.73-2.07 0-3.63-1.67-3.63-3.73 0-2.15.74-4.8.74-4.8C5.26 3.12 2 7.05 2 12c0 5.52 4.48 10 10 10s10-4.48 10-10c0-4.95-3.26-8.88-7.5-11.33z", fallback: "🔥" },
|
||||
{ id: "check", name: "Check", path: "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z", fallback: "✓" },
|
||||
{ id: "cross", name: "Cross", path: "M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z", fallback: "✕" },
|
||||
{ id: "bell", name: "Bell", path: "M18 16v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z", fallback: "🔔" },
|
||||
{ id: "pin", name: "Pin", path: "M16 12V4h1V2H7v2h1v8l-2 2v2h5.2v6h1.6v-6H18v-2l-2-2z", fallback: "📌" },
|
||||
{ id: "lock", name: "Lock", path: "M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z", fallback: "🔒" },
|
||||
{ id: "key", name: "Key", path: "M12.65 10a5.998 5.998 0 0 0-6.88-3.88c-2.29.46-4.15 2.29-4.63 4.58A6.006 6.006 0 0 0 7 18a5.99 5.99 0 0 0 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z", fallback: "🔑" },
|
||||
{ id: "eye", name: "Eye", path: "M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z", fallback: "👁" },
|
||||
{ id: "flag", name: "Flag", path: "M14.4 6L14 4H5v17h2v-7h5.6l.4 2h7V6z", fallback: "🚩" },
|
||||
{ id: "tag", name: "Tag", path: "M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z", fallback: "🏷" },
|
||||
{ id: "badge", name: "Badge", path: "M20.5 6c-2.61.7-5.67 1-8.5 1s-5.89-.3-8.5-1L3 8c1.86.5 4 .83 6 1v13h2v-6h2v6h2V9c2-.17 4.14-.5 6-1l-.5-2zM12 6c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z", fallback: "🎖" },
|
||||
{ id: "robot", name: "Robot", path: "M20 9V7c0-1.1-.9-2-2-2h-3.2c-.5-1.9-2.2-3.3-4.2-3.3S7.9 3.1 7.4 5H4.2C3 5 2 6 2 7.2v9.6C2 18 3 19 4.2 19h.2v1c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1h8v1c0 .6.4 1 1 1h1c.6 0 1-.4 1-1v-1h.2c1.2 0 2.2-1 2.2-2.2V9h-2zm-4 7H8v-2h8v2zm0-4H8V9h8v3z", fallback: "🤖" },
|
||||
{ id: "moon", name: "Moon", path: "M9 2c-1.05 0-2.05.16-3 .46 1.69 1.23 2.8 3.24 2.8 5.54 0 3.87-3.13 7-7 7-1.04 0-2.02-.23-2.9-.64C2.24 18.24 6.36 22 11.5 22c5.5 0 10-4.5 10-10S17 2 11.5 2c-.83 0-1.63.1-2.39.29C9.32 2.1 9.16 2 9 2z", fallback: "🌙" },
|
||||
{ id: "sun", name: "Sun", path: "M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79 1.42-1.41zM4 10.5H1v2h3v-2zm13-6.9l-1.41 1.41 1.79 1.79 1.42-1.41-1.8-1.79zM20 10.5v2h3v-2h-3zm-8-5c-3.87 0-7 3.13-7 7s3.13 7 7 7 7-3.13 7-7-3.13-7-7-7zm0 12c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm-4 6.91v3h2v-3h-2zm6 0h-2v3h2v-3z", fallback: "☀" },
|
||||
{ id: "cloud", name: "Cloud", path: "M19.35 10.04C18.67 6.59 15.64 4 12 4 9.11 4 6.6 5.64 5.35 8.04 2.34 8.36 0 10.91 0 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z", fallback: "☁" },
|
||||
{ id: "music", name: "Music", path: "M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z", fallback: "🎵" },
|
||||
{ id: "mic", name: "Mic", path: "M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zM17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z", fallback: "🎤" },
|
||||
{ id: "gamepad", name: "Gamepad", path: "M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10 7H8v3H6v-3H3v-2h3V8h2v3h3v2zm4.5 2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4-3c-.83 0-1.5-.67-1.5-1.5S18.67 9 19.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z", fallback: "🎮" },
|
||||
{ id: "diamond", name: "Diamond", path: "M19 3H5L2 9l10 12L22 9l-3-6zM7.43 9.14 9.18 5h2.14l1.75 4.14H7.43zm5.47 0h3.9l1.75-4.14h-2.14l-1.75 4.14zM12 17.5l-3.14-4.36h6.28L12 17.5z", fallback: "♦" },
|
||||
{ id: "spade", name: "Spade", path: "M12 2C9 7 4 9.91 4 14c0 2.76 1.79 5.02 4.28 5.83L7 23h10l-1.28-3.17C18.21 19.02 20 16.76 20 14c0-4.09-5-7-8-12z", fallback: "♠" },
|
||||
{ id: "anchor", name: "Anchor", path: "M19 13v6c0 1.1-.9 2-2 2H7c-1.1 0-2-.9-2-2v-6c0-1.1.9-2 2-2h2.2c.55-1.84 2.2-3.15 4.05-3.19.23-1.22 1.12-2.22 2.3-2.5V3h2v2.11c1.18.28 2.07 1.28 2.3 2.5 1.85.04 3.5 1.35 4.05 3.19H17c1.1 0 2 .9 2 2z", fallback: "⚓" },
|
||||
{ id: "feather", name: "Feather", path: "M20.41 3c-2.04 0-3.92.64-5.48 1.72-.94.66-1.72 1.47-2.32 2.4C11.92 6.41 10.56 6 9.12 6c-2.97 0-5.69 1.66-7.05 4.3l13.93 13.93C18.64 22.59 20.3 19.87 20.3 17c0-1.44-.41-2.8-1.12-3.95.93-.6 1.74-1.38 2.4-2.32C22.66 9.85 23.31 7.97 23.31 6c0-.33-.02-.65-.06-.97l-2.84-2.03z", fallback: "🪶" },
|
||||
{ id: "leaf", name: "Leaf", path: "M17 8C8 10 5.9 16.17 3.82 21.34l1.89.66.95-2.3c.48.17.98.3 1.34.3C19 20 22 3 22 3c-1 2-3 5-5 5z", fallback: "🍃" },
|
||||
{ id: "flower", name: "Flower", path: "M12 22c4.97 0 9-4.03 9-9-4.97 0-9-4.03-9-9-4.97 0-9 4.03-9 9 0 4.97 4.03 9 9 9zm0-16c3.87 0 7 3.13 7 7s-3.13 7-7 7-7-3.13-7-7 3.13-7 7-7z", fallback: "❀" },
|
||||
{ id: "snowflake", name: "Snowflake", path: "M22 11h-2.65l1.76-1.76-1.41-1.41L17.24 9.88V7h-2v2.88l-2.47-2.47-1.41 1.41L13.35 11H11V8.35l1.76-1.76-1.41-1.41L9.88 6.76V4h-2v2.76L5.41 4.18 4 5.59 5.76 7.35H3v2h2.65l-1.76 1.76 1.41 1.41L6.76 11.12V14h2v-2.88l2.47 2.47 1.41-1.41L10.65 11H13v2.65l-1.76 1.76 1.41 1.41 2.47-2.47V14h2v-2.88l2.47 2.47 1.41-1.41L19.35 13H22v-2z", fallback: "❄" },
|
||||
{ id: "zap", name: "Zap", path: "M11 21h-1l1-7H7.5c-.58 0-.57-.32-.38-.66.19-.34.05-.08.07-.12C8.48 10.94 11.07 6 13.5 6c.46 0 .85.33.96.79L15 10h3.5c.49 0 .64.32.45.66-.19.34-.05.08-.07.12C17.52 14.06 15.07 19 13 19l-1 2z", fallback: "⚡" },
|
||||
{ id: "planet", name: "Planet", path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z", fallback: "🪐" },
|
||||
{ id: "rocket", name: "Rocket", path: "M12 2.5s-4.5 2-6.5 6c-2 4-1 9-1 9s3.5-1 5.5-3v4c0 1.66 1.34 3 3 3s3-1.34 3-3v-4c2 2 5.5 3 5.5 3s1-5-1-9c-2-4-6.5-6-6.5-6zm0 3c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z", fallback: "🚀" },
|
||||
{ id: "ghost", name: "Ghost", path: "M12 2C7.58 2 4 5.58 4 10v11c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-4h10v4c0 .55.45 1 1 1h1c.55 0 1-.45 1-1V10c0-4.42-3.58-8-8-8zm0 3c1.1 0 2 .9 2 2s-.9 2-2 2-2-.9-2-2 .9-2 2-2z", fallback: "👻" },
|
||||
{ id: "skull", name: "Skull", path: "M12 2c-4.42 0-8 3.58-8 8 0 2.64 1.28 4.99 3.27 6.46L8 21h8l.73-4.54C18.72 14.99 20 12.64 20 10c0-4.42-3.58-8-8-8z", fallback: "💀" },
|
||||
{ id: "paw", name: "Paw", path: "M17.5 10.5c.83 0 1.5-.67 1.5-1.5S18.33 7.5 17.5 7.5 16 8.17 16 9s.67 1.5 1.5 1.5zm-11 0c.83 0 1.5-.67 1.5-1.5S7.33 7.5 6.5 7.5 5 8.17 5 9s.67 1.5 1.5 1.5zm5.5 4c-2.28 0-4.32 1.23-5.42 3.05.73 1.34 2.15 2.25 3.79 2.25 1.45 0 2.73-.75 3.48-1.89.37.58.83 1.09 1.37 1.5 1.02.76 2.27 1.18 3.56 1.18 1.46 0 2.8-.52 3.85-1.38C23.2 16.6 20.5 14.5 17.5 14.5c-.83 0-1.61.18-2.32.49-.73-.32-1.53-.49-2.38-.49z", fallback: "🐾" },
|
||||
{ id: "pizza", name: "Pizza", path: "M12 2C8.43 2 5.23 3.54 3.1 6.04 5.34 15.38 12 22 12 22s6.66-6.62 8.9-15.96C18.77 3.54 15.57 2 12 2zm0 15c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm0-5c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z", fallback: "🍕" },
|
||||
{ id: "coffee", name: "Coffee", path: "M18.5 3H6c-1.1 0-2 .9-2 2v5.71c0 3.83 2.95 7.04 6.78 7.29 3.96.26 7.22-3.06 7.22-7v-1h.5c1.93 0 3.5-1.57 3.5-3.5S20.43 3 18.5 3z", fallback: "☕" },
|
||||
{ id: "cake", name: "Cake", path: "M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm-7 9c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z", fallback: "🎂" },
|
||||
{ id: "gift", name: "Gift", path: "M20 6h-2.18c.11-.31.18-.65.18-1 0-1.66-1.34-3-3-3-1.05 0-1.96.63-2.4 1.5-.44-.87-1.35-1.5-2.4-1.5-1.66 0-3 1.34-3 3 0 .35.07.69.18 1H4c-1.11 0-1.99.89-1.99 2L2 19c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V8c0-1.11-.89-2-2-2zm-5-2c.55 0 1 .45 1 1s-.45 1-1 1-1-.45-1-1 .45-1 1-1zm-7 2h14v2H8V6zm0 4h14v8H8v-8z", fallback: "🎁" },
|
||||
{ id: "trophy", name: "Trophy", path: "M19 5h-2V3H7v2H5c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h2v2H7v2h10v-2h-2v-2h2c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2z", fallback: "🏆" },
|
||||
{ id: "medal", name: "Medal", path: "M20 2H4c-1.1 0-2 .9-2 2v4c0 1.1.9 2 2 2h2v10h2V10h8v10h2V10h2c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zM4 6h4v2H4V6zm16 0h-4v2h4V6z", fallback: "🏅" },
|
||||
{ id: "lightbulb", name: "Lightbulb", path: "M9 21c0 .55.45 1 1 1h4c.55 0 1-.45 1-1v-1H9v1zm3-19C8.14 2 5 5.14 5 9c0 2.38 1.19 4.47 3 5.74V17c0 .55.45 1 1 1h6c.55 0 1-.45 1-1v-2.26c1.81-1.27 3-3.36 3-5.74 0-3.86-3.14-7-7-7zm2.85 11.1l-.85.6V16h-4v-2.3l-.85-.6C7.8 12.16 7 10.63 7 9c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.63-.8 3.16-2.15 4.1z", fallback: "💡" },
|
||||
{ id: "battery", name: "Battery", path: "M15.67 4H14V2h-4v2H8.33C7.6 4 7 4.6 7 5.33v15.33C7 21.4 7.6 22 8.33 22h7.33c.74 0 1.34-.6 1.34-1.33V5.33C17 4.6 16.4 4 15.67 4z", fallback: "🔋" },
|
||||
{ id: "wifi", name: "Wifi", path: "M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3a4.237 4.237 0 0 0-6 0zm-4-4l2 2a7.074 7.074 0 0 1 10 0l2-2C15.14 9.14 8.87 9.14 5 13z", fallback: "📶" },
|
||||
{ id: "code", name: "Code", path: "M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z", fallback: "</>" },
|
||||
{ id: "terminal", name: "Terminal", path: "M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM7.5 15l4.5-3-4.5-3v6zM13 15h5v-2h-5v2z", fallback: "⌘" },
|
||||
{ id: "bug", name: "Bug", path: "M19 8h-1.81a5.98 5.98 0 0 0-1.19-2.56l1.5-1.5-1.41-1.41-1.5 1.5A5.96 5.96 0 0 0 13 3.06V1h-2v2.06A5.96 5.96 0 0 0 8.91 4.03l-1.5-1.5-1.41 1.41 1.5 1.5A5.98 5.98 0 0 0 6.81 8H5v2h1.09c-.05.33-.09.66-.09 1v1H5v2h1v1c0 .34.04.67.09 1H5v2h1.81c1.04 2.95 3.86 5 7.19 5s6.15-2.05 7.19-5H19v-2h-1.09c.05-.33.09-.66.09-1v-1h1v-2h-1v-1c0-.34-.04-.67-.09-1H19V8zm-4 9c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z", fallback: "🐛" },
|
||||
{ id: "search", name: "Search", path: "M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z", fallback: "🔍" },
|
||||
{ id: "home", name: "Home", path: "M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z", fallback: "⌂" },
|
||||
{ id: "mail", name: "Mail", path: "M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z", fallback: "✉" },
|
||||
{ id: "phone", name: "Phone", path: "M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z", fallback: "📞" },
|
||||
{ id: "pen", name: "Pen", path: "M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z", fallback: "✎" },
|
||||
{ id: "trash", name: "Trash", path: "M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z", fallback: "🗑" },
|
||||
{ id: "clock", name: "Clock", path: "M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v7l5.25 3.15.75-1.23-4.5-2.67z", fallback: "🕐" },
|
||||
{ id: "calendar", name: "Calendar", path: "M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-1.99.9-1.99 2L3 19c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM9 10H7v2h2v-2zm0 4H7v2h2v-2zm0-8H7v2h2V6zm4 0h-2v2h2V6zm0 8h-2v2h2v-2zm0-4h-2v2h2v-2z", fallback: "📅" },
|
||||
{ id: "globe", name: "Globe", path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z", fallback: "🌐" },
|
||||
{ id: "cog", name: "Cog", path: "M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.42h-3.84a.484.484 0 0 0-.48.42l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.488.488 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z", fallback: "⚙" },
|
||||
{ id: "megaphone", name: "Megaphone", path: "M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z", fallback: "📣" },
|
||||
{ id: "cart", name: "Cart", path: "M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2zM1 2v2h2l3.6 7.59-1.35 2.45C4.52 15.37 5.48 17 7 17h12v-2H7.42c-.14 0-.25-.11-.21-.26l.04-.15L8.1 13h7.45c.75 0 1.41-.41 1.75-1.03l3.58-6.49A1.003 1.003 0 0 0 20 4H5.21l-.94-2H1zm16 16c-1.1 0-1.99.9-1.99 2s.89 2 1.99 2 2-.9 2-2-.9-2-2-2z", fallback: "🛒" },
|
||||
{ id: "basket", name: "Basket", path: "M17.21 9l-4.38-6.56a.993.993 0 0 0-1.66 0L6.79 9H2v2h3.05l1.65 8.33A2.01 2.01 0 0 0 8.68 21h6.64c.97 0 1.81-.71 1.96-1.67L18.95 11H22V9h-4.79zM10 5.74 13.76 10H6.24L10 5.74z", fallback: "🧺" },
|
||||
{ id: "umbrella", name: "Umbrella", path: "M12 2c-5.33 4.55-8 8.48-8 11.8 0 4.98 3.8 8.2 8 8.2s8-3.22 8-8.2C20 10.48 17.33 6.55 12 2zm0 18c-2.21 0-4.39-1.79-4.39-4.38 0-2.22 1.4-4.38 4.39-4.38s4.39 2.16 4.39 4.38c0 2.59-2.18 4.38-4.39 4.38z", fallback: "☂" },
|
||||
{ id: "anchor2", name: "Anchor 2", path: "M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z", fallback: "◎" }
|
||||
];
|
||||
|
||||
export const ICONS_BY_ID: Record<string, IconDef> = Object.fromEntries(ICONS.map((i) => [i.id, i]));
|
||||
|
||||
export function getIcon(id: string | undefined): IconDef | undefined {
|
||||
if (!id || id === "none") return undefined;
|
||||
return ICONS_BY_ID[id];
|
||||
}
|
||||
37
plugins/custom-user-tags/src/lib/resolveTag.ts
Normal file
37
plugins/custom-user-tags/src/lib/resolveTag.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { chroma } from "@vendetta/metro/common";
|
||||
import { rawColors } from "@vendetta/ui";
|
||||
import { isValidHex } from "@shared/lib/color";
|
||||
import { getUserTag, UserTag } from "./tags";
|
||||
import { getIcon, isValidCustomSvg, IconDef } from "./icons";
|
||||
|
||||
export interface ResolvedTag {
|
||||
text: string;
|
||||
textColor: any;
|
||||
backgroundColor: string;
|
||||
icon?: IconDef;
|
||||
iconColor: any;
|
||||
}
|
||||
|
||||
function resolveIcon(tag: UserTag): IconDef | undefined {
|
||||
if (tag.customSvg && isValidCustomSvg(tag.customSvg)) {
|
||||
return { id: "custom", name: "Custom", fallback: tag.customSvgFallback?.trim() || "", svg: tag.customSvg.trim() };
|
||||
}
|
||||
return getIcon(tag.icon);
|
||||
}
|
||||
|
||||
export default function resolveTag(userId: string | undefined): ResolvedTag | undefined {
|
||||
const tag = getUserTag(userId);
|
||||
if ((!tag?.text && !tag?.icon && !tag?.customSvg) || !isValidHex(tag.color)) return undefined;
|
||||
|
||||
const backgroundColor = tag.color;
|
||||
const textColor = chroma(backgroundColor).get("lab.l") < 70 ? rawColors.WHITE_500 : rawColors.BLACK_500;
|
||||
const icon = resolveIcon(tag);
|
||||
|
||||
return {
|
||||
text: tag.iconOnly && icon ? "" : tag.text || "",
|
||||
textColor,
|
||||
backgroundColor,
|
||||
icon,
|
||||
iconColor: textColor
|
||||
};
|
||||
}
|
||||
28
plugins/custom-user-tags/src/lib/tags.ts
Normal file
28
plugins/custom-user-tags/src/lib/tags.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { storage } from "@vendetta/plugin";
|
||||
|
||||
export interface UserTag {
|
||||
text: string;
|
||||
color: string;
|
||||
icon?: string;
|
||||
customSvg?: string;
|
||||
customSvgFallback?: string;
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
export function allTags(): Record<string, UserTag> {
|
||||
storage.tags ??= {};
|
||||
return storage.tags;
|
||||
}
|
||||
|
||||
export function getUserTag(userId: string | undefined): UserTag | undefined {
|
||||
if (!userId) return undefined;
|
||||
return allTags()[userId];
|
||||
}
|
||||
|
||||
export function setUserTag(userId: string, tag: UserTag): void {
|
||||
allTags()[userId] = tag;
|
||||
}
|
||||
|
||||
export function removeUserTag(userId: string): void {
|
||||
delete allTags()[userId];
|
||||
}
|
||||
36
plugins/custom-user-tags/src/patches/chat.ts
Normal file
36
plugins/custom-user-tags/src/patches/chat.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
import { ReactNative, chroma } from "@vendetta/metro/common";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import resolveTag from "../lib/resolveTag";
|
||||
|
||||
const getTagProperties = findByName("getTagProperties", false);
|
||||
|
||||
// Chat message tags are rendered from plain data (not a patchable element tree), so this can only
|
||||
// ever produce a solid-color tag here - same platform limitation Staff Tags documents for the same
|
||||
// reason.
|
||||
export default () => {
|
||||
if (!getTagProperties) return () => {};
|
||||
|
||||
return after("default", getTagProperties, ([{ message }], ret) => {
|
||||
if (ret?.tagType || ret?.__revengeCustomTag) return;
|
||||
|
||||
const tag = resolveTag(message?.author?.id);
|
||||
if (!tag) return;
|
||||
|
||||
const tagText = tag.icon
|
||||
? tag.text
|
||||
? `${tag.icon.fallback} ${tag.text}`
|
||||
: tag.icon.fallback
|
||||
: tag.text;
|
||||
|
||||
return {
|
||||
...ret,
|
||||
tagText,
|
||||
tagTextColor: tag.textColor ? ReactNative.processColor(chroma(tag.textColor).hex()) : undefined,
|
||||
tagBackgroundColor: ReactNative.processColor(chroma(tag.backgroundColor).hex()),
|
||||
tagVerified: false,
|
||||
tagType: undefined,
|
||||
__revengeCustomTag: true
|
||||
};
|
||||
});
|
||||
};
|
||||
84
plugins/custom-user-tags/src/patches/details.tsx
Normal file
84
plugins/custom-user-tags/src/patches/details.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { findByProps, findByTypeNameAll } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import resolveTag from "../lib/resolveTag";
|
||||
import openTagEditor from "../ui/TagEditorAlert";
|
||||
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
|
||||
// Covers member list rows and the profile popout, plus the long-press hook for assigning a tag
|
||||
// (no confirmed way to hook Discord's own user context menu).
|
||||
const rowPatch = ([{ user }]: any[], res: any) => {
|
||||
try {
|
||||
if (user?.id && res?.props) {
|
||||
const existingLongPress = res.props.onLongPress;
|
||||
res.props.onLongPress = (...args: any[]) => {
|
||||
existingLongPress?.(...args);
|
||||
openTagEditor(user.id, user.globalName || user.username || user.id);
|
||||
};
|
||||
}
|
||||
|
||||
const label = res?.props?.label;
|
||||
const nameContainer = findInReactTree(
|
||||
label,
|
||||
(c) =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some(
|
||||
(ch: any) => typeof ch === "string" || typeof ch?.props?.children === "string"
|
||||
)
|
||||
);
|
||||
if (!nameContainer) return;
|
||||
|
||||
if (findInReactTree(nameContainer, (c) => c?.props?.__revengeCustomTag)) return;
|
||||
|
||||
const existingTag = findInReactTree(nameContainer, (c) => c?.type?.Types);
|
||||
if (existingTag && existingTag.props?.type !== 0) return;
|
||||
|
||||
const tag = resolveTag(user?.id);
|
||||
if (!tag) return;
|
||||
|
||||
if (existingTag) {
|
||||
Object.assign(existingTag.props, {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
icon: tag.icon,
|
||||
iconColor: tag.iconColor,
|
||||
verified: false,
|
||||
__revengeCustomTag: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(nameContainer.props.children)) {
|
||||
nameContainer.props.children = [nameContainer.props.children];
|
||||
}
|
||||
|
||||
if (TagModule) {
|
||||
nameContainer.props.children.push(
|
||||
<TagModule.default
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
icon={tag.icon}
|
||||
iconColor={tag.iconColor}
|
||||
verified={false}
|
||||
__revengeCustomTag={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Never let one bad lookup take down the whole member list.
|
||||
}
|
||||
};
|
||||
|
||||
export default () => {
|
||||
const patches: (() => void)[] = [];
|
||||
const rows = findByTypeNameAll("UserRow") ?? [];
|
||||
|
||||
rows.forEach((UserRow: any) => patches.push(after("type", UserRow, rowPatch)));
|
||||
|
||||
return () => patches.forEach((unpatch) => unpatch());
|
||||
};
|
||||
59
plugins/custom-user-tags/src/patches/name.tsx
Normal file
59
plugins/custom-user-tags/src/patches/name.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import resolveTag from "../lib/resolveTag";
|
||||
|
||||
const TagModule = findByProps("getBotLabel");
|
||||
|
||||
// findByName("DisplayName") doesn't survive minification - findByProps looks at the property key
|
||||
// instead.
|
||||
const DisplayNameModule = findByProps("DisplayName") as any;
|
||||
|
||||
export default () => {
|
||||
if (!DisplayNameModule?.DisplayName) return () => {};
|
||||
|
||||
return after("DisplayName", DisplayNameModule, ([{ user }]: any[], ret: any) => {
|
||||
try {
|
||||
if (findInReactTree(ret, (c) => c?.props?.__revengeCustomTag)) return;
|
||||
|
||||
const tagComponent = findInReactTree(ret, (c) => c?.type?.Types);
|
||||
if (tagComponent && tagComponent.props?.type !== 0) return;
|
||||
|
||||
const tag = resolveTag(user?.id);
|
||||
if (!tag) return;
|
||||
|
||||
if (tagComponent) {
|
||||
tagComponent.props = {
|
||||
type: 0,
|
||||
text: tag.text,
|
||||
textColor: tag.textColor,
|
||||
backgroundColor: tag.backgroundColor,
|
||||
icon: tag.icon,
|
||||
iconColor: tag.iconColor,
|
||||
verified: false,
|
||||
__revengeCustomTag: true
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const row = findInReactTree(ret, (c) => c?.props?.style?.flexDirection === "row");
|
||||
if (!Array.isArray(row?.props?.children) || !TagModule) return;
|
||||
|
||||
row.props.children.push(
|
||||
<TagModule.default
|
||||
style={{ marginLeft: 0 }}
|
||||
type={0}
|
||||
text={tag.text}
|
||||
textColor={tag.textColor}
|
||||
backgroundColor={tag.backgroundColor}
|
||||
icon={tag.icon}
|
||||
iconColor={tag.iconColor}
|
||||
verified={false}
|
||||
__revengeCustomTag={true}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
// Never let one bad lookup take down every name row on screen.
|
||||
}
|
||||
});
|
||||
};
|
||||
48
plugins/custom-user-tags/src/patches/profile.tsx
Normal file
48
plugins/custom-user-tags/src/patches/profile.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { findInReactTree } from "@vendetta/utils";
|
||||
import { registerTypeDetector, registerIntercept, patchCreateElement } from "@shared/lib/createElementIntercept";
|
||||
import resolveTag from "../lib/resolveTag";
|
||||
import CustomTag from "../ui/Tag";
|
||||
|
||||
// Same approach as Staff Tags' patches/profile.tsx: UserProfilePrimaryInfo builds the name row but
|
||||
// isn't a top-level export, so we grab it via createElementIntercept. Unlike Staff Tags, resolveTag
|
||||
// only needs a userId, no guild context, so we don't bother reading guildId here.
|
||||
export default function patchProfile(): () => void {
|
||||
const cleanups: (() => void)[] = [];
|
||||
patchCreateElement(cleanups);
|
||||
|
||||
registerTypeDetector(
|
||||
"custom-user-tags-profile-primary-info",
|
||||
(type) => typeof type === "function" && type.name === "UserProfilePrimaryInfo",
|
||||
(UserProfilePrimaryInfo: any) => {
|
||||
const PatchedUserProfilePrimaryInfo = (props: any) => {
|
||||
const ret = UserProfilePrimaryInfo(props);
|
||||
|
||||
try {
|
||||
const userId: string | undefined = props?.user?.id;
|
||||
if (!userId) return ret;
|
||||
|
||||
const tag = resolveTag(userId);
|
||||
if (!tag) return ret;
|
||||
|
||||
const row = findInReactTree(
|
||||
ret,
|
||||
(c: any) =>
|
||||
Array.isArray(c?.props?.children) &&
|
||||
c.props.children.some((ch: any) => ch?.type?.name === "UserTagAndPronouns")
|
||||
);
|
||||
if (!Array.isArray(row?.props?.children)) return ret;
|
||||
|
||||
row.props.children.push(<CustomTag tag={tag} />);
|
||||
} catch {
|
||||
// Skip - a broken profile tag beats a crashed profile screen.
|
||||
}
|
||||
|
||||
return ret;
|
||||
};
|
||||
|
||||
registerIntercept(UserProfilePrimaryInfo, PatchedUserProfilePrimaryInfo);
|
||||
}
|
||||
);
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
33
plugins/custom-user-tags/src/patches/tag.tsx
Normal file
33
plugins/custom-user-tags/src/patches/tag.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { findInReactTree } from "@vendetta/utils";
|
||||
import Icon from "../ui/Icon";
|
||||
|
||||
const Tag = findByProps("getBotLabel");
|
||||
const { View, Text } = ReactNative;
|
||||
|
||||
// Discord's Tag component ignores custom text/color props unless pushed into the already-rendered
|
||||
// tree directly.
|
||||
export default () => {
|
||||
if (!Tag) return () => {};
|
||||
|
||||
return after("default", Tag, ([{ text, textColor, backgroundColor, icon, iconColor }], ret) => {
|
||||
const label = findInReactTree(ret, (c) => typeof c?.props?.children === "string");
|
||||
if (!label) return;
|
||||
|
||||
if (text != null) label.props.children = text;
|
||||
if (textColor) label.props.style?.push?.({ color: textColor });
|
||||
if (backgroundColor) ret?.props?.style?.push?.({ backgroundColor });
|
||||
|
||||
if (icon?.path || icon?.svg) {
|
||||
const style = label.props.style ?? {};
|
||||
ret.props.children = (
|
||||
<View style={{ flexDirection: "row", alignItems: "center" }}>
|
||||
<Icon icon={icon} size={12} color={iconColor ?? textColor} style={{ marginRight: text ? 4 : 0 }} />
|
||||
<Text style={style}>{text != null ? text : label.props.children}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
264
plugins/custom-user-tags/src/ui/ColorInput.tsx
Normal file
264
plugins/custom-user-tags/src/ui/ColorInput.tsx
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { isValidHex, normalizeHex } from "@shared/lib/color";
|
||||
import { resolveSemanticColorSafe } from "@shared/lib/color";
|
||||
|
||||
const { View, Text, TextInput, TouchableOpacity } = ReactNative;
|
||||
|
||||
interface ColorInputProps {
|
||||
title: string;
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
"#F23F42", "#F0B232", "#FEE75C", "#23A55A",
|
||||
"#1ABC9C", "#5865F2", "#9B59B6", "#EB459E",
|
||||
"#99AAB5", "#FFFFFF",
|
||||
"#FF6B6B", "#FF9F43", "#FDCB6E", "#00B894",
|
||||
"#00CEC9", "#0984E3", "#6C5CE7", "#FD79A8",
|
||||
"#A29BFE", "#DFE6E9", "#636E72", "#2D3436",
|
||||
"#E17055", "#D63031", "#74B9FF", "#A8E6CF",
|
||||
"#FDFFAB", "#FFB7B2", "#FF8B94", "#C7CEEA",
|
||||
"#FF0000", "#00FF00", "#0000FF", "#FFFF00",
|
||||
"#00FFFF", "#FF00FF", "#000000", "#808080"
|
||||
];
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, Math.round(n)));
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
const full = normalizeHex(hex).slice(1);
|
||||
return [
|
||||
parseInt(full.slice(0, 2), 16),
|
||||
parseInt(full.slice(2, 4), 16),
|
||||
parseInt(full.slice(4, 6), 16)
|
||||
];
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return "#" + [r, g, b].map((n) => clamp(n, 0, 255).toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const d = max - min;
|
||||
let h = 0;
|
||||
if (d !== 0) {
|
||||
if (max === r) h = ((g - b) / d + 6) % 6;
|
||||
else if (max === g) h = (b - r) / d + 2;
|
||||
else h = (r - g) / d + 4;
|
||||
h *= 60;
|
||||
}
|
||||
return [h, max === 0 ? 0 : d / max, max];
|
||||
}
|
||||
|
||||
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
let [r, g, b] = [0, 0, 0];
|
||||
if (h < 60) [r, g, b] = [c, x, 0];
|
||||
else if (h < 120) [r, g, b] = [x, c, 0];
|
||||
else if (h < 180) [r, g, b] = [0, c, x];
|
||||
else if (h < 240) [r, g, b] = [0, x, c];
|
||||
else if (h < 300) [r, g, b] = [x, 0, c];
|
||||
else [r, g, b] = [c, 0, x];
|
||||
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
||||
}
|
||||
|
||||
function rainbow(count: number): string[] {
|
||||
return Array.from({ length: count }, (_, i) => rgbToHex(...hsvToRgb((i * 360) / count, 1, 1)));
|
||||
}
|
||||
|
||||
function shades(base: string, count: number): string[] {
|
||||
const [h, s, v] = rgbToHsv(...hexToRgb(base));
|
||||
return Array.from({ length: count }, (_, i) => rgbToHex(...hsvToRgb(h, s, 0.2 + (0.8 * i) / (count - 1))));
|
||||
}
|
||||
|
||||
function randomHex(): string {
|
||||
return rgbToHex(Math.floor(Math.random() * 256), Math.floor(Math.random() * 256), Math.floor(Math.random() * 256));
|
||||
}
|
||||
|
||||
export default function ColorInput({ title, value, placeholder, onChange }: ColorInputProps) {
|
||||
const normalized = value ? normalizeHex(value) : "";
|
||||
const valid = !value || isValidHex(value);
|
||||
const swatchColor = isValidHex(value) ? normalized : (isValidHex(placeholder) ? normalizeHex(placeholder) : "#5865F2");
|
||||
const [r, g, b] = hexToRgb(swatchColor);
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
|
||||
const updateHex = (text: string) => {
|
||||
const t = text.trim().toLowerCase();
|
||||
if (!t) {
|
||||
onChange("");
|
||||
return;
|
||||
}
|
||||
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(t)) {
|
||||
onChange(t.toUpperCase());
|
||||
} else {
|
||||
onChange(text);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRgb = (component: "r" | "g" | "b", raw: string) => {
|
||||
const num = parseInt(raw, 10);
|
||||
if (Number.isNaN(num)) return;
|
||||
const next = [r, g, b];
|
||||
if (component === "r") next[0] = num;
|
||||
else if (component === "g") next[1] = num;
|
||||
else next[2] = num;
|
||||
onChange(rgbToHex(...next as [number, number, number]));
|
||||
};
|
||||
|
||||
const hueColors = rainbow(12);
|
||||
const shadeColors = shades(swatchColor, 6);
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<View style={{ flexDirection: "row", alignItems: "center", marginBottom: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
backgroundColor: swatchColor,
|
||||
marginRight: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(128,128,128,0.35)"
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 15, fontWeight: "600", color: textColor }}>{title}</Text>
|
||||
</View>
|
||||
|
||||
<TextInput
|
||||
value={value ?? ""}
|
||||
placeholder={placeholder ?? "#5865F2"}
|
||||
onChangeText={updateHex}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
placeholderTextColor="rgba(128,128,128,0.6)"
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: valid ? "rgba(128,128,128,0.35)" : "#F23F42",
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 8,
|
||||
fontSize: 14,
|
||||
marginBottom: 6,
|
||||
color: textColor
|
||||
}}
|
||||
/>
|
||||
|
||||
{valid && (
|
||||
<View style={{ flexDirection: "row", justifyContent: "space-between", marginBottom: 8 }}>
|
||||
{(["r", "g", "b"] as const).map((c, i) => (
|
||||
<View key={c} style={{ flex: 1, marginHorizontal: c === "g" ? 8 : 0 }}>
|
||||
<Text style={{ fontSize: 11, color: textColor, opacity: 0.7, textTransform: "uppercase", marginBottom: 2 }}>{c}</Text>
|
||||
<TextInput
|
||||
value={String([r, g, b][i])}
|
||||
keyboardType="numeric"
|
||||
onChangeText={(v: string) => updateRgb(c, v)}
|
||||
style={{
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(128,128,128,0.35)",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 6,
|
||||
fontSize: 13,
|
||||
color: textColor
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{valid && (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", marginBottom: 4 }}>
|
||||
{shadeColors.map((color) => (
|
||||
<TouchableOpacity
|
||||
key={color}
|
||||
onPress={() => onChange(color.toUpperCase())}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 6,
|
||||
backgroundColor: color,
|
||||
marginRight: 6,
|
||||
marginBottom: 6,
|
||||
borderWidth: value?.toLowerCase() === color.toLowerCase() ? 2 : 1,
|
||||
borderColor: value?.toLowerCase() === color.toLowerCase() ? "#ffffff" : "rgba(128,128,128,0.35)"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{valid && (
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap", marginBottom: 4 }}>
|
||||
{hueColors.map((color) => (
|
||||
<TouchableOpacity
|
||||
key={color}
|
||||
onPress={() => onChange(color.toUpperCase())}
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: color,
|
||||
marginRight: 6,
|
||||
marginBottom: 6,
|
||||
borderWidth: value?.toLowerCase() === color.toLowerCase() ? 2 : 1,
|
||||
borderColor: value?.toLowerCase() === color.toLowerCase() ? "#ffffff" : "rgba(128,128,128,0.35)"
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
onPress={() => onChange(randomHex())}
|
||||
style={{
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 10,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "rgba(88,101,242,0.2)",
|
||||
alignItems: "center",
|
||||
marginBottom: 8
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#5865F2", fontWeight: "600" }}>Random color</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flexDirection: "row", flexWrap: "wrap" }}>
|
||||
{PRESETS.map((preset) => {
|
||||
const active = value?.toLowerCase() === preset.toLowerCase();
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={preset}
|
||||
onPress={() => onChange(preset)}
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 6,
|
||||
backgroundColor: preset,
|
||||
marginRight: 8,
|
||||
marginBottom: 8,
|
||||
borderWidth: active ? 2 : 1,
|
||||
borderColor: active ? "#ffffff" : "rgba(128,128,128,0.35)"
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{!valid && (
|
||||
<Text style={{ color: "#F23F42", marginTop: 2, fontSize: 12 }}>
|
||||
Invalid hex color, e.g. #5865F2
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
28
plugins/custom-user-tags/src/ui/Icon.tsx
Normal file
28
plugins/custom-user-tags/src/ui/Icon.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { React } from "@vendetta/metro/common";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { IconDef } from "../lib/icons";
|
||||
|
||||
const svgModule = findByProps("Svg") as Record<string, any> | undefined;
|
||||
const Svg = svgModule?.Svg ?? (() => null);
|
||||
const Path = svgModule?.Path ?? (() => null);
|
||||
const SvgXml = svgModule?.SvgXml ?? (() => null);
|
||||
|
||||
interface IconProps {
|
||||
icon: IconDef;
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export default function Icon({ icon, size = 12, color = "#fff", style }: IconProps) {
|
||||
// Custom SVGs keep their own authored colors - no fill override, unlike preset icons below.
|
||||
if (icon.svg) return <SvgXml xml={icon.svg} width={size} height={size} style={style} />;
|
||||
|
||||
if (!icon.path) return null;
|
||||
const viewBox = icon.viewBox ?? "0 0 24 24";
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox={viewBox} fill={color} style={style}>
|
||||
<Path d={icon.path} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
57
plugins/custom-user-tags/src/ui/IconPicker.tsx
Normal file
57
plugins/custom-user-tags/src/ui/IconPicker.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { resolveSemanticColorSafe } from "@shared/lib/color";
|
||||
import { ICONS } from "../lib/icons";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const { View, Text, TouchableOpacity, ScrollView } = ReactNative;
|
||||
|
||||
interface IconPickerProps {
|
||||
title?: string;
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function IconPicker({ title = "Icon", value, onChange, color = "#5865F2" }: IconPickerProps) {
|
||||
const textColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
const iconColor = resolveSemanticColorSafe(["TEXT_NORMAL", "TEXT_DEFAULT"], "#dbdee1");
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
||||
<Text style={{ fontSize: 15, fontWeight: "600", color: textColor, marginBottom: 8 }}>{title}</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} style={{ marginBottom: 8 }}>
|
||||
<View style={{ flexDirection: "row" }}>
|
||||
{ICONS.map((icon) => {
|
||||
const active = value === icon.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={icon.id}
|
||||
onPress={() => onChange(icon.id)}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 8,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
marginRight: 8,
|
||||
backgroundColor: active ? color : "rgba(128,128,128,0.15)",
|
||||
borderWidth: active ? 2 : 1,
|
||||
borderColor: active ? "#ffffff" : "rgba(128,128,128,0.35)"
|
||||
}}
|
||||
>
|
||||
{icon.path ? (
|
||||
<Icon icon={icon} size={20} color={active ? "#ffffff" : iconColor} />
|
||||
) : (
|
||||
<Text style={{ fontSize: 12, color: active ? "#ffffff" : iconColor }}>—</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
<Text style={{ fontSize: 13, color: textColor, opacity: 0.7 }}>
|
||||
{ICONS.find((i) => i.id === value)?.name ?? "No icon"}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
70
plugins/custom-user-tags/src/ui/Settings.tsx
Normal file
70
plugins/custom-user-tags/src/ui/Settings.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { useProxy } from "@vendetta/storage";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import { TableRowGroup, TextInput } from "@shared/ui/table";
|
||||
import SettingsScaffold from "@shared/ui/SettingsScaffold";
|
||||
import ListSection from "@shared/ui/ListSection";
|
||||
import PrimaryButton from "@shared/ui/PrimaryButton";
|
||||
import NoteBox from "@shared/ui/NoteBox";
|
||||
import { allTags } from "../lib/tags";
|
||||
import { getIcon } from "../lib/icons";
|
||||
import openTagEditor from "./TagEditorAlert";
|
||||
|
||||
const { View } = ReactNative;
|
||||
|
||||
// Opens the same rich editor used for long-press tagging (full SVG/icon-only support) instead of
|
||||
// keeping a second, simpler add-tag form here that would drift out of sync with it.
|
||||
function AddTagForm() {
|
||||
const [userId, setUserId] = React.useState("");
|
||||
|
||||
return (
|
||||
<View style={{ paddingHorizontal: 16, paddingBottom: 12 }}>
|
||||
<TextInput
|
||||
label="User ID"
|
||||
placeholder="long-press a user in Discord's own menu and Copy ID"
|
||||
value={userId}
|
||||
onChange={setUserId}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Edit tag"
|
||||
disabled={!userId.trim()}
|
||||
style={{ marginTop: 8 }}
|
||||
onPress={() => openTagEditor(userId.trim(), userId.trim())}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Settings() {
|
||||
useProxy(storage);
|
||||
const tags = allTags();
|
||||
useProxy(tags);
|
||||
const userIds = Object.keys(tags);
|
||||
|
||||
return (
|
||||
<SettingsScaffold>
|
||||
<NoteBox>
|
||||
Long-press a name in the member list or profile popout to tag someone directly, or add
|
||||
one here by user ID - turn on Developer Mode (Discord Settings → Advanced) to get a
|
||||
"Copy ID" option when you long-press a user elsewhere.
|
||||
</NoteBox>
|
||||
<TableRowGroup title="Add a tag">
|
||||
<AddTagForm />
|
||||
</TableRowGroup>
|
||||
<ListSection
|
||||
title="Tagged users"
|
||||
emptyText="No one's tagged yet."
|
||||
items={userIds.map((id) => {
|
||||
const icon = getIcon(tags[id].icon)?.fallback || tags[id].customSvgFallback;
|
||||
const label = (icon ? `${icon} ${tags[id].text}` : tags[id].text).trim() || id;
|
||||
return {
|
||||
key: id,
|
||||
label,
|
||||
subLabel: `${id} • Tap to edit`,
|
||||
onPress: () => openTagEditor(id, tags[id].text || id)
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</SettingsScaffold>
|
||||
);
|
||||
}
|
||||
29
plugins/custom-user-tags/src/ui/Tag.tsx
Normal file
29
plugins/custom-user-tags/src/ui/Tag.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { ResolvedTag } from "../lib/resolveTag";
|
||||
import Icon from "./Icon";
|
||||
|
||||
const { View, Text } = ReactNative;
|
||||
|
||||
const baseStyle = {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 2,
|
||||
marginLeft: 4,
|
||||
overflow: "hidden"
|
||||
};
|
||||
|
||||
interface TagProps {
|
||||
tag: ResolvedTag;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
export default function CustomTag({ tag, style }: TagProps) {
|
||||
return (
|
||||
<View style={[baseStyle, { backgroundColor: tag.backgroundColor }, style]}>
|
||||
{tag.icon && (tag.icon.path || tag.icon.svg) && <Icon icon={tag.icon} size={12} color={tag.iconColor} style={{ marginRight: tag.text ? 4 : 0 }} />}
|
||||
{!!tag.text && <Text style={{ color: tag.textColor, fontSize: 11, fontWeight: "700" }}>{tag.text}</Text>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
136
plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
Normal file
136
plugins/custom-user-tags/src/ui/TagEditorAlert.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { React, ReactNative } from "@vendetta/metro/common";
|
||||
import { findByProps } from "@vendetta/metro";
|
||||
import { TableSwitchRow, TextInput } from "@shared/ui/table";
|
||||
import { showToast } from "@vendetta/ui/toasts";
|
||||
import ColorInput from "./ColorInput";
|
||||
import IconPicker from "./IconPicker";
|
||||
import { getUserTag, setUserTag, removeUserTag } from "../lib/tags";
|
||||
import { isValidCustomSvg, MAX_CUSTOM_SVG_LENGTH } from "../lib/icons";
|
||||
|
||||
const { View } = ReactNative;
|
||||
|
||||
const ALERT_KEY = "custom-user-tags-editor";
|
||||
|
||||
// Looked up lazily (not at module scope) since this repo hasn't needed Discord's native alert system
|
||||
// before - safer to resolve it right when actually opening the editor than to risk an early miss at
|
||||
// plugin-load time getting permanently cached.
|
||||
function alertParts() {
|
||||
const alerts = findByProps("openAlert", "dismissAlert") as any;
|
||||
const modal = findByProps("AlertModal", "AlertActions") as any;
|
||||
return {
|
||||
openAlert: alerts?.openAlert,
|
||||
dismissAlert: alerts?.dismissAlert,
|
||||
AlertModal: modal?.AlertModal,
|
||||
AlertActions: modal?.AlertActions,
|
||||
AlertActionButton: modal?.AlertActionButton
|
||||
};
|
||||
}
|
||||
|
||||
function TagEditor({ userId, username }: { userId: string; username: string }) {
|
||||
const existing = getUserTag(userId);
|
||||
const [text, setText] = React.useState(existing?.text ?? "");
|
||||
const [color, setColor] = React.useState(existing?.color ?? "#5865F2");
|
||||
const [icon, setIcon] = React.useState(existing?.icon ?? "none");
|
||||
const [svg, setSvg] = React.useState(existing?.customSvg ?? "");
|
||||
const [svgFallback, setSvgFallback] = React.useState(existing?.customSvgFallback ?? "");
|
||||
const [iconOnly, setIconOnly] = React.useState(!!existing?.iconOnly);
|
||||
const { dismissAlert, AlertModal, AlertActions, AlertActionButton } = alertParts();
|
||||
|
||||
if (!AlertModal || !AlertActions || !AlertActionButton) return null;
|
||||
|
||||
const hasIcon = icon !== "none" || !!svg.trim();
|
||||
|
||||
const save = () => {
|
||||
if (svg.trim() && !isValidCustomSvg(svg)) {
|
||||
showToast("Invalid SVG markup", undefined);
|
||||
return;
|
||||
}
|
||||
if (!text.trim() && !hasIcon) {
|
||||
showToast("Enter some tag text or choose an icon first", undefined);
|
||||
return;
|
||||
}
|
||||
if (iconOnly && !hasIcon) {
|
||||
showToast("Select an icon before enabling icon-only", undefined);
|
||||
return;
|
||||
}
|
||||
setUserTag(userId, {
|
||||
text: text.trim(),
|
||||
color,
|
||||
icon: svg.trim() ? undefined : icon === "none" ? undefined : icon,
|
||||
customSvg: svg.trim() || undefined,
|
||||
customSvgFallback: svgFallback.trim() || undefined,
|
||||
iconOnly
|
||||
});
|
||||
showToast(`Tagged ${username}`, undefined);
|
||||
dismissAlert?.(ALERT_KEY);
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
removeUserTag(userId);
|
||||
showToast(`Removed ${username}'s tag`, undefined);
|
||||
dismissAlert?.(ALERT_KEY);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={`Tag ${username}`}
|
||||
content={
|
||||
<View>
|
||||
<TextInput label="Tag text" placeholder="e.g. FRIEND" value={text} onChange={setText} />
|
||||
<IconPicker
|
||||
title="Icon"
|
||||
value={icon}
|
||||
onChange={(v: string) => {
|
||||
setIcon(v);
|
||||
if (v !== "none") setSvg("");
|
||||
}}
|
||||
color={color}
|
||||
/>
|
||||
<TextInput
|
||||
label="Custom SVG"
|
||||
placeholder="<svg>...</svg>"
|
||||
value={svg}
|
||||
multiline
|
||||
maxLength={MAX_CUSTOM_SVG_LENGTH}
|
||||
onChange={(v: string) => {
|
||||
setSvg(v);
|
||||
if (v) setIcon("none");
|
||||
}}
|
||||
/>
|
||||
{!!svg && (
|
||||
<TextInput
|
||||
label="Fallback text for chat"
|
||||
placeholder="Icons can't render in chat, only member list & profile"
|
||||
value={svgFallback}
|
||||
onChange={setSvgFallback}
|
||||
/>
|
||||
)}
|
||||
<TableSwitchRow
|
||||
label="Icon only"
|
||||
subLabel="Hide the text label, show just the icon"
|
||||
value={iconOnly}
|
||||
disabled={!hasIcon}
|
||||
onValueChange={setIconOnly}
|
||||
/>
|
||||
<ColorInput title="Color" value={color} onChange={setColor} />
|
||||
</View>
|
||||
}
|
||||
actions={
|
||||
<AlertActions>
|
||||
<AlertActionButton text="Save" variant="primary" onPress={save} />
|
||||
{existing && <AlertActionButton text="Remove tag" variant="secondary" onPress={remove} />}
|
||||
<AlertActionButton text="Cancel" variant="secondary" />
|
||||
</AlertActions>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function openTagEditor(userId: string, username: string): void {
|
||||
const { openAlert } = alertParts();
|
||||
if (!openAlert) {
|
||||
showToast("Couldn't open the tag editor - alert system not found", undefined);
|
||||
return;
|
||||
}
|
||||
openAlert(ALERT_KEY, <TagEditor userId={userId} username={username} />);
|
||||
}
|
||||
116
plugins/fake-profile-themes-and-effects/LICENSE
Normal file
116
plugins/fake-profile-themes-and-effects/LICENSE
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
CC0 1.0 Universal
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||
depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||
a Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||
provided by applicable law or treaty (including future time extensions), (iii)
|
||||
in any current or future medium and for any number of copies, and (iv) for any
|
||||
purpose whatsoever, including without limitation commercial, advertising or
|
||||
promotional purposes (the "License"). The License shall be deemed effective as
|
||||
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||
License for any reason be judged legally invalid or ineffective under
|
||||
applicable law, such partial invalidity or ineffectiveness shall not
|
||||
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||
affirms that he or she will not (i) exercise any of his or her remaining
|
||||
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||
and causes of action with respect to the Work, in either case contrary to
|
||||
Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness
|
||||
for a particular purpose, non infringement, or the absence of latent or
|
||||
other defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without limitation
|
||||
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions
|
||||
or other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to this
|
||||
CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
<http://creativecommons.org/publicdomain/zero/1.0/>
|
||||
13
plugins/fake-profile-themes-and-effects/manifest.json
Normal file
13
plugins/fake-profile-themes-and-effects/manifest.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "FakeProfileThemesAndEffects",
|
||||
"description": "Profile theming and profile effects without Nitro, by hiding the color/effect data in your About Me using invisible zero-width characters.",
|
||||
"authors": [
|
||||
{ "name": "Rosie" },
|
||||
{ "name": "ryan" },
|
||||
{ "name": "シグマ siguma" }
|
||||
],
|
||||
"main": "src/index.ts",
|
||||
"vendetta": {
|
||||
"icon": "ic_profile_24px"
|
||||
}
|
||||
}
|
||||
60
plugins/fake-profile-themes-and-effects/src/index.ts
Normal file
60
plugins/fake-profile-themes-and-effects/src/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { logger } from "@vendetta";
|
||||
import { storage, id } from "@vendetta/plugin";
|
||||
import { applyPatches } from "@shared/lib/patcher";
|
||||
import { enforceBlacklistOnLoad } from "@shared/lib/blacklist";
|
||||
import { FluxDispatcher } from "@fpte/lib/flux";
|
||||
import { UserProfileStore, UserStore } from "@fpte/lib/stores";
|
||||
import {
|
||||
patchGetPurchase,
|
||||
patchGetUserProfile,
|
||||
patchUseProfileEffectSections,
|
||||
patchUseProfileTheme,
|
||||
patchUserProfileEditForm,
|
||||
patchNitroUpsellCard
|
||||
} from "@fpte/patches";
|
||||
import { Settings } from "@fpte/ui/pages";
|
||||
|
||||
/** Updates the profile theme and effect used by YouScreen and BottomTabBar. */
|
||||
function updateProfileThemeAndEffect() {
|
||||
const user = UserStore.getCurrentUser();
|
||||
if (!user) return;
|
||||
const user_profile = UserProfileStore.getUserProfile(user.id);
|
||||
if (!user_profile) return;
|
||||
FluxDispatcher.dispatch({
|
||||
type: "USER_PROFILE_FETCH_SUCCESS",
|
||||
user,
|
||||
user_profile,
|
||||
connected_accounts: user_profile.connectedAccounts
|
||||
});
|
||||
}
|
||||
|
||||
let unpatchAll: () => void = () => {};
|
||||
|
||||
export default {
|
||||
onLoad() {
|
||||
if (enforceBlacklistOnLoad(id)) return;
|
||||
|
||||
// The "real" picker chains Discord's own hooks onto this fiber outside React's normal
|
||||
// reconciliation, risking a "Rendered more/fewer hooks" crash - default to the safer,
|
||||
// self-contained fallback picker instead.
|
||||
storage.forceFallbackEffectPicker ??= true;
|
||||
|
||||
unpatchAll = applyPatches("FakeProfileThemesAndEffects", logger, {
|
||||
"purchase check": patchGetPurchase,
|
||||
"user profile data": patchGetUserProfile,
|
||||
"profile effect sections": () => {
|
||||
const unpatches = patchUseProfileEffectSections();
|
||||
return () => unpatches.forEach((u) => u());
|
||||
},
|
||||
"profile theme": patchUseProfileTheme,
|
||||
"profile edit form": patchUserProfileEditForm,
|
||||
"nitro upsell card": patchNitroUpsellCard
|
||||
});
|
||||
updateProfileThemeAndEffect();
|
||||
},
|
||||
onUnload() {
|
||||
unpatchAll();
|
||||
updateProfileThemeAndEffect();
|
||||
},
|
||||
settings: Settings
|
||||
};
|
||||
4
plugins/fake-profile-themes-and-effects/src/lib/flux.ts
Normal file
4
plugins/fake-profile-themes-and-effects/src/lib/flux.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import type { FluxDispatcher as $FluxDispatcher } from "@vencord/discord-types";
|
||||
import { FluxDispatcher as Dispatcher } from "@vendetta/metro/common";
|
||||
|
||||
export const FluxDispatcher = Dispatcher as $FluxDispatcher;
|
||||
138
plugins/fake-profile-themes-and-effects/src/lib/fpte.ts
Normal file
138
plugins/fake-profile-themes-and-effects/src/lib/fpte.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/* eslint-disable @stylistic/brace-style */
|
||||
|
||||
const DELIMITER_CODEPOINT = 0x200B; // zero-width space
|
||||
const DELIMITER = String.fromCodePoint(DELIMITER_CODEPOINT);
|
||||
const RADIX = 0x1000; // count of default-ignorable codepoints in the SSP plane
|
||||
const STARTING_CODEPOINT = 0xE0000; // first codepoint in the SSP plane
|
||||
const ENDING_CODEPOINT = STARTING_CODEPOINT + RADIX - 1;
|
||||
|
||||
/** Legacy format: `[#primary,#accent]`, each codepoint offset by +STARTING_CODEPOINT. */
|
||||
export function encodeColorsLegacy(primary: number, accent: number) {
|
||||
return String.fromCodePoint(...[...`[#${primary.toString(16)},#${accent.toString(16)}]`]
|
||||
.map(c => c.codePointAt(0)! + STARTING_CODEPOINT));
|
||||
}
|
||||
|
||||
/** @returns [primary, accent] colors, or -1 for either if not found. */
|
||||
export function decodeColorsLegacy(str: string): [primaryColor: number, accentColor: number] {
|
||||
const [primary, accent] = str.matchAll(/(?<=#)[\dA-Fa-f]{1,6}/g);
|
||||
return [primary ? parseInt(primary[0], 16) : -1, accent ? parseInt(accent[0], 16) : -1];
|
||||
}
|
||||
|
||||
/** Base-RADIX encoding of a 24-bit color, each codepoint offset by +STARTING_CODEPOINT. */
|
||||
export function encodeColor(color: number) {
|
||||
if (color === 0) return String.fromCodePoint(STARTING_CODEPOINT);
|
||||
let str = "";
|
||||
for (; color > 0; color = Math.trunc(color / RADIX))
|
||||
str = String.fromCodePoint(color % RADIX + STARTING_CODEPOINT) + str;
|
||||
return str;
|
||||
}
|
||||
|
||||
/** @returns the decoded 24-bit color; -1 if str is empty, -2 if over 0xFFFFFF. */
|
||||
export function decodeColor(str: string) {
|
||||
if (str === "") return -1;
|
||||
let color = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (color > 0xFFF_FFF) return -2;
|
||||
color += str.codePointAt(i)! * RADIX ** (str.length - 1 - i);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
/** Base-RADIX encoding of an effect ID, each codepoint offset by +STARTING_CODEPOINT. */
|
||||
export function encodeEffect(id: bigint) {
|
||||
if (id === 0n) return String.fromCodePoint(STARTING_CODEPOINT);
|
||||
let str = "";
|
||||
for (; id > 0n; id /= BigInt(RADIX))
|
||||
str = String.fromCodePoint(Number(id % BigInt(RADIX)) + STARTING_CODEPOINT) + str;
|
||||
return str;
|
||||
}
|
||||
|
||||
/** @returns the decoded effect ID; -1n if str is empty, -2n if over the max effect ID. */
|
||||
export function decodeEffect(str: string) {
|
||||
if (str === "") return -1n;
|
||||
let id = 0n;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
if (id >= 10_000_000_000_000_000_000n) return -2n;
|
||||
id += BigInt(str.codePointAt(i)!) * BigInt(RADIX) ** BigInt(str.length - 1 - i);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an FPTE string from primary/accent colors (negative = unset) and an effect ID (empty =
|
||||
* unset). In legacy mode, colors are legacy-encoded and both are always included (accent falls
|
||||
* back to primary, or vice versa); otherwise a shared/unset color is omitted to save space.
|
||||
*/
|
||||
export function buildFPTE(primary: number, accent: number, effect: string, legacy: boolean) {
|
||||
let fpte = "";
|
||||
|
||||
if (legacy) {
|
||||
if (primary >= 0) {
|
||||
fpte = accent >= 0 ? encodeColorsLegacy(primary, accent) : encodeColorsLegacy(primary, primary);
|
||||
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
return fpte;
|
||||
}
|
||||
if (accent >= 0) {
|
||||
fpte = encodeColorsLegacy(accent, accent);
|
||||
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
return fpte;
|
||||
}
|
||||
}
|
||||
else if (primary >= 0) {
|
||||
fpte = encodeColor(primary);
|
||||
if (accent >= 0 && primary !== accent) {
|
||||
fpte += DELIMITER + encodeColor(accent);
|
||||
if (effect) fpte += DELIMITER + encodeEffect(BigInt(effect));
|
||||
return fpte;
|
||||
}
|
||||
}
|
||||
else if (accent >= 0) fpte = encodeColor(accent);
|
||||
|
||||
// Only one (or no) color was written above, so the effect needs a double delimiter to mark
|
||||
// the skipped slot.
|
||||
if (effect)
|
||||
fpte += DELIMITER + DELIMITER + encodeEffect(BigInt(effect));
|
||||
|
||||
return fpte;
|
||||
}
|
||||
|
||||
/** Extracts the delimiter-separated values of the first FPTE substring in a string. */
|
||||
export function extractFPTE(str: string) {
|
||||
const fpte: [maybePrimaryOrLegacy: string, maybeAccentOrEffect: string, maybeEffect: string] = ["", "", ""];
|
||||
let i = 0;
|
||||
|
||||
for (const char of str) {
|
||||
const cp = char.codePointAt(0)!;
|
||||
|
||||
if (cp === DELIMITER_CODEPOINT) {
|
||||
if (i >= 2) break;
|
||||
i++;
|
||||
}
|
||||
else if (cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT)
|
||||
fpte[i]! += String.fromCodePoint(cp - STARTING_CODEPOINT);
|
||||
else if (i > 0 || fpte[0]) break;
|
||||
}
|
||||
|
||||
return fpte;
|
||||
}
|
||||
|
||||
/** Detects an FPTE substring by the presence of FPTE codepoints or the delimiter. */
|
||||
export function hasFPTE(str: string): boolean {
|
||||
for (const char of str) {
|
||||
const cp = char.codePointAt(0)!;
|
||||
if ((cp >= STARTING_CODEPOINT && cp <= ENDING_CODEPOINT) || cp === DELIMITER_CODEPOINT) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripFPTE(str: string) {
|
||||
return [...str]
|
||||
.filter(ch => {
|
||||
const cp = ch.codePointAt(0)!;
|
||||
return (cp < STARTING_CODEPOINT || cp > ENDING_CODEPOINT) && cp !== DELIMITER_CODEPOINT;
|
||||
})
|
||||
.join("")
|
||||
.trim();
|
||||
}
|
||||
12
plugins/fake-profile-themes-and-effects/src/lib/haptics.ts
Normal file
12
plugins/fake-profile-themes-and-effects/src/lib/haptics.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
|
||||
// Must use `as` or else `undefined` is lost due to a TS bug
|
||||
const hapticsModule = findByProps("triggerHapticFeedback") as Record<string, any> | undefined;
|
||||
|
||||
export type FeebackKey = "IMPACT_LIGHT" | "IMPACT_MEDIUM" | "IMPACT_HEAVY" | "NOTIFICATION_ERROR" | "DRAG_AND_DROP_START" | "DRAG_AND_DROP_END" | "DRAG_AND_DROP_MOVE";
|
||||
|
||||
export const HapticFeebackTypes: Record<FeebackKey, number>
|
||||
= hapticsModule?.HapticFeedbackTypes ?? {};
|
||||
|
||||
export const triggerHapticFeedback: (feedbackType?: number | undefined) => void
|
||||
= hapticsModule?.triggerHapticFeedback ?? (() => undefined);
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import type React from "react";
|
||||
|
||||
import { isIterable, isNonNullObject, type OmitCallSignature } from "@fpte/lib/utils";
|
||||
|
||||
export namespace RN {
|
||||
export type Fragment<P extends object = object> = Element<P, symbol>;
|
||||
|
||||
export interface ConsumerProps<T = unknown> {
|
||||
children: (value: T) => Node;
|
||||
}
|
||||
|
||||
export interface ProviderProps<T = unknown> {
|
||||
value: T;
|
||||
children?: Node;
|
||||
}
|
||||
|
||||
export type ConsumerType<T = unknown> = React.Context<T> & OmitCallSignature<React.NamedExoticComponent<ConsumerProps<T>>>;
|
||||
|
||||
export interface ProviderType<T = unknown> extends OmitCallSignature<React.ProviderExoticComponent<ProviderProps<T>>> {
|
||||
_context: React.Context<T>;
|
||||
}
|
||||
|
||||
export interface ForwardRefType<T = unknown, P extends object = object> extends OmitCallSignature<React.ForwardRefExoticComponent<P>> {
|
||||
render: React.ForwardRefRenderFunction<T, P>;
|
||||
}
|
||||
|
||||
export interface MemoType<T extends ElementType<any> = ElementType> extends OmitCallSignature<React.NamedExoticComponent<ComponentPropsWithRef<T>>> {
|
||||
readonly type: T;
|
||||
}
|
||||
|
||||
export interface Component<P, S> extends Omit<React.Component<P, S>, "render"> {
|
||||
render: () => Node;
|
||||
}
|
||||
|
||||
export interface ComponentClass<P extends object = object, S = React.ComponentState> extends OmitCallSignature<React.ComponentClass<P, S>> {
|
||||
new (props: P, context?: any): Component<P, S>;
|
||||
}
|
||||
|
||||
export interface FunctionComponent<P extends object = object> extends OmitCallSignature<React.FunctionComponent<P>> {
|
||||
(props: P, context?: any): Node;
|
||||
}
|
||||
|
||||
export type ComponentType<P extends object = object> = ComponentClass<P> | FunctionComponent<P>;
|
||||
|
||||
export type ElementType<P extends object = object> = ComponentType<P> | symbol | ConsumerType | ProviderType | ForwardRefType<unknown, P> | MemoType<ElementType<P>>;
|
||||
|
||||
export interface Element<P extends object = object, T extends ElementType = ElementType> {
|
||||
type: T;
|
||||
props: P;
|
||||
key: React.Key | null;
|
||||
}
|
||||
|
||||
export type Node = Iterable<Node> | Element | string | number | boolean | null | undefined;
|
||||
|
||||
export type PropsWithChildren<P extends object = object> = P & { children: Node; };
|
||||
|
||||
export type ComponentProps<T extends ElementType> = T extends ElementType<infer P> ? P : object;
|
||||
|
||||
export type ComponentPropsWithRef<T extends ElementType> =
|
||||
T extends React.ComponentClass<infer P>
|
||||
? React.PropsWithoutRef<P> & React.RefAttributes<InstanceType<T>>
|
||||
: React.PropsWithRef<ComponentProps<T>>;
|
||||
}
|
||||
|
||||
export function isElement(arg: RN.Node): arg is RN.Element {
|
||||
return isNonNullObject(arg) && "type" in arg;
|
||||
}
|
||||
|
||||
export function isElementWithChildren<T extends RN.Element>(arg: T | RN.Element<RN.PropsWithChildren>): arg is RN.Element<RN.PropsWithChildren> {
|
||||
return isNonNullObject(arg.props) && "children" in arg.props;
|
||||
}
|
||||
|
||||
export function isProviderType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ProviderType {
|
||||
return "_context" in arg;
|
||||
}
|
||||
|
||||
export function isForwardRefType(arg: Exclude<RN.ElementType, symbol>): arg is RN.ForwardRefType {
|
||||
return "render" in arg;
|
||||
}
|
||||
|
||||
export function isMemoType(arg: Exclude<RN.ElementType, symbol>): arg is RN.MemoType {
|
||||
return "type" in arg;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
|
||||
/** @see {@link https://github.com/facebook/react-native/blob/d724007364c4315e2cabace0fc6eae6e6212431d/packages/react-native/Libraries/Renderer/implementations/ReactNativeRenderer-dev.js#L3130-L3212} */
|
||||
export function getComponentNameFromType(type: RN.ElementType) {
|
||||
if (typeof type === "symbol")
|
||||
return Symbol.keyFor(type) || null;
|
||||
if (typeof type === "function")
|
||||
return type.displayName || type.name || null;
|
||||
// Host components (View, Text, ...) are plain strings, not objects - isProviderType and the
|
||||
// checks after it all use the `in` operator, which throws on a string primitive rather than
|
||||
// returning false. This is the exact same bug class as isElementWithChildren's props: null
|
||||
// crash, just a second, separate instance in this same file.
|
||||
if (typeof type !== "object" || type === null)
|
||||
return null;
|
||||
if (isProviderType(type))
|
||||
return type._context.displayName || null;
|
||||
if (type.displayName)
|
||||
return type.displayName;
|
||||
if (isForwardRefType(type))
|
||||
return type.render.displayName || type.render.name || null;
|
||||
if (isMemoType(type))
|
||||
return getComponentNameFromType(type.type);
|
||||
return null;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/prefer-nullish-coalescing */
|
||||
|
||||
export function findElementInTree<T extends RN.Element>(
|
||||
tree: RN.Node,
|
||||
filter: (element: RN.Element) => element is T,
|
||||
maxDepth?: number | undefined
|
||||
): T | null;
|
||||
export function findElementInTree(
|
||||
tree: RN.Node,
|
||||
filter: (element: RN.Element) => unknown,
|
||||
maxDepth?: number | undefined
|
||||
): RN.Element | null;
|
||||
export function findElementInTree(
|
||||
tree: RN.Node,
|
||||
filter: (element: RN.Element) => unknown,
|
||||
maxDepth = 200
|
||||
): RN.Element | null {
|
||||
if (isNonNullObject(tree)) {
|
||||
if (isIterable(tree)) {
|
||||
if (maxDepth > 0) {
|
||||
for (const node of tree) {
|
||||
const foundElement = findElementInTree(node, filter, maxDepth - 1);
|
||||
if (foundElement) return foundElement;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (filter(tree)) return tree;
|
||||
if (isElementWithChildren(tree))
|
||||
return findElementInTree(tree.props.children, filter, maxDepth - 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findParentInTree<T extends RN.Node>(
|
||||
tree: RN.Node,
|
||||
filter: (children: RN.Node) => children is T,
|
||||
maxDepth?: number | undefined
|
||||
): RN.Element<{ children: T; }> | null;
|
||||
export function findParentInTree(
|
||||
tree: RN.Node,
|
||||
filter: (children: RN.Node) => unknown,
|
||||
maxDepth?: number | undefined
|
||||
): RN.Element<RN.PropsWithChildren> | null;
|
||||
export function findParentInTree(
|
||||
tree: RN.Node,
|
||||
filter: (children: RN.Node) => unknown,
|
||||
maxDepth = 200
|
||||
): RN.Element<RN.PropsWithChildren> | null {
|
||||
if (isNonNullObject(tree)) {
|
||||
if (isIterable(tree)) {
|
||||
if (maxDepth > 0) {
|
||||
for (const node of tree) {
|
||||
const foundParent = findParentInTree(node, filter, maxDepth - 1);
|
||||
if (foundParent) return foundParent;
|
||||
}
|
||||
}
|
||||
} else if (isElementWithChildren(tree)) {
|
||||
if (filter(tree.props.children)) return tree;
|
||||
return findParentInTree(tree.props.children, filter, maxDepth - 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
25
plugins/fake-profile-themes-and-effects/src/lib/records.ts
Normal file
25
plugins/fake-profile-themes-and-effects/src/lib/records.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import type { ImmutableRecord } from "@vencord/discord-types";
|
||||
import { findByName } from "@vendetta/metro";
|
||||
|
||||
export const ProfileEffectRecord: typeof $ProfileEffectRecord = findByName("ProfileEffectRecord");
|
||||
|
||||
export type ProfileEffectRecordOwnProperties = Pick<$ProfileEffectRecord, "id" | "skuId" | "type">;
|
||||
|
||||
export type ProfileEffectProperties = Omit<ProfileEffectRecordOwnProperties, "type">;
|
||||
|
||||
declare class $ProfileEffectRecord extends ImmutableRecord<ProfileEffectRecordOwnProperties> {
|
||||
constructor(profileEffectProperties: ProfileEffectProperties);
|
||||
|
||||
static fromServer(profileEffectFromServer: { id: string; sku_id: string; }): $ProfileEffectRecord;
|
||||
|
||||
id: string;
|
||||
skuId: string;
|
||||
type: CollectiblesItemType.PROFILE_EFFECT;
|
||||
}
|
||||
|
||||
export const enum CollectiblesItemType {
|
||||
AVATAR_DECORATION = 0,
|
||||
PROFILE_EFFECT = 1,
|
||||
NONE = 100,
|
||||
BUNDLE = 1_000,
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { ExtractAction, FluxAction, FluxStore } from "@vencord/discord-types";
|
||||
import { findByStoreName } from "@vendetta/metro";
|
||||
import type tinycolor from "tinycolor2";
|
||||
|
||||
export const CollectiblesPurchaseStore: $CollectiblesPurchaseStore
|
||||
= findByStoreName("CollectiblesPurchaseStore");
|
||||
|
||||
export type CollectiblesPurchaseStoreAction = ExtractAction<FluxAction, "COLLECTIBLES_CLAIM" | "COLLECTIBLES_CLAIM_FAILURE" | "COLLECTIBLES_CLAIM_SUCCESS" | "COLLECTIBLES_PURCHASES_FETCH" | "COLLECTIBLES_PURCHASES_FETCH_FAILURE" | "COLLECTIBLES_PURCHASES_FETCH_SUCCESS" | "LOGOUT">;
|
||||
|
||||
declare class $CollectiblesPurchaseStore extends FluxStore<CollectiblesPurchaseStoreAction> {
|
||||
static displayName: "CollectiblesPurchaseStore";
|
||||
|
||||
get claimError(): Error | undefined;
|
||||
get fetchError(): Error | undefined;
|
||||
getPurchase(skuId: string | null | undefined): Purchase | undefined;
|
||||
get isClaiming(): boolean;
|
||||
get isFetching(): boolean;
|
||||
get purchases(): Map<string, Purchase>;
|
||||
}
|
||||
|
||||
export interface Purchase {
|
||||
banner: string;
|
||||
categorySkuId: string;
|
||||
items: {
|
||||
asset: string;
|
||||
id: string;
|
||||
label: string;
|
||||
skuId: string;
|
||||
type: number;
|
||||
}[];
|
||||
name: string;
|
||||
premiumType: number | null;
|
||||
prices: {
|
||||
countryPrices: {
|
||||
countryCode: string;
|
||||
prices: {
|
||||
amount: number;
|
||||
currency: string;
|
||||
exponent: number;
|
||||
}[];
|
||||
};
|
||||
}[];
|
||||
purchaseType: number;
|
||||
purchasedAt: Date;
|
||||
skuId: string;
|
||||
storeListingId: string;
|
||||
styles: {
|
||||
backgroundColors: [typeof tinycolor, typeof tinycolor];
|
||||
buttonColors: [typeof tinycolor, typeof tinycolor];
|
||||
confettiColors: (typeof tinycolor)[];
|
||||
};
|
||||
summary: string;
|
||||
type: number;
|
||||
unpublishedAt: Date | null;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import type { ExtractAction, FluxAction, FluxStore } from "@vencord/discord-types";
|
||||
import { findByProps, findByStoreName } from "@vendetta/metro";
|
||||
import { lazy } from "@shared/lib/lazy";
|
||||
|
||||
import type { CollectiblesItemType } from "@fpte/lib/records";
|
||||
|
||||
// There's no dedicated ProfileEffectStore anymore - effects are served from the same shop/
|
||||
// collectibles catalog used for avatar decorations and nameplates. getProfileEffectsFromCategories
|
||||
// returns ProfileEffectConfig objects directly, not the old {id, skuId, config} shape this file's
|
||||
// ProfileEffect type expects, so the adapter below rebuilds that shape.
|
||||
const getShopEffectsUtil = lazy(() => findByProps("getProfileEffectsFromCategories") as
|
||||
{ getProfileEffectsFromCategories(categories: unknown): ProfileEffectConfig[] } | undefined);
|
||||
const getCategoryStore = lazy(() => findByStoreName("CollectiblesCategoryStore") as
|
||||
{ categories: unknown } | undefined);
|
||||
|
||||
export const getProfileEffectStore = lazy((): { profileEffects: ProfileEffect[] } | undefined => {
|
||||
const util = getShopEffectsUtil();
|
||||
const categoryStore = getCategoryStore();
|
||||
if (!util || !categoryStore) return undefined;
|
||||
|
||||
const configs = util.getProfileEffectsFromCategories(categoryStore.categories) ?? [];
|
||||
return {
|
||||
profileEffects: configs.map((config): ProfileEffect => ({ id: config.skuId, skuId: config.skuId, config })),
|
||||
};
|
||||
});
|
||||
|
||||
export type ProfileEffectStoreAction = ExtractAction<FluxAction, "LOGOUT" | "PROFILE_EFFECTS_SET_TRY_IT_OUT" | "USER_PROFILE_EFFECTS_FETCH" | "USER_PROFILE_EFFECTS_FETCH_FAILURE" | "USER_PROFILE_EFFECTS_FETCH_SUCCESS">;
|
||||
|
||||
declare class $ProfileEffectStore extends FluxStore<ProfileEffectStoreAction> {
|
||||
static displayName: "ProfileEffectStore";
|
||||
|
||||
canFetch(): boolean;
|
||||
get fetchError(): Error | undefined;
|
||||
getProfileEffectById(effectId: string): ProfileEffect | undefined;
|
||||
hasFetched(): boolean;
|
||||
get isFetching(): boolean;
|
||||
get profileEffects(): ProfileEffect[];
|
||||
get tryItOutId(): string | null;
|
||||
}
|
||||
|
||||
export interface ProfileEffect {
|
||||
config: ProfileEffectConfig;
|
||||
id: string;
|
||||
skuId: string;
|
||||
}
|
||||
|
||||
export interface ProfileEffectConfig {
|
||||
accessibilityLabel: string;
|
||||
animationType: number;
|
||||
description: string;
|
||||
effects: {
|
||||
duartion: number;
|
||||
height: number;
|
||||
loop: boolean;
|
||||
loopDelay: number;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
src: string;
|
||||
start: number;
|
||||
width: number;
|
||||
zIndex: number;
|
||||
}[];
|
||||
id: string;
|
||||
reducedMotionSrc: string;
|
||||
sku_id: string;
|
||||
staticFrameSrc?: string;
|
||||
thumbnailPreviewSrc: string;
|
||||
title: string;
|
||||
type: CollectiblesItemType.PROFILE_EFFECT;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import type {
|
||||
UserProfileStore as $UserProfileStore,
|
||||
UserStore as $UserStore
|
||||
} from "@vencord/discord-types";
|
||||
import { findByStoreName } from "@vendetta/metro";
|
||||
|
||||
export * from "./CollectiblesPurchaseStore";
|
||||
export * from "./ProfileEffectStore";
|
||||
|
||||
export const UserProfileStore: $UserProfileStore = findByStoreName("UserProfileStore");
|
||||
|
||||
export const UserStore: $UserStore = findByStoreName("UserStore");
|
||||
17
plugins/fake-profile-themes-and-effects/src/lib/utils.ts
Normal file
17
plugins/fake-profile-themes-and-effects/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
declare const emptyObjectSymbol: unique symbol;
|
||||
|
||||
export interface EmptyObject {
|
||||
[emptyObjectSymbol]?: never;
|
||||
}
|
||||
|
||||
export type OmitCallSignature<T> = Pick<T, keyof T>;
|
||||
|
||||
export type StringDict<V extends string> = { [K in V as Uppercase<K>]: V };
|
||||
|
||||
export function isIterable<T extends object, V = any>(arg: T | Iterable<V>): arg is Iterable<V> {
|
||||
return typeof (arg as Iterable<V>)[Symbol.iterator] === "function";
|
||||
}
|
||||
|
||||
export function isNonNullObject<T>(arg: T | T & object): arg is T & object {
|
||||
return arg !== null && typeof arg === "object";
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export { patchGetPurchase } from "./patchGetPurchase";
|
||||
export { patchGetUserProfile } from "./patchGetUserProfile";
|
||||
//export { patchGuildProfileEditForm } from "./patchGuildProfileEditForm";
|
||||
export { patchUseProfileEffectSections } from "./patchUseProfileEffectSections";
|
||||
export { patchUseProfileTheme } from "./patchUseProfileTheme";
|
||||
export { patchUserProfileEditForm } from "./patchUserProfileEditForm";
|
||||
export { patchNitroUpsellCard } from "./patchNitroUpsellCard";
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { instead } from "@vendetta/patcher";
|
||||
|
||||
import { CollectiblesPurchaseStore } from "@fpte/lib/stores";
|
||||
import { previewUserId } from "@fpte/patches/patchUseProfileTheme";
|
||||
|
||||
export const patchGetPurchase = () => instead(
|
||||
"getPurchase",
|
||||
CollectiblesPurchaseStore,
|
||||
(args: unknown[], origFunc: (...args: any[]) => unknown) => previewUserId
|
||||
? { purchasedAt: new Date() }
|
||||
: origFunc(args)
|
||||
);
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { UserProfile } from "@vencord/discord-types";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
|
||||
import { decodeColor, decodeColorsLegacy, decodeEffect, extractFPTE } from "@fpte/lib/fpte";
|
||||
import { UserProfileStore } from "@fpte/lib/stores";
|
||||
|
||||
function updateProfileThemeColors(profile: UserProfile<false>, primary: number, accent: number) {
|
||||
if (primary > -1) {
|
||||
profile.themeColors = [primary, accent > -1 ? accent : primary];
|
||||
profile.premiumType = 2;
|
||||
} else if (accent > -1) {
|
||||
profile.themeColors = [accent, accent];
|
||||
profile.premiumType = 2;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProfileEffectId(profile: UserProfile<false>, id: bigint) {
|
||||
if (id > -1n) {
|
||||
profile.profileEffectId = profile.profileEffectID = id.toString();
|
||||
profile.premiumType = 2;
|
||||
}
|
||||
}
|
||||
|
||||
export const patchGetUserProfile = () => after("getUserProfile", UserProfileStore, (_args: unknown[], profile: UserProfile | undefined) => {
|
||||
if (!profile || profile.profileFetchFailed) return profile;
|
||||
|
||||
if (storage.prioritizeNitro) {
|
||||
if (profile.themeColors) {
|
||||
if (!(profile.profileEffectId || profile.profileEffectID)) {
|
||||
const fpte = extractFPTE(profile.bio);
|
||||
if (decodeColor(fpte[0]) === -2)
|
||||
updateProfileEffectId(profile, decodeEffect(fpte[1]));
|
||||
else
|
||||
updateProfileEffectId(profile, decodeEffect(fpte[2]));
|
||||
}
|
||||
return profile;
|
||||
} else if (profile.profileEffectId || profile.profileEffectID) {
|
||||
const fpte = extractFPTE(profile.bio);
|
||||
const primaryColor = decodeColor(fpte[0]);
|
||||
if (primaryColor === -2)
|
||||
updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0]));
|
||||
else
|
||||
updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1]));
|
||||
return profile;
|
||||
}
|
||||
}
|
||||
|
||||
const fpte = extractFPTE(profile.bio);
|
||||
const primaryColor = decodeColor(fpte[0]);
|
||||
if (primaryColor === -2) {
|
||||
updateProfileThemeColors(profile, ...decodeColorsLegacy(fpte[0]));
|
||||
updateProfileEffectId(profile, decodeEffect(fpte[1]));
|
||||
} else {
|
||||
updateProfileThemeColors(profile, primaryColor, decodeColor(fpte[1]));
|
||||
updateProfileEffectId(profile, decodeEffect(fpte[2]));
|
||||
}
|
||||
|
||||
return profile;
|
||||
});
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import React from "react";
|
||||
|
||||
import { findParentInTree, getComponentNameFromType, isElement, type RN } from "@fpte/lib/reactNativeRenderTree";
|
||||
import { Builder } from "@fpte/ui/components";
|
||||
|
||||
const funcParent = findByName("GuildProfileEditForm", false);
|
||||
|
||||
export const patchGuildProfileEditForm = () => after("default", funcParent, (_args: unknown[], tree: RN.Node) => {
|
||||
if (storage.hideBuilder) return tree;
|
||||
|
||||
console.log("[GuildProfileEditForm] Render tree:", tree);
|
||||
console.log("[GuildProfileEditForm] Direct children:", tree?.props?.children);
|
||||
|
||||
let guildId: string | undefined;
|
||||
const parent = findParentInTree(tree, (children): children is RN.Node[] =>
|
||||
Array.isArray(children) && children.some(child => {
|
||||
if (isElement(child)) {
|
||||
const name = getComponentNameFromType(child.type);
|
||||
console.log("[GuildProfileEditForm] Child component:", name, child.props);
|
||||
if (name === "EditGuildIdentityBio") {
|
||||
guildId = (child.props as any).displayProfile?.guildId;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
})
|
||||
);
|
||||
|
||||
if (parent) {
|
||||
console.log("[GuildProfileEditForm] Found parent:", parent);
|
||||
parent.props.children.splice(2, 0, <Builder guildId={guildId} />);
|
||||
} else {
|
||||
console.warn("[GuildProfileEditForm] Parent not found.");
|
||||
}
|
||||
|
||||
return tree;
|
||||
});
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { isElement, getComponentNameFromType, type RN } from "@fpte/lib/reactNativeRenderTree";
|
||||
|
||||
const funcParent = findByName("UserProfileEditForm", false);
|
||||
|
||||
function removeNitroCards(node: RN.Node): RN.Node | null {
|
||||
if (!node) return null;
|
||||
|
||||
if (
|
||||
isElement(node) &&
|
||||
getComponentNameFromType(node.type) === "UserProfilePremiumUpsellCard"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (node.props?.children) {
|
||||
let children = node.props.children;
|
||||
if (!Array.isArray(children)) children = [children];
|
||||
|
||||
const filteredChildren = children
|
||||
.map(removeNitroCards)
|
||||
.filter((child): child is RN.Node => child !== null);
|
||||
|
||||
node.props.children = filteredChildren.length === 1 ? filteredChildren[0] : filteredChildren;
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
export const patchNitroUpsellCard = () => {
|
||||
if (!funcParent) return () => {};
|
||||
|
||||
return after("default", funcParent, (_args: unknown[], tree: RN.Node) => {
|
||||
try {
|
||||
return removeNitroCards(tree) || tree;
|
||||
} catch {
|
||||
// One broken upsell-card removal shouldn't crash the whole profile edit screen.
|
||||
return tree;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { findByPropsAll } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
|
||||
import { ProfileEffectRecord } from "@fpte/lib/records";
|
||||
import { type ProfileEffect, getProfileEffectStore } from "@fpte/lib/stores";
|
||||
import { previewUserId } from "@fpte/patches/patchUseProfileTheme";
|
||||
|
||||
const useSectionModules = findByPropsAll("NONE_ITEM");
|
||||
|
||||
let prevProfileEffects: ProfileEffect[];
|
||||
|
||||
let prevSections: Record<string, any>[];
|
||||
|
||||
export const patchUseProfileEffectSections = () => useSectionModules.map(
|
||||
sectionModule => after("default", sectionModule, (_args: unknown[], origSections: typeof prevSections) => {
|
||||
try {
|
||||
const profileEffectStore = getProfileEffectStore();
|
||||
if (previewUserId && profileEffectStore && origSections?.[0]?.items) {
|
||||
const currProfileEffects = profileEffectStore.profileEffects;
|
||||
if (prevProfileEffects !== currProfileEffects) {
|
||||
origSections.splice(1);
|
||||
origSections[0]!.items.splice(1);
|
||||
profileEffectStore.profileEffects.forEach(effect => {
|
||||
origSections[0]!.items.push(new ProfileEffectRecord(effect.config));
|
||||
});
|
||||
prevSections = origSections;
|
||||
} else if (prevSections) {
|
||||
origSections = prevSections;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A broken section rewrite shouldn't crash the whole effect-browsing screen - let
|
||||
// Discord's own sections render unmodified instead.
|
||||
}
|
||||
return origSections;
|
||||
})
|
||||
);
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import type { DisplayProfile, ProfileThemeColors, UserRecord } from "@vencord/discord-types";
|
||||
import { findByName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FluxDispatcher } from "@fpte/lib/flux";
|
||||
import type { ProfileEffectConfig } from "@fpte/lib/stores";
|
||||
import { getProfileTheme, type Theme } from "@fpte/ui/color";
|
||||
|
||||
function updatePreview() {
|
||||
FluxDispatcher.dispatch({ type: "USER_SETTINGS_ACCOUNT_SUBMIT_SUCCESS" });
|
||||
}
|
||||
|
||||
let showPreview = true;
|
||||
export function useShowPreview(initialState: typeof showPreview) {
|
||||
const [state, setState] = useState(() => showPreview = initialState);
|
||||
return [
|
||||
state,
|
||||
(preview: typeof showPreview) => {
|
||||
setState(showPreview = preview);
|
||||
updatePreview();
|
||||
}
|
||||
] as const;
|
||||
}
|
||||
|
||||
let primaryColor: number | null = null;
|
||||
export function usePrimaryColor(initialState: typeof primaryColor) {
|
||||
const [state, setState] = useState(() => primaryColor = initialState);
|
||||
return [
|
||||
state,
|
||||
(color: typeof primaryColor) => {
|
||||
setState(primaryColor = color);
|
||||
if (showPreview) updatePreview();
|
||||
}
|
||||
] as const;
|
||||
}
|
||||
|
||||
let accentColor: number | null = null;
|
||||
export function useAccentColor(initialState: typeof accentColor) {
|
||||
const [state, setState] = useState(() => accentColor = initialState);
|
||||
return [
|
||||
state,
|
||||
(color: typeof accentColor) => {
|
||||
setState(accentColor = color);
|
||||
if (showPreview) updatePreview();
|
||||
}
|
||||
] as const;
|
||||
}
|
||||
|
||||
// TEMP
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
let profileEffect: ProfileEffectConfig | null = null;
|
||||
export function useProfileEffect(initialState: typeof profileEffect) {
|
||||
const [state, setState] = useState(() => profileEffect = initialState);
|
||||
return [
|
||||
state,
|
||||
(effect: typeof profileEffect) => {
|
||||
setState(profileEffect = effect);
|
||||
if (showPreview) updatePreview();
|
||||
}
|
||||
] as const;
|
||||
}
|
||||
|
||||
export let previewUserId: string | undefined;
|
||||
|
||||
export function setPreviewUserId(userId: typeof previewUserId) {
|
||||
previewUserId = userId;
|
||||
}
|
||||
|
||||
export const patchUseProfileTheme = (() => {
|
||||
let funcParent = findByName("useProfileTheme", false);
|
||||
if (funcParent)
|
||||
return () => after(
|
||||
"default",
|
||||
funcParent,
|
||||
(([options]: [UseProfileThemeOptions], profileTheme: ProfileTheme) => {
|
||||
const { user } = options;
|
||||
if (
|
||||
(user != null && user.id === previewUserId
|
||||
|| "pendingThemeColors" in options)
|
||||
&& showPreview
|
||||
) {
|
||||
if (primaryColor !== null) {
|
||||
profileTheme.theme = getProfileTheme(primaryColor);
|
||||
profileTheme.primaryColor = primaryColor;
|
||||
profileTheme.secondaryColor = accentColor ?? primaryColor;
|
||||
} else if (accentColor !== null) {
|
||||
profileTheme.theme = getProfileTheme(accentColor);
|
||||
profileTheme.primaryColor = accentColor;
|
||||
profileTheme.secondaryColor = accentColor;
|
||||
}
|
||||
}
|
||||
return profileTheme;
|
||||
}) as any
|
||||
);
|
||||
|
||||
funcParent = findByName("useProfileThemeColors", false);
|
||||
if (funcParent)
|
||||
return () => after(
|
||||
"default",
|
||||
funcParent,
|
||||
(([user, _displayProfile, previewProps]: UseProfileThemeColorsArgs, origRet: PartialProfileThemeColors) => {
|
||||
if (
|
||||
(user != null && user.id === previewUserId
|
||||
|| previewProps)
|
||||
&& showPreview
|
||||
) {
|
||||
if (primaryColor !== null)
|
||||
return [primaryColor, accentColor ?? primaryColor];
|
||||
if (accentColor !== null)
|
||||
return [accentColor, accentColor];
|
||||
}
|
||||
return origRet;
|
||||
}) as any
|
||||
);
|
||||
|
||||
return () => () => true;
|
||||
})();
|
||||
|
||||
interface UseProfileThemeOptions {
|
||||
user?: UserRecord | null | undefined;
|
||||
displayProfile?: DisplayProfile | null | undefined;
|
||||
pendingThemeColors: ProfileThemeColors | undefined;
|
||||
pendingAvatar?: UserRecord["avatar"] | undefined;
|
||||
isPreview?: boolean | null | undefined;
|
||||
}
|
||||
|
||||
interface ProfileTheme {
|
||||
theme: Theme;
|
||||
primaryColor: number | null;
|
||||
secondaryColor: number | null;
|
||||
}
|
||||
|
||||
type UseProfileThemeColorsArgs = [
|
||||
user: UserRecord | null | undefined,
|
||||
displayProfile: DisplayProfile | null | undefined,
|
||||
previewProps?: {
|
||||
pendingThemeColors: ProfileThemeColors | undefined;
|
||||
pendingAvatar?: UserRecord["avatar"] | undefined;
|
||||
isPreview?: boolean;
|
||||
} | undefined
|
||||
];
|
||||
|
||||
type PartialProfileThemeColors = [primaryColor: number | null, accentColor: number | null];
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
import { after } from "@vendetta/patcher";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import React from "react";
|
||||
|
||||
import { findParentInTree, getComponentNameFromType, isElement, type RN } from "@fpte/lib/reactNativeRenderTree";
|
||||
import { Builder } from "@fpte/ui/components";
|
||||
|
||||
const funcParent = findByName("UserProfileEditForm", false);
|
||||
|
||||
export const patchUserProfileEditForm = () => {
|
||||
if (!funcParent) return () => {};
|
||||
|
||||
return after("default", funcParent, (_args: unknown[], tree: RN.Node) => {
|
||||
try {
|
||||
if (storage.hideBuilder) return tree;
|
||||
|
||||
const parent = findParentInTree(tree, (children): children is RN.Node[] =>
|
||||
Array.isArray(children) &&
|
||||
children.some(child =>
|
||||
isElement(child) &&
|
||||
getComponentNameFromType(child.type) === "UserProfileEditFormTextField"
|
||||
)
|
||||
);
|
||||
|
||||
if (parent) {
|
||||
const index = parent.props.children.reduce<number[]>((acc, child, i) => {
|
||||
if (
|
||||
isElement(child) &&
|
||||
getComponentNameFromType(child.type) === "UserProfileEditFormTextField"
|
||||
) acc.push(i);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (index.length >= 3) {
|
||||
parent.props.children.splice(index[2] + 1, 0, <Builder />);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// One broken edit-form render shouldn't crash the whole profile edit screen.
|
||||
}
|
||||
|
||||
return tree;
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import type { UserRecord } from "@vencord/discord-types";
|
||||
import { findByName } from "@vendetta/metro";
|
||||
import { storage } from "@vendetta/plugin";
|
||||
import React, { type ReactElement, type ReactNode, useMemo } from "react";
|
||||
|
||||
import { findElementInTree, findParentInTree, getComponentNameFromType, type RN } from "@fpte/lib/reactNativeRenderTree";
|
||||
import { ProfileEffectRecord } from "@fpte/lib/records";
|
||||
import type { ProfileEffect, ProfileEffectConfig } from "@fpte/lib/stores";
|
||||
import { setPreviewUserId } from "@fpte/patches/patchUseProfileTheme";
|
||||
import { FallbackEffectPickerActionSheet } from "@fpte/ui/actionSheets";
|
||||
import { ThemeContextProvider, useThemeContext } from "@fpte/ui/color";
|
||||
|
||||
export interface EffectPickerActionSheetProps {
|
||||
effects: ProfileEffect[];
|
||||
onSelect: (effect: ProfileEffectConfig | null) => void;
|
||||
user: UserRecord;
|
||||
currentEffectId?: string | undefined;
|
||||
}
|
||||
|
||||
const EffectPicker: RN.FunctionComponent | undefined = findByName("EditProfileEffectActionSheet");
|
||||
|
||||
let lastGoodTree: RN.Node;
|
||||
|
||||
function PatchedEffectPickerActionSheet(props: EffectPickerActionSheetProps) {
|
||||
const { currentEffectId, effects, onSelect, user } = props;
|
||||
|
||||
// Must be checked before EffectPicker is called below - that call chains Discord's own hooks
|
||||
// onto this fiber, so checking the setting after the call still pays the crash it's meant to
|
||||
// avoid. forceFallbackEffectPicker doesn't change while mounted, so this early return is safe.
|
||||
if (storage.forceFallbackEffectPicker)
|
||||
return <FallbackEffectPickerActionSheet {...props} />;
|
||||
|
||||
const tree = EffectPicker!({ user });
|
||||
|
||||
const themeContext = useThemeContext();
|
||||
|
||||
// Must be built from effect.config, not our {id, skuId, config} wrapper - the wrapper leaves
|
||||
// everything but skuId/type undefined.
|
||||
const effectRecords = useMemo(() => effects.map(effect => ({ items: new ProfileEffectRecord(effect.config) })), [effects]);
|
||||
|
||||
let isLegacyEffectPicker = false;
|
||||
const effectPickerInner: RN.Element<any> | null = findElementInTree(tree, element => {
|
||||
if (getComponentNameFromType(element.type) === "EditProfileEffectInner")
|
||||
return true;
|
||||
if (
|
||||
"profileEffects" in element.props
|
||||
&& "selectedProfileEffect" in element.props
|
||||
&& typeof (element.props as any).setSelectedProfileEffect === "function"
|
||||
) return isLegacyEffectPicker = true;
|
||||
return false;
|
||||
});
|
||||
if (!effectPickerInner) {
|
||||
if (lastGoodTree) return lastGoodTree as ReactElement;
|
||||
return <FallbackEffectPickerActionSheet {...props} />;
|
||||
}
|
||||
|
||||
const applyButton: RN.Element<any> | null = findElementInTree(tree, element => getComponentNameFromType(element.type) === "Button");
|
||||
if (!applyButton) {
|
||||
if (lastGoodTree) return lastGoodTree as ReactElement;
|
||||
return <FallbackEffectPickerActionSheet {...props} />;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (isLegacyEffectPicker) {
|
||||
if (effectPickerInner.props.selectedProfileEffect === undefined)
|
||||
effectPickerInner.props.setSelectedProfileEffect(props.currentEffectId
|
||||
? { id: props.currentEffectId }
|
||||
: null);
|
||||
effectPickerInner.props.profileEffects = props.effects;
|
||||
|
||||
const profilePreview = findParentInTree(tree, children =>
|
||||
Array.isArray(children) && children.some(child =>
|
||||
getComponentNameFromType(child.type) === "DisplayBanner"));
|
||||
if (profilePreview) {
|
||||
const baseProvider = tree as RN.Element<any>;
|
||||
|
||||
profilePreview.props.children = (
|
||||
<ThemeContextProvider
|
||||
theme={baseProvider.props.theme}
|
||||
primaryColor={baseProvider.props.primaryColor}
|
||||
secondaryColor={baseProvider.props.secondaryColor}
|
||||
children={profilePreview.props.children as ReactNode}
|
||||
/>
|
||||
);
|
||||
|
||||
baseProvider.props.theme = themeContext.theme;
|
||||
baseProvider.props.primaryColor = themeContext.primaryColor;
|
||||
baseProvider.props.secondaryColor = themeContext.secondaryColor;
|
||||
}
|
||||
} else {
|
||||
if (effectPickerInner.props.selectedProfileEffect === undefined)
|
||||
effectPickerInner.props.setSelectedProfileEffect(currentEffectId
|
||||
? new ProfileEffectRecord({ skuId: currentEffectId } as any)
|
||||
: null);
|
||||
effectPickerInner.props.purchases = effectRecords;
|
||||
}
|
||||
|
||||
setPreviewUserId(user.id);
|
||||
applyButton.props.onPress = () => {
|
||||
setPreviewUserId(undefined);
|
||||
// selectedProfileEffect has no .id field, only .skuId.
|
||||
onSelect(effects.find(effect => effect.skuId === effectPickerInner.props.selectedProfileEffect?.skuId)?.config ?? null);
|
||||
};
|
||||
|
||||
return (lastGoodTree = tree) as ReactElement;
|
||||
}
|
||||
|
||||
export const EffectPickerActionSheet = EffectPicker
|
||||
? PatchedEffectPickerActionSheet
|
||||
: FallbackEffectPickerActionSheet;
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import React, { type ReactNode, useContext, useMemo, useState } from "react";
|
||||
import { View, type ViewStyle } from "react-native";
|
||||
|
||||
import { HapticFeebackTypes, triggerHapticFeedback } from "@fpte/lib/haptics";
|
||||
import type { ProfileEffect } from "@fpte/lib/stores";
|
||||
import { BottomSheet, BottomSheetScrollView, type EffectPickerActionSheetProps } from "@fpte/ui/actionSheets";
|
||||
import { IMG_NONE } from "@fpte/ui/assets";
|
||||
import { resolveSemanticColorSafe, useThemeContext } from "@fpte/ui/color";
|
||||
import { Button, FlashList, Icon, PressableOpacity, StaticEffect, Text } from "@fpte/ui/components";
|
||||
import { Radius, SafeAreaContext, Spacing, useWindowDimensions } from "@fpte/ui/length";
|
||||
|
||||
const ROW_SIZE = 3;
|
||||
|
||||
// lodash isn't a dependency here - an unresolved import threw "lodash is not defined" before
|
||||
// onLoad ever ran. Only one function was used from it, so it's inlined here instead.
|
||||
function chunk<T>(arr: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) result.push(arr.slice(i, i + size));
|
||||
return result;
|
||||
}
|
||||
|
||||
interface ItemProps {
|
||||
label: string;
|
||||
isSelected: boolean;
|
||||
size: number;
|
||||
colors: [bgColor: string, itemColor: string, selectedColor: string];
|
||||
onPress: () => void;
|
||||
style?: ViewStyle | undefined;
|
||||
children: Exclude<ReactNode, number | string>;
|
||||
}
|
||||
|
||||
function Item({ label, isSelected, size, colors, onPress, style, children }: ItemProps) {
|
||||
const [bgColor, itemColor, selectedColor] = colors;
|
||||
|
||||
return (
|
||||
<PressableOpacity
|
||||
accessibilityLabel={label}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: isSelected }}
|
||||
disabled={isSelected}
|
||||
onPress={() => {
|
||||
triggerHapticFeedback(HapticFeebackTypes.IMPACT_LIGHT);
|
||||
onPress();
|
||||
}}
|
||||
style={[
|
||||
{
|
||||
height: size,
|
||||
width: size,
|
||||
overflow: "hidden",
|
||||
backgroundColor: itemColor,
|
||||
borderColor: bgColor,
|
||||
borderRadius: Radius.sm,
|
||||
borderWidth: 2
|
||||
},
|
||||
isSelected && { borderColor: selectedColor },
|
||||
style
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</PressableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
export type FallbackEffectPickerActionSheetProps = Pick<EffectPickerActionSheetProps, "currentEffectId" | "effects" | "onSelect">;
|
||||
|
||||
export function FallbackEffectPickerActionSheet({ currentEffectId, effects, onSelect }: FallbackEffectPickerActionSheetProps) {
|
||||
const [selectedId, setSelectedId] = useState(currentEffectId);
|
||||
const [itemSize, setItemSize] = useState(0);
|
||||
|
||||
const { theme } = useThemeContext();
|
||||
const colors: ItemProps["colors"] = useMemo(() => [
|
||||
resolveSemanticColorSafe(theme, ["BACKGROUND_PRIMARY", "BACKGROUND_BASE_LOWER", "BACKGROUND_BASE_LOW"], "#1E1F22"),
|
||||
resolveSemanticColorSafe(theme, ["BACKGROUND_FLOATING", "MOBILE_FLOATINGBAR_BACKGROUND", "BACKGROUND_BASE_LOWER"], "#2B2D31"),
|
||||
resolveSemanticColorSafe(theme, ["BUTTON_OUTLINE_BRAND_BORDER_ACTIVE", "BORDER_FOCUS"], "#5865F2")
|
||||
], [theme]);
|
||||
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const safeArea = useContext(SafeAreaContext);
|
||||
|
||||
const effectRows = useMemo(() => {
|
||||
const effectChunks: (ProfileEffect | null | undefined)[][] = chunk([null, ...effects], ROW_SIZE);
|
||||
const lastChunk = effectChunks[effectChunks.length - 1]!;
|
||||
while (lastChunk.length < 3) lastChunk.push(undefined);
|
||||
return effectChunks;
|
||||
}, [effects]);
|
||||
|
||||
return (
|
||||
<BottomSheet
|
||||
transparentHeader={true}
|
||||
scrollable={true}
|
||||
startExpanded={true}
|
||||
startHeight={windowDimensions.height - safeArea.top}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
scrollsToTop={false}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
paddingBottom: 88
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="redesign/heading-18/bold"
|
||||
color="header-primary"
|
||||
style={{ margin: Spacing.PX_16 }}
|
||||
>
|
||||
{currentEffectId ? "Change Effect" : "Add Profile Effect"}
|
||||
</Text>
|
||||
<View
|
||||
style={{
|
||||
width: "72%",
|
||||
minHeight: 38
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
variant="heading-md/bold"
|
||||
color="header-primary"
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
{effects.find(effect => effect.id === selectedId)?.config.title ?? "None"}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
width: "92%",
|
||||
marginTop: 3
|
||||
}}
|
||||
>
|
||||
<FlashList
|
||||
accessibilityLabel="Profile Effect Selection Section"
|
||||
numColumns={1}
|
||||
estimatedItemSize={98}
|
||||
ItemSeparatorComponent={() => <View style={{ height: Spacing.PX_16 }} />}
|
||||
contentContainerStyle={{ paddingHorizontal: Spacing.PX_4 }}
|
||||
data={effectRows}
|
||||
extraData={selectedId}
|
||||
renderItem={({ item }) => (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingHorizontal: Spacing.PX_16
|
||||
}}
|
||||
>
|
||||
{item.map(effect => effect
|
||||
? (
|
||||
<Item
|
||||
label={effect.config.accessibilityLabel}
|
||||
isSelected={effect.id === selectedId}
|
||||
size={itemSize}
|
||||
colors={colors}
|
||||
onPress={() => { setSelectedId(effect.id); }}
|
||||
>
|
||||
<StaticEffect effect={effect.config} />
|
||||
</Item>
|
||||
)
|
||||
: effect === null
|
||||
? (
|
||||
<Item
|
||||
label="None"
|
||||
isSelected={!selectedId}
|
||||
size={itemSize}
|
||||
colors={colors}
|
||||
onPress={() => { setSelectedId(undefined); }}
|
||||
style={{
|
||||
alignItems: "center",
|
||||
justifyContent: "center"
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
source={IMG_NONE}
|
||||
size={Icon.Sizes.LARGE}
|
||||
/>
|
||||
<Text
|
||||
variant="text-sm/medium"
|
||||
color="header-primary"
|
||||
style={{ marginTop: Spacing.PX_4 }}
|
||||
>
|
||||
None
|
||||
</Text>
|
||||
</Item>
|
||||
)
|
||||
: (
|
||||
<View
|
||||
style={{
|
||||
width: itemSize,
|
||||
height: itemSize
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
onLayout={event => { setItemSize((event.nativeEvent.layout.width - 64) / ROW_SIZE); }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
<Button
|
||||
text="Apply"
|
||||
textStyle={{ fontSize: 16 }}
|
||||
onPress={() => { onSelect(effects.find(effect => effect.id === selectedId)?.config ?? null); }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
height: 48,
|
||||
marginHorizontal: 36,
|
||||
marginBottom: Spacing.PX_48,
|
||||
borderRadius: Radius.round
|
||||
}}
|
||||
/>
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
export * from "./EffectPickerActionSheet";
|
||||
export * from "./FallbackEffectPickerActionSheet";
|
||||
export * from "./showColorPicker";
|
||||
export * from "./showEffectPicker";
|
||||
|
||||
export const BottomSheet: ComponentType<Record<string, any>>
|
||||
= (findByProps("BottomSheet") as Record<string, any> | undefined)?.BottomSheet
|
||||
?? (findByProps("ActionSheet") as Record<string, any> | undefined)?.ActionSheet
|
||||
?? (() => { throw new Error("FakeProfileThemesAndEffects threw an error to avoid an otherwise-inevitable, unrecoverable freeze."); });
|
||||
|
||||
export const BottomSheetScrollView: ComponentType<any>
|
||||
= (findByProps("BottomSheetScrollView") as any)?.BottomSheetScrollView ?? (() => null);
|
||||
|
||||
// Must use `as` or else `undefined` is lost due to a TS bug
|
||||
const actionSheetModule = findByProps("showActionSheet") as Record<string, any> | undefined;
|
||||
|
||||
export const showActionSheet: (props: {
|
||||
content: ReactNode;
|
||||
key: string;
|
||||
impressionName?: string | undefined;
|
||||
impressionProperties?: object | undefined;
|
||||
backdropKind?: string | undefined;
|
||||
}) => void = actionSheetModule?.showActionSheet ?? (() => undefined);
|
||||
|
||||
export const hideActionSheet: (key?: string | undefined) => void
|
||||
= actionSheetModule?.default?.hideActionSheet ?? (() => undefined);
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { findByName } from "@vendetta/metro";
|
||||
|
||||
const $showColorPicker = findByName("showCustomColorPickerActionSheet") ?? (() => undefined);
|
||||
|
||||
export function showColorPicker(props: {
|
||||
color?: number | null | undefined;
|
||||
onSelect?: ((color: number) => void) | undefined;
|
||||
suggestedColors?: string[] | undefined;
|
||||
}) {
|
||||
props.color ??= 0;
|
||||
return $showColorPicker(props);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import React from "react";
|
||||
|
||||
import { FluxDispatcher } from "@fpte/lib/flux";
|
||||
import { type ProfileEffectConfig, getProfileEffectStore, UserStore } from "@fpte/lib/stores";
|
||||
import { setPreviewUserId } from "@fpte/patches/patchUseProfileTheme";
|
||||
import { EffectPickerActionSheet, hideActionSheet, showActionSheet } from "@fpte/ui/actionSheets";
|
||||
|
||||
const SHEET_KEY = "__FPTE__";
|
||||
|
||||
export function showEffectPicker(
|
||||
onSelect: (effect: ProfileEffectConfig | null) => void,
|
||||
currentEffectId?: string | undefined
|
||||
) {
|
||||
// Called directly from a native onPress - an uncaught throw here is fatal to the whole app
|
||||
// (surfaces as AndroidRuntime FATAL EXCEPTION, not a recoverable React error screen).
|
||||
try {
|
||||
const user = UserStore.getCurrentUser();
|
||||
const profileEffectStore = getProfileEffectStore();
|
||||
if (!user || !profileEffectStore) return;
|
||||
|
||||
function onClose(action: any) {
|
||||
if (action.key === SHEET_KEY) {
|
||||
FluxDispatcher.unsubscribe("HIDE_ACTION_SHEET", onClose);
|
||||
setPreviewUserId(undefined);
|
||||
}
|
||||
}
|
||||
FluxDispatcher.subscribe("HIDE_ACTION_SHEET", onClose);
|
||||
|
||||
showActionSheet({
|
||||
content: (
|
||||
<EffectPickerActionSheet
|
||||
effects={profileEffectStore.profileEffects}
|
||||
onSelect={effect => {
|
||||
onSelect(effect);
|
||||
hideActionSheet(SHEET_KEY);
|
||||
}}
|
||||
user={user}
|
||||
currentEffectId={currentEffectId}
|
||||
/>
|
||||
),
|
||||
key: SHEET_KEY
|
||||
});
|
||||
} catch {
|
||||
// Nothing sensible to fall back to for a picker that couldn't even open - just don't
|
||||
// crash the app over it.
|
||||
}
|
||||
}
|
||||
17
plugins/fake-profile-themes-and-effects/src/ui/assets.ts
Normal file
17
plugins/fake-profile-themes-and-effects/src/ui/assets.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { findByProps } from "@vendetta/metro";
|
||||
import { getAssetIDByName } from "@vendetta/ui/assets";
|
||||
import type { ImageSourcePropType } from "react-native";
|
||||
|
||||
export const IMG_NONE = getAssetIDByName("img_none");
|
||||
|
||||
export const PROFILE_EFFECT_WH_RATIO: number
|
||||
= (findByProps("DEFAULT_PROFILE_EFFECT_WH_RATIO") as Record<string, any> | undefined)?.DEFAULT_PROFILE_EFFECT_WH_RATIO
|
||||
?? 45 / 88;
|
||||
|
||||
export const SAMPLE_PROFILE_SMALL: ImageSourcePropType
|
||||
= (getAssetIDByName("sample-profile-small") as number | undefined)
|
||||
?? { uri: "https://discordapp.com/assets/f328a6f8209d4f1f5022.png" };
|
||||
|
||||
export const TOAST_TYPE_COPY = getAssetIDByName("toast_copy_link");
|
||||
|
||||
export const TOAST_TYPE_FAILURE = getAssetIDByName("Small");
|
||||
73
plugins/fake-profile-themes-and-effects/src/ui/color.ts
Normal file
73
plugins/fake-profile-themes-and-effects/src/ui/color.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { find, findByProps } from "@vendetta/metro";
|
||||
import { semanticColors as _semanticColors } from "@vendetta/ui";
|
||||
import type { ComponentType, PropsWithChildren } from "react";
|
||||
|
||||
import type { EmptyObject } from "@fpte/lib/utils";
|
||||
|
||||
export const semanticColors: Record<string, EmptyObject> = _semanticColors;
|
||||
|
||||
export const resolveSemanticColor: (theme: Theme, semanticColor: EmptyObject) => string
|
||||
= find(m => m.default?.internal?.resolveSemanticColor)?.default.internal.resolveSemanticColor
|
||||
?? find(m => m.meta?.resolveSemanticColor)?.meta.resolveSemanticColor
|
||||
?? (() => undefined);
|
||||
|
||||
// Discord renames semantic color tokens across versions - tries each candidate name in order and
|
||||
// never throws.
|
||||
export function resolveSemanticColorSafe(theme: Theme, tokenNames: string[], fallbackHex: string): string {
|
||||
for (const name of tokenNames) {
|
||||
const token = semanticColors?.[name];
|
||||
if (!token) continue;
|
||||
try {
|
||||
const resolved = resolveSemanticColor(theme, token);
|
||||
if (resolved) return resolved;
|
||||
} catch {
|
||||
// try the next candidate
|
||||
}
|
||||
}
|
||||
return fallbackHex;
|
||||
}
|
||||
|
||||
export const useAvatarColors: (
|
||||
avatarUrl: string,
|
||||
fillerColor: string,
|
||||
desaturateColors?: boolean | undefined /* = true */
|
||||
) => string[]
|
||||
= (findByProps("useAvatarColors") as Record<string, any> | undefined)?.useAvatarColors
|
||||
?? (() => undefined);
|
||||
|
||||
export type Theme = "dark" | "light" | "midnight" | "darker";
|
||||
|
||||
// Unguarded, this crashed the whole plugin bundle at load time - a property access on a failed
|
||||
// findByProps throws while the module is being evaluated, before onLoad is ever reached.
|
||||
export const getProfileTheme: <T extends number | null | undefined>(primaryColor: T) => T extends number ? Theme : null
|
||||
= (findByProps("getProfileTheme") as Record<string, any> | undefined)?.getProfileTheme
|
||||
?? ((primaryColor: any) => (primaryColor == null ? null : "dark")) as any;
|
||||
|
||||
export interface ThemeContext {
|
||||
theme: Theme;
|
||||
primaryColor: number | null;
|
||||
secondaryColor: number | null;
|
||||
gradient: {
|
||||
id: number;
|
||||
theme: Theme;
|
||||
colors: {
|
||||
stop: number;
|
||||
token: string;
|
||||
}[];
|
||||
angle: number;
|
||||
midpointPercentage: number;
|
||||
getName: () => string;
|
||||
} | null;
|
||||
flags: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export const useThemeContext: () => ThemeContext
|
||||
= (findByProps("useThemeContext") as Record<string, any> | undefined)?.useThemeContext
|
||||
?? (() => ({}));
|
||||
|
||||
export type ThemeContextProviderProps = PropsWithChildren<Partial<ThemeContext>>;
|
||||
|
||||
export const ThemeContextProvider: ComponentType<ThemeContextProviderProps>
|
||||
= (findByProps("ThemeContextProvider") as Record<string, any> | undefined)?.ThemeContextProvider
|
||||
?? (({ children }) => children);
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue