Implement chat history and multiline messages

This commit is contained in:
Sijmen 2023-11-22 22:51:57 +01:00
parent 63b04208d0
commit 9006f53e4d
Signed by: vijfhoek
GPG key ID: DAF7821E067D9C48
6 changed files with 361 additions and 79 deletions

1
Cargo.lock generated
View file

@ -477,6 +477,7 @@ dependencies = [
name = "cri" name = "cri"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"chrono",
"color-eyre", "color-eyre",
"futures", "futures",
"iced", "iced",

View file

@ -6,6 +6,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
chrono = "0.4.31"
color-eyre = "0.6.2" color-eyre = "0.6.2"
futures = "0.3.29" futures = "0.3.29"
iced = { version = "0.10.0", features = ["tokio"] } iced = { version = "0.10.0", features = ["tokio"] }

View file

@ -8,7 +8,16 @@ use tokio::{
pub async fn connect() -> Result<irc::client::Client> { pub async fn connect() -> Result<irc::client::Client> {
let client = irc::client::Client::new("config.toml").await?; 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) Ok(client)
} }

View file

@ -1,4 +1,12 @@
pub enum IrcMessage { use chrono::{DateTime, Local};
pub struct IrcMessage {
pub detail: MessageDetail,
pub message_id: Option<String>,
pub timestamp: DateTime<Local>,
}
pub enum MessageDetail {
Join { Join {
nickname: String, nickname: String,
}, },

View file

@ -2,7 +2,7 @@ mod irc_handler;
mod irc_message; mod irc_message;
mod message_log; mod message_log;
use crate::{irc_message::IrcMessage, message_log::MessageLog}; use chrono::{DateTime, Local};
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use iced::{ use iced::{
alignment::{Horizontal, Vertical}, alignment::{Horizontal, Vertical},
@ -11,9 +11,11 @@ use iced::{
widget::{column, container, mouse_area, row, text, text_input}, widget::{column, container, mouse_area, row, text, text_input},
Application, Background, Color, Element, Length, Settings, 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 once_cell::sync::Lazy;
use std::cell::RefCell; use std::{cell::RefCell, collections::HashMap};
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
static INPUT_ID: Lazy<text_input::Id> = Lazy::new(text_input::Id::unique); static INPUT_ID: Lazy<text_input::Id> = Lazy::new(text_input::Id::unique);
@ -49,6 +51,7 @@ pub enum UiMessage {
InputChanged(String), InputChanged(String),
InputSubmitted, InputSubmitted,
HandleChannelPress(Option<String>), HandleChannelPress(Option<String>),
None,
} }
struct Cri { struct Cri {
@ -170,23 +173,80 @@ impl Application for Cri {
.unwrap_or(&self.nickname) .unwrap_or(&self.nickname)
.to_string(); .to_string();
let tags_map = message
.tags
.clone()
.unwrap_or_default()
.iter()
.map(|Tag(k, v)| (k.clone(), v.clone()))
.collect::<HashMap<_, _>>();
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 { match &message.command {
IrcCommand::JOIN(chanlist, _, _) => { 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,
&timestamp,
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) => { IrcCommand::PART(chanlist, comment) => {
self.message_log self.message_log.on_part(
.on_part(chanlist, &source_nickname, comment.as_deref()); chanlist,
&source_nickname,
comment.as_deref(),
&timestamp,
message_id,
);
} }
IrcCommand::NICK(new) => { IrcCommand::NICK(new) => {
self.message_log.on_nick(&source_nickname, new); self.message_log
.on_nick(&source_nickname, new, &timestamp, message_id);
} }
IrcCommand::QUIT(comment) => { IrcCommand::QUIT(comment) => {
self.message_log self.message_log.on_quit(
.on_quit(&source_nickname, comment.as_deref()); &source_nickname,
comment.as_deref(),
&timestamp,
message_id,
);
}
IrcCommand::TOPIC(channel, topic) => {
self.message_log.on_topic(channel, topic.clone());
} }
IrcCommand::PRIVMSG(msgtarget, content) IrcCommand::PRIVMSG(msgtarget, content)
@ -197,11 +257,22 @@ impl Application for Cri {
&channel, &channel,
&source_nickname, &source_nickname,
content, content,
message_id,
&timestamp,
); );
} }
IrcCommand::Response(Response::RPL_WELCOME, args) => { IrcCommand::Response(Response::RPL_WELCOME, args) => {
self.nickname = args[0].clone() self.nickname = args[0].clone()
} }
IrcCommand::BATCH(tag, subcommand, params) => {
self.message_log.on_batch(
&self.nickname,
tag,
subcommand.as_ref(),
params.as_ref(),
&timestamp,
);
}
_ => self.message_log.on_other(&message.to_string()), _ => self.message_log.on_other(&message.to_string()),
} }
} }
@ -218,6 +289,7 @@ impl Application for Cri {
self.input_value.clear(); self.input_value.clear();
} }
UiMessage::HandleChannelPress(channel) => self.message_log.set_active(channel), UiMessage::HandleChannelPress(channel) => self.message_log.set_active(channel),
UiMessage::None => (),
} }
iced::Command::none() iced::Command::none()
} }
@ -227,8 +299,11 @@ impl Application for Cri {
"irc message", "irc message",
self.message_rx.take(), self.message_rx.take(),
move |mut receiver| async move { move |mut receiver| async move {
let message = receiver.as_mut().unwrap().recv().await.unwrap(); if let Some(message) = receiver.as_mut().unwrap().recv().await {
(UiMessage::IrcMessageReceived(Box::new(message)), receiver) (UiMessage::IrcMessageReceived(Box::new(message)), receiver)
} else {
(UiMessage::None, receiver)
}
}, },
) )
} }
@ -267,8 +342,8 @@ impl Application for Cri {
.iter() .iter()
.rev() .rev()
.find_map(|m| -> Option<Element<_, _>> { .find_map(|m| -> Option<Element<_, _>> {
match m { match &m.detail {
IrcMessage::Privmsg { nickname, message } => Some( MessageDetail::Privmsg { nickname, message } => Some(
row![ row![
text(format!("{nickname}: ")) text(format!("{nickname}: "))
.style(nickname_color) .style(nickname_color)
@ -277,7 +352,7 @@ impl Application for Cri {
] ]
.into(), .into(),
), ),
IrcMessage::Join { nickname } => Some( MessageDetail::Join { nickname } => Some(
row![ row![
text(nickname).style(nickname_color).size(text_size), text(nickname).style(nickname_color).size(text_size),
text(" joined").style(no_message_color).size(text_size) text(" joined").style(no_message_color).size(text_size)

View file

@ -1,24 +1,38 @@
use crate::irc_message::IrcMessage; use crate::irc_message::{IrcMessage, MessageDetail};
use chrono::{DateTime, Local};
use iced::{ use iced::{
alignment::Horizontal, alignment::Horizontal,
widget::{column, container, scrollable, text, Container}, widget::{column, container, scrollable, text, Container},
Background, Color, Length, Background, Color, Length,
}; };
use irc::proto::BatchSubCommand;
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
#[derive(Default)] #[derive(Default)]
pub struct Channel { pub struct Channel {
pub messages: Vec<IrcMessage>, pub messages: Vec<IrcMessage>,
pub message_ids: HashSet<String>,
pub topic: Option<String>,
pub unread_messages: i32, pub unread_messages: i32,
pub unread_highlights: i32, pub unread_highlights: i32,
pub unread_events: i32, pub unread_events: i32,
pub is_multiline: bool,
pub multiline_privmsgs: Option<Vec<String>>,
pub multiline_timestamp: Option<chrono::DateTime<Local>>,
pub multiline_nickname: Option<String>,
pub multiline_message_id: Option<String>,
} }
pub struct MessageLog { pub struct MessageLog {
channels: HashMap<Option<String>, Channel>, channels: HashMap<Option<String>, Channel>,
pub active_channel: Option<String>, pub active_channel: Option<String>,
/// Maps multiline batch tags to channels
pub batch_channels: HashMap<String, (BatchSubCommand, String)>,
} }
impl<'a> MessageLog { impl<'a> MessageLog {
@ -28,9 +42,14 @@ impl<'a> MessageLog {
Self { Self {
channels, channels,
active_channel: None, 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<String>, &'a Channel)> { pub fn get_all(&'a self) -> Vec<(&'a Option<String>, &'a Channel)> {
let mut log: Vec<_> = self.channels.iter().collect(); let mut log: Vec<_> = self.channels.iter().collect();
log.sort_unstable_by_key(|(name, _)| name.as_deref()); log.sort_unstable_by_key(|(name, _)| name.as_deref());
@ -45,12 +64,26 @@ impl<'a> MessageLog {
self.channels.entry(channel_name).or_default() 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<Local>,
message_id: Option<&str>,
) {
let is_active = self.active_channel.as_deref() != Some(channel_name); let is_active = self.active_channel.as_deref() != Some(channel_name);
let channel = self.get_mut(Some(channel_name.to_string())); 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 { 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<Local>,
message_id: Option<&str>,
) {
let is_active = self.active_channel.as_deref() != Some(channel_name); let is_active = self.active_channel.as_deref() != Some(channel_name);
let channel = self.get_mut(Some(channel_name.to_string())); let channel = self.get_mut(Some(channel_name.to_string()));
channel.messages.push(IrcMessage::Part {
nickname: nickname.to_string(), if message_id.is_some() && !channel.message_ids.insert(message_id.unwrap().to_owned()) {
reason: comment.map(str::to_string), 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 { 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<Local>,
message_id: Option<&str>,
) {
// TODO increment event counter for each relevant channel // 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 // TODO only show in relevant channels
log.messages.push(IrcMessage::Nick { channel.messages.push(IrcMessage {
old: old.to_string(), detail: MessageDetail::Nick {
new: new.to_string(), 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<Local>,
message_id: Option<&str>,
) {
// TODO increment event counter for each relevant channel // 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 // TODO only show in relevant channels
log.messages.push(IrcMessage::Quit { channel.messages.push(IrcMessage {
nickname: nickname.to_string(), detail: MessageDetail::Quit {
reason: reason.map(str::to_string), 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<String>>,
timestamp: &DateTime<Local>,
) {
if let Some(tag) = tag.strip_prefix('+') {
if subcommand == Some(&BatchSubCommand::CUSTOM("DRAFT/MULTILINE".to_string())) {
let channel_name = &params.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 = &params.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,
&timestamp,
);
} else if subcommand == BatchSubCommand::CUSTOM("CHATHISTORY".to_string()) {
channel.messages.sort_by_key(|m| m.timestamp);
}
}
}
}
pub fn on_privmsg( pub fn on_privmsg(
&mut self, &mut self,
current_nickname: &str, current_nickname: &str,
channel_name: &str, channel_name: &str,
nickname: &str, nickname: &str,
message: &str, message: &str,
message_id: Option<&str>,
timestamp: &DateTime<Local>,
) { ) {
let is_active = self.active_channel.as_deref() == Some(channel_name); let is_active = self.active_channel.as_deref() == Some(channel_name);
let channel = self.get_mut(Some(channel_name.to_string())); let channel = self.get_mut(Some(channel_name.to_string()));
channel.messages.push(IrcMessage::Privmsg {
nickname: nickname.to_string(), if channel.is_multiline {
message: message.to_string(), 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 { if !is_active {
let highlight_regex = let highlight_regex =
@ -121,8 +274,12 @@ impl<'a> MessageLog {
} }
pub fn on_other(&mut self, message: &str) { pub fn on_other(&mut self, message: &str) {
self.get_mut(None).messages.push(IrcMessage::Other { self.get_mut(None).messages.push(IrcMessage {
message: message.trim().to_string(), detail: MessageDetail::Other {
message: message.trim().to_string(),
},
message_id: None,
timestamp: chrono::Local::now(),
}) })
} }
@ -159,25 +316,39 @@ impl<'a> MessageLog {
..Default::default() ..Default::default()
}; };
let messages = self let channel = self.get(&self.active_channel).unwrap();
.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 .messages
.iter() .iter()
.flat_map(|message| -> Option<iced::Element<_>> { .flat_map(|irc_message| -> Option<iced::Element<_>> {
match message { let timestamp = irc_message.timestamp.format("%H:%M:%S");
IrcMessage::Join { nickname } => Some(
match &irc_message.detail {
MessageDetail::Join { nickname } => Some(
container( container(
container(text(format!("{nickname} joined the channel"))) container(
.style(move |_: &_| event_appearance) text(format!("{nickname} joined the channel"))
.padding([3, 10]), .horizontal_alignment(Horizontal::Center),
)
.style(move |_: &_| event_appearance)
.padding([3, 10]),
) )
.width(Length::Fill) .width(Length::Fill)
.center_x() .center_x()
.padding([3, 0]) .padding([3, 0])
.into(), .into(),
), ),
IrcMessage::Part { nickname, reason } => { MessageDetail::Part { nickname, reason } => {
let reason = match reason { let reason = match reason {
Some(reason) => format!(" ({reason})"), Some(reason) => format!(" ({reason})"),
None => String::new(), None => String::new(),
@ -194,7 +365,7 @@ impl<'a> MessageLog {
.into(), .into(),
) )
} }
IrcMessage::Nick { old, new } => Some( MessageDetail::Nick { old, new } => Some(
container( container(
container(text(format!("{old} changed their nickname to {new}"))) container(text(format!("{old} changed their nickname to {new}")))
.style(move |_: &_| event_appearance) .style(move |_: &_| event_appearance)
@ -205,7 +376,7 @@ impl<'a> MessageLog {
.padding([3, 0]) .padding([3, 0])
.into(), .into(),
), ),
IrcMessage::Quit { nickname, reason } => { MessageDetail::Quit { nickname, reason } => {
let reason = match reason { let reason = match reason {
Some(reason) => format!(" ({reason})"), Some(reason) => format!(" ({reason})"),
None => String::new(), None => String::new(),
@ -223,7 +394,7 @@ impl<'a> MessageLog {
.into(), .into(),
) )
} }
IrcMessage::Privmsg { nickname, message } => { MessageDetail::Privmsg { nickname, message } => {
let is_self = nickname == current_nickname; let is_self = nickname == current_nickname;
let mut elements = Vec::new(); let mut elements = Vec::new();
@ -231,44 +402,61 @@ impl<'a> MessageLog {
elements.push(text(nickname).style(dark_red).into()) elements.push(text(nickname).style(dark_red).into())
} }
elements.push(text(message).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( Some(
container( container(
container(column(elements)) container(column(elements))
.style(move |_: &_| { .style(move |_: &_| appearance)
if is_self {
own_message_appearance
} else {
message_appearance
}
})
.padding([4, 10]), .padding([4, 10]),
) )
.width(Length::Fill) .width(Length::Fill)
.align_x(if is_self { .align_x(alignment)
Horizontal::Right
} else {
Horizontal::Left
})
.padding([4, 8]) .padding([4, 8])
.into(), .into(),
) )
} }
IrcMessage::Other { message } => Some(text(message).into()), MessageDetail::Other { message } => Some(text(message).into()),
} }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
container( container(column![
scrollable(column(messages)) header,
.height(Length::Fill) container(
.width(Length::Fill), 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) .height(Length::Fill)
.width(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<String>) {
}) self.get_mut(Some(channel.to_string())).topic = topic;
} }
} }