From ea4ee1331efa6145f988435acbe1e43dc1abaf75 Mon Sep 17 00:00:00 2001 From: Marek 'seqre' Grzelak Date: Tue, 12 Sep 2023 00:31:24 -0500 Subject: [PATCH 1/3] [feat] pagination with buttons (#44) --- src/commands/mod.rs | 2 +- src/commands/todo.rs | 152 +++++++++++++++++++++++++++++++++---------- 2 files changed, 117 insertions(+), 37 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index c5f8605..ec02060 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,7 +7,7 @@ pub mod ping; #[allow(clippy::cast_sign_loss)] pub mod todo; -pub const DISCORD_EMBED_FIELDS_LIMIT: u32 = 25; +pub const DISCORD_EMBED_FIELDS_LIMIT: u32 = 15; #[poise::command(track_edits, slash_command)] pub async fn help( diff --git a/src/commands/todo.rs b/src/commands/todo.rs index edddd36..162ac5a 100644 --- a/src/commands/todo.rs +++ b/src/commands/todo.rs @@ -7,8 +7,9 @@ use std::{ collections::HashMap, sync::{ atomic::{AtomicI32, Ordering}, - Mutex, + Arc, Mutex, }, + time::Duration, }; use diesel::{ @@ -18,7 +19,8 @@ use diesel::{ use itertools::Itertools; use lazy_static::lazy_static; use poise::serenity_prelude::{ - ChannelId, CreateEmbed, GuildChannel, Member, MessageBuilder, UserId, + ButtonStyle, ChannelId, CreateEmbed, GuildChannel, Member, MessageBuilder, + MessageComponentInteraction, UserId, }; use time::{format_description, format_description::FormatItem, OffsetDateTime}; use tokio_stream::{self as stream, StreamExt}; @@ -35,7 +37,7 @@ lazy_static! { format_description::parse("[year]-[month]-[day] [hour]:[minute]:[second]").unwrap(); } -#[derive(Debug)] +#[derive(Debug, PartialEq)] struct TodoEntry { id: i32, assignee: Option, @@ -144,7 +146,6 @@ pub async fn list( #[flag] completed: bool, #[description = "Show only TODOs assigned to"] todo_assignee: Option, - #[description = "Page to show"] page: Option, ) -> Result<()> { use crate::schema::todos::dsl::{assignee, channel_id, completion_date, todos}; @@ -176,7 +177,7 @@ pub async fn list( if output.is_empty() { EmbedData::Text("There are no incompleted TODOs in this channel.".to_string()) } else { - EmbedData::Fields(output, page.unwrap_or(1)) + EmbedData::Fields(output) } } Err(NotFound) => EmbedData::Text("Not found.".to_string()), @@ -449,55 +450,134 @@ fn get_member_nickname(member: &Member) -> String { member.user.name.to_string() } -#[derive(Debug)] +#[derive(Debug, PartialEq)] enum EmbedData { Text(String), - Fields(Vec, u32), + Fields(Vec), } async fn respond(ctx: Context<'_>, data: EmbedData, ephemeral: bool) { + match data { + EmbedData::Text(text) => respond_text(ctx, text, ephemeral).await, + EmbedData::Fields(fields) => respond_fields(ctx, fields).await, + } +} + +async fn respond_text(ctx: Context<'_>, text: String, ephemeral: bool) { let response = ctx .send(|reply| { reply - .embed(|embed| create_embed(embed, data)) + .embed(|embed| embed.description(text)) .ephemeral(ephemeral) }) .await; + if let Err(e) = response { debug!("{:?}", e); } } +async fn respond_fields(ctx: Context<'_>, fields: Vec) { + let ctx_id = ctx.id(); + let prev_button_id = format!("{}prev", ctx_id); + let next_button_id = format!("{}next", ctx_id); + // let refresh_button_id = format!("{}refresh", ctx_id); -fn create_embed(builder: &mut CreateEmbed, data: EmbedData) -> &mut CreateEmbed { - match data { - EmbedData::Text(text) => builder.description(text), - EmbedData::Fields(fields, page) => { - let total = fields.iter().filter(|te| !te.completed).count(); - let pages = total.div_ceil(DISCORD_EMBED_FIELDS_LIMIT as usize); - let page = std::cmp::min(page, pages as u32); - let footer = format!("Page {page}/{pages}: {total} uncompleted TODOs"); - let skip = DISCORD_EMBED_FIELDS_LIMIT * (page - 1); - - let new_fields: Vec<(String, String, bool)> = fields - .into_iter() - .skip(skip.try_into().unwrap()) - .map(|entry| { - let mut title = format!("[{}]", entry.id); - if entry.completed { - title = format!("{title} [DONE]"); - } - if let Some(nick) = entry.assignee { - title = format!("{title} - {nick}"); - }; - (title, entry.text, false) + let mut fields = fields; + let mut page = 0; + let mut pages = fields.len().div_ceil(DISCORD_EMBED_FIELDS_LIMIT as usize) as u32; + + let response = ctx + .send(|reply| { + reply.embed(|embed| { + let footer = get_footer(&fields, page, pages); + let fields = get_embed_data(&fields, page); + embed + .title("TODOs") + .fields(fields) + .footer(|f| f.text(footer)) + }); + + reply.components(|comp| { + comp.create_action_row(|ar| { + ar.create_button(|cb| cb.custom_id(&prev_button_id).emoji('◀')) + // .create_button(|cb| { + // cb.custom_id(&refresh_button_id) + // .label("Refresh") + // .style(ButtonStyle::Secondary) + // }) + .create_button(|cb| cb.custom_id(&next_button_id).emoji('▶')) }) - .take(DISCORD_EMBED_FIELDS_LIMIT as usize) - .collect(); + }); + + reply + }) + .await; + + if let Err(e) = response { + debug!("{:?}", e); + } + + while let Some(button) = + poise::serenity_prelude::CollectComponentInteraction::new(ctx.serenity_context()) + .timeout(Duration::from_secs(60 * 3)) + .filter(move |comp| comp.data.custom_id.starts_with(&ctx_id.to_string())) + .await + { + debug!("Got button interaction: {:?}", button); + let interaction_id = button.data.custom_id.clone(); + if interaction_id == prev_button_id { + page = page.checked_sub(1).unwrap_or(pages - 1) + } else if interaction_id == next_button_id { + page += 1; + if page >= pages { + page = 0; + } + } + // else if interaction_id == refresh_button_id {} + else { + continue; + } + + let footer = get_footer(&fields, page, pages); + let fields = get_embed_data(&fields, page); - builder - .title("TODOs") - .fields(new_fields) - .footer(|f| f.text(footer)) + let response = button + .create_interaction_response(ctx, |ir| { + ir.kind(poise::serenity_prelude::InteractionResponseType::UpdateMessage) + .interaction_response_data(|ird| { + ird.embed(|ce| ce.fields(fields).footer(|f| f.text(footer))) + }) + }) + .await; + + if let Err(e) = response { + debug!("{:?}", e); } } } + +fn get_embed_data(fields: &Vec, page: u32) -> Vec<(String, String, bool)> { + let skip = page * DISCORD_EMBED_FIELDS_LIMIT; + let new_fields: Vec<(String, String, bool)> = fields + .into_iter() + .skip(skip.try_into().unwrap()) + .map(|entry| { + let mut title = format!("[{}]", entry.id); + if entry.completed { + title = format!("{title} [DONE]"); + } + if let Some(nick) = &entry.assignee { + title = format!("{title} - {nick}"); + }; + (title, entry.text.clone(), false) + }) + .take(DISCORD_EMBED_FIELDS_LIMIT as usize) + .collect(); + new_fields +} + +fn get_footer(fields: &Vec, page: u32, pages: u32) -> String { + let total = fields.iter().filter(|te| !te.completed).count(); + let footer = format!("Page {}/{pages}: {total} uncompleted TODOs", page + 1); + footer +} From f91d6b47984aecb32b4b3b1180559665fb08f684 Mon Sep 17 00:00:00 2001 From: Marek 'seqre' Grzelak Date: Tue, 12 Sep 2023 12:31:11 -0500 Subject: [PATCH 2/3] [feat] refresh button for todo list --- src/commands/todo.rs | 214 +++++++++++++++++++++++-------------------- 1 file changed, 116 insertions(+), 98 deletions(-) diff --git a/src/commands/todo.rs b/src/commands/todo.rs index 162ac5a..c11a838 100644 --- a/src/commands/todo.rs +++ b/src/commands/todo.rs @@ -138,6 +138,11 @@ pub async fn todo(_ctx: Context<'_>) -> Result<()> { // TODO: division of responsibilites, extract database manipulations to other // functions +struct QueryData { + completed: bool, + todo_assignee: Option, +} + /// List TODO entries #[poise::command(slash_command)] pub async fn list( @@ -147,23 +152,38 @@ pub async fn list( completed: bool, #[description = "Show only TODOs assigned to"] todo_assignee: Option, ) -> Result<()> { + let query_data = QueryData { + completed, + todo_assignee, + }; + let data = get_todos(ctx, &query_data).await; + + match data { + EmbedData::Text(text) => respond_text(ctx, text, false).await, + EmbedData::Fields(fields) => respond_fields(ctx, fields, query_data).await, + }; + + Ok(()) +} + +async fn get_todos(ctx: Context<'_>, query_data: &QueryData) -> EmbedData { use crate::schema::todos::dsl::{assignee, channel_id, completion_date, todos}; let mut query = todos .into_boxed() .filter(channel_id.eq(i64::from(ctx.channel_id()))); - if !completed { + if !query_data.completed { query = query.filter(completion_date.is_null()); }; - if let Some(member) = todo_assignee { + if let Some(member) = &query_data.todo_assignee { query = query.filter(assignee.eq(member.user.id.0 as i64)); }; let results = query.load::(&mut ctx.data().db.get().unwrap()); - let data = match results { + match results { Ok(todo_list) => { let mut output: Vec = vec![]; let mut todos_stream = stream::iter(todo_list); @@ -182,11 +202,7 @@ pub async fn list( } Err(NotFound) => EmbedData::Text("Not found.".to_string()), Err(_) => EmbedData::Text("Listing TODOs failed.".to_string()), - }; - - respond(ctx, data, false).await; - - Ok(()) + } } /// Add TODO entry @@ -199,7 +215,7 @@ pub async fn add( use crate::schema::todos::dsl::todos; let data = if content.len() > 1024 { - EmbedData::Text("Content can't have more than 1024 characters.".to_string()) + "Content can't have more than 1024 characters.".to_string() } else { let time = OffsetDateTime::now_utc().format(&TIME_FORMAT).unwrap(); let new_id = ctx.data().todo_data.get_id(ctx.channel_id()); @@ -223,19 +239,17 @@ pub async fn add( .execute(&mut ctx.data().db.get().unwrap()); match result { - Ok(_) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] (", &new_id)) - .push_mono_safe(&text) - .push(format!(") added and assigned to {nickname}.")) - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Adding TODO failed.".to_string()), + Ok(_) => MessageBuilder::new() + .push(format!("TODO [{}] (", &new_id)) + .push_mono_safe(&text) + .push(format!(") added and assigned to {nickname}.")) + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Adding TODO failed.".to_string(), } }; - respond(ctx, data, false).await; + respond_text(ctx, data, false).await; Ok(()) } @@ -252,18 +266,16 @@ pub async fn delete(ctx: Context<'_>, #[description = "TODO id"] todo_id: i64) - .get_result(&mut ctx.data().db.get().unwrap()); let data = match deleted { - Ok(deleted) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] (", &todo_id)) - .push_mono_safe(&deleted) - .push(") deleted.") - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Deleting TODO failed.".to_string()), + Ok(deleted) => MessageBuilder::new() + .push(format!("TODO [{}] (", &todo_id)) + .push_mono_safe(&deleted) + .push(") deleted.") + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Deleting TODO failed.".to_string(), }; - respond(ctx, data, true).await; + respond_text(ctx, data, true).await; Ok(()) } @@ -283,18 +295,16 @@ pub async fn complete(ctx: Context<'_>, #[description = "TODO id"] todo_id: i64) .get_result(&mut ctx.data().db.get().unwrap()); let data = match completed { - Ok(completed) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] (", &todo_id)) - .push_mono_safe(&completed) - .push(") completed.") - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Completing TODO failed.".to_string()), + Ok(completed) => MessageBuilder::new() + .push(format!("TODO [{}] (", &todo_id)) + .push_mono_safe(&completed) + .push(") completed.") + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Completing TODO failed.".to_string(), }; - respond(ctx, data, false).await; + respond_text(ctx, data, false).await; Ok(()) } @@ -312,18 +322,16 @@ pub async fn uncomplete(ctx: Context<'_>, #[description = "TODO id"] todo_id: i6 .get_result(&mut ctx.data().db.get().unwrap()); let data = match uncompleted { - Ok(uncompleted) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] (", &todo_id)) - .push_mono_safe(&uncompleted) - .push(") uncompleted.") - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Uncompleting TODO failed.".to_string()), + Ok(uncompleted) => MessageBuilder::new() + .push(format!("TODO [{}] (", &todo_id)) + .push_mono_safe(&uncompleted) + .push(") uncompleted.") + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Uncompleting TODO failed.".to_string(), }; - respond(ctx, data, true).await; + respond_text(ctx, data, true).await; Ok(()) } @@ -351,18 +359,16 @@ pub async fn assign( .get_result(&mut ctx.data().db.get().unwrap()); let data = match reassigned { - Ok(reassigned) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] (", &todo_id)) - .push_mono_safe(&reassigned) - .push(format!(") reassigned to {nickname}.")) - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Assigning TODO failed.".to_string()), + Ok(reassigned) => MessageBuilder::new() + .push(format!("TODO [{}] (", &todo_id)) + .push_mono_safe(&reassigned) + .push(format!(") reassigned to {nickname}.")) + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Assigning TODO failed.".to_string(), }; - respond(ctx, data, true).await; + respond_text(ctx, data, true).await; Ok(()) } @@ -387,18 +393,16 @@ pub async fn rmove( .get_result(&mut ctx.data().db.get().unwrap()); let data = match moved { - Ok(moved) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] (", &todo_id)) - .push_mono_safe(&moved) - .push(format!(") moved to {}.", new_channel.name())) - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Moving TODO failed.".to_string()), + Ok(moved) => MessageBuilder::new() + .push(format!("TODO [{}] (", &todo_id)) + .push_mono_safe(&moved) + .push(format!(") moved to {}.", new_channel.name())) + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Moving TODO failed.".to_string(), }; - respond(ctx, data, false).await; + respond_text(ctx, data, false).await; Ok(()) } @@ -413,7 +417,7 @@ pub async fn edit( use crate::schema::todos::dsl::{channel_id, id, todo, todos}; let data = if content.len() > 1024 { - EmbedData::Text("Content can't have more than 1024 characters.".to_string()) + "Content can't have more than 1024 characters.".to_string() } else { let text = content.replace('@', "@\u{200B}").replace('`', "'"); @@ -425,19 +429,17 @@ pub async fn edit( .get_result(&mut ctx.data().db.get().unwrap()); match edited { - Ok(edited) => EmbedData::Text( - MessageBuilder::new() - .push(format!("TODO [{}] edited to (", &todo_id)) - .push_mono_safe(&edited) - .push(").".to_string()) - .build(), - ), - Err(NotFound) => EmbedData::Text("Not found.".to_string()), - Err(_) => EmbedData::Text("Adding TODO failed.".to_string()), + Ok(edited) => MessageBuilder::new() + .push(format!("TODO [{}] edited to (", &todo_id)) + .push_mono_safe(&edited) + .push(").".to_string()) + .build(), + Err(NotFound) => "Not found.".to_string(), + Err(_) => "Adding TODO failed.".to_string(), } }; - respond(ctx, data, true).await; + respond_text(ctx, data, true).await; Ok(()) } @@ -456,13 +458,6 @@ enum EmbedData { Fields(Vec), } -async fn respond(ctx: Context<'_>, data: EmbedData, ephemeral: bool) { - match data { - EmbedData::Text(text) => respond_text(ctx, text, ephemeral).await, - EmbedData::Fields(fields) => respond_fields(ctx, fields).await, - } -} - async fn respond_text(ctx: Context<'_>, text: String, ephemeral: bool) { let response = ctx .send(|reply| { @@ -476,11 +471,11 @@ async fn respond_text(ctx: Context<'_>, text: String, ephemeral: bool) { debug!("{:?}", e); } } -async fn respond_fields(ctx: Context<'_>, fields: Vec) { +async fn respond_fields(ctx: Context<'_>, fields: Vec, query_data: QueryData) { let ctx_id = ctx.id(); let prev_button_id = format!("{}prev", ctx_id); let next_button_id = format!("{}next", ctx_id); - // let refresh_button_id = format!("{}refresh", ctx_id); + let refresh_button_id = format!("{}refresh", ctx_id); let mut fields = fields; let mut page = 0; @@ -500,11 +495,11 @@ async fn respond_fields(ctx: Context<'_>, fields: Vec) { reply.components(|comp| { comp.create_action_row(|ar| { ar.create_button(|cb| cb.custom_id(&prev_button_id).emoji('◀')) - // .create_button(|cb| { - // cb.custom_id(&refresh_button_id) - // .label("Refresh") - // .style(ButtonStyle::Secondary) - // }) + .create_button(|cb| { + cb.custom_id(&refresh_button_id) + .label("Refresh") + .style(ButtonStyle::Secondary) + }) .create_button(|cb| cb.custom_id(&next_button_id).emoji('▶')) }) }); @@ -532,9 +527,32 @@ async fn respond_fields(ctx: Context<'_>, fields: Vec) { if page >= pages { page = 0; } - } - // else if interaction_id == refresh_button_id {} - else { + } else if interaction_id == refresh_button_id { + let data = get_todos(ctx, &query_data).await; + match data { + EmbedData::Text(text) => { + let response = button + .create_interaction_response(ctx, |ir| { + ir.kind(poise::serenity_prelude::InteractionResponseType::UpdateMessage) + .interaction_response_data(|ird| { + ird.embed(|ce| ce.description(text)) + }) + }) + .await; + + if let Err(e) = response { + debug!("{:?}", e); + } + + continue; + } + EmbedData::Fields(_fields) => { + fields = _fields; + pages = fields.len().div_ceil(DISCORD_EMBED_FIELDS_LIMIT as usize) as u32; + page = 0; + } + } + } else { continue; } @@ -545,7 +563,7 @@ async fn respond_fields(ctx: Context<'_>, fields: Vec) { .create_interaction_response(ctx, |ir| { ir.kind(poise::serenity_prelude::InteractionResponseType::UpdateMessage) .interaction_response_data(|ird| { - ird.embed(|ce| ce.fields(fields).footer(|f| f.text(footer))) + ird.embed(|ce| ce.title("TODOs").fields(fields).footer(|f| f.text(footer))) }) }) .await; From 12801acb0b81efffd1dd04a7cf957277cb1be158 Mon Sep 17 00:00:00 2001 From: Marek 'seqre' Grzelak Date: Tue, 12 Sep 2023 12:37:41 -0500 Subject: [PATCH 3/3] [chore] clippy fixes --- src/commands/todo.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/todo.rs b/src/commands/todo.rs index c11a838..014803f 100644 --- a/src/commands/todo.rs +++ b/src/commands/todo.rs @@ -574,10 +574,10 @@ async fn respond_fields(ctx: Context<'_>, fields: Vec, query_data: Qu } } -fn get_embed_data(fields: &Vec, page: u32) -> Vec<(String, String, bool)> { +fn get_embed_data(fields: &[TodoEntry], page: u32) -> Vec<(String, String, bool)> { let skip = page * DISCORD_EMBED_FIELDS_LIMIT; let new_fields: Vec<(String, String, bool)> = fields - .into_iter() + .iter() .skip(skip.try_into().unwrap()) .map(|entry| { let mut title = format!("[{}]", entry.id); @@ -594,7 +594,7 @@ fn get_embed_data(fields: &Vec, page: u32) -> Vec<(String, String, bo new_fields } -fn get_footer(fields: &Vec, page: u32, pages: u32) -> String { +fn get_footer(fields: &[TodoEntry], page: u32, pages: u32) -> String { let total = fields.iter().filter(|te| !te.completed).count(); let footer = format!("Page {}/{pages}: {total} uncompleted TODOs", page + 1); footer