mirror of
https://gitlab.com/schmiddi-on-mobile/flare.git
synced 2026-10-30 15:37:46 -04:00
465 lines
17 KiB
Rust
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", "e)
|
|
.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 {}
|
|
}
|