beginnings: rendering text to message

This commit is contained in:
MapleLeaf
2021-12-08 14:12:26 -06:00
parent c0aa4ee108
commit 57d55fe58f
17 changed files with 516 additions and 27 deletions

28
src/container.ts Normal file
View File

@@ -0,0 +1,28 @@
import type { TextBasedChannels } from "discord.js"
import type { ReacordInstance } from "./instance.js"
export class ReacordContainer {
channel: TextBasedChannels
instances = new Set<ReacordInstance>()
constructor(channel: TextBasedChannels) {
this.channel = channel
}
add(instance: ReacordInstance) {
this.instances.add(instance)
instance.render(this.channel)
}
remove(instance: ReacordInstance) {
this.instances.delete(instance)
instance.destroy()
}
clear() {
this.instances.forEach((instance) => {
instance.destroy()
})
this.instances.clear()
}
}

5
src/helpers/raise.ts Normal file
View File

@@ -0,0 +1,5 @@
import { toError } from "./to-error.js"
export function raise(error: unknown): never {
throw toError(error)
}

View File

@@ -0,0 +1,6 @@
import { setTimeout } from "node:timers/promises"
export async function rejectAfter(timeMs: number) {
await setTimeout(timeMs)
return Promise.reject(`rejected after ${timeMs}ms`)
}

3
src/helpers/to-error.ts Normal file
View File

@@ -0,0 +1,3 @@
export function toError(value: unknown) {
return value instanceof Error ? value : new Error(String(value))
}

1
src/helpers/types.ts Normal file
View File

@@ -0,0 +1 @@
export type MaybePromise<T> = T | Promise<T>

View File

@@ -0,0 +1,10 @@
import { rejectAfter } from "./reject-after.js"
import type { MaybePromise } from "./types.js"
import { waitFor } from "./wait-for.js"
export function waitForWithTimeout(
condition: () => MaybePromise<boolean>,
timeout = 1000,
) {
return Promise.race([waitFor(condition), rejectAfter(timeout)])
}

8
src/helpers/wait-for.ts Normal file
View File

@@ -0,0 +1,8 @@
import { setTimeout } from "node:timers/promises"
import type { MaybePromise } from "./types.js"
export async function waitFor(condition: () => MaybePromise<boolean>) {
while (!(await condition())) {
await setTimeout()
}
}

25
src/instance.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { Message, TextBasedChannels } from "discord.js"
export class ReacordInstance {
message?: Message
content: string
constructor(content: string) {
this.content = content
}
render(channel: TextBasedChannels) {
if (this.message) {
this.message.edit(this.content).catch(console.error)
} else {
channel.send(this.content).then((message) => {
this.message = message
}, console.error)
}
}
destroy() {
this.message?.delete().catch(console.error)
this.message?.channel.messages.cache.delete(this.message?.id)
}
}

View File

@@ -1,6 +0,0 @@
import test from "ava"
import { result } from "./main.js"
test("it is b", (t) => {
t.deepEqual(result, ["b"])
})

View File

@@ -1,4 +1 @@
/* eslint-disable import/no-unused-modules */
import { matchSorter } from "match-sorter"
export const result = matchSorter(["a", "b", "c"], "b")
export * from "./render.js"

62
src/reconciler.ts Normal file
View File

@@ -0,0 +1,62 @@
import ReactReconciler from "react-reconciler"
import type { ReacordContainer } from "./container.js"
import { ReacordInstance } from "./instance.js"
export const reconciler = ReactReconciler<
unknown,
Record<string, unknown>,
ReacordContainer,
ReacordInstance,
ReacordInstance,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown,
unknown
>({
now: Date.now,
supportsMutation: true,
isPrimaryRenderer: true,
noTimeout: -1,
supportsHydration: false,
supportsPersistence: false,
getRootHostContext: () => ({}),
getChildHostContext: () => ({}),
shouldSetTextContent: () => false,
createInstance: (
type,
props,
rootContainerInstance,
hostContext,
internalInstanceHandle,
) => {
throw new Error("Not implemented")
},
createTextInstance: (
text,
rootContainerInstance,
hostContext,
internalInstanceHandle,
) => {
return new ReacordInstance(text)
},
prepareForCommit: () => null,
resetAfterCommit: () => null,
clearContainer: (container) => {
container.clear()
},
appendChildToContainer: (container, child) => {
container.add(child)
},
removeChildFromContainer: (container, child) => {
container.remove(child)
},
})

52
src/render.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import test from "ava"
import { Client, TextChannel } from "discord.js"
import { nanoid } from "nanoid"
import { testBotToken, testChannelId } from "../test/env.js"
import { raise } from "./helpers/raise.js"
import { waitForWithTimeout } from "./helpers/wait-for-with-timeout.js"
import { render } from "./render.js"
const client = new Client({
intents: ["GUILDS"],
})
let channel: TextChannel
test.before(async () => {
await client.login(testBotToken)
const result =
client.channels.cache.get(testChannelId) ??
(await client.channels.fetch(testChannelId)) ??
raise("Channel not found")
if (!(result instanceof TextChannel)) {
throw new Error("Channel must be a text channel")
}
channel = result
})
test.after(() => {
client.destroy()
})
test("rendering text", async (t) => {
const content = nanoid()
const root = render(content, channel)
await waitForWithTimeout(
() => channel.messages.cache.some((m) => m.content === content),
4000,
)
root.destroy()
await waitForWithTimeout(() => {
return channel.messages.cache
.filter((m) => !m.deleted)
.every((m) => m.content !== content)
}, 4000)
t.pass()
})

16
src/render.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { TextBasedChannels } from "discord.js"
import { ReacordContainer } from "./container"
import { reconciler } from "./reconciler"
export type ReacordRenderTarget = TextBasedChannels
export function render(content: string, target: ReacordRenderTarget) {
const container = new ReacordContainer(target)
const containerId = reconciler.createContainer(container, 0, false, null)
reconciler.updateContainer(content, containerId)
return {
destroy: () => {
reconciler.updateContainer(null, containerId)
},
}
}