Merge pull request #41 from itsMapleLeaf/dev

This commit is contained in:
Darius
2023-10-28 14:49:01 -05:00
committed by GitHub
25 changed files with 352 additions and 228 deletions

View File

@@ -0,0 +1,5 @@
---
"reacord": minor
---
breaking: more descriptive component event types

View File

@@ -0,0 +1,33 @@
---
"reacord": minor
---
add new descriptive adapter methods
The reacord instance names have been updated, and the old names are now deprecated.
- `send` -> `createChannelMessage`
- `reply` -> `createInteractionReply`
These new methods also accept discord JS options. Usage example:
```ts
// can accept either a channel object or a channel ID
reacord.createChannelMessage(channel)
reacord.createChannelMessage(channel, {
tts: true,
})
reacord.createChannelMessage(channel, {
reply: {
messageReference: "123456789012345678",
failIfNotExists: false,
},
})
reacord.createInteractionReply(interaction)
reacord.createInteractionReply(interaction, {
ephemeral: true,
})
```
These new methods replace the old ones, which are now deprecated.

View File

@@ -4,12 +4,9 @@
"scripts": {
"lint": "run-s --continue-on-error lint:*",
"lint:eslint": "eslint . --fix --cache --cache-file=node_modules/.cache/.eslintcache --report-unused-disable-directives",
"lint:prettier": "prettier . --write --cache --list-different",
"lint:prettier": "prettier . \"**/*.astro\" --write --cache --list-different",
"lint:types": "tsc -b & pnpm -r --parallel run typecheck",
"astro-sync": "pnpm --filter website exec astro sync",
"format": "run-s --continue-on-error format:*",
"format:eslint": "eslint . --report-unused-disable-directives --fix",
"format:prettier": "prettier --cache --write . \"**/*.astro\"",
"test": "vitest",
"build": "pnpm -r run build",
"build:website": "pnpm --filter website... run build",

View File

@@ -9,41 +9,52 @@ export interface ComponentEvent {
*
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: MessageInfo
message: ComponentEventMessage
/**
* The channel that this event occurred in.
*
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
channel: ComponentEventChannel
/**
* The user that triggered this event.
*
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
user: ComponentEventUser
/**
* The guild that this event occurred in.
*
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
guild?: ComponentEventGuild
/** Create a new reply to this event. */
reply(content?: ReactNode): ReacordInstance
reply(
content?: ReactNode,
options?: ComponentEventReplyOptions,
): 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 ChannelInfo {
export interface ComponentEventReplyOptions {
ephemeral?: boolean
tts?: boolean
}
/** @category Component Event */
export interface ComponentEventChannel {
id: string
name?: string
topic?: string
@@ -55,11 +66,11 @@ export interface ChannelInfo {
}
/** @category Component Event */
export interface MessageInfo {
export interface ComponentEventMessage {
id: string
channelId: string
authorId: string
member?: GuildMemberInfo
member?: ComponentEventGuildMember
content: string
timestamp: string
editedTimestamp?: string
@@ -70,14 +81,14 @@ export interface MessageInfo {
}
/** @category Component Event */
export interface GuildInfo {
export interface ComponentEventGuild {
id: string
name: string
member: GuildMemberInfo
member: ComponentEventGuildMember
}
/** @category Component Event */
export interface GuildMemberInfo {
export interface ComponentEventGuildMember {
id: string
nick?: string
displayName: string
@@ -92,7 +103,7 @@ export interface GuildMemberInfo {
}
/** @category Component Event */
export interface UserInfo {
export interface ComponentEventUser {
id: string
username: string
discriminator: 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

@@ -14,24 +14,17 @@ import type {
import { ChannelMessageRenderer } from "../internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../internal/renderers/interaction-reply-renderer"
import type {
ChannelInfo,
GuildInfo,
GuildMemberInfo,
MessageInfo,
UserInfo,
ComponentEventChannel,
ComponentEventGuild,
ComponentEventGuildMember,
ComponentEventMessage,
ComponentEventReplyOptions,
ComponentEventUser,
} from "./component-event"
import type { ReacordInstance } from "./instance"
import type { ReacordConfig } from "./reacord"
import { Reacord } from "./reacord"
interface SendOptions {
reply?: boolean
}
interface ReplyOptions {
ephemeral?: boolean
}
/**
* The Reacord adapter for Discord.js.
*
@@ -54,17 +47,51 @@ 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
* @see {@link Discord.MessageCreateOptions}
*/
override send(
channelId: string,
initialContent?: React.ReactNode,
options?: SendOptions,
public createChannelMessage(
target: Discord.ChannelResolvable,
options: Discord.MessageCreateOptions = {},
): ReacordInstance {
return this.createInstance(
this.createChannelRenderer(channelId, options),
this.createChannelMessageRenderer(target, 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
* @see {@link Discord.InteractionReplyOptions}
*/
public createInteractionReply(
interaction: Discord.CommandInteraction,
options: Discord.InteractionReplyOptions = {},
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, options),
)
}
/**
* Sends a message to a channel.
*
* @deprecated Use reacord.createChannelMessage() instead.
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
public send(
channel: Discord.ChannelResolvable,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
this.createChannelMessageRenderer(channel, {}),
initialContent,
)
}
@@ -72,15 +99,15 @@ 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,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, options),
this.createInteractionReplyRenderer(interaction, {}),
initialContent,
)
}
@@ -88,51 +115,49 @@ 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, { ephemeral:
* true })
* @see https://reacord.mapleleaf.dev/guides/sending-messages
*/
override ephemeralReply(
public ephemeralReply(
interaction: Discord.CommandInteraction,
initialContent?: React.ReactNode,
options?: Omit<ReplyOptions, "ephemeral">,
): ReacordInstance {
return this.createInstance(
this.createInteractionReplyRenderer(interaction, {
...options,
ephemeral: true,
}),
initialContent,
)
}
private createChannelRenderer(
event: string | Discord.Message,
opts?: SendOptions,
private createChannelMessageRenderer(
channelResolvable: Discord.ChannelResolvable,
messageCreateOptions?: Discord.MessageCreateOptions,
) {
return new ChannelMessageRenderer({
send: async (options) => {
// Backwards compatible channelId api
// `event` is treated as MessageEvent depending on its type
const channel =
typeof event === "string"
? this.client.channels.cache.get(event) ??
(await this.client.channels.fetch(event)) ??
raise(`Channel ${event} not found`)
: event.channel
send: async (messageOptions) => {
let channel = this.client.channels.resolve(channelResolvable)
if (!channel && typeof channelResolvable === "string") {
channel = await this.client.channels.fetch(channelResolvable)
}
if (!channel) {
const id =
typeof channelResolvable === "string"
? channelResolvable
: channelResolvable.id
raise(`Channel ${id} not found`)
}
if (!channel.isTextBased()) {
raise(`Channel ${channel.id} is not a text channel`)
raise(`Channel ${channel.id} must be a text channel`)
}
if (opts?.reply) {
if (typeof event === "string") {
raise("Cannot send reply with channel ID provided")
}
const message = await event.reply(getDiscordMessageOptions(options))
return createReacordMessage(message)
}
const message = await channel.send(getDiscordMessageOptions(options))
const message = await channel.send({
...getDiscordMessageOptions(messageOptions),
...messageCreateOptions,
})
return createReacordMessage(message)
},
})
@@ -142,24 +167,23 @@ export class ReacordDiscordJs extends Reacord {
interaction:
| Discord.CommandInteraction
| Discord.MessageComponentInteraction,
opts?: ReplyOptions,
interactionReplyOptions: Discord.InteractionReplyOptions,
) {
return new InteractionReplyRenderer({
type: "command",
id: interaction.id,
reply: async (options) => {
interactionId: interaction.id,
reply: async (messageOptions) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
...getDiscordMessageOptions(messageOptions),
...interactionReplyOptions,
fetchReply: true,
ephemeral: opts?.ephemeral,
})
return createReacordMessage(message)
},
followUp: async (options) => {
followUp: async (messageOptions) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
...getDiscordMessageOptions(messageOptions),
...interactionReplyOptions,
fetchReply: true,
ephemeral: opts?.ephemeral,
})
return createReacordMessage(message)
},
@@ -170,7 +194,7 @@ export class ReacordDiscordJs extends Reacord {
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
// todo please dear god clean this up
const channel: ChannelInfo = interaction.channel
const channel: ComponentEventChannel = interaction.channel
? {
...pruneNullishValues(
pick(interaction.channel, [
@@ -186,7 +210,7 @@ export class ReacordDiscordJs extends Reacord {
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo =
const message: ComponentEventMessage =
interaction.message instanceof Discord.Message
? {
...pick(interaction.message, [
@@ -209,7 +233,7 @@ export class ReacordDiscordJs extends Reacord {
}
: raise("Message not found")
const member: GuildMemberInfo | undefined =
const member: ComponentEventGuildMember | undefined =
interaction.member instanceof Discord.GuildMember
? {
...pruneNullishValues(
@@ -234,14 +258,14 @@ export class ReacordDiscordJs extends Reacord {
}
: undefined
const guild: GuildInfo | undefined = interaction.guild
const guild: ComponentEventGuild | undefined = interaction.guild
? {
...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
member: member ?? raise("unexpected: member is undefined"),
}
: undefined
const user: UserInfo = {
const user: ComponentEventUser = {
...pruneNullishValues(
pick(interaction.user, ["id", "username", "discriminator", "tag"]),
),
@@ -283,12 +307,13 @@ export class ReacordDiscordJs extends Reacord {
user,
guild,
reply: (content?: ReactNode) =>
reply: (content?: ReactNode, options?: ComponentEventReplyOptions) =>
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

@@ -1,4 +1,3 @@
import type { Interaction } from "../interaction"
import type { Message, MessageOptions } from "../message"
import { Renderer } from "./renderer"
@@ -6,17 +5,23 @@ import { Renderer } from "./renderer"
// so we know whether to call reply() or followUp()
const repliedInteractionIds = new Set<string>()
export type InteractionReplyRendererImplementation = {
interactionId: string
reply: (options: MessageOptions) => Promise<Message>
followUp: (options: MessageOptions) => Promise<Message>
}
export class InteractionReplyRenderer extends Renderer {
constructor(private interaction: Interaction) {
constructor(private implementation: InteractionReplyRendererImplementation) {
super()
}
protected createMessage(options: MessageOptions): Promise<Message> {
if (repliedInteractionIds.has(this.interaction.id)) {
return this.interaction.followUp(options)
if (repliedInteractionIds.has(this.implementation.interactionId)) {
return this.implementation.followUp(options)
}
repliedInteractionIds.add(this.interaction.id)
return this.interaction.reply(options)
repliedInteractionIds.add(this.implementation.interactionId)
return this.implementation.reply(options)
}
}

View File

@@ -36,7 +36,7 @@
}
},
"scripts": {
"build": "cpy ../../README.md ../../LICENSE . && tsup library/main.ts --target node16 --format cjs,esm --sourcemap --dts --dts-resolve",
"build": "cpy ../../README.md ../../LICENSE . && tsup library/main.ts --target node18 --format cjs,esm --sourcemap --dts --dts-resolve",
"build-watch": "pnpm build -- --watch",
"test": "vitest --coverage --no-watch",
"test-dev": "vitest",

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

@@ -8,10 +8,11 @@ import { setTimeout } from "node:timers/promises"
import type { ReactNode } from "react"
import { expect } from "vitest"
import type {
ChannelInfo,
GuildInfo,
MessageInfo,
UserInfo,
ComponentEventChannel,
ComponentEventGuild,
ComponentEventMessage,
ComponentEventReplyOptions,
ComponentEventUser,
} from "../library/core/component-event"
import type { ButtonClickEvent } from "../library/core/components/button"
import type { SelectChangeEvent } from "../library/core/components/select"
@@ -21,12 +22,14 @@ import type { Channel } from "../library/internal/channel"
import { Container } from "../library/internal/container"
import type {
ButtonInteraction,
CommandInteraction,
SelectInteraction,
} from "../library/internal/interaction"
import type { Message, MessageOptions } from "../library/internal/message"
import { ChannelMessageRenderer } from "../library/internal/renderers/channel-message-renderer"
import { InteractionReplyRenderer } from "../library/internal/renderers/interaction-reply-renderer"
import {
InteractionReplyRenderer,
type InteractionReplyRendererImplementation,
} from "../library/internal/renderers/interaction-reply-renderer"
export type MessageSample = ReturnType<ReacordTester["sampleMessages"]>[0]
@@ -42,26 +45,28 @@ 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?: ComponentEventReplyOptions,
): 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 +74,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()
@@ -171,9 +176,8 @@ class TestMessage implements Message {
}
}
class TestCommandInteraction implements CommandInteraction {
readonly type = "command"
readonly id = "test-command-interaction"
class TestCommandInteraction implements InteractionReplyRendererImplementation {
readonly interactionId = "test-command-interaction"
readonly channelId = "test-channel-id"
constructor(private messageContainer: Container<TestMessage>) {}
@@ -248,17 +252,19 @@ class TestSelectInteraction
class TestComponentEvent {
constructor(private tester: ReacordTester) {}
message: MessageInfo = {} as MessageInfo // todo
channel: ChannelInfo = {} as ChannelInfo // todo
user: UserInfo = {} as UserInfo // todo
guild: GuildInfo = {} as GuildInfo // todo
message: ComponentEventMessage = {} as ComponentEventMessage // todo
channel: ComponentEventChannel = {} as ComponentEventChannel // todo
user: ComponentEventUser = {} as ComponentEventUser // todo
guild: ComponentEventGuild = {} as ComponentEventGuild // 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

@@ -6,7 +6,7 @@ slug: getting-started
# Getting Started
These guides assume some familiarity with JavaScript, [React](https://reactjs.org), [Discord.js](https://discord.js.org) and the [Discord API](https://discord.dev). Keep these pages as reference if you need it.
These guides assume some familiarity with [JavaScript](https://developer.mozilla.org/en-US/docs/Web/javascript), [React](https://reactjs.org), [Discord.js](https://discord.js.org) and the [Discord API](https://discord.dev). Keep these pages as reference if you need it.
## Setup from template
@@ -47,6 +47,13 @@ await client.login(process.env.BOT_TOKEN)
To use JSX in your code, run it with [tsx](https://npm.im/tsx):
```bash
npm install tsx
tsx main.tsx
npm install -D tsx
npx tsx main.tsx
```
For production, I recommend compiling it with [tsup](https://npm.im/tsup):
```bash
npm install -D tsup
npx tsup src/main.tsx --target node20
```

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,8 @@ function Uptime() {
}
client.on("ready", () => {
reacord.send(channelId, <Uptime />)
const instance = reacord.createChannelMessage(channel)
instance.render(<Uptime />)
})
```
@@ -46,12 +46,26 @@ 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" />)
})
```
You can specify various options for the message:
```jsx
const instance = reacord.createChannelMessage(channel, {
tts: true,
reply: {
messageReference: someMessage.id,
},
flags: [MessageFlags.SuppressNotifications],
})
```
See the [Discord.js docs](https://discord.js.org/#/docs/discord.js/main/typedef/MessageCreateOptions) for all of the available options.
## Cleaning Up Instances
If you no longer want to use the instance, you can clean it up in a few ways:
@@ -75,7 +89,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()`. Here's an example:
```jsx
import { Client } from "discord.js"
@@ -94,8 +108,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,14 +148,14 @@ 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</>)
},
},
])
@@ -149,18 +163,36 @@ handleCommands(client, [
## Ephemeral Command Replies
Ephemeral replies are replies that only appear for one user. To create them, use the `.ephemeralReply()` function.
Ephemeral replies are replies that only appear for one user. To create them, use the `.createInteractionReply()` function and provide `ephemeral` option.
```tsx
```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 />)
```