Change pagination to use interactions instead of reactions

This commit is contained in:
Natsu Kagami 2025-02-20 19:34:14 +01:00
parent 460624c7fe
commit d1f3aa5fa9
Signed by: nki
GPG key ID: 55A032EB38B49ADB
8 changed files with 281 additions and 227 deletions

View file

@ -180,7 +180,7 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult {
let last_updated = ranks.iter().map(|(_, cfu)| cfu.last_update).min().unwrap(); let last_updated = ranks.iter().map(|(_, cfu)| cfu.last_update).min().unwrap();
paginate_reply( paginate_reply(
paginate_from_fn(move |page, ctx, msg| { paginate_from_fn(move |page, _, _, btns| {
use Align::*; use Align::*;
let ranks = ranks.clone(); let ranks = ranks.clone();
Box::pin(async move { Box::pin(async move {
@ -188,7 +188,7 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult {
let start = ITEMS_PER_PAGE * page; let start = ITEMS_PER_PAGE * page;
let end = ranks.len().min(start + ITEMS_PER_PAGE); let end = ranks.len().min(start + ITEMS_PER_PAGE);
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let ranks = &ranks[start..end]; let ranks = &ranks[start..end];
@ -222,8 +222,9 @@ pub async fn ranks(ctx: &Context, m: &Message) -> CommandResult {
)) ))
.build(); .build();
msg.edit(ctx, EditMessage::new().content(content)).await?; Ok(Some(
Ok(true) EditMessage::new().content(content).components(vec![btns]),
))
}) })
}) })
.with_page_count(total_pages), .with_page_count(total_pages),
@ -318,7 +319,7 @@ pub(crate) async fn contest_rank_table(
let ranks = Arc::new(ranks); let ranks = Arc::new(ranks);
paginate_reply( paginate_reply(
paginate_from_fn(move |page, ctx, msg| { paginate_from_fn(move |page, _, _, btns| {
let contest = contest.clone(); let contest = contest.clone();
let problems = problems.clone(); let problems = problems.clone();
let ranks = ranks.clone(); let ranks = ranks.clone();
@ -327,7 +328,7 @@ pub(crate) async fn contest_rank_table(
let start = page * ITEMS_PER_PAGE; let start = page * ITEMS_PER_PAGE;
let end = ranks.len().min(start + ITEMS_PER_PAGE); let end = ranks.len().min(start + ITEMS_PER_PAGE);
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let ranks = &ranks[start..end]; let ranks = &ranks[start..end];
@ -389,8 +390,9 @@ pub(crate) async fn contest_rank_table(
.push_line(format!("Page **{}/{}**", page + 1, total_pages)) .push_line(format!("Page **{}/{}**", page + 1, total_pages))
.build(); .build();
msg.edit(ctx, EditMessage::new().content(content)).await?; Ok(Some(
Ok(true) EditMessage::new().content(content).components(vec![btns]),
))
}) })
}) })
.with_page_count(total_pages), .with_page_count(total_pages),

View file

@ -44,14 +44,14 @@ async fn list(ctx: &Context, m: &Message, _: Args) -> CommandResult {
let pages = (roles.len() + ROLES_PER_PAGE - 1) / ROLES_PER_PAGE; let pages = (roles.len() + ROLES_PER_PAGE - 1) / ROLES_PER_PAGE;
paginate_reply( paginate_reply(
paginate_from_fn(|page, ctx, msg| { paginate_from_fn(|page, _, _, btns| {
let roles = roles.clone(); let roles = roles.clone();
Box::pin(async move { Box::pin(async move {
let page = page as usize; let page = page as usize;
let start = page * ROLES_PER_PAGE; let start = page * ROLES_PER_PAGE;
let end = roles.len().min(start + ROLES_PER_PAGE); let end = roles.len().min(start + ROLES_PER_PAGE);
if end <= start { if end <= start {
return Ok(false); return Ok(None);
} }
let roles = &roles[start..end]; let roles = &roles[start..end];
@ -77,8 +77,9 @@ async fn list(ctx: &Context, m: &Message, _: Args) -> CommandResult {
.push_line(format!("Page **{}/{}**", page + 1, pages)) .push_line(format!("Page **{}/{}**", page + 1, pages))
.build(); .build();
msg.edit(ctx, EditMessage::new().content(content)).await?; Ok(Some(
Ok(true) EditMessage::new().content(content).components(vec![btns]),
))
}) })
}) })
.with_page_count(pages), .with_page_count(pages),

View file

@ -66,25 +66,23 @@ async fn message_command(
} }
let images = std::sync::Arc::new(images); let images = std::sync::Arc::new(images);
paginate_reply( paginate_reply(
paginate_from_fn(|page, ctx, msg: &mut Message| { paginate_from_fn(|page, _, _, btns| {
let images = images.clone(); let images = images.clone();
Box::pin(async move { Box::pin(async move {
let page = page as usize; let page = page as usize;
if page >= images.len() { if page >= images.len() {
Ok(false) Ok(None)
} else { } else {
msg.edit( Ok(Some(
ctx, EditMessage::new()
EditMessage::new().content(format!( .content(format!(
"[🖼️ **{}/{}**] Here's the image you requested!\n\n{}", "[🖼️ **{}/{}**] Here's the image you requested!\n\n{}",
page + 1, page + 1,
images.len(), images.len(),
images[page] images[page]
)), ))
) .components(vec![btns]),
.await ))
.map(|_| true)
.map_err(|e| e.into())
} }
}) })
}) })

View file

@ -92,7 +92,12 @@ mod scores {
#[async_trait] #[async_trait]
impl pagination::Paginate for Paginate { impl pagination::Paginate for Paginate {
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> { async fn render(
&mut self,
page: u8,
ctx: &Context,
msg: &Message,
) -> Result<Option<EditMessage>> {
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone(); let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
let page = page as usize; let page = page as usize;
let score = &self.scores[page]; let score = &self.scores[page];
@ -115,22 +120,17 @@ mod scores {
.await? .await?
.ok_or_else(|| Error::msg("user not found"))?; .ok_or_else(|| Error::msg("user not found"))?;
msg.edit( hourglass.delete(ctx).await?;
ctx, save_beatmap(&env, msg.channel_id, &bm).await?;
Ok(Some(
EditMessage::new() EditMessage::new()
.embed({ .embed({
crate::discord::embeds::score_embed(score, &bm, &content, &user) crate::discord::embeds::score_embed(score, &bm, &content, &user)
.footer(format!("Page {}/{}", page + 1, self.scores.len())) .footer(format!("Page {}/{}", page + 1, self.scores.len()))
.build() .build()
}) })
.components(vec![score_components(self.guild_id)]), .components(vec![score_components(self.guild_id), self.pagination_row()]),
) ))
.await?;
save_beatmap(&env, msg.channel_id, &bm).await?;
// End
hourglass.delete(ctx).await?;
Ok(true)
} }
fn len(&self) -> Option<usize> { fn len(&self) -> Option<usize> {
@ -193,7 +193,12 @@ mod scores {
#[async_trait] #[async_trait]
impl pagination::Paginate for Paginate { impl pagination::Paginate for Paginate {
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> { async fn render(
&mut self,
page: u8,
ctx: &Context,
msg: &Message,
) -> Result<Option<EditMessage>> {
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone(); let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
let meta_cache = &env.beatmaps; let meta_cache = &env.beatmaps;
@ -202,7 +207,7 @@ mod scores {
let start = page * ITEMS_PER_PAGE; let start = page * ITEMS_PER_PAGE;
let end = self.scores.len().min(start + ITEMS_PER_PAGE); let end = self.scores.len().min(start + ITEMS_PER_PAGE);
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let hourglass = msg.react(ctx, '⌛').await?; let hourglass = msg.react(ctx, '⌛').await?;
@ -321,9 +326,12 @@ mod scores {
.push_line("[?] means pp was predicted by oppai-rs.") .push_line("[?] means pp was predicted by oppai-rs.")
.build(); .build();
msg.edit(ctx, EditMessage::new().content(content)).await?;
hourglass.delete(ctx).await?; hourglass.delete(ctx).await?;
Ok(true) Ok(Some(
EditMessage::new()
.content(content)
.components(vec![self.pagination_row()]),
))
} }
fn len(&self) -> Option<usize> { fn len(&self) -> Option<usize> {
@ -335,7 +343,7 @@ mod scores {
mod beatmapset { mod beatmapset {
use serenity::{ use serenity::{
all::{GuildId, Reaction}, all::{CreateButton, GuildId},
builder::{CreateEmbedFooter, EditMessage}, builder::{CreateEmbedFooter, EditMessage},
model::channel::{Message, ReactionType}, model::channel::{Message, ReactionType},
}; };
@ -352,6 +360,7 @@ mod beatmapset {
}; };
const SHOW_ALL_EMOTE: &str = "🗒️"; const SHOW_ALL_EMOTE: &str = "🗒️";
const SHOW_ALL: &str = "youmubot_osu::discord::display::show_all";
pub async fn display_beatmapset( pub async fn display_beatmapset(
ctx: Context, ctx: Context,
@ -377,8 +386,6 @@ mod beatmapset {
mode, mode,
mods, mods,
guild_id, guild_id,
all_reaction: None,
}; };
let ctx = ctx.clone(); let ctx = ctx.clone();
@ -401,8 +408,6 @@ mod beatmapset {
mode: Option<Mode>, mode: Option<Mode>,
mods: Option<UnparsedMods>, mods: Option<UnparsedMods>,
guild_id: Option<GuildId>, guild_id: Option<GuildId>,
all_reaction: Option<Reaction>,
} }
impl Paginate { impl Paginate {
@ -427,21 +432,25 @@ mod beatmapset {
Some(self.maps.len()) Some(self.maps.len())
} }
async fn render(&mut self, page: u8, ctx: &Context, msg: &mut Message) -> Result<bool> { async fn render(
&mut self,
page: u8,
ctx: &Context,
msg: &Message,
) -> Result<Option<EditMessage>> {
let page = page as usize; let page = page as usize;
if page == self.maps.len() { if page == self.maps.len() {
msg.edit( return Ok(Some(
ctx, EditMessage::new()
EditMessage::new().embed(crate::discord::embeds::beatmapset_embed( .embed(crate::discord::embeds::beatmapset_embed(
&self.maps[..], &self.maps[..],
self.mode, self.mode,
)), ))
) .components(vec![self.pagination_row()]),
.await?; ));
return Ok(true);
} }
if page > self.maps.len() { if page > self.maps.len() {
return Ok(false); return Ok(None);
} }
let map = &self.maps[page]; let map = &self.maps[page];
@ -458,7 +467,16 @@ mod beatmapset {
self.infos[page].insert(info) self.infos[page].insert(info)
} }
}; };
msg.edit(ctx, let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
save_beatmap(
&env,
msg.channel_id,
&BeatmapWithMode(map.clone(), self.mode),
)
.await
.pls_ok();
Ok(Some(
EditMessage::new().embed( EditMessage::new().embed(
crate::discord::embeds::beatmap_embed( crate::discord::embeds::beatmap_embed(
map, map,
@ -475,31 +493,19 @@ mod beatmapset {
)) ))
}) })
) )
.components(vec![beatmap_components(map.mode, self.guild_id)]), .components(vec![beatmap_components(map.mode, self.guild_id), self.pagination_row()]),
) ))
.await?;
let env = ctx.data.read().await.get::<OsuEnv>().unwrap().clone();
save_beatmap(
&env,
msg.channel_id,
&BeatmapWithMode(map.clone(), self.mode),
)
.await
.pls_ok();
Ok(true)
} }
async fn prerender( fn interaction_buttons(&self) -> Vec<CreateButton> {
&mut self, let mut btns = pagination::default_buttons(self);
ctx: &Context, btns.insert(
m: &mut serenity::model::channel::Message, 0,
) -> Result<()> { CreateButton::new(SHOW_ALL)
self.all_reaction = Some( .emoji(ReactionType::try_from(SHOW_ALL_EMOTE).unwrap())
m.react(&ctx, SHOW_ALL_EMOTE.parse::<ReactionType>().unwrap()) .label("Show all"),
.await?,
); );
Ok(()) btns
} }
async fn handle_reaction( async fn handle_reaction(
@ -507,26 +513,16 @@ mod beatmapset {
page: u8, page: u8,
ctx: &Context, ctx: &Context,
message: &mut serenity::model::channel::Message, message: &mut serenity::model::channel::Message,
reaction: &Reaction, reaction: &str,
) -> Result<Option<u8>> { ) -> Result<Option<u8>> {
// Render the old style. // Render the old style.
if let ReactionType::Unicode(s) = &reaction.emoji { if reaction == SHOW_ALL {
if s == SHOW_ALL_EMOTE { pagination::do_render(self, self.maps.len() as u8, ctx, message).await?;
self.render(self.maps.len() as u8, ctx, message).await?; return Ok(Some(self.maps.len() as u8));
return Ok(Some(self.maps.len() as u8));
}
} }
pagination::handle_pagination_reaction(page, self, ctx, message, reaction) pagination::handle_pagination_reaction(page, self, ctx, message, reaction)
.await .await
.map(Some) .map(Some)
} }
async fn cleanup(&mut self, ctx: &Context, _msg: &mut Message) {
if let Some(r) = self.all_reaction.take() {
if !r.delete_all(&ctx).await.is_ok() {
r.delete(&ctx).await.pls_ok();
}
}
}
} }
} }

View file

@ -192,18 +192,18 @@ pub fn dot_osu_hook<'a>(
} else { } else {
let osu_embeds = Arc::new(osu_embeds); let osu_embeds = Arc::new(osu_embeds);
paginate_reply( paginate_reply(
paginate_from_fn(|page, ctx, msg| { paginate_from_fn(|page, _, _, btns| {
let osu_embeds = osu_embeds.clone(); let osu_embeds = osu_embeds.clone();
Box::pin(async move { Box::pin(async move {
let (embed, attachments) = &osu_embeds[page as usize]; let (embed, attachments) = &osu_embeds[page as usize];
let mut edit = EditMessage::new() let mut edit = EditMessage::new()
.content(format!("Attached beatmaps ({}/{})", page + 1, embed_len)) .content(format!("Attached beatmaps ({}/{})", page + 1, embed_len))
.embed(embed.clone()); .embed(embed.clone())
.components(vec![btns]);
for att in attachments { for att in attachments {
edit = edit.new_attachment(att.clone()); edit = edit.new_attachment(att.clone());
} }
msg.edit(&ctx, edit).await?; Ok(Some(edit))
Ok(true)
}) })
}) })
.with_page_count(embed_len), .with_page_count(embed_len),

View file

@ -236,7 +236,7 @@ where
let total_len = users.len(); let total_len = users.len();
let total_pages = (total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE; let total_pages = (total_len + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE;
paginate_with_first_message( paginate_with_first_message(
paginate_from_fn(move |page: u8, ctx: &Context, m: &mut Message| { paginate_from_fn(move |page: u8, _: &Context, _: &Message, btns| {
let header = header.clone(); let header = header.clone();
use Align::*; use Align::*;
let users = users.clone(); let users = users.clone();
@ -244,7 +244,7 @@ where
let start = (page as usize) * ITEMS_PER_PAGE; let start = (page as usize) * ITEMS_PER_PAGE;
let end = (start + ITEMS_PER_PAGE).min(users.len()); let end = (start + ITEMS_PER_PAGE).min(users.len());
if start >= end { if start >= end {
return Ok(false); return Ok(None);
} }
let users = &users[start..end]; let users = &users[start..end];
let table = match query { let table = match query {
@ -317,8 +317,9 @@ where
last_update.format("<t:%s:R>"), last_update.format("<t:%s:R>"),
)) ))
.build(); .build();
m.edit(ctx, EditMessage::new().content(content)).await?; Ok(Some(
Ok(true) EditMessage::new().content(content).components(vec![btns]),
))
}) })
}) })
.with_page_count(total_pages), .with_page_count(total_pages),
@ -617,11 +618,11 @@ pub async fn display_rankings_table(
let header = Arc::new(to.content.clone()); let header = Arc::new(to.content.clone());
paginate_with_first_message( paginate_with_first_message(
paginate_from_fn(move |page: u8, ctx: &Context, m: &mut Message| { paginate_from_fn(move |page: u8, _, _, btns| {
let start = (page as usize) * ITEMS_PER_PAGE; let start = (page as usize) * ITEMS_PER_PAGE;
let end = (start + ITEMS_PER_PAGE).min(scores.len()); let end = (start + ITEMS_PER_PAGE).min(scores.len());
if start >= end { if start >= end {
return Box::pin(future::ready(Ok(false))); return Box::pin(future::ready(Ok(None)));
} }
let scores = scores[start..end].to_vec(); let scores = scores[start..end].to_vec();
let header = header.clone(); let header = header.clone();
@ -720,8 +721,9 @@ pub async fn display_rankings_table(
)) ))
.build(); .build();
m.edit(&ctx, EditMessage::new().content(content)).await?; Ok(Some(
Ok(true) EditMessage::new().content(content).components(vec![btns]),
))
}) })
}) })
.with_page_count(total_pages), .with_page_count(total_pages),

View file

@ -1,14 +1,18 @@
use crate::{Context, OkPrint, Result}; use crate::{Context, Result};
use futures_util::{future::Future, StreamExt as _}; use futures_util::future::Future;
use serenity::{ use serenity::{
all::{
CreateActionRow, CreateButton, CreateInteractionResponse, EditMessage, Interaction,
MessageId,
},
builder::CreateMessage, builder::CreateMessage,
collector,
model::{ model::{
channel::{Message, Reaction, ReactionType}, channel::{Message, ReactionType},
id::ChannelId, id::ChannelId,
}, },
prelude::TypeMapKey,
}; };
use std::convert::TryFrom; use std::{convert::TryFrom, sync::Arc};
use tokio::time as tokio_time; use tokio::time as tokio_time;
const ARROW_RIGHT: &str = "➡️"; const ARROW_RIGHT: &str = "➡️";
@ -16,19 +20,28 @@ const ARROW_LEFT: &str = "⬅️";
const REWIND: &str = ""; const REWIND: &str = "";
const FAST_FORWARD: &str = ""; const FAST_FORWARD: &str = "";
const NEXT: &str = "youmubot_pagination_next";
const PREV: &str = "youmubot_pagination_prev";
const FAST_NEXT: &str = "youmubot_pagination_fast_next";
const FAST_PREV: &str = "youmubot_pagination_fast_prev";
/// A trait that provides the implementation of a paginator. /// A trait that provides the implementation of a paginator.
#[async_trait::async_trait] #[async_trait::async_trait]
pub trait Paginate: Send + Sized { pub trait Paginate: Send + Sized {
/// Render the given page. /// Render the given page.
async fn render(&mut self, page: u8, ctx: &Context, m: &mut Message) -> Result<bool>; /// Remember to add the [[interaction_buttons]] as an action row!
async fn render(&mut self, page: u8, ctx: &Context, m: &Message)
-> Result<Option<EditMessage>>;
/// Any setting-up before the rendering stage. /// The [[CreateActionRow]] for pagination.
async fn prerender(&mut self, _ctx: &Context, _m: &mut Message) -> Result<()> { fn pagination_row(&self) -> CreateActionRow {
Ok(()) CreateActionRow::Buttons(self.interaction_buttons())
} }
/// Cleans up after the pagination has timed out. /// A list of buttons to create that would interact with pagination logic.
async fn cleanup(&mut self, _ctx: &Context, _m: &mut Message) -> () {} fn interaction_buttons(&self) -> Vec<CreateButton> {
default_buttons(self)
}
/// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling /// Handle the incoming reaction. Defaults to calling `handle_pagination_reaction`, but you can do some additional handling
/// before handing the functionality over. /// before handing the functionality over.
@ -39,7 +52,7 @@ pub trait Paginate: Send + Sized {
page: u8, page: u8,
ctx: &Context, ctx: &Context,
message: &mut Message, message: &mut Message,
reaction: &Reaction, reaction: &str,
) -> Result<Option<u8>> { ) -> Result<Option<u8>> {
handle_pagination_reaction(page, self, ctx, message, reaction) handle_pagination_reaction(page, self, ctx, message, reaction)
.await .await
@ -65,17 +78,48 @@ pub trait Paginate: Send + Sized {
} }
} }
pub async fn do_render(
p: &mut impl Paginate,
page: u8,
ctx: &Context,
m: &mut Message,
) -> Result<bool> {
if let Some(edit) = p.render(page, ctx, m).await? {
m.edit(ctx, edit).await?;
Ok(true)
} else {
Ok(false)
}
}
pub fn paginate_from_fn( pub fn paginate_from_fn(
pager: impl for<'m> FnMut( pager: impl for<'m> FnMut(
u8, u8,
&'m Context, &'m Context,
&'m mut Message, &'m Message,
) -> std::pin::Pin<Box<dyn Future<Output = Result<bool>> + Send + 'm>> CreateActionRow,
+ Send, ) -> std::pin::Pin<
Box<dyn Future<Output = Result<Option<EditMessage>>> + Send + 'm>,
> + Send,
) -> impl Paginate { ) -> impl Paginate {
pager pager
} }
pub fn default_buttons(p: &impl Paginate) -> Vec<CreateButton> {
let mut btns = vec![
CreateButton::new(PREV).emoji(ReactionType::try_from(ARROW_LEFT).unwrap()),
CreateButton::new(NEXT).emoji(ReactionType::try_from(ARROW_RIGHT).unwrap()),
];
if p.len().is_some_and(|v| v > 5) {
btns.insert(
0,
CreateButton::new(FAST_PREV).emoji(ReactionType::try_from(REWIND).unwrap()),
);
btns.push(CreateButton::new(FAST_NEXT).emoji(ReactionType::try_from(FAST_FORWARD).unwrap()))
}
btns
}
struct WithPageCount<Inner> { struct WithPageCount<Inner> {
inner: Inner, inner: Inner,
page_count: usize, page_count: usize,
@ -83,33 +127,28 @@ struct WithPageCount<Inner> {
#[async_trait::async_trait] #[async_trait::async_trait]
impl<Inner: Paginate> Paginate for WithPageCount<Inner> { impl<Inner: Paginate> Paginate for WithPageCount<Inner> {
async fn render(&mut self, page: u8, ctx: &Context, m: &mut Message) -> Result<bool> { async fn render(
&mut self,
page: u8,
ctx: &Context,
m: &Message,
) -> Result<Option<EditMessage>> {
if page as usize >= self.page_count { if page as usize >= self.page_count {
return Ok(false); return Ok(None);
} }
self.inner.render(page, ctx, m).await self.inner.render(page, ctx, m).await
} }
async fn prerender(&mut self, ctx: &Context, m: &mut Message) -> Result<()> {
self.inner.prerender(ctx, m).await
}
async fn handle_reaction( async fn handle_reaction(
&mut self, &mut self,
page: u8, page: u8,
ctx: &Context, ctx: &Context,
message: &mut Message, message: &mut Message,
reaction: &Reaction, reaction: &str,
) -> Result<Option<u8>> { ) -> Result<Option<u8>> {
// handle normal reactions first, then fallback to the inner one self.inner
let new_page = handle_pagination_reaction(page, self, ctx, message, reaction).await?; .handle_reaction(page, ctx, message, reaction)
.await
if new_page != page {
Ok(Some(new_page))
} else {
self.inner
.handle_reaction(page, ctx, message, reaction)
.await
}
} }
fn len(&self) -> Option<usize> { fn len(&self) -> Option<usize> {
@ -119,10 +158,6 @@ impl<Inner: Paginate> Paginate for WithPageCount<Inner> {
fn is_empty(&self) -> Option<bool> { fn is_empty(&self) -> Option<bool> {
Some(self.page_count == 0) Some(self.page_count == 0)
} }
async fn cleanup(&mut self, ctx: &Context, msg: &mut Message) {
self.inner.cleanup(ctx, msg).await;
}
} }
#[async_trait::async_trait] #[async_trait::async_trait]
@ -131,12 +166,20 @@ where
T: for<'m> FnMut( T: for<'m> FnMut(
u8, u8,
&'m Context, &'m Context,
&'m mut Message, &'m Message,
) -> std::pin::Pin<Box<dyn Future<Output = Result<bool>> + Send + 'm>> CreateActionRow,
+ Send, ) -> std::pin::Pin<
Box<dyn Future<Output = Result<Option<EditMessage>>> + Send + 'm>,
> + Send,
{ {
async fn render(&mut self, page: u8, ctx: &Context, m: &mut Message) -> Result<bool> { async fn render(
self(page, ctx, m).await &mut self,
page: u8,
ctx: &Context,
m: &Message,
) -> Result<Option<EditMessage>> {
let btns = self.pagination_row();
self(page, ctx, m, btns).await
} }
} }
@ -178,63 +221,22 @@ pub async fn paginate_with_first_message(
mut message: Message, mut message: Message,
timeout: std::time::Duration, timeout: std::time::Duration,
) -> Result<()> { ) -> Result<()> {
pager.prerender(ctx, &mut message).await?; let (send, recv) = flume::unbounded::<String>();
pager.render(0, ctx, &mut message).await?; Paginator::push(ctx, &message, send).await?;
do_render(&mut pager, 0, ctx, &mut message).await?;
// Just quit if there is only one page // Just quit if there is only one page
if pager.len().filter(|&v| v == 1).is_some() { if pager.len().filter(|&v| v == 1).is_some() {
return Ok(()); return Ok(());
} }
// React to the message
let large_count = pager.len().filter(|&p| p > 10).is_some();
let reactions = {
let mut rs = Vec::<Reaction>::with_capacity(4);
if large_count {
// add >> and << buttons
rs.push(message.react(&ctx, ReactionType::try_from(REWIND)?).await?);
}
rs.push(
message
.react(&ctx, ReactionType::try_from(ARROW_LEFT)?)
.await?,
);
rs.push(
message
.react(&ctx, ReactionType::try_from(ARROW_RIGHT)?)
.await?,
);
if large_count {
// add >> and << buttons
rs.push(
message
.react(&ctx, ReactionType::try_from(FAST_FORWARD)?)
.await?,
);
}
rs
};
// Build a reaction collector
let mut reaction_collector = {
// message.await_reactions(ctx).removed(true).build();
let message_id = message.id;
let me = message.author.id;
collector::collect(&ctx.shard, move |event| {
match event {
serenity::all::Event::ReactionAdd(r) => Some(r.reaction.clone()),
serenity::all::Event::ReactionRemove(r) => Some(r.reaction.clone()),
_ => None,
}
.filter(|r| r.message_id == message_id)
.filter(|r| r.user_id.is_some_and(|id| id != me))
})
};
let mut page = 0; let mut page = 0;
// Loop the handler function. // Loop the handler function.
let res: Result<()> = loop { let res: Result<()> = loop {
match tokio_time::timeout(timeout, reaction_collector.next()).await { match tokio_time::timeout(timeout, recv.clone().into_recv_async()).await {
Err(_) => break Ok(()), Err(_) => break Ok(()),
Ok(None) => break Ok(()), Ok(Err(_)) => break Ok(()),
Ok(Some(reaction)) => { Ok(Ok(reaction)) => {
page = match pager page = match pager
.handle_reaction(page, ctx, &mut message, &reaction) .handle_reaction(page, ctx, &mut message, &reaction)
.await .await
@ -247,14 +249,7 @@ pub async fn paginate_with_first_message(
} }
}; };
pager.cleanup(ctx, &mut message).await; Paginator::pop(ctx, &message).await?;
for reaction in reactions {
if reaction.delete_all(&ctx).await.pls_ok().is_none() {
// probably no permission to delete all reactions, fall back to delete my own.
reaction.delete(&ctx).await.pls_ok();
}
}
res res
} }
@ -265,35 +260,90 @@ pub async fn handle_pagination_reaction(
pager: &mut impl Paginate, pager: &mut impl Paginate,
ctx: &Context, ctx: &Context,
message: &mut Message, message: &mut Message,
reaction: &Reaction, reaction: &str,
) -> Result<u8> { ) -> Result<u8> {
let pages = pager.len(); let pages = pager.len();
let fast = pages.map(|v| v / 10).unwrap_or(5).max(5) as u8; let fast = pages.map(|v| v / 10).unwrap_or(5).max(5) as u8;
match &reaction.emoji { let new_page = match reaction {
ReactionType::Unicode(ref s) => { PREV | FAST_PREV if page == 0 => return Ok(page),
let new_page = match s.as_str() { PREV => page - 1,
ARROW_LEFT | REWIND if page == 0 => return Ok(page), FAST_PREV => {
ARROW_LEFT => page - 1, if page < fast {
REWIND => { 0
if page < fast {
0
} else {
page - fast
}
}
ARROW_RIGHT if pages.filter(|&pages| page as usize + 1 >= pages).is_some() => {
return Ok(page)
}
ARROW_RIGHT => page + 1,
FAST_FORWARD => (pages.unwrap() as u8 - 1).min(page + fast),
_ => return Ok(page),
};
Ok(if pager.render(new_page, ctx, message).await? {
new_page
} else { } else {
page page - fast
}) }
}
NEXT if pages.filter(|&pages| page as usize + 1 >= pages).is_some() => return Ok(page),
NEXT => page + 1,
FAST_NEXT => (pages.unwrap() as u8 - 1).min(page + fast),
_ => return Ok(page),
};
Ok(
if let Some(edit) = pager.render(new_page, ctx, message).await? {
message.edit(ctx, edit).await?;
new_page
} else {
page
},
)
}
#[derive(Debug, Clone)]
/// Handles distributing pagination interaction to the handlers.
pub struct Paginator {
pub(crate) channels: Arc<dashmap::DashMap<MessageId, flume::Sender<String>>>,
}
impl Paginator {
pub fn new() -> Self {
Self {
channels: Arc::new(dashmap::DashMap::new()),
}
}
async fn push(ctx: &Context, msg: &Message, channel: flume::Sender<String>) -> Result<()> {
ctx.data
.write()
.await
.get_mut::<Paginator>()
.unwrap()
.channels
.insert(msg.id, channel);
Ok(())
}
async fn pop(ctx: &Context, msg: &Message) -> Result<()> {
ctx.data
.write()
.await
.get_mut::<Paginator>()
.unwrap()
.channels
.remove(&msg.id);
Ok(())
}
}
impl TypeMapKey for Paginator {
type Value = Paginator;
}
#[async_trait::async_trait]
impl crate::hook::InteractionHook for Paginator {
async fn call(&self, ctx: &Context, interaction: &Interaction) -> Result<()> {
match interaction {
Interaction::Component(component_interaction) => {
if let Some(ch) = self.channels.get(&component_interaction.message.id) {
component_interaction
.create_response(ctx, CreateInteractionResponse::Acknowledge)
.await?;
ch.send_async(component_interaction.data.custom_id.clone())
.await
.ok();
}
Ok(())
}
_ => Ok(()),
} }
_ => Ok(page),
} }
} }

View file

@ -236,6 +236,11 @@ async fn main() {
} }
}; };
// Paginator
let paginator = youmubot_prelude::pagination::Paginator::new();
handler.push_interaction_hook(paginator.clone());
data.insert::<youmubot_prelude::pagination::Paginator>(paginator);
data.insert::<Env>(env.clone()); data.insert::<Env>(env.clone());
#[cfg(feature = "core")] #[cfg(feature = "core")]