diff --git a/data/src/message.rs b/data/src/message.rs index ff964a7f..6ea015f6 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -330,6 +330,13 @@ pub fn parse_fragments(text: String, channel_users: &[User]) -> Content { Either::Right(std::iter::once(fragment)) }) + .flat_map(|fragment| { + if let Fragment::Text(text) = &fragment { + return Either::Left(parse_channel_fragments(text).into_iter()); + } + + Either::Right(std::iter::once(fragment)) + }) .collect::>(); if fragments.len() == 1 && matches!(&fragments[0], Fragment::Text(_)) { @@ -403,6 +410,32 @@ fn parse_user_fragments(text: &str, channel_users: &[User]) -> Vec { }) } +fn parse_channel_fragments(text: &str) -> Vec { + text.chars() + .group_by(|c| c.is_whitespace()) + .into_iter() + .map(|(is_whitespace, chars)| { + let text = chars.collect::(); + + if !is_whitespace && proto::is_channel(&text) { + return Fragment::Channel(text); + } + + Fragment::Text(text) + }) + .fold(vec![], |mut acc, fragment| { + if let Some(Fragment::Text(text)) = acc.last_mut() { + if let Fragment::Text(next) = &fragment { + text.push_str(next); + return acc; + } + } + + acc.push(fragment); + acc + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Content { Plain(String), @@ -421,6 +454,7 @@ impl Content { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum Fragment { Text(String), + Channel(String), User(User), Url(Url), Formatted { @@ -433,6 +467,7 @@ impl Fragment { pub fn as_str(&self) -> &str { match self { Fragment::Text(s) => s, + Fragment::Channel(s) => s, Fragment::User(u) => u.as_str(), Fragment::Url(u) => u.as_str(), Fragment::Formatted { text, .. } => text, @@ -1013,6 +1048,7 @@ pub fn reference_user_text(sender: NickRef, own_nick: NickRef, text: &str) -> bo #[derive(Debug, Clone)] pub enum Link { + Channel(String), Url(String), User(User), } diff --git a/src/buffer.rs b/src/buffer.rs index 96570a7f..1951cb02 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -40,6 +40,7 @@ pub enum Message { #[derive(Debug, Clone)] pub enum Event { UserContext(user_context::Event), + OpenChannel(String), } impl Buffer { @@ -71,20 +72,27 @@ impl Buffer { let event = event.map(|event| match event { channel::Event::UserContext(event) => Event::UserContext(event), + channel::Event::OpenChannel(channel) => Event::OpenChannel(channel), }); (command.map(Message::Channel), event) } (Buffer::Server(state), Message::Server(message)) => { - let command = state.update(message, clients, history, config); + let (command, event) = state.update(message, clients, history, config); + + let event = event.map(|event| match event { + server::Event::UserContext(event) => Event::UserContext(event), + server::Event::OpenChannel(channel) => Event::OpenChannel(channel), + }); - (command.map(Message::Server), None) + (command.map(Message::Server), event) } (Buffer::Query(state), Message::Query(message)) => { let (command, event) = state.update(message, clients, history, config); let event = event.map(|event| match event { query::Event::UserContext(event) => Event::UserContext(event), + query::Event::OpenChannel(channel) => Event::OpenChannel(channel), }); (command.map(Message::Query), event) diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index 9262e667..16818e0e 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -21,6 +21,7 @@ pub enum Message { #[derive(Debug, Clone)] pub enum Event { UserContext(user_context::Event), + OpenChannel(String), } pub fn view<'a>( @@ -120,16 +121,16 @@ pub fn view<'a>( scroll_view::Message::Link, theme::selectable_text::default, move |link| match link { - message::Link::Url(_) => vec![], message::Link::User(_) => { user_context::Entry::list(buffer, our_user) } + _ => vec![], }, move |link, entry, length| match link { - message::Link::Url(_) => row![].into(), message::Link::User(user) => entry .view(user, current_user, length) .map(scroll_view::Message::UserContext), + _ => row![].into(), }, config, ); @@ -327,6 +328,7 @@ impl Channel { let event = event.map(|event| match event { scroll_view::Event::UserContext(event) => Event::UserContext(event), + scroll_view::Event::OpenChannel(channel) => Event::OpenChannel(channel), }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/query.rs b/src/buffer/query.rs index 515ed12b..abef293f 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -16,6 +16,7 @@ pub enum Message { #[derive(Debug, Clone)] pub enum Event { UserContext(user_context::Event), + OpenChannel(String), } pub fn view<'a>( @@ -80,14 +81,14 @@ pub fn view<'a>( scroll_view::Message::Link, theme::selectable_text::default, move |link| match link { - message::Link::Url(_) => vec![], message::Link::User(_) => user_context::Entry::list(buffer, None), + _ => vec![], }, move |link, entry, length| match link { - message::Link::Url(_) => row![].into(), message::Link::User(user) => entry .view(user, None, length) .map(scroll_view::Message::UserContext), + _ => row![].into(), }, config, ); @@ -242,6 +243,7 @@ impl Query { let event = event.map(|event| match event { scroll_view::Event::UserContext(event) => Event::UserContext(event), + scroll_view::Event::OpenChannel(channel) => Event::OpenChannel(channel), }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/scroll_view.rs b/src/buffer/scroll_view.rs index e93e7fb9..5667f8ec 100644 --- a/src/buffer/scroll_view.rs +++ b/src/buffer/scroll_view.rs @@ -25,6 +25,7 @@ pub enum Message { #[derive(Debug, Clone)] pub enum Event { UserContext(user_context::Event), + OpenChannel(String), } #[derive(Debug, Clone, Copy)] @@ -227,6 +228,9 @@ impl State { user_context::update(message).map(Event::UserContext), ); } + Message::Link(message::Link::Channel(channel)) => { + return (Task::none(), Some(Event::OpenChannel(channel))) + } Message::Link(message::Link::Url(url)) => { let _ = open::that_detached(url); } diff --git a/src/buffer/server.rs b/src/buffer/server.rs index fd1d3525..d6d08369 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -2,7 +2,7 @@ use data::{history, message, Config}; use iced::widget::{column, container, row, vertical_space}; use iced::{Length, Task}; -use super::{input_view, scroll_view}; +use super::{input_view, scroll_view, user_context}; use crate::widget::{message_content, selectable_text, Element}; use crate::{theme, Theme}; @@ -12,6 +12,12 @@ pub enum Message { InputView(input_view::Message), } +#[derive(Debug, Clone)] +pub enum Event { + UserContext(user_context::Event), + OpenChannel(String), +} + pub fn view<'a>( state: &'a Server, clients: &'a data::client::Map, @@ -119,11 +125,17 @@ impl Server { clients: &mut data::client::Map, history: &mut history::Manager, config: &Config, - ) -> Task { + ) -> (Task, Option) { match message { Message::ScrollView(message) => { - let (command, _) = self.scroll_view.update(message); - command.map(Message::ScrollView) + let (command, event) = self.scroll_view.update(message); + + let event = event.map(|event| match event { + scroll_view::Event::UserContext(event) => Event::UserContext(event), + scroll_view::Event::OpenChannel(channel) => Event::OpenChannel(channel), + }); + + (command.map(Message::ScrollView), event) } Message::InputView(message) => { let (command, event) = @@ -131,13 +143,15 @@ impl Server { .update(message, &self.buffer, clients, history, config); let command = command.map(Message::InputView); - match event { + let task = match event { Some(input_view::Event::InputSent) => Task::batch(vec![ command, self.scroll_view.scroll_to_end().map(Message::ScrollView), ]), None => command, - } + }; + + (task, None) } } } diff --git a/src/buffer/user_context.rs b/src/buffer/user_context.rs index 017a17b4..e432bdfa 100644 --- a/src/buffer/user_context.rs +++ b/src/buffer/user_context.rs @@ -113,6 +113,7 @@ pub enum Event { SingleClick(Nick), ToggleAccessLevel(Nick, String), SendFile(Nick), + OpenChannel(String), } pub fn update(message: Message) -> Option { @@ -122,6 +123,7 @@ pub fn update(message: Message) -> Option { Message::SingleClick(nick) => Some(Event::SingleClick(nick)), Message::ToggleAccessLevel(nick, mode) => Some(Event::ToggleAccessLevel(nick, mode)), Message::SendFile(nick) => Some(Event::SendFile(nick)), + Message::Link(message::Link::Channel(channel)) => Some(Event::OpenChannel(channel)), Message::Link(message::Link::Url(url)) => { let _ = open::that_detached(url); None diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index 67969faa..3e713237 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -2,6 +2,7 @@ use chrono::{DateTime, Utc}; use data::environment::RELEASE_WEBSITE; use std::collections::HashMap; use std::path::PathBuf; +use std::slice; use std::time::{Duration, Instant}; use data::config; @@ -153,144 +154,209 @@ impl Dashboard { config, ); - if let Some(buffer::Event::UserContext(event)) = event { - match event { - buffer::user_context::Event::ToggleAccessLevel(nick, mode) => { - let Some(buffer) = pane.buffer.data() else { - return (Task::none(), None); - }; - - let Some(target) = buffer.target() else { - return (Task::none(), None); - }; - - let command = data::Command::Mode( - target, - Some(mode), - Some(vec![nick.to_string()]), - ); - let input = data::Input::command(buffer.clone(), command); - - if let Some(encoded) = input.encoded() { - clients.send(input.buffer(), encoded); - } - } - buffer::user_context::Event::SendWhois(nick) => { - if let Some(buffer) = pane.buffer.data() { - let command = - data::Command::Whois(None, nick.to_string()); + let command = command.map(move |message| { + Message::Pane(window, pane::Message::Buffer(id, message)) + }); + let Some(event) = event else { + return (command, None); + }; + + match event { + buffer::Event::UserContext(event) => { + match event { + buffer::user_context::Event::ToggleAccessLevel( + nick, + mode, + ) => { + let Some(buffer) = pane.buffer.data() else { + return (command, None); + }; + + let Some(target) = buffer.target() else { + return (command, None); + }; + + let command = data::Command::Mode( + target, + Some(mode), + Some(vec![nick.to_string()]), + ); let input = data::Input::command(buffer.clone(), command); if let Some(encoded) = input.encoded() { clients.send(input.buffer(), encoded); } + } + buffer::user_context::Event::SendWhois(nick) => { + if let Some(buffer) = pane.buffer.data() { + let command = + data::Command::Whois(None, nick.to_string()); - if let Some(nick) = clients.nickname(buffer.server()) { - let mut user = nick.to_owned().into(); - let mut channel_users = &[][..]; + let input = + data::Input::command(buffer.clone(), command); - // Resolve our attributes if sending this message in a channel - if let data::Buffer::Channel(server, channel) = - &buffer + if let Some(encoded) = input.encoded() { + clients.send(input.buffer(), encoded); + } + + if let Some(nick) = + clients.nickname(buffer.server()) { - channel_users = - clients.get_channel_users(server, channel); + let mut user = nick.to_owned().into(); + let mut channel_users = &[][..]; - if let Some(user_with_attributes) = clients - .resolve_user_attributes( - server, channel, &user, - ) + // Resolve our attributes if sending this message in a channel + if let data::Buffer::Channel(server, channel) = + &buffer { - user = user_with_attributes.clone(); + channel_users = clients + .get_channel_users(server, channel); + + if let Some(user_with_attributes) = clients + .resolve_user_attributes( + server, channel, &user, + ) + { + user = user_with_attributes.clone(); + } } - } - if let Some(messages) = - input.messages(user, channel_users) - { - for message in messages { - self.history.record_message( - input.server(), - message, - ); + if let Some(messages) = + input.messages(user, channel_users) + { + for message in messages { + self.history.record_message( + input.server(), + message, + ); + } } } } } - } - buffer::user_context::Event::OpenQuery(nick) => { - if let Some(data) = pane.buffer.data() { - let buffer = - data::Buffer::Query(data.server().clone(), nick); + buffer::user_context::Event::OpenQuery(nick) => { + if let Some(data) = pane.buffer.data() { + let buffer = data::Buffer::Query( + data.server().clone(), + nick, + ); + return ( + Task::batch(vec![ + command, + self.open_buffer( + buffer, + config.buffer.clone().into(), + main_window, + ), + ]), + None, + ); + } + } + buffer::user_context::Event::SingleClick(nick) => { + let Some((_, pane, history)) = + self.get_focused_with_history_mut(main_window) + else { + return (command, None); + }; + return ( - self.open_buffer( - buffer, - config.buffer.clone().into(), - main_window, - ), + Task::batch(vec![ + command, + pane.buffer + .insert_user_to_input(nick, history) + .map(move |message| { + Message::Pane( + window, + pane::Message::Buffer(id, message), + ) + }), + ]), None, ); } + buffer::user_context::Event::SendFile(nick) => { + if let Some(buffer) = pane.buffer.data() { + let server = buffer.server().clone(); + let starting_directory = + config.file_transfer.save_directory.clone(); + + return ( + Task::batch(vec![ + command, + Task::perform( + async move { + rfd::AsyncFileDialog::new() + .set_directory( + starting_directory, + ) + .pick_file() + .await + .map(|handle| { + handle.path().to_path_buf() + }) + }, + move |file| { + Message::SendFileSelected( + server.clone(), + nick.clone(), + file, + ) + }, + ), + ]), + None, + ); + } + } + buffer::user_context::Event::OpenChannel(channel) => { + if let Some(server) = pane + .buffer + .data() + .map(data::Buffer::server) + .cloned() + { + return ( + Task::batch(vec![ + command, + self.open_channel( + server, + channel, + clients, + main_window, + config, + ), + ]), + None, + ); + } + } } - buffer::user_context::Event::SingleClick(nick) => { - let Some((_, pane, history)) = - self.get_focused_with_history_mut(main_window) - else { - return (Task::none(), None); - }; - + } + buffer::Event::OpenChannel(channel) => { + if let Some(server) = + pane.buffer.data().map(data::Buffer::server).cloned() + { return ( - pane.buffer.insert_user_to_input(nick, history).map( - move |message| { - Message::Pane( - window, - pane::Message::Buffer(id, message), - ) - }, - ), + Task::batch(vec![ + command, + self.open_channel( + server, + channel, + clients, + main_window, + config, + ), + ]), None, ); } - buffer::user_context::Event::SendFile(nick) => { - if let Some(buffer) = pane.buffer.data() { - let server = buffer.server().clone(); - let starting_directory = - config.file_transfer.save_directory.clone(); - - return ( - Task::perform( - async move { - rfd::AsyncFileDialog::new() - .set_directory(starting_directory) - .pick_file() - .await - .map(|handle| { - handle.path().to_path_buf() - }) - }, - move |file| { - Message::SendFileSelected( - server.clone(), - nick.clone(), - file, - ) - }, - ), - None, - ); - } - } } } - return ( - command.map(move |message| { - Message::Pane(window, pane::Message::Buffer(id, message)) - }), - None, - ); + return (command, None); } } pane::Message::ToggleShowUserList => { @@ -1768,6 +1834,40 @@ impl Dashboard { self.focus_pane(main_window, window, pane), ]) } + + fn open_channel( + &mut self, + server: Server, + channel: String, + clients: &mut data::client::Map, + main_window: &Window, + config: &Config, + ) -> Task { + let buffer = data::Buffer::Channel(server.clone(), channel.clone()); + + // Need to join channel + if !clients + .get_channels(&server) + .iter() + .any(|joined| channel == *joined) + { + clients.join(&server, slice::from_ref(&channel)); + } + + // Check if pane is already open + let matching_pane = self + .panes + .iter(main_window.id) + .find_map(|(window, pane, state)| { + (state.buffer.data() == Some(&buffer)).then_some((window, pane)) + }); + + if let Some((window, pane)) = matching_pane { + self.focus_pane(main_window, window, pane) + } else { + self.open_buffer(buffer, config.buffer.clone().into(), main_window) + } + } } impl<'a> From<&'a Dashboard> for data::Dashboard { diff --git a/src/theme/selectable_text.rs b/src/theme/selectable_text.rs index 84d8d64b..215173fc 100644 --- a/src/theme/selectable_text.rs +++ b/src/theme/selectable_text.rs @@ -107,7 +107,7 @@ impl selectable_rich_text::Link for message::Link { fn underline(&self) -> bool { match self { data::message::Link::Url(_) => true, - data::message::Link::User(_) => false, + data::message::Link::User(_) | data::message::Link::Channel(_) => false, } } } diff --git a/src/widget/message_content.rs b/src/widget/message_content.rs index 9e92615f..42ad0e27 100644 --- a/src/widget/message_content.rs +++ b/src/widget/message_content.rs @@ -64,6 +64,9 @@ fn message_content_impl<'a, T: Copy + 'a, M: 'a>( .iter() .map(|fragment| match fragment { data::message::Fragment::Text(s) => span(s), + data::message::Fragment::Channel(s) => span(s.as_str()) + .color(theme.colors().buffer.url) + .link(message::Link::Channel(s.as_str().to_string())), data::message::Fragment::User(user) => { let color_kind = &config.buffer.channel.message.nickname_color;