Extend message features
This commit is contained in:
1 parent
2665c61d33
commit
439575acd0
20 files changed
+1326
-155
No files matched your search
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode development",
|
||||
"build": "vite build",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"preview": "vite preview",
|
||||
"prettier": "prettier -w src/"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { prepareReminder } from "./components/Reminder/message";
|
||||
import type { MessagePayload } from "./components/Reminder/message";
|
||||
import axios from "axios";
|
||||
|
||||
enum ColorScheme {
|
||||
@@ -38,6 +40,7 @@ type EmbedField = {
|
||||
};
|
||||
|
||||
export type Reminder = {
|
||||
message_json?: MessagePayload | null;
|
||||
attachment: string | null;
|
||||
attachment_name: string | null;
|
||||
avatar: string | null;
|
||||
@@ -186,12 +189,14 @@ export const fetchGuildReminders = (guild: string) => ({
|
||||
|
||||
export const patchGuildReminder = (guild: string) => ({
|
||||
mutationFn: (reminder: Reminder) =>
|
||||
axios.patch(`/dashboard/api/guild/${guild}/reminders`, reminder),
|
||||
axios.patch(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder)),
|
||||
});
|
||||
|
||||
export const postGuildReminder = (guild: string) => ({
|
||||
mutationFn: (reminder: Reminder) =>
|
||||
axios.post(`/dashboard/api/guild/${guild}/reminders`, reminder).then((resp) => resp.data),
|
||||
axios
|
||||
.post(`/dashboard/api/guild/${guild}/reminders`, prepareReminder(reminder))
|
||||
.then((resp) => resp.data),
|
||||
});
|
||||
|
||||
export const deleteReminder = () => ({
|
||||
@@ -257,9 +262,12 @@ export const fetchUserReminders = () => ({
|
||||
|
||||
export const postUserReminder = () => ({
|
||||
mutationFn: (reminder: Reminder) =>
|
||||
axios.post(`/dashboard/api/user/reminders`, reminder).then((resp) => resp.data),
|
||||
axios
|
||||
.post(`/dashboard/api/user/reminders`, prepareReminder(reminder))
|
||||
.then((resp) => resp.data),
|
||||
});
|
||||
|
||||
export const patchUserReminder = () => ({
|
||||
mutationFn: (reminder: Reminder) => axios.patch(`/dashboard/api/user/reminders`, reminder),
|
||||
mutationFn: (reminder: Reminder) =>
|
||||
axios.patch(`/dashboard/api/user/reminders`, prepareReminder(reminder)),
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Embed } from "./index";
|
||||
import { ReminderContext, useReminder } from "../ReminderContext";
|
||||
import { Reminder } from "../../../api";
|
||||
import {
|
||||
addEmbed,
|
||||
embedFields,
|
||||
embedTextLength,
|
||||
MAX_EMBEDS,
|
||||
MAX_EMBED_TEXT,
|
||||
reminderEmbeds,
|
||||
removeEmbed,
|
||||
updateEmbed,
|
||||
} from "../message";
|
||||
|
||||
const EmbedEditor = ({ index }: { index: number }) => {
|
||||
const [reminder, setReminder] = useReminder();
|
||||
const embed = reminderEmbeds(reminder)[index];
|
||||
const update = (change: (r: Reminder) => Reminder) => {
|
||||
setReminder((current) => updateEmbed(current, index, change));
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="reminder-embed-editor" role="group" aria-label={`Embed ${index + 1}`}>
|
||||
<ReminderContext.Provider value={[{ ...reminder, ...embedFields(embed) }, update]}>
|
||||
<Embed />
|
||||
</ReminderContext.Provider>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-text remove-embed"
|
||||
aria-label={`Remove embed ${index + 1}`}
|
||||
onClick={() => setReminder((current) => removeEmbed(current, index))}
|
||||
>
|
||||
<i class="fas fa-trash" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Embeds = () => {
|
||||
const [reminder, setReminder] = useReminder();
|
||||
const embeds = reminderEmbeds(reminder);
|
||||
const textLength = embedTextLength(embeds);
|
||||
|
||||
return (
|
||||
<div class="reminder-embeds">
|
||||
{embeds.map((_, index) => (
|
||||
<EmbedEditor key={index} index={index} />
|
||||
))}
|
||||
<div class="add-embed-row">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-rounded add-embed"
|
||||
aria-label="Add embed"
|
||||
title={embeds.length >= MAX_EMBEDS ? "Maximum of 10 embeds" : "Add embed"}
|
||||
disabled={embeds.length >= MAX_EMBEDS}
|
||||
onClick={() => setReminder(addEmbed)}
|
||||
>
|
||||
<i class="fas fa-plus" aria-hidden="true" />
|
||||
</button>
|
||||
<span
|
||||
class={`embed-limit${textLength > MAX_EMBED_TEXT ? " has-text-danger" : ""}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{embeds.length}/{MAX_EMBEDS} embeds · {textLength}/{MAX_EMBED_TEXT} characters
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,7 +6,10 @@ export const Fields = () => {
|
||||
|
||||
return (
|
||||
<div class={"embed-multifield-box"}>
|
||||
{[...embed_fields, { value: "", title: "", inline: true }].map((field, index) => (
|
||||
{[
|
||||
...(embed_fields || []),
|
||||
...(embed_fields?.length >= 25 ? [] : [{ value: "", title: "", inline: true }]),
|
||||
].map((field, index) => (
|
||||
<Field
|
||||
{...field}
|
||||
index={index}
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Color } from "./Color";
|
||||
import { Fields } from "./Fields";
|
||||
import { Reminder } from "../../../api";
|
||||
import { ImagePicker } from "../ImagePicker";
|
||||
import { DEFAULT_EMBED_COLOR } from "../message";
|
||||
import { useReminder } from "../ReminderContext";
|
||||
|
||||
function intToColor(num: number) {
|
||||
return `#${num.toString(16).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
export const DEFAULT_COLOR = 9418359;
|
||||
export const DEFAULT_COLOR = DEFAULT_EMBED_COLOR;
|
||||
|
||||
export const Embed = () => {
|
||||
const [reminder, setReminder] = useReminder();
|
||||
@@ -21,12 +22,12 @@ export const Embed = () => {
|
||||
<div
|
||||
class="discord-embed"
|
||||
style={{
|
||||
borderLeftColor: intToColor(reminder.embed_color || DEFAULT_COLOR),
|
||||
borderLeftColor: intToColor(reminder.embed_color ?? DEFAULT_COLOR),
|
||||
}}
|
||||
>
|
||||
<div class="embed-body">
|
||||
<Color
|
||||
color={intToColor(reminder.embed_color || DEFAULT_COLOR)}
|
||||
color={intToColor(reminder.embed_color ?? DEFAULT_COLOR)}
|
||||
setReminder={setReminder}
|
||||
></Color>
|
||||
<div class="a">
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useReminder } from "./ReminderContext";
|
||||
import {
|
||||
addLinkButton,
|
||||
canAddLinkButton,
|
||||
linkButtons,
|
||||
MAX_LINK_BUTTONS,
|
||||
updateLinkButton,
|
||||
} from "./message";
|
||||
|
||||
export const LinkButtons = () => {
|
||||
const [reminder, setReminder] = useReminder();
|
||||
const buttons = linkButtons(reminder);
|
||||
|
||||
return (
|
||||
<div class="reminder-link-buttons" role="group" aria-label="Link buttons">
|
||||
{buttons.map(({ button, rowIndex, buttonIndex }, index) => (
|
||||
<div class="link-button-editor" key={`${rowIndex}-${buttonIndex}`}>
|
||||
<label class="link-button-label">
|
||||
Button label
|
||||
<input
|
||||
class="input is-small"
|
||||
aria-label={`Link button ${index + 1} label`}
|
||||
maxLength={80}
|
||||
value={button.label || ""}
|
||||
onInput={(event) => {
|
||||
const label = event.currentTarget.value;
|
||||
setReminder((current) =>
|
||||
updateLinkButton(current, rowIndex, buttonIndex, { label }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label class="link-button-url">
|
||||
URL
|
||||
<input
|
||||
class="input is-small"
|
||||
type="url"
|
||||
aria-label={`Link button ${index + 1} URL`}
|
||||
placeholder="https://example.com"
|
||||
maxLength={512}
|
||||
value={button.url || ""}
|
||||
onInput={(event) => {
|
||||
const url = event.currentTarget.value;
|
||||
setReminder((current) =>
|
||||
updateLinkButton(current, rowIndex, buttonIndex, { url }),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-text"
|
||||
aria-label={`Remove link button ${index + 1}`}
|
||||
onClick={() =>
|
||||
setReminder((current) =>
|
||||
updateLinkButton(current, rowIndex, buttonIndex, null),
|
||||
)
|
||||
}
|
||||
>
|
||||
<i class="fas fa-trash" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div class="add-link-button-row">
|
||||
<button
|
||||
type="button"
|
||||
class="button is-small is-rounded"
|
||||
disabled={!canAddLinkButton(reminder)}
|
||||
onClick={() => setReminder(addLinkButton)}
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<i class="fas fa-plus" aria-hidden="true" />
|
||||
</span>
|
||||
<span>Add link button</span>
|
||||
</button>
|
||||
<span class="link-button-limit" aria-live="polite">
|
||||
{buttons.length}/{MAX_LINK_BUTTONS} link buttons
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -82,6 +82,7 @@ const LoadTemplateModal = ({ setModalOpen }) => {
|
||||
setReminder((reminder) => ({
|
||||
...reminder,
|
||||
...template,
|
||||
message_json: null,
|
||||
// drop the template's ID
|
||||
id: undefined,
|
||||
}));
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Username } from "./Username";
|
||||
import { Content } from "./Content";
|
||||
import { Embed } from "./Embed";
|
||||
import { Embeds } from "./Embed/Embeds";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { LinkButtons } from "./LinkButtons";
|
||||
|
||||
export const Message = () => (
|
||||
<div class="column discord-frame">
|
||||
@@ -15,7 +16,8 @@ export const Message = () => (
|
||||
<div class="content">
|
||||
<Username />
|
||||
<Content />
|
||||
<Embed />
|
||||
<Embeds />
|
||||
<LinkButtons />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { Reminder } from "../../api";
|
||||
|
||||
export const MAX_EMBEDS = 10;
|
||||
export const MAX_EMBED_TEXT = 6000;
|
||||
export const DEFAULT_EMBED_COLOR = 9418359;
|
||||
export const MAX_BUTTON_ROWS = 5;
|
||||
export const MAX_BUTTONS_PER_ROW = 5;
|
||||
export const MAX_LINK_BUTTONS = MAX_BUTTON_ROWS * MAX_BUTTONS_PER_ROW;
|
||||
|
||||
export type MessageComponent = {
|
||||
type: number;
|
||||
style?: number;
|
||||
label?: string;
|
||||
url?: string;
|
||||
components?: MessageComponent[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type DiscordEmbed = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
color?: number;
|
||||
author?: { name?: string; icon_url?: string; [key: string]: unknown };
|
||||
footer?: { text?: string; icon_url?: string; [key: string]: unknown };
|
||||
image?: { url?: string; [key: string]: unknown };
|
||||
thumbnail?: { url?: string; [key: string]: unknown };
|
||||
fields?: { name: string; value: string; inline?: boolean }[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type MessagePayload = {
|
||||
content?: string;
|
||||
tts?: boolean;
|
||||
username?: string;
|
||||
avatar_url?: string;
|
||||
embeds?: DiscordEmbed[];
|
||||
attachments?: { id: number; filename: string }[];
|
||||
components?: MessageComponent[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function embedFields(embed: DiscordEmbed) {
|
||||
return {
|
||||
embed_title: embed.title || "",
|
||||
embed_description: embed.description || "",
|
||||
embed_color: embed.color ?? DEFAULT_EMBED_COLOR,
|
||||
embed_author: embed.author?.name || "",
|
||||
embed_author_url: embed.author?.icon_url || null,
|
||||
embed_footer: embed.footer?.text || "",
|
||||
embed_footer_url: embed.footer?.icon_url || null,
|
||||
embed_image_url: embed.image?.url || null,
|
||||
embed_thumbnail_url: embed.thumbnail?.url || null,
|
||||
embed_fields: (embed.fields || []).map(({ name, value, inline }) => ({
|
||||
title: name,
|
||||
value,
|
||||
inline: inline ?? false,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function toEmbed(reminder: Reminder, original: DiscordEmbed = {}): DiscordEmbed {
|
||||
const embed: DiscordEmbed = { ...original, color: reminder.embed_color };
|
||||
for (const [key, value] of [
|
||||
["title", reminder.embed_title],
|
||||
["description", reminder.embed_description],
|
||||
]) {
|
||||
if (value) embed[key] = value;
|
||||
else delete embed[key];
|
||||
}
|
||||
for (const [key, textKey, text, icon] of [
|
||||
["author", "name", reminder.embed_author, reminder.embed_author_url],
|
||||
["footer", "text", reminder.embed_footer, reminder.embed_footer_url],
|
||||
]) {
|
||||
const part = { ...(original[key] as object) };
|
||||
if (text) part[textKey] = text;
|
||||
else delete part[textKey];
|
||||
if (icon) part["icon_url"] = icon;
|
||||
else delete part["icon_url"];
|
||||
if (Object.keys(part).length) embed[key] = part;
|
||||
else delete embed[key];
|
||||
}
|
||||
for (const [key, url] of [
|
||||
["image", reminder.embed_image_url],
|
||||
["thumbnail", reminder.embed_thumbnail_url],
|
||||
]) {
|
||||
if (url) embed[key] = { ...(original[key] as object), url };
|
||||
else delete embed[key];
|
||||
}
|
||||
const fields = (reminder.embed_fields || []).map(({ title, value, inline }) => ({
|
||||
name: title,
|
||||
value,
|
||||
inline,
|
||||
}));
|
||||
if (fields.length) embed.fields = fields;
|
||||
else delete embed.fields;
|
||||
return embed;
|
||||
}
|
||||
|
||||
export function hasEmbedContent(embed: DiscordEmbed): boolean {
|
||||
return (
|
||||
[
|
||||
embed.title,
|
||||
embed.description,
|
||||
embed.author?.name,
|
||||
embed.footer?.text,
|
||||
embed.footer?.icon_url,
|
||||
embed.image?.url,
|
||||
embed.thumbnail?.url,
|
||||
].some((value) => !!value?.trim()) ||
|
||||
!!embed.fields?.length ||
|
||||
Object.keys(embed).some(
|
||||
(key) =>
|
||||
![
|
||||
"title",
|
||||
"description",
|
||||
"author",
|
||||
"footer",
|
||||
"image",
|
||||
"thumbnail",
|
||||
"color",
|
||||
"fields",
|
||||
].includes(key),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function reminderEmbeds(reminder: Reminder): DiscordEmbed[] {
|
||||
const embeds = reminder.message_json?.embeds;
|
||||
if (embeds) return embeds.length ? embeds : [{ color: DEFAULT_EMBED_COLOR }];
|
||||
return [toEmbed(reminder)];
|
||||
}
|
||||
|
||||
export function updateEmbed(
|
||||
reminder: Reminder,
|
||||
index: number,
|
||||
update: (r: Reminder) => Reminder,
|
||||
): Reminder {
|
||||
const embeds = [...reminderEmbeds(reminder)];
|
||||
const original = embeds[index];
|
||||
if (!original) return reminder;
|
||||
const changed = update({ ...reminder, ...embedFields(original) });
|
||||
embeds[index] = toEmbed(changed, original);
|
||||
return {
|
||||
...reminder,
|
||||
...embedFields(embeds[0]),
|
||||
message_json: { ...reminder.message_json, embeds },
|
||||
};
|
||||
}
|
||||
|
||||
export function addEmbed(reminder: Reminder): Reminder {
|
||||
const embeds = reminderEmbeds(reminder);
|
||||
if (embeds.length >= MAX_EMBEDS) return reminder;
|
||||
return {
|
||||
...reminder,
|
||||
message_json: {
|
||||
...reminder.message_json,
|
||||
embeds: [...embeds, { color: DEFAULT_EMBED_COLOR }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function removeEmbed(reminder: Reminder, index: number): Reminder {
|
||||
const embeds = reminderEmbeds(reminder).filter((_, i) => i !== index);
|
||||
return {
|
||||
...reminder,
|
||||
...embedFields(embeds[0] || {}),
|
||||
message_json: { ...reminder.message_json, embeds },
|
||||
};
|
||||
}
|
||||
|
||||
export function embedTextLength(embeds: DiscordEmbed[]): number {
|
||||
return embeds.reduce(
|
||||
(sum, embed) =>
|
||||
sum +
|
||||
[
|
||||
embed.title,
|
||||
embed.description,
|
||||
embed.author?.name,
|
||||
embed.footer?.text,
|
||||
...(embed.fields || []).flatMap((field) => [field.name, field.value]),
|
||||
].reduce((sum, value) => sum + Array.from((value || "").trim()).length, 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export function linkButtons(reminder: Reminder) {
|
||||
return (reminder.message_json?.components || []).flatMap((row, rowIndex) =>
|
||||
row.type === 1
|
||||
? (row.components || []).flatMap((button, buttonIndex) =>
|
||||
button.type === 2 && button.style === 5
|
||||
? [{ button, rowIndex, buttonIndex }]
|
||||
: [],
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function availableButtonRow(components: MessageComponent[]): number {
|
||||
return components.findIndex(
|
||||
(row) =>
|
||||
row.type === 1 &&
|
||||
!!row.components &&
|
||||
row.components.length < MAX_BUTTONS_PER_ROW &&
|
||||
row.components.every((button) => button.type === 2),
|
||||
);
|
||||
}
|
||||
|
||||
export function canAddLinkButton(reminder: Reminder): boolean {
|
||||
const components = reminder.message_json?.components || [];
|
||||
return availableButtonRow(components) >= 0 || components.length < MAX_BUTTON_ROWS;
|
||||
}
|
||||
|
||||
export function addLinkButton(reminder: Reminder): Reminder {
|
||||
if (!canAddLinkButton(reminder)) return reminder;
|
||||
const components = [...(reminder.message_json?.components || [])];
|
||||
const rowIndex = availableButtonRow(components);
|
||||
const button: MessageComponent = { type: 2, style: 5, label: "Open link", url: "" };
|
||||
if (rowIndex >= 0) {
|
||||
const row = components[rowIndex];
|
||||
components[rowIndex] = { ...row, components: [...row.components, button] };
|
||||
} else components.push({ type: 1, components: [button] });
|
||||
return { ...reminder, message_json: { ...reminder.message_json, components } };
|
||||
}
|
||||
|
||||
export function updateLinkButton(
|
||||
reminder: Reminder,
|
||||
rowIndex: number,
|
||||
buttonIndex: number,
|
||||
change: { label?: string; url?: string } | null,
|
||||
): Reminder {
|
||||
const components = [...(reminder.message_json?.components || [])];
|
||||
const row = components[rowIndex];
|
||||
const button = row?.components?.[buttonIndex];
|
||||
if (row?.type !== 1 || button?.type !== 2 || button.style !== 5) return reminder;
|
||||
const buttons = [...row.components];
|
||||
if (change) buttons[buttonIndex] = { ...button, ...change };
|
||||
else buttons.splice(buttonIndex, 1);
|
||||
if (buttons.length) components[rowIndex] = { ...row, components: buttons };
|
||||
else components.splice(rowIndex, 1);
|
||||
return { ...reminder, message_json: { ...reminder.message_json, components } };
|
||||
}
|
||||
|
||||
function validateComponents(components: MessageComponent[] = []) {
|
||||
if (components.length > MAX_BUTTON_ROWS)
|
||||
throw new Error(`A reminder can have at most ${MAX_BUTTON_ROWS} button rows.`);
|
||||
for (const row of components) {
|
||||
if (row.type !== 1) continue;
|
||||
if (
|
||||
!Array.isArray(row.components) ||
|
||||
!row.components.length ||
|
||||
row.components.length > MAX_BUTTONS_PER_ROW
|
||||
)
|
||||
throw new Error(
|
||||
`A button row must have between 1 and ${MAX_BUTTONS_PER_ROW} components.`,
|
||||
);
|
||||
for (const button of row.components) {
|
||||
if (button.type !== 2 || button.style !== 5) continue;
|
||||
if (
|
||||
(button.label !== undefined && typeof button.label !== "string") ||
|
||||
Array.from(button.label || "").length > 80 ||
|
||||
(!button.label && (!button.emoji || typeof button.emoji !== "object"))
|
||||
)
|
||||
throw new Error(
|
||||
"Link buttons require a label of at most 80 characters or an emoji.",
|
||||
);
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(button.url);
|
||||
} catch {
|
||||
throw new Error("Link buttons require a valid HTTP or HTTPS URL.");
|
||||
}
|
||||
if (!["http:", "https:"].includes(url.protocol) || Array.from(button.url).length > 512)
|
||||
throw new Error(
|
||||
"Link buttons require an HTTP or HTTPS URL of at most 512 characters.",
|
||||
);
|
||||
if (button.custom_id !== undefined || button.sku_id !== undefined)
|
||||
throw new Error("Link buttons cannot have a custom_id or sku_id.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function prepareReminder(reminder: Reminder): Reminder {
|
||||
validateComponents(reminder.message_json?.components);
|
||||
const embeds = reminderEmbeds(reminder).filter(hasEmbedContent);
|
||||
if (embeds.length > MAX_EMBEDS)
|
||||
throw new Error(`A reminder can have at most ${MAX_EMBEDS} embeds.`);
|
||||
if (embedTextLength(embeds) > MAX_EMBED_TEXT)
|
||||
throw new Error(`Embed text exceeds ${MAX_EMBED_TEXT} characters across all embeds.`);
|
||||
const message: MessagePayload = {
|
||||
...reminder.message_json,
|
||||
content: reminder.content,
|
||||
tts: reminder.tts,
|
||||
embeds,
|
||||
};
|
||||
if (reminder.username) message.username = reminder.username;
|
||||
else delete message.username;
|
||||
if (reminder.avatar) message.avatar_url = reminder.avatar;
|
||||
else delete message.avatar_url;
|
||||
if (reminder.attachment_name)
|
||||
message.attachments = [{ id: 0, filename: reminder.attachment_name }];
|
||||
else delete message.attachments;
|
||||
return { ...reminder, ...embedFields(embeds[0] || {}), message_json: message };
|
||||
}
|
||||
@@ -34,3 +34,55 @@ div.reminderContent {
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reminder-embed-editor {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.discord-embed {
|
||||
padding-bottom: 36px;
|
||||
}
|
||||
|
||||
.remove-embed {
|
||||
position: absolute;
|
||||
right: 24px;
|
||||
bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.add-embed-row,
|
||||
.add-link-button-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
|
||||
.embed-limit,
|
||||
.link-button-limit {
|
||||
font-size: 0.8rem;
|
||||
color: #737373;
|
||||
}
|
||||
}
|
||||
|
||||
.reminder-link-buttons {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.link-button-editor {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.link-button-label {
|
||||
flex: 1 1 140px;
|
||||
}
|
||||
.link-button-url {
|
||||
flex: 2 1 200px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
import ts from "typescript";
|
||||
|
||||
// Run the pure editor helpers without a browser or additional test dependencies.
|
||||
const source = await readFile(
|
||||
new URL("../src/components/Reminder/message.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020 },
|
||||
});
|
||||
const {
|
||||
addEmbed,
|
||||
embedFields,
|
||||
prepareReminder,
|
||||
reminderEmbeds,
|
||||
removeEmbed,
|
||||
updateEmbed,
|
||||
addLinkButton,
|
||||
canAddLinkButton,
|
||||
linkButtons,
|
||||
updateLinkButton,
|
||||
} = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||
|
||||
function reminder(embeds) {
|
||||
return {
|
||||
content: "hello",
|
||||
tts: false,
|
||||
username: "",
|
||||
avatar: null,
|
||||
attachment_name: null,
|
||||
uid: "unchanged",
|
||||
utc_time: "2026-10-12T12:00:00",
|
||||
...embedFields(embeds?.[0] || {}),
|
||||
message_json: embeds ? { embeds, allowed_mentions: { parse: [] } } : null,
|
||||
};
|
||||
}
|
||||
|
||||
test("editing one embed preserves the others and unedited payload properties", () => {
|
||||
const original = reminder([
|
||||
{ title: "first" },
|
||||
{ description: "second", author: { name: "author", url: "https://example.com" } },
|
||||
]);
|
||||
const edited = updateEmbed(original, 1, (r) => ({ ...r, embed_description: "updated" }));
|
||||
const payload = prepareReminder(edited).message_json;
|
||||
assert.equal(payload.embeds[0].title, "first");
|
||||
assert.equal(payload.embeds[1].description, "updated");
|
||||
assert.equal(payload.embeds[1].author.url, "https://example.com");
|
||||
assert.deepEqual(payload.allowed_mentions, { parse: [] });
|
||||
assert.equal(edited.uid, original.uid);
|
||||
assert.equal(original.message_json.embeds[1].description, "second");
|
||||
});
|
||||
|
||||
test("adding stops at ten embeds and removal permits another", () => {
|
||||
let value = reminder([{ title: "first" }]);
|
||||
for (let i = 0; i < 12; i++) value = addEmbed(value);
|
||||
assert.equal(reminderEmbeds(value).length, 10);
|
||||
assert.equal(addEmbed(value), value);
|
||||
value = removeEmbed(value, 0);
|
||||
assert.equal(reminderEmbeds(value).length, 9);
|
||||
value = updateEmbed(value, 0, (r) => ({ ...r, embed_title: "new first" }));
|
||||
assert.equal(prepareReminder(value).message_json.embeds[0].title, "new first");
|
||||
assert.equal(reminderEmbeds(addEmbed(value)).length, 10);
|
||||
});
|
||||
|
||||
test("saving and reloading multiple embeds preserves order and drops blank editors", () => {
|
||||
let value = addEmbed(reminder([{ description: "first" }]));
|
||||
value = updateEmbed(value, 1, (r) => ({ ...r, embed_description: "second" }));
|
||||
value = addEmbed(value);
|
||||
const saved = prepareReminder(value);
|
||||
assert.deepEqual(
|
||||
saved.message_json.embeds.map((embed) => embed.description),
|
||||
["first", "second"],
|
||||
);
|
||||
assert.deepEqual(reminderEmbeds(JSON.parse(JSON.stringify(saved))), saved.message_json.embeds);
|
||||
assert.deepEqual(prepareReminder(reminder()).message_json.embeds, []);
|
||||
});
|
||||
|
||||
test("legacy embeds are retained when a second embed is added", () => {
|
||||
const legacy = {
|
||||
...reminder(),
|
||||
embed_title: "legacy",
|
||||
embed_fields: [{ title: "name", value: "value", inline: true }],
|
||||
};
|
||||
const saved = prepareReminder(addEmbed(legacy));
|
||||
assert.equal(saved.message_json.embeds[0].title, "legacy");
|
||||
assert.deepEqual(saved.message_json.embeds[0].fields, [
|
||||
{ name: "name", value: "value", inline: true },
|
||||
]);
|
||||
});
|
||||
|
||||
test("combined embed text accepts 6000 characters and rejects 6001", () => {
|
||||
const embeds = [{ description: "x".repeat(3000) }, { description: "y".repeat(3000) }];
|
||||
assert.equal(prepareReminder(reminder(embeds)).message_json.embeds.length, 2);
|
||||
assert.throws(
|
||||
() => prepareReminder(reminder([embeds[0], { description: "y".repeat(3001) }])),
|
||||
/6000/,
|
||||
);
|
||||
assert.throws(
|
||||
() => prepareReminder(reminder(Array.from({ length: 11 }, () => ({ title: "embed" })))),
|
||||
/10 embeds/,
|
||||
);
|
||||
});
|
||||
|
||||
test("link buttons can be added, edited, saved, reloaded and removed without losing embeds", () => {
|
||||
const original = reminder([{ title: "first" }, { title: "second" }]);
|
||||
let value = addLinkButton(original);
|
||||
value = updateLinkButton(value, 0, 0, { label: "Visit", url: "https://example.com" });
|
||||
const saved = prepareReminder(value);
|
||||
assert.deepEqual(saved.message_json.components, [
|
||||
{
|
||||
type: 1,
|
||||
components: [{ type: 2, style: 5, label: "Visit", url: "https://example.com" }],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(saved.message_json.embeds, original.message_json.embeds);
|
||||
assert.deepEqual(saved.message_json.allowed_mentions, { parse: [] });
|
||||
assert.equal(
|
||||
linkButtons(JSON.parse(JSON.stringify(saved)))[0].button.url,
|
||||
"https://example.com",
|
||||
);
|
||||
assert.deepEqual(
|
||||
prepareReminder(updateLinkButton(saved, 0, 0, null)).message_json.components,
|
||||
[],
|
||||
);
|
||||
assert.equal(original.message_json.components, undefined);
|
||||
const legacy = updateLinkButton(addLinkButton({ ...reminder(), embed_title: "legacy" }), 0, 0, {
|
||||
url: "https://example.com",
|
||||
});
|
||||
assert.equal(prepareReminder(legacy).message_json.embeds[0].title, "legacy");
|
||||
});
|
||||
|
||||
test("link buttons fill five rows of five and preserve other components", () => {
|
||||
let value = reminder();
|
||||
for (let i = 0; i < 26; i++) value = addLinkButton(value);
|
||||
assert.equal(linkButtons(value).length, 25);
|
||||
assert.deepEqual(
|
||||
value.message_json.components.map((row) => row.components.length),
|
||||
[5, 5, 5, 5, 5],
|
||||
);
|
||||
assert.equal(canAddLinkButton(value), false);
|
||||
assert.equal(addLinkButton(value), value);
|
||||
value = updateLinkButton(value, 2, 2, null);
|
||||
assert.equal(canAddLinkButton(value), true);
|
||||
assert.equal(linkButtons(addLinkButton(value)).length, 25);
|
||||
const other = { type: 1, id: 7, components: [{ type: 3, custom_id: "select", options: [] }] };
|
||||
value = { ...reminder(), message_json: { components: [other] } };
|
||||
value = addLinkButton(value);
|
||||
assert.deepEqual(value.message_json.components[0], other);
|
||||
assert.equal(linkButtons(value)[0].rowIndex, 1);
|
||||
});
|
||||
|
||||
test("invalid link button URLs, labels and row limits are rejected on save", () => {
|
||||
let value = addLinkButton(reminder());
|
||||
assert.throws(() => prepareReminder(value), /URL/);
|
||||
value = updateLinkButton(value, 0, 0, {
|
||||
url: "https://example.com/" + "x".repeat(492),
|
||||
label: "x".repeat(80),
|
||||
});
|
||||
assert.equal(prepareReminder(value).message_json.components.length, 1);
|
||||
for (const change of [
|
||||
{ url: "invalid" },
|
||||
{ url: "javascript:alert(1)" },
|
||||
{ url: "https://example.com/" + "x".repeat(493) },
|
||||
{ label: "" },
|
||||
{ label: "x".repeat(81) },
|
||||
])
|
||||
assert.throws(() => prepareReminder(updateLinkButton(value, 0, 0, change)));
|
||||
const row = value.message_json.components[0];
|
||||
assert.throws(
|
||||
() => prepareReminder({ ...value, message_json: { components: Array(6).fill(row) } }),
|
||||
/5 button rows/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
prepareReminder({
|
||||
...value,
|
||||
message_json: {
|
||||
components: [{ ...row, components: Array(6).fill(row.components[0]) }],
|
||||
},
|
||||
}),
|
||||
/5 components/,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user