Development (#11)

We need this NOW.

---------

Co-authored-by: PineaFan <ash@pinea.dev>
Co-authored-by: pineafan <pineapplefanyt@gmail.com>
Co-authored-by: PineappleFan <PineaFan@users.noreply.github.com>
Co-authored-by: Skyler <skyler3665@gmail.com>
diff --git a/src/commands/user/about.ts b/src/commands/user/about.ts
index e43ecb7..0eb8580 100644
--- a/src/commands/user/about.ts
+++ b/src/commands/user/about.ts
@@ -10,7 +10,7 @@
     APISelectMenuOption,
     StringSelectMenuBuilder
 } from "discord.js";
-import type { SlashCommandSubcommandBuilder } from "@discordjs/builders";
+import type { SlashCommandSubcommandBuilder } from "discord.js";
 import EmojiEmbed from "../../utils/generateEmojiEmbed.js";
 import getEmojiByName from "../../utils/getEmojiByName.js";
 import generateKeyValueList from "../../utils/generateKeyValueList.js";
@@ -173,11 +173,8 @@
                         generateKeyValueList({
                             member: renderUser(member.user),
                             id: `\`${member.id}\``,
-                            roles: `${member.roles.cache.size - 1}`  // FIXME
-                        }) +
-                            "\n" +
-                            (s.length > 0 ? s : "*None*") +
-                            "\n"
+                            roles: `${member.roles.cache.size - 1}`
+                        }) + "\n" + (s.length > 0 ? s : "*None*") + "\n"
                     )
             )
             .setTitle("Roles")
@@ -258,13 +255,13 @@
         try {
             i = await m.awaitMessageComponent({
                 time: 300000,
-                filter: (i) => { return i.user.id === interaction.user.id && i.channel!.id === interaction.channel!.id }
+                filter: (i) => { return i.user.id === interaction.user.id && i.channel!.id === interaction.channel!.id && i.message.id === m.id }
             });
         } catch {
             timedOut = true;
             continue;
         }
-        i.deferUpdate();
+        await i.deferUpdate();
         if (i.customId === "left") {
             if (page > 0) page--;
             selectPaneOpen = false;
@@ -286,11 +283,6 @@
     });
 };
 
-const check = () => {
-    return true;
-};
-
 export { command };
 export { callback };
-export { check };
 export { userAbout };
\ No newline at end of file
diff --git a/src/commands/user/avatar.ts b/src/commands/user/avatar.ts
index 88b3270..da33f51 100644
--- a/src/commands/user/avatar.ts
+++ b/src/commands/user/avatar.ts
@@ -1,6 +1,6 @@
 import type { CommandInteraction } from "discord.js";
 import type Discord from "discord.js";
-import type { SlashCommandSubcommandBuilder } from "@discordjs/builders";
+import type { SlashCommandSubcommandBuilder } from "discord.js";
 import EmojiEmbed from "../../utils/generateEmojiEmbed.js";
 import generateKeyValueList from "../../utils/generateKeyValueList.js";
 import client from "../../utils/client.js";
@@ -35,10 +35,6 @@
     });
 };
 
-const check = () => {
-    return true;
-};
 
 export { command };
 export { callback };
