new structure with renderer skeleton

This commit is contained in:
itsMapleLeaf
2022-08-01 22:30:29 -05:00
parent cbd9120c34
commit 4171b7326a
13 changed files with 484 additions and 273 deletions

View File

@@ -5,32 +5,6 @@ import type { ReacordInstance } from "./instance"
* @category Component Event
*/
export type ComponentEvent = {
/**
* The message associated with this event.
* For example: with a button click,
* this is the message that the button is on.
* @see https://discord.com/developers/docs/resources/channel#message-object
*/
message: MessageInfo
/**
* The channel that this event occurred in.
* @see https://discord.com/developers/docs/resources/channel#channel-object
*/
channel: ChannelInfo
/**
* The user that triggered this event.
* @see https://discord.com/developers/docs/resources/user#user-object
*/
user: UserInfo
/**
* The guild that this event occurred in.
* @see https://discord.com/developers/docs/resources/guild#guild-object
*/
guild?: GuildInfo
/**
* Create a new reply to this event.
*/
@@ -42,72 +16,3 @@ export type ComponentEvent = {
*/
ephemeralReply(content?: ReactNode): ReacordInstance
}
/**
* @category Component Event
*/
export type ChannelInfo = {
id: string
name?: string
topic?: string
nsfw?: boolean
lastMessageId?: string
ownerId?: string
parentId?: string
rateLimitPerUser?: number
}
/**
* @category Component Event
*/
export type MessageInfo = {
id: string
channelId: string
authorId: UserInfo
member?: GuildMemberInfo
content: string
timestamp: string
editedTimestamp?: string
tts: boolean
mentionEveryone: boolean
/** The IDs of mentioned users */
mentions: string[]
}
/**
* @category Component Event
*/
export type GuildInfo = {
id: string
name: string
member: GuildMemberInfo
}
/**
* @category Component Event
*/
export type GuildMemberInfo = {
id: string
nick?: string
displayName: string
avatarUrl?: string
displayAvatarUrl: string
roles: string[]
color: number
joinedAt?: string
premiumSince?: string
pending?: boolean
communicationDisabledUntil?: string
}
/**
* @category Component Event
*/
export type UserInfo = {
id: string
username: string
discriminator: string
tag: string
avatarUrl: string
accentColor?: number
}

View File

@@ -1,3 +1,4 @@
import type { APIMessageComponentButtonInteraction } from "discord.js"
import { randomUUID } from "node:crypto"
import React from "react"
import { ReacordElement } from "../../internal/element.js"
@@ -27,7 +28,13 @@ export type ButtonProps = ButtonSharedProps & {
/**
* @category Button
*/
export type ButtonClickEvent = ComponentEvent
export type ButtonClickEvent = ComponentEvent & {
/**
* Event details, e.g. the user who clicked, guild member, guild id, etc.
* @see https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object
*/
interaction: APIMessageComponentButtonInteraction
}
/**
* @category Button
@@ -42,8 +49,8 @@ export function Button(props: ButtonProps) {
)
}
class ButtonNode extends Node<ButtonProps> {
private customId = randomUUID()
export class ButtonNode extends Node<ButtonProps> {
readonly customId = randomUUID()
// this has text children, but buttons themselves shouldn't yield text
// eslint-disable-next-line class-methods-use-this

View File

@@ -1,4 +1,5 @@
import { isInstanceOf } from "@reacord/helpers/is-instance-of.js"
import type { APIMessageComponentSelectMenuInteraction } from "discord.js"
import { randomUUID } from "node:crypto"
import type { ReactNode } from "react"
import React from "react"
@@ -73,7 +74,16 @@ export type SelectProps = {
* @category Select
*/
export type SelectChangeEvent = ComponentEvent & {
/** The set of values that were selected by the user.
* If `multiple`, this can have more than one value.
*/
values: string[]
/**
* Event details, e.g. the user who clicked, guild member, guild id, etc.
* @see https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object
*/
interaction: APIMessageComponentSelectMenuInteraction
}
/**
@@ -88,7 +98,7 @@ export function Select(props: SelectProps) {
)
}
class SelectNode extends Node<SelectProps> {
export class SelectNode extends Node<SelectProps> {
readonly customId = randomUUID()
override modifyMessageOptions(message: MessageOptions): void {

View File

@@ -1,17 +1,12 @@
/* eslint-disable class-methods-use-this */
import { pick } from "@reacord/helpers/pick"
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
import { raise } from "@reacord/helpers/raise"
import type {
APIMessageComponentButtonInteraction,
APIMessageComponentSelectMenuInteraction,
} from "discord.js"
import * as Discord from "discord.js"
import type { ReactNode } from "react"
import type { Except } from "type-fest"
import type {
ChannelInfo,
GuildInfo,
GuildMemberInfo,
MessageInfo,
UserInfo,
} from "../core/component-event"
import type { ReacordInstance } from "../core/instance"
import type { ReacordConfig } from "../core/reacord"
import { Reacord } from "../core/reacord"
@@ -39,6 +34,18 @@ export class ReacordDiscordJs extends Reacord {
)
}
})
client.ws.on(
Discord.GatewayDispatchEvents.InteractionCreate,
(data: Discord.APIInteraction) => {
if (data.type === Discord.InteractionType.MessageComponent) {
data
// this.handleComponentInteraction(
// this.createReacordComponentInteraction(data),
// )
}
},
)
}
/**
@@ -154,83 +161,7 @@ export class ReacordDiscordJs extends Reacord {
private createReacordComponentInteraction(
interaction: Discord.MessageComponentInteraction,
): ComponentInteraction {
// todo please dear god clean this up
const channel: ChannelInfo = interaction.channel
? {
...pruneNullishValues(
pick(interaction.channel, [
"topic",
"nsfw",
"lastMessageId",
"ownerId",
"parentId",
"rateLimitPerUser",
]),
),
id: interaction.channelId,
}
: raise("Non-channel interactions are not supported")
const message: MessageInfo =
interaction.message instanceof Discord.Message
? {
...pick(interaction.message, [
"id",
"channelId",
"authorId",
"content",
"tts",
"mentionEveryone",
]),
timestamp: new Date(
interaction.message.createdTimestamp,
).toISOString(),
editedTimestamp: interaction.message.editedTimestamp
? new Date(interaction.message.editedTimestamp).toISOString()
: undefined,
mentions: interaction.message.mentions.users.map((u) => u.id),
}
: raise("Message not found")
const member: GuildMemberInfo | undefined =
interaction.member instanceof Discord.GuildMember
? {
...pruneNullishValues(
pick(interaction.member, [
"id",
"nick",
"displayName",
"avatarUrl",
"displayAvatarUrl",
"color",
"pending",
]),
),
displayName: interaction.member.displayName,
roles: [...interaction.member.roles.cache.map((role) => role.id)],
joinedAt: interaction.member.joinedAt?.toISOString(),
premiumSince: interaction.member.premiumSince?.toISOString(),
communicationDisabledUntil:
interaction.member.communicationDisabledUntil?.toISOString(),
}
: undefined
const guild: GuildInfo | undefined = interaction.guild
? {
...pruneNullishValues(pick(interaction.guild, ["id", "name"])),
member: member ?? raise("unexpected: member is undefined"),
}
: undefined
const user: UserInfo = {
...pruneNullishValues(
pick(interaction.user, ["id", "username", "discriminator", "tag"]),
),
avatarUrl: interaction.user.avatarURL()!,
accentColor: interaction.user.accentColor ?? undefined,
}
const baseProps: Except<ComponentInteraction, "type"> = {
const baseProps = {
id: interaction.id,
customId: interaction.customId,
update: async (options: MessageOptions) => {
@@ -240,14 +171,14 @@ export class ReacordDiscordJs extends Reacord {
if (interaction.replied || interaction.deferred) return
await interaction.deferUpdate()
},
reply: async (options) => {
reply: async (options: MessageOptions) => {
const message = await interaction.reply({
...getDiscordMessageOptions(options),
fetchReply: true,
})
return createReacordMessage(message as Discord.Message)
},
followUp: async (options) => {
followUp: async (options: MessageOptions) => {
const message = await interaction.followUp({
...getDiscordMessageOptions(options),
fetchReply: true,
@@ -255,11 +186,6 @@ export class ReacordDiscordJs extends Reacord {
return createReacordMessage(message as Discord.Message)
},
event: {
channel,
message,
user,
guild,
reply: (content?: ReactNode) =>
this.createInstance(
this.createInteractionReplyRenderer(interaction),
@@ -278,6 +204,11 @@ export class ReacordDiscordJs extends Reacord {
return {
...baseProps,
type: "button",
event: {
...baseProps.event,
interaction:
interaction.toJSON() as APIMessageComponentButtonInteraction,
},
}
}
@@ -288,6 +219,8 @@ export class ReacordDiscordJs extends Reacord {
event: {
...baseProps.event,
values: interaction.values,
interaction:
interaction.toJSON() as APIMessageComponentSelectMenuInteraction,
},
}
}

View File

@@ -1,20 +1,45 @@
/* eslint-disable class-methods-use-this */
import { Container } from "./container.js"
import type { ComponentInteraction } from "./interaction"
import type { MessageOptions } from "./message"
export abstract class Node<Props> {
readonly children = new Container<Node<unknown>>()
export class Node<Props = unknown> {
private readonly _children: Node[] = []
constructor(public props: Props) {}
modifyMessageOptions(options: MessageOptions) {}
handleComponentInteraction(interaction: ComponentInteraction): boolean {
return false
get children(): readonly Node[] {
return this._children
}
get text(): string {
return [...this.children].map((child) => child.text).join("")
clear() {
this._children.splice(0)
}
add(...nodes: Node[]) {
this._children.push(...nodes)
}
remove(node: Node) {
const index = this._children.indexOf(node)
if (index !== -1) this._children.splice(index, 1)
}
insertBefore(node: Node, beforeNode: Node) {
const index = this._children.indexOf(beforeNode)
if (index !== -1) this._children.splice(index, 0, node)
}
replace(oldNode: Node, newNode: Node) {
const index = this._children.indexOf(oldNode)
if (index !== -1) this._children[index] = newNode
}
clone(): this {
const cloned: this = new (this.constructor as any)()
cloned.add(...this.children.map((child) => child.clone()))
return cloned
}
*walk(): Generator<Node> {
yield this
for (const child of this.children) {
yield* child.walk()
}
}
}

View File

@@ -1,16 +1,16 @@
/* eslint-disable unicorn/prefer-modern-dom-apis */
import { raise } from "@reacord/helpers/raise.js"
import type { HostConfig } from "react-reconciler"
import ReactReconciler from "react-reconciler"
import { DefaultEventPriority } from "react-reconciler/constants"
import type { ReacordInstancePrivate } from "../reacord-instance.js"
import { Node } from "./node.js"
import type { Renderer } from "./renderers/renderer"
import { TextNode } from "./text-node.js"
const config: HostConfig<
export const reconciler = ReactReconciler<
string, // Type,
Record<string, unknown>, // Props,
Renderer, // Container,
Node<unknown>, // Instance,
ReacordInstancePrivate, // Container,
Node, // Instance,
TextNode, // TextInstance,
never, // SuspenseInstance,
never, // HydratableInstance,
@@ -20,7 +20,7 @@ const config: HostConfig<
never, // ChildSet,
number, // TimeoutHandle,
number // NoTimeout,
> = {
>({
supportsMutation: true,
supportsPersistence: false,
supportsHydration: false,
@@ -49,7 +49,7 @@ const config: HostConfig<
return node
},
createTextInstance: (text) => new TextNode(text),
createTextInstance: (text) => new TextNode({ text }),
shouldSetTextContent: () => false,
detachDeletedInstance: (instance) => {},
beforeActiveInstanceBlur: () => {},
@@ -59,30 +59,30 @@ const config: HostConfig<
// eslint-disable-next-line unicorn/no-null
getInstanceFromScope: (scopeInstance: any) => null,
clearContainer: (renderer) => {
renderer.nodes.clear()
clearContainer: (instance) => {
instance.tree.clear()
},
appendChildToContainer: (renderer, child) => {
renderer.nodes.add(child)
appendChildToContainer: (instance, child) => {
instance.tree.add(child)
},
removeChildFromContainer: (renderer, child) => {
renderer.nodes.remove(child)
removeChildFromContainer: (instance, child) => {
instance.tree.remove(child)
},
insertInContainerBefore: (renderer, child, before) => {
renderer.nodes.addBefore(child, before)
insertInContainerBefore: (instance, child, before) => {
instance.tree.insertBefore(child, before)
},
appendInitialChild: (parent, child) => {
parent.children.add(child)
parent.add(child)
},
appendChild: (parent, child) => {
parent.children.add(child)
parent.add(child)
},
removeChild: (parent, child) => {
parent.children.remove(child)
parent.remove(child)
},
insertBefore: (parent, child, before) => {
parent.children.addBefore(child, before)
parent.insertBefore(child, before)
},
prepareUpdate: () => true,
@@ -90,13 +90,13 @@ const config: HostConfig<
node.props = newProps.props
},
commitTextUpdate: (node, oldText, newText) => {
node.props = newText
node.props.text = newText
},
// eslint-disable-next-line unicorn/no-null
prepareForCommit: () => null,
resetAfterCommit: (renderer) => {
renderer.render()
void renderer.update(renderer.tree)
},
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
@@ -106,6 +106,4 @@ const config: HostConfig<
finalizeInitialChildren: () => false,
getCurrentEventPriority: () => DefaultEventPriority,
}
export const reconciler = ReactReconciler(config)
})

View File

@@ -1,12 +1,3 @@
import type { MessageOptions } from "./message"
import { Node } from "./node.js"
export class TextNode extends Node<string> {
override modifyMessageOptions(options: MessageOptions) {
options.content = options.content + this.props
}
override get text() {
return this.props
}
}
export class TextNode extends Node<{ text: string }> {}

View File

@@ -1,18 +1,19 @@
export * from "./core/component-event"
export * from "./core/components/action-row"
export * from "./core/components/button"
export * from "./core/components/button-shared-props"
export * from "./core/components/embed"
export * from "./core/components/embed-author"
export * from "./core/components/embed-field"
export * from "./core/components/embed-footer"
export * from "./core/components/embed-image"
export * from "./core/components/embed-thumbnail"
export * from "./core/components/embed-title"
export * from "./core/components/link"
export * from "./core/components/option"
export * from "./core/components/select"
export * from "./core/instance"
export { useInstance } from "./core/instance-context"
export * from "./core/reacord"
export * from "./djs/reacord-discord-js"
// export * from "./core/component-event"
// export * from "./core/components/action-row"
// export * from "./core/components/button"
// export * from "./core/components/button-shared-props"
// export * from "./core/components/embed"
// export * from "./core/components/embed-author"
// export * from "./core/components/embed-field"
// export * from "./core/components/embed-footer"
// export * from "./core/components/embed-image"
// export * from "./core/components/embed-thumbnail"
// export * from "./core/components/embed-title"
// export * from "./core/components/link"
// export * from "./core/components/option"
// export * from "./core/components/select"
// export * from "./core/instance"
// export { useInstance } from "./core/instance-context"
// export * from "./core/reacord"
// export * from "./djs/reacord-discord-js"
export {}

View File

@@ -0,0 +1,153 @@
import type { APIInteraction } from "discord.js"
import {
Client,
GatewayDispatchEvents,
GatewayIntentBits,
InteractionType,
} from "discord.js"
import * as React from "react"
import { InstanceProvider } from "./core/instance-context.js"
import type { ReacordInstance } from "./reacord-instance.js"
import { ReacordInstancePrivate } from "./reacord-instance.js"
import type { Renderer } from "./renderer.js"
import {
ChannelMessageRenderer,
EphemeralInteractionReplyRenderer,
InteractionReplyRenderer,
} from "./renderer.js"
/**
* @category Core
*/
export type ReacordConfig = {
/** Discord bot token */
token: string
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be cleaned up
* to prevent memory leaks.
*/
maxInstances?: number
}
/**
* Info for replying to an interaction. For Discord.js
* (and probably other libraries) you should be able to pass the
* interaction object directly:
* ```js
* client.on("interactionCreate", (interaction) => {
* if (interaction.isChatInputCommand() && interaction.commandName === "hi") {
* interaction.reply("hi lol")
* }
* })
* ```
*/
export type InteractionInfo = {
id: string
token: string
}
/**
* @category Core
*/
export class ReacordClient {
private readonly config: Required<ReacordConfig>
private readonly client: Client
private instances: ReacordInstancePrivate[] = []
private destroyed = false
constructor(config: ReacordConfig) {
this.config = {
...config,
maxInstances: config.maxInstances ?? 50,
}
this.client = new Client({ intents: [GatewayIntentBits.Guilds] })
this.client.login(this.config.token).catch(console.error)
this.client.ws.on(
GatewayDispatchEvents.InteractionCreate,
(interaction: APIInteraction) => {
if (interaction.type !== InteractionType.MessageComponent) return
for (const instance of this.instances) {
instance.handleInteraction(interaction, this)
}
},
)
}
send(channelId: string, initialContent?: React.ReactNode): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(channelId),
initialContent,
)
}
reply(
interaction: InteractionInfo,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new InteractionReplyRenderer(interaction),
initialContent,
)
}
ephemeralReply(
interaction: InteractionInfo,
initialContent?: React.ReactNode,
): ReacordInstance {
return this.createInstance(
new EphemeralInteractionReplyRenderer(interaction),
initialContent,
)
}
destroy() {
this.client.destroy()
this.destroyed = true
}
private createInstance(
renderer: Renderer,
initialContent?: React.ReactNode,
): ReacordInstance {
if (this.destroyed) throw new Error("ReacordClient is destroyed")
const instance = new ReacordInstancePrivate(renderer)
this.instances.push(instance)
if (this.instances.length > this.config.maxInstances) {
void this.instances[0]?.deactivate()
this.removeInstance(this.instances[0]!)
}
const publicInstance: ReacordInstance = {
render: (content: React.ReactNode) => {
instance.render(
<InstanceProvider value={publicInstance}>{content}</InstanceProvider>,
)
},
deactivate: () => {
this.removeInstance(instance)
renderer.deactivate().catch(console.error)
},
destroy: () => {
this.removeInstance(instance)
renderer.destroy().catch(console.error)
},
}
if (initialContent !== undefined) {
publicInstance.render(initialContent)
}
return publicInstance
}
private removeInstance(instance: ReacordInstancePrivate) {
this.instances = this.instances.filter((the) => the !== instance)
}
}

View File

@@ -0,0 +1,130 @@
import type {
APIMessageComponentButtonInteraction,
APIMessageComponentInteraction,
APIMessageComponentSelectMenuInteraction,
} from "discord.js"
import { ComponentType } from "discord.js"
import type { ComponentEvent } from "./core/component-event.js"
import { ButtonNode } from "./core/components/button.js"
import type { SelectChangeEvent } from "./core/components/select.js"
import { SelectNode } from "./core/components/select.js"
import { Node } from "./internal/node.js"
import { reconciler } from "./internal/reconciler.js"
import type { ReacordClient } from "./reacord-client.js"
import type { Renderer } from "./renderer.js"
/**
* Represents an interactive message, which can later be replaced or deleted.
* @category Core
*/
export type ReacordInstance = {
/** Render some JSX to this instance (edits the message) */
render(content: React.ReactNode): void
/** Remove this message */
deactivate(): void
/**
* Same as destroy, but keeps the message and disables the components on it.
* This prevents it from listening to user interactions.
*/
destroy(): void
}
export class ReacordInstancePrivate {
private readonly container = reconciler.createContainer(
this,
0,
// eslint-disable-next-line unicorn/no-null
null,
false,
// eslint-disable-next-line unicorn/no-null
null,
"reacord",
() => {},
// eslint-disable-next-line unicorn/no-null
null,
)
readonly tree = new Node({})
private latestTree?: Node
constructor(private readonly renderer: Renderer) {}
render(content: React.ReactNode) {
reconciler.updateContainer(content, this.container)
}
async update(tree: Node) {
try {
await this.renderer.update(tree)
this.latestTree = tree
} catch (error) {
console.error(error)
}
}
async deactivate() {
try {
await this.renderer.deactivate()
} catch (error) {
console.error(error)
}
}
async destroy() {
try {
await this.renderer.destroy()
} catch (error) {
console.error(error)
}
}
handleInteraction(
interaction: APIMessageComponentInteraction,
client: ReacordClient,
) {
if (!this.latestTree) return
const baseEvent: ComponentEvent = {
reply: (content) => client.reply(interaction, content),
ephemeralReply: (content) => client.ephemeralReply(interaction, content),
}
if (interaction.data.component_type === ComponentType.Button) {
for (const node of this.latestTree.walk()) {
if (
node instanceof ButtonNode &&
node.customId === interaction.data.custom_id
) {
node.props.onClick({
...baseEvent,
interaction: interaction as APIMessageComponentButtonInteraction,
})
break
}
}
}
if (interaction.data.component_type === ComponentType.SelectMenu) {
const event: SelectChangeEvent = {
...baseEvent,
interaction: interaction as APIMessageComponentSelectMenuInteraction,
values: interaction.data.values,
}
for (const node of this.latestTree.walk()) {
if (
node instanceof SelectNode &&
node.customId === interaction.data.custom_id
) {
node.props.onChange?.(event)
node.props.onChangeMultiple?.(interaction.data.values, event)
if (interaction.data.values[0]) {
node.props.onChangeValue?.(interaction.data.values[0], event)
}
}
}
}
}
}

View File

@@ -0,0 +1,59 @@
import { AsyncQueue } from "@reacord/helpers/async-queue"
import type { Node } from "./internal/node.js"
import type { InteractionInfo } from "./reacord-client.js"
export type Renderer = {
update(tree: Node<unknown>): Promise<void>
deactivate(): Promise<void>
destroy(): Promise<void>
}
export class ChannelMessageRenderer implements Renderer {
private readonly queue = new AsyncQueue()
constructor(private readonly channelId: string) {}
update(tree: Node<unknown>): Promise<void> {
throw new Error("Method not implemented.")
}
deactivate(): Promise<void> {
throw new Error("Method not implemented.")
}
destroy(): Promise<void> {
throw new Error("Method not implemented.")
}
}
export class InteractionReplyRenderer implements Renderer {
constructor(private readonly interaction: InteractionInfo) {}
update(tree: Node<unknown>): Promise<void> {
throw new Error("Method not implemented.")
}
deactivate(): Promise<void> {
throw new Error("Method not implemented.")
}
destroy(): Promise<void> {
throw new Error("Method not implemented.")
}
}
export class EphemeralInteractionReplyRenderer implements Renderer {
constructor(private readonly interaction: InteractionInfo) {}
update(tree: Node<unknown>): Promise<void> {
throw new Error("Method not implemented.")
}
deactivate(): Promise<void> {
throw new Error("Method not implemented.")
}
destroy(): Promise<void> {
throw new Error("Method not implemented.")
}
}

View File

@@ -59,9 +59,10 @@ const tests: TestCase[] = [
<>
<Button
label="public clic"
onClick={(event) =>
event.reply(`${event.guild?.member.displayName} clic`)
}
onClick={(event) => {
console.info(event.interaction)
event.reply(`${event.interaction.member?.nick} clic`)
}}
/>
<Button
label="clic"

View File

@@ -5,16 +5,14 @@ import { omit } from "@reacord/helpers/omit"
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
import { raise } from "@reacord/helpers/raise"
import { waitFor } from "@reacord/helpers/wait-for"
import type {
APIMessageComponentButtonInteraction,
APIMessageComponentSelectMenuInteraction,
} from "discord.js"
import { randomUUID } from "node:crypto"
import { setTimeout } from "node:timers/promises"
import type { ReactNode } from "react"
import { expect } from "vitest"
import type {
ChannelInfo,
GuildInfo,
MessageInfo,
UserInfo,
} from "../library/core/component-event"
import type { ButtonClickEvent } from "../library/core/components/button"
import type { SelectChangeEvent } from "../library/core/components/select"
import type { ReacordInstance } from "../library/core/instance"
@@ -252,11 +250,6 @@ class TestSelectInteraction
class TestComponentEvent {
constructor(private tester: ReacordTester) {}
message: MessageInfo = {} as any // todo
channel: ChannelInfo = {} as any // todo
user: UserInfo = {} as any // todo
guild: GuildInfo = {} as any // todo
reply(content?: ReactNode): ReacordInstance {
return this.tester.reply(content)
}
@@ -268,12 +261,17 @@ class TestComponentEvent {
class TestButtonClickEvent
extends TestComponentEvent
implements ButtonClickEvent {}
implements ButtonClickEvent
{
interaction: APIMessageComponentButtonInteraction = {} as any // todo
}
class TestSelectChangeEvent
extends TestComponentEvent
implements SelectChangeEvent
{
interaction: APIMessageComponentSelectMenuInteraction = {} as any // todo
constructor(readonly values: string[], tester: ReacordTester) {
super(tester)
}