wip more stuff

This commit is contained in:
itsMapleLeaf
2022-10-02 17:57:49 -05:00
parent dc6239e598
commit b6f244aaa0
8 changed files with 315 additions and 117 deletions

View File

@@ -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

View 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"
}
}

View 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)} />
</>
)
}

View File

@@ -0,0 +1,3 @@
{
"extends": "../../tsconfig.base.json"
}

View File

@@ -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()
},
}

View File

@@ -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
}
}
}

View File

@@ -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> {