diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..248c914b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,7 @@ +[submodule "addons/nexus.entour"] + path = addons/nexus.entour + url = https://github.com/ShindouMihou/nexus.entour + +[submodule "addons/nexus.coroutines"] + path = addons/nexus.coroutines + url = https://github.com/ShindouMihou/nexus.coroutines diff --git a/README.md b/README.md index c1f0e0c8..1d44a5e8 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,9 @@ object ReportUserContextMenu: NexusUserContextMenu() { } ``` +For Kotlin users who are looking to build highly reactive, or interactive bots, you may be interested in our new rendering mechanism +called [Nexus.R], read more at [GitHub Wiki](https://github.com/ShindouMihou/Nexus/wiki/Nexus.R). + ## Getting Started To get started with Nexus, we recommend reading the wiki in its chronological order: @@ -53,6 +56,7 @@ Nexus is used in production by Discord bots, such as: - [Beemo](https://beemo.gg): An anti-raid Discord bot that prevents raids on many large servers. - [Amelia-chan](https://github.com/Amelia-chan/Amelia): A simple RSS Discord bot for the novel site, ScribbleHub. - [Threadscore](https://threadscore.mihou.pw): Gamifying Q&A for Discord. +- [Flyght](https://flyght.mihou.pw): Adds Know-Your-Member to Discord. Powered by [Nexus.R](https://github.com/ShindouMihou/Nexus/wiki/Nexus.R/). If you want to add your bot to the list, feel free to add it by creating a pull request! @@ -61,6 +65,7 @@ Nexus was created from the ground up to power Discord bots with a simplistic yet on performance, allowing developers to build their Discord bots fast and clean. - [x] **Object-based commands** - [x] **Object-based context menus** +- [x] **Supports an innovative, React+Svelte-like Rendering Mechanism** - [x] **Middlewares, Afterwares** - [x] **Supports auto-deferring of responses** - [x] **Flexible command synchronization system** diff --git a/addons/README.md b/addons/README.md new file mode 100644 index 00000000..d7b6e11c --- /dev/null +++ b/addons/README.md @@ -0,0 +1,15 @@ +# Addons + +In order to keep the repository specific to the actual functionalities of the Nexus features, +other cool things that would be nice to have in Nexus, such as confirmation menus, etc. are stored into different +repositories and linked here through Git Submodule for others to see. + +Here are the list of each different add-on that extends upon Nexus' features: +- [`entour`](nexus.entour) is a set of handy built-in components, hooks and tools for Nexus.R. This contains many +different things for develoeprs who uses Nexus.R such as Confirmation Menus. +- [`coroutines`](nexus.coroutines) adds coroutine support to the framework. + +### Contributing + +If you want to add your repository into this list, feel free to create a Issue stating the name, repository link and +the description for your repository. \ No newline at end of file diff --git a/addons/nexus.coroutines b/addons/nexus.coroutines new file mode 160000 index 00000000..70dc511b --- /dev/null +++ b/addons/nexus.coroutines @@ -0,0 +1 @@ +Subproject commit 70dc511b0fcf37ccb56429017f92135d83de2192 diff --git a/addons/nexus.entour b/addons/nexus.entour new file mode 160000 index 00000000..9b2d802a --- /dev/null +++ b/addons/nexus.entour @@ -0,0 +1 @@ +Subproject commit 9b2d802aabe74a684b1b991535653ebaff8b9d8f diff --git a/build.gradle b/build.gradle index 33151d2f..2fe2b634 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'pw.mihou' -version = '1.0.1' +version = '1.1.0' description = 'Nexus is the next-generation Javacord framework that aims to create Discord bots with less code, dynamic, more simplicity and beauty.' repositories { diff --git a/examples/nexus.r/ContextMenu.kt b/examples/nexus.r/ContextMenu.kt new file mode 100644 index 00000000..b219f967 --- /dev/null +++ b/examples/nexus.r/ContextMenu.kt @@ -0,0 +1,43 @@ +import org.javacord.api.event.interaction.MessageContextMenuCommandEvent +import org.javacord.api.interaction.MessageContextMenuInteraction +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.contexts.NexusContextMenuEvent +import pw.mihou.nexus.features.contexts.NexusMessageContextMenu +import pw.mihou.nexus.features.contexts.NexusUserContextMenu +import pw.mihou.nexus.features.messages.R +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import java.time.Instant + +/** + * Nexus.R supports basically everything, you can copy-paste the code from one event to another and it'll work + * the same regardless without much if any modification. In this example, we demonstrate how we can use Nexus.R + * to build amazingly simple yet reactive component. + * + * Our example will render a simple message with a button that increments the click count and re-render the + * message whenever a click is registered. + */ +object ContextMenu: NexusMessageContextMenu() { + val name = "react" + override fun onEvent(event: NexusContextMenuEvent) { + event.R { + var clicks by writable(0) + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } + } + } + } +} \ No newline at end of file diff --git a/examples/nexus.r/Interaction.kt b/examples/nexus.r/Interaction.kt new file mode 100644 index 00000000..b798ed3f --- /dev/null +++ b/examples/nexus.r/Interaction.kt @@ -0,0 +1,58 @@ +import org.javacord.api.event.interaction.ButtonClickEvent +import org.javacord.api.event.interaction.MessageContextMenuCommandEvent +import org.javacord.api.event.interaction.SelectMenuChooseEvent +import org.javacord.api.interaction.Interaction +import org.javacord.api.interaction.MessageContextMenuInteraction +import org.javacord.api.listener.interaction.ButtonClickListener +import org.javacord.api.listener.interaction.SelectMenuChooseListener +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.commons.R +import pw.mihou.nexus.features.contexts.NexusContextMenuEvent +import pw.mihou.nexus.features.contexts.NexusMessageContextMenu +import pw.mihou.nexus.features.contexts.NexusUserContextMenu +import pw.mihou.nexus.features.messages.R +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import java.time.Instant + +/** + * Nexus.R supports basically everything, you can copy-paste the code from one event to another and it'll work + * the same regardless without much if any modification. In this example, we demonstrate how we can use Nexus.R + * to build amazingly simple yet reactive component. + * + * Our example will render a simple message with a button that increments the click count and re-render the + * message whenever a click is registered. + */ +object InteractionExample: ButtonClickListener, SelectMenuChooseListener { + private fun render(interaction: Interaction) { + interaction.R(ephemeral = false) { + var clicks by writable(0) + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } + } + } + } + + override fun onButtonClick(event: ButtonClickEvent) { + render(event.interaction) + } + + override fun onSelectMenuChoose(event: SelectMenuChooseEvent) { + render(event.interaction) + } + + +} \ No newline at end of file diff --git a/examples/nexus.r/MessageEvent.kt b/examples/nexus.r/MessageEvent.kt new file mode 100644 index 00000000..7a25325c --- /dev/null +++ b/examples/nexus.r/MessageEvent.kt @@ -0,0 +1,39 @@ +import org.javacord.api.event.message.MessageCreateEvent +import org.javacord.api.listener.message.MessageCreateListener +import pw.mihou.nexus.features.messages.R +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import java.time.Instant + +/** + * Nexus.R supports basically everything, you can copy-paste the code from one event to another and it'll work + * the same regardless without much if any modification. In this example, we demonstrate how we can use Nexus.R + * to build amazingly simple yet reactive component. + * + * Our example will render a simple message with a button that increments the click count and re-render the + * message whenever a click is registered. + */ +object MessageEvent: MessageCreateListener { + override fun onMessageCreate(event: MessageCreateEvent) { + if (event.messageContent == "%nexus.r") { + event.R { + var clicks by writable(0) + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } + } + } + } + } +} \ No newline at end of file diff --git a/examples/nexus.r/README.md b/examples/nexus.r/README.md new file mode 100644 index 00000000..853855f5 --- /dev/null +++ b/examples/nexus.r/README.md @@ -0,0 +1,31 @@ +# nexus.R + +React Native + Svelte for Discord Bots. Nexus.R is an innovative way to create and send responses or messages +to any text channel or interaction from slash commands to messages commands and even Users or text channels +with reactivity, enabling quick and simple re-rendering of messages upon state changes. + +### Features +- [x] Write-once, Use Everywhere + - Nexus.R allows you to easily reuse responses whether it was written for slash commands or + for messages with hardly any changes. +- [x] States in Discord Bot + - Nexus.R introduces the infamous states of JavaScript world into Discord bots, allowing you to create + messages that will re-render itself upon different state changes, just like a reactive website! +- [x] Webdev-like Feel + - In order to make Discord bot development more accessible to even web developers, we've made the + feature feel like writing web code (JSX) but simpler! + +### Table of Contents + +As Nexus.R is incredibly simple, you can read the examples and pretty much get a gist of how to use it. Here +are the examples we think that you should read first though: +1. [**Starting out with Nexus.R**](): It's all the same, except for how it's started. *Read which one you prefer to see.* + - [Slash Commands](SlashCommand.kt) + - [Context Menus](ContextMenu.kt) + - [Message Events i.e. Message Commands](MessageEvent.kt) + - [Interactions i.e. Buttons and Select Menus](Interaction.kt) +2. [**Passing State to Another Function**](%5B1%5D_passing_state): As states are not simply the same regular Kotlin properties, there needs a +little bit of a very tiny change when you want to pass state to another function outside of the `Nexus.R` scope. +3. [**Creating Components**](%5B2%5D_components): Reusing code is also important in coding, and components are just one way to reuse code +4. [**Data Fetching**](%5B3%5D_data_fetching/DataFetching.kt): Data fetching is also an important factor in many bots, this is how you can load data before rending the message! +5. [**Hooks**](%5B4%5D_hooks): Hooks are vital to be able to reuse a lot of code, unlike components, hooks are not affected by rerenders and is a great place to define writables and related! \ No newline at end of file diff --git a/examples/nexus.r/SlashCommand.kt b/examples/nexus.r/SlashCommand.kt new file mode 100644 index 00000000..16eed130 --- /dev/null +++ b/examples/nexus.r/SlashCommand.kt @@ -0,0 +1,39 @@ +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.messages.R +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import java.time.Instant + +/** + * Nexus.R supports basically everything, you can copy-paste the code from one event to another and it'll work + * the same regardless without much if any modification. In this example, we demonstrate how we can use Nexus.R + * to build amazingly simple yet reactive component. + * + * Our example will render a simple message with a button that increments the click count and re-render the + * message whenever a click is registered. + */ +object SlashCommand: NexusHandler { + val name = "react" + val description = "Shows a demonstration of Nexus.R." + override fun onEvent(event: NexusCommandEvent) { + event.R { + var clicks by writable(0) + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } + } + } + } +} \ No newline at end of file diff --git a/examples/nexus.r/[1]_passing_state/PassingState.kt b/examples/nexus.r/[1]_passing_state/PassingState.kt new file mode 100644 index 00000000..9214c1e8 --- /dev/null +++ b/examples/nexus.r/[1]_passing_state/PassingState.kt @@ -0,0 +1,73 @@ +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import pw.mihou.nexus.features.react.writable.plusAssign +import java.time.Instant + +/** + * This example demonstrates how you pass [React.Writable] from the render function to another function. + * Unlike JavaScript frameworks, this isn't easily done as Kotlin maintains an immutable-only function argument, + * which means that we have to circumvent this by also maintaining the original [React.Writable] instance itself and + * passing that instead. + */ +object PassingState: NexusHandler { + val name = "react" + val description = "Shows a demonstration of Nexus.R." + override fun onEvent(event: NexusCommandEvent) { + event.R { + // As we need to maintain the original Writable instance, we need to separate it from the + // delegated property, this property won't have the native setter and related. + val clicksDelegate = writable(0) + var clicks by clicksDelegate + + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + increment(clicksDelegate) + } + Button(label = "Click this!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } + Button(label = "Click also!") { + it.buttonInteraction.acknowledge() + incrementDelegated(clicksDelegate) + } + } + } + } + + /** + * This demonstrates using the [React.Writable] methods to manipulate the value of the [React.Writable], thereby + * causing a re-render. Generally, unless you perform a ton of manipulation, you can live with this. + */ + private fun increment(clicks: React.Writable) { + // Newer version after a recent commit adding operator overloads over primitive types + // and lists. + clicks += 1 + + // Older method and more optimal for cases that are not supported with the operator overloads + // such as types that are not supported. + // clicks.update { it + 1 } + } + + /** + * This demonstrates how you can also still delegate inside the method as well to maintain + * a similar feel to how a regular property would. + */ + private fun incrementDelegated(clicksDelegate: React.Writable) { + var clicks by clicksDelegate + clicks += 1 + } +} \ No newline at end of file diff --git a/examples/nexus.r/[1]_passing_state/README.md b/examples/nexus.r/[1]_passing_state/README.md new file mode 100644 index 00000000..fd63d131 --- /dev/null +++ b/examples/nexus.r/[1]_passing_state/README.md @@ -0,0 +1,8 @@ +# Passing States (Writables) + +As writables aren't simple properties, we can't just simply pass them to another function outside of the `Nexus.R` scope and expect it to work the same +especially since Kotlin doesn't support mutable function arguments. As such, in this example, we demonstrate how to properly pass writables from the +`Nexus.R` scope to another function by passing the Delegate class (`React.Writable`) itself. + +In the example, we also demonstrate how the `React.Writable` can then be delegated again inside the other function and +will still re-render the message when needed. \ No newline at end of file diff --git a/examples/nexus.r/[2]_components/Component.kt b/examples/nexus.r/[2]_components/Component.kt new file mode 100644 index 00000000..8bdbc751 --- /dev/null +++ b/examples/nexus.r/[2]_components/Component.kt @@ -0,0 +1,57 @@ +import org.javacord.api.event.message.MessageCreateEvent +import org.javacord.api.listener.message.MessageCreateListener +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.messages.R +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import pw.mihou.nexus.features.react.writable.plusAssign +import java.time.Instant + +// Creating components can be done by simply creating an extension function over [React.Component], +// note once more that since we don't have access to the `Nexus.R` scope, we need to pass states like +// a regular function. Read the `[1]_passing_state` example if you haven't. +fun React.Component.Example(clicks: React.Writable) { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } +} + +// Once you've created the extension function, just simply call it on the `render` method. +// It is noted that you can't just simply create new Writable inside `render` as it is recreated +// again upon re-rendering. +object ComponentMessageExample: MessageCreateListener { + override fun onMessageCreate(event: MessageCreateEvent) { + event.R { + val clicksDelegate = writable(0) + render { + Example(clicksDelegate) + } + } + } +} + +// You can also do the same to slash commands, it doesn't matter as long as you have the parameters. +object ComponentSlashCommandExample: NexusHandler { + val name = "test" + val description = "An example of using components with Nexus.R" + override fun onEvent(event: NexusCommandEvent) { + event.R { + val clicksDelegate = writable(0) + render { + Example(clicksDelegate) + } + } + } +} \ No newline at end of file diff --git a/examples/nexus.r/[2]_components/README.md b/examples/nexus.r/[2]_components/README.md new file mode 100644 index 00000000..f047fd04 --- /dev/null +++ b/examples/nexus.r/[2]_components/README.md @@ -0,0 +1,15 @@ +# Components + +Reusing code is definitely one of the things that we'd like to do, and we can do so under +Nexus.R by simply creating an extension function over `React.Component` which would allow you to reuse +code easily. Although, it is noted that there are some drawbacks to this. + +### Drawbacks +1. You cannot simply create `Writable` as components are ran inside the `render` function which will re-render +the entire component, meaning that creating `Writable` inside `render` will result in the `Writable` being recreated instead. +2. Methods such as `onInitialRender` and `onRender` are not available. Components are simply functions ran inside `render` +which means anything inside cannot exist before the function itself is called which is also when the function happens to +render. + +Other than aforementioned drawbacks, components are an incredibly easy way to reuse code for cases such as +hybrid command bots i.e. bots that uses both slash and message commands. \ No newline at end of file diff --git a/examples/nexus.r/[3]_data_fetching/DataFetching.kt b/examples/nexus.r/[3]_data_fetching/DataFetching.kt new file mode 100644 index 00000000..b7842719 --- /dev/null +++ b/examples/nexus.r/[3]_data_fetching/DataFetching.kt @@ -0,0 +1,46 @@ +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.react.elements.Embed +import java.time.Instant + +// In this example, we'll demonstrate the different ways we can do data fetching via `onRender` and `onInitialRender`. +// Nexus.R uses `autoDefer` underneath, which means that you don't have to worry about calling `respondLater` or any +// of those mess, just take your time in grabbing that fresh data! +object DataFetching: NexusHandler { + val name = "react" + val description = "Shows a demonstration of Nexus.R." + override fun onEvent(event: NexusCommandEvent) { + event.R { + lateinit var qotd: String + + // event.R itself is actually a function, so you can initialize stuff here. + // but for demonstration purposes, we'll use `onInitialRender`, but just so you know, + // this is a 100% valid thing to do. + // + // qotd = "Lorem Ipsum Somethin' Magical!" + + onInitialRender { + // This is called way before `onRender` and `render` and can be where you can initialize + // data for the first time on render, although this is just the equivalent of doing it on `event.R` scope + // itself, but for people who prefer something like this, you can do this. + + // Imagine this is a data fetching thing. + qotd = "Lorem Ipsum Somethin' Magical!" + } + onRender { + // Anything that you want to do before the actual `render` is called. + // This is called again for re-rendering. + } + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("QOTD: $qotd"), + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + } + } + } +} \ No newline at end of file diff --git a/examples/nexus.r/[4]_hooks/Hooks.kt b/examples/nexus.r/[4]_hooks/Hooks.kt new file mode 100644 index 00000000..e4cccaa1 --- /dev/null +++ b/examples/nexus.r/[4]_hooks/Hooks.kt @@ -0,0 +1,72 @@ +import pw.mihou.nexus.Nexus +import pw.mihou.nexus.configuration.modules.Cancellable +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.messages.R +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.react.ReactComponent +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import pw.mihou.nexus.features.react.hooks.useHideButtons +import pw.mihou.nexus.features.react.writable.plusAssign +import java.time.Instant +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +// In this example, we'll demonstrate how to create reusable hooks in Nexus.R. Hooks are simply +// just extension functions over [React] itself and isn't affected through re-renders as it lives +// under [React]. You can even use it to pass components, in this example, we'll demonstrate using it +// to pass components. + +/** + * In here, we create an extension function over [React] which returns a [Triple] that contains the `showButtons`, `clicks` and + * the actual [ReactComponent] itself which will increment the clicks every time it is clicked. + */ +fun React.useClickButton(hideAfter: Duration = 10.minutes): Triple, React.Writable, ReactComponent> { + val showButtons = writable(true) + val clicks = writable(0) + + var removeButtons: Cancellable? = null + + onRender { + removeButtons?.cancel(true) + removeButtons = Nexus.launch.scheduler.launch(hideAfter.inWholeMilliseconds) { + showButtons.set(false) + } + } + + return Triple(showButtons, clicks) { + if (showButtons.get()) { + Button(label = "Click me!") { + it.buttonInteraction.acknowledge() + clicks += 1 + } + } + } +} + +object SlashCommand: NexusHandler { + val name = "react" + val description = "Shows a demonstration of Nexus.R." + override fun onEvent(event: NexusCommandEvent) { + event.R { + val (showButtonsDelegate, clicksDelegate, ClickButton) = useClickButton(10.minutes) + + var showButtons by showButtonsDelegate + var clicks by clicksDelegate + + render { + Embed { + Title("Rendered with Nexus.R") + SpacedBody( + p("This message was rendered with Nexus.R."), + p("The button has been clicked ") + bold("$clicks times.") + ) + Color(java.awt.Color.YELLOW) + Timestamp(Instant.now()) + } + ClickButton() + } + } + } +} diff --git a/src/main/java/pw/mihou/nexus/features/command/core/NexusCommandEventCore.kt b/src/main/java/pw/mihou/nexus/features/command/core/NexusCommandEventCore.kt index 0ae6eb7f..882cfeb3 100755 --- a/src/main/java/pw/mihou/nexus/features/command/core/NexusCommandEventCore.kt +++ b/src/main/java/pw/mihou/nexus/features/command/core/NexusCommandEventCore.kt @@ -8,7 +8,9 @@ import pw.mihou.nexus.Nexus import pw.mihou.nexus.configuration.modules.Cancellable import pw.mihou.nexus.features.command.facade.NexusCommand import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.react.React import pw.mihou.nexus.features.command.responses.NexusAutoResponse +import pw.mihou.nexus.features.commons.Deferrable import pw.mihou.nexus.features.messages.NexusMessage import java.time.Instant import java.util.concurrent.CompletableFuture @@ -22,55 +24,8 @@ class NexusCommandEventCore(override val event: SlashCommandCreateEvent, overrid override fun store(): MutableMap = store - override fun autoDefer(ephemeral: Boolean, response: Function): CompletableFuture { - var task: Cancellable? = null - val deferredTaskRan = AtomicBoolean(false) - if (updater.get() == null) { - val timeUntil = Instant.now().toEpochMilli() - event.interaction.creationTimestamp - .minusMillis(Nexus.configuration.global.autoDeferAfterMilliseconds) - .toEpochMilli() - - task = Nexus.launch.scheduler.launch(timeUntil) { - if (updater.get() == null) { - respondLaterEphemerallyIf(ephemeral).exceptionally(ExceptionLogger.get()) - } - deferredTaskRan.set(true) - } - } - val future = CompletableFuture() - Nexus.launcher.launch { - try { - val message = response.apply(null) - if (!deferredTaskRan.get() && task != null) { - task.cancel(false) - } - val updater = updater.get() - if (updater == null) { - val responder = respondNow() - if (ephemeral) { - responder.setFlags(MessageFlag.EPHEMERAL) - } - message.into(responder).respond() - .thenAccept { r -> future.complete(NexusAutoResponse(r, null)) } - .exceptionally { exception -> - future.completeExceptionally(exception) - return@exceptionally null - } - } else { - val completedUpdater = updater.join() - message.into(completedUpdater).update() - .thenAccept { r -> future.complete(NexusAutoResponse(null, r)) } - .exceptionally { exception -> - future.completeExceptionally(exception) - return@exceptionally null - } - } - } catch (exception: Exception) { - future.completeExceptionally(exception) - } - } - return future - } + override fun autoDefer(ephemeral: Boolean, response: Function): CompletableFuture = + Deferrable.autoDefer(event.slashCommandInteraction, updater, ephemeral, response) override fun respondLater(): CompletableFuture { return updater.updateAndGet { interaction.respondLater() }!! diff --git a/src/main/java/pw/mihou/nexus/features/command/core/NexusMiddlewareEventCore.kt b/src/main/java/pw/mihou/nexus/features/command/core/NexusMiddlewareEventCore.kt index 2989e49b..75be4b4f 100644 --- a/src/main/java/pw/mihou/nexus/features/command/core/NexusMiddlewareEventCore.kt +++ b/src/main/java/pw/mihou/nexus/features/command/core/NexusMiddlewareEventCore.kt @@ -1,18 +1,26 @@ package pw.mihou.nexus.features.command.core import org.javacord.api.event.interaction.SlashCommandCreateEvent +import pw.mihou.nexus.Nexus import pw.mihou.nexus.features.command.facade.NexusCommand import pw.mihou.nexus.features.command.facade.NexusCommandEvent import pw.mihou.nexus.features.command.facade.NexusMiddlewareEvent import pw.mihou.nexus.features.command.interceptors.core.NexusMiddlewareGateCore +import pw.mihou.nexus.features.react.React import pw.mihou.nexus.features.command.responses.NexusAutoResponse import pw.mihou.nexus.features.messages.NexusMessage import java.util.concurrent.CompletableFuture import java.util.function.Function +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours class NexusMiddlewareEventCore(private val _event: NexusCommandEvent, private val gate: NexusMiddlewareGateCore): NexusMiddlewareEvent { override val event: SlashCommandCreateEvent get() = _event.event override val command: NexusCommand get() = _event.command + override fun R(ephemeral: Boolean, lifetime: Duration, react: React.() -> Unit): CompletableFuture { + return _event.R(ephemeral, lifetime, react) + } + override fun store(): MutableMap = _event.store() override fun autoDefer(ephemeral: Boolean, response: Function): CompletableFuture { return _event.autoDefer(ephemeral, response) diff --git a/src/main/java/pw/mihou/nexus/features/command/facade/NexusCommandEvent.kt b/src/main/java/pw/mihou/nexus/features/command/facade/NexusCommandEvent.kt index 33a02deb..37ae1073 100755 --- a/src/main/java/pw/mihou/nexus/features/command/facade/NexusCommandEvent.kt +++ b/src/main/java/pw/mihou/nexus/features/command/facade/NexusCommandEvent.kt @@ -10,6 +10,7 @@ import pw.mihou.nexus.Nexus import pw.mihou.nexus.Nexus.sharding import pw.mihou.nexus.features.command.interceptors.core.NexusCommandInterceptorCore.execute import pw.mihou.nexus.features.command.interceptors.core.NexusCommandInterceptorCore.middlewares +import pw.mihou.nexus.features.react.React import pw.mihou.nexus.features.command.responses.NexusAutoResponse import pw.mihou.nexus.features.commons.NexusInteractionEvent import pw.mihou.nexus.features.messages.NexusMessage @@ -171,15 +172,4 @@ interface NexusCommandEvent : NexusInteractionEvent): CompletableFuture } diff --git a/src/main/java/pw/mihou/nexus/features/command/responses/NexusAutoResponse.kt b/src/main/java/pw/mihou/nexus/features/command/responses/NexusAutoResponse.kt index 51815934..0d78e401 100644 --- a/src/main/java/pw/mihou/nexus/features/command/responses/NexusAutoResponse.kt +++ b/src/main/java/pw/mihou/nexus/features/command/responses/NexusAutoResponse.kt @@ -5,10 +5,10 @@ import org.javacord.api.interaction.callback.InteractionOriginalResponseUpdater import java.util.concurrent.CompletableFuture data class NexusAutoResponse internal constructor( - val updater: InteractionOriginalResponseUpdater?, + val updater: InteractionOriginalResponseUpdater, val message: Message? ) { fun getOrRequestMessage(): CompletableFuture = - if (message == null && updater != null) updater.update() + if (message == null) updater.update() else CompletableFuture.completedFuture(message) } diff --git a/src/main/java/pw/mihou/nexus/features/commons/Deferrable.kt b/src/main/java/pw/mihou/nexus/features/commons/Deferrable.kt new file mode 100644 index 00000000..465c2ebf --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/commons/Deferrable.kt @@ -0,0 +1,121 @@ +package pw.mihou.nexus.features.commons + +import org.javacord.api.entity.message.MessageFlag +import org.javacord.api.interaction.Interaction +import org.javacord.api.interaction.InteractionBase +import org.javacord.api.interaction.callback.InteractionOriginalResponseUpdater +import org.javacord.api.util.logging.ExceptionLogger +import pw.mihou.nexus.Nexus +import pw.mihou.nexus.configuration.modules.Cancellable +import pw.mihou.nexus.features.command.responses.NexusAutoResponse +import pw.mihou.nexus.features.messages.NexusMessage +import pw.mihou.nexus.features.react.React +import java.time.Instant +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Function +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +object Deferrable { + internal fun autoDefer( + interaction: Interaction, + updater: AtomicReference?>, + ephemeral: Boolean, + response: Function + ): CompletableFuture { + var task: Cancellable? = null + val deferredTaskRan = AtomicBoolean(false) + if (updater.get() == null) { + val timeUntil = Instant.now().toEpochMilli() - interaction.creationTimestamp + .minusMillis(Nexus.configuration.global.autoDeferAfterMilliseconds) + .toEpochMilli() + + task = Nexus.launch.scheduler.launch(timeUntil) { + if (updater.get() == null) { + updater.updateAndGet { interaction.respondLater(ephemeral) }!!.exceptionally(ExceptionLogger.get()) + } + deferredTaskRan.set(true) + } + } + val future = CompletableFuture() + Nexus.launcher.launch { + try { + val message = response.apply(null) + if (!deferredTaskRan.get() && task != null) { + task.cancel(false) + } + @Suppress("NAME_SHADOWING") val updater = updater.get() + if (updater == null) { + val responder = interaction.createImmediateResponder() + if (ephemeral) { + responder.setFlags(MessageFlag.EPHEMERAL) + } + message.into(responder).respond() + .thenAccept { r -> future.complete(NexusAutoResponse(r, null)) } + .exceptionally { exception -> + future.completeExceptionally(exception) + return@exceptionally null + } + } else { + val completedUpdater = updater.join() + message.into(completedUpdater).update() + .thenAccept { r -> future.complete(NexusAutoResponse(completedUpdater, r)) } + .exceptionally { exception -> + future.completeExceptionally(exception) + return@exceptionally null + } + } + } catch (exception: Exception) { + future.completeExceptionally(exception) + } + } + return future + } +} + +/** + * Automatically answers either deferred or non-deferred based on circumstances, to configure the time that it should + * consider before deferring (this is based on time now - (interaction creation time - auto defer time)), you can + * modify [pw.mihou.nexus.configuration.modules.NexusGlobalConfiguration.autoDeferAfterMilliseconds]. + * + * For slash commands and context menus, we recommend using the Nexus event's methods instead which will enable better coordination + * with middlewares. + * + * @param ephemeral whether to respond ephemerally or not. + * @param response the response to send to Discord. + * @return the response from Discord. + */ +fun Interaction.autoDefer(ephemeral: Boolean, response: Function): CompletableFuture = + Deferrable.autoDefer(this, AtomicReference(null), ephemeral, response) + +/** + * An experimental feature to use the new Nexus.R rendering mechanism to render Discord messages + * with a syntax similar to a template engine that sports states (writables) that can easily update message + * upon state changes. + * + * This internally uses [autoDefer] to handle sending of the response, ensuring that we can handle long-running renders + * and others that may happen due to situations such as data fetching, etc. + * + * @param ephemeral whether to send the response as ephemeral or not. + * @param lifetime indicates how long before the [React] instance self-destructs to free up references. + * @param react the entire procedure over how rendering the response works. + */ +@JvmSynthetic +fun Interaction.R(ephemeral: Boolean, lifetime: Duration = 1.hours, react: React.() -> Unit): CompletableFuture { + val r = React(this.api, React.RenderMode.Interaction, lifetime) + return autoDefer(ephemeral) { + react(r) + + return@autoDefer r.message!! + }.thenApply { + val message = it.getOrRequestMessage().join() + + r.interactionUpdater = it.updater + r.acknowledgeUpdate(message) + + return@thenApply it + } +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/commons/NexusInteractionEvent.kt b/src/main/java/pw/mihou/nexus/features/commons/NexusInteractionEvent.kt index e82c1b14..75bf45ef 100644 --- a/src/main/java/pw/mihou/nexus/features/commons/NexusInteractionEvent.kt +++ b/src/main/java/pw/mihou/nexus/features/commons/NexusInteractionEvent.kt @@ -12,10 +12,16 @@ import org.javacord.api.interaction.ApplicationCommandInteraction import org.javacord.api.interaction.Interaction import org.javacord.api.interaction.callback.InteractionImmediateResponseBuilder import org.javacord.api.interaction.callback.InteractionOriginalResponseUpdater +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.command.responses.NexusAutoResponse +import pw.mihou.nexus.features.messages.NexusMessage import java.util.* import java.util.concurrent.CompletableFuture +import java.util.function.Function import java.util.function.Predicate import kotlin.NoSuchElementException +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours @JvmDefaultWithCompatibility interface NexusInteractionEvent { @@ -180,4 +186,31 @@ interface NexusInteractionEvent) = respondLaterEphemerallyIf(predicate.test(null)) + + /** + * Automatically answers either deferred or non-deferred based on circumstances, to configure the time that it should + * consider before deferring (this is based on time now - (interaction creation time - auto defer time)), you can + * modify [pw.mihou.nexus.configuration.modules.NexusGlobalConfiguration.autoDeferAfterMilliseconds]. + * + * @param ephemeral whether to respond ephemerally or not. + * @param response the response to send to Discord. + * @return the response from Discord. + */ + fun autoDefer(ephemeral: Boolean, response: Function): CompletableFuture + + /** + * An experimental feature to use the new Nexus.R rendering mechanism to render Discord messages + * with a syntax similar to a template engine that sports states (writables) that can easily update message + * upon state changes. + * + * This internally uses [autoDefer] to handle sending of the response, ensuring that we can handle long-running renders + * and others that may happen due to situations such as data fetching, etc. + * + * @param ephemeral whether to send the response as ephemeral or not. + * @param lifetime the time before the [React] instance self-destructs to free memory. + * @param react the entire procedure over how rendering the response works. + */ + @JvmSynthetic + fun R(ephemeral: Boolean = false, lifetime: Duration = 1.hours, react: React.() -> Unit): CompletableFuture = + interaction.R(ephemeral, lifetime, react) } \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/contexts/NexusContextMenuEvent.kt b/src/main/java/pw/mihou/nexus/features/contexts/NexusContextMenuEvent.kt index 0dfd67f7..0e57b147 100644 --- a/src/main/java/pw/mihou/nexus/features/contexts/NexusContextMenuEvent.kt +++ b/src/main/java/pw/mihou/nexus/features/contexts/NexusContextMenuEvent.kt @@ -1,9 +1,27 @@ package pw.mihou.nexus.features.contexts +import org.javacord.api.entity.message.MessageFlag import org.javacord.api.event.interaction.ApplicationCommandEvent +import org.javacord.api.interaction.callback.InteractionOriginalResponseUpdater +import org.javacord.api.util.logging.ExceptionLogger +import pw.mihou.nexus.Nexus +import pw.mihou.nexus.configuration.modules.Cancellable +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.command.responses.NexusAutoResponse +import pw.mihou.nexus.features.commons.Deferrable import pw.mihou.nexus.features.commons.NexusInteractionEvent +import pw.mihou.nexus.features.messages.NexusMessage +import java.time.Instant +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference +import java.util.function.Function class NexusContextMenuEvent( val contextMenu: NexusContextMenu, override val event: Event, override val interaction: Interaction -): NexusInteractionEvent \ No newline at end of file +): NexusInteractionEvent { + private var updater: AtomicReference?> = AtomicReference(null) + override fun autoDefer(ephemeral: Boolean, response: Function): CompletableFuture = + Deferrable.autoDefer(event.interaction, updater, ephemeral, response) +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/messages/ReactExtensions.kt b/src/main/java/pw/mihou/nexus/features/messages/ReactExtensions.kt new file mode 100644 index 00000000..9839ad18 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/messages/ReactExtensions.kt @@ -0,0 +1,75 @@ +package pw.mihou.nexus.features.messages + +import org.javacord.api.DiscordApi +import org.javacord.api.entity.message.Message +import org.javacord.api.entity.message.Messageable +import org.javacord.api.event.message.CertainMessageEvent +import pw.mihou.nexus.features.react.React +import java.util.concurrent.CompletableFuture +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +/** + * An internal extension that acknowledges the message result and calls [React.acknowledgeUpdate]. + * This is used to enable `onUpdate` calls to render. + * + * @param react the [React] instance. + */ +private fun CompletableFuture.ack(react: React): CompletableFuture { + return this.thenApply { message -> + react.acknowledgeUpdate(message) + return@thenApply message + } +} + +/** + * An experimental feature to use the new Nexus.R rendering mechanism to render Discord messages + * with a syntax similar to a template engine that sports states (writable) that can easily update message + * upon state changes. + * + * @param lifetime the lifetime it takes before the [React] destroys itself. + * @param react the entire procedure over how rendering the response works. + */ +@JvmSynthetic +fun CertainMessageEvent.R(lifetime: Duration = 1.hours, react: React.() -> Unit): CompletableFuture { + val r = React(this.api, React.RenderMode.Message, lifetime) + react(r) + + return r.messageBuilder!! + .replyTo(message) + .send(channel) + .ack(r) +} + +/** + * An experimental feature to use the new Nexus.R rendering mechanism to render Discord messages + * with a syntax similar to a template engine that sports states (writable) that can easily update message + * upon state changes. + * + * @param lifetime the lifetime it takes before the [React] destroys itself. + * @param react the entire procedure over how rendering the response works. + */ +@JvmSynthetic +fun Messageable.R(api: DiscordApi, lifetime: Duration = 1.hours, react: React.() -> Unit): CompletableFuture { + val r = React(api, React.RenderMode.Message, lifetime) + react(r) + + return r.messageBuilder!!.send(this).ack(r) +} + + +/** + * An experimental feature to use the new Nexus.R rendering mechanism to render Discord messages + * with a syntax similar to a template engine that sports states (writable) that can easily update message + * upon state changes. + * + * @param lifetime the lifetime it takes before the [React] destroys itself. + * @param react the entire procedure over how rendering the response works. + */ +fun Message.R(lifetime: Duration = 1.hours, react: React.() -> Unit): CompletableFuture { + val r = React(api, React.RenderMode.Message, lifetime) + react(r) + + r.acknowledgeUpdate(this) + return r.messageUpdater!!.replaceMessage().ack(r) +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/React.kt b/src/main/java/pw/mihou/nexus/features/react/React.kt new file mode 100644 index 00000000..412f59af --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/React.kt @@ -0,0 +1,622 @@ +package pw.mihou.nexus.features.react + +import org.javacord.api.DiscordApi +import org.javacord.api.entity.intent.Intent +import org.javacord.api.entity.message.Message +import org.javacord.api.entity.message.MessageBuilder +import org.javacord.api.entity.message.MessageUpdater +import org.javacord.api.entity.message.component.ActionRow +import org.javacord.api.entity.message.component.LowLevelComponent +import org.javacord.api.entity.message.embed.EmbedBuilder +import org.javacord.api.interaction.callback.InteractionOriginalResponseUpdater +import org.javacord.api.listener.GloballyAttachableListener +import org.javacord.api.listener.message.MessageDeleteListener +import org.javacord.api.util.event.ListenerManager +import pw.mihou.nexus.Nexus +import pw.mihou.nexus.configuration.modules.Cancellable +import pw.mihou.nexus.core.assignment.NexusUuidAssigner +import pw.mihou.nexus.features.messages.NexusMessage +import java.time.Instant +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import java.util.concurrent.locks.ReentrantLock +import kotlin.reflect.KProperty +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +typealias Subscription = (oldValue: T, newValue: T) -> Unit +typealias Unsubscribe = () -> Unit + +typealias RenderSubscription = () -> Unit +typealias DestroySubscription = () -> Unit +typealias UpdateSubscription = (message: Message) -> Unit +typealias ReactComponent = React.Component.() -> Unit +typealias Derive = (T) -> K + +/** + * [React] is the React-Svelte inspired method of rendering (or sending) messages as response to various scenarios such + * as message commands, slash commands, context menus and different kind of magic. We recommend using the available + * `event.R` method instead as it is mostly designed to enable this to work for your situation, or instead use the + * available `interaction.R` method for interactions. + */ +class React internal constructor(private val api: DiscordApi, private val renderMode: RenderMode, lifetime: Duration = 1.days) { + private var rendered: Boolean = false + + internal var message: NexusMessage? = null + internal var messageBuilder: MessageBuilder? = null + internal var messageUpdater: MessageUpdater? = null + internal var interactionUpdater: InteractionOriginalResponseUpdater? = null + + private var unsubscribe: Unsubscribe = {} + private var component: (Component.() -> Unit)? = null + + private var debounceTask: Cancellable? = null + private var mutex = ReentrantLock() + + internal var resultingMessage: Message? = null + + private var firstRenderSubscribers = mutableListOf() + private var renderSubscribers = mutableListOf() + + private var updateSubscribers = mutableListOf() + private var expansions = mutableListOf() + + private var destroyJob: Cancellable? = if (lifetime.isInfinite()) null else Nexus.launch.scheduler.launch(lifetime.inWholeMilliseconds) { + destroy() + } + + private var messageDeleteListenerManager: ListenerManager? = null + private var destroySubscribers = mutableListOf() + + companion object { + /** + * Defines how long we should wait before proceeding to re-render the component, this is intended to ensure + * that all other states being changed in that period is applied as well, preventing multiple unnecessary re-renders + * which can be costly as we send HTTP requests to Discord. + * + * As a default, we recommend within 25ms to 250ms depending on how long the betweens of your state changes are, + * you can go even lower as long as the states are being changed immediately, otherwise, keeping it as default + * is recommended. + */ + var debounceMillis = 25L + } + + internal enum class RenderMode { + Interaction, + Message, + UpdateMessage + } + + /** + * Subscribes a task to be ran whenever the component is being rendered, this happens on the initial render + * and re-renders but takes a lower priority than the ones in [onInitialRender]. + * @param subscription the subscription to execute on render. + */ + fun onRender(subscription: RenderSubscription) { + renderSubscribers.add(subscription) + } + + /** + * Subscribes a task to be ran whenever the component is being rendered. This happens first before the actual + * component being rendered, therefore, you can use this to load data before the component is actually rendered. + * @param subscription the subscription to execute on first render. + */ + fun onInitialRender(subscription: RenderSubscription) { + firstRenderSubscribers.add(subscription) + } + + /** + * Subscribes a task to be ran whenever the message itself updates, this can be when a re-render + * is successfully acknowledged by Discord, or when the message was sent for the first time. + * + * Not to be confused with [onRender] which executes before the message is updated and even before + * the message is rendered. + * + * @param subscription the subscription to execute on update. + */ + fun onUpdate(subscription: UpdateSubscription) { + updateSubscribers.add(subscription) + } + + /** + * Subscribes a task to be ran when the [React] instance is destroyed. This can be when the lifetime + * ends or the message is deleted. This is ran all synchronous and will block the destruction thread + * until it completes. + * + * @param subscription the subscription to execute. + */ + fun onDestroy(subscription: DestroySubscription) { + destroySubscribers.add(subscription) + } + + /** + * An internal function to update the [resultingMessage] and run all the [updateSubscribers]. + * @param message the message resulting from a render. + */ + internal fun acknowledgeUpdate(message: Message) { + this.resultingMessage = message + if (api.intents.contains(Intent.GUILD_MESSAGES) || api.intents.contains(Intent.DIRECT_MESSAGES)) { + messageDeleteListenerManager?.remove() + messageDeleteListenerManager = this.resultingMessage?.run { + api.addMessageDeleteListener { + destroy() + } + } + } + updateSubscribers.forEach { + try { + it(message) + } catch (err: Exception) { + Nexus.logger.error("An uncaught exception was received by Nexus.R's update subscription dispatcher with the following stacktrace.", err) + } + } + } + + /** + * Destroys any references to this [React] instance. It is recommended to do this when + * you no longer need the interactivity as this will free up a ton of unused memory that should've + * been free. + */ + fun destroy() { + synchronized(destroySubscribers) { + destroySubscribers.forEach(DestroySubscription::invoke) + + unsubscribe() + component = null + this.unsubscribe = {} + this.destroySubscribers = mutableListOf() + this.resultingMessage = null + this.interactionUpdater = null + this.updateSubscribers = mutableListOf() + this.messageUpdater = null + this.messageBuilder = null + this.renderSubscribers = mutableListOf() + this.message = null + this.debounceTask = null + this.destroyJob = null + this.expansions.forEach(Unsubscribe::invoke) + this.expansions = mutableListOf() + this.messageDeleteListenerManager?.remove() + this.messageDeleteListenerManager = null + } + } + + /** + * Renders the given component, this will also be used to re-render the component onwards. Note that using two + * renders will result in the last executed render being used. + * @param component the component to render. + */ + fun render(component: ReactComponent) { + try { + val element = apply(component) + + when (renderMode) { + RenderMode.Interaction -> { + val (unsubscribe, message) = element.render(api) + this.message = message + this.unsubscribe = unsubscribe + } + + RenderMode.Message -> { + val builder = MessageBuilder() + val unsubscribe = element.render(builder, api) + + this.messageBuilder = builder + this.unsubscribe = unsubscribe + } + RenderMode.UpdateMessage -> { + if (resultingMessage == null) { + throw IllegalStateException("Updating a message with React needs `resultingMessage` to not be null.") + } + val updater = MessageUpdater(resultingMessage) + val unsubscribe = element.render(updater, api) + + this.messageUpdater = updater + this.unsubscribe = unsubscribe + } + } + this.rendered = true + } catch (err: Exception) { + Nexus.logger.error("An uncaught exception was received by Nexus.R's renderer with the following stacktrace.", err) + } + } + + private fun apply(component: Component.() -> Unit): Component { + this.component = component + val element = Component() + + if (!rendered) { + firstRenderSubscribers.forEach { + try { + it() + } catch (err: Exception) { + Nexus.logger.error("An uncaught exception was received by Nexus.R's initial render subscription dispatcher with the following stacktrace.", err) + } + } + } + + renderSubscribers.forEach { + try { + it() + } catch (err: Exception) { + Nexus.logger.error("An uncaught exception was received by Nexus.R's render subscription dispatcher with the following stacktrace.", err) + } + } + + component(element) + return element + } + + /** + * Creates a [Writable] that can react to changes of the value, allowing you to re-render the message + * with the new states. Internally, this simply creates a [Writable] then adds a subscriber to re-render + * the message whenever the state changes (debounced by [React.debounceMillis] milliseconds). + * + * We recommend using [Writable]'s constructor and then using [React.expand] to add the subscriber whenever you need + * to create a [Writable] outside of the [React] scope. (View the source code of this method to see how it looks). + * + * To pass a [Writable], we recommend creating another variable that has the [Writable] itself as the value and another + * one that uses `by` and then passing the [Writable] instead. (Refer to the wiki for this, as function parameters are not mutable + * and delegated variables pass their [Writable.getValue] result, so changes cannot be listened). + * + * @param value the initial value. + * @return a [Writable] with a [Subscription] that will re-render the [ReactComponent] when the state changes. + */ + fun writable(value: T): Writable { + val element = Writable(value) + return expand(element) + } + + /** + * Adds a [Subscription] that enables the [ReactComponent] to be re-rendered whenever the value of the [Writable] + * changes, this is what [writable] uses internally to react to changes. + * @param writable the writable to subscribe. + * @return the [Writable] with the re-render subscription attached. + */ + fun expand(writable: Writable): Writable { + val stateUnsubscribe = writable.subscribe { _, _ -> + try { + if (!mutex.tryLock()) return@subscribe + val component = this.component ?: return@subscribe + debounceTask?.cancel(false) + debounceTask = Nexus.launch.scheduler.launch(debounceMillis) { + this.unsubscribe() + + debounceTask = null + if (this.component == null) return@launch + val interactionUpdater = interactionUpdater + if (interactionUpdater != null) { + val view = apply(component) + this.unsubscribe = view.render(interactionUpdater, api) + interactionUpdater.update().exceptionally { + Nexus.logger.error("Failed to re-render message using Nexus.R with the following stacktrace.", it) + return@exceptionally null + }.thenAccept(::acknowledgeUpdate) + } else { + val message = resultingMessage + if (message != null) { + val updater = message.createUpdater() + val view = apply(component) + this.unsubscribe = view.render(updater, api) + updater.replaceMessage().exceptionally { + Nexus.logger.error("Failed to re-render message using Nexus.R with the following stacktrace.", it) + return@exceptionally null + }.thenAccept(::acknowledgeUpdate) + } + } + } + mutex.unlock() + } catch (err: Exception) { + Nexus.logger.error("Failed to re-render message using Nexus.R with the following stacktrace.", err) + } + } + expansions.add(stateUnsubscribe) + return writable + } + + /** + * Creates a new [ReadOnly] state that has a value derived of this [Writable], which means that the value + * of the new [ReadOnly] state changes whenever the value of the current [Writable] changes. + * + * Note: This does not inherit the subscriptions of the [Writable]. + * + * Different from [Writable.derive] itself, this has the re-render subscription which will make changes to the + * origin [Writable] also signal the system to re-render. This is intended to be used for cases where the origin + * [Writable] is not subscribed to the re-render subscription, for some reason. + * + * @param modifier the action to do to mutate the value into the desired value. + * @return a new [ReadOnly] state that is derived from the current [Writable]. + */ + fun derive(writable: Writable, modifier: Derive) = writable.derive(modifier).apply { + expand(this.writable) + } + + /** + * [ReadOnly] is a state similar to [Writable], but instead, only the getters are exposed to the public. It's a + * simple wrapper around [Writable] and is used by [Writable.derive] to enable read-only states. As it is of + * no-use to external code, [ReadOnly] can only be created internally by Nexus. + */ + class ReadOnly internal constructor(value: T) { + internal val writable = Writable(value) + internal fun set(value: T) = writable.set(value) + + /** + * Gets the value of this [Writable]. This is intended to be used for delegation. You may be looking for + * [get] instead which allows you to directly get the value. + */ + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + return writable.get() + } + + /** + * Gets the current value of the [Writable]. If you need to listen to changes to the value, + * use the [subscribe] method instead to subscribe to changes. + * + * @return the value of the [Writable]. + */ + fun get(): T = writable.get() + + /** + * Subscribes to changes to the value of the [Writable]. This is ran asynchronously after the value has + * been changed. + * + * @param subscription the task to execute upon a change to the value is detected. + * @return an [Unsubscribe] method to unsubscribe the [Subscription]. + */ + infix fun subscribe(subscription: Subscription): Unsubscribe = writable.subscribe(subscription) + } + + /** + * Writable are the equivalent to state in React.js, or [writable] in Svelte (otherwise known as `$state` in Svelte Runes), + * these are simply properties that will execute subscribed tasks whenever the property changes, enabling reactivity. + * [React] uses this to support re-rendering the [ReactComponent] whenever a state changes, allowing developers to write + * incredibly reactive yet beautifully simple code that are similar to Svelte. + * + * We recommend using the constructor method to create a [Writable] for use cases outside of [React] scope, otherwise + * use the [writable] method to create a [Writable] inside of [React] scope which will have a re-render subscription. + * (Read the wiki for more information). + * + */ + class Writable(value: T) { + private val subscribers: MutableList> = mutableListOf() + private val _value: AtomicReference = AtomicReference(value) + + /** + * Gets the value of this [Writable]. This is intended to be used for delegation. You may be looking for + * [get] instead which allows you to directly get the value. + */ + operator fun getValue(thisRef: Any?, property: KProperty<*>): T { + return _value.get() + } + + /** + * Sets the value of this [Writable]. This is intended to be used for delegation. You may be looking for + * [set] or [update] instead which allows you to manipulate the [Writable]'s value. + */ + operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + set(value) + } + + /** + * Manipulates the value of the [Writable]. + * This will run all the subscriptions asynchronously after the value has been changed, ensuring that + * all subscriptions are executed without interfering or delaying one another. + * + * When performing things such as increment, decrements, or anything that requires the current value, we + * recommend using [update] instead which will allow you to atomically update the value. + * + * @param value the new value of the [Writable]. + */ + infix fun set(value: T) { + val oldValue = _value.get() + _value.set(value) + + this.react(oldValue, value) + } + + /** + * Atomically updates the value of the [Writable]. This is recommended to use when manipulating the value of, say + * a numerical value, for instance, incrementing, decrementing, multiplying, etc. as this is performed atomically + * which stops a lot of thread-unsafety. + * + * Similar to [set], this executes all the subscriptions asynchronously. + * @param updater the updater to update the value of the [Writable]. + */ + infix fun update(updater: (T) -> T) { + val oldValue = _value.get() + _value.getAndUpdate(updater) + + val value = _value.get() + this.react(oldValue, value) + } + + /** + * Gets the current value of the [Writable]. If you need to listen to changes to the value, + * use the [subscribe] method instead to subscribe to changes. + * + * @return the value of the [Writable]. + */ + fun get(): T = _value.get() + + /** + * Subscribes to changes to the value of the [Writable]. This is ran asynchronously after the value has + * been changed. + * + * @param subscription the task to execute upon a change to the value is detected. + * @return an [Unsubscribe] method to unsubscribe the [Subscription]. + */ + infix fun subscribe(subscription: Subscription): Unsubscribe { + subscribers.add(subscription) + return { subscribers.remove(subscription) } + } + + /** + * Reacts to the change and executes all the subscriptions that were subscribed at the + * time of execution. + * + * @param oldValue the old value. + * @param value the current value. + */ + internal fun react(oldValue: T, value: T) { + subscribers.forEach { Nexus.launcher.launch { + try { + it(oldValue, value) + } catch (err: Exception) { + Nexus.logger.error("An uncaught exception was received by Nexus.R's writable subscriptions with the following stacktrace.", err) + } + } } + } + + /** + * Creates a new [ReadOnly] state that has a value derived of this [Writable], which means that the value + * of the new [ReadOnly] state changes whenever the value of the current [Writable] changes. + * + * Note: This does not inherit the subscriptions of the [Writable], which means that subscriptions such as + * re-rendering is not inherited, but it's not as needed as the value + * + * @param modifier the action to do to mutate the value into the desired value. + * @return a new [ReadOnly] state that is derived from the current [Writable]. + */ + infix fun derive(modifier: Derive): ReadOnly { + val currentValue = get() + val state = ReadOnly(modifier(currentValue)) + + this.subscribe { _, newValue -> + state.set(modifier(newValue)) + } + return state + } + + override fun toString(): String { + return _value.get().toString() + } + + override fun hashCode(): Int { + return _value.get().hashCode() + } + + override fun equals(other: Any?): Boolean { + val value = _value.get() + if (other == null && value == null) return true + if (other == null) return false + return value == other + } + } + + /** + * An internal class of [React]. You do not need to touch this at all, and it is not recommended to even create + * this by yourself as it will do nothing. + */ + class Component internal constructor() { + internal var embeds: MutableList = mutableListOf() + internal var contents: String? = null + internal var components: MutableList = mutableListOf() + internal var listeners: MutableList = mutableListOf() + internal var uuids: MutableList = mutableListOf() + + private fun attachListeners(api: DiscordApi): Unsubscribe { + val listenerManagers = listeners.map { api.addListener(it) } + return { + listenerManagers.forEach { managers -> + managers.forEach { it.remove() } + } + listeners = mutableListOf() + + components = mutableListOf() + contents = null + embeds = mutableListOf() + + uuids.forEach { NexusUuidAssigner.deny(it) } + uuids = mutableListOf() + } + } + + private fun chunkComponents(): List { + val actionRows = mutableListOf() + var lowLevelComponents = mutableListOf() + + for ((index, component) in components.withIndex()) { + if (component.isSelectMenu) { + actionRows += ActionRow.of(component) + continue + } else { + if (lowLevelComponents.size >= 3) { + actionRows += ActionRow.of(lowLevelComponents) + lowLevelComponents = mutableListOf() + } + + lowLevelComponents += component + } + + if (index == (components.size - 1) && lowLevelComponents.size <= 3) { + actionRows += ActionRow.of(lowLevelComponents) + lowLevelComponents = mutableListOf() + } + } + + if (lowLevelComponents.isNotEmpty() && lowLevelComponents.size <= 3) { + actionRows += ActionRow.of(lowLevelComponents) + } + + return actionRows + } + + fun render(api: DiscordApi): Pair { + return attachListeners(api) to NexusMessage.with { + this.removeAllEmbeds() + this.addEmbeds(embeds) + + if (contents != null) { + this.setContent(contents) + } + + chunkComponents().forEach { this.addComponents(it) } + } + } + + // TODO: Reduce code duplication by using MessageBuilderBase (currently package-private) + // https://discord.com/channels/151037561152733184/151326093482262528/1163425854186065951 + fun render(updater: MessageUpdater, api: DiscordApi): Unsubscribe { + updater.apply { + this.removeAllEmbeds() + this.addEmbeds(embeds) + + if (contents != null) { + this.setContent(contents) + } + chunkComponents().forEach { this.addComponents(it) } + } + return attachListeners(api) + } + + fun render(builder: MessageBuilder, api: DiscordApi): Unsubscribe { + builder.apply { + this.removeAllEmbeds() + this.addEmbeds(embeds) + + if (contents != null) { + this.setContent(contents) + } + chunkComponents().forEach { this.addComponents(it) } + } + return attachListeners(api) + } + + + fun render(updater: InteractionOriginalResponseUpdater, api: DiscordApi): Unsubscribe{ + updater.apply { + this.removeAllEmbeds() + this.removeAllComponents() + + this.addEmbeds(embeds) + + if (contents != null) { + this.setContent(contents) + } + chunkComponents().forEach { this.addComponents(it) } + } + return attachListeners(api) + } + } +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/elements/Button.kt b/src/main/java/pw/mihou/nexus/features/react/elements/Button.kt new file mode 100644 index 00000000..59c6bdba --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/elements/Button.kt @@ -0,0 +1,78 @@ +package pw.mihou.nexus.features.react.elements + +import org.javacord.api.entity.message.component.ButtonBuilder +import org.javacord.api.entity.message.component.ButtonStyle +import org.javacord.api.event.interaction.ButtonClickEvent +import org.javacord.api.listener.interaction.ButtonClickListener +import pw.mihou.nexus.core.assignment.NexusUuidAssigner +import pw.mihou.nexus.features.react.React + +fun React.Component.PrimaryButton( + label: String, + customId: String? = null, + emoji: String? = null, + disabled: Boolean = false, + onClick: ((event: ButtonClickEvent) -> Unit)? = null +) = Button(ButtonStyle.PRIMARY, label, customId, emoji, disabled, onClick) + +fun React.Component.SecondaryButton( + label: String, + customId: String? = null, + emoji: String? = null, + disabled: Boolean = false, + onClick: ((event: ButtonClickEvent) -> Unit)? = null +) = Button(ButtonStyle.SECONDARY, label, customId, emoji, disabled, onClick) + +fun React.Component.SuccessButton( + label: String, + customId: String? = null, + emoji: String? = null, + disabled: Boolean = false, + onClick: ((event: ButtonClickEvent) -> Unit)? = null +) = Button(ButtonStyle.SUCCESS, label, customId, emoji, disabled, onClick) + +fun React.Component.DangerButton( + label: String, + customId: String? = null, + emoji: String? = null, + disabled: Boolean = false, + onClick: ((event: ButtonClickEvent) -> Unit)? = null +) = Button(ButtonStyle.DANGER, label, customId, emoji, disabled, onClick) + +fun React.Component.Button( + style: ButtonStyle = ButtonStyle.PRIMARY, + label: String, + customId: String? = null, + emoji: String? = null, + disabled: Boolean = false, + onClick: ((event: ButtonClickEvent) -> Unit)? = null +) { + val button = ButtonBuilder() + button.setStyle(style) + button.setLabel(label) + + if (emoji != null) { + button.setEmoji(emoji) + } + + button.setDisabled(disabled) + + val uuid = customId ?: run { + val id = NexusUuidAssigner.request() + uuids.add(id) + return@run id + } + button.setCustomId(uuid) + + if (onClick != null) { + listeners += ButtonClickListener { + if (it.buttonInteraction.customId != uuid) { + return@ButtonClickListener + } + + onClick(it) + } + } + + components += button.build() +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/elements/Embed.kt b/src/main/java/pw/mihou/nexus/features/react/elements/Embed.kt new file mode 100644 index 00000000..113de964 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/elements/Embed.kt @@ -0,0 +1,180 @@ +package pw.mihou.nexus.features.react.elements + +import org.javacord.api.entity.Icon +import org.javacord.api.entity.message.MessageAuthor +import org.javacord.api.entity.message.embed.EmbedBuilder +import org.javacord.api.entity.user.User +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.react.styles.TextStyles +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.File +import java.io.InputStream +import java.time.Instant + +fun React.Component.Embed(embed: Embed.() -> Unit) { + val element = Embed() + embed(element) + + embeds.add(element.embed) +} + +class Embed: TextStyles { + internal val embed = EmbedBuilder() + + fun Title(text: String) { + embed.setTitle(text) + } + + fun Body(vararg nodes: String) { + embed.setDescription(nodes.joinToString("")) + } + fun SpacedBody(vararg nodes: String) { + embed.setDescription(nodes.joinToString("\n")) + } + fun Body(spaced: Boolean = false, builder: MutableList.() -> Unit) { + val backing = mutableListOf() + builder(backing) + embed.setDescription(if (spaced) backing.joinToString("\n") else backing.joinToString()) + } + fun Field(name: String, inline: Boolean = false, vararg nodes: String) { + embed.addField(name, nodes.joinToString(""), inline) + } + fun Field(name: String, inline: Boolean = false, spaced: Boolean = false, builder: MutableList.() -> Unit) { + val backing = mutableListOf() + builder(backing) + embed.addField(name, if (spaced) backing.joinToString("\n") else backing.joinToString(), inline) + } + fun Image(url: String) { + embed.setImage(url) + } + fun Image(image: Icon) { + embed.setImage(image) + } + fun Image(image: File) { + embed.setImage(image) + } + fun Image(image: InputStream) { + embed.setImage(image) + } + fun Image(image: InputStream, fileType: String) { + embed.setImage(image, fileType) + } + fun Image(image: ByteArray) { + embed.setImage(image) + } + fun Image(image: ByteArray, fileType: String) { + embed.setImage(image, fileType) + } + fun Image(image: BufferedImage) { + embed.setImage(image) + } + fun Image(image: BufferedImage, fileType: String) { + embed.setImage(image, fileType) + } + fun Thumbnail(url: String) { + embed.setThumbnail(url) + } + fun Thumbnail(thumbnail: Icon) { + embed.setThumbnail(thumbnail) + } + fun Thumbnail(thumbnail: File) { + embed.setThumbnail(thumbnail) + } + fun Thumbnail(thumbnail: InputStream) { + embed.setThumbnail(thumbnail) + } + fun Thumbnail(thumbnail: InputStream, fileType: String) { + embed.setThumbnail(thumbnail, fileType) + } + fun Thumbnail(thumbnail: ByteArray) { + embed.setThumbnail(thumbnail) + } + fun Thumbnail(thumbnail: ByteArray, fileType: String) { + embed.setThumbnail(thumbnail, fileType) + } + fun Thumbnail(thumbnail: BufferedImage) { + embed.setThumbnail(thumbnail) + } + fun Thumbnail(thumbnail: BufferedImage, fileType: String) { + embed.setThumbnail(thumbnail, fileType) + } + fun Color(color: Color) { + embed.setColor(color) + } + fun Color(hex: Int) { + embed.setColor(java.awt.Color(hex)) + } + fun Timestamp(timestamp: Instant) { + embed.setTimestamp(timestamp) + } + fun CurrentTimestamp() { + embed.setTimestampToNow() + } + fun Footer(text: String) { + embed.setFooter(text) + } + fun Footer(text: String, iconUrl: String) { + embed.setFooter(text, iconUrl) + } + fun Footer(text: String, icon: Icon) { + embed.setFooter(text, icon) + } + fun Footer(text: String, icon: File) { + embed.setFooter(text, icon) + } + fun Footer(text: String, icon: InputStream) { + embed.setFooter(text, icon) + } + fun Footer(text: String, icon: InputStream, fileType: String) { + embed.setFooter(text, icon, fileType) + } + fun Footer(text: String, icon: ByteArray) { + embed.setFooter(text, icon) + } + fun Footer(text: String, icon: ByteArray, fileType: String) { + embed.setFooter(text, icon, fileType) + } + fun Footer(text: String, icon: BufferedImage) { + embed.setFooter(text, icon) + } + fun Footer(text: String, icon: BufferedImage, fileType: String) { + embed.setFooter(text, icon, fileType) + } + fun Author(name: String) { + embed.setAuthor(name) + } + fun Author(author: MessageAuthor) { + embed.setAuthor(author) + } + fun Author(author: User) { + embed.setAuthor(author) + } + fun Author(name: String, url: String, iconUrl: String) { + embed.setAuthor(name, url, iconUrl) + } + fun Author(name: String, url: String, icon: Icon) { + embed.setAuthor(name, url, icon) + } + fun Author(name: String, url: String, icon: File) { + embed.setAuthor(name, url, icon) + } + fun Author(name: String, url: String, icon: InputStream) { + embed.setAuthor(name, url, icon) + } + fun Author(name: String, url: String, icon: InputStream, fileType: String) { + embed.setAuthor(name, url, icon, fileType) + } + fun Author(name: String, url: String, icon: ByteArray) { + embed.setAuthor(name, url, icon) + } + fun Author(name: String, url: String, icon: ByteArray, fileType: String) { + embed.setAuthor(name, url, icon, fileType) + } + fun Author(name: String, url: String, icon: BufferedImage) { + embed.setAuthor(name, url, icon) + } + fun Author(name: String, url: String, icon: BufferedImage, fileType: String) { + embed.setAuthor(name, url, icon, fileType) + } +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/elements/SelectMenu.kt b/src/main/java/pw/mihou/nexus/features/react/elements/SelectMenu.kt new file mode 100644 index 00000000..9e1efb58 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/elements/SelectMenu.kt @@ -0,0 +1,159 @@ +@file:Suppress("FunctionName") +package pw.mihou.nexus.features.react.elements + +import org.javacord.api.entity.channel.ChannelType +import org.javacord.api.entity.message.component.ComponentType +import org.javacord.api.entity.message.component.SelectMenuBuilder +import org.javacord.api.entity.message.component.SelectMenuOption +import org.javacord.api.entity.message.component.SelectMenuOptionBuilder +import org.javacord.api.event.interaction.SelectMenuChooseEvent +import org.javacord.api.listener.interaction.SelectMenuChooseListener +import pw.mihou.nexus.core.assignment.NexusUuidAssigner +import pw.mihou.nexus.features.react.React + +fun React.Component.SelectMenu( + componentType: ComponentType, + customId: String = NexusUuidAssigner.request(), + minimumValues: Int = 1, + maximumValues: Int = 1, + disabled: Boolean = false, + onSelect: ((event: SelectMenuChooseEvent) -> Unit)? = null, + selectMenu: SelectMenu.() -> Unit +) { + val element = SelectMenu( + SelectMenuBuilder(componentType, customId) + .setDisabled(disabled) + .setMaximumValues(maximumValues) + .setMinimumValues(minimumValues)) + selectMenu(element) + + if (onSelect != null) { + listeners += SelectMenuChooseListener { + if (it.selectMenuInteraction.customId != customId) { + return@SelectMenuChooseListener + } + + onSelect(it) + } + } + + components += element.selectMenu.build() +} + +fun React.Component.ChannelSelectMenu( + types: Set, + placeholder: String? = null, + customId: String = NexusUuidAssigner.request(), + minimumValues: Int = 1, + maximumValues: Int = 1, + disabled: Boolean = false, + onSelect: ((event: SelectMenuChooseEvent) -> Unit)? = null +) = SelectMenu( + componentType = ComponentType.SELECT_MENU_CHANNEL, + customId = customId, + minimumValues = minimumValues, + maximumValues = maximumValues, + disabled = disabled, + onSelect = onSelect +) { + types.forEach(::ChannelType) + placeholder?.let { Placeholder(it) } +} + +fun React.Component.ChannelSelectMenu( + placeholder: String? = null, + customId: String = NexusUuidAssigner.request(), + minimumValues: Int = 1, + maximumValues: Int = 1, + disabled: Boolean = false, + onSelect: ((event: SelectMenuChooseEvent) -> Unit)? = null +) = SelectMenu( + componentType = ComponentType.SELECT_MENU_CHANNEL, + customId = customId, + minimumValues = minimumValues, + maximumValues = maximumValues, + disabled = disabled, + onSelect = onSelect +) { + placeholder?.let { Placeholder(it) } +} + +fun React.Component.UserSelectMenu( + placeholder: String? = null, + customId: String = NexusUuidAssigner.request(), + minimumValues: Int = 1, + maximumValues: Int = 1, + disabled: Boolean = false, + onSelect: ((event: SelectMenuChooseEvent) -> Unit)? = null +) = SelectMenu( + componentType = ComponentType.SELECT_MENU_USER, + customId = customId, + minimumValues = minimumValues, + maximumValues = maximumValues, + disabled = disabled, + onSelect = onSelect +) { + placeholder?.let { Placeholder(it) } +} + +fun React.Component.MentionableSelectMenu( + placeholder: String? = null, + customId: String = NexusUuidAssigner.request(), + minimumValues: Int = 1, + maximumValues: Int = 1, + disabled: Boolean = false, + onSelect: ((event: SelectMenuChooseEvent) -> Unit)? = null +) = SelectMenu( + componentType = ComponentType.SELECT_MENU_MENTIONABLE, + customId = customId, + minimumValues = minimumValues, + maximumValues = maximumValues, + disabled = disabled, + onSelect = onSelect +) { + placeholder?.let { Placeholder(it) } +} + +fun React.Component.SelectMenu( + options: List, + placeholder: String? = null, + customId: String = NexusUuidAssigner.request(), + minimumValues: Int = 1, + maximumValues: Int = 1, + disabled: Boolean = false, + onSelect: ((event: SelectMenuChooseEvent) -> Unit)? = null +) = SelectMenu( + componentType = ComponentType.SELECT_MENU_CHANNEL, + customId = customId, + minimumValues = minimumValues, + maximumValues = maximumValues, + disabled = disabled, + onSelect = onSelect +) { + options.forEach(::Option) + placeholder?.let { Placeholder(it) } +} + +class SelectMenu(internal val selectMenu: SelectMenuBuilder) { + fun ChannelType(type: ChannelType) { + selectMenu.addChannelType(type) + } + + fun Option(option: SelectMenuOption) { + selectMenu.addOption(option) + } + + fun Option(builder: SelectMenuOptionBuilder.() -> Unit) { + val original = SelectMenuOptionBuilder() + builder(original) + selectMenu.addOption(original.build()) + } + + fun Options(vararg options: SelectMenuOption) { + selectMenu.addOptions(options.toList()) + } + + fun Placeholder(text: String) { + selectMenu.setPlaceholder(text) + } +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/elements/Text.kt b/src/main/java/pw/mihou/nexus/features/react/elements/Text.kt new file mode 100644 index 00000000..2534543a --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/elements/Text.kt @@ -0,0 +1,26 @@ +package pw.mihou.nexus.features.react.elements + +import pw.mihou.nexus.features.react.React +import pw.mihou.nexus.features.react.styles.TextStyles + +fun React.Component.Text(text: Text.() -> Unit) { + val element = Text() + text(element) + + contents = element.content +} + +class Text: TextStyles { + internal var content: String = "" + fun Body(vararg nodes: String) { + content = nodes.joinToString("") + } + fun SpacedBody(vararg nodes: String) { + content = nodes.joinToString("\n") + } + fun Body(spaced: Boolean = false, builder: MutableList.() -> Unit) { + val backing = mutableListOf() + builder(backing) + content = if (spaced) backing.joinToString("\n") else backing.joinToString() + } +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/elements/UrlButton.kt b/src/main/java/pw/mihou/nexus/features/react/elements/UrlButton.kt new file mode 100644 index 00000000..07f34564 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/elements/UrlButton.kt @@ -0,0 +1,18 @@ +package pw.mihou.nexus.features.react.elements + +import org.javacord.api.entity.message.component.ButtonBuilder +import org.javacord.api.entity.message.component.ButtonStyle +import pw.mihou.nexus.features.react.React + +fun React.Component.UrlButton(label: String, url: String, emoji: String? = null) { + val button = ButtonBuilder() + button.setStyle(ButtonStyle.LINK) + button.setLabel(label) + button.setUrl(url) + + if (emoji != null) { + button.setEmoji(emoji) + } + + components += button.build() +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/styles/TextStyles.kt b/src/main/java/pw/mihou/nexus/features/react/styles/TextStyles.kt new file mode 100644 index 00000000..eed9f9aa --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/styles/TextStyles.kt @@ -0,0 +1,213 @@ +package pw.mihou.nexus.features.react.styles + +import pw.mihou.nexus.features.react.React +import java.time.Instant + +interface TextStyles { + private fun renderTextStyles(bold: Boolean = false, underline: Boolean = false, italic: Boolean = false, + strikethrough: Boolean = false, spoiler: Boolean = false, + highlighted: Boolean = false): Pair { + var prefix = "" + var suffix = "" + + if (bold) { + prefix += "**" + suffix += "**" + if (italic) { + prefix += "*" + suffix += "*" + } + } + + if (underline) { + prefix += "__" + suffix = "__$suffix" + } + + if (italic && !bold) { + prefix += "*" + suffix = "*$suffix" + } + + if (strikethrough) { + prefix += "~~" + suffix = "~~$suffix" + } + + if (highlighted) { + prefix += "`" + suffix = "`$suffix" + } + + if (spoiler) { + prefix = "||$prefix" + suffix = "$suffix||" + } + + return prefix to suffix + } + + /** + * Renders a regular text content, unlike methods such as [bold], [italic], you can write this as unstyled + * or stack different stylings over the text through parameters. + * + * @param text the text to render. + * @param bold whether to make the text bold. + * @param underline whether to underline the text. + * @param italic whether to make the text italic. + * @param strikethrough whether to add a strikethrough to the text. + * @param spoiler whether to hide the text behind a spoiler. + */ + fun p(text: String, bold: Boolean = false, underline: Boolean = false, italic: Boolean = false, + strikethrough: Boolean = false, spoiler: Boolean = false, highlighted: Boolean = false): String { + val (prefix, suffix) = renderTextStyles(bold, underline, italic, strikethrough, spoiler, highlighted) + return prefix + text + suffix + } + + /** + * Renders a bold text. We recommend using [p] when you want to stack different + * styling over one another. + * + * @param text the text to render. + */ + fun bold(text: String) = "**$text**" + + /** + * Renders an italic text. We recommend using [p] when you want to stack different + * styling over one another. + * + * @param text the text to render. + */ + fun italic(text: String) = "*$text*" + + /** + * Renders a highlighted text. We recommend using [p] when you want to stack different + * styling over one another. + * + * @param text the text to render. + */ + fun mark(text: String) = "`$text`" + + /** + * Renders a spoiler text. We recommend using [p] when you want to stack different + * styling over one another. + * + * @param text the text to render. + */ + fun spoiler(text: String) = "||$text||" + + /** + * Renders a text with a strikethrough. We recommend using [p] when you want to stack different + * styling over one another. + * + * @param text the text to render. + */ + fun del(text: String) = "~~$text~~" + + /** + * Renders a next line, we recommend using `StyledBody` instead when next text has a next line. + */ + fun br(): String = "\n" + + /** + * Renders a link text that allows users to click and be redirected to the [href]. + * + * @param text the text to render. + * @param bold whether to make the text bold. + * @param underline whether to underline the text. + * @param italic whether to make the text italic. + * @param strikethrough whether to add a strikethrough to the text. + * @param spoiler whether to hide the text behind a spoiler. + * @param highlighted whether to make the text highlighted. + */ + fun link(text: String, href: String, bold: Boolean = false, underline: Boolean = false, italic: Boolean = false, + strikethrough: Boolean = false, spoiler: Boolean = false, highlighted: Boolean = false): String { + val (prefix, suffix) = renderTextStyles(bold, underline, italic, strikethrough, spoiler, highlighted) + return "$prefix[$text]($href)$suffix" + } + + /** + * Renders a main title heading text. + * @param text the text to render. + */ + fun h1(text: String): String { + return "# $text" + } + + /** + * Renders a subsection heading text. + * @param text the text to render. + */ + fun h2(text: String): String { + return "## $text" + } + + /** + * Renders a sub-subsection heading text. + * @param text the text to render. + */ + fun h3(text: String): String { + return "### $text" + } + + /** + * Renders an unordered list with the given nodes. This cannot be used to create nested + * unordered lists. + * + * @param nodes the nodes to include in the unordered list. + */ + fun ul(vararg nodes: String): String { + return nodes.joinToString("\n") { "* $it" } + } + + /** + * Renders an ordered list with the given nodes. This cannot be used to create nested + * ordered lists. + * + * @param nodes the nodes to include in the unordered list. + */ + fun ol(vararg nodes: String): String { + var text = "" + for ((index, node) in nodes.withIndex()) { + text += "${index + 1}. $node\n" + } + return text + } + + /** + * Renders the texts inside a codeblock. + * + * @param language the language to use to highlight the nodes. + * @param nodes the nodes to include in the codeblock. + */ + fun codeblock(language: String, vararg nodes: String): String { + return "```$language\n${nodes.joinToString("\n")}\n```" + } + + /** + * Renders the texts under a blockquote. + * + * @param nodes the texts to write inside a blockquote. + */ + fun blockquote(vararg nodes: String): String { + return nodes.joinToString("\n") { "> $it"} + } + + /** + * Renders the [Instant] as a timestamp in Discord. By default, this uses the relative time format, but you + * can customize it to show different outputs. + * + * **Timestamp Formats**: + * 1. **Relative** ([TimeFormat.RELATIVE]): shows a relative time (e.g. 30 seconds ago) + * 2. **Short Time** ([TimeFormat.SHORT_TIME]): shows the time without the seconds (e.g. 10:07 PM) + * 3. **Long Time** ([TimeFormat.LONG_TIME]): shows the time with the seconds (e.g. 10:07:00 PM) + * 4. **Short Date** ([TimeFormat.SHORT_DATE]): shows the date with the month as a number and the year shortened (e.g. 10/17/23) + * 5. **Long Date** ([TimeFormat.LONG_DATE]): shows the date with the full month name and the full year (e.g. October 17, 2023) + * 6. **Long Date With Short Time** ([TimeFormat.LONG_DATE_WITH_SHORT_TIME]): shows the date with a short time (e.g. October 17, 2023 at 10:07 PM) + * 7. **Long Date With Day of Week and Short Time** ([TimeFormat.LONG_DATE_WITH_DAY_OF_WEEK_AND_SHORT_TIME]): shows the date with the short time and the day of the week (e.g. Tuesday, October 17, 2023 at 10:07 PM) + * + * @param instant the [Instant] to render as a timestamp. + * @param format the format of the timestamp. + */ + fun time(instant: Instant, format: TimeFormat = TimeFormat.RELATIVE) = "" +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/styles/TimeFormat.kt b/src/main/java/pw/mihou/nexus/features/react/styles/TimeFormat.kt new file mode 100644 index 00000000..8414be02 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/styles/TimeFormat.kt @@ -0,0 +1,38 @@ +package pw.mihou.nexus.features.react.styles + +enum class TimeFormat(val suffix: String) { + /** + * Shows the time without the seconds (e.g. 10:07 PM) + */ + SHORT_TIME("t"), + + /** + * Shows the time with the seconds (e.g. 10:07:00 PM) + */ + LONG_TIME("T"), + + /** + * Shows the date with the month as a number and the year shortened (e.g. 10/17/23) + */ + SHORT_DATE("d"), + + /** + * Shows the date with the full month name and the full year (e.g. October 17, 2023) + */ + LONG_DATE("D"), + + /** + * Shows the date with a short time (e.g. October 17, 2023 at 10:07 PM). + */ + LONG_DATE_WITH_SHORT_TIME("f"), + + /** + * Shows the date with the short time and the day of the week (e.g. Tuesday, October 17, 2023 at 10:07 PM) + */ + LONG_DATE_WITH_DAY_OF_WEEK_AND_SHORT_TIME("F"), + + /** + * Shows a relative time (e.g. 30 seconds ago). + */ + RELATIVE("R"); +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/Byte.kt b/src/main/java/pw/mihou/nexus/features/react/writable/Byte.kt new file mode 100644 index 00000000..ef0920ac --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/Byte.kt @@ -0,0 +1,72 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: Byte): React.Writable { + this.update { (it + number).toByte() } + return this +} + +operator fun React.Writable.plusAssign(number: Byte) { + this.plus(number) +} + +operator fun React.Writable.minus(number: Byte): React.Writable { + this.update { (it - number).toByte() } + return this +} + +operator fun React.Writable.minusAssign(number: Byte) { + this.minus(number) +} + +operator fun React.Writable.times(number: Byte): React.Writable { + this.update { (it * number).toByte() } + return this +} + + +operator fun React.Writable.timesAssign(number: Byte) { + this.times(number) +} + +operator fun React.Writable.div(number: Byte): React.Writable { + this.update { (it / number).toByte() } + return this +} + +operator fun React.Writable.divAssign(number: Byte) { + this.div(number) +} + + +operator fun React.Writable.rem(number: Byte): React.Writable { + this.update { (it % number).toByte() } + return this +} + +operator fun React.Writable.remAssign(number: Byte) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: Byte): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1.toByte()) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1.toByte()) +} + +operator fun React.Writable.unaryPlus(): React.Writable { + this.update { (+it).toByte() } + return this +} + +operator fun React.Writable.unaryMinus(): React.Writable { + this.update { (-it).toByte() } + return this +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/Double.kt b/src/main/java/pw/mihou/nexus/features/react/writable/Double.kt new file mode 100644 index 00000000..cd77d8a8 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/Double.kt @@ -0,0 +1,72 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: Double): React.Writable { + this.update { it + number } + return this +} + +operator fun React.Writable.plusAssign(number: Double) { + this.plus(number) +} + +operator fun React.Writable.minus(number: Double): React.Writable { + this.update { it - number } + return this +} + +operator fun React.Writable.minusAssign(number: Double) { + this.minus(number) +} + +operator fun React.Writable.times(number: Double): React.Writable { + this.update { it * number } + return this +} + + +operator fun React.Writable.timesAssign(number: Double) { + this.times(number) +} + +operator fun React.Writable.div(number: Double): React.Writable { + this.update { it / number } + return this +} + +operator fun React.Writable.divAssign(number: Double) { + this.div(number) +} + + +operator fun React.Writable.rem(number: Double): React.Writable { + this.update { it % number } + return this +} + +operator fun React.Writable.remAssign(number: Double) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: Double): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1.0) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1.0) +} + +operator fun React.Writable.unaryPlus(): React.Writable { + this.update { +it } + return this +} + +operator fun React.Writable.unaryMinus(): React.Writable { + this.update { -it } + return this +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/Float.kt b/src/main/java/pw/mihou/nexus/features/react/writable/Float.kt new file mode 100644 index 00000000..325d95ef --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/Float.kt @@ -0,0 +1,72 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: Float): React.Writable { + this.update { it + number } + return this +} + +operator fun React.Writable.plusAssign(number: Float) { + this.plus(number) +} + +operator fun React.Writable.minus(number: Float): React.Writable { + this.update { it - number } + return this +} + +operator fun React.Writable.minusAssign(number: Float) { + this.minus(number) +} + +operator fun React.Writable.times(number: Float): React.Writable { + this.update { it * number } + return this +} + + +operator fun React.Writable.timesAssign(number: Float) { + this.times(number) +} + +operator fun React.Writable.div(number: Float): React.Writable { + this.update { it / number } + return this +} + +operator fun React.Writable.divAssign(number: Float) { + this.div(number) +} + + +operator fun React.Writable.rem(number: Float): React.Writable { + this.update { it % number } + return this +} + +operator fun React.Writable.remAssign(number: Float) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: Float): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1.0f) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1.0f) +} + +operator fun React.Writable.unaryPlus(): React.Writable { + this.update { +it } + return this +} + +operator fun React.Writable.unaryMinus(): React.Writable { + this.update { -it } + return this +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/Int.kt b/src/main/java/pw/mihou/nexus/features/react/writable/Int.kt new file mode 100644 index 00000000..7b33e757 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/Int.kt @@ -0,0 +1,72 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: Int): React.Writable { + this.update { it + number } + return this +} + +operator fun React.Writable.plusAssign(number: Int) { + this.plus(number) +} + +operator fun React.Writable.minus(number: Int): React.Writable { + this.update { it - number } + return this +} + +operator fun React.Writable.minusAssign(number: Int) { + this.minus(number) +} + +operator fun React.Writable.times(number: Int): React.Writable { + this.update { it * number } + return this +} + + +operator fun React.Writable.timesAssign(number: Int) { + this.times(number) +} + +operator fun React.Writable.div(number: Int): React.Writable { + this.update { it / number } + return this +} + +operator fun React.Writable.divAssign(number: Int) { + this.div(number) +} + + +operator fun React.Writable.rem(number: Int): React.Writable { + this.update { it % number } + return this +} + +operator fun React.Writable.remAssign(number: Int) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: Int): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1) +} + +operator fun React.Writable.unaryPlus(): React.Writable { + this.update { +it } + return this +} + +operator fun React.Writable.unaryMinus(): React.Writable { + this.update { -it } + return this +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/List.kt b/src/main/java/pw/mihou/nexus/features/react/writable/List.kt new file mode 100644 index 00000000..50b78277 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/List.kt @@ -0,0 +1,41 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(element: T): React.Writable where List : MutableCollection { + this.update { + it += element + it + } + return this +} + +operator fun React.Writable.plusAssign(element: T) where List : MutableCollection { + this.plus(element) +} + +operator fun React.Writable.minus(element: T): React.Writable where List : MutableCollection { + this.update { + it.remove(element) + it + } + return this +} + +operator fun React.Writable.minusAssign(element: T) where List : MutableCollection { + this.plus(element) +} + +operator fun React.Writable.contains(element: T): Boolean where List : Collection { + return this.get().contains(element) +} + +@Suppress("UNCHECKED_CAST") +operator fun React.Writable.get(index: Int): T? where List : Collection { + return when(val collection = this.get()) { + is kotlin.collections.List<*> -> collection[index] as? T + is MutableList<*> -> collection[index] as? T + is Array<*> -> collection[index] as? T + else -> collection.withIndex().find { (i, _) -> i == index } as? T + } +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/Long.kt b/src/main/java/pw/mihou/nexus/features/react/writable/Long.kt new file mode 100644 index 00000000..7ccec7ac --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/Long.kt @@ -0,0 +1,72 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: Long): React.Writable { + this.update { it + number } + return this +} + +operator fun React.Writable.plusAssign(number: Long) { + this.plus(number) +} + +operator fun React.Writable.minus(number: Long): React.Writable { + this.update { it - number } + return this +} + +operator fun React.Writable.minusAssign(number: Long) { + this.minus(number) +} + +operator fun React.Writable.times(number: Long): React.Writable { + this.update { it * number } + return this +} + + +operator fun React.Writable.timesAssign(number: Long) { + this.times(number) +} + +operator fun React.Writable.div(number: Long): React.Writable { + this.update { it / number } + return this +} + +operator fun React.Writable.divAssign(number: Long) { + this.div(number) +} + + +operator fun React.Writable.rem(number: Long): React.Writable { + this.update { it % number } + return this +} + +operator fun React.Writable.remAssign(number: Long) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: Long): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1) +} + +operator fun React.Writable.unaryPlus(): React.Writable { + this.update { +it } + return this +} + +operator fun React.Writable.unaryMinus(): React.Writable { + this.update { -it } + return this +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/Short.kt b/src/main/java/pw/mihou/nexus/features/react/writable/Short.kt new file mode 100644 index 00000000..bd85fbf1 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/Short.kt @@ -0,0 +1,72 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: Short): React.Writable { + this.update { (it + number).toShort() } + return this +} + +operator fun React.Writable.plusAssign(number: Short) { + this.plus(number) +} + +operator fun React.Writable.minus(number: Short): React.Writable { + this.update { (it - number).toShort() } + return this +} + +operator fun React.Writable.minusAssign(number: Short) { + this.minus(number) +} + +operator fun React.Writable.times(number: Short): React.Writable { + this.update { (it * number).toShort() } + return this +} + + +operator fun React.Writable.timesAssign(number: Short) { + this.times(number) +} + +operator fun React.Writable.div(number: Short): React.Writable { + this.update { (it / number).toShort() } + return this +} + +operator fun React.Writable.divAssign(number: Short) { + this.div(number) +} + + +operator fun React.Writable.rem(number: Short): React.Writable { + this.update { (it % number).toShort() } + return this +} + +operator fun React.Writable.remAssign(number: Short) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: Short): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1) +} + +operator fun React.Writable.unaryPlus(): React.Writable { + this.update { (+it).toShort() } + return this +} + +operator fun React.Writable.unaryMinus(): React.Writable { + this.update { (-it).toShort() } + return this +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/String.kt b/src/main/java/pw/mihou/nexus/features/react/writable/String.kt new file mode 100644 index 00000000..c5300675 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/String.kt @@ -0,0 +1,11 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(text: String): React.Writable { + this.update { it + text } + return this +} +operator fun React.Writable.plusAssign(text: String) { + this.plus(text) +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/UByte.kt b/src/main/java/pw/mihou/nexus/features/react/writable/UByte.kt new file mode 100644 index 00000000..5eadc6c3 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/UByte.kt @@ -0,0 +1,62 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: UByte): React.Writable { + this.update { (it + number).toUByte() } + return this +} + +operator fun React.Writable.plusAssign(number: UByte) { + this.plus(number) +} + +operator fun React.Writable.minus(number: UByte): React.Writable { + this.update { (it - number).toUByte() } + return this +} + +operator fun React.Writable.minusAssign(number: UByte) { + this.minus(number) +} + +operator fun React.Writable.times(number: UByte): React.Writable { + this.update { (it * number).toUByte() } + return this +} + + +operator fun React.Writable.timesAssign(number: UByte) { + this.times(number) +} + +operator fun React.Writable.div(number: UByte): React.Writable { + this.update { (it / number).toUByte() } + return this +} + +operator fun React.Writable.divAssign(number: UByte) { + this.div(number) +} + + +operator fun React.Writable.rem(number: UByte): React.Writable { + this.update { (it % number).toUByte() } + return this +} + +operator fun React.Writable.remAssign(number: UByte) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: UByte): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1.toUByte()) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1.toUByte()) +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/UInt.kt b/src/main/java/pw/mihou/nexus/features/react/writable/UInt.kt new file mode 100644 index 00000000..1f81bed8 --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/UInt.kt @@ -0,0 +1,62 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: UInt): React.Writable { + this.update { it + number } + return this +} + +operator fun React.Writable.plusAssign(number: UInt) { + this.plus(number) +} + +operator fun React.Writable.minus(number: UInt): React.Writable { + this.update { it - number } + return this +} + +operator fun React.Writable.minusAssign(number: UInt) { + this.minus(number) +} + +operator fun React.Writable.times(number: UInt): React.Writable { + this.update { it * number } + return this +} + + +operator fun React.Writable.timesAssign(number: UInt) { + this.times(number) +} + +operator fun React.Writable.div(number: UInt): React.Writable { + this.update { it / number } + return this +} + +operator fun React.Writable.divAssign(number: UInt) { + this.div(number) +} + + +operator fun React.Writable.rem(number: UInt): React.Writable { + this.update { it % number } + return this +} + +operator fun React.Writable.remAssign(number: UInt) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: UInt): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1.toUInt()) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1.toUInt()) +} \ No newline at end of file diff --git a/src/main/java/pw/mihou/nexus/features/react/writable/ULong.kt b/src/main/java/pw/mihou/nexus/features/react/writable/ULong.kt new file mode 100644 index 00000000..74a5766e --- /dev/null +++ b/src/main/java/pw/mihou/nexus/features/react/writable/ULong.kt @@ -0,0 +1,62 @@ +package pw.mihou.nexus.features.react.writable + +import pw.mihou.nexus.features.react.React + +operator fun React.Writable.plus(number: ULong): React.Writable { + this.update { it + number } + return this +} + +operator fun React.Writable.plusAssign(number: ULong) { + this.plus(number) +} + +operator fun React.Writable.minus(number: ULong): React.Writable { + this.update { it - number } + return this +} + +operator fun React.Writable.minusAssign(number: ULong) { + this.minus(number) +} + +operator fun React.Writable.times(number: ULong): React.Writable { + this.update { it * number } + return this +} + + +operator fun React.Writable.timesAssign(number: ULong) { + this.times(number) +} + +operator fun React.Writable.div(number: ULong): React.Writable { + this.update { it / number } + return this +} + +operator fun React.Writable.divAssign(number: ULong) { + this.div(number) +} + + +operator fun React.Writable.rem(number: ULong): React.Writable { + this.update { it % number } + return this +} + +operator fun React.Writable.remAssign(number: ULong) { + this.rem(number) +} + +operator fun React.Writable.compareTo(number: ULong): Int { + return this.get().compareTo(number) +} + +operator fun React.Writable.dec(): React.Writable { + return minus(1.toULong()) +} + +operator fun React.Writable.inc(): React.Writable { + return plus(1.toULong()) +} \ No newline at end of file diff --git a/src/test/java/commands/ReactiveTest.kt b/src/test/java/commands/ReactiveTest.kt new file mode 100644 index 00000000..cb272bfa --- /dev/null +++ b/src/test/java/commands/ReactiveTest.kt @@ -0,0 +1,44 @@ +package commands + +import org.javacord.api.entity.message.component.ComponentType +import pw.mihou.nexus.features.command.facade.NexusCommandEvent +import pw.mihou.nexus.features.command.facade.NexusHandler +import pw.mihou.nexus.features.react.elements.Button +import pw.mihou.nexus.features.react.elements.Embed +import pw.mihou.nexus.features.react.elements.SelectMenu + +class ReactiveTest: NexusHandler { + val name = "react" + val description = "A test regarding React" + override fun onEvent(event: NexusCommandEvent) { + event.R { + var clicks by writable(0) + render { + Embed { + Title("R.Embeds") + SpacedBody( + p("Hello World", bold = true, underline = true), + p("This is a little experiment over how this would look DX-wise. Discord message components that will also support states."), + bold("Rendered with Nexus."), + link("Test @ Nexus", "https://github.com/ShindouMihou/Nexus") + ) + } + Button(label = "Click to be DM'd") { event -> + event.interaction.user.sendMessage("Hi") + clicks += 1 + } + SelectMenu( + componentType = ComponentType.SELECT_MENU_STRING, + onSelect = { + it.selectMenuInteraction.acknowledge() + it.selectMenuInteraction.selectedChannels.forEach { channel -> + println("Channel selected: ${channel.name}") + } + } + ) { + ChannelType(org.javacord.api.entity.channel.ChannelType.SERVER_TEXT_CHANNEL) + } + } + } + } +} \ No newline at end of file diff --git a/src/test/java/react/TextStylesTest.kt b/src/test/java/react/TextStylesTest.kt new file mode 100644 index 00000000..a93a6c50 --- /dev/null +++ b/src/test/java/react/TextStylesTest.kt @@ -0,0 +1,17 @@ +package react + +import org.junit.jupiter.api.Test +import pw.mihou.nexus.features.react.styles.TextStyles +import kotlin.test.assertEquals + +class TextStylesTest: TextStyles { + companion object { + const val PLACEHOLDER_TEXT = "Hello World" + } + + @Test + fun `test stacked styling`() { + val result = p(PLACEHOLDER_TEXT, bold = true, underline = true, italic = true, strikethrough = true, spoiler = true) + assertEquals("||***__~~Hello World~~__***||", result) + } +} \ No newline at end of file