diff --git a/data/src/history.rs b/data/src/history.rs index 63f4d7293..2b9862fa4 100644 --- a/data/src/history.rs +++ b/data/src/history.rs @@ -8,6 +8,7 @@ use tokio::fs; use tokio::time::Instant; pub use self::manager::{Manager, Resource}; +use crate::time::Posix; use crate::user::Nick; use crate::{compression, environment, message, server, Message}; @@ -120,29 +121,27 @@ pub enum History { kind: Kind, messages: Vec, last_received_at: Option, + unread_message_count: usize, + opened_at: Posix, }, Full { server: server::Server, kind: Kind, messages: Vec, last_received_at: Option, + opened_at: Posix, }, } impl History { - fn partial(server: server::Server, kind: Kind) -> Self { + fn partial(server: server::Server, kind: Kind, opened_at: Posix) -> Self { Self::Partial { server, kind, messages: vec![], last_received_at: None, - } - } - - fn messages(&self) -> &[Message] { - match self { - History::Partial { messages, .. } => messages, - History::Full { messages, .. } => messages, + unread_message_count: 0, + opened_at, } } @@ -151,8 +150,13 @@ impl History { History::Partial { messages, last_received_at, + unread_message_count, .. } => { + if message.triggers_unread() { + *unread_message_count += 1; + } + messages.push(message); *last_received_at = Some(Instant::now()); } @@ -174,6 +178,7 @@ impl History { kind, messages, last_received_at, + .. } => { if let Some(last_received) = *last_received_at { let since = now.duration_since(last_received); @@ -195,6 +200,7 @@ impl History { kind, messages, last_received_at, + .. } => { if let Some(last_received) = *last_received_at { let since = now.duration_since(last_received); @@ -234,7 +240,7 @@ impl History { let kind = kind.clone(); let messages = std::mem::take(messages); - *self = Self::partial(server.clone(), kind.clone()); + *self = Self::partial(server.clone(), kind.clone(), Posix::now()); Some(async move { overwrite(&server, &kind, &messages).await }) } @@ -258,6 +264,14 @@ impl History { } } } + +#[derive(Debug)] +pub struct View<'a> { + pub total: usize, + pub old_messages: Vec<&'a Message>, + pub new_messages: Vec<&'a Message>, +} + #[derive(Debug, thiserror::Error)] pub enum Error { #[error("can't resolve data directory")] diff --git a/data/src/history/manager.rs b/data/src/history/manager.rs index eaae71a20..169bac7ff 100644 --- a/data/src/history/manager.rs +++ b/data/src/history/manager.rs @@ -7,6 +7,7 @@ use tokio::time::Instant; use crate::history::{self, History}; use crate::message::{self, Limit}; +use crate::time::Posix; use crate::user::Nick; use crate::{server, Server}; @@ -133,30 +134,18 @@ impl Manager { server: &Server, channel: &str, limit: Option, - ) -> (usize, Vec<&crate::Message>) { + ) -> Option> { self.data - .messages(server, &history::Kind::Channel(channel.to_string())) - .map(|messages| { - let total = messages.len(); - - (total, with_limit(limit, messages.iter())) - }) - .unwrap_or_else(|| (0, vec![])) + .history_view(server, &history::Kind::Channel(channel.to_string()), limit) } pub fn get_server_messages( &self, server: &Server, limit: Option, - ) -> (usize, Vec<&crate::Message>) { + ) -> Option> { self.data - .messages(server, &history::Kind::Server) - .map(|messages| { - let total = messages.len(); - - (total, with_limit(limit, messages.iter())) - }) - .unwrap_or_else(|| (0, vec![])) + .history_view(server, &history::Kind::Server, limit) } pub fn get_query_messages( @@ -164,15 +153,9 @@ impl Manager { server: &Server, nick: &Nick, limit: Option, - ) -> (usize, Vec<&crate::Message>) { + ) -> Option> { self.data - .messages(server, &history::Kind::Query(nick.clone())) - .map(|messages| { - let total = messages.len(); - - (total, with_limit(limit, messages.iter())) - }) - .unwrap_or_else(|| (0, vec![])) + .history_view(server, &history::Kind::Query(nick.clone()), limit) } pub fn get_unique_queries(&self, server: &Server) -> Vec<&Nick> { @@ -192,6 +175,23 @@ impl Manager { queries } + pub fn has_unread(&self, server: &Server, kind: &history::Kind) -> bool { + self.data + .map + .get(server) + .and_then(|map| map.get(kind)) + .map(|history| { + matches!( + history, + History::Partial { + unread_message_count, + .. + } if *unread_message_count > 0 + ) + }) + .unwrap_or_default() + } + pub fn broadcast(&mut self, server: &Server, broadcast: Broadcast) { let Some(map) = self.data.map.get(server) else { return; @@ -275,15 +275,18 @@ impl Data { History::Partial { messages: new_messages, last_received_at, + opened_at, .. } => { let last_received_at = *last_received_at; + let opened_at = *opened_at; messages.extend(std::mem::take(new_messages)); entry.insert(History::Full { server, kind, messages, last_received_at, + opened_at, }); } _ => { @@ -292,6 +295,7 @@ impl Data { kind, messages, last_received_at: None, + opened_at: Posix::now(), }); } }, @@ -301,16 +305,37 @@ impl Data { kind, messages, last_received_at: None, + opened_at: Posix::now(), }); } } } - fn messages(&self, server: &server::Server, kind: &history::Kind) -> Option<&[crate::Message]> { - self.map - .get(server) - .and_then(|map| map.get(kind)) - .map(History::messages) + fn history_view( + &self, + server: &server::Server, + kind: &history::Kind, + limit: Option, + ) -> Option { + let History::Full { messages, opened_at, .. } = self.map.get(server)?.get(kind)? else { + return None; + }; + + let total = messages.len(); + let limited = with_limit(limit, messages.iter()); + + let split_at = limited + .iter() + .position(|message| message.received_at >= *opened_at) + .unwrap_or(limited.len()); + + let (old, new) = limited.split_at(split_at); + + Some(history::View { + total, + old_messages: old.to_vec(), + new_messages: new.to_vec(), + }) } fn add_message( @@ -323,7 +348,7 @@ impl Data { .entry(server.clone()) .or_default() .entry(kind.clone()) - .or_insert_with(|| History::partial(server, kind)) + .or_insert_with(|| History::partial(server, kind, message.received_at)) .add_message(message) } diff --git a/data/src/message.rs b/data/src/message.rs index 47d01d93b..1a34bcd69 100644 --- a/data/src/message.rs +++ b/data/src/message.rs @@ -39,6 +39,16 @@ pub enum Source { Query(Nick, Sender), } +impl Source { + pub fn sender(&self) -> Option<&Sender> { + match self { + Source::Server => None, + Source::Channel(_, sender) => Some(sender), + Source::Query(_, sender) => Some(sender), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Sender { User(User), @@ -92,6 +102,11 @@ impl Message { } } + pub fn triggers_unread(&self) -> bool { + matches!(self.direction, Direction::Received) + && matches!(self.source.sender(), Some(Sender::User(_) | Sender::Action)) + } + pub fn received(encoded: Encoded, our_nick: &Nick) -> Option { let server_time = server_time(&encoded); let text = text(&encoded, our_nick)?; diff --git a/src/buffer/scroll_view.rs b/src/buffer/scroll_view.rs index 006b6bd1c..22799b832 100644 --- a/src/buffer/scroll_view.rs +++ b/src/buffer/scroll_view.rs @@ -2,10 +2,11 @@ use data::message::Limit; use data::server::Server; use data::user::Nick; use data::{history, time}; -use iced::widget::scrollable; +use iced::widget::{column, container, horizontal_rule, scrollable}; use iced::{Command, Length}; -use crate::widget::{Column, Element}; +use crate::theme; +use crate::widget::Element; #[derive(Debug, Clone)] pub enum Message { @@ -31,42 +32,66 @@ pub fn view<'a>( history: &'a history::Manager, format: impl Fn(&'a data::Message) -> Option> + 'a, ) -> Element<'a, Message> { - let (total, messages) = match kind { + let Some(history::View { + total, + old_messages, + new_messages, + }) = (match kind { Kind::Server(server) => history.get_server_messages(server, Some(state.limit)), Kind::Channel(server, channel) => { history.get_channel_messages(server, channel, Some(state.limit)) } Kind::Query(server, user) => history.get_query_messages(server, user, Some(state.limit)), + }) else { + return column![].into(); }; - let count = messages.len(); + let count = old_messages.len() + new_messages.len(); let remaining = count < total; - let oldest = messages - .first() + let oldest = old_messages + .iter() + .chain(&new_messages) + .next() .map(|message| message.received_at) .unwrap_or_else(time::Posix::now); let status = state.status; - scrollable( - Column::with_children(messages.into_iter().filter_map(format).collect()) + let old = old_messages + .into_iter() + .filter_map(&format) + .collect::>(); + let new = new_messages + .into_iter() + .filter_map(format) + .collect::>(); + + let show_divider = !new.is_empty() || matches!(status, Status::Idle(Anchor::Bottom)); + + let content = if show_divider { + let divider = container(horizontal_rule(1).style(theme::Rule::Unread)) .width(Length::Fill) - .padding([0, 8]), - ) - .vertical_scroll( - scrollable::Properties::default() - .alignment(status.alignment()) - .width(5) - .scroller_width(5), - ) - .on_scroll(move |viewport| Message::Scrolled { - count, - remaining, - oldest, - status, - viewport, - }) - .id(state.scrollable.clone()) - .into() + .padding(5); + column![column(old), divider, column(new)] + } else { + column![column(old), column(new)] + }; + + scrollable(container(content).width(Length::Fill).padding([0, 8])) + .vertical_scroll( + scrollable::Properties::default() + .alignment(status.alignment()) + .width(5) + .scroller_width(5), + ) + .on_scroll(move |viewport| Message::Scrolled { + count, + remaining, + oldest, + status, + viewport, + }) + .id(state.scrollable.clone()) + .into() } #[derive(Debug, Clone)] diff --git a/src/icon.rs b/src/icon.rs index 6668a628f..5f5448254 100644 --- a/src/icon.rs +++ b/src/icon.rs @@ -6,6 +6,10 @@ use crate::{font, theme}; // Based off https://github.com/iced-rs/iced_aw/blob/main/src/graphics/icons/bootstrap.rs +pub fn dot<'a>() -> Text<'a> { + to_text('\u{f287}') +} + pub fn error<'a>() -> Text<'a> { to_text('\u{f33a}') } @@ -18,14 +22,6 @@ pub fn wifi_off<'a>() -> Text<'a> { to_text('\u{f61b}') } -pub fn chat<'a>() -> Text<'a> { - to_text('\u{f267}') -} - -pub fn person<'a>() -> Text<'a> { - to_text('\u{f4e1}') -} - pub fn close<'a>() -> Text<'a> { to_text('\u{f659}') } diff --git a/src/screen/dashboard/side_menu.rs b/src/screen/dashboard/side_menu.rs index b275b5968..34f1c9586 100644 --- a/src/screen/dashboard/side_menu.rs +++ b/src/screen/dashboard/side_menu.rs @@ -5,7 +5,7 @@ use iced::widget::{ use iced::Length; use super::pane::Pane; -use crate::widget::{context_menu, Element}; +use crate::widget::{context_menu, Collection, Element}; use crate::{icon, theme}; #[derive(Debug, Clone)] @@ -58,6 +58,7 @@ impl SideMenu { focus, Buffer::Server(server.clone()), false, + false, )); } data::client::State::Ready(connection) => { @@ -66,6 +67,7 @@ impl SideMenu { focus, Buffer::Server(server.clone()), true, + false, )); for channel in connection.channels() { @@ -74,6 +76,7 @@ impl SideMenu { focus, Buffer::Channel(server.clone(), channel.clone()), true, + history.has_unread(server, &history::Kind::Channel(channel.clone())), )); } @@ -84,6 +87,7 @@ impl SideMenu { focus, Buffer::Query(server.clone(), user.clone()), true, + history.has_unread(server, &history::Kind::Query(user.clone())), )); } @@ -141,6 +145,7 @@ fn buffer_button<'a>( focus: Option, buffer: Buffer, connected: bool, + has_unread: bool, ) -> Element<'a, Message> { let open = panes .iter() @@ -157,20 +162,18 @@ fn buffer_button<'a>( ] .spacing(8) .align_items(iced::Alignment::Center), - Buffer::Channel(_, channel) => row![ - horizontal_space(4), - icon::chat(), - text(channel).style(theme::Text::Primary) - ] - .spacing(8) - .align_items(iced::Alignment::Center), - Buffer::Query(_, nick) => row![ - horizontal_space(4), - icon::person(), - text(nick).style(theme::Text::Primary) - ] - .spacing(8) - .align_items(iced::Alignment::Center), + Buffer::Channel(_, channel) => row![] + .push(horizontal_space(3)) + .push_maybe(has_unread.then_some(icon::dot().size(6).style(theme::Text::Info))) + .push(horizontal_space(if has_unread { 10 } else { 16 })) + .push(text(channel).style(theme::Text::Primary)) + .align_items(iced::Alignment::Center), + Buffer::Query(_, nick) => row![] + .push(horizontal_space(3)) + .push_maybe(has_unread.then_some(icon::dot().size(6).style(theme::Text::Info))) + .push(horizontal_space(if has_unread { 10 } else { 16 })) + .push(text(nick).style(theme::Text::Primary)) + .align_items(iced::Alignment::Center), }; let base = button(row) diff --git a/src/theme.rs b/src/theme.rs index ac79f671e..53fe77031 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -131,6 +131,7 @@ impl Subpalette { pub enum Rule { #[default] Default, + Unread, } impl rule::StyleSheet for Theme { @@ -144,6 +145,12 @@ impl rule::StyleSheet for Theme { radius: 0.0.into(), fill_mode: rule::FillMode::Full, }, + Rule::Unread => rule::Appearance { + color: self.colors.accent.base, + width: 1, + radius: 0.0.into(), + fill_mode: rule::FillMode::Full, + }, } } } @@ -187,7 +194,7 @@ impl text::StyleSheet for Theme { color: Some(self.colors.error.base), }, Text::Nickname(seed) => { - let original_color = self.colors.accent.base; + let original_color = self.colors.action.base; let color = seed .map(|seed| palette::randomize_color(original_color, seed.as_str())) .unwrap_or_else(|| original_color); @@ -300,7 +307,10 @@ impl button::StyleSheet for Theme { ..Default::default() }, Button::SideMenu { selected } if *selected => button::Appearance { - background: Some(Background::Color(self.colors.accent.alpha_02)), + background: Some(Background::Color(Color { + a: 0.8, + ..self.colors.background.darken_06 + })), border_radius: 3.0.into(), ..Default::default() }, @@ -358,11 +368,11 @@ impl button::StyleSheet for Theme { ..Default::default() }, Button::SideMenu { selected } if *selected => button::Appearance { - background: Some(Background::Color(self.colors.accent.alpha_04)), + background: Some(Background::Color(self.colors.background.darken_09)), ..active }, Button::SideMenu { .. } => button::Appearance { - background: Some(Background::Color(self.colors.background.mute_06)), + background: Some(Background::Color(self.colors.background.darken_03)), border_radius: 3.0.into(), ..active },