Paging it yourself
Page a message without a Paginator, using paginate() where the class doesn't fit. Covers the cursor you declare, what paginate() returns, and where the buttons still work.
paginate() is the slicing math on its own. It takes a list, a page number, and a page size, then returns a PageView with totalPages filled in. The cursor, the buttons, and the routing are all yours to write.
You page by hand when a Paginator can't carry what your message needs, as in these two cases.
- An embed. Discord forbids an embed on a ComponentsV2 message, which is what seedcord's reply methods send.
- Extra values on the button. A paginator's cursor carries a page and a slot. A filter, a sort key, or a target user id takes a
CustomIdyou declare yourself.
import {
class ButtonHandler<
Defs extends readonly AnyCustomId[],
Cache extends CacheType = "cached"
>
ButtonHandler,
function ButtonRoute<const Defs extends readonly AnyCustomId[]>(
...defs: Defs
): <TCtor extends AnyHandlerCtor>(
constructor: AssertComponentRoute<InteractionKind.Button, Defs, TCtor>
) => void
ButtonRoute,
class CustomId<
Prefix extends string,
Shape extends CustomIdShape = {}
>
CustomId,
function paginate<Item>(
items: readonly Item[],
page: number,
perPage: number
): PageView<Item>
paginate,
class SlashHandler<
Route extends keyof SlashRegistry,
Cache extends CacheType = CacheFor<Route>
>
SlashHandler,
function SlashRoute<const Route extends keyof SlashRegistry>(
...routes: Route[]
): <TCtor extends AnyHandlerCtor>(
constructor: AssertSlashRoute<Route, TCtor>
) => void
SlashRoute
} from '@seedcord/gateway';
interface interface ScoreScore {
Score.name: stringname: string;
Score.points: numberpoints: number;
}
const const PER_PAGE: 5PER_PAGE = 5;
const const PAGE_BOUND: 999PAGE_BOUND = 999;
const const Board: CustomId<"lb", Record<"page", CustomIdField<number>>>Board = new new CustomId<"lb", {}>(prefix: "lb", shape?: {} | undefined): CustomId<"lb", {}>CustomId('lb').CustomId<"lb", {}>.int<"page", false>(name: "page", min: number, max: number, opts?: FieldOptions<false> | undefined): CustomId<"lb", Record<"page", CustomIdField<number>>> (+1 overload)int('page', 0, const PAGE_BOUND: 999PAGE_BOUND);
function function renderBoard(page: number): {
embeds: EmbedBuilder[];
components: ActionRowBuilder<ButtonBuilder>[];
}
renderBoard(page: numberpage: number) {
const const view: PageView<Score>view = paginate<Score>(items: readonly Score[], page: number, perPage: number): PageView<Score>paginate(const SCORES: Score[]SCORES, page: numberpage, const PER_PAGE: 5PER_PAGE);
const const row: BoardRowrow = new constructor BoardRow(prevId: string, nextId: string, hasPrev: boolean, hasNext: boolean): BoardRowBoardRow(
const Board: CustomId<"lb", Record<"page", CustomIdField<number>>>Board.CustomId<"lb", Record<"page", CustomIdField<number>>>.encode(values: DecodedParams<Record<"page", CustomIdField<number>>>): stringencode({ page: numberpage: var Math: MathMath.Math.max(...values: number[]): numbermax(0, const view: PageView<Score>view.PageView<Score>.page: numberpage - 1) }),
const Board: CustomId<"lb", Record<"page", CustomIdField<number>>>Board.CustomId<"lb", Record<"page", CustomIdField<number>>>.encode(values: DecodedParams<Record<"page", CustomIdField<number>>>): stringencode({ page: numberpage: var Math: MathMath.Math.min(...values: number[]): numbermin(const view: PageView<Score>view.PageView<Score>.page: numberpage + 1, const PAGE_BOUND: 999PAGE_BOUND) }),
const view: PageView<Score>view.PageView<Score>.hasPrev: booleanhasPrev,
const view: PageView<Score>view.PageView<Score>.hasNext: booleanhasNext
);
return {
embeds: EmbedBuilder[]embeds: [new constructor BoardCard(view: import("@seedcord/gateway").PageView<Score>): BoardCardBoardCard(const view: PageView<Score>view).BoardCard.component: EmbedBuildercomponent],
components: ActionRowBuilder<ButtonBuilder>[]components: [const row: BoardRowrow.BoardRow.component: ActionRowBuilder<ButtonBuilder>component]
};
}
@SlashRoute<"leaderboard">(...routes: "leaderboard"[]): <TCtor>(constructor: AssertSlashRoute<"leaderboard", TCtor>) => voidSlashRoute('leaderboard')
export class class LeaderboardLeaderboard extends class SlashHandler<
Route extends keyof SlashRegistry,
Cache extends CacheType = CacheFor<Route>
>
SlashHandler<'leaderboard'> {
public async Leaderboard.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
// eslint-disable-next-line @seedcord/no-raw-interaction-acks -- an embed cannot ride a ComponentsV2 reply
await this.BaseHandler<ChatInputCommandInteraction<"cached">, Core>.event: ChatInputCommandInteraction<"cached">event.CommandInteraction<"cached">.reply(options: string | MessagePayload | InteractionReplyOptions): Promise<InteractionResponse<true>> (+2 overloads)reply(function renderBoard(page: number): {
embeds: EmbedBuilder[];
components: ActionRowBuilder<ButtonBuilder>[];
}
renderBoard(0));
}
}
@ButtonRoute<readonly [CustomId<"lb", Record<"page", CustomIdField<number>>>]>(defs_0: CustomId<"lb", Record<"page", CustomIdField<number>>>): <TCtor>(constructor: AssertComponentRoute<InteractionKind.Button, readonly [CustomId<"lb", Record<"page", CustomIdField<number>>>], TCtor>) => voidButtonRoute(const Board: CustomId<"lb", Record<"page", CustomIdField<number>>>Board)
export class class LeaderboardNavLeaderboardNav extends class ButtonHandler<
Defs extends readonly AnyCustomId[],
Cache extends CacheType = "cached"
>
ButtonHandler<[typeof const Board: CustomId<"lb", Record<"page", CustomIdField<number>>>Board]> {
public async LeaderboardNav.execute(): Promise<void>execute(): interface Promise<T>Promise<void> {
// eslint-disable-next-line @seedcord/no-raw-interaction-acks -- an embed cannot ride a ComponentsV2 reply
await this.BaseHandler<ButtonInteraction<"cached">, Core>.event: ButtonInteraction<"cached">event.MessageComponentInteraction<"cached">.update(options?: string | MessagePayload | InteractionUpdateOptions): Promise<InteractionResponse<true>> (+2 overloads)update(function renderBoard(page: number): {
embeds: EmbedBuilder[];
components: ActionRowBuilder<ButtonBuilder>[];
}
renderBoard(this.ComponentHandler<ButtonInteraction<"cached">, [CustomId<"lb", Record<"page", CustomIdField<number>>>]>.params: DecodedParams<Record<"page", CustomIdField<number>>>params.page: numberpage));
}
}Both handlers call renderBoard, so the first reply and every click build the same message from the page number alone.
Gateway and http differ
this.event is the discord.js interaction on gateway, which declares reply and update itself. Http delivers this.event as a raw payload that doesn't have ack methods, so the same job goes through this.api, whose api.interactions callbacks answer Discord themselves.
Raw acks covers both routes and the lint rule the two handlers above disable.
The cursor you declare
Board here carries a page and nothing else. Prev targets page - 1 while Next targets page + 1, two numbers that never collide.
A third button breaks that. From page 1, First and Prev both target page 0. Discord rejects a message carrying two identical custom ids, so you give the cursor a second field, .int('slot', 0, 4), and pass each button its own slot.
Warning
encode throws CustomIdValueRejected once a value passes the bound its field declared. Clamp the target page on both ends before you encode it, the way Math.min and Math.max do in the fence above.
What paginate returns
const view = paginate(scores, 12, 5);Say scores holds 20 entries. At 5 per page that's 4 pages, numbered 0 to 3. Asking for page 12 gives back a view.page of 3, which is the fourth and last page. page is clamped into [0, totalPages - 1], so a button carrying a stale page number resolves to the nearest real one. A fractional page truncates.
totalPages in the hover is a plain number, since the whole list was in the argument. An empty list still reports 1.
A perPage of zero, a fraction, or a negative throws PaginationInvalidPerPage.
Where the buttons still work
renderBoard builds a plain object, so any call taking Discord's message shape accepts it. Both handlers above pass it straight to an ack.
The Prev and Next buttons route from any message, since the custom id carries everything the handler reads. @ButtonRoute matches on its prefix alone. On gateway, a weekly job that posts renderBoard(0) with channel.send gets working buttons too.