wip more stuff
This commit is contained in:
@@ -10,14 +10,14 @@ export class AsyncQueue {
|
||||
private items: QueueItem[] = []
|
||||
private running = false
|
||||
|
||||
add<T>(callback: AsyncCallback<T>): Promise<Awaited<T>> {
|
||||
append<T>(callback: AsyncCallback<T>): Promise<Awaited<T>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.items.push({ callback, resolve: resolve as any, reject })
|
||||
void this.runQueue()
|
||||
void this.run()
|
||||
})
|
||||
}
|
||||
|
||||
private async runQueue() {
|
||||
private async run() {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
|
||||
|
||||
21
packages/playground/package.json
Normal file
21
packages/playground/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@reacord/playground",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@reacord/helpers": "workspace:*",
|
||||
"discord.js": "^14.1.2",
|
||||
"dotenv": "^16.0.1",
|
||||
"ora": "^6.1.2",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/react": "^18.0.16",
|
||||
"tsx": "^3.8.0",
|
||||
"typescript": "^4.7.4"
|
||||
}
|
||||
}
|
||||
54
packages/playground/src/main.tsx
Normal file
54
packages/playground/src/main.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { raise } from "@reacord/helpers/raise"
|
||||
import { Client, GatewayIntentBits } from "discord.js"
|
||||
import * as dotenv from "dotenv"
|
||||
import { join } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { oraPromise } from "ora"
|
||||
import React from "react"
|
||||
import { Button, ReacordClient } from "../../reacord/src/main"
|
||||
|
||||
dotenv.config({
|
||||
path: join(fileURLToPath(import.meta.url), "../../../../.env"),
|
||||
override: true,
|
||||
})
|
||||
|
||||
const token = process.env.TEST_BOT_TOKEN ?? raise("TEST_BOT_TOKEN not defined")
|
||||
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] })
|
||||
const reacord = new ReacordClient({ token })
|
||||
|
||||
client.once("ready", async (client) => {
|
||||
try {
|
||||
await oraPromise(
|
||||
client.application.commands.create({
|
||||
name: "counter",
|
||||
description: "counts things",
|
||||
}),
|
||||
"Registering commands",
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to register commands:", error)
|
||||
}
|
||||
})
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
if (
|
||||
interaction.isChatInputCommand() &&
|
||||
interaction.commandName === "counter"
|
||||
) {
|
||||
reacord.reply(interaction, <Counter />)
|
||||
// reacord.reply(interaction, "test3").render("test4")
|
||||
}
|
||||
})
|
||||
|
||||
await oraPromise(client.login(token), "Logging in")
|
||||
|
||||
function Counter() {
|
||||
const [count, setCount] = React.useState(0)
|
||||
return (
|
||||
<>
|
||||
count: {count}
|
||||
<Button label="+" onClick={() => setCount(count + 1)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
3
packages/playground/tsconfig.json
Normal file
3
packages/playground/tsconfig.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
@@ -2,14 +2,21 @@ import type { APIInteraction, Client } from "discord.js"
|
||||
import {
|
||||
GatewayDispatchEvents,
|
||||
GatewayIntentBits,
|
||||
InteractionResponseType,
|
||||
InteractionType,
|
||||
Routes,
|
||||
} from "discord.js"
|
||||
import * as React from "react"
|
||||
import { createDiscordClient } from "./create-discord-client"
|
||||
import type { ReacordInstance } from "./reacord-instance"
|
||||
import { ReacordInstancePrivate } from "./reacord-instance"
|
||||
import { InstanceProvider } from "./react/instance-context"
|
||||
import { Renderer } from "./renderer"
|
||||
import type { Renderer } from "./renderer"
|
||||
import {
|
||||
ChannelMessageRenderer,
|
||||
EphemeralInteractionReplyRenderer,
|
||||
InteractionReplyRenderer,
|
||||
} from "./renderer"
|
||||
|
||||
/**
|
||||
* @category Core
|
||||
@@ -65,10 +72,28 @@ export class ReacordClient {
|
||||
|
||||
this.discordClientPromise
|
||||
.then((client) => {
|
||||
// we listen to the websocket message instead of the normal "interactionCreate" event,
|
||||
// so that we can pass a library-agnostic APIInteraction object to the user's component callbacks
|
||||
// the DJS MessageComponentInteraction doesn't have the raw data on it (as of writing this)
|
||||
client.ws.on(
|
||||
GatewayDispatchEvents.InteractionCreate,
|
||||
(interaction: APIInteraction) => {
|
||||
async (interaction: APIInteraction) => {
|
||||
if (interaction.type !== InteractionType.MessageComponent) return
|
||||
|
||||
// handling a component interaction may not always result in a re-render,
|
||||
// and in the case that it doesn't, discord will incorrectly show "interaction failed",
|
||||
// so here, we'll just always defer an update just in case
|
||||
//
|
||||
// we _can_ be a little smarter and check to see if an update happened before deferring,
|
||||
// but I can figure that out later
|
||||
//
|
||||
// or we can make the user defer themselves if they don't update,
|
||||
// but that's bad UX probably
|
||||
await client.rest.post(
|
||||
Routes.interactionCallback(interaction.id, interaction.token),
|
||||
{ body: { type: InteractionResponseType.DeferredMessageUpdate } },
|
||||
)
|
||||
|
||||
for (const instance of this.instances) {
|
||||
instance.handleInteraction(interaction, this)
|
||||
}
|
||||
@@ -88,7 +113,7 @@ export class ReacordClient {
|
||||
|
||||
reply(interaction: InteractionInfo, initialContent?: React.ReactNode) {
|
||||
return this.createInstance(
|
||||
new InteractionReplyRenderer(interaction),
|
||||
new InteractionReplyRenderer(interaction, this.discordClientPromise),
|
||||
initialContent,
|
||||
)
|
||||
}
|
||||
@@ -98,7 +123,10 @@ export class ReacordClient {
|
||||
initialContent?: React.ReactNode,
|
||||
) {
|
||||
return this.createInstance(
|
||||
new EphemeralInteractionReplyRenderer(interaction),
|
||||
new EphemeralInteractionReplyRenderer(
|
||||
interaction,
|
||||
this.discordClientPromise,
|
||||
),
|
||||
initialContent,
|
||||
)
|
||||
}
|
||||
@@ -132,11 +160,11 @@ export class ReacordClient {
|
||||
},
|
||||
deactivate: () => {
|
||||
this.removeInstance(instance)
|
||||
renderer.deactivate().catch(console.error)
|
||||
renderer.deactivate()
|
||||
},
|
||||
destroy: () => {
|
||||
this.removeInstance(instance)
|
||||
renderer.destroy().catch(console.error)
|
||||
renderer.destroy()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -50,35 +50,23 @@ export class ReacordInstancePrivate {
|
||||
readonly tree = new Node({})
|
||||
private latestTree?: Node
|
||||
|
||||
constructor(private readonly renderer: Renderer) {}
|
||||
constructor(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)
|
||||
}
|
||||
update(tree: Node) {
|
||||
this.renderer.update(tree)
|
||||
this.latestTree = tree
|
||||
}
|
||||
|
||||
async deactivate() {
|
||||
try {
|
||||
await this.renderer.deactivate()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
deactivate() {
|
||||
this.renderer.deactivate()
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
try {
|
||||
await this.renderer.destroy()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
destroy() {
|
||||
this.renderer.destroy()
|
||||
}
|
||||
|
||||
handleInteraction(
|
||||
@@ -87,6 +75,8 @@ export class ReacordInstancePrivate {
|
||||
) {
|
||||
if (!this.latestTree) return
|
||||
|
||||
this.renderer.onComponentInteraction(interaction)
|
||||
|
||||
const baseEvent: ComponentEvent = {
|
||||
reply: (content) => client.reply(interaction, content),
|
||||
ephemeralReply: (content) => client.ephemeralReply(interaction, content),
|
||||
@@ -102,7 +92,7 @@ export class ReacordInstancePrivate {
|
||||
...baseEvent,
|
||||
interaction: interaction as APIMessageComponentButtonInteraction,
|
||||
})
|
||||
break
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +114,7 @@ export class ReacordInstancePrivate {
|
||||
if (interaction.data.values[0]) {
|
||||
node.props.onChangeValue?.(interaction.data.values[0], event)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { AsyncQueue } from "@reacord/helpers/async-queue.js"
|
||||
import type { Client, Message } from "discord.js"
|
||||
import { TextChannel } from "discord.js"
|
||||
import type { MessageUpdatePayload } from "./make-message-update-payload.js"
|
||||
import { makeMessageUpdatePayload } from "./make-message-update-payload.js"
|
||||
import type { Node } from "./node.js"
|
||||
import type { InteractionInfo } from "./reacord-client.js"
|
||||
import { AsyncQueue } from "@reacord/helpers/async-queue"
|
||||
import type {
|
||||
Client,
|
||||
Message,
|
||||
RESTPostAPIInteractionFollowupResult,
|
||||
Snowflake,
|
||||
} from "discord.js"
|
||||
import { InteractionResponseType, Routes, TextChannel } from "discord.js"
|
||||
import type { MessageUpdatePayload } from "./make-message-update-payload"
|
||||
import { makeMessageUpdatePayload } from "./make-message-update-payload"
|
||||
import type { Node } from "./node"
|
||||
import type { InteractionInfo } from "./reacord-client"
|
||||
|
||||
export abstract class Renderer {
|
||||
private readonly queue = new AsyncQueue()
|
||||
private active = true
|
||||
private destroyPromise?: Promise<void>
|
||||
private componentInteraction?: InteractionInfo
|
||||
private readonly queue = new AsyncQueue()
|
||||
|
||||
constructor(protected readonly clientPromise: Promise<Client<true>>) {}
|
||||
|
||||
protected abstract handleUpdate(payload: MessageUpdatePayload): Promise<void>
|
||||
protected abstract handleDestroy(): Promise<void>
|
||||
@@ -17,59 +24,97 @@ export abstract class Renderer {
|
||||
|
||||
update(tree: Node) {
|
||||
const payload = makeMessageUpdatePayload(tree)
|
||||
return this.queue.add(async () => {
|
||||
if (!this.active) return
|
||||
await this.handleUpdate(payload)
|
||||
})
|
||||
|
||||
this.queue
|
||||
.append(async () => {
|
||||
if (!this.active) return
|
||||
|
||||
if (this.componentInteraction) {
|
||||
await this.updateInteractionMessage(
|
||||
this.componentInteraction,
|
||||
payload,
|
||||
)
|
||||
this.componentInteraction = undefined
|
||||
return
|
||||
}
|
||||
|
||||
await this.handleUpdate(payload)
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.destroyPromise) return this.destroyPromise
|
||||
if (!this.active) return
|
||||
this.active = false
|
||||
|
||||
const promise = this.queue.add(() => this.handleDestroy())
|
||||
|
||||
// if it failed, we'll want to try again
|
||||
promise.catch((error) => {
|
||||
console.error("Failed to destroy message:", error)
|
||||
this.destroyPromise = undefined
|
||||
})
|
||||
|
||||
return (this.destroyPromise = promise)
|
||||
this.queue.append(() => this.handleDestroy()).catch(console.error)
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
return this.queue.add(async () => {
|
||||
if (!this.active) return
|
||||
this.queue
|
||||
.append(async () => {
|
||||
await this.handleDeactivate()
|
||||
this.active = false
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
|
||||
await this.handleDeactivate()
|
||||
onComponentInteraction(info: InteractionInfo) {
|
||||
this.componentInteraction = info
|
||||
|
||||
// set active to false *after* running deactivation,
|
||||
// so that other queued operations run first,
|
||||
// and we can show the correct deactivated state
|
||||
this.active = false
|
||||
// a component update might not happen in response to this interaction,
|
||||
// so we'll defer it after a timeout if it's not handled by then
|
||||
setTimeout(() => {
|
||||
this.queue
|
||||
.append(() => {
|
||||
if (!this.componentInteraction) return
|
||||
const info = this.componentInteraction
|
||||
this.componentInteraction = undefined
|
||||
return this.deferMessageUpdate(info)
|
||||
})
|
||||
.catch(console.error)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
private async updateInteractionMessage(
|
||||
{ id, token }: InteractionInfo,
|
||||
payload: MessageUpdatePayload,
|
||||
) {
|
||||
const client = await this.clientPromise
|
||||
await client.rest.post(Routes.interactionCallback(id, token), {
|
||||
body: {
|
||||
type: InteractionResponseType.UpdateMessage,
|
||||
data: payload,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async deferMessageUpdate({ id, token }: InteractionInfo) {
|
||||
const client = await this.clientPromise
|
||||
await client.rest.post(Routes.interactionCallback(id, token), {
|
||||
body: { type: InteractionResponseType.DeferredMessageUpdate },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class ChannelMessageRenderer extends Renderer {
|
||||
private channel: TextChannel | undefined
|
||||
private message: Message | undefined
|
||||
private channel?: TextChannel
|
||||
private message?: Message
|
||||
|
||||
constructor(
|
||||
private readonly channelId: string,
|
||||
private readonly clientPromise: Promise<Client<true>>,
|
||||
clientPromise: Promise<Client<true>>,
|
||||
) {
|
||||
super()
|
||||
super(clientPromise)
|
||||
}
|
||||
|
||||
override async handleUpdate(payload: MessageUpdatePayload): Promise<void> {
|
||||
if (this.message) {
|
||||
await this.message.edit(payload)
|
||||
} else {
|
||||
const channel = await this.getChannel()
|
||||
this.message = await channel.send(payload)
|
||||
return
|
||||
}
|
||||
|
||||
const channel = await this.getChannel()
|
||||
this.message = await channel.send(payload)
|
||||
}
|
||||
|
||||
override async handleDestroy(): Promise<void> {
|
||||
@@ -101,14 +146,80 @@ export class ChannelMessageRenderer extends Renderer {
|
||||
}
|
||||
|
||||
export class InteractionReplyRenderer extends Renderer {
|
||||
constructor(private readonly interaction: InteractionInfo) {
|
||||
super()
|
||||
private messageCreated = false
|
||||
|
||||
constructor(
|
||||
private interaction: InteractionInfo,
|
||||
clientPromise: Promise<Client<true>>,
|
||||
) {
|
||||
super(clientPromise)
|
||||
}
|
||||
|
||||
handleUpdate(payload: MessageUpdatePayload): Promise<void> {
|
||||
async handleUpdate(payload: MessageUpdatePayload): Promise<void> {
|
||||
const client = await this.clientPromise
|
||||
if (!this.messageCreated) {
|
||||
await client.rest.post(
|
||||
Routes.interactionCallback(this.interaction.id, this.interaction.token),
|
||||
{
|
||||
body: {
|
||||
type: InteractionResponseType.ChannelMessageWithSource,
|
||||
data: payload,
|
||||
},
|
||||
},
|
||||
)
|
||||
this.messageCreated = true
|
||||
} else {
|
||||
await client.rest.patch(
|
||||
Routes.webhookMessage(
|
||||
client.application.id,
|
||||
this.interaction.token,
|
||||
"@original",
|
||||
),
|
||||
{ body: payload },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
handleDestroy(): Promise<void> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
|
||||
handleDeactivate(): Promise<void> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
}
|
||||
|
||||
export class InteractionFollowUpRenderer extends Renderer {
|
||||
private messageId?: Snowflake
|
||||
|
||||
constructor(
|
||||
readonly interaction: InteractionInfo,
|
||||
clientPromise: Promise<Client<true>>,
|
||||
) {
|
||||
super(clientPromise)
|
||||
}
|
||||
|
||||
async handleUpdate(payload: MessageUpdatePayload): Promise<void> {
|
||||
const client = await this.clientPromise
|
||||
|
||||
if (!this.messageId) {
|
||||
const response = (await client.rest.post(
|
||||
Routes.webhookMessage(client.application.id, this.interaction.token),
|
||||
{ body: payload },
|
||||
)) as RESTPostAPIInteractionFollowupResult
|
||||
this.messageId = response.id
|
||||
} else {
|
||||
await client.rest.patch(
|
||||
Routes.webhookMessage(
|
||||
client.application.id,
|
||||
this.interaction.token,
|
||||
this.messageId,
|
||||
),
|
||||
{ body: payload },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
handleDestroy(): Promise<void> {
|
||||
throw new Error("Method not implemented.")
|
||||
}
|
||||
@@ -119,8 +230,11 @@ export class InteractionReplyRenderer extends Renderer {
|
||||
}
|
||||
|
||||
export class EphemeralInteractionReplyRenderer extends Renderer {
|
||||
constructor(private readonly interaction: InteractionInfo) {
|
||||
super()
|
||||
constructor(
|
||||
private readonly interaction: InteractionInfo,
|
||||
clientPromise: Promise<Client<true>>,
|
||||
) {
|
||||
super(clientPromise)
|
||||
}
|
||||
|
||||
handleUpdate(payload: MessageUpdatePayload): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user