initial hacked-together draft

This commit is contained in:
itsMapleLeaf
2022-07-23 17:46:54 -05:00
parent 72f4a4afff
commit 1197d12a19
3 changed files with 358 additions and 93 deletions

View File

@@ -0,0 +1,211 @@
import type {
Client,
Interaction,
Message,
MessageEditOptions,
MessageOptions,
} from "discord.js"
import type { ReactNode } from "react"
import ReactReconciler from "react-reconciler"
import { DefaultEventPriority } from "react-reconciler/constants"
export function createReacordDiscordJs(client: Client) {
return {
send(channelId: string, initialContent?: ReactNode) {
let message: Message | undefined
const tree: MessageTree = {
children: [],
render: async () => {
const messageOptions: MessageOptions & MessageEditOptions = {
content: tree.children.map((child) => child.text).join(""),
}
try {
if (message) {
await message.edit(messageOptions)
return
}
let channel = client.channels.cache.get(channelId)
if (!channel) {
channel = (await client.channels.fetch(channelId)) ?? undefined
}
if (!channel) {
throw new Error(`Channel ${channelId} not found`)
}
if (!channel.isTextBased()) {
throw new Error(`Channel ${channelId} is not a text channel`)
}
message = await channel.send(messageOptions)
} catch (error) {
console.error(
"Reacord encountered an error while rendering.",
error,
)
}
},
}
const container = reconciler.createContainer(
tree,
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,
)
const instance = {
render(content: ReactNode) {
reconciler.updateContainer(content, container)
},
}
if (initialContent !== undefined) {
instance.render(initialContent)
}
return instance
},
reply(interaction: Interaction, initialContent?: ReactNode) {},
ephemeralReply(interaction: Interaction, initialContent?: ReactNode) {},
}
}
type MessageTree = {
children: TextNode[]
render: () => void
}
type TextNode = {
type: "text"
text: string
}
const reconciler = ReactReconciler<
string, // Type
Record<string, unknown>, // Props
MessageTree, // Container
never, // Instance
TextNode, // TextInstance
never, // SuspenseInstance
never, // HydratableInstance
never, // PublicInstance
{}, // HostContext
true, // UpdatePayload
never, // ChildSet
NodeJS.Timeout, // TimeoutHandle
-1 // NoTimeout
>({
isPrimaryRenderer: true,
supportsMutation: true,
supportsHydration: false,
supportsPersistence: false,
scheduleTimeout: setTimeout,
cancelTimeout: clearTimeout,
noTimeout: -1,
createInstance() {
throw new Error("Not implemented")
},
createTextInstance(text) {
return { type: "text", text }
},
appendInitialChild(parent, child) {},
appendChild(parentInstance, child) {},
appendChildToContainer(container, child) {
container.children.push(child)
},
insertBefore(parentInstance, child, beforeChild) {},
insertInContainerBefore(container, child, beforeChild) {
const index = container.children.indexOf(beforeChild)
if (index !== -1) container.children.splice(index, 0, child)
},
removeChild(parentInstance, child) {},
removeChildFromContainer(container, child) {
container.children = container.children.filter((c) => c !== child)
},
clearContainer(container) {
container.children = []
},
commitTextUpdate(textInstance, oldText, newText) {
textInstance.text = newText
},
commitUpdate(
instance,
updatePayload,
type,
prevProps,
nextProps,
internalHandle,
) {},
prepareForCommit() {
// eslint-disable-next-line unicorn/no-null
return null
},
resetAfterCommit(container) {
container.render()
},
finalizeInitialChildren() {
return false
},
prepareUpdate() {
return true
},
shouldSetTextContent() {
return false
},
getRootHostContext() {
return {}
},
getChildHostContext() {
return {}
},
getPublicInstance() {
throw new Error("Refs are not supported")
},
preparePortalMount() {},
getCurrentEventPriority() {
return DefaultEventPriority
},
getInstanceFromNode() {
return undefined
},
beforeActiveInstanceBlur() {},
afterActiveInstanceBlur() {},
prepareScopeUpdate() {},
getInstanceFromScope() {
// eslint-disable-next-line unicorn/no-null
return null
},
detachDeletedInstance() {},
})

View File

