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..014803f 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, @@ -136,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( @@ -144,25 +151,39 @@ pub async fn list( #[flag] completed: bool, #[description = "Show only TODOs assigned to"] todo_assignee: Option, - #[description = "Page to show"] page: 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); @@ -176,16 +197,12 @@ 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()), Err(_) => EmbedData::Text("Listing TODOs failed.".to_string()), - }; - - respond(ctx, data, false).await; - - Ok(()) + } } /// Add TODO entry @@ -198,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()); @@ -222,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(()) } @@ -251,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(()) } @@ -282,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(()) } @@ -311,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(()) } @@ -350,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(()) } @@ -386,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(()) } @@ -412,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('`', "'"); @@ -424,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(()) } @@ -449,55 +452,150 @@ 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) { +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, 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); -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(); + }); - builder - .title("TODOs") - .fields(new_fields) - .footer(|f| f.text(footer)) + 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 { + 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; + } + + let footer = get_footer(&fields, page, pages); + let fields = get_embed_data(&fields, page); + + let response = button + .create_interaction_response(ctx, |ir| { + ir.kind(poise::serenity_prelude::InteractionResponseType::UpdateMessage) + .interaction_response_data(|ird| { + ird.embed(|ce| ce.title("TODOs").fields(fields).footer(|f| f.text(footer))) + }) + }) + .await; + + if let Err(e) = response { + debug!("{:?}", e); } } } + +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 + .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: &[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 +}