diff --git a/Cargo.lock b/Cargo.lock index 9ccf37f3f..318e5c82b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -239,6 +239,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + [[package]] name = "ellipse" version = "0.2.0" @@ -744,6 +750,15 @@ dependencies = [ "serde", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.5" @@ -1276,6 +1291,7 @@ dependencies = [ "gtk4", "image", "indexmap", + "itertools", "libadwaita", "locale_config", "log", diff --git a/Cargo.toml b/Cargo.toml index 9269e1bf9..860ab3545 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ gettext-rs = { version = "0.7", features = ["gettext-system"] } gtk = { version = "0.6", package = "gtk4", features = ["v4_8", "blueprint"] } image = { version = "0.24", default-features = false, features = ["webp"] } indexmap = "1.9" +itertools = "0.10.5" locale_config = "0.3" log = "0.4" once_cell = "1.17" diff --git a/src/session/content/chat_history_item.rs b/src/session/content/chat_history_item.rs index cf1682790..57ad3cb25 100644 --- a/src/session/content/chat_history_item.rs +++ b/src/session/content/chat_history_item.rs @@ -12,13 +12,25 @@ pub(crate) enum ChatHistoryItemType { DayDivider(DateTime), } +#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, glib::Enum)] +#[enum_type(name = "MessageStyle")] +pub(crate) enum MessageStyle { + #[default] + Single, + First, + Last, + Center, +} + mod imp { use super::*; use once_cell::sync::{Lazy, OnceCell}; + use std::cell::Cell; #[derive(Debug, Default)] pub(crate) struct ChatHistoryItem { pub(super) type_: OnceCell, + pub(super) style: Cell, } #[glib::object_subclass] @@ -30,20 +42,33 @@ mod imp { impl ObjectImpl for ChatHistoryItem { fn properties() -> &'static [glib::ParamSpec] { static PROPERTIES: Lazy> = Lazy::new(|| { - vec![glib::ParamSpecBoxed::builder::("type") - .write_only() - .construct_only() - .build()] + vec![ + glib::ParamSpecBoxed::builder::("type") + .write_only() + .construct_only() + .build(), + glib::ParamSpecEnum::builder::("style").build(), + ] }); PROPERTIES.as_ref() } + fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "style" => self.style.get().into(), + _ => unimplemented!(), + } + } + fn set_property(&self, _id: usize, value: &glib::Value, pspec: &glib::ParamSpec) { match pspec.name() { "type" => { let type_ = value.get::().unwrap(); self.type_.set(type_).unwrap(); } + "style" => { + self.style.set(value.get().unwrap()); + } _ => unimplemented!(), } } @@ -69,6 +94,52 @@ impl ChatHistoryItem { self.imp().type_.get().unwrap() } + pub(crate) fn style(&self) -> MessageStyle { + self.imp().style.get() + } + + pub(crate) fn set_style(&self, style: MessageStyle) { + self.imp().style.set(style); + } + + pub(crate) fn is_groupable(&self) -> bool { + if let Some(message) = self.message() { + use tdlib::enums::MessageContent::*; + matches!( + message.content().0, + MessageText(_) + | MessageAnimation(_) + | MessageAudio(_) + | MessageDocument(_) + | MessagePhoto(_) + | MessageSticker(_) + | MessageVideo(_) + | MessageVideoNote(_) + | MessageVoiceNote(_) + | MessageLocation(_) + | MessageVenue(_) + | MessageContact(_) + | MessageAnimatedEmoji(_) + | MessageDice(_) + | MessageGame(_) + | MessagePoll(_) + | MessageInvoice(_) + | MessageCall(_) + | MessageUnsupported + ) + } else { + false + } + } + + pub(crate) fn group_key(&self) -> Option<(bool, i64)> { + if self.is_groupable() { + self.message().map(|m| (m.is_outgoing(), m.sender().id())) + } else { + None + } + } + pub(crate) fn message(&self) -> Option<&Message> { if let ChatHistoryItemType::Message(message) = self.type_() { Some(message) diff --git a/src/session/content/chat_history_model.rs b/src/session/content/chat_history_model.rs index 984f4f9f9..fc66f7efd 100644 --- a/src/session/content/chat_history_model.rs +++ b/src/session/content/chat_history_model.rs @@ -2,10 +2,11 @@ use glib::clone; use gtk::prelude::*; use gtk::subclass::prelude::*; use gtk::{gio, glib}; +use itertools::Itertools; use std::cmp::Ordering; use thiserror::Error; -use crate::session::content::{ChatHistoryItem, ChatHistoryItemType}; +use crate::session::content::{ChatHistoryItem, ChatHistoryItemType, MessageStyle}; use crate::tdlib::{Chat, Message}; #[derive(Error, Debug)] @@ -228,6 +229,68 @@ impl ChatHistoryModel { (position as u32, removed) }; + // Set message styles + { + let list = imp.list.borrow_mut(); + let range = list.range((position as usize)..(position as usize + added as usize)); + for (_, mut group) in &range.group_by(|item| item.group_key()) { + use MessageStyle::*; + if let Some(last) = group.next() { + if let Some(first) = group + .map(|m| { + m.set_style(Center); + m + }) + .last() + { + last.set_style(Last); + first.set_style(First); + } else { + last.set_style(Single); + } + } + } + + // Check corner cases + fn refresh_grouping_at_connection(first: &ChatHistoryItem, second: &ChatHistoryItem) { + if first.group_key() == second.group_key() { + use MessageStyle::*; + + let new_style = match first.style() { + Single => First, + Last => Center, + other => other, + }; + + first.set_style(new_style); + + let new_style = match second.style() { + Single => Last, + First => Center, + other => other, + }; + + second.set_style(new_style); + } + } + + if position > 0 { + if let Some(after_last) = list.get(position as usize - 1) { + if let Some(last) = list.get(position as usize) { + refresh_grouping_at_connection(last, after_last); + } + } + } + + if position + added + 1 < list.len() as u32 { + if let Some(before_first) = list.get((position + added) as usize + 1) { + if let Some(first) = list.get((position + added) as usize) { + refresh_grouping_at_connection(before_first, first); + } + } + } + } + self.upcast_ref::() .items_changed(position, removed, added); } diff --git a/src/session/content/message_row/bubble.rs b/src/session/content/message_row/bubble.rs index 839dfbcbc..47b679c6e 100644 --- a/src/session/content/message_row/bubble.rs +++ b/src/session/content/message_row/bubble.rs @@ -4,7 +4,8 @@ use gtk::{glib, CompositeTemplate}; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; -use crate::session::content::message_row::{MessageIndicators, MessageLabel}; +use crate::session::content::message_row::{MessageIndicators, MessageLabel, MessageRow}; +use crate::session::content::MessageStyle; use crate::tdlib::{Chat, ChatType, Message, MessageSender, SponsoredMessage}; const MAX_WIDTH: i32 = 400; @@ -115,6 +116,20 @@ mod imp { } impl WidgetImpl for MessageBubble { + fn map(&self) { + self.parent_map(); + if let Some(style) = self.style() { + use MessageStyle::*; + let label_is_visible = match style { + Single | First => true, + Last | Center => false, + }; + if self.sender_color_class.borrow().is_some() { + self.sender_label.set_visible(label_is_visible); + } + } + } + fn measure(&self, orientation: gtk::Orientation, for_size: i32) -> (i32, i32, i32, i32) { // Limit the widget width if orientation == gtk::Orientation::Horizontal { @@ -141,6 +156,17 @@ mod imp { gtk::SizeRequestMode::HeightForWidth } } + + impl MessageBubble { + fn style(&self) -> Option { + self.obj() + .parent()? + .parent()? + .downcast::() + .ok()? + .message_style() + } + } } glib::wrapper! { diff --git a/src/session/content/message_row/mod.rs b/src/session/content/message_row/mod.rs index db9e0746b..6442e6218 100644 --- a/src/session/content/message_row/mod.rs +++ b/src/session/content/message_row/mod.rs @@ -31,6 +31,7 @@ use gtk::{gio, glib, CompositeTemplate}; use tdlib::enums::{MessageContent, StickerFormat}; use crate::components::Avatar; +use crate::session::content::{ChatHistoryItem, ChatHistoryRow, MessageStyle}; use crate::tdlib::{Chat, ChatType, Message, MessageForwardOrigin, MessageSender}; use crate::utils::spawn; @@ -124,7 +125,27 @@ mod imp { } } - impl WidgetImpl for MessageRow {} + impl WidgetImpl for MessageRow { + fn map(&self) { + self.parent_map(); + let obj = self.obj(); + if let Some(style) = obj.message_style() { + use MessageStyle::*; + let avatar_is_visible = match style { + Single | Last => true, + First | Center => false, + }; + if let Some(avatar) = self.avatar.borrow().as_ref() { + avatar.set_visible(avatar_is_visible); + if !avatar_is_visible { + obj.set_margin_start(AVATAR_SIZE + 6); + } else { + obj.set_margin_start(0); + } + } + } + } + } } glib::wrapper! { @@ -202,6 +223,17 @@ impl MessageRow { self.imp().message.borrow().clone().unwrap() } + pub(crate) fn message_style(&self) -> Option { + let item = self + .parent()? + .downcast::() + .ok()? + .item()? + .downcast::() + .ok()?; + Some(item.style()) + } + pub(crate) fn set_message(&self, message: glib::Object) { let imp = self.imp(); diff --git a/src/session/content/mod.rs b/src/session/content/mod.rs index 2a9b4b30a..79a190a09 100644 --- a/src/session/content/mod.rs +++ b/src/session/content/mod.rs @@ -10,7 +10,7 @@ mod send_photo_dialog; use self::chat_action_bar::ChatActionBar; use self::chat_history::ChatHistory; -use self::chat_history_item::{ChatHistoryItem, ChatHistoryItemType}; +use self::chat_history_item::{ChatHistoryItem, ChatHistoryItemType, MessageStyle}; use self::chat_history_model::{ChatHistoryError, ChatHistoryModel}; use self::chat_history_row::ChatHistoryRow; use self::chat_info_window::ChatInfoWindow;