Merge pull request #40 from domin-mnd/main

This commit is contained in:
Darius
2023-10-28 13:04:33 -05:00
committed by GitHub
19 changed files with 301 additions and 150 deletions

View File

@@ -33,15 +33,23 @@ export interface ComponentEvent {
guild?: GuildInfo
/** Create a new reply to this event. */
reply(content?: ReactNode): ReacordInstance
reply(content?: ReactNode, options?: ReplyInfo): ReacordInstance
/**
* Create an ephemeral reply to this event, shown only to the user who
* triggered it.
*
* @deprecated Use event.reply(content, { ephemeral: true })
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
/** @category Component Event */
export interface ReplyInfo {
ephemeral?: boolean
tts?: boolean
}
/** @category Component Event */
export interface ChannelInfo {
id: string

View File

@@ -7,7 +7,7 @@ import type { ReactNode } from "react"
*/
export interface ReacordInstance {
/** Render some JSX to this instance (edits the message) */
render: (content: ReactNode) => void
render: (content: ReactNode) => ReacordInstance
/** Remove this message */
destroy: () => void

View File

@@ -18,19 +18,49 @@ import type {
GuildInfo,
GuildMemberInfo,
MessageInfo,
ReplyInfo,
UserInfo,
} from "./component-event"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
import { Reacord } from "./reacord"
interface SendOptions {
/**
* Options for the channel message.
*
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
export interface LegacyCreateChannelMessageOptions
extends CreateChannelMessageOptions {
/**
* Send message as a reply. Requires the use of message event instead of
* channel id provided as argument.
*
* @deprecated Use reacord.createMessageReply()
*/
reply?: boolean
}
interface ReplyOptions {
ephemeral?: boolean
}
/**
* Options for the channel message.
*
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
export interface CreateChannelMessageOptions {}
/**
* Options for the message reply method.
*
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
export interface CreateMessageReplyOptions {}
/**
* Custom options for the interaction reply method.
*
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
export type CreateInteractionReplyOptions = ReplyInfo
/**
* The Reacord adapter for Discord.js.
@@ -54,17 +84,67 @@ export class ReacordDiscordJs extends Reacord {
}
/**
* Sends a message to a channel. Alternatively replies to message event.
* Sends a message to a channel.
*
* @param target - Discord channel object.
* @param [options] - Options for the channel message
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override send(
channelId: string,
initialContent?: React.ReactNode,
options?: SendOptions,
public createChannelMessage(
target: Discord.Channel,
options: CreateChannelMessageOptions = {},
): ReacordInstance {
return this.createInstance(
this.createChannelRenderer(channelId, options),
this.createChannelMessageRenderer(target, options),
)
}
/**
* Replies to a message by sending a message.
*
* @param message - Discord message event object.
* @param [options] - Options for the message reply method.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
public createMessageReply(
message: Discord.Message,
options: CreateMessageReplyOptions = {},
): ReacordInstance {
return this.createInstance(
this.createMessageReplyRenderer(message, options),
)
}
/**
* Replies to a command interaction by sending a message.
*
* @param interaction - Discord command interaction object.
* @param [options] - Custom options for the interaction reply method.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
public createInteractionReply(
interaction: Discord.CommandInteraction,
options: CreateInteractionReplyOptions = {},
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, options),
)
}
/**
* Sends a message to a channel. Alternatively replies to message event.
*
* @deprecated Use reacord.createChannelMessage() or
* reacord.createMessageReply() instead.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
public send(
event: string | Discord.Message,
initialContent?: React.ReactNode,
options: LegacyCreateChannelMessageOptions = {},
): ReacordInstance {
return this.createInstance(
this.createMessageReplyRenderer(event, options),
initialContent,
)
}
@@ -72,12 +152,13 @@ export class ReacordDiscordJs extends Reacord {
/**
* Sends a message as a reply to a command interaction.
*
* @deprecated Use reacord.createInteractionReply() instead.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override reply(
public reply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
options?: ReplyOptions,
options: CreateInteractionReplyOptions = {},
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, options),
@@ -88,13 +169,14 @@ export class ReacordDiscordJs extends Reacord {
/**
* Sends an ephemeral message as a reply to a command interaction.
*
* @deprecated Use reacord.reply(interaction, content, { ephemeral: true })
* @deprecated Use reacord.createInteractionReply(interaction, content, {
* ephemeral: true })
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override ephemeralReply(
public ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
options?: Omit<ReplyOptions, "ephemeral">,
options?: Omit<CreateInteractionReplyOptions, "ephemeral">,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, {
@@ -105,9 +187,25 @@ export class ReacordDiscordJs extends Reacord {
)
}
private createChannelRenderer(
private createChannelMessageRenderer(
channel: Discord.Channel,
_opts?: CreateMessageReplyOptions,
) {
return new ChannelMessageRenderer({
send: async (options) => {
if (!channel.isTextBased()) {
raise(`Channel ${channel.id} is not a text channel`)
}
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
})
}
private createMessageReplyRenderer(
event: string | Discord.Message,
opts?: SendOptions,
opts: CreateChannelMessageOptions | LegacyCreateChannelMessageOptions,
) {
return new ChannelMessageRenderer({
send: async (options) => {
@@ -124,7 +222,7 @@ export class ReacordDiscordJs extends Reacord {
raise(`Channel ${channel.id} is not a text channel`)
}
if (opts?.reply) {
if ("reply" in opts && opts.reply) {
if (typeof event === "string") {
raise("Cannot send reply with channel ID provided")
}
@@ -142,7 +240,7 @@ export class ReacordDiscordJs extends Reacord {
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
opts?: ReplyOptions,
opts: CreateInteractionReplyOptions,
) {
return new InteractionReplyRenderer({
type: "command",
@@ -150,16 +248,16 @@ export class ReacordDiscordJs extends Reacord {
reply: async (options) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
...opts,
fetchReply: true,
ephemeral: opts?.ephemeral,
})
return createReacordMessage(message)
},
followUp: async (options) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
...opts,
fetchReply: true,
ephemeral: opts?.ephemeral,
})
return createReacordMessage(message)
},
@@ -283,12 +381,13 @@ export class ReacordDiscordJs extends Reacord {
user,
guild,
reply: (content?: ReactNode) =>
reply: (content?: ReactNode, options?: ReplyInfo) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction),
this.createInteractionReplyRenderer(interaction, options ?? {}),
content,
),
/** @deprecated Use event.reply(content, { ephemeral: true }) */
ephemeralReply: (content: ReactNode) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction, {

View File

@@ -23,10 +23,6 @@ export abstract class Reacord {
constructor(private readonly config: ReacordConfig = {}) {}
abstract send(...args: unknown[]): ReacordInstance
abstract reply(...args: unknown[]): ReacordInstance
abstract ephemeralReply(...args: unknown[]): ReacordInstance
protected handleComponentInteraction(interaction: ComponentInteraction) {
for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return
@@ -61,6 +57,7 @@ export abstract class Reacord {
<InstanceProvider value={instance}>{content}</InstanceProvider>,
container,
)
return instance
},
deactivate: () => {
this.deactivate(renderer)

View File

@@ -50,7 +50,7 @@ const createTest = async (
}
await createTest("basic", (channel) => {
reacord.send(channel.id, "Hello, world!")
reacord.createChannelMessage(channel).render("Hello, world!")
})
await createTest("counter", (channel) => {
@@ -73,7 +73,7 @@ await createTest("counter", (channel) => {
</>
)
}
reacord.send(channel.id, <Counter />)
reacord.createChannelMessage(channel).render(<Counter />)
})
await createTest("select", (channel) => {
@@ -102,8 +102,7 @@ await createTest("select", (channel) => {
)
}
const instance = reacord.send(
channel.id,
const instance = reacord.createChannelMessage(channel).render(
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
@@ -114,8 +113,7 @@ await createTest("select", (channel) => {
})
await createTest("ephemeral button", (channel) => {
reacord.send(
channel.id,
reacord.createChannelMessage(channel).render(
<>
<Button
label="public clic"
@@ -125,7 +123,7 @@ await createTest("ephemeral button", (channel) => {
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
onClick={(event) => event.reply("you clic", { ephemeral: true })}
/>
</>,
)
@@ -136,9 +134,11 @@ await createTest("delete this", (channel) => {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.send(channel.id, <DeleteThis />)
reacord.createChannelMessage(channel).render(<DeleteThis />)
})
await createTest("link", (channel) => {
reacord.send(channel.id, <Link label="hi" url="https://mapleleaf.dev" />)
reacord
.createChannelMessage(channel)
.render(<Link label="hi" url="https://mapleleaf.dev" />)
})

View File

@@ -6,8 +6,9 @@ import { test } from "vitest"
test("rendering behavior", async () => {
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
const reply = tester
.createInteractionReply()
.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
await tester.assertMessages([
{
@@ -244,8 +245,7 @@ test("rendering behavior", async () => {
test("delete", async () => {
const tester = new ReacordTester()
const reply = tester.reply()
reply.render(
const reply = tester.createInteractionReply().render(
<>
some text
<Embed>some embed</Embed>

View File

@@ -53,9 +53,7 @@ test("single select", async () => {
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
tester.createInteractionReply().render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
@@ -119,9 +117,7 @@ test("multiple select", async () => {
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
tester.createInteractionReply().render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
@@ -148,7 +144,7 @@ test("multiple select", async () => {
test("optional onSelect + unknown value", async () => {
const tester = new ReacordTester()
tester.reply().render(<Select placeholder="select" />)
tester.createInteractionReply().render(<Select placeholder="select" />)
await tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{

View File

@@ -11,6 +11,7 @@ import type {
ChannelInfo,
GuildInfo,
MessageInfo,
ReplyInfo,
UserInfo,
} from "../library/core/component-event"
import type { ButtonClickEvent } from "../library/core/components/button"
@@ -42,26 +43,26 @@ export class ReacordTester extends Reacord {
return [...this.messageContainer]
}
override send(initialContent?: ReactNode): ReacordInstance {
public createChannelMessage(): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
initialContent,
)
}
override reply(initialContent?: ReactNode): ReacordInstance {
public createMessageReply(): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(new TestChannel(this.messageContainer)),
)
}
public createInteractionReply(_options?: ReplyInfo): ReacordInstance {
return this.createInstance(
new InteractionReplyRenderer(
new TestCommandInteraction(this.messageContainer),
),
initialContent,
)
}
override ephemeralReply(initialContent?: ReactNode): ReacordInstance {
return this.reply(initialContent)
}
assertMessages(expected: MessageSample[]) {
return waitFor(() => {
expect(this.sampleMessages()).toEqual(expected)
@@ -69,7 +70,7 @@ export class ReacordTester extends Reacord {
}
async assertRender(content: ReactNode, expected: MessageSample[]) {
const instance = this.reply()
const instance = this.createInteractionReply()
instance.render(content)
await this.assertMessages(expected)
instance.destroy()
@@ -254,11 +255,13 @@ class TestComponentEvent {
guild: GuildInfo = {} as GuildInfo // todo
reply(content?: ReactNode): ReacordInstance {
return this.tester.reply(content)
return this.tester.createInteractionReply().render(content)
}
ephemeralReply(content?: ReactNode): ReacordInstance {
return this.tester.ephemeralReply(content)
return this.tester
.createInteractionReply({ ephemeral: true })
.render(content)
}
}

View File

@@ -49,7 +49,9 @@ describe("useInstance", () => {
}
const tester = new ReacordTester()
const instance = tester.send(<TestComponent name="parent" />)
const instance = tester
.createChannelMessage()
.render(<TestComponent name="parent" />)
await tester.assertMessages([messageOutput("parent")])
expect(instanceFromHook).toBe(instance)

File diff suppressed because one or more lines are too long

View File

@@ -3,5 +3,5 @@ export type Props = astroHTML.JSX.AnchorHTMLAttributes
---
<a rel="noopener noreferrer" target="_blank" {...Astro.props}>
<slot />
<slot />
</a>

View File

@@ -7,32 +7,32 @@ const guides = await getCollection("guides")
---
<Layout>
<div class="isolate">
<header
class="bg-slate-700/30 shadow sticky top-0 backdrop-blur-sm transition z-10 flex"
>
<div class="container">
<MainNavigation />
</div>
</header>
<main class="container mt-8 flex items-start gap-4">
<nav class="w-48 sticky top-24 hidden md:block">
<h2 class="text-2xl">Guides</h2>
<ul class="mt-3 flex flex-col gap-2 items-start">
{
guides.map((guide) => (
<li>
<a class="link" href={`/guides/${guide.slug}`}>
{guide.data.title}
</a>
</li>
))
}
</ul>
</nav>
<section class="prose prose-invert pb-8 flex-1 min-w-0">
<slot />
</section>
</main>
</div>
<div class="isolate">
<header
class="sticky top-0 z-10 flex bg-slate-700/30 shadow backdrop-blur-sm transition"
>
<div class="container">
<MainNavigation />
</div>
</header>
<main class="container mt-8 flex items-start gap-4">
<nav class="sticky top-24 hidden w-48 md:block">
<h2 class="text-2xl">Guides</h2>
<ul class="mt-3 flex flex-col items-start gap-2">
{
guides.map((guide) => (
<li>
<a class="link" href={`/guides/${guide.slug}`}>
{guide.data.title}
</a>
</li>
))
}
</ul>
</nav>
<section class="prose prose-invert min-w-0 flex-1 pb-8">
<slot />
</section>
</main>
</div>
</Layout>

View File

@@ -7,7 +7,7 @@ import faviconUrl from "~/assets/favicon.png"
import "~/styles/tailwind.css"
---
<!DOCTYPE html>
<!doctype html>
<html lang="en" class="bg-slate-900 text-slate-100">
<head>
<meta charset="utf-8" />

View File

@@ -1,30 +1,30 @@
<details class="md:hidden relative" data-menu>
<summary
class="list-none p-2 -m-2 cursor-pointer hover:text-emerald-500 transition"
>
<slot name="button" />
</summary>
<div
class="w-48 max-h-[calc(100vh-5rem)] bg-slate-800 shadow rounded-lg overflow-x-hidden overflow-y-auto top-[calc(100%+8px)] right-0 absolute z-10"
>
<slot />
</div>
<details class="relative md:hidden" data-menu>
<summary
class="-m-2 cursor-pointer list-none p-2 transition hover:text-emerald-500"
>
<slot name="button" />
</summary>
<div
class="absolute right-0 top-[calc(100%+8px)] z-10 max-h-[calc(100vh-5rem)] w-48 overflow-y-auto overflow-x-hidden rounded-lg bg-slate-800 shadow"
>
<slot />
</div>
</details>
<script>
for (const menu of document.querySelectorAll<HTMLDetailsElement>(
"[data-menu]",
)) {
window.addEventListener("click", (event) => {
if (!menu.contains(event.target as Node)) {
menu.open = false
}
})
menu.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
menu.open = false
menu.querySelector("summary")!.focus()
}
})
}
for (const menu of document.querySelectorAll<HTMLDetailsElement>(
"[data-menu]",
)) {
window.addEventListener("click", (event) => {
if (!menu.contains(event.target as Node)) {
menu.open = false
}
})
menu.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
menu.open = false
menu.querySelector("summary")!.focus()
}
})
}
</script>

View File

@@ -9,14 +9,13 @@ slug: sending-messages
You can send messages via Reacord to a channel like so.
```jsx
const channelId = "abc123deadbeef"
client.on("ready", () => {
reacord.send(channelId, "Hello, world!")
const channel = await client.channels.fetch("abc123deadbeef")
reacord.createChannelMessage(channel).render("Hello, world!")
})
```
The `.send()` function creates a **Reacord instance**. You can pass strings, numbers, or anything that can be rendered by React, such as JSX!
The `.createChannelMessage()` function creates a **Reacord instance**. You can pass strings, numbers, or anything that can be rendered by React, such as JSX!
Components rendered through this instance can include state and effects, and the message on Discord will update automatically.
@@ -36,7 +35,9 @@ function Uptime() {
}
client.on("ready", () => {
reacord.send(channelId, <Uptime />)
const instance = reacord.createChannelMessage(channel)
instance.render(<Uptime />)
})
```
@@ -46,12 +47,27 @@ The instance can be rendered to multiple times, which will update the message ea
const Hello = ({ subject }) => <>Hello, {subject}!</>
client.on("ready", () => {
const instance = reacord.send(channel)
const instance = reacord.createChannelMessage(channel)
instance.render(<Hello subject="World" />)
instance.render(<Hello subject="Moon" />)
})
```
## Replying to Messages
Instead of sending messages to a channel, you may want to reply to a specific message instead. To do this, create an instance using `.createMessageReply()` instead:
```jsx
const Hello = ({ username }) => <>Hello, {username}!</>
client.on("messageCreate", (message) => {
reacord
.createMessageReply(message)
.render(<Hello username={message.author.displayName} />)
})
```
## Cleaning Up Instances
If you no longer want to use the instance, you can clean it up in a few ways:
@@ -75,7 +91,7 @@ const reacord = new ReacordDiscordJs(client, {
This section also applies to other kinds of application commands, such as context menu commands.
</aside>
To reply to a command interaction, use the `.reply()` function. This function returns an instance that works the same way as the one from `.send()`. Here's an example:
To reply to a command interaction, use the `.createInteractionReply()` function. This function returns an instance that works the same way as the one from `.createChannelMessage()` and `.createMessageReply()`. Here's an example:
```jsx
import { Client } from "discord.js"
@@ -94,8 +110,8 @@ client.on("ready", () => {
client.on("interactionCreate", (interaction) => {
if (interaction.isCommand() && interaction.commandName === "ping") {
// Use the reply() function instead of send
reacord.reply(interaction, <>pong!</>)
// Use the createInteractionReply() function instead of createChannelMessage
reacord.createInteractionReply(interaction).render(<>pong!</>)
}
})
@@ -134,33 +150,55 @@ handleCommands(client, [
name: "ping",
description: "pong!",
run: (interaction) => {
reacord.reply(interaction, <>pong!</>)
reacord.createInteractionReply(interaction).render(<>pong!</>)
},
},
{
name: "hi",
description: "say hi",
run: (interaction) => {
reacord.reply(interaction, <>hi</>)
reacord.createInteractionReply(interaction).render(<>hi</>)
},
},
])
```
## Ephemeral Command Replies
## Interaction Options
Ephemeral replies are replies that only appear for one user. To create them, use the `.ephemeralReply()` function.
Just like `.createChannelMessage()` and `.createMessageReply()`, interaction replies provide a way to specify certain `interaction.reply()` options.
```tsx
### Ephemeral Command Replies
Ephemeral replies are replies that only appear for one user. To create them, use the `.createInteractionReply()` function and provide `ephemeral` option.
```jsx
handleCommands(client, [
{
name: "pong",
description: "pong, but in secret",
run: (interaction) => {
reacord.ephemeralReply(interaction, <>(pong)</>)
reacord
.createInteractionReply(interaction, { ephemeral: true })
.render(<>(pong)</>)
},
},
])
```
The `ephemeralReply` function also returns an instance, but ephemeral replies cannot be updated via `instance.render()`. You can `.deactivate()` them, but `.destroy()` will not delete the message; only the user can hide it from view.
### Text-to-Speech Command Replies
Additionally interaction replies may have `tts` option to turn on text-to-speech ability for the reply. To create such reply, use `.createInteractionReply()` function and provide `tts` option.
```jsx
handleCommands(client, [
{
name: "pong",
description: "pong, but converted into audio",
run: (interaction) => {
reacord
.createInteractionReply(interaction, { tts: true })
.render(<>pong!</>)
},
},
])
```

View File

@@ -24,7 +24,9 @@ function FancyMessage({ title, description }) {
```
```jsx
reacord.send(channelId, <FancyMessage title="Hello" description="World" />)
reacord
.createChannelMessage(channel)
.render(<FancyMessage title="Hello" description="World" />)
```
Reacord also comes with multiple embed components, for defining embeds on a piece-by-piece basis. This enables composition:
@@ -52,8 +54,7 @@ function FancyMessage({ children }) {
```
```jsx
reacord.send(
channelId,
reacord.createChannelMessage(channel).render(
<FancyMessage>
<FancyDetails title="Hello" description="World" />
</FancyMessage>,

View File

@@ -35,7 +35,9 @@ function TheButton() {
const publicReply = event.reply(`${name} clicked the button. wow`)
setTimeout(() => publicReply.destroy(), 3000)
const privateReply = event.ephemeralReply("good job, you clicked it")
const privateReply = event.reply("good job, you clicked it", {
ephemeral: true,
})
privateReply.deactivate() // we don't need to listen to updates on this
}

View File

@@ -36,8 +36,7 @@ export function FruitSelect({ onConfirm }) {
```
```jsx
const instance = reacord.send(
channelId,
const instance = reacord.createChannelMessage(channel).render(
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
@@ -49,7 +48,7 @@ const instance = reacord.send(
For a multi-select, use the `multiple` prop, then you can use `values` and `onChangeMultiple` to handle multiple values.
```tsx
```jsx
export function FruitSelect({ onConfirm }) {
const [values, setValues] = useState([])

View File

@@ -22,5 +22,5 @@ function SelfDestruct() {
)
}
reacord.send(channelId, <SelfDestruct />)
reacord.createChannelMessage(channel).render(<SelfDestruct />)
```