diff --git a/package.json b/package.json index 2c0f71e..0019cfb 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "tsc -p tsconfig.json", "prepack": "npm run build", "test:typescript": "ts-node ./tests/generate.ts", + "test:send-components-v2": "ts-node ./tests/components-v2-send.ts", "lint": "prettier --write --cache . && eslint --cache --fix .", "typecheck": "tsc -p tsconfig.eslint.json" }, diff --git a/src/generator/renderers/components.tsx b/src/generator/renderers/components.tsx index 83bfaa4..15638c0 100644 --- a/src/generator/renderers/components.tsx +++ b/src/generator/renderers/components.tsx @@ -1,40 +1,135 @@ -import { DiscordActionRow, DiscordButton } from '@derockdev/discord-components-react'; -import { ButtonStyle, ComponentType, type MessageActionRowComponent, type ActionRow } from 'discord.js'; +import { DiscordActionRow, DiscordAttachment, DiscordSpoiler } from '@derockdev/discord-components-react'; +import { + ComponentType, + type ThumbnailComponent, + type MessageActionRowComponent, + type TopLevelComponent, +} from 'discord.js'; import React from 'react'; import { parseDiscordEmoji } from '../../utils/utils'; +import DiscordSelectMenu from './components/Select Menu'; +import DiscordContainer from './components/Container'; +import DiscordSection from './components/section/Section'; +import DiscordMediaGallery from './components/Media Gallery'; +import DiscordSeparator from './components/Spacing'; +import DiscordButton from './components/Button'; +import DiscordThumbnail from './components/Thumbnail'; +import MessageContent from './content'; +import { RenderType } from './content'; +import type { RenderMessageContext } from '..'; +import { ButtonStyleMapping } from './components/styles'; -export default function ComponentRow({ row, id }: { row: ActionRow; id: number }) { - return ( - - {row.components.map((component, id) => ( - - ))} - - ); -} +export default function ComponentRow({ + component, + id, + context, +}: { + component: TopLevelComponent; + id: number; + context: RenderMessageContext; +}) { + switch (component.type) { + case ComponentType.ActionRow: + return ( + + <> + {component.components.map((nestedComponent, id) => ( + + ))} + + + ); + + case ComponentType.Container: + return ( + + <> + {component.components.map((nestedComponent, id) => ( + + ))} + + + ); + + case ComponentType.File: + return ( + <> + {component.spoiler ? ( + + + + ) : ( + + )} + + ); + + case ComponentType.MediaGallery: + return ; + + case ComponentType.Section: + return ( + + {component.components.map((nestedComponent, id) => ( + + ))} + + ); + + case ComponentType.Separator: + return ; -const ButtonStyleMapping = { - [ButtonStyle.Primary]: 'primary', - [ButtonStyle.Secondary]: 'secondary', - [ButtonStyle.Success]: 'success', - [ButtonStyle.Danger]: 'destructive', - [ButtonStyle.Link]: 'secondary', - [ButtonStyle.Premium]: 'primary', -} satisfies Record[0]['type']>; - -export function Component({ component, id }: { component: MessageActionRowComponent; id: number }) { - if (component.type === ComponentType.Button) { - return ( - - {component.label} - - ); + case ComponentType.TextDisplay: + return ; + + default: + return null; } +} + +export function Component({ + component, + id, +}: { + component: MessageActionRowComponent | ThumbnailComponent; + id: number; +}) { + switch (component.type) { + case ComponentType.Button: + return ( + + {component.label} + + ); - return undefined; + case ComponentType.StringSelect: + case ComponentType.UserSelect: + case ComponentType.RoleSelect: + case ComponentType.MentionableSelect: + case ComponentType.ChannelSelect: + return ; + + case ComponentType.Thumbnail: + return ; + + default: + return undefined; + } } diff --git a/src/generator/renderers/components/Button.tsx b/src/generator/renderers/components/Button.tsx new file mode 100644 index 0000000..69d4478 --- /dev/null +++ b/src/generator/renderers/components/Button.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +interface DiscordButtonProps { + type: string; + url?: string; + emoji?: string; + children: React.ReactNode; +} + +export function DiscordButton({ type, url, emoji, children }: DiscordButtonProps) { + return ( + + {emoji && ( + + emoji + + )} + {children} + {url && ( + + + + + + + )} + + ); +} + +export default DiscordButton; diff --git a/src/generator/renderers/components/Container.tsx b/src/generator/renderers/components/Container.tsx new file mode 100644 index 0000000..1e091f7 --- /dev/null +++ b/src/generator/renderers/components/Container.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +function DiscordContainer({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +export default DiscordContainer; diff --git a/src/generator/renderers/components/Media Gallery.tsx b/src/generator/renderers/components/Media Gallery.tsx new file mode 100644 index 0000000..1b1e7d9 --- /dev/null +++ b/src/generator/renderers/components/Media Gallery.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import type { MediaGalleryComponent } from 'discord.js'; +import { getGalleryLayout, getImageStyle } from './utils'; + +function DiscordMediaGallery({ component }: { component: MediaGalleryComponent }) { + if (!component.items || component.items.length === 0) { + return null; + } + + const count = component.items.length; + const imagesToShow = component.items.slice(0, 10); + const hasMore = component.items.length > 10; + + return ( +
+ {imagesToShow.map((media, idx) => ( +
+ {media.description + {hasMore && idx === imagesToShow.length - 1 && ( +
+ +{component.items.length - 10} +
+ )} +
+ ))} +
+ ); +} + +export default DiscordMediaGallery; diff --git a/src/generator/renderers/components/Select Menu.tsx b/src/generator/renderers/components/Select Menu.tsx new file mode 100644 index 0000000..ba817ff --- /dev/null +++ b/src/generator/renderers/components/Select Menu.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { type MessageActionRowComponent, ComponentType } from 'discord.js'; +import { parseDiscordEmoji } from '../../../utils/utils'; +import { getSelectTypeLabel } from './utils'; + +function DiscordSelectMenu({ + component, +}: { + component: Exclude; +}) { + const isStringSelect = component.type === ComponentType.StringSelect; + const placeholder = component.placeholder || getSelectTypeLabel(component.type); + + return ( +
+
{placeholder}
+
+ + + +
+ {isStringSelect && component.options && component.options.length > 0 && ( +
+ {component.options.map((option, idx) => ( +
+ {option.emoji && {parseDiscordEmoji(option.emoji)}} + {option.label} +
+ ))} +
+ )} +
+ ); +} + +export default DiscordSelectMenu; diff --git a/src/generator/renderers/components/Spacing.tsx b/src/generator/renderers/components/Spacing.tsx new file mode 100644 index 0000000..af617cb --- /dev/null +++ b/src/generator/renderers/components/Spacing.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { SeparatorSpacingSize } from 'discord.js'; + +function DiscordSeparator({ divider, spacing }: { divider: boolean; spacing: SeparatorSpacingSize }) { + return ( +
+ ); +} + +export default DiscordSeparator; diff --git a/src/generator/renderers/components/Thumbnail.tsx b/src/generator/renderers/components/Thumbnail.tsx new file mode 100644 index 0000000..77b2198 --- /dev/null +++ b/src/generator/renderers/components/Thumbnail.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +function DiscordThumbnail({ url }: { url: string }) { + return ( + Thumbnail + ); +} + +export default DiscordThumbnail; diff --git a/src/generator/renderers/components/section/Section.tsx b/src/generator/renderers/components/section/Section.tsx new file mode 100644 index 0000000..a6bcfce --- /dev/null +++ b/src/generator/renderers/components/section/Section.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import type { ButtonComponent, ThumbnailComponent } from 'discord.js'; +import { Component } from '../../components'; +import SectionContent from './SectionContent'; +import SectionAccessory from './SectionAccessory'; + +interface DiscordSectionProps { + children: React.ReactNode; + accessory?: ButtonComponent | ThumbnailComponent; + id: number; +} + +function DiscordSection({ children, accessory, id }: DiscordSectionProps) { + return ( +
+ {children} + {accessory && } +
+ ); +} + +export default DiscordSection; diff --git a/src/generator/renderers/components/section/SectionAccessory.tsx b/src/generator/renderers/components/section/SectionAccessory.tsx new file mode 100644 index 0000000..120a61f --- /dev/null +++ b/src/generator/renderers/components/section/SectionAccessory.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +interface SectionAccessoryProps { + children?: React.ReactNode; +} + +function SectionAccessory({ children }: SectionAccessoryProps) { + if (!children) return null; + + return ( +
+ {children} +
+ ); +} + +export default SectionAccessory; diff --git a/src/generator/renderers/components/section/SectionContent.tsx b/src/generator/renderers/components/section/SectionContent.tsx new file mode 100644 index 0000000..b96d3c0 --- /dev/null +++ b/src/generator/renderers/components/section/SectionContent.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +interface SectionContentProps { + children: React.ReactNode; +} + +function SectionContent({ children }: SectionContentProps) { + return ( +
+ {children} +
+ ); +} + +export default SectionContent; diff --git a/src/generator/renderers/components/styles.ts b/src/generator/renderers/components/styles.ts new file mode 100644 index 0000000..25593b2 --- /dev/null +++ b/src/generator/renderers/components/styles.ts @@ -0,0 +1,104 @@ +import type { CSSProperties } from 'react'; +import { ButtonStyle } from 'discord.js'; + +// Container styles +export const containerStyle = { + display: 'grid', + gap: '4px', + width: '100%', + maxWidth: '500px', + borderRadius: '8px', + overflow: 'hidden', +} satisfies CSSProperties; + +// Base image style +export const baseImageStyle = { + overflow: 'hidden', + position: 'relative', + background: '#2b2d31', +} satisfies CSSProperties; + +// Button style mapping +export const ButtonStyleMapping = { + [ButtonStyle.Primary]: 'primary', + [ButtonStyle.Secondary]: 'secondary', + [ButtonStyle.Success]: 'success', + [ButtonStyle.Danger]: 'destructive', + [ButtonStyle.Link]: 'secondary', +} as const; + +export const globalStyles = ` + .discord-container { + display: grid; + gap: 4px; + width: 100%; + max-width: 500px; + border-radius: 8px; + overflow: hidden; + } + + .discord-base-image { + overflow: hidden; + position: relative; + background: #2b2d31; + } + + .discord-button { + color: #ffffff !important; + padding: 2px 16px; + border-radius: 8px; + text-decoration: none !important; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: 500; + height: 32px; + min-height: 32px; + min-width: 60px; + cursor: pointer; + font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif; + text-align: center; + box-sizing: border-box; + border: none; + outline: none; + transition: background-color 0.2s ease; + } + + .discord-button-primary { + background-color: hsl(234.935 calc(1*85.556%) 64.706% /1); + } + + .discord-button-secondary { + background-color: hsl(240 calc(1*4%) 60.784% /0.12156862745098039); + } + + .discord-button-success { + background-color: hsl(145.97 calc(1*100%) 26.275% /1); + } + + .discord-button-destructive { + background-color: hsl(355.636 calc(1*64.706%) 50% /1); + } + + .discord-select-menu { + margin-top: 2px; + margin-bottom: 2px; + position: relative; + width: 100%; + max-width: 500px; + height: 40px; + background-color: #2b2d31; + border-radius: 4px; + color: #b5bac1; + cursor: pointer; + font-family: Whitney, "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 14px; + display: flex; + align-items: center; + padding: 0 8px; + justify-content: space-between; + box-sizing: border-box; + border: 1px solid #1e1f22; + } +`; diff --git a/src/generator/renderers/components/utils.ts b/src/generator/renderers/components/utils.ts new file mode 100644 index 0000000..5922712 --- /dev/null +++ b/src/generator/renderers/components/utils.ts @@ -0,0 +1,168 @@ +import { ComponentType } from 'discord.js'; +import { baseImageStyle, containerStyle } from './styles'; + +/** + * Gets the appropriate label for different select menu types + */ +const SELECT_LABEL_MAP = { + [ComponentType.UserSelect]: 'Select User', + [ComponentType.RoleSelect]: 'Select Role', + [ComponentType.MentionableSelect]: 'Select Mentionable', + [ComponentType.ChannelSelect]: 'Select Channel', + [ComponentType.StringSelect]: 'Make a Selection', +} as const; + +export function getSelectTypeLabel(type: ComponentType): string { + return SELECT_LABEL_MAP[type as keyof typeof SELECT_LABEL_MAP] ?? 'Select Option'; +} + +/** + * Gets the grid layout for media galleries based on count + */ +export function getGalleryLayout(count: number) { + switch (count) { + case 1: + return { + ...containerStyle, + gridTemplateColumns: '1fr', + gridTemplateRows: 'auto', + }; + case 2: + return { + ...containerStyle, + gridTemplateColumns: '1fr 1fr', + gridTemplateRows: 'auto', + }; + case 3: + case 4: + return { + ...containerStyle, + gridTemplateColumns: '1fr 1fr', + gridTemplateRows: '1fr 1fr', + }; + case 5: + return { + ...containerStyle, + gridTemplateColumns: '1fr 1fr 1fr', + gridTemplateRows: 'auto auto', + }; + default: + if (count >= 7) { + return { + ...containerStyle, + gridTemplateColumns: '1fr 1fr 1fr', + gridTemplateRows: 'auto auto auto auto', + }; + } else { + return { + ...containerStyle, + gridTemplateColumns: '1fr 1fr 1fr', + gridTemplateRows: 'auto', + }; + } + } +} + +/** + * Gets the style for an individual image based on its position and total count + */ +export function getImageStyle(idx: number, count: number) { + switch (count) { + case 3: + if (idx === 0) { + return { + ...baseImageStyle, + gridRow: '1 / span 2', + gridColumn: '1', + aspectRatio: '1/2', + }; + } + break; + + case 5: + if (idx < 2) { + return { + ...baseImageStyle, + gridRow: '1', + gridColumn: idx === 0 ? '1 / span 2' : '3', + }; + } else { + return { + ...baseImageStyle, + gridRow: '2', + gridColumn: `${idx - 2 + 1}`, + }; + } + + case 7: + if (idx === 0) { + return { + ...baseImageStyle, + gridRow: '1', + gridColumn: '1 / span 3', + }; + } else if (idx <= 3) { + return { + ...baseImageStyle, + gridRow: '2', + gridColumn: `${idx - 0}`, + }; + } else { + return { + ...baseImageStyle, + gridRow: '3', + gridColumn: `${idx - 3}`, + }; + } + + case 8: + if (idx < 2) { + return { + ...baseImageStyle, + gridRow: '1', + gridColumn: idx === 0 ? '1 / span 2' : '3', + }; + } else if (idx < 5) { + return { + ...baseImageStyle, + gridRow: '2', + gridColumn: `${idx - 2 + 1}`, + }; + } else { + return { + ...baseImageStyle, + gridRow: '3', + gridColumn: `${idx - 5 + 1}`, + }; + } + + case 10: + if (idx === 0) { + return { + ...baseImageStyle, + gridRow: '1', + gridColumn: '1 / span 3', + }; + } else if (idx <= 3) { + return { + ...baseImageStyle, + gridRow: '2', + gridColumn: `${idx - 0}`, + }; + } else if (idx <= 6) { + return { + ...baseImageStyle, + gridRow: '3', + gridColumn: `${idx - 3}`, + }; + } else { + return { + ...baseImageStyle, + gridRow: '4', + gridColumn: `${idx - 6}`, + }; + } + } + + return baseImageStyle; +} diff --git a/src/generator/renderers/message.tsx b/src/generator/renderers/message.tsx index 979f459..17998e8 100644 --- a/src/generator/renderers/message.tsx +++ b/src/generator/renderers/message.tsx @@ -7,7 +7,7 @@ import { DiscordThread, DiscordThreadMessage, } from '@derockdev/discord-components-react'; -import { ComponentType, type Message as MessageType } from 'discord.js'; +import { type Message as MessageType } from 'discord.js'; import React from 'react'; import type { RenderMessageContext } from '..'; import { parseDiscordEmoji } from '../../utils/utils'; @@ -70,12 +70,9 @@ export default async function DiscordMessage({ {/* components */} {message.components.length > 0 && ( - {message.components - // TODO: support other component types - .filter((x) => x.type === ComponentType.ActionRow) - .map((component, id) => ( - - ))} + {message.components.map((component, id) => ( + + ))} )} diff --git a/src/generator/renderers/reply.tsx b/src/generator/renderers/reply.tsx index 0d2016b..b595b03 100644 --- a/src/generator/renderers/reply.tsx +++ b/src/generator/renderers/reply.tsx @@ -12,7 +12,7 @@ export default async function MessageReply({ message, context }: { message: Mess if (!referencedMessage) return Message could not be loaded.; - const isCrosspost = referencedMessage.reference && referencedMessage.reference.guildId !== message.guild?.id; + const isCrossPost = referencedMessage.reference && referencedMessage.reference.guildId !== message.guild?.id; const isCommand = referencedMessage.interaction !== null; return ( @@ -25,10 +25,10 @@ export default async function MessageReply({ message, context }: { message: Mess } avatar={referencedMessage.author.avatarURL({ size: 32 }) ?? undefined} roleColor={referencedMessage.member?.displayHexColor ?? undefined} - bot={!isCrosspost && referencedMessage.author.bot} + bot={!isCrossPost && referencedMessage.author.bot} verified={referencedMessage.author.flags?.has(UserFlags.VerifiedBot)} op={message?.channel?.isThread?.() && referencedMessage.author.id === message?.channel?.ownerId} - server={isCrosspost ?? undefined} + server={isCrossPost ?? undefined} command={isCommand} > {referencedMessage.content ? ( diff --git a/src/generator/transcript.tsx b/src/generator/transcript.tsx index e7ef381..4c8ee8e 100644 --- a/src/generator/transcript.tsx +++ b/src/generator/transcript.tsx @@ -4,6 +4,7 @@ import React from 'react'; import type { RenderMessageContext } from '.'; import MessageContent, { RenderType } from './renderers/content'; import DiscordMessage from './renderers/message'; +import { globalStyles } from './renderers/components/styles'; /** * The core transcript component. @@ -15,7 +16,7 @@ import DiscordMessage from './renderers/message'; export default async function DiscordMessages({ messages, channel, callbacks, ...options }: RenderMessageContext) { return ( - {/* header */} +