new structure with renderer skeleton
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 }> {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
153
packages/reacord/library/reacord-client.tsx
Normal file
153
packages/reacord/library/reacord-client.tsx
Normal 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)
|
||||
}
|
||||
}
|
||||
130
packages/reacord/library/reacord-instance.ts
Normal file
130
packages/reacord/library/reacord-instance.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
packages/reacord/library/renderer.ts
Normal file
59
packages/reacord/library/renderer.ts
Normal 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.")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user