-export { check };
diff --git a/src/commands/user/role.ts b/src/commands/user/role.ts
new file mode 100644
index 0000000..41820ac
--- /dev/null
+++ b/src/commands/user/role.ts
@@ -0,0 +1,160 @@
+import { ActionRowBuilder, APIMessageComponentEmoji, ButtonBuilder, ButtonInteraction, ButtonStyle, CommandInteraction, GuildMember, Role, RoleSelectMenuBuilder, RoleSelectMenuInteraction, UserSelectMenuBuilder, UserSelectMenuInteraction } from "discord.js";
+import type { SlashCommandSubcommandBuilder } from "discord.js";
+import client from "../../utils/client.js";
+import EmojiEmbed from "../../utils/generateEmojiEmbed.js";
+import { LoadingEmbed } from "../../utils/defaults.js";
+import getEmojiByName from "../../utils/getEmojiByName.js";
+import listToAndMore from "../../utils/listToAndMore.js"
+
+const { renderUser } = client.logger;
+
+const canEdit = (role: Role, member: GuildMember, me: GuildMember): [string, boolean] => {
+    if(role.position >= me.roles.highest.position ||
+       role.position >= member.roles.highest.position
+    ) return [`~~<@&${role.id}>~~`, false];
+    return [`<@&${role.id}>`, true];
+};
+
+const command = (builder: SlashCommandSubcommandBuilder) =>
+    builder
+        .setName("role")
+        .setDescription("Gives or removes a role from someone")
+        .addUserOption((option) => option.setName("user").setDescription("The user to give or remove the role from"))
+
+const callback = async (interaction: CommandInteraction): Promise<unknown> => {
+    const m = await interaction.reply({ embeds: LoadingEmbed, fetchReply: true, ephemeral: true });
+
+    let member = interaction.options.getMember("user") as GuildMember | null;
+
+    if(!member) {
+        const memberEmbed = new EmojiEmbed()
+            .setTitle("Role")
+            .setDescription(`Please choose a member to edit the roles of.`)
+            .setEmoji("GUILD.ROLES.CREATE")
+            .setStatus("Success");
+        const memberChooser = new ActionRowBuilder<UserSelectMenuBuilder>().addComponents(
+            new UserSelectMenuBuilder()
+                .setCustomId("memberChooser")
+                .setPlaceholder("Select a member")
+        );
+        await interaction.editReply({embeds: [memberEmbed], components: [memberChooser]});
+
+        const filter = (i: UserSelectMenuInteraction) => i.customId === "memberChooser" && i.user.id === interaction.user.id;
+
+        let i: UserSelectMenuInteraction | null;
+        try {
+            i = await m.awaitMessageComponent<5>({ filter, time: 300000});
+        } catch (e) {
+            return;
+        }
+
+        memberEmbed.setDescription(`Editing roles for ${renderUser(i.values[0]!)}`);
+        await i.deferUpdate();
+        await interaction.editReply({ embeds: LoadingEmbed, components: [] })
+        member = await interaction.guild?.members.fetch(i.values[0]!)!;
+
+    }
+
+    let closed = false;
+    let rolesToChange: string[] = [];
+    const roleAdd = new ActionRowBuilder<RoleSelectMenuBuilder>()
+        .addComponents(
+            new RoleSelectMenuBuilder()
+                .setCustomId("roleAdd")
+                .setPlaceholder("Select a role to add")
+                .setMaxValues(25)
+        );
+
+    do {
+
+        const removing = rolesToChange.filter((r) => member!.roles.cache.has(r)).map((r) => canEdit(interaction.guild?.roles.cache.get(r)!, interaction.member as GuildMember, interaction.guild?.members.me!)[0])
+        const adding = rolesToChange.filter((r) => !member!.roles.cache.has(r)).map((r) => canEdit(interaction.guild?.roles.cache.get(r)!, interaction.member as GuildMember, interaction.guild?.members.me!)[0])
+        const embed = new EmojiEmbed()
+        .setTitle("Role")
+        .setDescription(
+            `${getEmojiByName("ICONS.EDIT")} Editing roles for <@${member.id}>\n\n` +
+            `Adding:\n` +
+            `${listToAndMore(adding.length > 0 ? adding : ["None"], 5)}\n` +
+            `Removing:\n` +
+            `${listToAndMore(removing.length > 0 ? removing : ["None"], 5)}\n`
+        )
+        .setEmoji("GUILD.ROLES.CREATE")
+        .setStatus("Success");
+
+        const buttons = new ActionRowBuilder<ButtonBuilder>()
+            .addComponents(
+                new ButtonBuilder()
+                    .setCustomId("roleSave")
+                    .setLabel("Apply")
+                    .setEmoji(getEmojiByName("ICONS.SAVE", "id") as APIMessageComponentEmoji)
+                    .setStyle(ButtonStyle.Success),
+                new ButtonBuilder()
+                    .setCustomId("roleDiscard")
+                    .setLabel("Reset")
+                    .setEmoji(getEmojiByName("CONTROL.CROSS", "id") as APIMessageComponentEmoji)
+                    .setStyle(ButtonStyle.Danger)
+            );
+
+        await interaction.editReply({ embeds: [embed], components: [roleAdd, buttons] });
+
+        let i: RoleSelectMenuInteraction | ButtonInteraction | null;
+        try {
+            i = await m.awaitMessageComponent({ filter: (i) => i.user.id === interaction.user.id, time: 300000 }) as RoleSelectMenuInteraction | ButtonInteraction;
+        } catch (e) {
+            closed = true;
+            continue;
+        }
+
+        i.deferUpdate();
+        if(i.isButton()) {
+            switch(i.customId) {
+                case "roleSave": {
+                    const roles = rolesToChange.map((r) => interaction.guild?.roles.cache.get(r)!);
+                    await interaction.editReply({ embeds: LoadingEmbed, components: [] });
+                    const rolesToAdd: Role[] = [];
+                    const rolesToRemove: Role[] = [];
+                    for(const role of roles) {
+                        if(!canEdit(role, interaction.member as GuildMember, interaction.guild?.members.me!)[1]) continue;
+                        if(member.roles.cache.has(role.id)) {
+                            rolesToRemove.push(role);
+                        } else {
+                            rolesToAdd.push(role);
+                        }
+                    }
+                    await member.roles.add(rolesToAdd);
+                    await member.roles.remove(rolesToRemove);
+                    rolesToChange = [];
+                    break;
+                }
+                case "roleDiscard": {
+                    rolesToChange = [];
+                    await interaction.editReply({ embeds: LoadingEmbed, components: [] });
+                    break;
+                }
+            }
+        } else {
+            rolesToChange = i.values;
+        }
+
+    } while (!closed);
+
+};
+
+const check = (interaction: CommandInteraction, partial: boolean = false) => {
+    const member = interaction.member as GuildMember;
+    // Check if the user has manage_roles permission
+    if (!member.permissions.has("ManageRoles")) return "You do not have the *Manage Roles* permission";
+    if (partial) return true;
+    if (!interaction.guild) return
+    const me = interaction.guild.members.me!;
+    // Check if Nucleus has permission to role
+    if (!me.permissions.has("ManageRoles")) return "I do not have the *Manage Roles* permission";
+    // Allow the owner to role anyone
+    if (member.id === interaction.guild.ownerId) return true;
+    // Allow role
+    return true;
+};
+
+export { command };
+export { callback };
+export { check };
diff --git a/src/commands/user/track.ts b/src/commands/user/track.ts
index 0814cfa..c7f441f 100644
--- a/src/commands/user/track.ts
+++ b/src/commands/user/track.ts
@@ -1,10 +1,11 @@
 import { LoadingEmbed } from "../../utils/defaults.js";
