Compare commits
3 commits
603f4c96e2
...
b0dcb6b170
Author | SHA1 | Date | |
---|---|---|---|
b0dcb6b170 | |||
8d2e832671 | |||
7fd71c8f07 |
6 changed files with 492 additions and 460 deletions
464
src/cri.rs
Normal file
464
src/cri.rs
Normal file
|
@ -0,0 +1,464 @@
|
||||||
|
use std::{cell::RefCell, collections::HashMap};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Local};
|
||||||
|
use iced::{
|
||||||
|
alignment::{Horizontal, Vertical},
|
||||||
|
executor,
|
||||||
|
widget::{column, container, mouse_area, row, text, text_input},
|
||||||
|
Application, Background, Color, Element, Length, Theme,
|
||||||
|
};
|
||||||
|
use irc::proto::{message::Tag, CapSubCommand, Capability, Command as IrcCommand, Response};
|
||||||
|
use once_cell::sync::Lazy;
|
||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
cri_flags::CriFlags,
|
||||||
|
irc_message::MessageDetail,
|
||||||
|
message_log::MessageLog,
|
||||||
|
ui_message::{self, UiMessage},
|
||||||
|
};
|
||||||
|
|
||||||
|
static INPUT_ID: Lazy<text_input::Id> = Lazy::new(text_input::Id::unique);
|
||||||
|
|
||||||
|
pub struct Cri {
|
||||||
|
message_rx: RefCell<Option<UnboundedReceiver<irc::proto::Message>>>,
|
||||||
|
input_tx: RefCell<UnboundedSender<irc::proto::Message>>,
|
||||||
|
message_log: MessageLog,
|
||||||
|
input_value: String,
|
||||||
|
nickname: String,
|
||||||
|
capabilities: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cri {
|
||||||
|
fn send_message(&mut self, input_value: &str) {
|
||||||
|
let active_channel = self.message_log.active_channel.clone();
|
||||||
|
if let Some(active_channel) = &active_channel {
|
||||||
|
let command = IrcCommand::PRIVMSG(active_channel.into(), input_value.into());
|
||||||
|
let message: irc::proto::Message = command.into();
|
||||||
|
self.input_tx.borrow().send(message.clone()).unwrap();
|
||||||
|
|
||||||
|
let echo_message = self
|
||||||
|
.capabilities
|
||||||
|
.contains(&Capability::EchoMessage.as_ref().into());
|
||||||
|
if !echo_message {
|
||||||
|
self.message_log.on_privmsg(
|
||||||
|
&self.nickname,
|
||||||
|
active_channel,
|
||||||
|
&self.nickname,
|
||||||
|
input_value,
|
||||||
|
None,
|
||||||
|
&Local::now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_command(&mut self, command: &str) {
|
||||||
|
let mut tokens = command.split_whitespace();
|
||||||
|
|
||||||
|
let command = tokens.next().unwrap();
|
||||||
|
match command {
|
||||||
|
"/join" | "/j" => self.handle_join_command(&mut tokens),
|
||||||
|
"/part" | "/p" => self.handle_part_command(&mut tokens),
|
||||||
|
"/query" | "/q" => self.handle_query_command(tokens),
|
||||||
|
"/list" => self.handle_list_command(),
|
||||||
|
_ => (),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_part_command(&mut self, tokens: &mut std::str::SplitWhitespace<'_>) {
|
||||||
|
let channel = tokens
|
||||||
|
.next()
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| self.message_log.active_channel.clone());
|
||||||
|
if channel.is_none() {
|
||||||
|
// TODO error message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = channel.unwrap();
|
||||||
|
if !channel.starts_with('#') {
|
||||||
|
// TODO error message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reason = tokens.collect::<Vec<_>>().join(" ");
|
||||||
|
let reason = if reason.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(reason)
|
||||||
|
};
|
||||||
|
|
||||||
|
self.input_tx
|
||||||
|
.borrow()
|
||||||
|
.send(IrcCommand::PART(channel, reason).into())
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_join_command(&mut self, tokens: &mut std::str::SplitWhitespace<'_>) {
|
||||||
|
let channel = tokens
|
||||||
|
.next()
|
||||||
|
.map(String::from)
|
||||||
|
.or_else(|| self.message_log.active_channel.clone());
|
||||||
|
if channel.is_none() {
|
||||||
|
// TODO error message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = channel.unwrap();
|
||||||
|
if !channel.starts_with('#') {
|
||||||
|
// TODO error message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.input_tx
|
||||||
|
.borrow()
|
||||||
|
.send(IrcCommand::JOIN(channel.clone(), tokens.next().map(String::from), None).into())
|
||||||
|
.unwrap();
|
||||||
|
self.message_log.set_active(Some(channel));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_query_command(&mut self, mut tokens: std::str::SplitWhitespace<'_>) {
|
||||||
|
self.message_log
|
||||||
|
.set_active(Some(tokens.next().unwrap().into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_list_command(&self) {
|
||||||
|
self.input_tx
|
||||||
|
.borrow()
|
||||||
|
.send(IrcCommand::LIST(None, None).into())
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_join(
|
||||||
|
&mut self,
|
||||||
|
chanlist: &str,
|
||||||
|
source_nickname: &String,
|
||||||
|
timestamp: DateTime<Local>,
|
||||||
|
message_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
let already_joined = self.message_log.has_channel(dbg!(chanlist));
|
||||||
|
self.message_log
|
||||||
|
.on_join(chanlist, source_nickname, ×tamp, message_id);
|
||||||
|
|
||||||
|
if !already_joined
|
||||||
|
&& source_nickname == &self.nickname
|
||||||
|
&& self.capabilities.contains(&"draft/chathistory".into())
|
||||||
|
{
|
||||||
|
self.input_tx
|
||||||
|
.borrow()
|
||||||
|
.send(
|
||||||
|
IrcCommand::Raw(
|
||||||
|
"CHATHISTORY".into(),
|
||||||
|
vec![
|
||||||
|
"LATEST".into(),
|
||||||
|
chanlist.into(),
|
||||||
|
"*".into(),
|
||||||
|
100.to_string(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Application for Cri {
|
||||||
|
type Executor = executor::Default;
|
||||||
|
type Message = UiMessage;
|
||||||
|
type Theme = Theme;
|
||||||
|
type Flags = CriFlags;
|
||||||
|
|
||||||
|
fn new(flags: Self::Flags) -> (Self, iced::Command<Self::Message>) {
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
message_rx: RefCell::new(Some(flags.message_rx)),
|
||||||
|
input_tx: RefCell::new(flags.input_tx),
|
||||||
|
message_log: MessageLog::new(),
|
||||||
|
input_value: String::new(),
|
||||||
|
nickname: "cri".into(), // TODO take default value from config
|
||||||
|
capabilities: Vec::new(),
|
||||||
|
},
|
||||||
|
iced::Command::none(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> String {
|
||||||
|
"cri".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, message: Self::Message) -> iced::Command<Self::Message> {
|
||||||
|
match message {
|
||||||
|
ui_message::UiMessage::IrcMessageReceived(message) => {
|
||||||
|
// TODO use actual nickname
|
||||||
|
let source_nickname: String =
|
||||||
|
message.source_nickname().unwrap_or(&self.nickname).into();
|
||||||
|
|
||||||
|
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_owned())
|
||||||
|
.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").cloned().flatten();
|
||||||
|
let message_id = message_id.as_deref();
|
||||||
|
|
||||||
|
match &message.command {
|
||||||
|
IrcCommand::CAP(_, CapSubCommand::ACK, capability, _) => {
|
||||||
|
let capability = capability.as_ref().unwrap();
|
||||||
|
self.capabilities.push(capability.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
IrcCommand::JOIN(chanlist, _, _) => {
|
||||||
|
self.on_join(chanlist, &source_nickname, timestamp, message_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
IrcCommand::PART(chanlist, comment) => {
|
||||||
|
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, ×tamp, message_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
IrcCommand::QUIT(comment) => {
|
||||||
|
self.message_log.on_quit(
|
||||||
|
&source_nickname,
|
||||||
|
comment.as_deref(),
|
||||||
|
×tamp,
|
||||||
|
message_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IrcCommand::PRIVMSG(msgtarget, content)
|
||||||
|
| IrcCommand::NOTICE(msgtarget, content) => {
|
||||||
|
let channel: String = message.response_target().unwrap_or(msgtarget).into();
|
||||||
|
self.message_log.on_privmsg(
|
||||||
|
&self.nickname,
|
||||||
|
&channel,
|
||||||
|
&source_nickname,
|
||||||
|
content,
|
||||||
|
message_id,
|
||||||
|
×tamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
IrcCommand::Response(Response::RPL_WELCOME, args) => {
|
||||||
|
self.nickname = args[0].clone()
|
||||||
|
}
|
||||||
|
IrcCommand::Response(Response::RPL_NAMREPLY, args) => {
|
||||||
|
let channel = &args[2];
|
||||||
|
let names: Vec<_> = args[3].split_ascii_whitespace().collect();
|
||||||
|
self.message_log.on_names_reply(channel, names);
|
||||||
|
}
|
||||||
|
IrcCommand::Response(Response::RPL_TOPIC, args) => {
|
||||||
|
let channel = &args[1];
|
||||||
|
let topic = &args[2];
|
||||||
|
self.message_log.on_topic(channel, topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui_message::UiMessage::InputChanged(text) => self.input_value = text,
|
||||||
|
ui_message::UiMessage::InputSubmitted => {
|
||||||
|
if self.input_value.starts_with("//") {
|
||||||
|
self.send_message(&self.input_value.clone()[1..])
|
||||||
|
} else if self.input_value.starts_with('/') {
|
||||||
|
self.send_command(&self.input_value.clone())
|
||||||
|
} else {
|
||||||
|
self.send_message(&self.input_value.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.input_value.clear();
|
||||||
|
}
|
||||||
|
ui_message::UiMessage::HandleChannelPress(channel) => {
|
||||||
|
self.message_log.set_active(channel)
|
||||||
|
}
|
||||||
|
ui_message::UiMessage::None => (),
|
||||||
|
}
|
||||||
|
iced::Command::none()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> iced::Subscription<Self::Message> {
|
||||||
|
iced::subscription::unfold(
|
||||||
|
"irc message",
|
||||||
|
self.message_rx.take(),
|
||||||
|
move |mut receiver| async move {
|
||||||
|
if let Some(message) = receiver.as_mut().unwrap().recv().await {
|
||||||
|
(
|
||||||
|
ui_message::UiMessage::IrcMessageReceived(Box::new(message)),
|
||||||
|
receiver,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(ui_message::UiMessage::None, receiver)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self) -> iced::Element<'_, Self::Message, iced::Renderer<Self::Theme>> {
|
||||||
|
let dark_grey = Color::new(0.58, 0.65, 0.65, 1.0);
|
||||||
|
let light_blue = Color::new(0.26, 0.62, 0.85, 1.0);
|
||||||
|
let light_red = Color::new(0.99, 0.36, 0.40, 1.0);
|
||||||
|
|
||||||
|
let log = self.message_log.view(&self.nickname);
|
||||||
|
|
||||||
|
let message_box = text_input("your magnum opus", &self.input_value)
|
||||||
|
.id(INPUT_ID.clone())
|
||||||
|
.on_input(ui_message::UiMessage::InputChanged)
|
||||||
|
.on_submit(ui_message::UiMessage::InputSubmitted);
|
||||||
|
|
||||||
|
let channels = column(
|
||||||
|
self.message_log
|
||||||
|
.get_all()
|
||||||
|
.iter()
|
||||||
|
.map(|(channel_name, channel)| {
|
||||||
|
let is_active = self.message_log.active_channel == **channel_name;
|
||||||
|
let label = match channel_name {
|
||||||
|
None => "Server",
|
||||||
|
Some(channel_name) => channel_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
let text_color = if is_active { Some(Color::WHITE) } else { None };
|
||||||
|
let nickname_color = if is_active { Color::WHITE } else { light_blue };
|
||||||
|
let no_message_color = if is_active { Color::WHITE } else { dark_grey };
|
||||||
|
|
||||||
|
let text_size = 14.0;
|
||||||
|
let last_message = container(
|
||||||
|
channel
|
||||||
|
.messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.find_map(|m| -> Option<Element<_, _>> {
|
||||||
|
match &m.detail {
|
||||||
|
MessageDetail::Privmsg { nickname, message } => Some(
|
||||||
|
row![
|
||||||
|
text(format!("{nickname}: "))
|
||||||
|
.style(nickname_color)
|
||||||
|
.size(text_size),
|
||||||
|
text(message).size(text_size)
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
MessageDetail::Join { nickname } => Some(
|
||||||
|
row![
|
||||||
|
text(nickname).style(nickname_color).size(text_size),
|
||||||
|
text(" joined").style(no_message_color).size(text_size)
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(
|
||||||
|
text(if channel_name.is_some() {
|
||||||
|
"No messages"
|
||||||
|
} else {
|
||||||
|
"Server-related messages"
|
||||||
|
})
|
||||||
|
.style(no_message_color)
|
||||||
|
.size(text_size)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.padding([2, 0]);
|
||||||
|
|
||||||
|
let unread_events = channel.unread_events;
|
||||||
|
let unread_messages = channel.unread_messages;
|
||||||
|
let unread_highlights = channel.unread_highlights;
|
||||||
|
let unread_total = unread_events + unread_messages + unread_highlights;
|
||||||
|
|
||||||
|
let unread_indicator = container(
|
||||||
|
container(
|
||||||
|
text(if unread_total > 0 {
|
||||||
|
unread_total.to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
})
|
||||||
|
.style(Color::WHITE)
|
||||||
|
.size(12),
|
||||||
|
)
|
||||||
|
.width(20)
|
||||||
|
.height(20)
|
||||||
|
.align_x(Horizontal::Center)
|
||||||
|
.align_y(Vertical::Center)
|
||||||
|
.style(move |_: &_| container::Appearance {
|
||||||
|
background: Some(Background::Color(if unread_highlights > 0 {
|
||||||
|
light_red
|
||||||
|
} else if unread_messages > 0 {
|
||||||
|
light_blue
|
||||||
|
} else if unread_events > 0 {
|
||||||
|
dark_grey
|
||||||
|
} else {
|
||||||
|
Color::TRANSPARENT
|
||||||
|
})),
|
||||||
|
border_radius: 12.0.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.padding([4, 4])
|
||||||
|
.align_y(Vertical::Center)
|
||||||
|
.height(Length::Fill);
|
||||||
|
|
||||||
|
let container = container(row![
|
||||||
|
column![text(label), last_message].width(Length::Fill),
|
||||||
|
unread_indicator
|
||||||
|
])
|
||||||
|
.style(move |_: &_| container::Appearance {
|
||||||
|
background: match is_active {
|
||||||
|
true => Some(Background::Color(light_blue)),
|
||||||
|
false => None,
|
||||||
|
},
|
||||||
|
text_color,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding([4, 4])
|
||||||
|
.align_y(Vertical::Center)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(54);
|
||||||
|
|
||||||
|
mouse_area(container)
|
||||||
|
.on_press(ui_message::UiMessage::HandleChannelPress(
|
||||||
|
(*channel_name).clone(),
|
||||||
|
))
|
||||||
|
.into()
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.width(Length::Fixed(200.0));
|
||||||
|
|
||||||
|
let content = row![channels, column![log, message_box].height(Length::Fill)];
|
||||||
|
|
||||||
|
container(content).into()
|
||||||
|
}
|
||||||
|
}
|
6
src/cri_flags.rs
Normal file
6
src/cri_flags.rs
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||||
|
|
||||||
|
pub struct CriFlags {
|
||||||
|
pub message_rx: UnboundedReceiver<irc::proto::Message>,
|
||||||
|
pub input_tx: UnboundedSender<irc::proto::Message>,
|
||||||
|
}
|
|
@ -34,15 +34,16 @@ pub async fn message_loop(
|
||||||
loop {
|
loop {
|
||||||
select! {
|
select! {
|
||||||
val = irc_stream.next() => {
|
val = irc_stream.next() => {
|
||||||
if let Some(message) = val.transpose()? {
|
if let Some(Ok(message)) = val {
|
||||||
println!("[Rx] {}", message.to_string().trim());
|
println!("[Rx] {}", message.to_string().trim());
|
||||||
message_tx.send(message)?;
|
message_tx.send(message)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val = input_rx.recv() => {
|
val = input_rx.recv() => {
|
||||||
let message = val.unwrap();
|
if let Some(message) = val {
|
||||||
println!("[Tx] {}", message.to_string().trim());
|
println!("[Tx] {}", message.to_string().trim());
|
||||||
client.send(message)?;
|
client.send(message)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
461
src/main.rs
461
src/main.rs
|
@ -1,24 +1,15 @@
|
||||||
|
mod cri;
|
||||||
|
mod cri_flags;
|
||||||
mod irc_handler;
|
mod irc_handler;
|
||||||
mod irc_message;
|
mod irc_message;
|
||||||
mod message_log;
|
mod message_log;
|
||||||
|
mod ui_message;
|
||||||
|
|
||||||
use chrono::{DateTime, Local};
|
|
||||||
use color_eyre::eyre::Result;
|
use color_eyre::eyre::Result;
|
||||||
use iced::{
|
use iced::{Application, Settings};
|
||||||
alignment::{Horizontal, Vertical},
|
use tokio::sync::mpsc::{self};
|
||||||
executor,
|
|
||||||
theme::Theme,
|
|
||||||
widget::{column, container, mouse_area, row, text, text_input},
|
|
||||||
Application, Background, Color, Element, Length, Settings,
|
|
||||||
};
|
|
||||||
use irc::proto::{message::Tag, CapSubCommand, Capability, Command as IrcCommand, Response};
|
|
||||||
use irc_message::MessageDetail;
|
|
||||||
use message_log::MessageLog;
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use std::{cell::RefCell, collections::HashMap};
|
|
||||||
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
|
||||||
|
|
||||||
static INPUT_ID: Lazy<text_input::Id> = Lazy::new(text_input::Id::unique);
|
use crate::{cri::Cri, cri_flags::CriFlags};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
@ -39,443 +30,3 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
struct CriFlags {
|
|
||||||
pub message_rx: UnboundedReceiver<irc::proto::Message>,
|
|
||||||
pub input_tx: UnboundedSender<irc::proto::Message>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum UiMessage {
|
|
||||||
IrcMessageReceived(Box<irc::proto::Message>),
|
|
||||||
InputChanged(String),
|
|
||||||
InputSubmitted,
|
|
||||||
HandleChannelPress(Option<String>),
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct Cri {
|
|
||||||
message_rx: RefCell<Option<UnboundedReceiver<irc::proto::Message>>>,
|
|
||||||
input_tx: RefCell<UnboundedSender<irc::proto::Message>>,
|
|
||||||
message_log: MessageLog,
|
|
||||||
input_value: String,
|
|
||||||
nickname: String,
|
|
||||||
capabilities: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Cri {
|
|
||||||
fn send_message(&mut self, input_value: &str) {
|
|
||||||
let active_channel = self.message_log.active_channel.clone();
|
|
||||||
if let Some(active_channel) = &active_channel {
|
|
||||||
let command = IrcCommand::PRIVMSG(active_channel.into(), input_value.into());
|
|
||||||
let message: irc::proto::Message = command.into();
|
|
||||||
self.input_tx.borrow().send(message.clone()).unwrap();
|
|
||||||
|
|
||||||
let echo_message = self
|
|
||||||
.capabilities
|
|
||||||
.contains(&Capability::EchoMessage.as_ref().into());
|
|
||||||
if !echo_message {
|
|
||||||
self.message_log.on_privmsg(
|
|
||||||
&self.nickname,
|
|
||||||
active_channel,
|
|
||||||
&self.nickname,
|
|
||||||
input_value,
|
|
||||||
None,
|
|
||||||
&Local::now(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn send_command(&mut self, command: &str) {
|
|
||||||
let mut tokens = command.split_whitespace();
|
|
||||||
|
|
||||||
let command = tokens.next().unwrap();
|
|
||||||
match command {
|
|
||||||
"/join" | "/j" => self.handle_join_command(&mut tokens),
|
|
||||||
"/part" | "/p" => self.handle_part_command(&mut tokens),
|
|
||||||
"/query" | "/q" => self.handle_query_command(tokens),
|
|
||||||
"/list" => self.handle_list_command(),
|
|
||||||
_ => (),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_part_command(&mut self, tokens: &mut std::str::SplitWhitespace<'_>) {
|
|
||||||
let channel = tokens
|
|
||||||
.next()
|
|
||||||
.map(String::from)
|
|
||||||
.or_else(|| self.message_log.active_channel.clone());
|
|
||||||
if channel.is_none() {
|
|
||||||
// TODO error message
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let channel = channel.unwrap();
|
|
||||||
if !channel.starts_with('#') {
|
|
||||||
// TODO error message
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let reason = tokens.collect::<Vec<_>>().join(" ");
|
|
||||||
let reason = if reason.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(reason)
|
|
||||||
};
|
|
||||||
|
|
||||||
self.input_tx
|
|
||||||
.borrow()
|
|
||||||
.send(IrcCommand::PART(channel, reason).into())
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_join_command(&mut self, tokens: &mut std::str::SplitWhitespace<'_>) {
|
|
||||||
let channel = tokens
|
|
||||||
.next()
|
|
||||||
.map(String::from)
|
|
||||||
.or_else(|| self.message_log.active_channel.clone());
|
|
||||||
if channel.is_none() {
|
|
||||||
// TODO error message
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let channel = channel.unwrap();
|
|
||||||
if !channel.starts_with('#') {
|
|
||||||
// TODO error message
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.input_tx
|
|
||||||
.borrow()
|
|
||||||
.send(IrcCommand::JOIN(channel.clone(), tokens.next().map(String::from), None).into())
|
|
||||||
.unwrap();
|
|
||||||
self.message_log.set_active(Some(channel));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_query_command(&mut self, mut tokens: std::str::SplitWhitespace<'_>) {
|
|
||||||
self.message_log
|
|
||||||
.set_active(Some(tokens.next().unwrap().into()));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn handle_list_command(&self) {
|
|
||||||
self.input_tx
|
|
||||||
.borrow()
|
|
||||||
.send(IrcCommand::LIST(None, None).into())
|
|
||||||
.unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Application for Cri {
|
|
||||||
type Executor = executor::Default;
|
|
||||||
type Message = UiMessage;
|
|
||||||
type Theme = Theme;
|
|
||||||
type Flags = CriFlags;
|
|
||||||
|
|
||||||
fn new(flags: Self::Flags) -> (Self, iced::Command<Self::Message>) {
|
|
||||||
(
|
|
||||||
Self {
|
|
||||||
message_rx: RefCell::new(Some(flags.message_rx)),
|
|
||||||
input_tx: RefCell::new(flags.input_tx),
|
|
||||||
message_log: MessageLog::new(),
|
|
||||||
input_value: String::new(),
|
|
||||||
nickname: "cri".into(), // TODO take default value from config
|
|
||||||
capabilities: Vec::new(),
|
|
||||||
},
|
|
||||||
iced::Command::none(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn title(&self) -> String {
|
|
||||||
"cri".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, message: Self::Message) -> iced::Command<Self::Message> {
|
|
||||||
match message {
|
|
||||||
UiMessage::IrcMessageReceived(message) => {
|
|
||||||
// TODO use actual nickname
|
|
||||||
let source_nickname: String =
|
|
||||||
message.source_nickname().unwrap_or(&self.nickname).into();
|
|
||||||
|
|
||||||
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_owned())
|
|
||||||
.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").cloned().flatten();
|
|
||||||
let message_id = message_id.as_deref();
|
|
||||||
|
|
||||||
match &message.command {
|
|
||||||
IrcCommand::CAP(_, CapSubCommand::ACK, capability, _) => {
|
|
||||||
let capability = capability.as_ref().unwrap();
|
|
||||||
self.capabilities.push(capability.clone());
|
|
||||||
}
|
|
||||||
IrcCommand::JOIN(chanlist, _, _) => {
|
|
||||||
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".into(),
|
|
||||||
vec![
|
|
||||||
"LATEST".into(),
|
|
||||||
chanlist.clone(),
|
|
||||||
"*".into(),
|
|
||||||
100.to_string(),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
IrcCommand::PART(chanlist, comment) => {
|
|
||||||
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, ×tamp, message_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
IrcCommand::QUIT(comment) => {
|
|
||||||
self.message_log.on_quit(
|
|
||||||
&source_nickname,
|
|
||||||
comment.as_deref(),
|
|
||||||
×tamp,
|
|
||||||
message_id,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IrcCommand::PRIVMSG(msgtarget, content)
|
|
||||||
| IrcCommand::NOTICE(msgtarget, content) => {
|
|
||||||
let channel: String = message.response_target().unwrap_or(msgtarget).into();
|
|
||||||
self.message_log.on_privmsg(
|
|
||||||
&self.nickname,
|
|
||||||
&channel,
|
|
||||||
&source_nickname,
|
|
||||||
content,
|
|
||||||
message_id,
|
|
||||||
×tamp,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IrcCommand::Response(Response::RPL_WELCOME, args) => {
|
|
||||||
self.nickname = args[0].clone()
|
|
||||||
}
|
|
||||||
IrcCommand::Response(Response::RPL_NAMREPLY, args) => {
|
|
||||||
let channel = &args[2];
|
|
||||||
let names: Vec<_> = args[3].split_ascii_whitespace().collect();
|
|
||||||
self.message_log.on_names_reply(channel, names);
|
|
||||||
}
|
|
||||||
IrcCommand::Response(Response::RPL_TOPIC, args) => {
|
|
||||||
let channel = &args[1];
|
|
||||||
let topic = &args[2];
|
|
||||||
self.message_log.on_topic(channel, topic);
|
|
||||||
}
|
|
||||||
|
|
||||||
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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiMessage::InputChanged(text) => self.input_value = text,
|
|
||||||
UiMessage::InputSubmitted => {
|
|
||||||
if self.input_value.starts_with("//") {
|
|
||||||
self.send_message(&self.input_value.clone()[1..])
|
|
||||||
} else if self.input_value.starts_with('/') {
|
|
||||||
self.send_command(&self.input_value.clone())
|
|
||||||
} else {
|
|
||||||
self.send_message(&self.input_value.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.input_value.clear();
|
|
||||||
}
|
|
||||||
UiMessage::HandleChannelPress(channel) => self.message_log.set_active(channel),
|
|
||||||
UiMessage::None => (),
|
|
||||||
}
|
|
||||||
iced::Command::none()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subscription(&self) -> iced::Subscription<Self::Message> {
|
|
||||||
iced::subscription::unfold(
|
|
||||||
"irc message",
|
|
||||||
self.message_rx.take(),
|
|
||||||
move |mut receiver| async move {
|
|
||||||
if let Some(message) = receiver.as_mut().unwrap().recv().await {
|
|
||||||
(UiMessage::IrcMessageReceived(Box::new(message)), receiver)
|
|
||||||
} else {
|
|
||||||
(UiMessage::None, receiver)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view(&self) -> iced::Element<'_, Self::Message, iced::Renderer<Self::Theme>> {
|
|
||||||
let dark_grey = Color::new(0.58, 0.65, 0.65, 1.0);
|
|
||||||
let light_blue = Color::new(0.26, 0.62, 0.85, 1.0);
|
|
||||||
let light_red = Color::new(0.99, 0.36, 0.40, 1.0);
|
|
||||||
|
|
||||||
let log = self.message_log.view(&self.nickname);
|
|
||||||
|
|
||||||
let message_box = text_input("your magnum opus", &self.input_value)
|
|
||||||
.id(INPUT_ID.clone())
|
|
||||||
.on_input(UiMessage::InputChanged)
|
|
||||||
.on_submit(UiMessage::InputSubmitted);
|
|
||||||
|
|
||||||
let channels = column(
|
|
||||||
self.message_log
|
|
||||||
.get_all()
|
|
||||||
.iter()
|
|
||||||
.map(|(channel_name, channel)| {
|
|
||||||
let is_active = self.message_log.active_channel == **channel_name;
|
|
||||||
let label = match channel_name {
|
|
||||||
None => "Server",
|
|
||||||
Some(channel_name) => channel_name,
|
|
||||||
};
|
|
||||||
|
|
||||||
let text_color = if is_active { Some(Color::WHITE) } else { None };
|
|
||||||
let nickname_color = if is_active { Color::WHITE } else { light_blue };
|
|
||||||
let no_message_color = if is_active { Color::WHITE } else { dark_grey };
|
|
||||||
|
|
||||||
let text_size = 14.0;
|
|
||||||
let last_message = container(
|
|
||||||
channel
|
|
||||||
.messages
|
|
||||||
.iter()
|
|
||||||
.rev()
|
|
||||||
.find_map(|m| -> Option<Element<_, _>> {
|
|
||||||
match &m.detail {
|
|
||||||
MessageDetail::Privmsg { nickname, message } => Some(
|
|
||||||
row![
|
|
||||||
text(format!("{nickname}: "))
|
|
||||||
.style(nickname_color)
|
|
||||||
.size(text_size),
|
|
||||||
text(message).size(text_size)
|
|
||||||
]
|
|
||||||
.into(),
|
|
||||||
),
|
|
||||||
MessageDetail::Join { nickname } => Some(
|
|
||||||
row![
|
|
||||||
text(nickname).style(nickname_color).size(text_size),
|
|
||||||
text(" joined").style(no_message_color).size(text_size)
|
|
||||||
]
|
|
||||||
.into(),
|
|
||||||
),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.unwrap_or(
|
|
||||||
text(if channel_name.is_some() {
|
|
||||||
"No messages"
|
|
||||||
} else {
|
|
||||||
"Server-related messages"
|
|
||||||
})
|
|
||||||
.style(no_message_color)
|
|
||||||
.size(text_size)
|
|
||||||
.into(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.padding([2, 0]);
|
|
||||||
|
|
||||||
let unread_events = channel.unread_events;
|
|
||||||
let unread_messages = channel.unread_messages;
|
|
||||||
let unread_highlights = channel.unread_highlights;
|
|
||||||
let unread_total = unread_events + unread_messages + unread_highlights;
|
|
||||||
|
|
||||||
let unread_indicator = container(
|
|
||||||
container(
|
|
||||||
text(if unread_total > 0 {
|
|
||||||
unread_total.to_string()
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
})
|
|
||||||
.style(Color::WHITE)
|
|
||||||
.size(12),
|
|
||||||
)
|
|
||||||
.width(20)
|
|
||||||
.height(20)
|
|
||||||
.align_x(Horizontal::Center)
|
|
||||||
.align_y(Vertical::Center)
|
|
||||||
.style(move |_: &_| container::Appearance {
|
|
||||||
background: Some(Background::Color(if unread_highlights > 0 {
|
|
||||||
light_red
|
|
||||||
} else if unread_messages > 0 {
|
|
||||||
light_blue
|
|
||||||
} else if unread_events > 0 {
|
|
||||||
dark_grey
|
|
||||||
} else {
|
|
||||||
Color::TRANSPARENT
|
|
||||||
})),
|
|
||||||
border_radius: 12.0.into(),
|
|
||||||
..Default::default()
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.padding([4, 4])
|
|
||||||
.align_y(Vertical::Center)
|
|
||||||
.height(Length::Fill);
|
|
||||||
|
|
||||||
let container = container(row![
|
|
||||||
column![text(label), last_message].width(Length::Fill),
|
|
||||||
unread_indicator
|
|
||||||
])
|
|
||||||
.style(move |_: &_| container::Appearance {
|
|
||||||
background: match is_active {
|
|
||||||
true => Some(Background::Color(light_blue)),
|
|
||||||
false => None,
|
|
||||||
},
|
|
||||||
text_color,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.padding([4, 4])
|
|
||||||
.align_y(Vertical::Center)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.height(54);
|
|
||||||
|
|
||||||
mouse_area(container)
|
|
||||||
.on_press(UiMessage::HandleChannelPress((*channel_name).clone()))
|
|
||||||
.into()
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>(),
|
|
||||||
)
|
|
||||||
.height(Length::Fill)
|
|
||||||
.width(Length::Fixed(200.0));
|
|
||||||
|
|
||||||
let content = row![channels, column![log, message_box].height(Length::Fill)];
|
|
||||||
|
|
||||||
container(content).into()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -292,7 +292,7 @@ impl<'a> MessageLog {
|
||||||
channel.unread_highlights = 0;
|
channel.unread_highlights = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn view(&self, current_nickname: &str) -> Container<'_, crate::UiMessage> {
|
pub fn view(&self, current_nickname: &str) -> Container<'_, crate::ui_message::UiMessage> {
|
||||||
let lighter_grey = Color::new(0.93, 0.94, 0.95, 1.0);
|
let lighter_grey = Color::new(0.93, 0.94, 0.95, 1.0);
|
||||||
let dark_grey = Color::new(0.58, 0.65, 0.65, 1.0);
|
let dark_grey = Color::new(0.58, 0.65, 0.65, 1.0);
|
||||||
let lighter_green = Color::new(0.94, 0.99, 0.87, 1.0);
|
let lighter_green = Color::new(0.94, 0.99, 0.87, 1.0);
|
||||||
|
|
10
src/ui_message.rs
Normal file
10
src/ui_message.rs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
use irc::proto::Message as IrcMessage;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum UiMessage {
|
||||||
|
IrcMessageReceived(Box<IrcMessage>),
|
||||||
|
InputChanged(String),
|
||||||
|
InputSubmitted,
|
||||||
|
HandleChannelPress(Option<String>),
|
||||||
|
None,
|
||||||
|
}
|
Loading…
Reference in a new issue