Skip to content

Commit 136b972

Browse files
TheMonDonItzDerock
andauthored
Add support of discords "new" components v2! (#193)
* Initial commit of components v2 * Run lint lol * Requested changes + bug fixes * refactor the const over to function + spelling * fix linting error * add script to send componentv2 tests --------- Co-authored-by: Derock <derock@derock.dev>
1 parent eb93214 commit 136b972

17 files changed

Lines changed: 862 additions & 46 deletions

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"build": "tsc -p tsconfig.json",
1111
"prepack": "npm run build",
1212
"test:typescript": "ts-node ./tests/generate.ts",
13+
"test:send-components-v2": "ts-node ./tests/components-v2-send.ts",
1314
"lint": "prettier --write --cache . && eslint --cache --fix .",
1415
"typecheck": "tsc -p tsconfig.eslint.json"
1516
},
Lines changed: 128 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,135 @@
1-
import { DiscordActionRow, DiscordButton } from '@derockdev/discord-components-react';
2-
import { ButtonStyle, ComponentType, type MessageActionRowComponent, type ActionRow } from 'discord.js';
1+
import { DiscordActionRow, DiscordAttachment, DiscordSpoiler } from '@derockdev/discord-components-react';
2+
import {
3+
ComponentType,
4+
type ThumbnailComponent,
5+
type MessageActionRowComponent,
6+
type TopLevelComponent,
7+
} from 'discord.js';
38
import React from 'react';
49
import { parseDiscordEmoji } from '../../utils/utils';
10+
import DiscordSelectMenu from './components/Select Menu';
11+
import DiscordContainer from './components/Container';
12+
import DiscordSection from './components/section/Section';
13+
import DiscordMediaGallery from './components/Media Gallery';
14+
import DiscordSeparator from './components/Spacing';
15+
import DiscordButton from './components/Button';
16+
import DiscordThumbnail from './components/Thumbnail';
17+
import MessageContent from './content';
18+
import { RenderType } from './content';
19+
import type { RenderMessageContext } from '..';
20+
import { ButtonStyleMapping } from './components/styles';
521