-import Discord, { CommandInteraction, GuildMember, Message, ActionRowBuilder, ButtonBuilder, ButtonStyle, SelectMenuOptionBuilder, APIMessageComponentEmoji, StringSelectMenuBuilder, MessageComponentInteraction, StringSelectMenuInteraction } from "discord.js";
-import type { SlashCommandSubcommandBuilder } from "@discordjs/builders";
+import Discord, { CommandInteraction, GuildMember, Message, ActionRowBuilder, ButtonBuilder, ButtonStyle, APIMessageComponentEmoji, StringSelectMenuBuilder, MessageComponentInteraction, StringSelectMenuInteraction, StringSelectMenuOptionBuilder } from "discord.js";
+import type { SlashCommandSubcommandBuilder } from "discord.js";
 import EmojiEmbed from "../../utils/generateEmojiEmbed.js";
 import getEmojiByName from "../../utils/getEmojiByName.js";
 import addPlural from "../../utils/plurals.js";
 import client from "../../utils/client.js";
+import { createVerticalTrack } from "../../utils/createPageIndicator.js";
 
 const command = (builder: SlashCommandSubcommandBuilder) =>
     builder
@@ -12,17 +13,8 @@
         .setDescription("Moves a user along a role track")
         .addUserOption((option) => option.setName("user").setDescription("The user to manage").setRequired(true));
 
