new structure with renderer skeleton
This commit is contained in:
@@ -5,32 +5,6 @@ import type { ReacordInstance } from "./instance"
|
|||||||
* @category Component Event
|
* @category Component Event
|
||||||
*/
|
*/
|
||||||
export type ComponentEvent = {
|
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.
|
* Create a new reply to this event.
|
||||||
*/
|
*/
|
||||||
@@ -42,72 +16,3 @@ export type ComponentEvent = {
|
|||||||
*/
|
*/
|
||||||
ephemeralReply(content?: ReactNode): ReacordInstance
|
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 { randomUUID } from "node:crypto"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
import { ReacordElement } from "../../internal/element.js"
|
import { ReacordElement } from "../../internal/element.js"
|
||||||
@@ -27,7 +28,13 @@ export type ButtonProps = ButtonSharedProps & {
|
|||||||
/**
|
/**
|
||||||
* @category Button
|
* @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
|
* @category Button
|
||||||
@@ -42,8 +49,8 @@ export function Button(props: ButtonProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
class ButtonNode extends Node<ButtonProps> {
|
export class ButtonNode extends Node<ButtonProps> {
|
||||||
private customId = randomUUID()
|
readonly customId = randomUUID()
|
||||||
|
|
||||||
// this has text children, but buttons themselves shouldn't yield text
|
// this has text children, but buttons themselves shouldn't yield text
|
||||||
// eslint-disable-next-line class-methods-use-this
|
// eslint-disable-next-line class-methods-use-this
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { isInstanceOf } from "@reacord/helpers/is-instance-of.js"
|
import { isInstanceOf } from "@reacord/helpers/is-instance-of.js"
|
||||||
|
import type { APIMessageComponentSelectMenuInteraction } from "discord.js"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import React from "react"
|
import React from "react"
|
||||||
@@ -73,7 +74,16 @@ export type SelectProps = {
|
|||||||
* @category Select
|
* @category Select
|
||||||
*/
|
*/
|
||||||
export type SelectChangeEvent = ComponentEvent & {
|
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[]
|
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()
|
readonly customId = randomUUID()
|
||||||
|
|
||||||
override modifyMessageOptions(message: MessageOptions): void {
|
override modifyMessageOptions(message: MessageOptions): void {
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
/* eslint-disable class-methods-use-this */
|
/* 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 { raise } from "@reacord/helpers/raise"
|
||||||
|
import type {
|
||||||
|
APIMessageComponentButtonInteraction,
|
||||||
|
APIMessageComponentSelectMenuInteraction,
|
||||||
|
} from "discord.js"
|
||||||
import * as Discord from "discord.js"
|
import * as Discord from "discord.js"
|
||||||
import type { ReactNode } from "react"
|
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 { ReacordInstance } from "../core/instance"
|
||||||
import type { ReacordConfig } from "../core/reacord"
|
import type { ReacordConfig } from "../core/reacord"
|
||||||
import { Reacord } 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(
|
private createReacordComponentInteraction(
|
||||||
interaction: Discord.MessageComponentInteraction,
|
interaction: Discord.MessageComponentInteraction,
|
||||||
): ComponentInteraction {
|
): ComponentInteraction {
|
||||||
// todo please dear god clean this up
|
const baseProps = {
|
||||||
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"> = {
|
|
||||||
id: interaction.id,
|
id: interaction.id,
|
||||||
customId: interaction.customId,
|
customId: interaction.customId,
|
||||||
update: async (options: MessageOptions) => {
|
update: async (options: MessageOptions) => {
|
||||||
@@ -240,14 +171,14 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
if (interaction.replied || interaction.deferred) return
|
if (interaction.replied || interaction.deferred) return
|
||||||
await interaction.deferUpdate()
|
await interaction.deferUpdate()
|
||||||
},
|
},
|
||||||
reply: async (options) => {
|
reply: async (options: MessageOptions) => {
|
||||||
const message = await interaction.reply({
|
const message = await interaction.reply({
|
||||||
...getDiscordMessageOptions(options),
|
...getDiscordMessageOptions(options),
|
||||||
fetchReply: true,
|
fetchReply: true,
|
||||||
})
|
})
|
||||||
return createReacordMessage(message as Discord.Message)
|
return createReacordMessage(message as Discord.Message)
|
||||||
},
|
},
|
||||||
followUp: async (options) => {
|
followUp: async (options: MessageOptions) => {
|
||||||
const message = await interaction.followUp({
|
const message = await interaction.followUp({
|
||||||
...getDiscordMessageOptions(options),
|
...getDiscordMessageOptions(options),
|
||||||
fetchReply: true,
|
fetchReply: true,
|
||||||
@@ -255,11 +186,6 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
return createReacordMessage(message as Discord.Message)
|
return createReacordMessage(message as Discord.Message)
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
channel,
|
|
||||||
message,
|
|
||||||
user,
|
|
||||||
guild,
|
|
||||||
|
|
||||||
reply: (content?: ReactNode) =>
|
reply: (content?: ReactNode) =>
|
||||||
this.createInstance(
|
this.createInstance(
|
||||||
this.createInteractionReplyRenderer(interaction),
|
this.createInteractionReplyRenderer(interaction),
|
||||||
@@ -278,6 +204,11 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
return {
|
return {
|
||||||
...baseProps,
|
...baseProps,
|
||||||
type: "button",
|
type: "button",
|
||||||
|
event: {
|
||||||
|
...baseProps.event,
|
||||||
|
interaction:
|
||||||
|
interaction.toJSON() as APIMessageComponentButtonInteraction,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +219,8 @@ export class ReacordDiscordJs extends Reacord {
|
|||||||
event: {
|
event: {
|
||||||
...baseProps.event,
|
...baseProps.event,
|
||||||
values: interaction.values,
|
values: interaction.values,
|
||||||
|
interaction:
|
||||||
|
interaction.toJSON() as APIMessageComponentSelectMenuInteraction,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
/* eslint-disable class-methods-use-this */
|
export class Node<Props = unknown> {
|
||||||
import { Container } from "./container.js"
|
private readonly _children: Node[] = []
|
||||||
import type { ComponentInteraction } from "./interaction"
|
|
||||||
import type { MessageOptions } from "./message"
|
|
||||||
|
|
||||||
export abstract class Node<Props> {
|
|
||||||
readonly children = new Container<Node<unknown>>()
|
|
||||||
|
|
||||||
constructor(public props: Props) {}
|
constructor(public props: Props) {}
|
||||||
|
|
||||||
modifyMessageOptions(options: MessageOptions) {}
|
get children(): readonly Node[] {
|
||||||
|
return this._children
|
||||||
handleComponentInteraction(interaction: ComponentInteraction): boolean {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get text(): string {
|
clear() {
|
||||||
return [...this.children].map((child) => child.text).join("")
|
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 { raise } from "@reacord/helpers/raise.js"
|
||||||
import type { HostConfig } from "react-reconciler"
|
|
||||||
import ReactReconciler from "react-reconciler"
|
import ReactReconciler from "react-reconciler"
|
||||||
import { DefaultEventPriority } from "react-reconciler/constants"
|
import { DefaultEventPriority } from "react-reconciler/constants"
|
||||||
|
import type { ReacordInstancePrivate } from "../reacord-instance.js"
|
||||||
import { Node } from "./node.js"
|
import { Node } from "./node.js"
|
||||||
import type { Renderer } from "./renderers/renderer"
|
|
||||||
import { TextNode } from "./text-node.js"
|
import { TextNode } from "./text-node.js"
|
||||||
|
|
||||||
const config: HostConfig<
|
export const reconciler = ReactReconciler<
|
||||||
string, // Type,
|
string, // Type,
|
||||||
Record<string, unknown>, // Props,
|
Record<string, unknown>, // Props,
|
||||||
Renderer, // Container,
|
ReacordInstancePrivate, // Container,
|
||||||
Node<unknown>, // Instance,
|
Node, // Instance,
|
||||||
TextNode, // TextInstance,
|
TextNode, // TextInstance,
|
||||||
never, // SuspenseInstance,
|
never, // SuspenseInstance,
|
||||||
never, // HydratableInstance,
|
never, // HydratableInstance,
|
||||||
@@ -20,7 +20,7 @@ const config: HostConfig<
|
|||||||
never, // ChildSet,
|
never, // ChildSet,
|
||||||
number, // TimeoutHandle,
|
number, // TimeoutHandle,
|
||||||
number // NoTimeout,
|
number // NoTimeout,
|
||||||
> = {
|
>({
|
||||||
supportsMutation: true,
|
supportsMutation: true,
|
||||||
supportsPersistence: false,
|
supportsPersistence: false,
|
||||||
supportsHydration: false,
|
supportsHydration: false,
|
||||||
@@ -49,7 +49,7 @@ const config: HostConfig<
|
|||||||
|
|
||||||
return node
|
return node
|
||||||
},
|
},
|
||||||
createTextInstance: (text) => new TextNode(text),
|
createTextInstance: (text) => new TextNode({ text }),
|
||||||
shouldSetTextContent: () => false,
|
shouldSetTextContent: () => false,
|
||||||
detachDeletedInstance: (instance) => {},
|
detachDeletedInstance: (instance) => {},
|
||||||
beforeActiveInstanceBlur: () => {},
|
beforeActiveInstanceBlur: () => {},
|
||||||
@@ -59,30 +59,30 @@ const config: HostConfig<
|
|||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
getInstanceFromScope: (scopeInstance: any) => null,
|
getInstanceFromScope: (scopeInstance: any) => null,
|
||||||
|
|
||||||
clearContainer: (renderer) => {
|
clearContainer: (instance) => {
|
||||||
renderer.nodes.clear()
|
instance.tree.clear()
|
||||||
},
|
},
|
||||||
appendChildToContainer: (renderer, child) => {
|
appendChildToContainer: (instance, child) => {
|
||||||
renderer.nodes.add(child)
|
instance.tree.add(child)
|
||||||
},
|
},
|
||||||
removeChildFromContainer: (renderer, child) => {
|
removeChildFromContainer: (instance, child) => {
|
||||||
renderer.nodes.remove(child)
|
instance.tree.remove(child)
|
||||||
},
|
},
|
||||||
insertInContainerBefore: (renderer, child, before) => {
|
insertInContainerBefore: (instance, child, before) => {
|
||||||
renderer.nodes.addBefore(child, before)
|
instance.tree.insertBefore(child, before)
|
||||||
},
|
},
|
||||||
|
|
||||||
appendInitialChild: (parent, child) => {
|
appendInitialChild: (parent, child) => {
|
||||||
parent.children.add(child)
|
parent.add(child)
|
||||||
},
|
},
|
||||||
appendChild: (parent, child) => {
|
appendChild: (parent, child) => {
|
||||||
parent.children.add(child)
|
parent.add(child)
|
||||||
},
|
},
|
||||||
removeChild: (parent, child) => {
|
removeChild: (parent, child) => {
|
||||||
parent.children.remove(child)
|
parent.remove(child)
|
||||||
},
|
},
|
||||||
insertBefore: (parent, child, before) => {
|
insertBefore: (parent, child, before) => {
|
||||||
parent.children.addBefore(child, before)
|
parent.insertBefore(child, before)
|
||||||
},
|
},
|
||||||
|
|
||||||
prepareUpdate: () => true,
|
prepareUpdate: () => true,
|
||||||
@@ -90,13 +90,13 @@ const config: HostConfig<
|
|||||||
node.props = newProps.props
|
node.props = newProps.props
|
||||||
},
|
},
|
||||||
commitTextUpdate: (node, oldText, newText) => {
|
commitTextUpdate: (node, oldText, newText) => {
|
||||||
node.props = newText
|
node.props.text = newText
|
||||||
},
|
},
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
// eslint-disable-next-line unicorn/no-null
|
||||||
prepareForCommit: () => null,
|
prepareForCommit: () => null,
|
||||||
resetAfterCommit: (renderer) => {
|
resetAfterCommit: (renderer) => {
|
||||||
renderer.render()
|
void renderer.update(renderer.tree)
|
||||||
},
|
},
|
||||||
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
|
prepareScopeUpdate: (scopeInstance: any, instance: any) => {},
|
||||||
|
|
||||||
@@ -106,6 +106,4 @@ const config: HostConfig<
|
|||||||
finalizeInitialChildren: () => false,
|
finalizeInitialChildren: () => false,
|
||||||
|
|
||||||
getCurrentEventPriority: () => DefaultEventPriority,
|
getCurrentEventPriority: () => DefaultEventPriority,
|
||||||
}
|
})
|
||||||
|
|
||||||
export const reconciler = ReactReconciler(config)
|
|
||||||
|
|||||||
@@ -1,12 +1,3 @@
|
|||||||
import type { MessageOptions } from "./message"
|
|
||||||
import { Node } from "./node.js"
|
import { Node } from "./node.js"
|
||||||
|
|
||||||
export class TextNode extends Node<string> {
|
export class TextNode extends Node<{ text: string }> {}
|
||||||
override modifyMessageOptions(options: MessageOptions) {
|
|
||||||
options.content = options.content + this.props
|
|
||||||
}
|
|
||||||
|
|
||||||
override get text() {
|
|
||||||
return this.props
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
export * from "./core/component-event"
|
// export * from "./core/component-event"
|
||||||
export * from "./core/components/action-row"
|
// export * from "./core/components/action-row"
|
||||||
export * from "./core/components/button"
|
// export * from "./core/components/button"
|
||||||
export * from "./core/components/button-shared-props"
|
// export * from "./core/components/button-shared-props"
|
||||||
export * from "./core/components/embed"
|
// export * from "./core/components/embed"
|
||||||
export * from "./core/components/embed-author"
|
// export * from "./core/components/embed-author"
|
||||||
export * from "./core/components/embed-field"
|
// export * from "./core/components/embed-field"
|
||||||
export * from "./core/components/embed-footer"
|
// export * from "./core/components/embed-footer"
|
||||||
export * from "./core/components/embed-image"
|
// export * from "./core/components/embed-image"
|
||||||
export * from "./core/components/embed-thumbnail"
|
// export * from "./core/components/embed-thumbnail"
|
||||||
export * from "./core/components/embed-title"
|
// export * from "./core/components/embed-title"
|
||||||
export * from "./core/components/link"
|
// export * from "./core/components/link"
|
||||||
export * from "./core/components/option"
|
// export * from "./core/components/option"
|
||||||
export * from "./core/components/select"
|
// export * from "./core/components/select"
|
||||||
export * from "./core/instance"
|
// export * from "./core/instance"
|
||||||
export { useInstance } from "./core/instance-context"
|
// export { useInstance } from "./core/instance-context"
|
||||||
export * from "./core/reacord"
|
// export * from "./core/reacord"
|
||||||
export * from "./djs/reacord-discord-js"
|
// 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
|
<Button
|
||||||
label="public clic"
|
label="public clic"
|
||||||
onClick={(event) =>
|
onClick={(event) => {
|
||||||
event.reply(`${event.guild?.member.displayName} clic`)
|
console.info(event.interaction)
|
||||||
}
|
event.reply(`${event.interaction.member?.nick} clic`)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="clic"
|
label="clic"
|
||||||
|
|||||||
@@ -5,16 +5,14 @@ import { omit } from "@reacord/helpers/omit"
|
|||||||
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
|
import { pruneNullishValues } from "@reacord/helpers/prune-nullish-values"
|
||||||
import { raise } from "@reacord/helpers/raise"
|
import { raise } from "@reacord/helpers/raise"
|
||||||
import { waitFor } from "@reacord/helpers/wait-for"
|
import { waitFor } from "@reacord/helpers/wait-for"
|
||||||
|
import type {
|
||||||
|
APIMessageComponentButtonInteraction,
|
||||||
|
APIMessageComponentSelectMenuInteraction,
|
||||||
|
} from "discord.js"
|
||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { setTimeout } from "node:timers/promises"
|
import { setTimeout } from "node:timers/promises"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { expect } from "vitest"
|
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 { ButtonClickEvent } from "../library/core/components/button"
|
||||||
import type { SelectChangeEvent } from "../library/core/components/select"
|
import type { SelectChangeEvent } from "../library/core/components/select"
|
||||||
import type { ReacordInstance } from "../library/core/instance"
|
import type { ReacordInstance } from "../library/core/instance"
|
||||||
@@ -252,11 +250,6 @@ class TestSelectInteraction
|
|||||||
class TestComponentEvent {
|
class TestComponentEvent {
|
||||||
constructor(private tester: ReacordTester) {}
|
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 {
|
reply(content?: ReactNode): ReacordInstance {
|
||||||
return this.tester.reply(content)
|
return this.tester.reply(content)
|
||||||
}
|
}
|
||||||
@@ -268,12 +261,17 @@ class TestComponentEvent {
|
|||||||
|
|
||||||
class TestButtonClickEvent
|
class TestButtonClickEvent
|
||||||
extends TestComponentEvent
|
extends TestComponentEvent
|
||||||
implements ButtonClickEvent {}
|
implements ButtonClickEvent
|
||||||
|
{
|
||||||
|
interaction: APIMessageComponentButtonInteraction = {} as any // todo
|
||||||
|
}
|
||||||
|
|
||||||
class TestSelectChangeEvent
|
class TestSelectChangeEvent
|
||||||
extends TestComponentEvent
|
extends TestComponentEvent
|
||||||
implements SelectChangeEvent
|
implements SelectChangeEvent
|
||||||
{
|
{
|
||||||
|
interaction: APIMessageComponentSelectMenuInteraction = {} as any // todo
|
||||||
|
|
||||||
constructor(readonly values: string[], tester: ReacordTester) {
|
constructor(readonly values: string[], tester: ReacordTester) {
|
||||||
super(tester)
|
super(tester)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user