From c46cff9029530140c39ea29e096444f9b2ae0409 Mon Sep 17 00:00:00 2001 From: Cory Forsstrom Date: Thu, 3 Oct 2024 11:36:07 -0700 Subject: [PATCH] Add highlights buffer --- data/src/client.rs | 16 +- data/src/history.rs | 5 + data/src/history/manager.rs | 18 ++ data/src/history/metadata.rs | 1 + data/src/message.rs | 41 ++++- data/src/pane.rs | 1 + data/src/server.rs | 1 + src/appearance/theme/selectable_text.rs | 4 +- src/buffer.rs | 98 ++++++++++- src/buffer/channel.rs | 16 +- src/buffer/channel/topic.rs | 3 +- src/buffer/highlights.rs | 155 ++++++++++++++++ src/buffer/logs.rs | 7 +- src/buffer/query.rs | 13 +- src/buffer/scroll_view.rs | 20 ++- src/buffer/server.rs | 7 +- src/buffer/user_context.rs | 8 +- src/icon.rs | 7 +- src/main.rs | 34 +++- src/screen/dashboard.rs | 225 ++++++++++++++---------- src/screen/dashboard/pane.rs | 3 + src/screen/dashboard/sidebar.rs | 12 ++ 22 files changed, 553 insertions(+), 142 deletions(-) create mode 100644 src/buffer/highlights.rs diff --git a/data/src/client.rs b/data/src/client.rs index b19c6ad0..eaf16a09 100644 --- a/data/src/client.rs +++ b/data/src/client.rs @@ -37,7 +37,11 @@ pub enum State { #[derive(Debug)] pub enum Notification { DirectMessage(User), - Highlight(User, String), + Highlight { + enabled: bool, + user: User, + channel: String, + }, MonitoredOnline(Vec), MonitoredOffline(Vec), } @@ -650,13 +654,15 @@ impl Client { } // Highlight notification - if message::references_user_text(user.nickname(), self.nickname(), text) - && self.highlight_blackout.allow_highlights() - { + if message::references_user_text(user.nickname(), self.nickname(), text) { return Some(vec![Event::Notification( message.clone(), self.nickname().to_owned(), - Notification::Highlight(user, channel.clone()), + Notification::Highlight { + enabled: self.highlight_blackout.allow_highlights(), + user, + channel: channel.clone(), + }, )]); } else if user.nickname() == self.nickname() && context.is_some() { // If we sent (echo) & context exists (we sent from this client), ignore diff --git a/data/src/history.rs b/data/src/history.rs index 720240de..f09627a2 100644 --- a/data/src/history.rs +++ b/data/src/history.rs @@ -32,6 +32,7 @@ pub enum Kind { Channel(String), Query(Nick), Logs, + Highlights, } impl Kind { @@ -41,6 +42,7 @@ impl Kind { Kind::Channel(channel) => Some(channel), Kind::Query(nick) => Some(nick.as_ref()), Kind::Logs => None, + Kind::Highlights => None, } } } @@ -52,6 +54,7 @@ impl fmt::Display for Kind { Kind::Channel(channel) => write!(f, "channel {channel}"), Kind::Query(nick) => write!(f, "user {}", nick), Kind::Logs => write!(f, "logs"), + Kind::Highlights => write!(f, "highlights"), } } } @@ -63,6 +66,7 @@ impl From for Kind { message::Target::Channel { channel, .. } => Kind::Channel(channel), message::Target::Query { nick, .. } => Kind::Query(nick), message::Target::Logs => Kind::Logs, + message::Target::Highlights { .. } => Kind::Highlights, } } } @@ -159,6 +163,7 @@ async fn path(server: &server::Server, kind: &Kind) -> Result { Kind::Channel(channel) => format!("{server}channel{channel}"), Kind::Query(nick) => format!("{server}nickname{}", nick), Kind::Logs => "logs".to_string(), + Kind::Highlights => "highlights".to_string(), }; let hashed_name = seahash::hash(name.as_bytes()); diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index e5f44b78..de02a6e4 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -25,6 +25,13 @@ impl Resource { kind: history::Kind::Logs, } } + + pub fn highlights() -> Self { + Self { + server: server::HIGHLIGHTS.clone(), + kind: history::Kind::Highlights, + } + } } #[derive(Debug)] @@ -250,6 +257,17 @@ impl Manager { ) } + pub fn record_highlight( + &mut self, + message: crate::Message, + ) -> Option> { + self.data.add_message( + server::HIGHLIGHTS.clone(), + history::Kind::Highlights, + message, + ) + } + pub fn update_read_marker( &mut self, server: Server, diff --git a/data/src/history/metadata.rs b/data/src/history/metadata.rs index 76f0d68e..e6ec0eca 100644 --- a/data/src/history/metadata.rs +++ b/data/src/history/metadata.rs @@ -127,6 +127,7 @@ async fn path(server: &server::Server, kind: &Kind) -> Result { Kind::Channel(channel) => format!("{server}channel{channel}-metadata"), Kind::Query(nick) => format!("{server}nickname{}-metadata", nick), Kind::Logs => "logs-metadata".to_string(), + Kind::Highlights => "highlights-metadata".to_string(), }; let hashed_name = seahash::hash(name.as_bytes()); diff --git a/data/src/message.rs b/data/src/message.rs index 95fb3f5d..9542b989 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -18,7 +18,7 @@ pub use self::source::Source; use crate::config::buffer::UsernameFormat; use crate::time::{self, Posix}; use crate::user::{Nick, NickRef}; -use crate::{ctcp, Config, User}; +use crate::{ctcp, Config, Server, User}; // References: // - https://datatracker.ietf.org/doc/html/rfc1738#section-5 @@ -118,6 +118,11 @@ pub enum Target { source: Source, }, Logs, + Highlights { + server: Server, + channel: Channel, + source: Source, + }, } impl Target { @@ -127,6 +132,7 @@ impl Target { Target::Channel { prefix, .. } => prefix.as_ref(), Target::Query { .. } => None, Target::Logs => None, + Target::Highlights { .. } => None, } } @@ -136,6 +142,7 @@ impl Target { Target::Channel { source, .. } => source, Target::Query { source, .. } => source, Target::Logs => &Source::Internal(source::Internal::Logs), + Target::Highlights { source, .. } => source, } } } @@ -287,6 +294,23 @@ impl Message { hash, } } + + pub fn into_highlight(mut self, server: Server) -> Option { + self.target = match self.target { + Target::Channel { + channel, + source: Source::User(user), + .. + } => Target::Highlights { + server, + channel, + source: Source::User(user), + }, + _ => return None, + }; + + Some(self) + } } impl Serialize for Message { @@ -1147,7 +1171,19 @@ pub fn references_user(sender: NickRef, own_nick: NickRef, message: &Message) -> } pub fn references_user_text(sender: NickRef, own_nick: NickRef, text: &str) -> bool { - sender != own_nick && text_references_nickname(text, own_nick).is_some() + sender != own_nick + && text + .chars() + .group_by(|c| c.is_whitespace()) + .into_iter() + .any(|(is_whitespace, chars)| { + if !is_whitespace { + let text = chars.collect::(); + text_references_nickname(&text, own_nick).is_some() + } else { + false + } + }) } #[derive(Debug, Clone)] @@ -1155,6 +1191,7 @@ pub enum Link { Channel(String), Url(String), User(User), + GoToMessage(Server, String, Hash), } fn fail_as_none<'de, T, D>(deserializer: D) -> Result, D::Error> diff --git a/data/src/pane.rs b/data/src/pane.rs index fee56d56..17a9ef38 100644 --- a/data/src/pane.rs +++ b/data/src/pane.rs @@ -17,6 +17,7 @@ pub enum Pane { Empty, FileTransfers, Logs, + Highlights, } #[derive(Debug, Clone, Copy, Deserialize, Serialize)] diff --git a/data/src/server.rs b/data/src/server.rs index 3796ee3a..79a02276 100644 --- a/data/src/server.rs +++ b/data/src/server.rs @@ -14,6 +14,7 @@ use crate::config::Error; // Hack since log messages are app wide and not scoped to any server pub static LOGS: Lazy = Lazy::new(|| Server("".to_string())); +pub static HIGHLIGHTS: Lazy = Lazy::new(|| Server("".to_string())); pub type Handle = Sender; diff --git a/src/appearance/theme/selectable_text.rs b/src/appearance/theme/selectable_text.rs index 215173fc..fa4a1e73 100644 --- a/src/appearance/theme/selectable_text.rs +++ b/src/appearance/theme/selectable_text.rs @@ -107,7 +107,9 @@ impl selectable_rich_text::Link for message::Link { fn underline(&self) -> bool { match self { data::message::Link::Url(_) => true, - data::message::Link::User(_) | data::message::Link::Channel(_) => false, + data::message::Link::User(_) + | data::message::Link::Channel(_) + | data::message::Link::GoToMessage(..) => false, } } } diff --git a/src/buffer.rs b/src/buffer.rs index 943c5c3c..4b18d0d4 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -1,10 +1,11 @@ pub use data::buffer::Settings; use data::user::Nick; -use data::{buffer, file_transfer, history, Config}; +use data::{buffer, file_transfer, history, message, Config}; use iced::Task; pub use self::channel::Channel; pub use self::file_transfers::FileTransfers; +pub use self::highlights::Highlights; pub use self::logs::Logs; pub use self::query::Query; pub use self::server::Server; @@ -15,6 +16,7 @@ use crate::Theme; pub mod channel; pub mod empty; pub mod file_transfers; +pub mod highlights; mod input_view; pub mod logs; pub mod query; @@ -30,6 +32,7 @@ pub enum Buffer { Query(Query), FileTransfers(FileTransfers), Logs(Logs), + Highlights(Highlights), } #[derive(Debug, Clone)] @@ -39,11 +42,13 @@ pub enum Message { Query(query::Message), FileTransfers(file_transfers::Message), Logs(logs::Message), + Highlights(highlights::Message), } pub enum Event { UserContext(user_context::Event), OpenChannel(String), + GoToMessage(data::Server, String, message::Hash), History(Task), } @@ -59,6 +64,7 @@ impl Buffer { Buffer::Server(state) => Some(&state.server), Buffer::Query(state) => Some(&state.server), Buffer::Logs(_) => Some(&data::server::LOGS), + Buffer::Highlights(_) => Some(&data::server::HIGHLIGHTS), } } @@ -70,6 +76,7 @@ impl Buffer { Buffer::Query(state) => Some(&state.buffer), Buffer::FileTransfers(_) => None, Buffer::Logs(_) => None, + Buffer::Highlights(_) => None, } } @@ -131,6 +138,20 @@ impl Buffer { (command.map(Message::Logs), event) } + (Buffer::Highlights(state), Message::Highlights(message)) => { + let (command, event) = state.update(message); + + let event = event.map(|event| match event { + highlights::Event::UserContext(event) => Event::UserContext(event), + highlights::Event::OpenChannel(channel) => Event::OpenChannel(channel), + highlights::Event::GoToMessage(server, channel, message) => { + Event::GoToMessage(server, channel, message) + } + highlights::Event::History(task) => Event::History(task), + }); + + (command.map(Message::Highlights), event) + } _ => (Task::none(), None), } } @@ -169,6 +190,9 @@ impl Buffer { file_transfers::view(state, file_transfers).map(Message::FileTransfers) } Buffer::Logs(state) => logs::view(state, history, config, theme).map(Message::Logs), + Buffer::Highlights(state) => { + highlights::view(state, clients, history, config, theme).map(Message::Highlights) + } } } @@ -194,7 +218,9 @@ impl Buffer { pub fn focus(&self) -> Task { match self { - Buffer::Empty | Buffer::FileTransfers(_) | Buffer::Logs(_) => Task::none(), + Buffer::Empty | Buffer::FileTransfers(_) | Buffer::Logs(_) | Buffer::Highlights(_) => { + Task::none() + } Buffer::Channel(channel) => channel.focus().map(Message::Channel), Buffer::Server(server) => server.focus().map(Message::Server), Buffer::Query(query) => query.focus().map(Message::Query), @@ -203,7 +229,7 @@ impl Buffer { pub fn reset(&mut self) { match self { - Buffer::Empty | Buffer::FileTransfers(_) | Buffer::Logs(_) => {} + Buffer::Empty | Buffer::FileTransfers(_) | Buffer::Logs(_) | Buffer::Highlights(_) => {} Buffer::Channel(channel) => channel.reset(), Buffer::Server(server) => server.reset(), Buffer::Query(query) => query.reset(), @@ -217,9 +243,11 @@ impl Buffer { ) -> Task { if let Some(buffer) = self.data().cloned() { match self { - Buffer::Empty | Buffer::Server(_) | Buffer::FileTransfers(_) | Buffer::Logs(_) => { - Task::none() - } + Buffer::Empty + | Buffer::Server(_) + | Buffer::FileTransfers(_) + | Buffer::Logs(_) + | Buffer::Highlights(_) => Task::none(), Buffer::Channel(channel) => channel .input_view .insert_user(nick, buffer, history) @@ -253,6 +281,10 @@ impl Buffer { .scroll_view .scroll_to_start() .map(|message| Message::Logs(logs::Message::ScrollView(message))), + Buffer::Highlights(highlights) => highlights + .scroll_view + .scroll_to_start() + .map(|message| Message::Highlights(highlights::Message::ScrollView(message))), } } @@ -275,6 +307,56 @@ impl Buffer { .scroll_view .scroll_to_end() .map(|message| Message::Logs(logs::Message::ScrollView(message))), + Buffer::Highlights(highlights) => highlights + .scroll_view + .scroll_to_end() + .map(|message| Message::Highlights(highlights::Message::ScrollView(message))), + } + } + + pub fn scroll_to_message( + &mut self, + message: message::Hash, + history: &history::Manager, + config: &Config, + ) -> Task { + match self { + Buffer::Empty | Buffer::FileTransfers(_) => Task::none(), + Buffer::Channel(state) => state + .scroll_view + .scroll_to_message( + message, + scroll_view::Kind::Channel(&state.server, &state.channel), + history, + config, + ) + .map(|message| Message::Channel(channel::Message::ScrollView(message))), + Buffer::Server(state) => state + .scroll_view + .scroll_to_message( + message, + scroll_view::Kind::Server(&state.server), + history, + config, + ) + .map(|message| Message::Server(server::Message::ScrollView(message))), + Buffer::Query(state) => state + .scroll_view + .scroll_to_message( + message, + scroll_view::Kind::Query(&state.server, &state.nick), + history, + config, + ) + .map(|message| Message::Query(query::Message::ScrollView(message))), + Buffer::Logs(state) => state + .scroll_view + .scroll_to_message(message, scroll_view::Kind::Logs, history, config) + .map(|message| Message::Logs(logs::Message::ScrollView(message))), + Buffer::Highlights(state) => state + .scroll_view + .scroll_to_message(message, scroll_view::Kind::Highlights, history, config) + .map(|message| Message::Highlights(highlights::Message::ScrollView(message))), } } @@ -309,6 +391,10 @@ impl Buffer { .scroll_view .scroll_to_backlog(scroll_view::Kind::Logs, history, config) .map(|message| Message::Logs(logs::Message::ScrollView(message))), + Buffer::Highlights(state) => state + .scroll_view + .scroll_to_backlog(scroll_view::Kind::Highlights, history, config) + .map(|message| Message::Highlights(highlights::Message::ScrollView(message))), } } } diff --git a/src/buffer/channel.rs b/src/buffer/channel.rs index 944c6fca..fab4700b 100644 --- a/src/buffer/channel.rs +++ b/src/buffer/channel.rs @@ -113,8 +113,9 @@ pub fn view<'a>( .horizontal_alignment(alignment::Horizontal::Right); } - let nick = user_context::view(text, user, current_user, buffer, our_user) - .map(scroll_view::Message::UserContext); + let nick = + user_context::view(text, user, current_user, Some(buffer), our_user) + .map(scroll_view::Message::UserContext); let text = message_content::with_context( &message.content, @@ -123,7 +124,7 @@ pub fn view<'a>( theme::selectable_text::default, move |link| match link { message::Link::User(_) => { - user_context::Entry::list(buffer, our_user) + user_context::Entry::list(Some(buffer), our_user) } _ => vec![], }, @@ -328,9 +329,10 @@ impl Channel { Message::ScrollView(message) => { 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), + let event = event.and_then(|event| match event { + scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), + scroll_view::Event::OpenChannel(channel) => Some(Event::OpenChannel(channel)), + scroll_view::Event::GoToMessage(..) => None, }); (command.map(Message::ScrollView), event) @@ -457,7 +459,7 @@ mod nick_list { }) .width(Length::Fixed(width)); - user_context::view(content, user, Some(user), buffer, our_user) + user_context::view(content, user, Some(user), Some(buffer), our_user) })); Scrollable::new(content) diff --git a/src/buffer/channel/topic.rs b/src/buffer/channel/topic.rs index 923e2b99..2fb6757a 100644 --- a/src/buffer/channel/topic.rs +++ b/src/buffer/channel/topic.rs @@ -31,6 +31,7 @@ pub fn update(message: Message) -> Option { Message::Link(message::Link::User(user)) => Some(Event::UserContext( user_context::Event::SingleClick(user.nickname().to_owned()), )), + Message::Link(message::Link::GoToMessage(..)) => None, } } @@ -59,7 +60,7 @@ pub fn view<'a>( }), user, Some(user), - buffer, + Some(buffer), our_user, ) } else { diff --git a/src/buffer/highlights.rs b/src/buffer/highlights.rs new file mode 100644 index 00000000..95ea83e1 --- /dev/null +++ b/src/buffer/highlights.rs @@ -0,0 +1,155 @@ +use data::{history, message, Config, Server}; +use iced::widget::{container, row, span}; +use iced::{Length, Task}; + +use super::{scroll_view, user_context}; +use crate::widget::{message_content, selectable_rich_text, selectable_text, Element}; +use crate::{theme, Theme}; + +#[derive(Debug, Clone)] +pub enum Message { + ScrollView(scroll_view::Message), +} + +pub enum Event { + UserContext(user_context::Event), + OpenChannel(String), + GoToMessage(Server, String, message::Hash), + History(Task), +} + +pub fn view<'a>( + state: &'a Highlights, + clients: &'a data::client::Map, + history: &'a history::Manager, + config: &'a Config, + theme: &'a Theme, +) -> Element<'a, Message> { + let messages = container( + scroll_view::view( + &state.scroll_view, + scroll_view::Kind::Highlights, + history, + config, + move |message, _, _| match &message.target { + message::Target::Highlights { + server, + channel, + source: message::Source::User(user), + } => { + let users = clients.get_channel_users(server, channel); + + let timestamp = + config + .buffer + .format_timestamp(&message.server_time) + .map(|timestamp| { + selectable_text(timestamp).style(theme::selectable_text::timestamp) + }); + + let channel = selectable_rich_text::<_, _, (), _, _>(vec![ + span(channel).color(theme.colors().buffer.url).link( + message::Link::GoToMessage( + server.clone(), + channel.to_string(), + message.hash, + ), + ), + span(" "), + ]) + .on_link(scroll_view::Message::Link); + + let with_access_levels = config.buffer.nickname.show_access_levels; + + let current_user = users.iter().find(|current_user| *current_user == user); + + let text = selectable_text( + config + .buffer + .nickname + .brackets + .format(user.display(with_access_levels)), + ) + .style(|theme| { + theme::selectable_text::nickname( + theme, + user.nick_color(theme.colors(), config.buffer.nickname.color), + user.is_away(), + ) + }); + + let nick = user_context::view(text, user, current_user, None, None) + .map(scroll_view::Message::UserContext); + + let text = message_content::with_context( + &message.content, + theme, + scroll_view::Message::Link, + theme::selectable_text::default, + move |link| match link { + message::Link::User(_) => user_context::Entry::list(None, None), + _ => vec![], + }, + move |link, entry, length| match link { + message::Link::User(user) => entry + .view(user, current_user, length) + .map(scroll_view::Message::UserContext), + _ => row![].into(), + }, + config, + ); + + Some( + container( + row![] + .push_maybe(timestamp) + .push(channel) + .push(nick) + .push(selectable_text(" ")) + .push(text), + ) + .into(), + ) + } + _ => None, + }, + ) + .map(Message::ScrollView), + ) + .height(Length::Fill); + + container(messages) + .width(Length::Fill) + .height(Length::Fill) + .padding(8) + .into() +} + +#[derive(Debug, Clone, Default)] +pub struct Highlights { + pub scroll_view: scroll_view::State, +} + +impl Highlights { + pub fn new() -> Self { + Self::default() + } + + pub fn update(&mut self, message: Message) -> (Task, Option) { + match message { + Message::ScrollView(message) => { + 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), + scroll_view::Event::GoToMessage(server, channel, message) => { + Event::GoToMessage(server, channel, message) + } + }); + + (command.map(Message::ScrollView), event) + } + } + } +} diff --git a/src/buffer/logs.rs b/src/buffer/logs.rs index 8718d919..3ae11348 100644 --- a/src/buffer/logs.rs +++ b/src/buffer/logs.rs @@ -69,9 +69,10 @@ impl Logs { Message::ScrollView(message) => { 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), + let event = event.and_then(|event| match event { + scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), + scroll_view::Event::OpenChannel(channel) => Some(Event::OpenChannel(channel)), + scroll_view::Event::GoToMessage(_, _, _) => None, }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/query.rs b/src/buffer/query.rs index bb5908e4..adad43e8 100644 --- a/src/buffer/query.rs +++ b/src/buffer/query.rs @@ -72,7 +72,7 @@ pub fn view<'a>( .horizontal_alignment(alignment::Horizontal::Right); } - let nick = user_context::view(text, user, None, buffer, None) + let nick = user_context::view(text, user, None, Some(buffer), None) .map(scroll_view::Message::UserContext); let message = message_content::with_context( @@ -81,7 +81,9 @@ pub fn view<'a>( scroll_view::Message::Link, theme::selectable_text::default, move |link| match link { - message::Link::User(_) => user_context::Entry::list(buffer, None), + message::Link::User(_) => { + user_context::Entry::list(Some(buffer), None) + } _ => vec![], }, move |link, entry, length| match link { @@ -242,9 +244,10 @@ impl Query { Message::ScrollView(message) => { 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), + let event = event.and_then(|event| match event { + scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), + scroll_view::Event::OpenChannel(channel) => Some(Event::OpenChannel(channel)), + scroll_view::Event::GoToMessage(_, _, _) => None, }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/scroll_view.rs b/src/buffer/scroll_view.rs index dd110c1f..11df271c 100644 --- a/src/buffer/scroll_view.rs +++ b/src/buffer/scroll_view.rs @@ -27,6 +27,7 @@ pub enum Message { pub enum Event { UserContext(user_context::Event), OpenChannel(String), + GoToMessage(Server, String, message::Hash), } #[derive(Debug, Clone, Copy)] @@ -35,6 +36,7 @@ pub enum Kind<'a> { Channel(&'a Server, &'a str), Query(&'a Server, &'a Nick), Logs, + Highlights, } impl Kind<'_> { @@ -44,6 +46,7 @@ impl Kind<'_> { Kind::Channel(server, _) => server, Kind::Query(server, _) => server, Kind::Logs => &server::LOGS, + Kind::Highlights => &server::HIGHLIGHTS, } } } @@ -55,6 +58,7 @@ impl From> for history::Kind { Kind::Channel(_, channel) => history::Kind::Channel(channel.to_string()), Kind::Query(_, nick) => history::Kind::Query(nick.clone()), Kind::Logs => history::Kind::Logs, + Kind::Highlights => history::Kind::Highlights, } } } @@ -274,6 +278,12 @@ impl State { ))), ) } + Message::Link(message::Link::GoToMessage(server, channel, message)) => { + return ( + Task::none(), + Some(Event::GoToMessage(server, channel, message)), + ) + } Message::ScrollTo(scroll_to::Result { prev, hit }) => { let scroll_to::Offsets { absolute, relative } = hit; @@ -329,7 +339,7 @@ impl State { pub fn scroll_to_message( &mut self, - message: &data::Message, + message: message::Hash, kind: Kind, history: &history::Manager, config: &Config, @@ -347,18 +357,18 @@ impl State { let Some(pos) = old_messages .iter() .chain(&new_messages) - .position(|m| m.hash == message.hash) + .position(|m| m.hash == message) else { return Task::none(); }; - // Get all messages from bottom until 1 before found message + // Get all messages from bottom until 1 before message let offset = total - pos + 1; self.limit = Limit::Bottom(offset.max(Limit::DEFAULT_COUNT)); self.status = Status::ScrollTo; - scroll_to::find_offsets(self.scrollable.clone(), scroll_to::Key::message(message)) + scroll_to::find_offsets(self.scrollable.clone(), scroll_to::Key::Message(message)) .map(Message::ScrollTo) } @@ -569,6 +579,8 @@ mod scroll_to { ) { if id == Some(&self.scrollable.clone().into()) { self.viewport = Some((bounds, content_bounds)); + } else { + self.viewport = None; } } diff --git a/src/buffer/server.rs b/src/buffer/server.rs index 71a43935..f083767c 100644 --- a/src/buffer/server.rs +++ b/src/buffer/server.rs @@ -130,9 +130,10 @@ impl Server { Message::ScrollView(message) => { 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), + let event = event.and_then(|event| match event { + scroll_view::Event::UserContext(event) => Some(Event::UserContext(event)), + scroll_view::Event::OpenChannel(channel) => Some(Event::OpenChannel(channel)), + scroll_view::Event::GoToMessage(_, _, _) => None, }); (command.map(Message::ScrollView), event) diff --git a/src/buffer/user_context.rs b/src/buffer/user_context.rs index 18a11ac1..459ba768 100644 --- a/src/buffer/user_context.rs +++ b/src/buffer/user_context.rs @@ -18,9 +18,9 @@ pub enum Entry { } impl Entry { - pub fn list(buffer: &Buffer, our_user: Option<&User>) -> Vec { + pub fn list(buffer: Option<&Buffer>, our_user: Option<&User>) -> Vec { match buffer { - Buffer::Channel(_, _) => { + Some(Buffer::Channel(_, _)) => { if our_user.is_some_and(|u| u.has_access_level(data::user::AccessLevel::Oper)) { vec![ Entry::UserInfo, @@ -41,7 +41,7 @@ impl Entry { ] } } - Buffer::Server(_) | Buffer::Query(_, _) => vec![Entry::Whois, Entry::SendFile], + _ => vec![Entry::Whois, Entry::SendFile], } } @@ -128,7 +128,7 @@ pub fn view<'a>( content: impl Into>, user: &'a User, current_user: Option<&'a User>, - buffer: &'a Buffer, + buffer: Option<&'a Buffer>, our_user: Option<&'a User>, ) -> Element<'a, Message> { let entries = Entry::list(buffer, our_user); diff --git a/src/icon.rs b/src/icon.rs index 0fb0ea05..daf95d28 100644 --- a/src/icon.rs +++ b/src/icon.rs @@ -84,10 +84,9 @@ pub fn documentation<'a>() -> Text<'a> { to_text('\u{E812}') } -// TODO: Highlight buffer. -// pub fn highlight<'a>() -> Text<'a> { -// to_text('\u{E811}') -// } +pub fn highlights<'a>() -> Text<'a> { + to_text('\u{E811}') +} fn to_text<'a>(unicode: char) -> Text<'a> { text(unicode.to_string()) diff --git a/src/main.rs b/src/main.rs index b63b0bad..76a09735 100644 --- a/src/main.rs +++ b/src/main.rs @@ -645,9 +645,24 @@ impl Halloy { ) { commands.push( dashboard - .record_message(&server, message) + .record_message(&server, message.clone()) .map(Message::Dashboard), ); + + if matches!( + notification, + data::client::Notification::Highlight { .. } + ) { + commands.extend( + message.into_highlight(server.clone()).map( + |message| { + dashboard + .record_highlight(message) + .map(Message::Dashboard) + }, + ), + ); + } } match notification { @@ -667,15 +682,18 @@ impl Halloy { ); } } - data::client::Notification::Highlight( + data::client::Notification::Highlight { + enabled, user, channel, - ) => { - notification::highlight( - &self.config.notifications, - user.nickname(), - channel, - ); + } => { + if enabled { + notification::highlight( + &self.config.notifications, + user.nickname(), + channel, + ); + } } data::client::Notification::MonitoredOnline( targets, diff --git a/src/screen/dashboard.rs b/src/screen/dashboard.rs index 38919056..e1595b4b 100644 --- a/src/screen/dashboard.rs +++ b/src/screen/dashboard.rs @@ -1,4 +1,5 @@ use chrono::{DateTime, Utc}; +use data::dashboard::BufferAction; use data::environment::{RELEASE_WEBSITE, WIKI_WEBSITE}; use data::history::ReadMarker; use std::collections::HashMap; @@ -257,9 +258,10 @@ impl Dashboard { Task::batch(vec![ task, self.open_buffer( - buffer, - config.buffer.clone().into(), main_window, + config.buffer.clone().into(), + |b| b.data() == Some(&buffer), + || Buffer::from(buffer.clone()), ), ]), None, @@ -349,6 +351,42 @@ impl Dashboard { None, ) } + buffer::Event::GoToMessage(server, channel, message) => { + let buffer = data::Buffer::Channel(server, channel); + + let mut tasks = vec![]; + + if self + .panes + .get_mut_by_buffer(main_window.id, &buffer) + .is_none() + { + tasks.push(self.open_buffer( + main_window, + config.buffer.clone().into(), + |b| b.data() == Some(&buffer), + || Buffer::from(buffer.clone()), + )); + } + + if let Some((window, pane, state)) = + self.panes.get_mut_by_buffer(main_window.id, &buffer) + { + tasks.push( + state + .buffer + .scroll_to_message(message, &self.history, config) + .map(move |message| { + Message::Pane( + window, + pane::Message::Buffer(pane, message), + ) + }), + ); + } + + return (Task::batch(tasks), None); + } } return (task, None); @@ -384,7 +422,12 @@ impl Dashboard { let (event_task, event) = match event { sidebar::Event::Open(kind) => ( - self.open_buffer(kind, config.buffer.clone().into(), main_window), + self.open_buffer( + main_window, + config.buffer.clone().into(), + |b| b.data() == Some(&kind), + || Buffer::from(kind.clone()), + ), None, ), sidebar::Event::Popout(buffer) => ( @@ -454,6 +497,9 @@ impl Dashboard { (self.toggle_file_transfers(config, main_window), None) } sidebar::Event::ToggleLogs => (self.toggle_logs(config, main_window), None), + sidebar::Event::ToggleHighlights => { + (self.toggle_highlights(config, main_window), None) + } sidebar::Event::ToggleCommandBar => ( self.toggle_command_bar( &closed_buffers(self, main_window.id, clients), @@ -1082,47 +1128,13 @@ impl Dashboard { } } - // TODO: Perhaps rewrite this, i just did this quickly. fn toggle_file_transfers(&mut self, config: &Config, main_window: &Window) -> Task { - let panes = self.panes.clone(); - - // If file transfers already is open, we close it. - for (window, id, pane) in panes.iter(main_window.id) { - if matches!(pane.buffer, Buffer::FileTransfers(_)) { - return self.close_pane(main_window, window, id); - } - } - - // If we only have one pane, and its empty, we replace it. - if self.panes.len() == 1 { - for (id, pane) in panes.main.iter() { - if let Buffer::Empty = &pane.buffer { - self.panes.main.panes.entry(*id).and_modify(|p| { - *p = Pane::new(Buffer::FileTransfers(buffer::FileTransfers::new()), config) - }); - self.last_changed = Some(Instant::now()); - - return self.focus_pane(main_window, main_window.id, *id); - } - } - } - - let mut commands = vec![]; - let _ = self.new_pane(pane_grid::Axis::Vertical, config, main_window); - - if let Some((window, pane)) = self.focus.take() { - if let Some(state) = self.panes.get_mut(main_window.id, window, pane) { - state.buffer = Buffer::FileTransfers(buffer::FileTransfers::new()); - self.last_changed = Some(Instant::now()); - - commands.extend(vec![ - self.reset_pane(main_window, window, pane), - self.focus_pane(main_window, window, pane), - ]); - } - } - - Task::batch(commands) + self.toggle_buffer( + config, + main_window, + |buffer| matches!(buffer, Buffer::FileTransfers(_)), + || Buffer::FileTransfers(buffer::FileTransfers::new()), + ) } fn toggle_theme_editor(&mut self, theme: &mut Theme, main_window: &Window) -> Task { @@ -1139,60 +1151,65 @@ impl Dashboard { } fn toggle_logs(&mut self, config: &Config, main_window: &Window) -> Task { - let panes = self.panes.clone(); - - // If logs already is open, we close it. - for (window, id, pane) in panes.iter(main_window.id) { - if matches!(pane.buffer, Buffer::Logs(_)) { - return self.close_pane(main_window, window, id); - } - } - - // If we only have one pane, and its empty, we replace it. - if self.panes.len() == 1 { - for (id, pane) in panes.main.iter() { - if let Buffer::Empty = &pane.buffer { - self.panes - .main - .panes - .entry(*id) - .and_modify(|p| *p = Pane::new(Buffer::Logs(buffer::Logs::new()), config)); - self.last_changed = Some(Instant::now()); + self.toggle_buffer( + config, + main_window, + |buffer| matches!(buffer, Buffer::Logs(_)), + || Buffer::Logs(buffer::Logs::new()), + ) + } - return self.focus_pane(main_window, main_window.id, *id); - } - } - } + fn toggle_highlights(&mut self, config: &Config, main_window: &Window) -> Task { + self.toggle_buffer( + config, + main_window, + |buffer| matches!(buffer, Buffer::Highlights(_)), + || Buffer::Highlights(buffer::Highlights::new()), + ) + } - let mut commands = vec![]; - let _ = self.new_pane(pane_grid::Axis::Vertical, config, main_window); + fn toggle_buffer( + &mut self, + config: &Config, + main_window: &Window, + matches: impl Fn(&Buffer) -> bool, + new: impl Fn() -> Buffer, + ) -> Task { + let panes = self.panes.clone(); - if let Some((window, pane)) = self.focus.take() { - if let Some(state) = self.panes.get_mut(main_window.id, window, pane) { - state.buffer = Buffer::Logs(buffer::Logs::new()); - self.last_changed = Some(Instant::now()); + let open = panes + .iter(main_window.id) + .find_map(|(window_id, pane, state)| { + (matches)(&state.buffer).then_some((window_id, pane)) + }); - commands.extend(vec![ - self.reset_pane(main_window, window, pane), - self.focus_pane(main_window, window, pane), - ]); + if let Some((window, pane)) = open { + self.close_pane(main_window, window, pane) + } else { + match config.sidebar.buffer_action { + // Don't replace for file transfer / logs / highlights + BufferAction::NewPane | BufferAction::ReplacePane => { + self.open_buffer(main_window, config.buffer.clone().into(), matches, new) + } + BufferAction::NewWindow => { + self.open_popout_window(main_window, Pane::new(new(), config)) + } } } - - Task::batch(commands) } fn open_buffer( &mut self, - kind: data::Buffer, - settings: buffer::Settings, main_window: &Window, + settings: buffer::Settings, + matches: impl Fn(&Buffer) -> bool, + new: impl Fn() -> Buffer, ) -> Task { let panes = self.panes.clone(); // If channel already is open, we focus it. for (window, id, pane) in panes.iter(main_window.id) { - if pane.buffer.data() == Some(&kind) { + if matches(&pane.buffer) { self.focus = Some((window, id)); return self.focus_pane(main_window, window, id); @@ -1207,7 +1224,7 @@ impl Dashboard { .main .panes .entry(*id) - .and_modify(|p| *p = Pane::with_settings(Buffer::from(kind), settings)); + .and_modify(|p| *p = Pane::with_settings(new(), settings)); self.last_changed = Some(Instant::now()); return self.focus_pane(main_window, main_window.id, *id); @@ -1228,11 +1245,10 @@ impl Dashboard { } }; - let result = self.panes.main.split( - axis, - pane_to_split, - Pane::with_settings(Buffer::from(kind), settings), - ); + let result = + self.panes + .main + .split(axis, pane_to_split, Pane::with_settings(new(), settings)); self.last_changed = Some(Instant::now()); if let Some((pane, _)) = result { @@ -1318,6 +1334,14 @@ impl Dashboard { } } + pub fn record_highlight(&mut self, message: data::Message) -> Task { + if let Some(task) = self.history.record_highlight(message) { + Task::perform(task, Message::History) + } else { + Task::none() + } + } + pub fn broadcast( &mut self, server: &Server, @@ -1522,7 +1546,12 @@ impl Dashboard { .and_then(|panes| panes.get(pane).cloned()) { let task = match pane.buffer.data().cloned() { - Some(buffer) => self.open_buffer(buffer, pane.settings, main_window), + Some(buffer) => self.open_buffer( + main_window, + pane.settings, + |b| b.data() == Some(&buffer), + || Buffer::from(buffer.clone()), + ), None if matches!(pane.buffer, Buffer::FileTransfers(_)) => { self.toggle_file_transfers(config, main_window) } @@ -1714,6 +1743,10 @@ impl Dashboard { Buffer::Logs(buffer::Logs::new()), buffer::Settings::default(), )), + data::Pane::Highlights => Configuration::Pane(Pane::with_settings( + Buffer::Highlights(buffer::Highlights::new()), + buffer::Settings::default(), + )), } } @@ -1886,7 +1919,12 @@ impl Dashboard { 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) + self.open_buffer( + main_window, + config.buffer.clone().into(), + |b| b.data() == Some(&buffer), + || Buffer::from(buffer.clone()), + ) } } } @@ -1969,6 +2007,15 @@ impl Panes { } } + fn get_mut_by_buffer( + &mut self, + main_window: window::Id, + buffer: &data::Buffer, + ) -> Option<(window::Id, pane_grid::Pane, &mut Pane)> { + self.iter_mut(main_window) + .find(|(_, _, state)| state.buffer.data().is_some_and(|b| b == buffer)) + } + fn iter( &self, main_window: window::Id, diff --git a/src/screen/dashboard/pane.rs b/src/screen/dashboard/pane.rs index 6c9d6e4e..4c36fd83 100644 --- a/src/screen/dashboard/pane.rs +++ b/src/screen/dashboard/pane.rs @@ -83,6 +83,7 @@ impl Pane { } Buffer::FileTransfers(_) => "File Transfers".to_string(), Buffer::Logs(_) => "Logs".to_string(), + Buffer::Highlights(_) => "Highlights".to_string(), }; let title_bar = self.title_bar.view( @@ -134,6 +135,7 @@ impl Pane { }), Buffer::FileTransfers(_) => None, Buffer::Logs(_) => Some(history::Resource::logs()), + Buffer::Highlights(_) => Some(history::Resource::highlights()), } } @@ -305,6 +307,7 @@ impl From for data::Pane { Buffer::Query(state) => data::Buffer::Query(state.server, state.nick), Buffer::FileTransfers(_) => return data::Pane::FileTransfers, Buffer::Logs(_) => return data::Pane::Logs, + Buffer::Highlights(_) => return data::Pane::Highlights, }; data::Pane::Buffer { diff --git a/src/screen/dashboard/sidebar.rs b/src/screen/dashboard/sidebar.rs index 7b240209..a508208a 100644 --- a/src/screen/dashboard/sidebar.rs +++ b/src/screen/dashboard/sidebar.rs @@ -27,6 +27,7 @@ pub enum Message { Leave(Buffer), ToggleFileTransfers, ToggleLogs, + ToggleHighlights, ToggleCommandBar, ToggleThemeEditor, ReloadingConfigFile, @@ -48,6 +49,7 @@ pub enum Event { Leave(Buffer), ToggleFileTransfers, ToggleLogs, + ToggleHighlights, ToggleCommandBar, ToggleThemeEditor, OpenReleaseWebsite, @@ -95,6 +97,7 @@ impl Sidebar { Message::Leave(buffer) => (Task::none(), Some(Event::Leave(buffer))), Message::ToggleFileTransfers => (Task::none(), Some(Event::ToggleFileTransfers)), Message::ToggleLogs => (Task::none(), Some(Event::ToggleLogs)), + Message::ToggleHighlights => (Task::none(), Some(Event::ToggleHighlights)), Message::ToggleCommandBar => (Task::none(), Some(Event::ToggleCommandBar)), Message::ToggleThemeEditor => (Task::none(), Some(Event::ToggleThemeEditor)), Message::ReloadingConfigFile => { @@ -187,6 +190,13 @@ impl Sidebar { }), Message::ToggleFileTransfers, ), + Menu::Highlights => context_button( + text("Highlights"), + // TODO: Add keybind + None, + icon::highlights(), + Message::ToggleHighlights, + ), Menu::Logs => context_button( text("Logs"), Some(&keyboard.logs), @@ -391,6 +401,7 @@ enum Menu { RefreshConfig, CommandBar, ThemeEditor, + Highlights, Logs, FileTransfers, Version, @@ -405,6 +416,7 @@ impl Menu { Menu::HorizontalRule, Menu::CommandBar, Menu::FileTransfers, + Menu::Highlights, Menu::Logs, Menu::RefreshConfig, Menu::ThemeEditor,