-const generateFromTrack = (position: number, active: string | boolean, size: number, disabled: string | boolean) => {
-    active = active ? "ACTIVE" : "INACTIVE";
-    disabled = disabled ? "GREY." : "";
-    if (position === 0 && size === 1) return "TRACKS.SINGLE." + disabled + active;
-    if (position === size - 1) return "TRACKS.VERTICAL.BOTTOM." + disabled + active;
-    if (position === 0) return "TRACKS.VERTICAL.TOP." + disabled + active;
-    return "TRACKS.VERTICAL.MIDDLE." + disabled + active;
-};
-
 const callback = async (interaction: CommandInteraction): Promise<unknown> => {
-    const { renderUser } = client.logger;
+    const { renderUser, renderRole} = client.logger;
     const member = interaction.options.getMember("user") as GuildMember;
     const guild = interaction.guild;
     if (!guild) return;
@@ -44,10 +36,10 @@
         const dropdown = new Discord.StringSelectMenuBuilder()
             .addOptions(
                 config.tracks.map((option, index) => {
-                    const hasRoleInTrack = option.track.some((element: string) => {
+                    const hasRoleInTrack: boolean = option.track.some((element: string) => {
                         return memberRoles.cache.has(element);
                     });
-                    return new SelectMenuOptionBuilder({
+                    return new StringSelectMenuOptionBuilder({
                         default: index === track,
                         label: option.name,
                         value: index.toString(),
@@ -68,33 +60,23 @@
             (data.retainPrevious
                 ? "When promoted, the user keeps previous roles"
                 : "Members will lose their current role when promoted") + "\n";
-        generated +=
-            "\n" +
-            data.track
-                .map((role, index) => {
-                    const allow: boolean =
-                        roles.get(role)!.position >= (interaction.member as GuildMember).roles.highest.position &&
-                        !managed;
-                    allowed.push(!allow);
-                    return (
-                        getEmojiByName(
-                            generateFromTrack(index, memberRoles.cache.has(role), data.track.length, allow)
-                        ) +
-                        " " +
-                        roles.get(role)!.name +
-                        " [<@&" +
-                        roles.get(role)!.id +
-                        ">]"
-                    );
-                })
-                .join("\n");
+        for (const role of data.track) {
+            const disabled: boolean =
+                roles.get(role)!.position >= (interaction.member as GuildMember).roles.highest.position && !managed;
+            allowed.push(!disabled)
+        }
+        generated += "\n" + createVerticalTrack(
+            data.track.map((role) => renderRole(roles.get(role)!)),
+            data.track.map((role) => memberRoles.cache.has(role)),
+            allowed.map((allow) => !allow)
+        );
         const selected = [];
         for (const position of data.track) {
             if (memberRoles.cache.has(position)) selected.push(position);
         }
         const conflict = data.retainPrevious ? false : selected.length > 1;
         let conflictDropdown: StringSelectMenuBuilder[] = [];
-        const conflictDropdownOptions: SelectMenuOptionBuilder[] = [];
+        const conflictDropdownOptions: StringSelectMenuOptionBuilder[] = [];
         let currentRoleIndex: number = -1;
         if (conflict) {
             generated += `\n\n${getEmojiByName(`PUNISH.WARN.${managed ? "YELLOW" : "RED"}`)} This user has ${
@@ -106,10 +88,9 @@
                     "In order to promote or demote this user, you must select which role the member should keep.";
                 selected.forEach((role) => {
                     conflictDropdownOptions.push(
-                        new SelectMenuOptionBuilder({
-                            label: roles.get(role)!.name,
-                            value: roles.get(role)!.id
-                        })
+                        new StringSelectMenuOptionBuilder()
+                            .setLabel(roles.get(role)!.name)
+                            .setValue(roles.get(role)!.id)
                     );
                 });
                 conflictDropdown = [
@@ -169,7 +150,7 @@
         try {
             component = await m.awaitMessageComponent({
                 time: 300000,
-                filter: (i) => { return i.user.id === interaction.user.id && i.channel!.id === interaction.channel!.id }
+                filter: (i) => { return i.user.id === interaction.user.id && i.channel!.id === interaction.channel!.id && i.message.id === m.id }
             });
         } catch (e) {
             timedOut = true;
@@ -207,9 +188,9 @@
     }
 };
 
-const check = async (interaction: CommandInteraction) => {
+const check = async (interaction: CommandInteraction, _partial: boolean = false) => {
     const tracks = (await client.database.guilds.read(interaction.guild!.id)).tracks;
-    if (tracks.length === 0) throw new Error("This server does not have any tracks");
+    if (tracks.length === 0) return "This server does not have any tracks";
     const member = interaction.member as GuildMember;
     // Allow the owner to promote anyone
     if (member.id === interaction.guild!.ownerId) return true;
@@ -223,8 +204,7 @@
         break;
     }
     // Check if the user has manage_roles permission
-    if (!managed && !member.permissions.has("ManageRoles"))
-        throw new Error("You do not have the *Manage Roles* permission");
+    if (!managed && !member.permissions.has("ManageRoles")) return "You do not have the *Manage Roles* permission";
     // Allow track
     return true;
 };