@@ -0,0 +1,134 @@
import type { TextChannel } from "discord.js"
import { ChannelType, Client, IntentsBitField } from "discord.js"
import "dotenv/config"
import { kebabCase } from "lodash-es"
import * as React from "react"
import { useState } from "react"
import {
Button,
Option,
ReacordDiscordJs,
Select,
useInstance,
} from "../library/main"
const client = new Client({ intents: IntentsBitField.Flags.Guilds })
const reacord = new ReacordDiscordJs(client)
await client.login(process.env.TEST_BOT_TOKEN)
const guild = await client.guilds.fetch(process.env.TEST_GUILD_ID!)
const category = await guild.channels.fetch(process.env.TEST_CATEGORY_ID!)
if (category?.type !== ChannelType.GuildCategory) {
throw new Error(
`channel ${process.env.TEST_CATEGORY_ID} is not a guild category. received ${category?.type}`,
)
}
for (const [, channel] of category.children.cache) {
await channel.delete()
}
let prefix = 0
const createTest = async (
name: string,
block: (channel: TextChannel) => void | Promise<unknown>,
) => {
prefix += 1
const channel = await category.children.create({
type: ChannelType.GuildText,
name: `${String(prefix).padStart(3, "0")}-${kebabCase(name)}`,
})
await block(channel)
}
await createTest("basic", (channel) => {
reacord.send(channel.id, "Hello, world!")
})
await createTest("counter", (channel) => {
const Counter = () => {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button
style="primary"
emoji=""
onClick={() => setCount(count + 1)}
/>
<Button
style="primary"
emoji=""
onClick={() => setCount(count - 1)}
/>
<Button label="reset" onClick={() => setCount(0)} />
</>
)
}
reacord.send(channel.id, <Counter />)
})
await createTest("select", (channel) => {
function FruitSelect({ onConfirm }: { onConfirm: (choice: string) => void }) {
const [value, setValue] = useState<string>()
return (
<>
<Select
placeholder="choose a fruit"
value={value}
onChangeValue={setValue}
>
<Option value="🍎" emoji="🍎" label="apple" description="it red" />
<Option value="🍌" emoji="🍌" label="banana" description="bnanbna" />
<Option value="🍒" emoji="🍒" label="cherry" description="heh" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => {
if (value) onConfirm(value)
}}
/>
</>
)
}
const instance = reacord.send(
channel.id,
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
instance.deactivate()
}}
/>,
)
})
await createTest("ephemeral button", (channel) => {
reacord.send(
channel.id,
<>
<Button
label="public clic"
onClick={(event) =>
event.reply(`${event.guild?.member.displayName} clic`)
}
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
/>
</>,
)
})
await createTest("delete this", (channel) => {
function DeleteThis() {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.send(channel.id, <DeleteThis />)
})

View File

@@ -2,18 +2,11 @@ import type { TextChannel } from "discord.js"
import { ChannelType, Client, IntentsBitField } from "discord.js"
import "dotenv/config"
import { kebabCase } from "lodash-es"
import * as React from "react"
import { useState } from "react"
import {
Button,
Option,
ReacordDiscordJs,
Select,
useInstance,
} from "../library/main"
import React, { useEffect, useState } from "react"
import { createReacordDiscordJs } from "../library.new/discord-js"
const client = new Client({ intents: IntentsBitField.Flags.Guilds })
const reacord = new ReacordDiscordJs(client)
const reacord = createReacordDiscordJs(client)
await client.login(process.env.TEST_BOT_TOKEN)
@@ -44,91 +37,18 @@ const createTest = async (
}
await createTest("basic", (channel) => {
reacord.send(channel.id, "Hello, world!")
})
function Timer() {
const [count, setCount] = useState(0)
await createTest("counter", (channel) => {
const Counter = () => {
const [count, setCount] = React.useState(0)
return (
<>
count: {count}
<Button
style="primary"
emoji=""
onClick={() => setCount(count + 1)}
/>
<Button
style="primary"
emoji=""
onClick={() => setCount(count - 1)}
/>
<Button label="reset" onClick={() => setCount(0)} />
</>
)
}
reacord.send(channel.id, <Counter />)
})
useEffect(() => {
const id = setInterval(() => {
setCount((count) => count + 3)
}, 3000)
return () => clearInterval(id)
}, [])
await createTest("select", (channel) => {
function FruitSelect({ onConfirm }: { onConfirm: (choice: string) => void }) {
const [value, setValue] = useState<string>()
return (
<>
<Select
placeholder="choose a fruit"
value={value}
onChangeValue={setValue}
>
<Option value="🍎" emoji="🍎" label="apple" description="it red" />
<Option value="🍌" emoji="🍌" label="banana" description="bnanbna" />
<Option value="🍒" emoji="🍒" label="cherry" description="heh" />
</Select>
<Button
label="confirm"
disabled={value == undefined}
onClick={() => {
if (value) onConfirm(value)
}}
/>
</>
)
return <>this component has been running for {count} seconds</>
}
const instance = reacord.send(
channel.id,
<FruitSelect
onConfirm={(value) => {
instance.render(`you chose ${value}`)
instance.deactivate()
}}
/>,
)
})
await createTest("ephemeral button", (channel) => {
reacord.send(
channel.id,
<>
<Button
label="public clic"
onClick={(event) =>
event.reply(`${event.guild?.member.displayName} clic`)
}
/>
<Button
label="clic"
onClick={(event) => event.ephemeralReply("you clic")}
/>
</>,
)
})
await createTest("delete this", (channel) => {
function DeleteThis() {
const instance = useInstance()
return <Button label="delete this" onClick={() => instance.destroy()} />
}
reacord.send(channel.id, <DeleteThis />)
reacord.send(channel.id, <Timer />)
})