diff --git a/src/Scripts/hub/browse.ts b/src/Scripts/hub/browse.ts index 126e76c1..0f4b5351 100644 --- a/src/Scripts/hub/browse.ts +++ b/src/Scripts/hub/browse.ts @@ -1,33 +1,48 @@ -import { ActionRowBuilder, ButtonBuilder, ButtonInteraction, ButtonStyle, ChatInputCommandInteraction, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + ChannelSelectMenuBuilder, + ChannelType, + ChatInputCommandInteraction, + EmbedBuilder, + GuildTextBasedChannel, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from 'discord.js'; import { calculateAverageRating, createHubListingsEmbed, getDb } from '../../Utils/functions/utils'; import { paginate } from '../../Utils/functions/paginator'; import { hubs } from '@prisma/client'; import logger from '../../Utils/logger'; import { captureException } from '@sentry/node'; +import createConnection from '../network/createConnection'; export async function execute(interaction: ChatInputCommandInteraction) { - const sortBy = interaction.options.getString('sort') as 'connections' | 'active' | 'popular' | 'recent' | undefined; + const sortBy = interaction.options.getString('sort') as + | 'connections' + | 'active' + | 'popular' + | 'recent' + | undefined; const hubName = interaction.options.getString('search') || undefined; const db = getDb(); let sortedHubs: hubs[] = []; - switch (sortBy) { - case 'active': - sortedHubs = await db.hubs.findMany({ - where: { name: hubName, private: false }, - orderBy: { messages: { _count: 'desc' } }, - }); - break; case 'popular': - sortedHubs = (await db.hubs - .findMany({ where: { name: hubName, private: false } })) - .sort((a, b) => { - const aAverage = calculateAverageRating(a.rating.map((rating) => rating.rating)); - const bAverage = calculateAverageRating(b.rating.map((rating) => rating.rating)); - return bAverage - aAverage; - }); + sortedHubs = ( + await db.hubs.findMany({ + where: { name: hubName, private: false }, + include: { connections: true }, + }) + ).sort((a, b) => { + const aAverage = calculateAverageRating(a.rating.map((rating) => rating.rating)); + const bAverage = calculateAverageRating(b.rating.map((rating) => rating.rating)); + return bAverage - aAverage; + }); break; case 'recent': sortedHubs = await db.hubs.findMany({ @@ -41,19 +56,25 @@ export async function execute(interaction: ChatInputCommandInteraction) { orderBy: { connections: { _count: 'desc' } }, }); break; + + case 'active': default: - sortedHubs = await db.hubs.findMany({ where: { name: hubName, private: false } }); + sortedHubs = await db.hubs.findMany({ + where: { name: hubName, private: false }, + orderBy: { messages: { _count: 'desc' } }, + }); break; } + const hubList = await Promise.all( + sortedHubs?.map(async (hub) => { + const totalNetworks = await db.connectedList + .count({ where: { hubId: hub.id } }) + .catch(() => 0); - const hubList = sortedHubs?.map(async (hub) => { - const totalNetworks = await db.connectedList - .count({ where: { hubId: hub.id } }) - .catch(() => 0); - - return createHubListingsEmbed(hub, { totalNetworks }); - }); + return createHubListingsEmbed(hub, { totalNetworks }); + }), + ); if (!hubList || hubList.length === 0) { interaction.reply({ @@ -68,14 +89,18 @@ export async function execute(interaction: ChatInputCommandInteraction) { .setCustomId(`rate-${sortedHubs[0].id}`) .setLabel('Rate') .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`join-${sortedHubs[0].id}`) + .setLabel('Join') + .setStyle(ButtonStyle.Success), ); - - paginate(interaction, await Promise.all(hubList), { + paginate(interaction, hubList, { extraComponent: { actionRow: [paginateBtns], updateComponents(pageNumber) { paginateBtns.components[0].setCustomId(`rate-${sortedHubs[pageNumber].id}`); + paginateBtns.components[1].setCustomId(`join-${sortedHubs[pageNumber].id}`); return paginateBtns; }, async execute(i: ButtonInteraction) { @@ -97,7 +122,7 @@ export async function execute(interaction: ChatInputCommandInteraction) { ); await i.showModal(ratingModal); i.awaitModalSubmit({ time: 30_000 }) - .then(async m => { + .then(async (m) => { const rating = parseInt(m.fields.getTextInputValue('rating')); if (isNaN(rating) || rating < 1 || rating > 5) { return m.reply({ @@ -142,6 +167,117 @@ export async function execute(interaction: ChatInputCommandInteraction) { } }); } + else if (i.customId.startsWith('join-')) { + const hubDetails = await db.hubs.findFirst({ + where: { id: i.customId.replace('join-', '') }, + include: { connections: true }, + }); + + if (!hubDetails) { + i.reply({ + content: 'Hub not found.', + ephemeral: true, + }); + return; + } + + const alreadyJoined = hubDetails.connections.find((c) => c.serverId === i.guildId); + if (alreadyJoined) { + i.reply({ + content: `You have already joined **${hubDetails.name}** from <#${alreadyJoined.channelId}>!`, + ephemeral: true, + }); + return; + } + + let channel = i.channel; + + const channelSelect = new ActionRowBuilder().addComponents( + new ChannelSelectMenuBuilder() + .setCustomId('channel_select') + .setPlaceholder('Select a different channel.') + .setChannelTypes([ + ChannelType.PublicThread, + ChannelType.PrivateThread, + ChannelType.GuildText, + ]), + ); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId('confirm') + .setLabel('Confirm') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId('cancel') + .setLabel('Cancel') + .setStyle(ButtonStyle.Danger), + ); + + // use current channel embed + const embed = new EmbedBuilder() + .setDescription(` + Are you sure you wish to join **${hubDetails.name}** from ${interaction.channel}? + + **Note:** You can always change this later using \`/network manage\`. + `, + ) + .setColor('Aqua') + .setFooter({ text: 'Use a different channel? Use the dropdown below.' }); + + const reply = await i.reply({ + embeds: [embed], + components: [channelSelect, buttons], + fetchReply: true, + ephemeral: true, + }); + + const response = await reply + .awaitMessageComponent({ + time: 60_000 * 2, + filter: (e) => e.user.id === i.user.id, + }) + .catch(() => null); + + if (!response?.inCachedGuild() || response.customId === 'cancel') { + i.deleteReply().catch(() => null); + return; + } + + if (response.isChannelSelectMenu()) { + channel = response.guild.channels.cache.get(response.values[0]) as GuildTextBasedChannel; + } + + if ( + (channel?.type === ChannelType.GuildText || channel?.isThread()) && + (response.customId === 'confirm' || response.customId === 'channel_select') + ) { + const channelConnected = await db.connectedList.findFirst({ + where: { channelId: channel.id }, + }); + + if (channelConnected) { + response.update({ + content: 'This channel is already connected to another hub!', + embeds: [], + components: [], + }); + return; + } + + createConnection.execute(response, hubDetails, channel, true).then((success) => { + if (success) { + response.editReply({ + content: `Successfully joined hub ${hubDetails.name} from ${channel}! Use \`/network manage\` to manage your connection. And \`/hub leave\` to leave the hub.`, + embeds: [], + components: [], + }); + return; + } + response.message.delete().catch(() => null); + }); + } + } }, }, }); diff --git a/src/Scripts/hub/networks.ts b/src/Scripts/hub/connections.ts similarity index 100% rename from src/Scripts/hub/networks.ts rename to src/Scripts/hub/connections.ts diff --git a/src/Scripts/network/createConnection.ts b/src/Scripts/network/createConnection.ts index 8c53c5c3..7b8a92b2 100644 --- a/src/Scripts/network/createConnection.ts +++ b/src/Scripts/network/createConnection.ts @@ -1,35 +1,22 @@ import { stripIndents } from 'common-tags'; -import { ChannelType, ChatInputCommandInteraction, Collection, TextChannel, ThreadChannel } from 'discord.js'; +import { ChannelType, AnySelectMenuInteraction, ChatInputCommandInteraction, TextChannel, ThreadChannel, ButtonInteraction } from 'discord.js'; import { disconnect } from '../../Structures/network'; import { hubs } from '@prisma/client'; import logger from '../../Utils/logger'; import onboarding from './onboarding'; import { getDb } from '../../Utils/functions/utils'; -const onboardingInProgress = new Collection(); - export default { - async execute(interaction: ChatInputCommandInteraction, hub: hubs, networkChannel: TextChannel | ThreadChannel) { + async execute( + interaction: AnySelectMenuInteraction | ButtonInteraction | ChatInputCommandInteraction, + hub: hubs, + networkChannel: TextChannel | ThreadChannel, + ephemeral = false, + ) { const emoji = interaction.client.emotes.normal; - // Check if server is already attempting to join a hub - if (onboardingInProgress.has(networkChannel.id)) { - const err = { - content: `${emoji.no} There has already been an attempt to join a hub in ${networkChannel}. Please wait for that to finish before trying again!`, - ephemeral: true, - }; - interaction.deferred || interaction.replied - ? interaction.followUp(err) - : interaction.reply(err); - return; - } - // Mark this as in-progress so server can't join twice - onboardingInProgress.set(networkChannel.id, networkChannel.id); - // Show new users rules & info about network - const onboardingStatus = await onboarding.execute(interaction, hub.name); - // remove in-progress marker as onboarding has either been cancelled or completed - onboardingInProgress.delete(networkChannel.id); + const onboardingStatus = await onboarding.execute(interaction, hub.name, networkChannel.id, ephemeral); // if user cancelled onboarding or didn't click any buttons, stop here if (!onboardingStatus) return; @@ -38,7 +25,7 @@ export default { let webhook; if (networkChannel.isThread() && networkChannel.parent) { const webhooks = await networkChannel.parent.fetchWebhooks(); - const webhookCreated = webhooks.find(w => w.owner?.id === interaction.client.user?.id); + const webhookCreated = webhooks.find((w) => w.owner?.id === interaction.client.user?.id); if (webhookCreated) { webhook = webhookCreated; @@ -57,13 +44,14 @@ export default { }); } else { - return interaction.followUp('This channel is not supported for InterChat. Please use a text channel or a thread.'); + return interaction.followUp( + 'This channel is not supported for InterChat. Please use a text channel or a thread.', + ); } - const { connectedList } = getDb(); createdConnection = await connectedList.create({ - data:{ + data: { channelId: networkChannel.id, parentId: networkChannel.isThread() ? networkChannel.id : undefined, serverId: networkChannel.guild.id, @@ -76,14 +64,18 @@ export default { }); const numOfConnections = await connectedList.count({ where: { hub: { id: hub.id } } }); - await networkChannel?.send(`This channel has been connected with **${hub.name}**. ${ - numOfConnections > 1 - ? `You are currently with ${numOfConnections - 1} other servers, Enjoy! ${emoji.clipart}` - : `It seems no one else is there currently... *cricket noises* ${emoji.clipart}` - }`); + await networkChannel?.send( + `This channel has been connected with **${hub.name}**. ${ + numOfConnections > 1 + ? `You are currently with ${numOfConnections - 1} other servers, Enjoy! ${ + emoji.clipart + }` + : `It seems no one else is there currently... *cricket noises* ${emoji.clipart}` + }`, + ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any catch (err: any) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any logger.error(err); if (err.message === 'Missing Permissions' || err.message === 'Missing Access') { const errMsg = `${emoji.no} Please make sure you have granted me \`Manage Webhooks\` and \`View Channel\` permissions for the selected channel.`; @@ -97,17 +89,19 @@ export default { ? interaction.followUp(errMsg) : interaction.reply(errMsg); } - onboardingInProgress.delete(networkChannel.id); disconnect(networkChannel.id); return; } - interaction.client.sendInNetwork(stripIndents` + interaction.client.sendInNetwork( + stripIndents` A new server has joined us! ${emoji.clipart} **Server Name:** __${interaction.guild?.name}__ **Member Count:** __${interaction.guild?.memberCount}__ - `, { id: hub.id }); + `, + { id: hub.id }, + ); // return the created connection so we can use it in the next step return createdConnection; diff --git a/src/Scripts/network/onboarding.ts b/src/Scripts/network/onboarding.ts index 5e713661..bcda0814 100644 --- a/src/Scripts/network/onboarding.ts +++ b/src/Scripts/network/onboarding.ts @@ -1,13 +1,35 @@ import { stripIndents } from 'common-tags'; -import { ActionRowBuilder, ButtonStyle, ChatInputCommandInteraction, EmbedBuilder, ButtonBuilder, ComponentType, ButtonInteraction } from 'discord.js'; +import { ActionRowBuilder, ButtonStyle, ChatInputCommandInteraction, EmbedBuilder, ButtonBuilder, ComponentType, ButtonInteraction, AnySelectMenuInteraction, Collection } from 'discord.js'; import { colors, rulesEmbed } from '../../Utils/functions/utils'; +const onboardingInProgress = new Collection(); + /* Make user accept and understand important info on first setup */ export default { - async execute(interaction: ChatInputCommandInteraction, hubName: string) { + async execute( + interaction: ChatInputCommandInteraction | AnySelectMenuInteraction | ButtonInteraction, + hubName: string, + channelId: string, + ephemeral = false, + ) { + // Check if server is already attempting to join a hub + if (onboardingInProgress.has(channelId)) { + const err = { + content: `${interaction.client.emotes.normal.no} There has already been an attempt to join a hub in <#${channelId}>. Please wait for that to finish before trying again!`, + ephemeral, + }; + interaction.deferred || interaction.replied + ? interaction.followUp(err) + : interaction.reply(err); + return; + } + // Mark this as in-progress so server can't join twice + onboardingInProgress.set(channelId, channelId); + const embed = new EmbedBuilder() .setTitle(`👋 Hey there! Welcome to ${hubName}!`) - .setDescription(stripIndents` + .setDescription( + stripIndents` To keep things organized, it's recommended to use a separate channel for just for this hub. But don't worry, you can always change this later. Before we dive in, take a moment to review our rules. We want everyone to have a smooth and fun experience. @@ -15,49 +37,44 @@ export default { **How it works:** the InterChat Network is like a magic bridge that links channels on different servers that are with us in this hub. So, you can chat with people from all over! Developer Note: And hey, if you have any cool ideas for new features, let us know! We're always looking to improve. - `) + `, + ) .setColor(colors('chatbot')) .setFooter({ text: `InterChat Network | Version ${interaction.client.version}` }); const nextButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('cancel') - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId('next') - .setLabel('Next') - .setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId('next').setLabel('Next').setStyle(ButtonStyle.Success), ); const replyMsg = { embeds: [embed], components: [nextButton], + fetchReply: true, + ephemeral, }; - const reply = await (interaction.deferred ? interaction.editReply(replyMsg) : interaction.reply(replyMsg)); + + const reply = await (interaction.deferred + ? interaction.editReply(replyMsg) + : interaction.reply(replyMsg)); const filter = (i: ButtonInteraction) => i.user.id === interaction.user.id; - const response = await reply.awaitMessageComponent({ - time: 60_000 * 2, - filter, - componentType: ComponentType.Button, - }).catch(() => null); + const response = await reply + .awaitMessageComponent({ + time: 60_000 * 2, + filter, + componentType: ComponentType.Button, + }) + .catch(() => null); if (!response || response?.customId === 'cancel') { await interaction.deleteReply(); } - else if (response.customId === 'next') { const acceptButton = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId('cancel') - .setLabel('Cancel') - .setStyle(ButtonStyle.Danger), - new ButtonBuilder() - .setCustomId('accept') - .setLabel('Accept') - .setStyle(ButtonStyle.Success), + new ButtonBuilder().setCustomId('cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger), + new ButtonBuilder().setCustomId('accept').setLabel('Accept').setStyle(ButtonStyle.Success), ); const acceptOnboarding = await response.update({ @@ -65,16 +82,24 @@ export default { components: [acceptButton], }); - const acceptResp = await acceptOnboarding.awaitMessageComponent({ - time: 60_000, - filter, - componentType: ComponentType.Button, - }).catch(() => null); + const acceptResp = await acceptOnboarding + .awaitMessageComponent({ + time: 60_000, + filter, + componentType: ComponentType.Button, + }) + .catch(() => null); // To avoid getting interaction failures await acceptResp?.deferUpdate(); + + // remove in-progress marker as onboarding has either been cancelled or completed + onboardingInProgress.delete(channelId); + return acceptResp?.customId === 'accept' ? true : false; } + + onboardingInProgress.delete(channelId); return false; }, }; diff --git a/src/Utils/functions/paginator.ts b/src/Utils/functions/paginator.ts index e40884f2..dd0d62e0 100644 --- a/src/Utils/functions/paginator.ts +++ b/src/Utils/functions/paginator.ts @@ -47,7 +47,6 @@ export async function paginate(interaction: CommandInteraction, pages: EmbedBuil const listMessage = interaction.replied || interaction.deferred ? await interaction.followUp(data) : await interaction.reply(data); const col = listMessage.createMessageComponentCollector({ - filter: i => i.user.id === interaction.user.id, idle: options?.stopAfter || 60000, componentType: ComponentType.Button, });