diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..88f7008
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Gradle / Android
+.gradle/
+build/
+plugins/*/build/
+local.properties
+.kotlin/
+
+# JS
+node_modules/
+bun.lockb
+bun.lock
+package-lock.json
+deno.lock
+
+# Misc
+.DS_Store
+.idea/
+*.iml
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100755
index 0000000..58f1c37
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,8 @@
+{
+ "editor.codeActionsOnSave": {
+ "source.biome": "explicit",
+ "source.fixAll.biome": "explicit",
+ "source.organizeImports.biome": "explicit",
+ "source.removeUnusedImports": "explicit"
+ }
+}
diff --git a/LICENSE b/LICENSE
new file mode 100755
index 0000000..ed990f8
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,621 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..71cec6d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,281 @@
+# revenge-next-plugs
+
+Revenge Next plugin repository, scaffolded from [revenge-plugin-template](https://github.com/revenge-mod/revenge-plugin-template).
+Each plugin becomes one ZIP file. One repository can hold any number of plugins under `plugins/`.
+
+## Plugins
+
+- `staff-tags` — ported from [revenge-plugins](https://github.com/everestmcarthur/revenge-plugins) (Classic).
+
+A plugin has up to three parts. Its own `manifest.json` declares all of them:
+
+- **`dist.android`** is native Kotlin code. The build compiles it to a DEXed JAR, and the plugin loader loads it with `DexClassLoader`.
+ This code runs early, before the JS bundle.
+- **`dist.script`** is the JavaScript bundle. The Revenge JS side runs it.
+
+## Layout
+
+Each folder under `plugins/` is one plugin. A plugin is **native** when it has a `src/main` folder.
+A plugin is **JS-only** when it has only a JS entry file. A plugin can have both.
+
+```
+├── plugins/
+│ ├── example-plugin/ # Native + JS
+│ │ ├── manifest.json # id, metadata, dist.* paths
+│ │ ├── src/main/kotlin/com/example/plugin/MyPlugin.kt # -> plugin.jar
+│ │ └── js/index.ts # -> index.js
+│ ├── example-js-plugin/ # JS-only: no src/main, no dist.android
+│ │ ├── manifest.json
+│ │ └── js/index.ts
+│ ├── example-library/ # Dependency example: the plugin others depend on
+│ └── example-dependent/ # Dependency example: depends on com.example.library
+```
+
+The bundler looks for the JS entry in this order: `js/index.*`, then `src/index.*`, then `index.*` in the plugin folder.
+Each step accepts `.ts`, `.tsx`, `.js` and `.jsx`.
+
+To add a plugin, create `plugins//manifest.json` and add a `src/main` folder for native code, a JS entry file, or both.
+
+## Prerequisites
+
+- **JDK 25 or later** and the **Android SDK**, with `build-tools` and `platform 36`.
+ Don't forget to set `sdk.dir` in `local.properties`, or set the `ANDROID_HOME` environment variable.
+- **A JS runtime** for the JS build: [Node](https://nodejs.org/) 22.18 or later,
+ [Deno](https://deno.com/) 2, or [Bun](https://bun.com/).
+- **The Revenge plugin API in your local Maven repository.** Run this in the `revenge-xposed` repository:
+
+ ```sh
+ ./gradlew :api:publishToMavenLocal
+ ```
+
+ The task publishes `io.github.revenge:api`. `gradle/libs.versions.toml` pins the version.
+
+## Build
+
+Build and package every plugin:
+
+```sh
+./gradlew packageAllPlugins
+```
+
+The task writes one `build/dist/.zip` per plugin. Each ZIP holds `manifest.json`, the dexed JAR
+of a native plugin, and the JS bundle of a plugin that has one.
+
+Build one plugin, or only one part of it:
+
+```sh
+./gradlew packageExamplePlugin # one plugin -> build/dist/.zip
+./gradlew :plugins:example-plugin:dexJar # native only -> plugins/example-plugin/build/outputs/plugin/plugin.jar
+# replace bun with npm if you use node
+bun install # install the dependencies
+bun run build # every JS bundle -> plugins//build/js/index.js
+bun run build example-plugin # the JS bundle of one plugin
+```
+
+Gradle derives each package task name from the folder name, example: `plugins/example-plugin/` gives `packageExamplePlugin`.
+
+## `manifest.json`
+
+```jsonc
+{
+ "format": 1, // manifest format version. Required. Always 1 today.
+ "id": "com.example.plugin", // also the folder name on disk
+ "name": "Example Plugin",
+ "description": "...",
+ "author": "Your Name",
+ "version": "1.0.0", // the version of this plugin. Required.
+ "dependencies": { // keyed by plugin id
+ "revenge.api": { "version": ">=1" },
+ "discord": { "version": "*" }
+ },
+ "dist": {
+ "script": "index.js", // relative to the plugin folder
+ "android": {
+ "path": "plugin.jar", // relative to the plugin folder
+ "class": "com.example.plugin.MyPlugin" // the class that exposes the `plugin {}` val
+ }
+ }
+}
+```
+
+### `version`
+
+Revenge uses its own version scheme. A version is one or more integer segments.
+One lowercase alphanumeric prerelease label can follow. `1.0.0`, `2026.7` and `1.2.0-beta2` are all valid.
+
+Two rules control the order:
+
+- A short version compares as right-padded. `1.2` equals `1.2.0`.
+- A labeled version always sorts before its bare version. `1.2.0-rc` is lower than `1.2.0`.
+
+This scheme looks like SemVer, but it is not SemVer. A CalVer-shaped version works equally well.
+
+### `dependencies`
+
+`dependencies` is a map, and each key is a plugin id:
+
+```jsonc
+"dependencies": {
+ "com.example.library": { "version": ">=1.0 <2", "optional": false }
+}
+```
+
+Every field inside the value is optional. `{}` means `{ "version": "*" }`, which accepts any version.
+The key itself must still exist. The host never assumes a dependency that you do not declare.
+
+A version range uses explicit bounds only: `<`, `<=`, `=`, `>=` and `>`, separated by spaces.
+The range syntax has no `^` and no `~`. The `"*"` wildcard accepts every version.
+
+Ranges are checked at install time, at every boot, and when the user enables the plugin.
+Plugins don't load when required dependencies fail or don't satisfy the version requirements.
+
+Dependencies are resolved **by ID** against the repositories that the user enabled.
+When a dependency lives in another repository, the user must add that repository before installing the plugin.
+
+The `example-library` and `example-dependent` pair shows this. The dependent declares `"com.example.library": { "version": ">=1" }`.
+An install therefore also installs the library. The library always loads and starts first.
+
+If the library is missing or out of range, the dependent never loads.
+
+### Optional dependencies
+
+`"optional": true` marks a dependency that never blocks your plugin.
+Your plugin still loads when that dependency is missing, out of range, or broken.
+
+When the dependency is present, it loads before your plugin, and its code are linked and made available to your plugin.
+
+To detect the dependency, probe for one of its classes:
+
+```kotlin
+val themesAvailable = runCatching {
+ Class.forName("com.example.themes.ThemeApi", false, javaClass.classLoader)
+}.isSuccess
+```
+
+Keep all code that touches the optional API in a separate adapter class. Reference that class only after the probe succeeds.
+A reference to a missing class stays safe until a code path runs it.
+
+In JS, check if your plugin API is decorated:
+
+```ts
+start({ themes }) {
+ const themesAvailable = !!themes
+}
+```
+
+### Reserved IDs
+
+Two dependency IDs are reserved.
+
+- **`revenge.api`** resolves to the Revenge release version, which is the plugin API version.
+ This dependency is **mandatory**. Constrain it to the API versions you tested, for example `">=1 <2"`.
+- **`discord`** resolves to the Discord app version, for example `>=355.0`.
+
+## Native plugins
+
+A native plugin is a **top-level `val`** that you build with the `plugin {}` DSL. You implement no
+interface, and you subclass nothing. The host reads the class that `dist.android.class` names, and
+takes the first `PluginBuilder` value it exposes. You import and use a Ktor plugin value the same
+way.
+
+```kotlin
+@file:JvmName("MyPlugin") // makes dist.android.class read as com.example.plugin.MyPlugin
+
+package com.example.plugin
+
+import io.github.revenge.plugins.plugin
+import io.github.revenge.xposed.api.registerMethod
+
+val myPlugin = plugin {
+ start {
+ log.i("Loaded ${manifest.id} in ${appInfo.packageName}")
+ registerMethod("${manifest.id}.ping") { "pong" }
+ }
+ stop {
+ log.i("Unloaded ${manifest.id}")
+ }
+}
+```
+
+A Kotlin top-level `val` compiles into a file-facade class. `MyPlugin.kt` becomes `MyPluginKt`.
+The `@file:JvmName("MyPlugin")` annotation renames that facade. `dist.android.class` can then use the clean name `com.example.plugin.MyPlugin`.
+
+If you omit the annotation, point `dist.android.class` at `...MyPluginKt`.
+Declare exactly one `plugin {}` val in the file that the manifest names.
+
+The host provides the Revenge API, the Xposed API, coroutines and the Kotlin standard library. The
+build marks them `compileOnly`. The host class loader supplies them at runtime, so the JAR must not
+contain them.
+
+> **Note:** `d8` can print a `malformed kotlin.Metadata` warning. This warning is not fatal.
+> The SDK metadata library is older than the Kotlin compiler. `d8` still writes a correct DEX, and the DEX loads.
+> Only the rewrite of Kotlin reflection metadata stops.
+
+## Distribution
+
+This template is also a **plugin repository**. A repository is a static host that serves `index.json`
+describing every published plugin channels, versions, absolute artifact URLs, and SHA-256 digests.
+
+A user can add the repository URL in Revenge. Browsing, dependency resolution and updates all run on the client.
+
+### Channels
+
+A **channel is a named pointer into the published versions of one plugin**. In `index.json` each plugin carries both maps:
+
+```jsonc
+"channels": { "latest": "1.2.0", "testing": "1.3.0-beta" },
+"versions": { "1.2.0": { /* … */ }, "1.3.0-beta": { /* … */ } }
+```
+
+`versions` holds the artifact data. `channels` only states which published version an audience gets.
+
+The client picks a channel at install time, and it follows that pointer for update checks.
+A stable user never sees a beta, because the `latest` pointer never points at one.
+
+**Automatic pointers**:
+
+- `latest` is the newest version with **no label**. `1.2.0` qualifies. `1.3.0-beta` never does.
+- `beta` is the newest version **overall**. The generator emits it only when it differs from `latest`.
+ When your newest release is stable, no `beta` pointer exists.
+
+**Manual overrides**: Use the `channels` key in `repo.config.json`, keyed by plugin ID:
+
+```jsonc
+{
+ "name": "My Plugin Repository",
+ "channels": {
+ "com.example.plugin": {
+ "latest": "1.1.4", // keep latest on 1.1.4, for example when 1.2.0 shipped broken
+ "lts": "1.0.9" // or add a channel of your own
+ }
+ }
+}
+```
+
+These rules apply:
+
+- The generator computes `latest` and `beta` first. It then applies your overrides.
+- An override must point at a published version of that plugin. Otherwise the generator fails.
+- A channel name carries no version semantics. An `lts` version is the same artifact as its plain version.
+ You only point at it for longer. To promote `beta` to `latest`, edit the pointer. No rebuilds or republishes.
+- **A dependency never references a channel.** A dependency constrains versions only, so a mixed-channel install can resolve.
+
+### Serve a repository on your machine
+
+You can test the full repository flow against your own builds: add the repository, browse it, install, and update.
+Build the ZIPs first, then start the dev server. The server regenerates the index and serves it beside the artifacts:
+
+```sh
+./gradlew packageAllPlugins # or one package task
+bun run serve # http://:8080
+```
+
+Add the URL on the device as a repository. If the device cannot reach your IP, or if it blocks cleartext traffic, use loopback through ADB:
+
+```sh
+bun run serve -- --base-url http://127.0.0.1:8080
+adb reverse tcp:8080 tcp:8080
+```
+
+The server rescans the dist folder on every index request.
+Bump a manifest version, rebuild that plugin, and check for updates on the device. The new version will appear.
diff --git a/biome.json b/biome.json
new file mode 100644
index 0000000..ec06e76
--- /dev/null
+++ b/biome.json
@@ -0,0 +1,113 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.6/schema.json",
+ "assist": {
+ "actions": {
+ "source": {
+ "organizeImports": {
+ "level": "on",
+ "options": {
+ "groups": [
+ {
+ "type": false
+ }
+ ]
+ }
+ }
+ }
+ },
+ "enabled": true
+ },
+ "files": {
+ "includes": [
+ "**/*.js",
+ "**/*.json",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.jsx",
+ "**/*.mjs"
+ ],
+ "ignoreUnknown": true
+ },
+ "formatter": {
+ "useEditorconfig": true
+ },
+ "javascript": {
+ "formatter": {
+ "arrowParentheses": "asNeeded",
+ "enabled": true,
+ "quoteProperties": "asNeeded",
+ "quoteStyle": "single",
+ "semicolons": "asNeeded",
+ "trailingCommas": "all"
+ }
+ },
+ "json": {
+ "parser": {
+ "allowComments": true,
+ "allowTrailingCommas": true
+ }
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "preset": "recommended",
+ "a11y": {
+ "useKeyWithClickEvents": {
+ "level": "off"
+ }
+ },
+ "complexity": {
+ "noBannedTypes": {
+ "level": "warn"
+ },
+ "noCommaOperator": {
+ "level": "off"
+ },
+ "useArrowFunction": {
+ "level": "warn"
+ }
+ },
+ "performance": {
+ "noDelete": {
+ "level": "warn"
+ }
+ },
+ "style": {
+ "noNonNullAssertion": {
+ "level": "off"
+ },
+ "useImportType": {
+ "level": "warn",
+ "options": {
+ "style": "separatedType"
+ }
+ },
+ "useNodejsImportProtocol": {
+ "level": "off"
+ },
+ "useTemplate": {
+ "level": "warn"
+ }
+ },
+ "suspicious": {
+ "noAssignInExpressions": {
+ "level": "off"
+ },
+ "noConfusingVoidType": {
+ "level": "off"
+ },
+ "noExplicitAny": {
+ "level": "off"
+ },
+ "useIterableCallbackReturn": {
+ "level": "off"
+ }
+ }
+ }
+ },
+ "vcs": {
+ "clientKind": "git",
+ "enabled": true,
+ "useIgnoreFile": true
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..11cd9e9
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,304 @@
+import com.android.build.api.dsl.LibraryExtension
+import com.android.build.api.variant.LibraryAndroidComponentsExtension
+import groovy.json.JsonSlurper
+import java.io.File
+import java.util.concurrent.Callable
+import javax.inject.Inject
+import org.gradle.process.ExecOperations
+import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
+
+plugins {
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.kotlin.android) apply false
+}
+
+val compileSdkVer = libs.versions.compileSdk.get()
+val minSdkVer = libs.versions.minSdk.get()
+val javaVer = libs.versions.javaVersion.get()
+val revengeApiDep = libs.revenge.api.get().toString()
+val xposedApiDep = libs.xposed.api.get().toString()
+val coroutinesDep = libs.kotlinx.coroutines.android.get().toString()
+val androidLibraryPluginId = libs.plugins.android.library.get().pluginId
+
+// Gradle 9 removed Project.exec from task actions
+interface ExecOpsProvider {
+ @get:Inject val execOps: ExecOperations
+}
+
+val rootExecOps = objects.newInstance().execOps
+
+// `./gradlew clean packageAllPlugins` also runs every `:plugins::clean`,
+// which would delete freshly built artifacts mid-build; the tasks below `mustRunAfter` these.
+val cleanTasks = Callable { rootProject.allprojects.mapNotNull { it.tasks.findByName("clean") } }
+
+// ---------------------------------------------------------------------------------------------
+// Plugins declared under plugins//manifest.json
+// ---------------------------------------------------------------------------------------------
+
+@Suppress("UNCHECKED_CAST")
+fun parseJson(file: File) = JsonSlurper().parse(file) as Map
+
+@Suppress("UNCHECKED_CAST")
+fun Map.obj(key: String) = this[key] as? Map
+
+fun Map.str(key: String) = this[key] as? String
+
+data class PluginDef(val dir: File, val id: String, val jarName: String?, val scriptName: String?)
+
+val pluginDefs = file("plugins").listFiles().orEmpty()
+ .filter(File::isDirectory)
+ .sortedBy(File::getName)
+ .mapNotNull { dir ->
+ val manifest = File(dir, "manifest.json").takeIf(File::isFile)?.let(::parseJson)
+ ?: return@mapNotNull null
+ val id = manifest.str("id") ?: return@mapNotNull null
+ val dist = manifest.obj("dist").orEmpty()
+ PluginDef(dir, id, dist.obj("android")?.str("path"), dist.str("script"))
+ }
+
+// ---------------------------------------------------------------------------------------------
+// JS toolchain discovery
+// ---------------------------------------------------------------------------------------------
+
+// Gradle's exec runs with a minimal, non-login PATH that usually omits user-level install dirs,
+// so the well-known locations are probed explicitly.
+data class JsTool(val kind: String, val exe: File) {
+ val installCommand: List
+ get() = when (kind) {
+ "bun" -> listOf(exe.absolutePath, "install", "--silent")
+ "npm" -> listOf(exe.absolutePath, "install", "--no-audit", "--no-fund", "--silent")
+ "deno" -> listOf(exe.absolutePath, "install", "--quiet")
+ else -> error("Unknown JS tool: $kind")
+ }
+
+ val buildCommand: List
+ get() = when (kind) {
+ "bun", "npm" -> listOf(exe.absolutePath, "run", "build")
+ // Deno creates no node_modules/.bin shims, so `deno task build` cannot resolve the CLI,
+ // so we run its bin file directly.
+ "deno" -> listOf(
+ exe.absolutePath, "run", "-A",
+ "node_modules/@revenge-mod/plugin-cli/bin/revenge-plugin.js", "build",
+ )
+ else -> error("Unknown JS tool: $kind")
+ }
+}
+
+fun findJsTool(): JsTool? {
+ val kindOverride = findProperty("jsTool") as String?
+ val pathOverride = (findProperty("jsToolPath") as String?) ?: System.getenv("BUN")
+ if (!pathOverride.isNullOrBlank()) {
+ val exe = File(pathOverride).takeIf(File::isFile) ?: return null
+ return JsTool(kindOverride ?: exe.name.substringBefore('.'), exe)
+ }
+
+ val homes = buildSet {
+ System.getProperty("user.home")?.let(::add)
+ System.getenv("HOME")?.let(::add)
+ System.getProperty("user.name")?.let { user ->
+ add("/home/$user")
+ add("/var/home/$user") // atomic/ostree distros: /home -> /var/home
+ }
+ }
+ val pathDirs = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter(String::isNotEmpty)
+
+ fun candidatesFor(kind: String): List = buildList {
+ pathDirs.forEach { add(File(it, kind)) }
+ when (kind) {
+ "bun" -> homes.forEach { add(File(it, ".bun/bin/bun")) }
+ // nvm keeps npm next to node, under versions/node//bin.
+ "npm" -> homes.forEach { home ->
+ File(home, ".nvm/versions/node").listFiles()
+ ?.sortedDescending()
+ ?.forEach { add(File(it, "bin/npm")) }
+ }
+ "deno" -> homes.forEach { add(File(it, ".deno/bin/deno")) }
+ }
+ add(File("/usr/local/bin/$kind"))
+ add(File("/opt/homebrew/bin/$kind"))
+ }
+
+ val kinds = kindOverride?.let(::listOf) ?: listOf("bun", "npm", "deno")
+ return kinds.firstNotNullOfOrNull { kind ->
+ candidatesFor(kind).firstOrNull(File::isFile)?.let { JsTool(kind, it) }
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Native plugin modules (:plugins:) are configured entirely from here,
+// so a native plugin needs nothing but a src/main folder and a manifest.json.
+// ---------------------------------------------------------------------------------------------
+
+configure(subprojects.filter { it.path.startsWith(":plugins:") }) {
+ // AGP 9 has built-in Kotlin support, so org.jetbrains.kotlin.android isn't applied.
+ pluginManager.apply(androidLibraryPluginId)
+
+ extensions.configure {
+ namespace = "revenge.plugin." + name.replace(Regex("[^A-Za-z0-9]"), "_")
+ compileSdk = compileSdkVer.toInt()
+ defaultConfig { minSdk = minSdkVer.toInt() }
+ compileOptions {
+ sourceCompatibility = JavaVersion.toVersion(javaVer)
+ targetCompatibility = JavaVersion.toVersion(javaVer)
+ }
+ sourceSets {
+ named("main") { kotlin.directories += "src/main/kotlin" }
+ }
+ }
+
+ extensions.configure {
+ jvmToolchain(javaVer.toInt())
+ }
+
+ // AGP's lint tasks require the module's build.gradle as an input, which these script-less modules don't have.
+ // There is nothing to lint here anyway.
+ tasks.configureEach {
+ if (name.contains("Lint") || name.startsWith("lint")) enabled = false
+ }
+
+ dependencies {
+ // Provided at runtime by the host, so never bundled
+ add("compileOnly", revengeApiDep)
+ add("compileOnly", xposedApiDep)
+ add("compileOnly", coroutinesDep)
+ }
+
+ val androidExt = extensions.getByType()
+ val sdkDirProvider = extensions.getByType().sdkComponents.sdkDirectory
+ val execOps = objects.newInstance().execOps
+
+ tasks.register("dexJar") {
+ group = "revenge"
+ description = "Compiles this plugin into a DEX-containing JAR loadable by DexClassLoader."
+ dependsOn("compileReleaseKotlin", "compileReleaseJavaWithJavac")
+ mustRunAfter(cleanTasks)
+
+ // Only this module's own classes are DEXed. Dependencies compileOnly.
+ val classDirs = files(
+ // AGP 9 built-in Kotlin output, then the pre-AGP 9 location
+ layout.buildDirectory.dir("intermediates/built_in_kotlinc/release/compileReleaseKotlin/classes"),
+ layout.buildDirectory.dir("tmp/kotlin-classes/release"),
+ layout.buildDirectory.dir("intermediates/javac/release/classes"),
+ )
+ inputs.files(classDirs).withPropertyName("classDirs").optional()
+
+ val outJar = layout.buildDirectory.file("outputs/plugin/plugin.jar")
+ outputs.file(outJar).withPropertyName("dexJar")
+
+ val buildToolsVersion = androidExt.buildToolsVersion
+
+ doLast {
+ val classFiles = classDirs.asFileTree.matching { include("**/*.class") }.files
+ if (classFiles.isEmpty()) error("No compiled classes found to dex in $path.")
+
+ val sdkDir = sdkDirProvider.get().asFile
+ // Use newest d8 as the AGP-default build-tools could ship one too old for class files from a newer toolchain
+ val d8 = File(sdkDir, "build-tools").listFiles()
+ ?.sortedByDescending(File::getName)
+ ?.map { File(it, "d8") }
+ ?.firstOrNull(File::isFile)
+ ?: file("$sdkDir/build-tools/$buildToolsVersion/d8")
+ val androidJar = file("$sdkDir/platforms/android-$compileSdkVer/android.jar")
+ require(d8.exists()) { "d8 not found at $d8" }
+ require(androidJar.exists()) { "android.jar not found at $androidJar" }
+
+ val out = outJar.get().asFile
+ out.parentFile.mkdirs()
+ execOps.exec {
+ commandLine(
+ d8.absolutePath,
+ "--min-api", minSdkVer,
+ "--lib", androidJar.absolutePath,
+ "--output", out.absolutePath,
+ *classFiles.map { it.absolutePath }.toTypedArray(),
+ )
+ }
+ logger.lifecycle("Dexed $path -> ${out.relativeTo(rootProject.projectDir)}")
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// JS bundles (all plugins at once) and per-plugin packaging into build/dist/.zip
+// ---------------------------------------------------------------------------------------------
+
+val buildJs = tasks.register("buildJs") {
+ group = "revenge"
+ description = "Builds every plugin's JS bundle via the revenge-plugin CLI."
+ mustRunAfter(cleanTasks)
+
+ // The bundler is @revenge-mod/plugin-cli, so the lockfiles (which pin its revision) are the inputs.
+ inputs.files(
+ "package.json", "tsconfig.json", "biome.json",
+ "bun.lock", "bun.lockb", "package-lock.json", "deno.lock",
+ ).withPropertyName("config")
+ // Everything a plugin's bundle can be built from.
+ inputs.files(fileTree("plugins") { exclude("*/build/**", "*/src/**") }).withPropertyName("sources")
+
+ pluginDefs.filter { it.scriptName != null }.forEach {
+ outputs.dir(File(it.dir, "build/js")).withPropertyName("bundle-${it.dir.name}")
+ }
+
+ doLast {
+ val tool = findJsTool()
+ if (tool == null) {
+ logger.warn(
+ "No JS toolchain found (bun, npm, deno). Install Node >= 22.18, Bun or Deno 2, " +
+ "or pass -PjsTool=/-PjsToolPath=. Skipping JS build.",
+ )
+ return@doLast
+ }
+
+ logger.lifecycle("Building JS bundles with ${tool.kind} (${tool.exe})")
+ // Put the tool's own dir on PATH so nested calls (npm -> node, script -> bin shims) resolve.
+ val path = tool.exe.parentFile.absolutePath + File.pathSeparator + System.getenv("PATH").orEmpty()
+ listOf(tool.installCommand, tool.buildCommand).forEach { cmd ->
+ rootExecOps.exec {
+ workingDir = rootDir
+ environment("PATH", path)
+ commandLine(cmd)
+ }
+ }
+ }
+}
+
+val packageAllPlugins = tasks.register("packageAllPlugins") {
+ group = "revenge"
+ description = "Builds and packages every plugin into build/dist/.zip."
+}
+
+fun taskSuffix(dirName: String): String =
+ dirName.split(Regex("[^A-Za-z0-9]")).filter(String::isNotEmpty)
+ .joinToString("") { it.replaceFirstChar(Char::uppercase) }
+
+pluginDefs.forEach { (dir, id, jarName, scriptName) ->
+ val pkg = tasks.register("package${taskSuffix(dir.name)}") {
+ group = "revenge"
+ description = "Packages '$id' into a distributable ZIP."
+ dependsOn(buildJs)
+ mustRunAfter(cleanTasks)
+
+ from(File(dir, "manifest.json"))
+
+ if (jarName != null && File(dir, "src/main").isDirectory) {
+ val nativePath = ":plugins:${dir.name}"
+ dependsOn("$nativePath:dexJar")
+ from(provider { project(nativePath).tasks.named("dexJar").get().outputs.files.singleFile }) {
+ rename { jarName }
+ }
+ }
+
+ if (scriptName != null) {
+ from(File(dir, "build/js")) {
+ include("index.js")
+ rename { scriptName }
+ }
+ }
+
+ archiveFileName.set("$id.zip")
+ destinationDirectory.set(layout.buildDirectory.dir("dist"))
+ doLast { logger.lifecycle("Packaged $id -> ${archiveFile.get().asFile.relativeTo(rootDir)}") }
+ }
+
+ packageAllPlugins.configure { dependsOn(pkg) }
+}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..9e772aa
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,4 @@
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.jvmargs=-Xmx2048m
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..2401af2
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,21 @@
+[versions]
+agp = "9.1.1"
+kotlin = "2.4.10"
+
+coroutines = "1.11.0"
+xposed = "82"
+revengeApi = "1.0.0"
+
+compileSdk = "36"
+minSdk = "24"
+targetSdk = "36"
+javaVersion = "25"
+
+[libraries]
+kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
+xposed-api = { module = "de.robv.android.xposed:api", version.ref = "xposed" }
+revenge-api = { module = "io.github.revenge:api", version.ref = "revengeApi" }
+
+[plugins]
+android-library = { id = "com.android.library", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100755
index 0000000..2c35211
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100755
index 0000000..a351597
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100755
index 0000000..9d21a21
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d4dcf0c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,42 @@
+{
+ "name": "revenge-next-plugs",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts build",
+ "build:dev": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts build --dev",
+ "generate-index": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts generate-index",
+ "serve": "bun node_modules/@revenge-mod/plugin-cli/src/main.ts serve",
+ "lint": "biome check",
+ "lint:types": "tsc --noEmit && tsc -p cli --noEmit"
+ },
+ "dependencies": {
+ "@revenge-mod/devtools-client": "^1.4.0",
+ "@revenge-mod/devtools-shared": "^1.3.0",
+ "@revenge-mod/plugin-cli": "git+https://github.com/revenge-mod/revenge-plugin-cli.git",
+ "@revenge-mod/types": "git+https://github.com/revenge-mod/revenge-types.git"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.5.6",
+ "@shopify/flash-list": "^2.3.2",
+ "@types/node": "^26.1.2",
+ "@types/react": "^19.2.17",
+ "react-native": "0.81.6",
+ "typescript": "^7.0.2"
+ },
+ "overrides": {
+ "@react-native/community-cli-plugin": "npm:dry-uninstall",
+ "@react-native/codegen": "npm:dry-uninstall",
+ "babel-jest": "npm:dry-uninstall",
+ "babel-plugin-syntax-hermes-parser": "npm:dry-uninstall",
+ "hermes-compiler": "npm:dry-uninstall",
+ "jsc-android": "npm:dry-uninstall",
+ "react-devtools-core": "npm:dry-uninstall",
+ "recyclerlistview": "npm:dry-uninstall",
+ "react-native-gesture-handler": "npm:dry-uninstall",
+ "react-native-screens": "npm:dry-uninstall"
+ },
+ "trustedDependencies": [
+ "@swc/core"
+ ]
+}
\ No newline at end of file
diff --git a/plugins/next-eval-checks/js/index.ts b/plugins/next-eval-checks/js/index.ts
new file mode 100644
index 0000000..ae44c9c
--- /dev/null
+++ b/plugins/next-eval-checks/js/index.ts
@@ -0,0 +1,429 @@
+import { getModules } from '@revenge-mod/modules/finders'
+import {
+ createFilterGenerator,
+ FilterFlag,
+ FilterScopes,
+ withName,
+ withProps,
+ withSingleProp,
+} from '@revenge-mod/modules/finders/filters'
+import { after, before, instead as insteadPatch } from '@revenge-mod/patcher'
+import { React, ReactNative } from '@revenge-mod/react'
+import { afterJSX } from '@revenge-mod/react/jsx-runtime'
+import { findInReactFiber } from '@revenge-mod/utils/react'
+
+/**
+ * One-shot diagnostic plugin for staff-tags' six live-verify assumptions. Logs results via
+ * Discord's own Logger (Settings > Debug Logs) as each check resolves - some resolve almost
+ * immediately (module lookups), some only once you navigate to the relevant screen
+ * (DisplayName/HeaderName needs a channel open, UserRow needs the member list open,
+ * getTagProperties needs a message with a bot/staff tag visible).
+ *
+ * All of the imports above are static and generate no code by themselves - the compiled bundle
+ * only touches `revenge.*` where these are actually *called*, and every call below happens
+ * inside `start()` or a callback it registers, never at module top level. Every reactive
+ * callback is also wrapped in `guard()`. Both are lessons from the staff-tags boot-crash history
+ * (project memory: revenge-next-plugin-patch-safety) - a diagnostic plugin is not exempt from
+ * either just because it's throwaway.
+ */
+
+function guard(fn: () => T, fallback: T): T {
+ try {
+ return fn()
+ } catch {
+ return fallback
+ }
+}
+
+function safeStringify(value: unknown): string {
+ return JSON.stringify(value, (_key, v) =>
+ typeof v === 'bigint' ? `${v}n` : v,
+ )
+}
+
+export default plugin({
+ start(api) {
+ const log = (label: string, data: unknown) => {
+ api.logger.log(`[EvalChecks] ${label}: ${safeStringify(data)}`)
+ }
+
+ function withTimeout(label: string, ms: number, found: { value: boolean }) {
+ setTimeout(() => {
+ if (!found.value)
+ log(label, { found: false, note: `not found within ${ms}ms` })
+ }, ms)
+ }
+
+ // 1. Permissions module
+ guard(() => {
+ const permFound = { value: false }
+ const permsMapFound = { value: false }
+
+ getModules(
+ withProps<{ computePermissions: unknown; canEveryoneRole: unknown }>(
+ 'computePermissions',
+ 'canEveryoneRole',
+ ),
+ mod => {
+ guard(() => {
+ permFound.value = true
+ log('permissionsModule', {
+ found: true,
+ keys: Object.keys(mod),
+ computePermissionsType: typeof mod.computePermissions,
+ })
+ }, undefined)
+ },
+ )
+ withTimeout('permissionsModule', 5000, permFound)
+
+ getModules(
+ withSingleProp<{ Permissions: Record }>('Permissions'),
+ mod => {
+ guard(() => {
+ permsMapFound.value = true
+ log('permissionsMap', {
+ found: true,
+ sampleKeys: Object.keys(mod.Permissions).slice(0, 10),
+ adminBitType: typeof mod.Permissions.ADMINISTRATOR,
+ })
+ }, undefined)
+ },
+ )
+ withTimeout('permissionsMap', 5000, permsMapFound)
+ }, undefined)
+
+ // 2. Tag module (default + getBotLabel)
+ guard(() => {
+ const found = { value: false }
+ getModules(
+ withProps<{ default: unknown; getBotLabel: unknown }>(
+ 'default',
+ 'getBotLabel',
+ ),
+ mod => {
+ guard(() => {
+ found.value = true
+ log('tagModule', {
+ found: true,
+ keys: Object.keys(mod),
+ defaultType: typeof mod.default,
+ defaultName: (mod.default as any)?.name,
+ getBotLabelType: typeof mod.getBotLabel,
+ })
+ }, undefined)
+ },
+ { skipDefault: true },
+ )
+ withTimeout('tagModule', 5000, found)
+ }, undefined)
+
+ // 3. Gradient component
+ guard(() => {
+ const startEndFound = { value: false }
+ const locationsFound = { value: false }
+
+ getModules(
+ withProps>('colors', 'start', 'end'),
+ mod => {
+ guard(() => {
+ startEndFound.value = true
+ log('gradientComponent.startEnd', {
+ found: true,
+ type: typeof mod,
+ name: (mod as any)?.name,
+ })
+ }, undefined)
+ },
+ )
+ withTimeout('gradientComponent.startEnd', 5000, startEndFound)
+
+ getModules(
+ withProps>('colors', 'locations'),
+ mod => {
+ guard(() => {
+ locationsFound.value = true
+ log('gradientComponent.locations', {
+ found: true,
+ type: typeof mod,
+ name: (mod as any)?.name,
+ })
+ }, undefined)
+ },
+ )
+ withTimeout('gradientComponent.locations', 5000, locationsFound)
+ }, undefined)
+
+ // 4. DisplayName / HeaderName - needs a channel with messages open
+ guard(() => {
+ const moduleFound = { value: false }
+ const renderSeen = { value: false }
+
+ getModules(withName('DisplayName'), DisplayName => {
+ guard(() => {
+ moduleFound.value = true
+ log('displayName.module', {
+ found: true,
+ fnName: (DisplayName as any)?.name,
+ })
+
+ let unpatchJSX = () => {}
+ unpatchJSX = afterJSX(DisplayName, el =>
+ guard(() => {
+ const unpatchType = after(el as any, 'type', ret =>
+ guard(() => {
+ unpatchType()
+ unpatchJSX()
+ renderSeen.value = true
+ const row = findInReactFiber(
+ ret as any,
+ ((c: any) =>
+ c?.props?.style?.flexDirection === 'row') as any,
+ )
+ log('displayName.render', {
+ found: true,
+ rowContainerSeen: !!row,
+ })
+ return ret
+ }, ret),
+ )
+ return el
+ }, el),
+ )
+ }, undefined)
+ })
+ withTimeout('displayName.module', 8000, moduleFound)
+ withTimeout('displayName.render', 15000, renderSeen)
+
+ const headerFound = { value: false }
+ getModules(withName('HeaderName'), HeaderName => {
+ guard(() => {
+ headerFound.value = true
+ log('headerName', { found: true, fnName: (HeaderName as any)?.name })
+ }, undefined)
+ })
+ withTimeout('headerName', 8000, headerFound)
+ }, undefined)
+
+ // 5. UserRow - needs the member list open
+ guard(() => {
+ let count = 0
+ const unsub = getModules(
+ withName('UserRow'),
+ (UserRow, id) => {
+ guard(() => {
+ count++
+ log('userRow', {
+ found: true,
+ index: count,
+ id,
+ fnName: (UserRow as any)?.name,
+ })
+ }, undefined)
+ },
+ { max: 10 },
+ )
+ setTimeout(() => {
+ unsub()
+ if (count === 0)
+ log('userRow', { found: false, note: 'not found within 15000ms' })
+ }, 15000)
+ }, undefined)
+
+ // 6. getTagProperties - needs a message with a bot/staff tag visible
+ guard(() => {
+ const found = { value: false }
+ getModules(
+ withName('getTagProperties'),
+ mod => {
+ guard(() => {
+ const namespace = mod as { default?: (args: any) => any }
+ if (!namespace.default) return
+
+ const unpatch = insteadPatch(
+ namespace as { default: (args: any) => any },
+ 'default',
+ ([args], original) => {
+ const ret = original(args)
+ return guard(() => {
+ if (!found.value) {
+ found.value = true
+ unpatch()
+ log('getTagProperties', {
+ found: true,
+ argKeys: Object.keys(args ?? {}),
+ messageKeys: args?.message
+ ? Object.keys(args.message)
+ : null,
+ })
+ }
+ return ret
+ }, ret)
+ },
+ )
+ }, undefined)
+ },
+ { returnNamespace: true },
+ )
+ withTimeout('getTagProperties', 15000, found)
+ }, undefined)
+
+ // 7. Name sweep - DisplayName/HeaderName/UserRow are confirmed gone under those exact
+ // names (Discord renamed/refactored them at some point), so instead of guessing single
+ // replacement names one at a time, this sweeps every currently-initialized module for an
+ // exported function whose .name matches a broad pattern, logging every match found. Real
+ // candidates for the message-row name, channel header, and member-list row should show up
+ // here - keep the member list and a channel with messages open for this one.
+ guard(() => {
+ const pattern = /Name|Row|Header|Author|Member|Username|Tag$/i
+
+ const withNameMatching = createFilterGenerator<[]>(
+ (_args, _id, exports) => {
+ const name = (exports as any)?.name
+ return typeof name === 'string' && pattern.test(name)
+ },
+ () => 'evalChecks.nameSweep',
+ FilterFlag.RequiresExports,
+ FilterScopes.Initialized,
+ )
+
+ let count = 0
+ getModules(
+ withNameMatching(),
+ (mod, id) => {
+ guard(() => {
+ count++
+ log('nameSweep', {
+ index: count,
+ id,
+ name: (mod as any)?.name,
+ type: typeof mod,
+ })
+ }, undefined)
+ },
+ { max: 80 },
+ )
+ setTimeout(() => {
+ log('nameSweep.summary', { totalMatches: count })
+ }, 20000)
+ }, undefined)
+
+ // 8. React.memo/forwardRef sweep - name-based search (checks 4/5/7) can never find a
+ // React.memo()-wrapped or forwardRef()-wrapped component, because the wrapper object has
+ // no .name of its own. This patches React.memo/forwardRef themselves so the inner named
+ // function's real name is captured at the moment it's wrapped, before that name is lost.
+ // Only catches memo()/forwardRef() calls that happen AFTER this patch is installed - if
+ // the target component's module already ran before this plugin's start(), it's missed.
+ // For a real answer, force-quit and reopen the app with this plugin already enabled,
+ // rather than just backgrounding/foregrounding it.
+ guard(() => {
+ const pattern =
+ /Name|Row|Header|Author|Member|Username|Tag|Display|Nameplate/i
+ const seen = new Set()
+
+ const report = (kind: string, name: string | undefined) => {
+ if (name && pattern.test(name) && !seen.has(name)) {
+ seen.add(name)
+ log('memoSweep', { kind, name })
+ }
+ }
+
+ if (typeof React?.memo === 'function') {
+ before(React as any, 'memo', args => {
+ guard(() => {
+ report('memo', (args?.[0] as any)?.name)
+ }, undefined)
+ return args
+ })
+ } else {
+ log('memoSweep.setup', { error: 'React.memo not available yet' })
+ }
+
+ if (typeof React?.forwardRef === 'function') {
+ before(React as any, 'forwardRef', args => {
+ guard(() => {
+ report('forwardRef', (args?.[0] as any)?.name)
+ }, undefined)
+ return args
+ })
+ } else {
+ log('memoSweep.setup', { error: 'React.forwardRef not available yet' })
+ }
+
+ setTimeout(() => {
+ log('memoSweep.summary', { totalMatches: seen.size })
+ }, 20000)
+ }, undefined)
+
+ // 9. Message-author-name discovery via Text-content correlation. Neither name search nor
+ // the memo/forwardRef sweep found anything for the chat message's author name row, which
+ // suggests it isn't a separately-named component at all anymore (likely inlined into a
+ // bigger message-row component). Instead of searching by name, this grabs a real author
+ // name off a live message (piggybacking on the already-confirmed getTagProperties hook),
+ // then watches every element created for one whose content matches that name. When
+ // found, it captures a JS stack trace at that exact moment - since createElement calls run
+ // synchronously inside the calling component's own function body, the stack should reveal
+ // the enclosing component's name even though it isn't independently searchable.
+ guard(() => {
+ // Keeps updating to the MOST RECENT message's author rather than locking onto the
+ // first one seen - a cached/already-rendered message's author is useless as a search
+ // target (its Text was created before this patch installed, same issue as the memo
+ // sweep), so the target has to track whatever message is *about to* freshly render.
+ let authorName: string | undefined
+ let matchCount = 0
+
+ getModules(
+ withName<(args: any) => any>('getTagProperties'),
+ mod => {
+ guard(() => {
+ const namespace = mod as { default?: (args: any) => any }
+ if (!namespace.default) return
+
+ insteadPatch(namespace as { default: (args: any) => any }, 'default', ([args], original) => {
+ const ret = original(args)
+ guard(() => {
+ const author = args?.message?.author
+ const name = author?.globalName || author?.username || author?.nick
+ if (typeof name === 'string' && name.length > 0 && name !== authorName) {
+ authorName = name
+ log('textSweep.authorTarget', { authorName })
+ }
+ }, undefined)
+ return ret
+ })
+ }, undefined)
+ },
+ { returnNamespace: true }
+ )
+
+ if (!ReactNative?.Text) {
+ log('textSweep.setup', { error: 'ReactNative.Text not available yet' })
+ } else {
+ afterJSX(ReactNative.Text, el =>
+ guard(() => {
+ if (!authorName || matchCount >= 3) return el
+
+ const children = (el.props as any)?.children
+ const isMatch = children === authorName || (typeof children === 'string' && children.includes(authorName)) || (Array.isArray(children) && children.some((c: any) => c === authorName))
+
+ if (isMatch) {
+ matchCount++
+ const stack = new Error().stack ?? ''
+ log('textSweep.match', {
+ authorName,
+ matchCount,
+ stackLines: stack.split('\n').slice(0, 12)
+ })
+ }
+
+ return el
+ }, el)
+ )
+ }
+
+ setTimeout(() => {
+ log('textSweep.summary', { matchCount, lastAuthorName: authorName })
+ }, 45000)
+ }, undefined)
+ },
+})
diff --git a/plugins/next-eval-checks/manifest.json b/plugins/next-eval-checks/manifest.json
new file mode 100644
index 0000000..c0a72cf
--- /dev/null
+++ b/plugins/next-eval-checks/manifest.json
@@ -0,0 +1,19 @@
+{
+ "format": 1,
+ "id": "dev.everestmcarthur.next-eval-checks",
+ "name": "Next Eval Checks",
+ "description": "One-shot diagnostic plugin for staff-tags' live-verify assumptions. Logs results to Settings > Debug Logs, then you can uninstall it.",
+ "author": "everestmcarthur",
+ "version": "1.0.4",
+ "dependencies": {
+ "revenge.api": {
+ "version": ">=1 <2"
+ },
+ "discord": {
+ "version": "*"
+ }
+ },
+ "dist": {
+ "script": "index.js"
+ }
+}
diff --git a/plugins/staff-tags/js/index.ts b/plugins/staff-tags/js/index.ts
new file mode 100644
index 0000000..66bf4ad
--- /dev/null
+++ b/plugins/staff-tags/js/index.ts
@@ -0,0 +1,27 @@
+import { applyPatches } from './lib/applyPatches'
+import patchChat from './patches/chat'
+import patchDetails from './patches/details'
+import patchName from './patches/name'
+import patchTag from './patches/tag'
+import Settings from './ui/pages/Settings'
+import type { StaffTagsStorage } from './lib/types'
+
+export default plugin<{ jsonStorage: StaffTagsStorage }>({
+ jsonStorage: {
+ load: true,
+ default: { useRoleColor: false, tags: {} },
+ },
+
+ start(api) {
+ const unpatchAll = applyPatches('Staff Tags', api.logger, {
+ tag: patchTag,
+ chat: () => patchChat(api.jsonStorage),
+ name: () => patchName(api.jsonStorage),
+ details: () => patchDetails(api.jsonStorage),
+ })
+
+ api.cleanup(unpatchAll)
+ },
+
+ SettingsComponent: Settings,
+})
diff --git a/plugins/staff-tags/js/lib/afterRendered.ts b/plugins/staff-tags/js/lib/afterRendered.ts
new file mode 100644
index 0000000..c4b1039
--- /dev/null
+++ b/plugins/staff-tags/js/lib/afterRendered.ts
@@ -0,0 +1,18 @@
+import { after } from '@revenge-mod/patcher'
+import type { FC, ReactElement, ReactNode } from 'react'
+
+/**
+ * Hooks the render output of a *specific element instance* by patching its own `type` property.
+ *
+ * This is safe (unlike patching a component's module export) because `element.type` is a fresh
+ * reference on an object we just created ourselves - nothing else has captured it yet, so there's
+ * no same-chunk-destructuring staleness to worry about. See @revenge-mod/patcher's `after` docs
+ * and the pattern used by Revenge's own `user-badges` plugin (`afterRendered` there).
+ */
+export const afterRendered = (
+ element: ReactElement>,
+ hook: (el: ReactNode) => ReactNode,
+) =>
+ after(element, 'type', el =>
+ el instanceof Promise ? el.then(hook) : hook(el),
+ )
diff --git a/plugins/staff-tags/js/lib/applyPatches.ts b/plugins/staff-tags/js/lib/applyPatches.ts
new file mode 100644
index 0000000..5cf2a1d
--- /dev/null
+++ b/plugins/staff-tags/js/lib/applyPatches.ts
@@ -0,0 +1,35 @@
+export interface PluginLogger {
+ error(message: string): void
+}
+
+/**
+ * Wraps a patch-application function so a single broken lookup only disables that one surface
+ * instead of crashing the whole plugin's `start()` and taking every other patch down with it.
+ */
+function safePatch(
+ pluginName: string,
+ patchName: string,
+ apply: () => () => unknown,
+ logger: PluginLogger,
+): () => unknown {
+ try {
+ return apply()
+ } catch (e) {
+ logger.error(
+ `[${pluginName}] Failed to apply the "${patchName}" patch, that surface will be skipped: ${e}`,
+ )
+ return () => {}
+ }
+}
+
+/** Applies a named set of patches with safePatch and returns one combined unpatch function. */
+export function applyPatches(
+ pluginName: string,
+ logger: PluginLogger,
+ patches: Record () => unknown>,
+): () => void {
+ const unpatches = Object.entries(patches).map(([name, apply]) =>
+ safePatch(pluginName, name, apply, logger),
+ )
+ return () => unpatches.forEach(unpatch => unpatch())
+}
diff --git a/plugins/staff-tags/js/lib/color.ts b/plugins/staff-tags/js/lib/color.ts
new file mode 100644
index 0000000..9fee18c
--- /dev/null
+++ b/plugins/staff-tags/js/lib/color.ts
@@ -0,0 +1,64 @@
+export const HEX_REGEX = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
+
+export function isValidHex(value: string | undefined | null): value is string {
+ return !!value && HEX_REGEX.test(value)
+}
+
+/** Expands short form #abc to #aabbcc; leaves 6-digit hex untouched. */
+export function normalizeHex(hex: string): string {
+ if (hex.length === 4) {
+ return `#${hex
+ .slice(1)
+ .split('')
+ .map(c => c + c)
+ .join('')}`
+ }
+ return hex
+}
+
+function hexToRgb(hex: string): number[] {
+ return (normalizeHex(hex).match(/\w\w/g) ?? ['00', '00', '00']).map(x =>
+ Number.parseInt(x, 16),
+ )
+}
+
+function rgbToHex(rgb: number[]): string {
+ return `#${rgb.map(x => Math.max(0, Math.min(255, x)).toString(16).padStart(2, '0')).join('')}`
+}
+
+export function interpolateColor(
+ color1: string,
+ color2: string,
+ percentage: number,
+): string {
+ const rgb1 = hexToRgb(color1)
+ const rgb2 = hexToRgb(color2)
+ const interpolated = rgb1.map((c1, i) =>
+ Math.round(c1 + (rgb2[i] - c1) * percentage),
+ )
+
+ return rgbToHex(interpolated)
+}
+
+/** Mixes a color toward white by `amount` (0-1). Stands in for chroma's `.brighten()` without a chroma-js module lookup. */
+export function brighten(hex: string, amount: number): string {
+ return interpolateColor(hex, '#FFFFFF', Math.max(0, Math.min(1, amount)))
+}
+
+/**
+ * WCAG relative luminance (0 = black, 1 = white). Used to pick readable text color against a
+ * background - stands in for chroma's `.get('lab.l')` threshold without a chroma-js module lookup.
+ */
+export function relativeLuminance(hex: string): number {
+ const [r, g, b] = hexToRgb(hex).map(c => {
+ const s = c / 255
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
+ })
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+}
+
+/** Hardcoded Discord raw palette values (not exposed anywhere in @revenge-mod/types yet). */
+export const rawColors = {
+ WHITE_500: '#FFFFFF',
+ BLACK_500: '#1E1F22',
+}
diff --git a/plugins/staff-tags/js/lib/fiber.ts b/plugins/staff-tags/js/lib/fiber.ts
new file mode 100644
index 0000000..33a8b5e
--- /dev/null
+++ b/plugins/staff-tags/js/lib/fiber.ts
@@ -0,0 +1,10 @@
+/**
+ * `findInReactFiber` only narrows its result when given a type-predicate function
+ * (`(node) => node is X`). These walks are over untyped, Discord-internal fiber/element shapes,
+ * so there's no real `X` to narrow to - this just satisfies that signature with `any`.
+ */
+export function fiberFilter(
+ predicate: (node: any) => boolean,
+): (node: any) => node is any {
+ return predicate as (node: any) => node is any
+}
diff --git a/plugins/staff-tags/js/lib/getTag.ts b/plugins/staff-tags/js/lib/getTag.ts
new file mode 100644
index 0000000..77a9c97
--- /dev/null
+++ b/plugins/staff-tags/js/lib/getTag.ts
@@ -0,0 +1,245 @@
+import { Stores } from '@revenge-mod/discord/flux'
+import { getModules } from '@revenge-mod/modules/finders'
+import { withProps, withSingleProp } from '@revenge-mod/modules/finders/filters'
+import { brighten, isValidHex, rawColors, relativeLuminance } from './color'
+import type { DiscordModules } from '@revenge-mod/discord/types'
+import type { JsonStorage } from '@revenge-mod/json-storage'
+import type { StaffTagsStorage, TagOverride } from './types'
+
+interface PermissionsModule {
+ computePermissions(args: {
+ user: unknown
+ context: unknown
+ overwrites?: unknown
+ }): bigint | number | undefined
+ canEveryoneRole?: unknown
+}
+
+interface GuildMember {
+ colorString?: string
+}
+
+type GuildMemberStoreType = DiscordModules.Flux.Store<{
+ getMember(
+ guildId: string | undefined,
+ userId: string | undefined,
+ ): GuildMember | null
+}>
+
+/**
+ * Reads `Stores.GuildMemberStore` fresh on each call rather than caching it at module scope.
+ *
+ * External plugin bundles run via a bare `new Function('revenge', 'plugin', script)` call with
+ * no Metro-style dependency ordering, at preInit, regardless of which lifecycle the plugin
+ * actually declares - so touching *any* `revenge.*` API at module top level runs before
+ * Revenge's own API-providing plugins are guaranteed to have set anything up. This function is
+ * only ever called from inside a render patch, well after boot, where that's no longer a risk.
+ */
+function getGuildMemberStore(): GuildMemberStoreType {
+ return Stores.GuildMemberStore as GuildMemberStoreType
+}
+
+/**
+ * Subscribed once, lazily, from `start()` rather than at plugin-module import time. Uses
+ * `getModules` (reacts once Discord itself initializes the module) instead of `lookupModule`
+ * (which force-initializes uninitialized modules right away) - `getTag()` only runs from inside
+ * a render patch, well after boot, so this is lower-risk than the boot-time lookups elsewhere in
+ * this plugin, but there's no reason to force it either. If the module isn't warmed up yet on the
+ * very first call, permission-based tags just don't resolve for that call and pick up on the next
+ * render once it's available.
+ *
+ * TODO(live-verify): computePermissions/Permissions module shapes, see eval-for-revenge.
+ */
+let permissionsModule: PermissionsModule | undefined
+let PermissionsMap: { Permissions: Record } | undefined
+let permissionsSubscribed = false
+
+function resolvePermissionsModules(): void {
+ if (permissionsSubscribed) return
+ permissionsSubscribed = true
+ getModules(
+ withProps('computePermissions', 'canEveryoneRole'),
+ mod => {
+ permissionsModule = mod
+ },
+ )
+ getModules(
+ withSingleProp<{ Permissions: Record }>(
+ 'Permissions',
+ ),
+ mod => {
+ PermissionsMap = mod
+ },
+ )
+}
+
+export interface TagDefinition {
+ id: string
+ defaultText: string
+ defaultColor: string
+ condition?: (guild: any, channel: any, user: any) => boolean
+ permissions?: string[]
+}
+
+export const TAG_DEFINITIONS: TagDefinition[] = [
+ {
+ id: 'webhook',
+ defaultText: 'WEBHOOK',
+ defaultColor: '#99AAB5',
+ condition: (_g, _c, user) => !!user?.isNonUserBot?.(),
+ },
+ {
+ id: 'owner',
+ defaultText: 'OWNER',
+ defaultColor: '#F0B232',
+ condition: (guild, _c, user) => !!guild && guild.ownerId === user?.id,
+ },
+ {
+ id: 'admin',
+ defaultText: 'ADMIN',
+ defaultColor: '#F23F42',
+ permissions: ['ADMINISTRATOR'],
+ },
+ {
+ id: 'staff',
+ defaultText: 'STAFF',
+ defaultColor: '#23A55A',
+ permissions: [
+ 'MANAGE_GUILD',
+ 'MANAGE_CHANNELS',
+ 'MANAGE_ROLES',
+ 'MANAGE_WEBHOOKS',
+ ],
+ },
+ {
+ id: 'mod',
+ defaultText: 'MOD',
+ defaultColor: '#5865F2',
+ permissions: ['MANAGE_MESSAGES', 'KICK_MEMBERS', 'BAN_MEMBERS'],
+ },
+ {
+ id: 'vc_mod',
+ defaultText: 'VC Mod',
+ defaultColor: '#1ABC9C',
+ permissions: ['MOVE_MEMBERS', 'MUTE_MEMBERS', 'DEAFEN_MEMBERS'],
+ },
+ {
+ id: 'chat_mod',
+ defaultText: 'Chat Mod',
+ defaultColor: '#9B59B6',
+ permissions: ['MODERATE_MEMBERS'],
+ },
+]
+
+export interface ResolvedTag {
+ id: string
+ text: string
+ textColor: string
+ backgroundColor: string
+ gradientColor?: string
+ verified: boolean
+}
+
+/** Reads the per-tag settings override from the (already-loaded) storage cache. Never mutates it - use `storage.set()` for writes. */
+export function tagSettings(
+ storage: JsonStorage,
+ id: string,
+): TagOverride {
+ return storage.cache?.tags?.[id] ?? {}
+}
+
+function resolveBackgroundColor(
+ def: TagDefinition,
+ settings: TagOverride,
+ storage: JsonStorage,
+ guild: any,
+ user: any,
+): string {
+ if (settings.useCustomColor && isValidHex(settings.color)) {
+ return settings.color
+ }
+
+ if (storage.cache?.useRoleColor) {
+ try {
+ const roleColor = getGuildMemberStore()?.getMember?.(
+ guild?.id,
+ user?.id,
+ )?.colorString
+ if (roleColor) return roleColor
+ } catch {
+ // fall through to default
+ }
+ }
+
+ return def.defaultColor
+}
+
+export default function getTag(
+ storage: JsonStorage,
+ guild: any,
+ channel: any,
+ user: any,
+): ResolvedTag | undefined {
+ if (!user) return undefined
+
+ resolvePermissionsModules()
+
+ let permissions: string[] = []
+ if (guild) {
+ try {
+ const permissionsInt = permissionsModule?.computePermissions?.({
+ user,
+ context: guild,
+ overwrites: channel?.permissionOverwrites,
+ })
+
+ if (permissionsInt != null && PermissionsMap?.Permissions) {
+ permissions = Object.entries(PermissionsMap.Permissions)
+ .filter(([, bit]) => (permissionsInt as bigint) & (bit as bigint))
+ .map(([name]) => name)
+ }
+ } catch {
+ // no guild permission context available, treat as none
+ }
+ }
+
+ for (const def of TAG_DEFINITIONS) {
+ const settings = tagSettings(storage, def.id)
+ if (settings.enabled === false) continue
+
+ const matchesCondition = !!def.condition?.(guild, channel, user)
+ const matchesPermission =
+ !user.bot && !!def.permissions?.some(p => permissions.includes(p))
+ if (!matchesCondition && !matchesPermission) continue
+
+ const backgroundColor = resolveBackgroundColor(
+ def,
+ settings,
+ storage,
+ guild,
+ user,
+ )
+ const textColor =
+ relativeLuminance(backgroundColor) > 0.35
+ ? rawColors.BLACK_500
+ : rawColors.WHITE_500
+
+ let gradientColor: string | undefined
+ if (settings.useGradient) {
+ gradientColor = isValidHex(settings.gradientColor)
+ ? settings.gradientColor
+ : brighten(backgroundColor, 0.4)
+ }
+
+ return {
+ id: def.id,
+ text: settings.text?.trim() || def.defaultText,
+ textColor,
+ backgroundColor,
+ gradientColor,
+ verified: false,
+ }
+ }
+
+ return undefined
+}
diff --git a/plugins/staff-tags/js/lib/gradient.ts b/plugins/staff-tags/js/lib/gradient.ts
new file mode 100644
index 0000000..c777c1e
--- /dev/null
+++ b/plugins/staff-tags/js/lib/gradient.ts
@@ -0,0 +1,53 @@
+import { lookupModule } from '@revenge-mod/modules/finders'
+import { withProps } from '@revenge-mod/modules/finders/filters'
+import type { FC, ReactNode } from 'react'
+import type { StyleProp, ViewStyle } from 'react-native'
+
+interface GradientProps {
+ colors: string[]
+ start?: { x: number; y: number }
+ end?: { x: number; y: number }
+ style?: StyleProp
+ children?: ReactNode
+}
+
+let cached: FC | null | undefined
+let attempted = false
+
+/**
+ * Discord ships a native gradient view (used for boost/nitro badges) but it isn't part of any
+ * stable public API, so this is a best-effort lookup that quietly returns null if it can't be
+ * found - gradient tags then fall back to a solid color instead of crashing anything.
+ *
+ * `withProps` checks the default export automatically and unwraps to it, mirroring the old
+ * `findByProps(...).default` lookup from the Classic build.
+ *
+ * Deliberately still uses `lookupModule` (which force-initializes uninitialized modules) rather
+ * than `getModules` here, unlike the other lookups in this plugin: this only ever runs from
+ * inside `GradientTag`'s render, which only happens post-boot when a gradient tag is actually
+ * about to be drawn on screen, and the call site needs a component reference synchronously to
+ * decide what JSX to return - `getModules`' callback-based "maybe later" shape doesn't fit here.
+ *
+ * TODO(live-verify): unconfirmed against the current Next/Discord build, see eval-for-revenge.
+ */
+export function getGradientComponent(): FC | null {
+ if (attempted) return cached ?? null
+ attempted = true
+
+ try {
+ const [startEndMatch] = lookupModule(
+ withProps>('colors', 'start', 'end'),
+ )
+ const [locationsMatch] = lookupModule(
+ withProps>('colors', 'locations'),
+ )
+ cached =
+ (startEndMatch as FC | undefined) ??
+ (locationsMatch as FC | undefined) ??
+ null
+ } catch {
+ cached = null
+ }
+
+ return cached
+}
diff --git a/plugins/staff-tags/js/lib/safe.ts b/plugins/staff-tags/js/lib/safe.ts
new file mode 100644
index 0000000..3c91a53
--- /dev/null
+++ b/plugins/staff-tags/js/lib/safe.ts
@@ -0,0 +1,31 @@
+/**
+ * Runs `fn` and swallows any throw, returning `fallback` instead.
+ *
+ * `applyPatches`/`safePatch` only guard the synchronous call that *registers* a patch (e.g.
+ * `getModules(filter, cb)` itself). The `cb` runs later - whenever Discord happens to initialize
+ * a matching module, which can be deep inside Discord's own boot sequence - completely outside
+ * that try/catch. An uncaught throw there crashes app startup instead of just skipping a tag.
+ * Every render-triggered callback (getModules/afterJSX/beforeJSX/afterRendered) needs its own
+ * guard for this reason.
+ */
+export function guard(fn: () => T, fallback: T): T {
+ try {
+ return fn()
+ } catch {
+ return fallback
+ }
+}
+
+/**
+ * Adds a style object to an existing `style` prop without assuming its shape. RN style props are
+ * commonly a single object (not an array) - spreading a non-array/non-iterable value with `[...x]`
+ * throws, which is what crashed app boot the first time this shipped.
+ */
+export function mergeStyle(
+ style: unknown,
+ patch: Record,
+): unknown[] {
+ if (Array.isArray(style)) return [...style, patch]
+ if (style) return [style, patch]
+ return [patch]
+}
diff --git a/plugins/staff-tags/js/lib/types.ts b/plugins/staff-tags/js/lib/types.ts
new file mode 100644
index 0000000..4af4dd9
--- /dev/null
+++ b/plugins/staff-tags/js/lib/types.ts
@@ -0,0 +1,13 @@
+export interface TagOverride {
+ enabled?: boolean
+ text?: string
+ useCustomColor?: boolean
+ color?: string
+ useGradient?: boolean
+ gradientColor?: string
+}
+
+export interface StaffTagsStorage {
+ useRoleColor: boolean
+ tags: Record
+}
diff --git a/plugins/staff-tags/js/patches/chat.ts b/plugins/staff-tags/js/patches/chat.ts
new file mode 100644
index 0000000..6bba9c3
--- /dev/null
+++ b/plugins/staff-tags/js/patches/chat.ts
@@ -0,0 +1,103 @@
+import { Stores } from '@revenge-mod/discord/flux'
+import { getModules } from '@revenge-mod/modules/finders'
+import { withName } from '@revenge-mod/modules/finders/filters'
+import { instead } from '@revenge-mod/patcher'
+import { processColor } from 'react-native'
+import getTag from '../lib/getTag'
+import { guard } from '../lib/safe'
+import type { JsonStorage } from '@revenge-mod/json-storage'
+import type { StaffTagsStorage } from '../lib/types'
+
+interface GetTagPropertiesArgs {
+ message: { author?: any }
+ channel?: { guild_id?: string }
+}
+
+interface TagProperties {
+ tagType?: unknown
+ tagText?: string
+ tagTextColor?: unknown
+ tagBackgroundColor?: unknown
+ tagVerified?: boolean
+}
+
+/**
+ * Chat message tags are rendered from plain data (not a patchable element tree), so gradients
+ * aren't possible here - only in the member list and profile, where we control the JSX directly.
+ *
+ * `getTagProperties` is a plain data function, not a rendered component, so `afterJSX` doesn't
+ * apply - this stays a module-property patch via `patcher.instead`, same mechanism the Classic
+ * build used successfully for this exact function.
+ *
+ * Uses `getModules` (reacts once Discord itself initializes the module) instead of
+ * `lookupModule` (which force-initializes uninitialized modules right away) - forcing this
+ * module to init during `start()`, before Discord is done booting, is what crashed app startup
+ * the first time this shipped.
+ *
+ * `{ returnNamespace: true }` asks for the whole module object (not just the unwrapped default
+ * export) so `instead` has a `parent`/`key` pair to patch in place.
+ *
+ * The `instead` hook is guarded: unlike the JSX patches, it *replaces* the original function for
+ * every future call, so an uncaught throw here wouldn't just skip one tag - it would break
+ * message rendering entirely from that point on.
+ *
+ * Confirmed live (see /root/evals-for-rn): args are `{message, isSystemDM, channel, colors}` -
+ * `channel` is handed to us directly, so no `ChannelStore.getChannel()` lookup is needed.
+ */
+export default function patchChat(storage: JsonStorage) {
+ let unpatch = () => {}
+
+ const GuildStore = Stores.GuildStore as unknown as {
+ getGuild(id: string | undefined): any
+ }
+
+ const unsub = getModules(
+ withName<(args: GetTagPropertiesArgs) => TagProperties>('getTagProperties'),
+ TagPropertiesModule => {
+ guard(() => {
+ const mod = TagPropertiesModule as unknown as {
+ default?: (args: GetTagPropertiesArgs) => TagProperties
+ }
+ if (!mod.default) return
+
+ unpatch = instead(
+ mod as { default: (args: GetTagPropertiesArgs) => TagProperties },
+ 'default',
+ ([args], original) => {
+ // Call through unguarded - if Discord's own function throws, that's not
+ // something we caused or can meaningfully recover from here.
+ const ret = original(args)
+
+ // Only our own augmentation is guarded: fall back to the real, unmodified
+ // result rather than risk breaking every message render from here on.
+ return guard(() => {
+ if (ret?.tagType) return ret
+
+ const { message, channel } = args
+ const guild = GuildStore?.getGuild(channel?.guild_id)
+ const tag = getTag(storage, guild, channel, message?.author)
+ if (!tag) return ret
+
+ return {
+ ...ret,
+ tagText: tag.text,
+ tagTextColor: tag.textColor
+ ? processColor(tag.textColor)
+ : undefined,
+ tagBackgroundColor: processColor(tag.backgroundColor),
+ tagVerified: tag.verified,
+ tagType: undefined,
+ }
+ }, ret)
+ },
+ )
+ }, undefined)
+ },
+ { returnNamespace: true },
+ )
+
+ return () => {
+ unsub()
+ unpatch()
+ }
+}
diff --git a/plugins/staff-tags/js/patches/details.tsx b/plugins/staff-tags/js/patches/details.tsx
new file mode 100644
index 0000000..4aa2c81
--- /dev/null
+++ b/plugins/staff-tags/js/patches/details.tsx
@@ -0,0 +1,153 @@
+import { Stores } from '@revenge-mod/discord/flux'
+import { getModules } from '@revenge-mod/modules/finders'
+import { withName, withProps } from '@revenge-mod/modules/finders/filters'
+import { afterJSX } from '@revenge-mod/react/jsx-runtime'
+import { findInReactFiber } from '@revenge-mod/utils/react'
+import { afterRendered } from '../lib/afterRendered'
+import { fiberFilter } from '../lib/fiber'
+import getTag from '../lib/getTag'
+import { guard } from '../lib/safe'
+import GradientTag from '../ui/GradientTag'
+import type { JsonStorage } from '@revenge-mod/json-storage'
+import type { FC } from 'react'
+import type { StaffTagsStorage } from '../lib/types'
+
+interface UserRowProps {
+ guildId?: string
+ user: any
+}
+
+interface TagComponentProps {
+ type: number
+ text: string
+ textColor: string
+ backgroundColor: string
+ verified: boolean
+}
+
+/**
+ * Covers member list rows and the profile popout, where the name and tag sit on the same line.
+ *
+ * Multiple distinct `UserRow` implementations can exist in the same build (member list vs.
+ * profile popout, etc.), so this subscribes rather than doing a single lookup - `max` is a guess
+ * and may need adjusting once verified live, see eval-for-revenge.
+ *
+ * Uses `getModules` (reacts once Discord itself initializes each module) instead of
+ * `lookupModule` (which force-initializes uninitialized modules right away) - forcing UserRow /
+ * the Tag module to init during `start()`, before Discord is done booting, is what crashed app
+ * startup the first time this shipped.
+ *
+ * Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
+ * initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
+ * synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping
+ * one row.
+ *
+ * TODO(live-verify): UserRow component name/count, rendered-tree shape (`c?.type?.Types`), see
+ * eval-for-revenge.
+ */
+export default function patchDetails(storage: JsonStorage) {
+ const patches: (() => void)[] = []
+
+ let TagModule: { default?: FC } | undefined
+ const unsubTagModule = getModules(
+ withProps('default', 'getBotLabel'),
+ mod => {
+ TagModule = mod as { default?: FC }
+ },
+ { skipDefault: true },
+ )
+ patches.push(unsubTagModule)
+
+ const GuildStore = Stores.GuildStore as unknown as {
+ getGuild(id: string | undefined): any
+ }
+
+ const unsubUserRow = getModules(
+ withName>('UserRow'),
+ UserRow => {
+ guard(() => {
+ patches.push(
+ afterJSX(UserRow, el =>
+ guard(() => {
+ const { guildId, user } = el.props
+ const unpatch = afterRendered(el, ret =>
+ guard(() => {
+ unpatch()
+
+ const label = findInReactFiber(
+ ret as any,
+ fiberFilter(
+ c =>
+ Array.isArray(c?.props?.children) &&
+ c.props.children.some(
+ (ch: any) =>
+ typeof ch === 'string' ||
+ typeof ch?.props?.children === 'string',
+ ),
+ ),
+ )
+ if (!label) return ret
+
+ const existingTag = findInReactFiber(
+ label as any,
+ fiberFilter(c => c?.type?.Types),
+ )
+ if (existingTag && existingTag.props?.type !== 0) return ret
+
+ const guild = GuildStore?.getGuild(guildId)
+ const tag = getTag(storage, guild, undefined, user)
+ if (!tag) return ret
+
+ if (existingTag) {
+ Object.assign(existingTag.props, {
+ type: 0,
+ text: tag.text,
+ textColor: tag.textColor,
+ backgroundColor: tag.backgroundColor,
+ verified: tag.verified,
+ })
+ return ret
+ }
+
+ const container = label as any
+ if (!Array.isArray(container.props.children)) {
+ container.props.children = [container.props.children]
+ }
+
+ if (tag.gradientColor) {
+ container.props.children.push(
+ ,
+ )
+ } else if (TagModule?.default) {
+ const Component = TagModule.default
+ container.props.children.push(
+ ,
+ )
+ }
+
+ return ret
+ }, ret),
+ )
+ return el
+ }, el),
+ ),
+ )
+ }, undefined)
+ },
+ { max: 4 },
+ )
+ patches.push(unsubUserRow)
+
+ return () => patches.forEach(unpatch => unpatch())
+}
diff --git a/plugins/staff-tags/js/patches/name.tsx b/plugins/staff-tags/js/patches/name.tsx
new file mode 100644
index 0000000..1998de1
--- /dev/null
+++ b/plugins/staff-tags/js/patches/name.tsx
@@ -0,0 +1,175 @@
+import { Stores } from '@revenge-mod/discord/flux'
+import { getModules } from '@revenge-mod/modules/finders'
+import { withName, withProps } from '@revenge-mod/modules/finders/filters'
+import { afterJSX } from '@revenge-mod/react/jsx-runtime'
+import { findInReactFiber } from '@revenge-mod/utils/react'
+import { afterRendered } from '../lib/afterRendered'
+import { fiberFilter } from '../lib/fiber'
+import getTag from '../lib/getTag'
+import { guard } from '../lib/safe'
+import GradientTag from '../ui/GradientTag'
+import type { JsonStorage } from '@revenge-mod/json-storage'
+import type { FC } from 'react'
+import type { StaffTagsStorage } from '../lib/types'
+
+interface DisplayNameProps {
+ guildId?: string
+ channelId?: string
+ user: any
+}
+
+interface HeaderNameProps {
+ channelId?: string
+}
+
+interface TagComponentProps {
+ type: number
+ text: string
+ textColor: string
+ backgroundColor: string
+ verified: boolean
+ style?: any
+}
+
+/**
+ * Covers the name row shown in chat messages (next to the display name + timestamp) and channel
+ * headers.
+ *
+ * Uses `getModules` (reacts once Discord itself initializes each module) instead of
+ * `lookupModule` (which force-initializes uninitialized modules right away) - forcing
+ * DisplayName/HeaderName/the Tag module to init during `start()`, before Discord is done
+ * booting, is what crashed app startup the first time this shipped.
+ *
+ * Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
+ * initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
+ * synchronous setup calls - an uncaught throw here crashes app startup instead of just skipping
+ * a name row.
+ *
+ * TODO(live-verify): DisplayName/HeaderName component names and rendered-tree shapes
+ * (`c?.type?.Types`, the flexDirection: "row" container), see eval-for-revenge.
+ */
+export default function patchName(storage: JsonStorage) {
+ const cleanups: (() => void)[] = []
+
+ let TagModule: { default?: FC } | undefined
+ const unsubTagModule = getModules(
+ withProps('default', 'getBotLabel'),
+ mod => {
+ TagModule = mod as { default?: FC }
+ },
+ { skipDefault: true },
+ )
+ cleanups.push(unsubTagModule)
+
+ const GuildStore = Stores.GuildStore as unknown as {
+ getGuild(id: string | undefined): any
+ }
+ const ChannelStore = Stores.ChannelStore as unknown as {
+ getChannel(id: string | undefined): any
+ }
+
+ const unsubHeaderName = getModules(
+ withName>('HeaderName'),
+ HeaderName => {
+ guard(() => {
+ cleanups.push(
+ afterJSX(HeaderName, el =>
+ guard(() => {
+ const { channelId } = el.props
+ const unpatch = afterRendered(el, ret =>
+ guard(() => {
+ unpatch()
+ const node = ret as any
+ if (node?.props) node.props.channelId = channelId
+ return ret
+ }, ret),
+ )
+ return el
+ }, el),
+ ),
+ )
+ }, undefined)
+ },
+ )
+ cleanups.push(unsubHeaderName)
+
+ const unsubDisplayName = getModules(
+ withName>('DisplayName'),
+ DisplayName => {
+ guard(() => {
+ cleanups.push(
+ afterJSX(DisplayName, el =>
+ guard(() => {
+ const { guildId, channelId, user } = el.props
+ const unpatch = afterRendered(el, ret =>
+ guard(() => {
+ unpatch()
+
+ const tagComponent = findInReactFiber(
+ ret as any,
+ fiberFilter(c => c?.type?.Types),
+ )
+
+ // A real built-in tag (bot/system/etc.) is already present - don't touch it.
+ if (tagComponent && tagComponent.props?.type !== 0) return ret
+
+ const guild = GuildStore?.getGuild(guildId)
+ const channel = ChannelStore?.getChannel(channelId)
+ const tag = getTag(storage, guild, channel, user)
+ if (!tag) return ret
+
+ if (tagComponent) {
+ tagComponent.props = {
+ type: 0,
+ text: tag.text,
+ textColor: tag.textColor,
+ backgroundColor: tag.backgroundColor,
+ verified: tag.verified,
+ }
+ return ret
+ }
+
+ const row = findInReactFiber(
+ ret as any,
+ fiberFilter(c => c?.props?.style?.flexDirection === 'row'),
+ )
+ if (!Array.isArray(row?.props?.children)) return ret
+
+ if (tag.gradientColor) {
+ row.props.children.push(
+ ,
+ )
+ } else if (TagModule?.default) {
+ const Component = TagModule.default
+ row.props.children.push(
+ ,
+ )
+ }
+
+ return ret
+ }, ret),
+ )
+ return el
+ }, el),
+ ),
+ )
+ }, undefined)
+ },
+ )
+ cleanups.push(unsubDisplayName)
+
+ return () => cleanups.forEach(fn => fn())
+}
diff --git a/plugins/staff-tags/js/patches/tag.tsx b/plugins/staff-tags/js/patches/tag.tsx
new file mode 100644
index 0000000..22c44d4
--- /dev/null
+++ b/plugins/staff-tags/js/patches/tag.tsx
@@ -0,0 +1,94 @@
+import { getModules } from '@revenge-mod/modules/finders'
+import { withProps } from '@revenge-mod/modules/finders/filters'
+import { afterJSX } from '@revenge-mod/react/jsx-runtime'
+import { findInReactFiber } from '@revenge-mod/utils/react'
+import { afterRendered } from '../lib/afterRendered'
+import { fiberFilter } from '../lib/fiber'
+import { guard, mergeStyle } from '../lib/safe'
+import type { FC } from 'react'
+
+interface TagProps {
+ text?: string
+ textColor?: string
+ backgroundColor?: string
+}
+
+/**
+ * Discord's own Tag component ignores custom text/color props unless we reach into its
+ * already-rendered output and push them in ourselves - this is what actually makes the
+ * text/textColor/backgroundColor props (that `name.tsx`/`details.tsx` inject) visible.
+ *
+ * `afterJSX` intercepts every `createElement(Tag, ...)` call globally, regardless of who calls
+ * it (Discord's own code or ours) and regardless of which chunk captured the reference -
+ * patching the component's module export directly wouldn't reliably reach Discord's own
+ * same-chunk render calls.
+ *
+ * Uses `getModules` (reacts once Discord itself initializes the module) instead of
+ * `lookupModule` (which force-initializes uninitialized modules right away to check their
+ * exports) - forcing this module to init during `start()`, before Discord is done booting, is
+ * what crashed app startup the first time this shipped.
+ *
+ * Every callback below is wrapped in `guard()`: it runs whenever Discord itself renders/
+ * initializes the matched module, not inside `applyPatches`' try/catch, which only covers the
+ * synchronous setup call above - an uncaught throw here crashes app startup instead of just
+ * skipping this one tag.
+ *
+ * TODO(live-verify): Tag module shape (`default` + `getBotLabel`), see eval-for-revenge.
+ */
+export default function patchTag() {
+ let unpatchTag = () => {}
+
+ const unsub = getModules(
+ withProps<{ default: FC; getBotLabel: unknown }>(
+ 'default',
+ 'getBotLabel',
+ ),
+ TagModule => {
+ guard(() => {
+ const Tag = TagModule.default
+ if (!Tag) return
+
+ unpatchTag = afterJSX(Tag, el =>
+ guard(() => {
+ const { text, textColor, backgroundColor } = el.props
+ if (!text && !textColor && !backgroundColor) return el
+
+ const unpatch = afterRendered(el, ret =>
+ guard(() => {
+ unpatch()
+
+ const label = findInReactFiber(
+ ret as any,
+ fiberFilter(c => typeof c?.props?.children === 'string'),
+ )
+ if (!label) return ret
+
+ if (text) label.props.children = text
+ if (textColor)
+ label.props.style = mergeStyle(label.props.style, {
+ color: textColor,
+ })
+ if (backgroundColor && (ret as any)?.props) {
+ ;(ret as any).props.style = mergeStyle(
+ (ret as any).props.style,
+ { backgroundColor },
+ )
+ }
+
+ return ret
+ }, ret),
+ )
+
+ return el
+ }, el),
+ )
+ }, undefined)
+ },
+ { skipDefault: true },
+ )
+
+ return () => {
+ unsub()
+ unpatchTag()
+ }
+}
diff --git a/plugins/staff-tags/js/ui/ColorInput.tsx b/plugins/staff-tags/js/ui/ColorInput.tsx
new file mode 100644
index 0000000..bd03c16
--- /dev/null
+++ b/plugins/staff-tags/js/ui/ColorInput.tsx
@@ -0,0 +1,104 @@
+import { Text, TextInput, TouchableOpacity, View } from 'react-native'
+import { isValidHex } from '../lib/color'
+
+const PRESETS = [
+ '#F23F42',
+ '#F0B232',
+ '#FEE75C',
+ '#23A55A',
+ '#1ABC9C',
+ '#5865F2',
+ '#9B59B6',
+ '#EB459E',
+ '#99AAB5',
+ '#FFFFFF',
+]
+
+interface ColorInputProps {
+ title: string
+ value?: string
+ placeholder?: string
+ onChange: (value: string) => void
+}
+
+/**
+ * Hex color field with a live swatch preview and tap-to-pick presets. Built entirely from RN
+ * primitives (no Discord-internal component reuse), so it doesn't carry the same "might silently
+ * stop rendering after a Discord update" risk as the plugin's UI patches do.
+ */
+export default function ColorInput({
+ title,
+ value,
+ placeholder,
+ onChange,
+}: ColorInputProps) {
+ const valid = !value || isValidHex(value)
+ const swatchColor = isValidHex(value)
+ ? value
+ : isValidHex(placeholder)
+ ? placeholder
+ : '#5865F2'
+
+ return (
+
+
+
+ {title}
+
+
+
+ {PRESETS.map(preset => {
+ const active = value?.toLowerCase() === preset.toLowerCase()
+ return (
+ 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)',
+ }}
+ />
+ )
+ })}
+
+ {!valid && (
+
+ Invalid hex color, e.g. #5865F2
+
+ )}
+
+ )
+}
diff --git a/plugins/staff-tags/js/ui/GradientTag.tsx b/plugins/staff-tags/js/ui/GradientTag.tsx
new file mode 100644
index 0000000..43d4e10
--- /dev/null
+++ b/plugins/staff-tags/js/ui/GradientTag.tsx
@@ -0,0 +1,54 @@
+import { Text, View } from 'react-native'
+import { getGradientComponent } from '../lib/gradient'
+import type { StyleProp, ViewStyle } from 'react-native'
+
+const baseStyle: ViewStyle = {
+ flexDirection: 'row',
+ alignItems: 'center',
+ borderRadius: 4,
+ paddingHorizontal: 4,
+ marginLeft: 4,
+ overflow: 'hidden',
+}
+
+interface GradientTagProps {
+ text: string
+ textColor: string
+ backgroundColor: string
+ gradientColor?: string
+ style?: StyleProp
+}
+
+export default function GradientTag({
+ text,
+ textColor,
+ backgroundColor,
+ gradientColor,
+ style,
+}: GradientTagProps) {
+ const textStyle = {
+ color: textColor,
+ fontSize: 11,
+ fontWeight: '700' as const,
+ }
+ const Gradient = gradientColor ? getGradientComponent() : null
+
+ if (Gradient) {
+ return (
+
+ {text}
+
+ )
+ }
+
+ return (
+
+ {text}
+
+ )
+}
diff --git a/plugins/staff-tags/js/ui/pages/Settings.tsx b/plugins/staff-tags/js/ui/pages/Settings.tsx
new file mode 100644
index 0000000..073741b
--- /dev/null
+++ b/plugins/staff-tags/js/ui/pages/Settings.tsx
@@ -0,0 +1,116 @@
+import { Design } from '@revenge-mod/discord/design'
+import { ScrollView, View } from 'react-native'
+import { TAG_DEFINITIONS } from '../../lib/getTag'
+import ColorInput from '../ColorInput'
+import type { PluginApi } from '@revenge-mod/plugins/types'
+import type { StaffTagsStorage, TagOverride } from '../../lib/types'
+
+interface TagSettingsSectionProps {
+ api: PluginApi<{ jsonStorage: StaffTagsStorage }>
+ id: string
+ defaultText: string
+ defaultColor: string
+}
+
+/**
+ * `Design` is read inside each component (not destructured at module top level): external
+ * plugin bundles run via a bare `new Function('revenge', 'plugin', script)` call at preInit with
+ * no Metro-style dependency ordering, so `revenge.discord.design.Design`'s fields aren't
+ * guaranteed populated yet at that point. By the time these components actually render (the user
+ * opened the plugin's settings page), Discord's design system is long since ready.
+ */
+function TagSettingsSection({
+ api,
+ id,
+ defaultText,
+ defaultColor,
+}: TagSettingsSectionProps) {
+ const { TableRowGroup, TableSwitchRow, TextInput } = Design
+ const storage = api.jsonStorage.use()
+ const settings: TagOverride = storage?.tags?.[id] ?? {}
+ const enabled = settings.enabled !== false
+
+ const update = (patch: TagOverride) => {
+ api.jsonStorage.set({ tags: { [id]: patch } })
+ }
+
+ return (
+
+ update({ enabled: v })}
+ />
+ update({ text: v })}
+ />
+ update({ useCustomColor: v })}
+ />
+ {settings.useCustomColor && (
+ update({ color: v })}
+ />
+ )}
+ update({ useGradient: v })}
+ />
+ {settings.useGradient && (
+ update({ gradientColor: v })}
+ />
+ )}
+
+ )
+}
+
+export default function Settings({
+ api,
+}: {
+ api: PluginApi<{ jsonStorage: StaffTagsStorage }>
+}) {
+ const { TableRowGroup, TableSwitchRow } = Design
+ const storage = api.jsonStorage.use()
+
+ return (
+
+
+
+ api.jsonStorage.set({ useRoleColor: v })
+ }
+ />
+
+ {TAG_DEFINITIONS.map(def => (
+
+ ))}
+
+
+ )
+}
diff --git a/plugins/staff-tags/manifest.json b/plugins/staff-tags/manifest.json
new file mode 100644
index 0000000..56f4ea9
--- /dev/null
+++ b/plugins/staff-tags/manifest.json
@@ -0,0 +1,19 @@
+{
+ "format": 1,
+ "id": "dev.everestmcarthur.staff-tags",
+ "name": "Staff Tags",
+ "description": "Adds customizable tags (OWNER, ADMIN, STAFF, MOD, and more) next to members based on their server permissions. Supports per-tag colors, gradients, custom text, and visibility toggles.",
+ "author": "everestmcarthur",
+ "version": "1.0.4",
+ "dependencies": {
+ "revenge.api": {
+ "version": ">=1 <2"
+ },
+ "discord": {
+ "version": "*"
+ }
+ },
+ "dist": {
+ "script": "index.js"
+ }
+}
diff --git a/repo.config.json b/repo.config.json
new file mode 100644
index 0000000..308a24a
--- /dev/null
+++ b/repo.config.json
@@ -0,0 +1,5 @@
+{
+ "name": "revenge-next-plugs",
+ "description": "Everest's Revenge Next plugin repository.",
+ "channels": {}
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..5ebd52c
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,30 @@
+import java.io.File
+
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ // Xposed API (provided by the framework at runtime).
+ maven(url = "https://api.xposed.info/")
+ // The Revenge plugin API, published locally from the revenge-xposed repo via
+ // `./gradlew :api:publishToMavenLocal`.
+ mavenLocal()
+ }
+}
+
+rootProject.name = "revenge-plugin-template"
+
+// Each `plugins//` folder with Kotlin sources (`src/main`) is a native plugin module.
+// Plugins without `src/main` are JS-only and are packaged without a Gradle project.
+file("plugins").listFiles()
+ ?.filter { it.isDirectory && File(it, "src/main").isDirectory }
+ ?.sortedBy { it.name }
+ ?.forEach { dir -> include(":plugins:${dir.name}") }
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..61d6280
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "resolveJsonModule": true,
+ "types": ["@revenge-mod/types"]
+ },
+ "include": ["plugins", "types"],
+ "exclude": ["plugins/*/build"]
+}
diff --git a/types/assets.d.ts b/types/assets.d.ts
new file mode 100644
index 0000000..2c1291c
--- /dev/null
+++ b/types/assets.d.ts
@@ -0,0 +1,24 @@
+declare module '*.webp' {
+ const content: string
+ export default content
+}
+
+declare module '*.png' {
+ const content: string
+ export default content
+}
+
+declare module '*.jpg' {
+ const content: string
+ export default content
+}
+
+declare module '*.jpeg' {
+ const content: string
+ export default content
+}
+
+declare module '*.svg' {
+ const content: string
+ export default content
+}