From b1cc10c6ef3c0c3f9c9d539fe24452fd741cfc72 Mon Sep 17 00:00:00 2001 From: derpystuff <3515180-derpystuff@users.noreply.gitlab.com> Date: Sun, 22 May 2022 12:49:16 +0200 Subject: [PATCH] [core] new swag paginator --- labscore/client.js | 4 +- labscore/paginator.js | 22 -- labscore/paginator/index.js | 7 + .../paginator/structures/BasePaginator.js | 139 ++++++++++++ labscore/paginator/structures/Paginator.js | 205 ++++++++++++++++++ .../paginator/structures/PaginatorCluster.js | 37 ++++ .../paginator/structures/ReactionPaginator.js | 48 ++++ 7 files changed, 438 insertions(+), 24 deletions(-) delete mode 100644 labscore/paginator.js create mode 100644 labscore/paginator/index.js create mode 100644 labscore/paginator/structures/BasePaginator.js create mode 100644 labscore/paginator/structures/Paginator.js create mode 100644 labscore/paginator/structures/PaginatorCluster.js create mode 100644 labscore/paginator/structures/ReactionPaginator.js diff --git a/labscore/client.js b/labscore/client.js index 15990ca..161ad8c 100644 --- a/labscore/client.js +++ b/labscore/client.js @@ -1,6 +1,6 @@ const { Constants, ClusterClient, CommandClient } = require('detritus-client'); -const { createPaginator } = require('./paginator') - const Paginator = require('detritus-pagination').PaginatorCluster +//const { createPaginator } = require('./paginator') +const Paginator = require('./paginator').PaginatorCluster // Create client const cluster = new ClusterClient("", { diff --git a/labscore/paginator.js b/labscore/paginator.js deleted file mode 100644 index 2e19e14..0000000 --- a/labscore/paginator.js +++ /dev/null @@ -1,22 +0,0 @@ -const Paginator = require('detritus-pagination').PaginatorCluster - -const paginators = {} - -function createPaginator(client){ - return new Paginator(client, { - // Maximum number of milliseconds for the bot to paginate - // It is recommended not to set this too high - // Defaults to 300000ms (5 minutes) - maxTime: 300000, - // Whether it should jump back to page 1 if the user tried to go past the last page - // Defaults to false - pageLoop: true, - // Whether a page number should be shown in embed footers - // If a string is passed as page, it will append the page number to the string - pageNumber: true - }); -} - -module.exports = { - createPaginator -} \ No newline at end of file diff --git a/labscore/paginator/index.js b/labscore/paginator/index.js new file mode 100644 index 0000000..62dc831 --- /dev/null +++ b/labscore/paginator/index.js @@ -0,0 +1,7 @@ +module.exports = { + Paginator: require("./structures/Paginator"), + PaginatorCluster: require("./structures/PaginatorCluster"), + get version() { + return require("../package").version; + } +}; \ No newline at end of file diff --git a/labscore/paginator/structures/BasePaginator.js b/labscore/paginator/structures/BasePaginator.js new file mode 100644 index 0000000..6825d90 --- /dev/null +++ b/labscore/paginator/structures/BasePaginator.js @@ -0,0 +1,139 @@ +const EventEmitter = require("eventemitter3"); +const { Context } = require("detritus-client/lib/command"); + +module.exports = class BasePaginator extends EventEmitter { + constructor(client, data) { + super(); + this.client = client; + this.message = BasePaginator.asMessage(data.message); + this.commandMessage = data.commandMessage || null; + this.pages = data.pages; + this.index = 0; + this.targetUser = data.targetUser || this.message.author.id; + + // Reference to reply function + // Uses context.editOrReply if an instance of Context was passed + // Defaults to message.reply + this.editOrReply = (data.message.editOrReply || data.message.reply).bind(data.message); + } + + static asMessage(ctx) { + return ctx instanceof Context ? ctx.message : ctx; + } + + get isShared() { + return this.commandMessage instanceof Map; + } + + isCommandMessage(messageId) { + if (!this.commandMessage) return false; + + return this.isShared ? this.commandMessage.has(messageId) : this.commandMessage.id === messageId; + } + + isInChannel(channelId) { + if (!this.commandMessage) return false; + + return this.isShared ? Array.from(this.commandMessage.values()).some(x => x.channelId === channelId) : this.commandMessage.channelId === channelId; + } + + isTarget(user) { + return this.targetUser instanceof Set ? this.targetUser.has(user) : this.targetUser === user; + } + + async update(data) { + if (this.isShared) { + for (const m of this.commandMessage.values()) { + await m.edit(data); + } + } else if (this.commandMessage) { + this.commandMessage.edit(data); + } + } + + async init() { + // Create Components + let msg = this.pages[this.index]; + msg.components = await this.client.components(this) + return this.commandMessage = await this.editOrReply(msg); + } + + async previous() { + if (Array.isArray(this.pages) && this.pages.length > 0) { + if (this.client.pageLoop) { + await this.update(this.pages[this.index === 0 ? this.index = this.pages.length - 1 : --this.index]); + } else if (this.index !== 0) { + await this.update(this.pages[--this.index]); + } else { + return this.commandMessage; + } + } + this.emit("previous", this); + return this.commandMessage; + } + + async getPrevious() { + if (Array.isArray(this.pages) && this.pages.length > 0) { + if (this.client.pageLoop) { + return this.pages[this.index === 0 ? this.index = this.pages.length - 1 : --this.index] + } else if (this.index !== 0) { + return this.pages[--this.index] + } else { + return this.commandMessage; + } + } + this.emit("previous", this); + return this.commandMessage; + } + + async next() { + if (Array.isArray(this.pages) && this.pages.length > 0) { + if (this.client.pageLoop) { + await this.update(this.pages[this.index === this.pages.length - 1 ? this.index = 0 : ++this.index]); + } else if (this.index !== this.pages.length - 1) { + await this.update(this.pages[++this.index]); + } else { + return this.commandMessage; + } + } + this.emit("next", this); + return this.commandMessage; + } + + async getNext() { + if (Array.isArray(this.pages) && this.pages.length > 0) { + if (this.client.pageLoop) { + return this.pages[this.index === this.pages.length - 1 ? this.index = 0 : ++this.index] + } else if (this.index !== this.pages.length - 1) { + return this.pages[++this.index] + } else { + return this.commandMessage; + } + } + this.emit("next", this); + return this.commandMessage; + } + + async jumpTo(page) { + if (isNaN(page) || this.pages[page] === undefined) { + throw new Error("Invalid page"); + } + await this.update(this.pages[page]); + + this.emit("page", { + page, + paginator: this + }); + return this.commandMessage; + } + + stop(timeout = false) { + this.emit("stop", this, timeout); + this.removeAllListeners(); + const targetIndex = this.client.activeListeners.findIndex(v => v.message.id === this.message.id); + this.client.activeListeners.splice(targetIndex, 1); + // Disable components + this.update({components:[]}); + return this; + } +}; \ No newline at end of file diff --git a/labscore/paginator/structures/Paginator.js b/labscore/paginator/structures/Paginator.js new file mode 100644 index 0000000..74ca6d4 --- /dev/null +++ b/labscore/paginator/structures/Paginator.js @@ -0,0 +1,205 @@ +const ReactionPaginator = require("./ReactionPaginator"); +const assert = require("assert"); + +const { Constants, Utils } = require('detritus-client') +const { Components, ComponentActionRow } = Utils +const { InteractionCallbackTypes } = Constants + +const allowedEvents = new Set([ + "MESSAGE_REACTION_ADD", + "MESSAGE_CREATE" +]); + +function deprecate(message) { + console.warn(`[detritus-pagination] Deprecation warning: ${message}`); +} + +const ButtonEmoji = Object.freeze({ + NEXT: '<:right:977871577758707782>', + PREVIOUS: '<:left:977871577532211200>', + STOP: '<:ico_trash:929498022386221096>' +}) + +const { hasOwnProperty } = Object.prototype; + +// Keep track of created instances in a WeakSet to prevent memory leaks +// We do this to notify the user when a Paginator is attached to the same client +const instances = new WeakSet(); + +module.exports = class Paginator { + constructor(client, data = {}) { + if (instances.has(client)) { + deprecate("Avoid attaching multiple Paginators to the same client, as it can lead to memory leaks"); + } else { + instances.add(client); + } + + assert.ok( + hasOwnProperty.call(client, "gateway"), + "Provided `client` has no `gateway` property. Consider using `require('detritus-pagination').PaginatorCluster` if you're using CommandClient." + ); + + this.client = client; + this.maxTime = data.maxTime || 300000; + this.pageLoop = typeof data.pageLoop !== "boolean" ? false : data.pageLoop; + this.pageNumber = typeof data.pageNumber !== "boolean" ? false : data.pageNumber; + this.activeListeners = []; + + this.client.gateway.on("packet", async packet => { + const { + d: data, + t: event + } = packet; + if (!data) return; + if (!allowedEvents.has(event)) return; + + for (const listener of this.activeListeners) { + if (!(listener instanceof ReactionPaginator)) continue; + if (!listener.commandMessage) continue; + + if (listener.isCommandMessage(data.message_id) && + listener.isTarget(data.user_id)) { + await this.handleReactionEvent(data, listener); + } else if (event === "MESSAGE_CREATE" && + listener.isInChannel(data.channel_id) && + listener.isTarget(data.user_id) && + listener.waitingForPage) { + await this.handleMessageEvent(data, listener); + } + } + }); + } + + async handleButtonEvent(context) { + // Get listener + let listener; + for (const l of this.activeListeners) { + if (!(l instanceof ReactionPaginator)) continue; + if (!l.commandMessage) continue; + + if (l.isCommandMessage(context.message.id)) { + listener = l + } + } + + if(!listener.isTarget(context.user.id)) { + await context.respond(InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE) + return; + } + + switch (context.customId) { + case "next": + //await context.respond(InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE) + //listener.next(); + await context.respond(InteractionCallbackTypes.UPDATE_MESSAGE, await listener.getNext()) + break; + case "previous": + await context.respond(InteractionCallbackTypes.UPDATE_MESSAGE, await listener.getPrevious()) + break; + case "stop": + await context.respond(InteractionCallbackTypes.DEFERRED_UPDATE_MESSAGE) + listener.stop(); + break; + } + } + + // TODO: Clean up legacy code from ReactionPaginator + + // Legacy + async handleReactionEvent(data, listener) { + switch (data.emoji.name) { + case listener.reactions.nextPage: + listener.next(); + break; + case listener.reactions.previousPage: + listener.previous(); + break; + case listener.reactions.firstPage: + listener.jumpTo(0); + break; + case listener.reactions.lastPage: + listener.jumpTo(listener.pages.length - 1); + break; + case listener.reactions.stop: + listener.stop(); + break; + case listener.reactions.skipTo: + if (listener.waitingForPage) return; + listener.waitingForPage = await this.client.rest.createMessage(data.channel_id, "What page do you want to go to?"); + break; + default: + if (!Object.values(listener.reactions).includes(data.emoji.name)) return; + } + + listener.emit("raw", data); + listener.clearReaction(data.emoji.name); + } + + async handleMessageEvent(data, listener) { + const page = parseInt(data.content, 10); + if (isNaN(page)) { + return; + } + + listener.jumpTo(page - 1) + .then(async () => { + try { + await listener.waitingForPage.delete(); + await this.client.rest.deleteMessage(data.channel_id, data.id); + } catch (e) { } + + listener.waitingForPage = null; + }).catch(() => { }); + } + + async components(listener) { + const components = new Components({ + timeout: this.expires, + run: this.handleButtonEvent.bind(this), + }); + + components.createButton({ + customId: "previous", + disabled: 0, + style: 2, + emoji: ButtonEmoji.PREVIOUS + }); + components.createButton({ + customId: "next", + disabled: 0, + style: 2, + emoji: ButtonEmoji.NEXT + }); + + //components.createButton({ + // customId: "stop", + // disabled: 0, + // style: 2, + // emoji: ButtonEmoji.STOP + //}); + return components; + } + + async createReactionPaginator(data) { + if (this.pageNumber && Array.isArray(data.pages)) { + for (let i = 0; i < data.pages.length; ++i) { + const element = data.pages[i]; + + } + } + + const instance = new ReactionPaginator(this, data); + this.activeListeners.push(instance); + + setTimeout(() => { + instance.stop(true); + }, data.maxTime || this.maxTime); + + if (instance.commandMessage === null && data.pages) { + await instance.init(); + } + + //await instance.addReactions(); + return instance; + } +}; diff --git a/labscore/paginator/structures/PaginatorCluster.js b/labscore/paginator/structures/PaginatorCluster.js new file mode 100644 index 0000000..f11d6f7 --- /dev/null +++ b/labscore/paginator/structures/PaginatorCluster.js @@ -0,0 +1,37 @@ +const { ClusterClient } = require("detritus-client"); +const Paginator = require("./Paginator"); +const assert = require("assert"); + +module.exports = class PaginatorCluster { + constructor(clusterClient, data = {}) { + assert.ok( + clusterClient instanceof ClusterClient, + "clusterClient must be an instance of ClusterClient" + ); + + const paginators = new WeakMap(); + + for (const [, client] of clusterClient.shards) { + paginators.set(client, new Paginator(client, data)); + } + + this.data = data; + this.paginators = paginators; + } + + findOrSetPaginator(client) { + const cachedPaginator = this.paginators.get(client); + if (cachedPaginator) return cachedPaginator; + + const paginator = new Paginator(client, this.data); + this.paginators.set(client, paginator); + + return paginator; + } + + createReactionPaginator(data) { + const targetPaginator = this.findOrSetPaginator(data.message.client); + + return targetPaginator.createReactionPaginator(data); + } +} \ No newline at end of file diff --git a/labscore/paginator/structures/ReactionPaginator.js b/labscore/paginator/structures/ReactionPaginator.js new file mode 100644 index 0000000..a8a987c --- /dev/null +++ b/labscore/paginator/structures/ReactionPaginator.js @@ -0,0 +1,48 @@ +const BasePaginator = require("./BasePaginator"); + +module.exports = class ReactionPaginator extends BasePaginator { + constructor(client, data) { + super(client, data); + this.waitingForPage = null; + this.reactions = data.reactions || { + firstPage: "⏮️", + previousPage: "⬅️", + nextPage: "➡️", + lastPage: "⏭️", + skipTo: "🔢", + stop: "⏹️" + }; + } + + async addReactions() { + if (!this.commandMessage) return; + + for (const reactions of Object.values(this.reactions)) { + if (this.isShared) { + for (const msg of this.commandMessage.values()) { + await msg.react(reactions).catch(); + } + } else { + await this.commandMessage.react(reactions).catch(() => {}); + } + } + } + + // TODO: this only works if cache is enabled + // perhaps add option to use REST API to fetch all reactions? + async clearReactions() { + const reactions = this.isShared ? Array.from(this.commandMessage.values()).map(x => Array.from(x.reactions.values())).flat() : this.commandMessage.reactions.values(); + + for (const reaction of reactions) { + this.clearReaction(reaction.emoji.name); + } + } + + async clearReaction(emoji) { + const reaction = this.commandMessage.reactions.find(x => x.emoji.name === emoji); + + if (reaction) { + reaction.delete(this.message.author.id).catch(() => {}); + } + } +};