refactor and simplify things
This commit is contained in:
@@ -74,14 +74,7 @@ test("empty embed fallback", async () => {
|
|||||||
|
|
||||||
test("embed with only author", async () => {
|
test("embed with only author", async () => {
|
||||||
await root.render(<Embed author={{ name: "only author" }} />)
|
await root.render(<Embed author={{ name: "only author" }} />)
|
||||||
await assertMessages([
|
await assertMessages([{ embeds: [{ author: { name: "only author" } }] }])
|
||||||
{ embeds: [{ description: "_ _", author: { name: "only author" } }] },
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("empty embed author", async () => {
|
|
||||||
await root.render(<Embed author={{}} />)
|
|
||||||
await assertMessages([{ embeds: [{ description: "_ _" }] }])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("kitchen sink", async () => {
|
test("kitchen sink", async () => {
|
||||||
@@ -252,7 +245,7 @@ async function assertMessages(expected: MessageOptions[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractMessageData(message: Message): MessageOptions {
|
function extractMessageData(message: Message): MessageOptions {
|
||||||
return {
|
return pruneUndefinedValues({
|
||||||
content: nonEmptyOrUndefined(message.content),
|
content: nonEmptyOrUndefined(message.content),
|
||||||
embeds: nonEmptyOrUndefined(
|
embeds: nonEmptyOrUndefined(
|
||||||
pruneUndefinedValues(
|
pruneUndefinedValues(
|
||||||
@@ -305,7 +298,7 @@ function extractMessageData(message: Message): MessageOptions {
|
|||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneUndefinedValues<T>(input: T) {
|
function pruneUndefinedValues<T>(input: T) {
|
||||||
|
|||||||
@@ -16,4 +16,5 @@ const config = {
|
|||||||
verbose: true,
|
verbose: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-unused-modules
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"lint": "eslint --ext js,ts,tsx .",
|
"lint": "eslint --ext js,ts,tsx .",
|
||||||
"lint-fix": "pnpm lint -- --fix",
|
"lint-fix": "pnpm lint -- --fix",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
|
"test": "node --experimental-vm-modules --no-warnings node_modules/jest/bin/jest.js",
|
||||||
"test-watch": "pnpm test -- --watch",
|
"test-watch": "pnpm test -- --watch",
|
||||||
"coverage": "pnpm test -- --coverage",
|
"coverage": "pnpm test -- --coverage",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
import type {
|
|
||||||
MessageActionRowComponentOptions,
|
|
||||||
MessageOptions,
|
|
||||||
} from "discord.js"
|
|
||||||
import React from "react"
|
|
||||||
import { ContainerInstance } from "./container-instance.js"
|
|
||||||
|
|
||||||
export type ActionRowProps = {
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ActionRow(props: ActionRowProps) {
|
|
||||||
return (
|
|
||||||
<reacord-element createInstance={() => new ActionRowInstance()}>
|
|
||||||
{props.children}
|
|
||||||
</reacord-element>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class ActionRowInstance extends ContainerInstance {
|
|
||||||
readonly name = "ActionRow"
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({ warnOnNonTextChildren: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line class-methods-use-this
|
|
||||||
override renderToMessage(options: MessageOptions) {
|
|
||||||
const row = {
|
|
||||||
type: "ACTION_ROW" as const,
|
|
||||||
components: [] as MessageActionRowComponentOptions[],
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of this.children) {
|
|
||||||
if (!child.renderToActionRow) {
|
|
||||||
console.warn(`${child.name} is not an action row component`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
child.renderToActionRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
options.components ??= []
|
|
||||||
options.components.push(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import type {
|
|
||||||
MessageActionRowOptions,
|
|
||||||
MessageEmbedOptions,
|
|
||||||
MessageOptions,
|
|
||||||
} from "discord.js"
|
|
||||||
|
|
||||||
export abstract class BaseInstance {
|
|
||||||
/** The name of the JSX element represented by this instance */
|
|
||||||
abstract readonly name: string
|
|
||||||
|
|
||||||
/** If the element represents text, the text for this element */
|
|
||||||
getText?(): string
|
|
||||||
|
|
||||||
/** If this element can be a child of a message,
|
|
||||||
* the function to modify the message options */
|
|
||||||
renderToMessage?(options: MessageOptions): void
|
|
||||||
|
|
||||||
/** If this element can be a child of an embed,
|
|
||||||
* the function to modify the embed options */
|
|
||||||
renderToEmbed?(options: MessageEmbedOptions): void
|
|
||||||
|
|
||||||
/** If this element can be a child of an action row,
|
|
||||||
* the function to modify the action row options */
|
|
||||||
renderToActionRow?(options: MessageActionRowOptions): void
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
import type {
|
|
||||||
BaseMessageComponentOptions,
|
|
||||||
EmojiResolvable,
|
|
||||||
MessageActionRowOptions,
|
|
||||||
MessageButtonOptions,
|
|
||||||
MessageButtonStyle,
|
|
||||||
MessageOptions,
|
|
||||||
} from "discord.js"
|
|
||||||
import { nanoid } from "nanoid"
|
|
||||||
import React from "react"
|
|
||||||
import { ContainerInstance } from "./container-instance.js"
|
|
||||||
import { last } from "./helpers/last.js"
|
|
||||||
import { pick } from "./helpers/pick.js"
|
|
||||||
import { toUpper } from "./helpers/to-upper.js"
|
|
||||||
|
|
||||||
export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link">
|
|
||||||
|
|
||||||
export type ButtonProps = {
|
|
||||||
style?: ButtonStyle
|
|
||||||
emoji?: EmojiResolvable
|
|
||||||
disabled?: boolean
|
|
||||||
children?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Button(props: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<reacord-element createInstance={() => new ButtonInstance(props)}>
|
|
||||||
{props.children}
|
|
||||||
</reacord-element>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class ButtonInstance extends ContainerInstance {
|
|
||||||
readonly name = "Button"
|
|
||||||
|
|
||||||
constructor(private readonly props: ButtonProps) {
|
|
||||||
super({ warnOnNonTextChildren: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
private getButtonOptions(): Required<BaseMessageComponentOptions> &
|
|
||||||
MessageButtonOptions {
|
|
||||||
return {
|
|
||||||
...pick(this.props, "emoji", "disabled"),
|
|
||||||
type: "BUTTON",
|
|
||||||
style: this.props.style ? toUpper(this.props.style) : "SECONDARY",
|
|
||||||
label: this.getChildrenText(),
|
|
||||||
customId: nanoid(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToMessage(options: MessageOptions) {
|
|
||||||
options.components ??= []
|
|
||||||
|
|
||||||
// i hate this
|
|
||||||
let actionRow:
|
|
||||||
| (Required<BaseMessageComponentOptions> & MessageActionRowOptions)
|
|
||||||
| undefined = last(options.components)
|
|
||||||
|
|
||||||
if (
|
|
||||||
!actionRow ||
|
|
||||||
actionRow.components[0]?.type === "SELECT_MENU" ||
|
|
||||||
actionRow.components.length >= 5
|
|
||||||
) {
|
|
||||||
actionRow = {
|
|
||||||
type: "ACTION_ROW",
|
|
||||||
components: [],
|
|
||||||
}
|
|
||||||
options.components.push(actionRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
actionRow.components.push(this.getButtonOptions())
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToActionRow(row: MessageActionRowOptions) {
|
|
||||||
row.components.push(this.getButtonOptions())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
13
src/components/action-row.tsx
Normal file
13
src/components/action-row.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export type ActionRowProps = {
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionRow(props: ActionRowProps) {
|
||||||
|
return (
|
||||||
|
<reacord-element createNode={() => ({ type: "actionRow", children: [] })}>
|
||||||
|
{props.children}
|
||||||
|
</reacord-element>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
src/components/button.tsx
Normal file
21
src/components/button.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { EmojiResolvable, MessageButtonStyle } from "discord.js"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export type ButtonStyle = Exclude<Lowercase<MessageButtonStyle>, "link">
|
||||||
|
|
||||||
|
export type ButtonProps = {
|
||||||
|
style?: ButtonStyle
|
||||||
|
emoji?: EmojiResolvable
|
||||||
|
disabled?: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button(props: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<reacord-element
|
||||||
|
createNode={() => ({ ...props, type: "button", children: [] })}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</reacord-element>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/embed-field.tsx
Normal file
17
src/components/embed-field.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export type EmbedFieldProps = {
|
||||||
|
name: string
|
||||||
|
children: React.ReactNode
|
||||||
|
inline?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmbedField(props: EmbedFieldProps) {
|
||||||
|
return (
|
||||||
|
<reacord-element
|
||||||
|
createNode={() => ({ ...props, type: "embedField", children: [] })}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</reacord-element>
|
||||||
|
)
|
||||||
|
}
|
||||||
32
src/components/embed.tsx
Normal file
32
src/components/embed.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { ColorResolvable } from "discord.js"
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export type EmbedProps = {
|
||||||
|
title?: string
|
||||||
|
color?: ColorResolvable
|
||||||
|
url?: string
|
||||||
|
timestamp?: Date | number | string
|
||||||
|
imageUrl?: string
|
||||||
|
thumbnailUrl?: string
|
||||||
|
author?: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
iconUrl?: string
|
||||||
|
}
|
||||||
|
footer?: {
|
||||||
|
text: string
|
||||||
|
iconUrl?: string
|
||||||
|
}
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Embed(props: EmbedProps) {
|
||||||
|
return (
|
||||||
|
<reacord-element
|
||||||
|
createNode={() => ({ ...props, type: "embed", children: [] })}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</reacord-element>
|
||||||
|
)
|
||||||
|
}
|
||||||
14
src/components/text.tsx
Normal file
14
src/components/text.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { ReactNode } from "react"
|
||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export type TextProps = {
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text(props: TextProps) {
|
||||||
|
return (
|
||||||
|
<reacord-element createNode={() => ({ type: "textElement", children: [] })}>
|
||||||
|
{props.children}
|
||||||
|
</reacord-element>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { BaseInstance } from "./base-instance.js"
|
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-unused-modules
|
|
||||||
export type ContainerInstanceOptions = {
|
|
||||||
/**
|
|
||||||
* Whether or not to log a warning when calling getChildrenText() with non-text children
|
|
||||||
*
|
|
||||||
* Regardless of what this is set to, non-text children will always be skipped */
|
|
||||||
warnOnNonTextChildren: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class ContainerInstance extends BaseInstance {
|
|
||||||
readonly children: BaseInstance[] = []
|
|
||||||
|
|
||||||
constructor(private readonly options: ContainerInstanceOptions) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
add(child: BaseInstance) {
|
|
||||||
this.children.push(child)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getChildrenText(): string {
|
|
||||||
let text = ""
|
|
||||||
for (const child of this.children) {
|
|
||||||
if (!child.getText) {
|
|
||||||
if (this.options.warnOnNonTextChildren) {
|
|
||||||
console.warn(`${child.name} is not a valid child of ${this.name}`)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
text += child.getText()
|
|
||||||
}
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import type { MessageEmbedOptions } from "discord.js"
|
|
||||||
import React from "react"
|
|
||||||
import { ContainerInstance } from "./container-instance.js"
|
|
||||||
import { pick } from "./helpers/pick.js"
|
|
||||||
|
|
||||||
export type EmbedFieldProps = {
|
|
||||||
name: string
|
|
||||||
children: React.ReactNode
|
|
||||||
inline?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmbedField(props: EmbedFieldProps) {
|
|
||||||
return (
|
|
||||||
<reacord-element createInstance={() => new EmbedFieldInstance(props)}>
|
|
||||||
{props.children}
|
|
||||||
</reacord-element>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class EmbedFieldInstance extends ContainerInstance {
|
|
||||||
readonly name = "EmbedField"
|
|
||||||
|
|
||||||
constructor(private readonly props: EmbedFieldProps) {
|
|
||||||
super({ warnOnNonTextChildren: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToEmbed(options: MessageEmbedOptions) {
|
|
||||||
options.fields ??= []
|
|
||||||
options.fields.push({
|
|
||||||
...pick(this.props, "name", "inline"),
|
|
||||||
value: this.getChildrenText(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type {
|
|
||||||
ColorResolvable,
|
|
||||||
MessageEmbedOptions,
|
|
||||||
MessageOptions,
|
|
||||||
} from "discord.js"
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import React from "react"
|
|
||||||
import { ContainerInstance } from "./container-instance.js"
|
|
||||||
|
|
||||||
export type EmbedProps = {
|
|
||||||
title?: string
|
|
||||||
color?: ColorResolvable
|
|
||||||
url?: string
|
|
||||||
timestamp?: Date | number | string
|
|
||||||
imageUrl?: string
|
|
||||||
thumbnailUrl?: string
|
|
||||||
author?: {
|
|
||||||
name?: string
|
|
||||||
url?: string
|
|
||||||
iconUrl?: string
|
|
||||||
}
|
|
||||||
footer?: {
|
|
||||||
text?: string
|
|
||||||
iconUrl?: string
|
|
||||||
}
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Embed(props: EmbedProps) {
|
|
||||||
return (
|
|
||||||
<reacord-element createInstance={() => new EmbedInstance(props)}>
|
|
||||||
{props.children}
|
|
||||||
</reacord-element>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class EmbedInstance extends ContainerInstance {
|
|
||||||
readonly name = "Embed"
|
|
||||||
|
|
||||||
constructor(readonly props: EmbedProps) {
|
|
||||||
super({ warnOnNonTextChildren: false })
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToMessage(message: MessageOptions) {
|
|
||||||
message.embeds ??= []
|
|
||||||
message.embeds.push(this.getEmbedOptions())
|
|
||||||
}
|
|
||||||
|
|
||||||
getEmbedOptions(): MessageEmbedOptions {
|
|
||||||
const options: MessageEmbedOptions = {
|
|
||||||
...this.props,
|
|
||||||
image: this.props.imageUrl ? { url: this.props.imageUrl } : undefined,
|
|
||||||
thumbnail: this.props.thumbnailUrl
|
|
||||||
? { url: this.props.thumbnailUrl }
|
|
||||||
: undefined,
|
|
||||||
author: {
|
|
||||||
...this.props.author,
|
|
||||||
iconURL: this.props.author?.iconUrl,
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
text: "",
|
|
||||||
...this.props.footer,
|
|
||||||
iconURL: this.props.footer?.iconUrl,
|
|
||||||
},
|
|
||||||
timestamp: this.props.timestamp
|
|
||||||
? new Date(this.props.timestamp) // this _may_ need date-fns to parse this
|
|
||||||
: undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of this.children) {
|
|
||||||
if (!child.renderToEmbed) {
|
|
||||||
console.warn(`${child.name} is not a valid child of ${this.name}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
child.renderToEmbed(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// can't render an empty embed
|
|
||||||
if (!options.description) {
|
|
||||||
options.description = "_ _"
|
|
||||||
}
|
|
||||||
|
|
||||||
return options
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// eslint-disable-next-line import/no-unused-modules
|
||||||
export function pick<T, K extends keyof T>(
|
export function pick<T, K extends keyof T>(
|
||||||
object: T,
|
object: T,
|
||||||
...keys: K[]
|
...keys: K[]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable import/no-unused-modules */
|
||||||
export type MaybePromise<T> = T | Promise<T>
|
export type MaybePromise<T> = T | Promise<T>
|
||||||
|
|
||||||
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
|
export type ValueOf<Type> = Type extends ReadonlyArray<infer Value>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { rejectAfter } from "./reject-after.js"
|
|||||||
import type { MaybePromise } from "./types.js"
|
import type { MaybePromise } from "./types.js"
|
||||||
import { waitFor } from "./wait-for.js"
|
import { waitFor } from "./wait-for.js"
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-unused-modules
|
||||||
export function waitForWithTimeout(
|
export function waitForWithTimeout(
|
||||||
condition: () => MaybePromise<boolean>,
|
condition: () => MaybePromise<boolean>,
|
||||||
timeout = 1000,
|
timeout = 1000,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { inspect } from "node:util"
|
import { inspect } from "node:util"
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-unused-modules
|
||||||
export function withLoggedMethodCalls<T extends object>(value: T) {
|
export function withLoggedMethodCalls<T extends object>(value: T) {
|
||||||
return new Proxy(value as Record<string | symbol, unknown>, {
|
return new Proxy(value as Record<string | symbol, unknown>, {
|
||||||
get(target, property) {
|
get(target, property) {
|
||||||
|
|||||||
9
src/jsx.d.ts
vendored
9
src/jsx.d.ts
vendored
@@ -1,11 +1,14 @@
|
|||||||
declare namespace JSX {
|
import type { ReactNode } from "react"
|
||||||
import type { ReactNode } from "react"
|
import type { Node } from "./node-tree"
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace JSX {
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||||
interface IntrinsicElements {
|
interface IntrinsicElements {
|
||||||
"reacord-element": {
|
"reacord-element": {
|
||||||
createInstance: () => unknown
|
createNode: () => Node
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
src/main.ts
10
src/main.ts
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable import/no-unused-modules */
|
/* eslint-disable import/no-unused-modules */
|
||||||
export * from "./action-row.js"
|
export * from "./components/action-row.js"
|
||||||
export * from "./button.js"
|
export * from "./components/button.js"
|
||||||
export * from "./embed-field.js"
|
export * from "./components/embed-field.js"
|
||||||
export * from "./embed.js"
|
export * from "./components/embed.js"
|
||||||
|
export * from "./components/text.js"
|
||||||
export * from "./root.js"
|
export * from "./root.js"
|
||||||
export * from "./text.js"
|
|
||||||
|
|||||||
192
src/node-tree.ts
Normal file
192
src/node-tree.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import type {
|
||||||
|
BaseMessageComponentOptions,
|
||||||
|
ColorResolvable,
|
||||||
|
EmojiResolvable,
|
||||||
|
MessageActionRowOptions,
|
||||||
|
MessageEmbedOptions,
|
||||||
|
MessageOptions,
|
||||||
|
} from "discord.js"
|
||||||
|
import { nanoid } from "nanoid"
|
||||||
|
import type { ButtonStyle } from "./components/button.js"
|
||||||
|
import { last } from "./helpers/last.js"
|
||||||
|
import { toUpper } from "./helpers/to-upper.js"
|
||||||
|
|
||||||
|
export type MessageNode = {
|
||||||
|
type: "message"
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TextNode = {
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TextElementNode = {
|
||||||
|
type: "textElement"
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmbedNode = {
|
||||||
|
type: "embed"
|
||||||
|
title?: string
|
||||||
|
color?: ColorResolvable
|
||||||
|
url?: string
|
||||||
|
timestamp?: Date | number | string
|
||||||
|
imageUrl?: string
|
||||||
|
thumbnailUrl?: string
|
||||||
|
author?: {
|
||||||
|
name: string
|
||||||
|
url?: string
|
||||||
|
iconUrl?: string
|
||||||
|
}
|
||||||
|
footer?: {
|
||||||
|
text: string
|
||||||
|
iconUrl?: string
|
||||||
|
}
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmbedFieldNode = {
|
||||||
|
type: "embedField"
|
||||||
|
name: string
|
||||||
|
inline?: boolean
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionRowNode = {
|
||||||
|
type: "actionRow"
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ButtonNode = {
|
||||||
|
type: "button"
|
||||||
|
style?: ButtonStyle
|
||||||
|
emoji?: EmojiResolvable
|
||||||
|
disabled?: boolean
|
||||||
|
children: Node[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Node =
|
||||||
|
| MessageNode
|
||||||
|
| TextNode
|
||||||
|
| TextElementNode
|
||||||
|
| EmbedNode
|
||||||
|
| EmbedFieldNode
|
||||||
|
| ActionRowNode
|
||||||
|
| ButtonNode
|
||||||
|
|
||||||
|
export function getMessageOptions(node: MessageNode): MessageOptions {
|
||||||
|
if (node.children.length === 0) {
|
||||||
|
// can't send an empty message
|
||||||
|
return { content: "_ _" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: MessageOptions = {}
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === "text" || child.type === "textElement") {
|
||||||
|
options.content = `${options.content ?? ""}${getNodeText(child)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.type === "embed") {
|
||||||
|
options.embeds ??= []
|
||||||
|
options.embeds.push(getEmbedOptions(child))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.type === "actionRow") {
|
||||||
|
options.components ??= []
|
||||||
|
options.components.push({
|
||||||
|
type: "ACTION_ROW",
|
||||||
|
components: [],
|
||||||
|
})
|
||||||
|
addActionRowItems(options.components, child.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (child.type === "button") {
|
||||||
|
options.components ??= []
|
||||||
|
addActionRowItems(options.components, [child])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.content && !options.embeds?.length) {
|
||||||
|
options.content = "_ _"
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeText(node: Node): string | undefined {
|
||||||
|
if (node.type === "text") {
|
||||||
|
return node.text
|
||||||
|
}
|
||||||
|
if (node.type === "textElement") {
|
||||||
|
return node.children.map(getNodeText).join("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEmbedOptions(node: EmbedNode) {
|
||||||
|
const options: MessageEmbedOptions = {
|
||||||
|
title: node.title,
|
||||||
|
color: node.color,
|
||||||
|
url: node.url,
|
||||||
|
timestamp: node.timestamp ? new Date(node.timestamp) : undefined,
|
||||||
|
image: { url: node.imageUrl },
|
||||||
|
thumbnail: { url: node.thumbnailUrl },
|
||||||
|
description: node.children.map(getNodeText).join(""),
|
||||||
|
author: node.author
|
||||||
|
? { ...node.author, iconURL: node.author.iconUrl }
|
||||||
|
: undefined,
|
||||||
|
footer: node.footer
|
||||||
|
? { text: node.footer.text, iconURL: node.footer.iconUrl }
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
if (child.type === "embedField") {
|
||||||
|
options.fields ??= []
|
||||||
|
options.fields.push({
|
||||||
|
name: child.name,
|
||||||
|
value: child.children.map(getNodeText).join("") || "_ _",
|
||||||
|
inline: child.inline,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.description && !options.author) {
|
||||||
|
options.description = "_ _"
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionRowOptions = Required<BaseMessageComponentOptions> &
|
||||||
|
MessageActionRowOptions
|
||||||
|
|
||||||
|
function addActionRowItems(components: ActionRowOptions[], nodes: Node[]) {
|
||||||
|
let actionRow = last(components)
|
||||||
|
|
||||||
|
if (
|
||||||
|
actionRow == undefined ||
|
||||||
|
actionRow.components[0]?.type === "SELECT_MENU" ||
|
||||||
|
actionRow.components.length >= 5
|
||||||
|
) {
|
||||||
|
actionRow = {
|
||||||
|
type: "ACTION_ROW",
|
||||||
|
components: [],
|
||||||
|
}
|
||||||
|
components.push(actionRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === "button") {
|
||||||
|
actionRow.components.push({
|
||||||
|
type: "BUTTON",
|
||||||
|
label: node.children.map(getNodeText).join(""),
|
||||||
|
style: node.style ? toUpper(node.style) : "SECONDARY",
|
||||||
|
emoji: node.emoji,
|
||||||
|
disabled: node.disabled,
|
||||||
|
customId: nanoid(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,46 +1,41 @@
|
|||||||
/* eslint-disable unicorn/no-null */
|
/* eslint-disable unicorn/no-null */
|
||||||
import { inspect } from "node:util"
|
import { inspect } from "node:util"
|
||||||
import ReactReconciler from "react-reconciler"
|
import ReactReconciler from "react-reconciler"
|
||||||
import { BaseInstance } from "./base-instance.js"
|
|
||||||
import { ContainerInstance } from "./container-instance.js"
|
|
||||||
import type { ReacordContainer } from "./container.js"
|
|
||||||
import { raise } from "./helpers/raise.js"
|
import { raise } from "./helpers/raise.js"
|
||||||
import { TextInstance } from "./text-instance.js"
|
import type { MessageNode, Node, TextNode } from "./node-tree.js"
|
||||||
|
import type { MessageRenderer } from "./renderer.js"
|
||||||
|
|
||||||
type ElementTag = string
|
type ElementTag = string
|
||||||
|
|
||||||
type Props = Record<string, unknown>
|
type Props = Record<string, unknown>
|
||||||
|
|
||||||
const createInstance = (type: string, props: Props): BaseInstance => {
|
const createInstance = (type: string, props: Props): Node => {
|
||||||
if (type !== "reacord-element") {
|
if (type !== "reacord-element") {
|
||||||
raise(`createInstance: unknown type: ${type}`)
|
raise(`createInstance: unknown type: ${type}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof props.createInstance !== "function") {
|
if (typeof props.createNode !== "function") {
|
||||||
const actual = inspect(props.createInstance)
|
const actual = inspect(props.createNode)
|
||||||
raise(`invalid createInstance function, received: ${actual}`)
|
raise(`invalid createNode function, received: ${actual}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const instance = props.createInstance()
|
return props.createNode()
|
||||||
if (!(instance instanceof BaseInstance)) {
|
|
||||||
raise(`invalid instance: ${inspect(instance)}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return instance
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChildSet = MessageNode
|
||||||
|
|
||||||
export const reconciler = ReactReconciler<
|
export const reconciler = ReactReconciler<
|
||||||
string, // Type (jsx tag),
|
string, // Type (jsx tag),
|
||||||
Props, // Props,
|
Props, // Props,
|
||||||
ReacordContainer, // Container,
|
MessageRenderer, // Container,
|
||||||
BaseInstance, // Instance,
|
Node, // Instance,
|
||||||
TextInstance, // TextInstance,
|
TextNode, // TextInstance,
|
||||||
never, // SuspenseInstance,
|
never, // SuspenseInstance,
|
||||||
never, // HydratableInstance,
|
never, // HydratableInstance,
|
||||||
never, // PublicInstance,
|
never, // PublicInstance,
|
||||||
null, // HostContext,
|
null, // HostContext,
|
||||||
[], // UpdatePayload,
|
[], // UpdatePayload,
|
||||||
BaseInstance[], // ChildSet,
|
ChildSet, // ChildSet,
|
||||||
unknown, // TimeoutHandle,
|
unknown, // TimeoutHandle,
|
||||||
unknown // NoTimeout
|
unknown // NoTimeout
|
||||||
>({
|
>({
|
||||||
@@ -59,41 +54,37 @@ export const reconciler = ReactReconciler<
|
|||||||
|
|
||||||
createInstance,
|
createInstance,
|
||||||
|
|
||||||
createTextInstance: (text) => new TextInstance(text),
|
createTextInstance: (text) => ({ type: "text", text }),
|
||||||
|
|
||||||
createContainerChildSet: () => [],
|
createContainerChildSet: (): ChildSet => ({
|
||||||
|
type: "message",
|
||||||
|
children: [],
|
||||||
|
}),
|
||||||
|
|
||||||
appendChildToContainerChildSet: (
|
appendChildToContainerChildSet: (childSet: ChildSet, child: Node) => {
|
||||||
childSet: BaseInstance[],
|
childSet.children.push(child)
|
||||||
child: BaseInstance,
|
|
||||||
) => {
|
|
||||||
childSet.push(child)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
finalizeContainerChildren: (
|
finalizeContainerChildren: (container: MessageRenderer, children: ChildSet) =>
|
||||||
container: ReacordContainer,
|
false,
|
||||||
children: BaseInstance[],
|
|
||||||
) => false,
|
|
||||||
|
|
||||||
replaceContainerChildren: (
|
replaceContainerChildren: (
|
||||||
container: ReacordContainer,
|
container: MessageRenderer,
|
||||||
children: BaseInstance[],
|
children: ChildSet,
|
||||||
) => {
|
) => {
|
||||||
container.render(children)
|
container.render(children)
|
||||||
},
|
},
|
||||||
|
|
||||||
appendInitialChild: (parent, child) => {
|
appendInitialChild: (parent, child) => {
|
||||||
if (parent instanceof ContainerInstance) {
|
if ("children" in parent) {
|
||||||
parent.add(child)
|
parent.children.push(child)
|
||||||
} else {
|
} else {
|
||||||
raise(
|
raise(`${parent.type} cannot have children`)
|
||||||
`Cannot append child of type ${child.constructor.name} to ${parent.constructor.name}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
cloneInstance: (
|
cloneInstance: (
|
||||||
instance: BaseInstance,
|
instance: Node,
|
||||||
_: unknown,
|
_: unknown,
|
||||||
type: ElementTag,
|
type: ElementTag,
|
||||||
oldProps: Props,
|
oldProps: Props,
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Message, MessageOptions, TextBasedChannels } from "discord.js"
|
import type { Message, MessageOptions, TextBasedChannels } from "discord.js"
|
||||||
import type { BaseInstance } from "./base-instance.js"
|
import type { MessageNode } from "./node-tree.js"
|
||||||
|
import { getMessageOptions } from "./node-tree.js"
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: "updateMessage"; options: MessageOptions }
|
| { type: "updateMessage"; options: MessageOptions }
|
||||||
| { type: "deleteMessage" }
|
| { type: "deleteMessage" }
|
||||||
|
|
||||||
export class ReacordContainer {
|
export class MessageRenderer {
|
||||||
private channel: TextBasedChannels
|
private channel: TextBasedChannels
|
||||||
private message?: Message
|
private message?: Message
|
||||||
private actions: Action[] = []
|
private actions: Action[] = []
|
||||||
@@ -15,22 +16,11 @@ export class ReacordContainer {
|
|||||||
this.channel = channel
|
this.channel = channel
|
||||||
}
|
}
|
||||||
|
|
||||||
render(children: BaseInstance[]) {
|
render(node: MessageNode) {
|
||||||
const options: MessageOptions = {}
|
this.addAction({
|
||||||
for (const child of children) {
|
type: "updateMessage",
|
||||||
if (!child.renderToMessage) {
|
options: getMessageOptions(node),
|
||||||
console.warn(`${child.name} is not a valid message child`)
|
})
|
||||||
continue
|
|
||||||
}
|
|
||||||
child.renderToMessage(options)
|
|
||||||
}
|
|
||||||
|
|
||||||
// can't render an empty message
|
|
||||||
if (!options?.content && !options.embeds?.length) {
|
|
||||||
options.content = "_ _"
|
|
||||||
}
|
|
||||||
|
|
||||||
this.addAction({ type: "updateMessage", options })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
/* eslint-disable unicorn/no-null */
|
/* eslint-disable unicorn/no-null */
|
||||||
import type { TextBasedChannels } from "discord.js"
|
import type { TextBasedChannels } from "discord.js"
|
||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { ReacordContainer } from "./container"
|
|
||||||
import { reconciler } from "./reconciler"
|
import { reconciler } from "./reconciler"
|
||||||
|
import { MessageRenderer } from "./renderer"
|
||||||
|
|
||||||
export type ReacordRenderTarget = TextBasedChannels
|
export type ReacordRenderTarget = TextBasedChannels
|
||||||
|
|
||||||
export type ReacordRoot = ReturnType<typeof createRoot>
|
export type ReacordRoot = ReturnType<typeof createRoot>
|
||||||
|
|
||||||
export function createRoot(target: ReacordRenderTarget) {
|
export function createRoot(target: ReacordRenderTarget) {
|
||||||
const container = new ReacordContainer(target)
|
const container = new MessageRenderer(target)
|
||||||
const containerId = reconciler.createContainer(container, 0, false, null)
|
const containerId = reconciler.createContainer(container, 0, false, null)
|
||||||
return {
|
return {
|
||||||
render: (content: ReactNode) => {
|
render: (content: ReactNode) => {
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import type { MessageEmbedOptions, MessageOptions } from "discord.js"
|
|
||||||
import { BaseInstance } from "./base-instance.js"
|
|
||||||
|
|
||||||
/** Represents raw strings in JSX */
|
|
||||||
export class TextInstance extends BaseInstance {
|
|
||||||
readonly name = "Text"
|
|
||||||
|
|
||||||
constructor(private readonly text: string) {
|
|
||||||
super()
|
|
||||||
}
|
|
||||||
|
|
||||||
override getText() {
|
|
||||||
return this.text
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToMessage(options: MessageOptions) {
|
|
||||||
options.content = (options.content ?? "") + this.getText()
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToEmbed(options: MessageEmbedOptions) {
|
|
||||||
options.description = (options.description ?? "") + this.getText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
36
src/text.tsx
36
src/text.tsx
@@ -1,36 +0,0 @@
|
|||||||
import type { MessageEmbedOptions, MessageOptions } from "discord.js"
|
|
||||||
import type { ReactNode } from "react"
|
|
||||||
import React from "react"
|
|
||||||
import { ContainerInstance } from "./container-instance.js"
|
|
||||||
|
|
||||||
export type TextProps = {
|
|
||||||
children?: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Text(props: TextProps) {
|
|
||||||
return (
|
|
||||||
<reacord-element createInstance={() => new TextElementInstance()}>
|
|
||||||
{props.children}
|
|
||||||
</reacord-element>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
class TextElementInstance extends ContainerInstance {
|
|
||||||
readonly name = "Text"
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super({ warnOnNonTextChildren: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
override getText() {
|
|
||||||
return this.getChildrenText()
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToMessage(options: MessageOptions) {
|
|
||||||
options.content = (options.content ?? "") + this.getText()
|
|
||||||
}
|
|
||||||
|
|
||||||
override renderToEmbed(options: MessageEmbedOptions) {
|
|
||||||
options.description = (options.description ?? "") + this.getText()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user