6-
export default function ComponentRow({ row, id }: { row: ActionRow<MessageActionRowComponent>; id: number }) {
7-
return (
8-
<DiscordActionRow key={id}>
9-
{row.components.map((component, id) => (
10-
<Component component={component} id={id} key={id} />
11-
))}
12-
</DiscordActionRow>
13-
);
14-
}
22+
export default function ComponentRow({
23+
component,
24+
id,
25+
context,
26+
}: {
27+
component: TopLevelComponent;
28+
id: number;
29+
context: RenderMessageContext;
30+
}) {
31+
switch (component.type) {
32+
case ComponentType.ActionRow:
33+
return (
34+
<DiscordActionRow key={id}>
35+
<>
36+
{component.components.map((nestedComponent, id) => (
37+
<Component component={nestedComponent} id={id} key={id} />
38+
))}
39+
</>
40+
</DiscordActionRow>
41+
);
42+
43+
case ComponentType.Container:
44+
return (
45+
<DiscordContainer key={id}>
46+
<>
47+
{component.components.map((nestedComponent, id) => (
48+
<ComponentRow component={nestedComponent} id={id} key={id} context={context} />
49+
))}
50+
</>
51+
</DiscordContainer>
52+
);
53+
54+
case ComponentType.File:
55+
return (
56+
<>
57+
{component.spoiler ? (
58+
<DiscordSpoiler key={component.id} slot="attachment">
59+
<DiscordAttachment
60+
type="file"
61+
key={component.id}
62+
slot="attachment"
63+
url={component.file.url}
64+
alt="Discord Attachment"
65+
/>
66+
</DiscordSpoiler>
67+
) : (
68+
<DiscordAttachment
69+
type="file"
70+
key={component.id}
71+
slot="attachment"
72+
url={component.file.url}
73+
alt="Discord Attachment"
74+
/>
75+
)}
76+
</>
77+
);
78+
79+
case ComponentType.MediaGallery:
80+
return <DiscordMediaGallery component={component} key={id} />;
81+
82+
case ComponentType.Section:
83+
return (
84+
<DiscordSection key={id} accessory={component.accessory} id={id}>
85+
{component.components.map((nestedComponent, id) => (
86+
<ComponentRow component={nestedComponent} id={id} key={id} context={context} />
87+
))}
88+
</DiscordSection>
89+
);
90+
91+
case ComponentType.Separator:
92+
return <DiscordSeparator key={id} spacing={component.spacing} divider={component.divider} />;
1593

16-
const ButtonStyleMapping = {
17-
[ButtonStyle.Primary]: 'primary',
18-
[ButtonStyle.Secondary]: 'secondary',
19-
[ButtonStyle.Success]: 'success',
20-
[ButtonStyle.Danger]: 'destructive',
21-
[ButtonStyle.Link]: 'secondary',
22-
[ButtonStyle.Premium]: 'primary',
23-
} satisfies Record<ButtonStyle, Parameters<typeof DiscordButton>[0]['type']>;
24-
25-
export function Component({ component, id }: { component: MessageActionRowComponent; id: number }) {
26-
if (component.type === ComponentType.Button) {
27-
return (
28-
<DiscordButton
29-
key={id}
30-
type={ButtonStyleMapping[component.style]}
31-
url={component.url ?? undefined}
32-
emoji={component.emoji ? parseDiscordEmoji(component.emoji) : undefined}
33-
>
34-
{component.label}
35-
</DiscordButton>
36-
);
94+
case ComponentType.TextDisplay:
95+
return <MessageContent key={id} content={component.content} context={{ ...context, type: RenderType.NORMAL }} />;
96+
97+
default:
98+
return null;
3799
}
100+
}
101+
102+
export function Component({
103+
component,
104+
id,
105+
}: {
106+
component: MessageActionRowComponent | ThumbnailComponent;
107+
id: number;
108+
}) {
109+
switch (component.type) {
110+
case ComponentType.Button:
111+
return (
112+
<DiscordButton
113+
key={id}
114+
type={ButtonStyleMapping[component.style as keyof typeof ButtonStyleMapping]}
115+
url={component.url ?? undefined}
116+
emoji={component.emoji ? parseDiscordEmoji(component.emoji) : undefined}
117+
>
118+
{component.label}
119+
</DiscordButton>
120+
);
38121

39-
return undefined;
122+
case ComponentType.StringSelect:
123+
case ComponentType.UserSelect:
124+
case ComponentType.RoleSelect:
125+
case ComponentType.MentionableSelect:
126+
case ComponentType.ChannelSelect:
127+
return <DiscordSelectMenu key={id} component={component} />;
128+
129+
case ComponentType.Thumbnail:
130+
return <DiscordThumbnail key={id} url={component.media.url} />;
131+
132+
default:
133+
return undefined;
134+
}
40135
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react';
2+
3+
interface DiscordButtonProps {
4+
type: string;
5+
url?: string;
6+
emoji?: string;
7+
children: React.ReactNode;
8+
}
9+
10+
export function DiscordButton({ type, url, emoji, children }: DiscordButtonProps) {
11+
return (
12+
<a href={url} target="_blank" className={`discord-button discord-button-${type}`}>
13+
{emoji && (
14+
<span style={{ display: 'flex', alignItems: 'center' }}>
15+
<img src={emoji} alt="emoji" style={{ width: '16px', height: '16px', marginRight: '8px' }} />
16+
</span>
17+
)}
18+
<span style={{ display: 'flex', alignItems: 'center' }}>{children}</span>
19+
{url && (
20+
<span style={{ marginLeft: '8px', display: 'flex', alignItems: 'center' }}>
21+
<svg role="img" xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24">
22+
<path
23+
fill="currentColor"
24+
d="M15 2a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 1 1-2 0V4.41l-4.3 4.3a1 1 0 1 1-1.4-1.42L19.58 3H16a1 1 0 0 1-1-1Z"
25+
/>
26+
<path
27+
fill="currentColor"
28+
d="M5 2a3 3 0 0 0-3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-6a1 1 0 1 0-2 0v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h6a1 1 0 1 0 0-2H5Z"
29+
/>
30+
</svg>
31+
</span>
32+
)}
33+
</a>
34+
);
35+
}
36+
37+
export default DiscordButton;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import React from 'react';
2+
3+
function DiscordContainer({ children }: { children: React.ReactNode }) {
4+
return (
5+
<div
6+
style={{
7+
display: 'flex',
8+
width: '500px',
9+
flexDirection: 'column',
10+
backgroundColor: '#3f4248',
11+
padding: '16px',
12+
border: '1px solid #4f5359',
13+
marginTop: '2px',
14+
marginBottom: '2px',
15+
borderRadius: '10px',
16+
gap: '8px',
17+
}}
18+
>
19+
{children}
20+
</div>
21+
);
22+
}
23+
24+
export default DiscordContainer;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from 'react';
2+
import type { MediaGalleryComponent } from 'discord.js';
3+
import { getGalleryLayout, getImageStyle } from './utils';
4+
5+
function DiscordMediaGallery({ component }: { component: MediaGalleryComponent }) {
6+
if (!component.items || component.items.length === 0) {
7+
return null;
8+
}
9+
10+
const count = component.items.length;
11+
const imagesToShow = component.items.slice(0, 10);
12+
const hasMore = component.items.length > 10;
13+
14+
return (
15+
<div style={getGalleryLayout(count)}>
16+
{imagesToShow.map((media, idx) => (
17+
<div key={idx} style={getImageStyle(idx, count)}>
18+
<img
19+
src={media.media.url}
20+
alt={media.description || 'Media content'}
21+
style={{
22+
width: '100%',
23+
height: '100%',
24+
objectFit: 'cover',
25+
}}
26+
/>
27+
{hasMore && idx === imagesToShow.length - 1 && (
28+
<div
29+
style={{
30+
position: 'absolute',
31+
top: 0,
32+
left: 0,
33+
width: '100%',
34+
height: '100%',
35+
display: 'flex',
36+
alignItems: 'center',
37+
justifyContent: 'center',
38+
backgroundColor: 'rgba(0, 0, 0, 0.7)',
39+
color: 'white',
40+
fontSize: '20px',
41+
fontWeight: 'bold',
42+
}}
43+
>
44+
+{component.items.length - 10}
45+
</div>
46+
)}
47+
</div>
48+
))}
49+
</div>
50+
);
51+
}
52+
53+
export default DiscordMediaGallery;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import { type MessageActionRowComponent, ComponentType } from 'discord.js';
3+
import { parseDiscordEmoji } from '../../../utils/utils';
4+
import { getSelectTypeLabel } from './utils';
5+
6+
function DiscordSelectMenu({
7+
component,
8+
}: {
9+
component: Exclude<MessageActionRowComponent, { type: ComponentType.Button }>;
10+
}) {
11+
const isStringSelect = component.type === ComponentType.StringSelect;
12+
const placeholder = component.placeholder || getSelectTypeLabel(component.type);
13+
14+
return (
15+
<div className="discord-select-menu">
16+
<div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{placeholder}</div>
17+
<div style={{ display: 'flex', alignItems: 'center', marginLeft: '8px' }}>
18+
<svg width="24" height="24" viewBox="0 0 24 24">
19+
<path fill="currentColor" d="M7 10L12 15L17 10H7Z" />
20+
</svg>
21+
</div>
22+
{isStringSelect && component.options && component.options.length > 0 && (
23+
<div
24+
style={{
25+
display: 'none',
26+
position: 'absolute',
27+
top: '44px',
28+
left: '0',
29+
width: '100%',
30+
backgroundColor: '#2b2d31',
31+
borderRadius: '4px',
32+
zIndex: 10,
33+
border: '1px solid #1e1f22',
34+
maxHeight: '320px',
35+
overflowY: 'auto',
36+
}}
37+
>
38+
{component.options.map((option, idx) => (
39+
<div
40+
key={idx}
41+
style={{
42+
padding: '8px 12px',
43+
cursor: 'pointer',
44+
display: 'flex',
45+
alignItems: 'center',
46+
borderBottom: idx < component.options.length - 1 ? '1px solid #1e1f22' : 'none',
47+
}}
48+
>
49+
{option.emoji && <span style={{ marginRight: '8px' }}>{parseDiscordEmoji(option.emoji)}</span>}
50+
<span>{option.label}</span>
51+
</div>
52+
))}
53+
</div>
54+
)}
55+
</div>
56+
);
57+
}
58+
59+
export default DiscordSelectMenu;
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react';
2+
import { SeparatorSpacingSize } from 'discord.js';
3+
4+
function DiscordSeparator({ divider, spacing }: { divider: boolean; spacing: SeparatorSpacingSize }) {
5+
return (
6+
<div
7+
style={{
8+
width: '100%',
9+
height: divider ? '1px' : '0px',
10+
backgroundColor: '#4f5359',
11+
margin: spacing === SeparatorSpacingSize.Large ? '8px 0' : '0',
12+
}}
13+
/>
14+
);
15+
}
16+
17+
export default DiscordSeparator;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import React from 'react';
2+
3+
function DiscordThumbnail({ url }: { url: string }) {
4+
return (
5+
<img
6+
src={url}
7+
alt="Thumbnail"
8+
style={{
9+
width: '85px',
10+
height: '85px',
11+
objectFit: 'cover',
12+
borderRadius: '8px',
13+
}}
14+
/>
15+
);
16+
}
17+
18+
export default DiscordThumbnail;

0 commit comments

Comments
 (0)