diff --git a/Cargo.lock b/Cargo.lock index 806be8d..19fabbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -477,6 +477,7 @@ dependencies = [ name = "cri" version = "0.1.0" dependencies = [ + "chrono", "color-eyre", "futures", "iced", diff --git a/Cargo.toml b/Cargo.toml index 636210a..80ab227 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +chrono = "0.4.31" color-eyre = "0.6.2" futures = "0.3.29" iced = { version = "0.10.0", features = ["tokio"] } diff --git a/src/irc_handler.rs b/src/irc_handler.rs index d23902b..417d40b 100644 --- a/src/irc_handler.rs +++ b/src/irc_handler.rs @@ -8,7 +8,16 @@ use tokio::{ pub async fn connect() -> Result { let client = irc::client::Client::new("config.toml").await?; - client.send_cap_req(&[Capability::EchoMessage, Capability::ServerTime])?; + client.send_cap_req(&[ + Capability::AccountTag, + Capability::Batch, + Capability::EchoMessage, + Capability::ServerTime, + Capability::Custom("message-tags"), + Capability::Custom("draft/chathistory"), + Capability::Custom("draft/event-playback"), + Capability::Custom("draft/multiline"), + ])?; Ok(client) } diff --git a/src/irc_message.rs b/src/irc_message.rs index c4cbf1e..bdb0c3d 100644 --- a/src/irc_message.rs +++ b/src/irc_message.rs @@ -1,4 +1,12 @@ -pub enum IrcMessage { +use chrono::{DateTime, Local}; + +pub struct IrcMessage { + pub detail: MessageDetail, + pub message_id: Option, + pub timestamp: DateTime, +} + +pub enum MessageDetail { Join { nickname: String, }, diff --git a/src/main.rs b/src/main.rs index 576780a..04dc7b5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ mod irc_handler; mod irc_message; mod message_log; -use crate::{irc_message::IrcMessage, message_log::MessageLog}; +use chrono::{DateTime, Local}; use color_eyre::eyre::Result; use iced::{ alignment::{Horizontal, Vertical}, @@ -11,9 +11,11 @@ use iced::{ widget::{column, container, mouse_area, row, text, text_input}, Application, Background, Color, Element, Length, Settings, }; -use irc::proto::{Command as IrcCommand, Response}; +use irc::proto::{message::Tag, Command as IrcCommand, Response}; +use irc_message::MessageDetail; +use message_log::MessageLog; use once_cell::sync::Lazy; -use std::cell::RefCell; +use std::{cell::RefCell, collections::HashMap}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; static INPUT_ID: Lazy = Lazy::new(text_input::Id::unique); @@ -49,6 +51,7 @@ pub enum UiMessage { InputChanged(String), InputSubmitted, HandleChannelPress(Option), + None, } struct Cri { @@ -170,23 +173,80 @@ impl Application for Cri { .unwrap_or(&self.nickname) .to_string(); + let tags_map = message + .tags + .clone() + .unwrap_or_default() + .iter() + .map(|Tag(k, v)| (k.clone(), v.clone())) + .collect::>(); + + let timestamp = tags_map + .get(&"time".to_string()) + .cloned() + .flatten() + .and_then(|time| DateTime::parse_from_rfc3339(&time).ok()) + .map(DateTime::into) + .unwrap_or_else(Local::now); + + let message_id = tags_map.get(&"msgid".to_string()).cloned().flatten(); + let message_id = message_id.as_deref(); + match &message.command { IrcCommand::JOIN(chanlist, _, _) => { - self.message_log.on_join(chanlist, &source_nickname); + let already_joined = self.message_log.has_channel(chanlist); + self.message_log.on_join( + chanlist, + &source_nickname, + ×tamp, + message_id, + ); + + if !already_joined && source_nickname == self.nickname { + self.input_tx + .borrow() + .send( + IrcCommand::Raw( + "CHATHISTORY".to_string(), + vec![ + "LATEST".to_string(), + chanlist.clone(), + "*".to_string(), + 100.to_string(), + ], + ) + .into(), + ) + .unwrap(); + } } IrcCommand::PART(chanlist, comment) => { - self.message_log - .on_part(chanlist, &source_nickname, comment.as_deref()); + self.message_log.on_part( + chanlist, + &source_nickname, + comment.as_deref(), + ×tamp, + message_id, + ); } IrcCommand::NICK(new) => { - self.message_log.on_nick(&source_nickname, new); + self.message_log + .on_nick(&source_nickname, new, ×tamp, message_id); } IrcCommand::QUIT(comment) => { - self.message_log - .on_quit(&source_nickname, comment.as_deref()); + self.message_log.on_quit( + &source_nickname, + comment.as_deref(), + ×tamp, + message_id, + ); + } + + IrcCommand::TOPIC(channel, topic) => { + self.message_log.on_topic(channel, topic.clone()); } IrcCommand::PRIVMSG(msgtarget, content) @@ -197,11 +257,22 @@ impl Application for Cri { &channel, &source_nickname, content, + message_id, + ×tamp, ); } IrcCommand::Response(Response::RPL_WELCOME, args) => { self.nickname = args[0].clone() } + IrcCommand::BATCH(tag, subcommand, params) => { + self.message_log.on_batch( + &self.nickname, + tag, + subcommand.as_ref(), + params.as_ref(), + ×tamp, + ); + } _ => self.message_log.on_other(&message.to_string()), } } @@ -218,6 +289,7 @@ impl Application for Cri { self.input_value.clear(); } UiMessage::HandleChannelPress(channel) => self.message_log.set_active(channel), + UiMessage::None => (), } iced::Command::none() } @@ -227,8 +299,11 @@ impl Application for Cri { "irc message", self.message_rx.take(), move |mut receiver| async move { - let message = receiver.as_mut().unwrap().recv().await.unwrap(); - (UiMessage::IrcMessageReceived(Box::new(message)), receiver) + if let Some(message) = receiver.as_mut().unwrap().recv().await { + (UiMessage::IrcMessageReceived(Box::new(message)), receiver) + } else { + (UiMessage::None, receiver) + } }, ) } @@ -267,8 +342,8 @@ impl Application for Cri { .iter() .rev() .find_map(|m| -> Option> { - match m { - IrcMessage::Privmsg { nickname, message } => Some( + match &m.detail { + MessageDetail::Privmsg { nickname, message } => Some( row![ text(format!("{nickname}: ")) .style(nickname_color) @@ -277,7 +352,7 @@ impl Application for Cri { ] .into(), ), - IrcMessage::Join { nickname } => Some( + MessageDetail::Join { nickname } => Some( row![ text(nickname).style(nickname_color).size(text_size), text(" joined").style(no_message_color).size(text_size) diff --git a/src/message_log.rs b/src/message_log.rs index 07f59ba..d275e45 100644 --- a/src/message_log.rs +++ b/src/message_log.rs @@ -1,24 +1,38 @@ -use crate::irc_message::IrcMessage; +use crate::irc_message::{IrcMessage, MessageDetail}; +use chrono::{DateTime, Local}; use iced::{ alignment::Horizontal, widget::{column, container, scrollable, text, Container}, Background, Color, Length, }; +use irc::proto::BatchSubCommand; use regex::Regex; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; #[derive(Default)] pub struct Channel { pub messages: Vec, + pub message_ids: HashSet, + + pub topic: Option, pub unread_messages: i32, pub unread_highlights: i32, pub unread_events: i32, + + pub is_multiline: bool, + pub multiline_privmsgs: Option>, + pub multiline_timestamp: Option>, + pub multiline_nickname: Option, + pub multiline_message_id: Option, } pub struct MessageLog { channels: HashMap, Channel>, pub active_channel: Option, + + /// Maps multiline batch tags to channels + pub batch_channels: HashMap, } impl<'a> MessageLog { @@ -28,9 +42,14 @@ impl<'a> MessageLog { Self { channels, active_channel: None, + batch_channels: HashMap::new(), } } + pub fn has_channel(&self, channel: &str) -> bool { + self.channels.contains_key(&Some(channel.to_string())) + } + pub fn get_all(&'a self) -> Vec<(&'a Option, &'a Channel)> { let mut log: Vec<_> = self.channels.iter().collect(); log.sort_unstable_by_key(|(name, _)| name.as_deref()); @@ -45,12 +64,26 @@ impl<'a> MessageLog { self.channels.entry(channel_name).or_default() } - pub fn on_join(&mut self, channel_name: &str, nickname: &str) { + pub fn on_join( + &mut self, + channel_name: &str, + nickname: &str, + timestamp: &DateTime, + message_id: Option<&str>, + ) { let is_active = self.active_channel.as_deref() != Some(channel_name); - let channel = self.get_mut(Some(channel_name.to_string())); - channel.messages.push(IrcMessage::Join { - nickname: nickname.to_string(), + + if message_id.is_some() && !channel.message_ids.insert(message_id.unwrap().to_owned()) { + return; + } + + channel.messages.push(IrcMessage { + detail: MessageDetail::Join { + nickname: nickname.to_string(), + }, + message_id: message_id.map(str::to_string), + timestamp: *timestamp, }); if is_active { @@ -58,13 +91,28 @@ impl<'a> MessageLog { } } - pub fn on_part(&mut self, channel_name: &str, nickname: &str, comment: Option<&str>) { + pub fn on_part( + &mut self, + channel_name: &str, + nickname: &str, + comment: Option<&str>, + timestamp: &DateTime, + message_id: Option<&str>, + ) { let is_active = self.active_channel.as_deref() != Some(channel_name); - let channel = self.get_mut(Some(channel_name.to_string())); - channel.messages.push(IrcMessage::Part { - nickname: nickname.to_string(), - reason: comment.map(str::to_string), + + if message_id.is_some() && !channel.message_ids.insert(message_id.unwrap().to_owned()) { + return; + } + + channel.messages.push(IrcMessage { + detail: MessageDetail::Part { + nickname: nickname.to_string(), + reason: comment.map(str::to_string), + }, + message_id: message_id.map(str::to_string), + timestamp: *timestamp, }); if is_active { @@ -72,42 +120,147 @@ impl<'a> MessageLog { } } - pub fn on_nick(&mut self, old: &str, new: &str) { + pub fn on_nick( + &mut self, + old: &str, + new: &str, + timestamp: &DateTime, + message_id: Option<&str>, + ) { // TODO increment event counter for each relevant channel - for log in self.channels.values_mut() { + for channel in self.channels.values_mut() { + if message_id.is_some() && !channel.message_ids.insert(message_id.unwrap().to_owned()) { + continue; + } + // TODO only show in relevant channels - log.messages.push(IrcMessage::Nick { - old: old.to_string(), - new: new.to_string(), + channel.messages.push(IrcMessage { + detail: MessageDetail::Nick { + old: old.to_string(), + new: new.to_string(), + }, + message_id: message_id.map(str::to_string), + timestamp: *timestamp, }); } } - pub fn on_quit(&mut self, nickname: &str, reason: Option<&str>) { + pub fn on_quit( + &mut self, + nickname: &str, + reason: Option<&str>, + timestamp: &DateTime, + message_id: Option<&str>, + ) { // TODO increment event counter for each relevant channel - for log in self.channels.values_mut() { + for channel in self.channels.values_mut() { + if message_id.is_some() && !channel.message_ids.insert(message_id.unwrap().to_owned()) { + continue; + } + // TODO only show in relevant channels - log.messages.push(IrcMessage::Quit { - nickname: nickname.to_string(), - reason: reason.map(str::to_string), + channel.messages.push(IrcMessage { + detail: MessageDetail::Quit { + nickname: nickname.to_string(), + reason: reason.map(str::to_string), + }, + message_id: message_id.map(str::to_string), + timestamp: *timestamp, }) } } + pub fn on_batch( + &mut self, + current_nickname: &str, + tag: &str, + subcommand: Option<&BatchSubCommand>, + params: Option<&Vec>, + timestamp: &DateTime, + ) { + if let Some(tag) = tag.strip_prefix('+') { + if subcommand == Some(&BatchSubCommand::CUSTOM("DRAFT/MULTILINE".to_string())) { + let channel_name = ¶ms.unwrap()[0]; + self.batch_channels.insert( + tag.to_string(), + (subcommand.unwrap().clone(), channel_name.clone()), + ); + + let channel = self.get_mut(Some(channel_name.clone())); + + channel.is_multiline = true; + channel.multiline_privmsgs = Some(Vec::new()); + channel.multiline_timestamp = Some(*timestamp); + } else if subcommand == Some(&BatchSubCommand::CUSTOM("CHATHISTORY".to_string())) { + let channel_name = ¶ms.unwrap()[0]; + self.batch_channels.insert( + tag.to_string(), + (subcommand.unwrap().clone(), channel_name.clone()), + ); + } + } else if let Some(tag) = tag.strip_prefix('-') { + if let Some((subcommand, channel_name)) = self.batch_channels.remove(tag) { + let channel = self.get_mut(Some(channel_name.clone())); + + if subcommand == BatchSubCommand::CUSTOM("DRAFT/MULTILINE".to_string()) { + channel.is_multiline = false; + + let nickname = channel.multiline_nickname.clone().unwrap(); + let message = channel.multiline_privmsgs.as_ref().unwrap().join("\n"); + let timestamp = channel.multiline_timestamp.unwrap(); + + self.on_privmsg( + current_nickname, + &channel_name, + &nickname, + &message, + None, + ×tamp, + ); + } else if subcommand == BatchSubCommand::CUSTOM("CHATHISTORY".to_string()) { + channel.messages.sort_by_key(|m| m.timestamp); + } + } + } + } + pub fn on_privmsg( &mut self, current_nickname: &str, channel_name: &str, nickname: &str, message: &str, + message_id: Option<&str>, + timestamp: &DateTime, ) { let is_active = self.active_channel.as_deref() == Some(channel_name); - let channel = self.get_mut(Some(channel_name.to_string())); - channel.messages.push(IrcMessage::Privmsg { - nickname: nickname.to_string(), - message: message.to_string(), - }); + + if channel.is_multiline { + channel.multiline_nickname = Some(nickname.to_string()); + channel + .multiline_privmsgs + .as_mut() + .unwrap() + .push(message.to_string()); + + if let Some(message_id) = message_id { + channel.multiline_message_id = Some(message_id.to_owned()); + } + + return; + } + + if message_id.is_none() || channel.message_ids.insert(message_id.unwrap().to_owned()) { + channel.messages.push(IrcMessage { + detail: MessageDetail::Privmsg { + nickname: nickname.to_string(), + message: message.to_string(), + }, + message_id: message_id.map(str::to_string), + timestamp: *timestamp, + }); + } if !is_active { let highlight_regex = @@ -121,8 +274,12 @@ impl<'a> MessageLog { } pub fn on_other(&mut self, message: &str) { - self.get_mut(None).messages.push(IrcMessage::Other { - message: message.trim().to_string(), + self.get_mut(None).messages.push(IrcMessage { + detail: MessageDetail::Other { + message: message.trim().to_string(), + }, + message_id: None, + timestamp: chrono::Local::now(), }) } @@ -159,25 +316,39 @@ impl<'a> MessageLog { ..Default::default() }; - let messages = self - .get(&self.active_channel) - .unwrap() + let channel = self.get(&self.active_channel).unwrap(); + + let header = container(column![ + text( + self.active_channel + .clone() + .unwrap_or("Server messages".to_string()) + ), + text(channel.topic.clone().unwrap_or_default()) + ]); + + let messages = channel .messages .iter() - .flat_map(|message| -> Option> { - match message { - IrcMessage::Join { nickname } => Some( + .flat_map(|irc_message| -> Option> { + let timestamp = irc_message.timestamp.format("%H:%M:%S"); + + match &irc_message.detail { + MessageDetail::Join { nickname } => Some( container( - container(text(format!("{nickname} joined the channel"))) - .style(move |_: &_| event_appearance) - .padding([3, 10]), + container( + text(format!("{nickname} joined the channel")) + .horizontal_alignment(Horizontal::Center), + ) + .style(move |_: &_| event_appearance) + .padding([3, 10]), ) .width(Length::Fill) .center_x() .padding([3, 0]) .into(), ), - IrcMessage::Part { nickname, reason } => { + MessageDetail::Part { nickname, reason } => { let reason = match reason { Some(reason) => format!(" ({reason})"), None => String::new(), @@ -194,7 +365,7 @@ impl<'a> MessageLog { .into(), ) } - IrcMessage::Nick { old, new } => Some( + MessageDetail::Nick { old, new } => Some( container( container(text(format!("{old} changed their nickname to {new}"))) .style(move |_: &_| event_appearance) @@ -205,7 +376,7 @@ impl<'a> MessageLog { .padding([3, 0]) .into(), ), - IrcMessage::Quit { nickname, reason } => { + MessageDetail::Quit { nickname, reason } => { let reason = match reason { Some(reason) => format!(" ({reason})"), None => String::new(), @@ -223,7 +394,7 @@ impl<'a> MessageLog { .into(), ) } - IrcMessage::Privmsg { nickname, message } => { + MessageDetail::Privmsg { nickname, message } => { let is_self = nickname == current_nickname; let mut elements = Vec::new(); @@ -231,44 +402,61 @@ impl<'a> MessageLog { elements.push(text(nickname).style(dark_red).into()) } elements.push(text(message).into()); + elements.push( + text(timestamp) + .style(dark_grey) + .horizontal_alignment(Horizontal::Right) + .into(), + ); + + let appearance = if is_self { + own_message_appearance + } else { + message_appearance + }; + + let alignment = if is_self { + Horizontal::Right + } else { + Horizontal::Left + }; Some( container( container(column(elements)) - .style(move |_: &_| { - if is_self { - own_message_appearance - } else { - message_appearance - } - }) + .style(move |_: &_| appearance) .padding([4, 10]), ) .width(Length::Fill) - .align_x(if is_self { - Horizontal::Right - } else { - Horizontal::Left - }) + .align_x(alignment) .padding([4, 8]) .into(), ) } - IrcMessage::Other { message } => Some(text(message).into()), + MessageDetail::Other { message } => Some(text(message).into()), } }) .collect::>(); - container( - scrollable(column(messages)) - .height(Length::Fill) - .width(Length::Fill), - ) + container(column![ + header, + container( + scrollable(column(messages)) + .height(Length::Fill) + .width(Length::Fill), + ) + .height(Length::Fill) + .width(Length::Fill) + .style(move |_: &_| container::Appearance { + background: Some(Background::Color(lighter_grey)), + ..Default::default() + }) + ]) .height(Length::Fill) .width(Length::Fill) - .style(move |_: &_| container::Appearance { - background: Some(Background::Color(lighter_grey)), - ..Default::default() - }) + } + + pub fn on_topic(&mut self, channel: &str, topic: Option) { + self.get_mut(Some(channel.to_string())).topic = topic; } }