Files
flare/src/backend/message.rs
T

465 lines
17 KiB
Rust

use super::{Contact, Manager};
use crate::backend::{Attachment, Channel};
use crate::prelude::*;
use ftypes::{AmendmentMessage, MessageId, ReactionOperation};
use gtk::pango::{AttrColor, AttrFontDesc, AttrInt, AttrList, Attribute, FontDescription, Weight};
use glib::{
Object,
subclass::{prelude::ObjectImpl, types::ObjectSubclassIsExt},
};
const MENTION_CHAR: char = '@';
const MENTION_COLOR: (u16, u16, u16) = (0, 0, u16::MAX);
/// At least 4 minutes need to pass such that for two messages from the same sender, the second one will
/// also show avatar and sender title.
const MESSAGE_SENT_SHOW_NAME_DURATION: u64 = 4 * 60 * 1000;
/// At least 1 minute need to pass such that for two messages from the same sender, the second one will
/// also show the timestamp.
const MESSAGE_SENT_SHOW_TIMESTAMP_DURATION: u64 = 60 * 1000;
glib::wrapper! {
pub struct Message(ObjectSubclass<imp::Message>);
}
impl Message {
pub(super) async fn from_ftypes(
inner: ftypes::RichMessage,
manager: &Manager,
) -> Result<Self, ftypes::Error> {
let channel = manager
.channel_from_id(inner.thread)
.await?
.expect("Message to have a channel");
let contact = manager
.contact_from_id(inner.sender())
.await?
.expect("Message to have a contact");
let quote = match &inner.content.inner {
ftypes::MainMessageContent::Text(text_message) => text_message.quote.as_ref(),
_ => None,
};
let quote = if let Some(quote) = quote {
// TODO: Error?
Box::pin(manager.message(quote.quoted_message))
.await
.ok()
.flatten()
} else {
None
};
let s = Object::builder::<Message>()
.property("manager", manager)
.property("sender", &contact)
.property("channel", &channel)
.property("quote", &quote)
.property("read", channel.property::<bool>("is-active"))
.build();
s.imp().inner.borrow_mut().replace(inner);
s.init_data().await;
Ok(s)
}
pub(super) fn add_amendment(&self, amendment: &AmendmentMessage) {
let inner = self.inner();
*self.imp().inner.borrow_mut() = Some(inner.amend(amendment.clone()));
gspawn!(clone!(
#[strong(rename_to = s)]
self,
#[strong]
amendment,
async move {
s.init_data().await;
s.notify_changed();
if let ftypes::AmendmentMessageContent::Reaction(reaction) = &amendment.content
&& reaction.operation == ReactionOperation::Add
&& s.sender().is_self()
&& s.manager().settings().boolean("notify-reactions")
{
let manager = s.manager();
let Ok(Some(channel)) = manager.channel_from_id(amendment.thread).await else {
return;
};
let Ok(Some(sender)) = manager.contact_from_id(amendment.sender()).await else {
return;
};
manager.send_notification_for_channel(
None,
channel,
sender,
gettextrs::gettext("Reacted {} to a message")
.replace("{}", &reaction.emoji),
);
}
}
));
}
pub(super) fn remove_amendment(&self, amendment: &AmendmentMessage) {
let inner = self.inner();
*self.imp().inner.borrow_mut() = Some(inner.remove_amendment(amendment.clone()));
gspawn!(clone!(
#[strong(rename_to = s)]
self,
async move {
s.init_data().await;
s.notify_changed();
}
));
}
fn notify_changed(&self) {
self.notify_is_deleted();
self.channel().notify_last_message();
self.notify_textual_description();
self.notify_body();
self.notify_reactions();
self.notify_has_reactions();
self.notify_is_unsupported();
}
// A unique ID of the message, built from the timestamp and sender.
pub fn uid(&self) -> Option<String> {
let timestamp = self.timestamp();
let sender_uuid = self.sender().account_id().0.raw_uuid();
Some(format!("{timestamp:x}{sender_uuid:x}"))
}
pub fn inner(&self) -> ftypes::RichMessage {
self.imp()
.inner
.borrow()
.clone()
.expect("Message to have an inner ftypes message")
}
pub fn mark_as_read(&self) -> bool {
let this = self.imp();
let read = *this.read.borrow();
if !read {
this.read.replace(true);
self.notify("read");
return true;
}
false
}
fn text_message(&self) -> Option<ftypes::TextMessage> {
let ftypes::MainMessageContent::Text(message) = self.inner().content.inner else {
return None;
};
Some(message)
}
pub(super) async fn init_data(&self) {
let obj = self.imp();
self.prepare_format_body().await;
// Load attachments in parallel.
// TODO: Stickers
let attachment_futures = self
.text_message()
.into_iter()
.flat_map(|m| m.attachments)
.map(|pointer| async move { Attachment::from_ftypes(&pointer, &self.manager()).await });
let attachments = futures::future::join_all(attachment_futures).await;
obj.attachments.swap(&RefCell::new(attachments));
}
pub fn attachments(&self) -> Vec<Attachment> {
self.imp().attachments.borrow().clone()
}
pub fn quote_timestamp(&self) -> Option<u64> {
self.quote().map(|q| q.timestamp())
}
async fn prepare_format_body(&self) {
let (body, attrs) = self.format_body().await;
*self.imp().formatted_body.borrow_mut() = body;
*self.imp().message_attributes.borrow_mut() = attrs;
self.notify_message_attributes();
self.notify_body();
}
/// Formats the message body based on its ranges, e.g. to insert mention names.
///
/// Returns the resulting strings and an [AttrList] that can be used in labels to highlight areas.
async fn format_body(&self) -> (Option<String>, AttrList) {
let Some(text_message) = self.text_message() else {
return (None, AttrList::new());
};
let Some(mut body) = text_message.body.clone() else {
return (None, AttrList::new());
};
let mut ranges = text_message.body_ranges.clone();
if ranges.is_empty() {
return (Some(body), AttrList::new());
}
ranges.sort_unstable_by_key(|r| r.start);
let attrs = AttrList::new();
let mut offset = 0;
for r in ranges {
match r.associated_value {
ftypes::AssociatedValue::Mention(contact_id) => {
// TODO: Error?
let Ok(Some(contact)) = self.manager().contact_from_id(contact_id).await else {
log::trace!(
"Body got a mention for which the contact could not be found; ignoring"
);
continue;
};
let title = contact.title();
let replacement = format!("{}{}", MENTION_CHAR, title);
let index_start = r.start + offset;
let index_end = r.start + r.length + offset;
body.replace_range(index_start..index_end, &replacement);
let index_start_highlight = index_start;
let index_end_highlight = index_start_highlight + replacement.len();
let (red, green, blue) = MENTION_COLOR;
let mut highlight = AttrColor::new_foreground(red, green, blue);
highlight.set_start_index(index_start_highlight as u32);
highlight.set_end_index(index_end_highlight as u32);
attrs.insert(highlight);
offset += replacement.len() - r.length;
}
ftypes::AssociatedValue::Style(style) => {
let attr: Option<Attribute> = match style {
ftypes::BodyStyle::None => None,
ftypes::BodyStyle::Bold => {
Some((*AttrInt::new_weight(Weight::Bold)).clone())
}
ftypes::BodyStyle::Italic => Some(
(*AttrFontDesc::new(&FontDescription::from_string("italic"))).clone(),
),
ftypes::BodyStyle::Spoiler => None,
ftypes::BodyStyle::Strikethrough => {
Some((*AttrInt::new_strikethrough(true)).clone())
}
ftypes::BodyStyle::Monospace => Some(
(*AttrFontDesc::new(&FontDescription::from_string("monospace")))
.clone(),
),
};
if let Some(mut attr) = attr {
let index_start_highlight = r.start + offset;
let index_end_highlight = index_start_highlight + r.length;
attr.set_start_index(index_start_highlight as u32);
attr.set_end_index(index_end_highlight as u32);
attrs.insert(attr);
}
}
}
}
(Some(body), attrs)
}
/// Send a notification if needed.
///
/// Sending a notification is skipped if:
/// - The message is from self, or
/// - The message has no textual description
pub fn send_notification(&self) {
self.manager().send_notification_for_channel(
self.uid(),
self.channel(),
self.sender(),
self.textual_description().unwrap_or_default(),
);
}
pub fn flash_requires_attention(&self) {
self.set_property("requires-attention", true);
gspawn!(clone!(
#[strong(rename_to = s)]
self,
async move {
glib::timeout_future_seconds(2).await;
s.set_property("requires-attention", false);
}
));
}
pub fn id(&self) -> MessageId {
self.inner().id
}
/// Days since 01.01.1970
pub fn day_timestamp(&self) -> u64 {
self.timestamp() / (1000 * 60 * 60 * 24)
}
pub fn update_show_header(&self, previous: Option<&Message>) {
if let Some(msg) = previous.and_then(|p| p.downcast_ref::<super::Message>()) {
// Show header if:
// - The sender of the previous message is different to the sender of this message, or
// - Some time has passed between the two messages.
// Furthermore, never show headers for messages sent by self.
self.set_show_header(
(self.sender().account_id() != msg.sender().account_id()
|| self.timestamp() >= msg.timestamp() + MESSAGE_SENT_SHOW_NAME_DURATION)
&& self.sender().account_id() != self.manager().account_id(),
);
} else {
self.set_show_header(true);
}
}
pub fn update_show_timestamp(&self, next: Option<&Message>) {
if let Some(msg) = next.and_then(|p| p.downcast_ref::<super::Message>()) {
// Show timestamp if:
// - The sender of the previous message is different to the sender of this message, or
// - Some time has passed between the two messages.
self.set_show_timestamp(
self.sender().account_id() != msg.sender().account_id()
|| msg.timestamp() >= self.timestamp() + MESSAGE_SENT_SHOW_TIMESTAMP_DURATION,
);
} else {
self.set_show_timestamp(true);
}
}
}
mod imp {
use std::marker::PhantomData;
use gtk::pango::AttrList;
use crate::backend::{Attachment, Manager};
use super::*;
#[derive(Debug, Default, glib::Properties)]
#[properties(wrapper_type = super::Message)]
pub struct Message {
#[property(get, set, construct_only, type = Contact)]
sender: RefCell<Option<Contact>>,
#[property(get, set, construct_only, type = Channel)]
channel: RefCell<Option<Channel>>,
#[property(get, set, construct_only)]
pub(super) read: RefCell<bool>,
#[property(get, set)]
pub(super) pending: RefCell<bool>,
#[property(get, set)]
pub(super) error: RefCell<bool>,
#[property(name = "timestamp", type = u64, get = |s: &Self| s.inner.borrow().as_ref().map(|m| m.id.timestamp.timestamp_millis()).unwrap_or_default() as u64)]
#[property(name = "reactions", get = Self::reactions, type = String, default = Some(""))]
#[property(name = "has-reactions", get = Self::has_reactions, type = bool)]
#[property(name = "is-unsupported", get = Self::is_unsupported, type = bool)]
#[property(name = "is-deleted", get = Self::is_deleted, type = bool)]
pub(super) inner: RefCell<Option<ftypes::RichMessage>>,
#[property(get, set, construct_only, type = Manager)]
manager: RefCell<Option<Manager>>,
#[property(get, set, nullable)]
pub(super) quote: RefCell<Option<super::Message>>,
pub(super) attachments: RefCell<Vec<Attachment>>,
#[property(get = Self::textual_description)]
pub(super) textual_description: PhantomData<Option<String>>,
#[property(name = "body", get)]
pub(super) formatted_body: RefCell<Option<String>>,
#[property(get)]
pub(super) message_attributes: RefCell<AttrList>,
#[property(get, set)]
pub(super) requires_attention: Cell<bool>,
#[property(get, set)]
show_header: Cell<bool>,
#[property(get, set)]
show_timestamp: Cell<bool>,
#[property(get = Self::datetime)]
datetime: PhantomData<Option<glib::DateTime>>,
}
impl Message {
fn reactions(&self) -> String {
self.obj()
.inner()
.active_reactions()
.iter()
.map(|r| r.content.emoji.to_owned())
.collect()
}
fn has_reactions(&self) -> bool {
!self.obj().inner().active_reactions().is_empty()
}
fn is_unsupported(&self) -> bool {
!matches!(
self.obj().inner().content.inner,
ftypes::MainMessageContent::Text(_) | ftypes::MainMessageContent::Deleted
)
}
fn is_deleted(&self) -> bool {
self.obj().inner().content.deletion.is_some()
}
fn textual_description(&self) -> Option<String> {
let obj = self.obj();
if let Some(body) = obj.body()
&& !body.is_empty()
{
body.lines().next().map(|s| s.to_owned())
} else {
let attachments = self.attachments.borrow();
let formatter = if attachments.iter().all(|a| a.is_image()) {
gettextrs::ngettext("Sent an image", "Sent {} images", attachments.len() as u32)
} else if attachments.iter().all(|a| a.is_video()) {
gettextrs::ngettext("Sent an video", "Sent {} videos", attachments.len() as u32)
} else if attachments.iter().all(|a| a.is_audio()) {
gettextrs::ngettext(
"Sent a voice message",
"Sent {} voice messages",
attachments.len() as u32,
)
} else {
gettextrs::ngettext("Sent a file", "Sent {} files", attachments.len() as u32)
};
Some(formatter.replace("{}", &attachments.len().to_string()))
}
}
// Time as [glib::DateTime]. Should not return `None`, but just in case.
fn datetime(&self) -> Option<glib::DateTime> {
glib::DateTime::from_unix_utc(
(self.obj().timestamp() / 1000)
.try_into()
.unwrap_or_default(),
)
.ok()
.and_then(|d| d.to_local().ok())
}
}
#[glib::object_subclass]
impl ObjectSubclass for Message {
const NAME: &'static str = "FlMessage";
type Type = super::Message;
}
#[glib::derived_properties]
impl ObjectImpl for Message {}
}