use std::{borrow::Cow, fmt};
use gtk::{gio, glib, glib::closure_local, prelude::*, subclass::prelude::*};
use indexmap::IndexMap;
use matrix_sdk_ui::timeline::{
AnyOtherFullStateEventContent, Error as TimelineError, EventSendState, EventTimelineItem,
RepliedToEvent, TimelineDetails, TimelineItemContent,
};
use ruma::{
events::{
receipt::Receipt,
room::message::{MessageType, OriginalSyncRoomMessageEvent},
AnySyncTimelineEvent, Mentions, TimelineEventType,
},
serde::Raw,
EventId, MatrixToUri, MatrixUri, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedTransactionId,
OwnedUserId,
};
use serde::{de::IgnoredAny, Deserialize};
use tracing::{debug, error};
mod reaction_group;
mod reaction_list;
pub use self::{
reaction_group::{ReactionData, ReactionGroup},
reaction_list::ReactionList,
};
use super::{
timeline::{TimelineItem, TimelineItemImpl},
Member, Room,
};
use crate::{
prelude::*,
spawn_tokio,
utils::matrix::{raw_eq, MediaMessage, VisualMediaMessage},
};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum EventKey {
TransactionId(OwnedTransactionId),
EventId(OwnedEventId),
}
impl fmt::Display for EventKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EventKey::TransactionId(txn_id) => write!(f, "transaction_id:{txn_id}"),
EventKey::EventId(event_id) => write!(f, "event_id:{event_id}"),
}
}
}
impl StaticVariantType for EventKey {
fn static_variant_type() -> Cow<'static, glib::VariantTy> {
Cow::Borrowed(glib::VariantTy::STRING)
}
}
impl ToVariant for EventKey {
fn to_variant(&self) -> glib::Variant {
self.to_string().to_variant()
}
}
impl FromVariant for EventKey {
fn from_variant(variant: &glib::Variant) -> Option<Self> {
let s = variant.str()?;
if let Some(s) = s.strip_prefix("transaction_id:") {
Some(EventKey::TransactionId(s.into()))
} else if let Some(s) = s.strip_prefix("event_id:") {
EventId::parse(s).ok().map(EventKey::EventId)
} else {
None
}
}
}
#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
#[enum_type(name = "MessageState")]
pub enum MessageState {
#[default]
None,
Sending,
RecoverableError,
PermanentError,
Edited,
}
#[derive(Clone, Debug)]
pub struct UserReadReceipt {
pub user_id: OwnedUserId,
pub receipt: Receipt,
}
mod imp {
use std::{
cell::{Cell, OnceCell, RefCell},
marker::PhantomData,
sync::LazyLock,
};
use glib::subclass::Signal;
use super::*;
#[derive(Debug, glib::Properties)]
#[properties(wrapper_type = super::Event)]
pub struct Event {
pub item: RefCell<Option<EventTimelineItem>>,
#[property(get, set = Self::set_room, construct_only)]
pub room: OnceCell<Room>,
#[property(get)]
pub reactions: ReactionList,
#[property(get)]
pub read_receipts: gio::ListStore,
#[property(get, builder(MessageState::default()))]
pub state: Cell<MessageState>,
#[property(get = Self::source)]
pub source: PhantomData<Option<String>>,
#[property(get = Self::has_source)]
pub has_source: PhantomData<bool>,
#[property(get = Self::event_id_string)]
pub event_id_string: PhantomData<Option<String>>,
#[property(get = Self::timeline_id, type = String)]
pub timeline_id: RefCell<Option<String>>,
#[property(get = Self::sender_id_string)]
pub sender_id_string: PhantomData<String>,
#[property(get = Self::timestamp)]
pub timestamp: PhantomData<glib::DateTime>,
#[property(get = Self::timestamp_full)]
pub timestamp_full: PhantomData<String>,
#[property(get = Self::is_edited)]
pub is_edited: PhantomData<bool>,
#[property(get = Self::latest_edit_source)]
pub latest_edit_source: PhantomData<String>,
#[property(get = Self::latest_edit_event_id_string)]
pub latest_edit_event_id_string: PhantomData<String>,
#[property(get = Self::latest_edit_timestamp)]
pub latest_edit_timestamp: PhantomData<Option<glib::DateTime>>,
#[property(get = Self::latest_edit_timestamp_full)]
pub latest_edit_timestamp_full: PhantomData<String>,
#[property(get = Self::is_highlighted)]
pub is_highlighted: PhantomData<bool>,
#[property(get = Self::has_read_receipts)]
pub has_read_receipts: PhantomData<bool>,
}
impl Default for Event {
fn default() -> Self {
Self {
item: Default::default(),
room: Default::default(),
reactions: Default::default(),
read_receipts: gio::ListStore::new::<glib::BoxedAnyObject>(),
state: Default::default(),
source: Default::default(),
has_source: Default::default(),
event_id_string: Default::default(),
timeline_id: Default::default(),
sender_id_string: Default::default(),
timestamp: Default::default(),
timestamp_full: Default::default(),
is_edited: Default::default(),
latest_edit_source: Default::default(),
latest_edit_event_id_string: Default::default(),
latest_edit_timestamp: Default::default(),
latest_edit_timestamp_full: Default::default(),
is_highlighted: Default::default(),
has_read_receipts: Default::default(),
}
}
}
#[glib::object_subclass]
impl ObjectSubclass for Event {
const NAME: &'static str = "RoomEvent";
type Type = super::Event;
type ParentType = TimelineItem;
}
#[glib::derived_properties]
impl ObjectImpl for Event {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> =
LazyLock::new(|| vec![Signal::builder("item-changed").build()]);
SIGNALS.as_ref()
}
}
impl TimelineItemImpl for Event {
fn id(&self) -> String {
format!("Event::{}", self.obj().key())
}
fn can_hide_header(&self) -> bool {
content_can_show_header(&self.obj().content())
}
fn event_sender_id(&self) -> Option<OwnedUserId> {
Some(self.obj().sender_id())
}
fn selectable(&self) -> bool {
true
}
}
impl Event {
pub fn set_item(&self, item: EventTimelineItem, timeline_id: &str) {
let obj = self.obj();
let prev_raw = self.raw();
let prev_event_id = self.event_id_string();
let was_edited = self.is_edited();
let was_highlighted = self.is_highlighted();
let prev_latest_edit_raw = self.latest_edit_raw();
let had_source = self.has_source();
self.reactions.update(item.reactions());
obj.update_read_receipts(item.read_receipts());
self.item.replace(Some(item));
self.timeline_id.replace(Some(timeline_id.to_owned()));
if !raw_eq(prev_raw.as_ref(), self.raw().as_ref()) {
obj.notify_source();
}
if self.event_id_string() != prev_event_id {
obj.notify_event_id_string();
}
if self.is_edited() != was_edited {
obj.notify_is_edited();
}
if self.is_highlighted() != was_highlighted {
obj.notify_is_highlighted();
}
if !raw_eq(
prev_latest_edit_raw.as_ref(),
self.latest_edit_raw().as_ref(),
) {
obj.notify_latest_edit_source();
obj.notify_latest_edit_event_id_string();
obj.notify_latest_edit_timestamp();
obj.notify_latest_edit_timestamp_full();
}
if self.has_source() != had_source {
obj.notify_has_source();
}
obj.update_state();
obj.emit_by_name::<()>("item-changed", &[]);
obj.notify_timeline_id();
}
pub fn raw(&self) -> Option<Raw<AnySyncTimelineEvent>> {
self.item.borrow().as_ref()?.original_json().cloned()
}
fn source(&self) -> Option<String> {
self.item
.borrow()
.as_ref()?
.original_json()
.map(raw_to_pretty_string)
}
fn has_source(&self) -> bool {
self.item
.borrow()
.as_ref()
.is_some_and(|i| i.original_json().is_some())
}
fn event_id_string(&self) -> Option<String> {
self.item
.borrow()
.as_ref()?
.event_id()
.map(ToString::to_string)
}
fn timeline_id(&self) -> String {
self.timeline_id
.borrow()
.clone()
.expect("event should always have timeline ID after construction")
}
fn sender_id_string(&self) -> String {
self.item
.borrow()
.as_ref()
.map(|i| i.sender().to_string())
.unwrap_or_default()
}
fn set_room(&self, room: Room) {
let room = self.room.get_or_init(|| room);
if let Some(session) = room.session() {
self.reactions.set_user(session.user().clone());
}
}
fn timestamp(&self) -> glib::DateTime {
let ts = self.obj().origin_server_ts();
glib::DateTime::from_unix_utc(ts.as_secs().into())
.and_then(|t| t.to_local())
.unwrap()
}
fn timestamp_full(&self) -> String {
self.timestamp()
.format("%c")
.map(Into::into)
.unwrap_or_default()
}
fn is_edited(&self) -> bool {
let item_ref = self.item.borrow();
let Some(item) = item_ref.as_ref() else {
return false;
};
match item.content() {
TimelineItemContent::Message(msg) => msg.is_edited(),
_ => false,
}
}
fn latest_edit_raw(&self) -> Option<Raw<AnySyncTimelineEvent>> {
let borrowed_item = self.item.borrow();
let item = borrowed_item.as_ref()?;
if let Some(raw) = item.latest_edit_json() {
return Some(raw.clone());
}
item.original_json()?
.get_field::<RawUnsigned>("unsigned")
.ok()
.flatten()?
.relations?
.replace
}
fn latest_edit_source(&self) -> String {
self.latest_edit_raw()
.as_ref()
.map(raw_to_pretty_string)
.unwrap_or_default()
}
fn latest_edit_event_id_string(&self) -> String {
self.latest_edit_raw()
.as_ref()
.and_then(|r| r.get_field::<String>("event_id").ok().flatten())
.unwrap_or_default()
}
fn latest_edit_timestamp(&self) -> Option<glib::DateTime> {
self.latest_edit_raw()
.as_ref()
.and_then(|r| {
r.get_field::<MilliSecondsSinceUnixEpoch>("origin_server_ts")
.ok()
.flatten()
})
.map(|ts| {
glib::DateTime::from_unix_utc(ts.as_secs().into())
.and_then(|t| t.to_local())
.unwrap()
})
}
fn latest_edit_timestamp_full(&self) -> String {
self.latest_edit_timestamp()
.and_then(|d| d.format("%c").ok())
.map(Into::into)
.unwrap_or_default()
}
fn is_highlighted(&self) -> bool {
let item_ref = self.item.borrow();
let Some(item) = item_ref.as_ref() else {
return false;
};
item.is_highlighted()
}
fn has_read_receipts(&self) -> bool {
self.read_receipts.n_items() > 0
}
}
}
glib::wrapper! {
pub struct Event(ObjectSubclass<imp::Event>) @extends TimelineItem;
}
impl Event {
pub fn new(item: EventTimelineItem, timeline_id: &str, room: &Room) -> Self {
let obj = glib::Object::builder::<Self>()
.property("room", room)
.build();
obj.imp().set_item(item, timeline_id);
obj
}
pub fn try_update_with(&self, item: &EventTimelineItem, timeline_id: &str) -> bool {
match &self.key() {
EventKey::TransactionId(txn_id)
if item.is_local_echo() && item.transaction_id() == Some(txn_id) =>
{
self.imp().set_item(item.clone(), timeline_id);
return true;
}
EventKey::EventId(event_id)
if !item.is_local_echo() && item.event_id() == Some(event_id) =>
{
self.imp().set_item(item.clone(), timeline_id);
return true;
}
_ => {}
}
false
}
pub fn item(&self) -> EventTimelineItem {
self.imp().item.borrow().clone().unwrap()
}
pub fn raw(&self) -> Option<Raw<AnySyncTimelineEvent>> {
self.imp().raw()
}
pub fn key(&self) -> EventKey {
let item_ref = self.imp().item.borrow();
let item = item_ref.as_ref().unwrap();
if item.is_local_echo() {
EventKey::TransactionId(item.transaction_id().unwrap().to_owned())
} else {
EventKey::EventId(item.event_id().unwrap().to_owned())
}
}
pub fn matches_key(&self, key: &EventKey) -> bool {
let item_ref = self.imp().item.borrow();
let item = item_ref.as_ref().unwrap();
match key {
EventKey::TransactionId(txn_id) => item.transaction_id().is_some_and(|id| id == txn_id),
EventKey::EventId(event_id) => item.event_id().is_some_and(|id| id == event_id),
}
}
pub fn event_id(&self) -> Option<OwnedEventId> {
match self.key() {
EventKey::TransactionId(_) => None,
EventKey::EventId(event_id) => Some(event_id),
}
}
pub fn transaction_id(&self) -> Option<OwnedTransactionId> {
match self.key() {
EventKey::TransactionId(txn_id) => Some(txn_id),
EventKey::EventId(_) => None,
}
}
pub fn sender_id(&self) -> OwnedUserId {
self.imp()
.item
.borrow()
.as_ref()
.unwrap()
.sender()
.to_owned()
}
pub fn sender(&self) -> Member {
self.room()
.get_or_create_members()
.get_or_create(self.sender_id())
}
pub fn origin_server_ts(&self) -> MilliSecondsSinceUnixEpoch {
self.imp().item.borrow().as_ref().unwrap().timestamp()
}
pub fn origin_server_ts_u64(&self) -> u64 {
self.origin_server_ts().get().into()
}
pub fn is_redacted(&self) -> bool {
matches!(
self.imp().item.borrow().as_ref().unwrap().content(),
TimelineItemContent::RedactedMessage
)
}
pub fn content(&self) -> TimelineItemContent {
self.imp().item.borrow().as_ref().unwrap().content().clone()
}
pub fn message(&self) -> Option<MessageType> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(msg) => Some(msg.msgtype().clone()),
_ => None,
}
}
pub fn media_message(&self) -> Option<MediaMessage> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(msg) => MediaMessage::from_message(msg.msgtype()),
_ => None,
}
}
pub fn visual_media_message(&self) -> Option<VisualMediaMessage> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(msg) => VisualMediaMessage::from_message(msg.msgtype()),
_ => None,
}
}
pub fn mentions(&self) -> Option<Mentions> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(msg) => msg.mentions().cloned(),
_ => None,
}
}
pub fn can_contain_at_room(&self) -> bool {
self.imp()
.item
.borrow()
.as_ref()
.unwrap()
.content()
.can_contain_at_room()
}
fn compute_state(&self) -> MessageState {
let item_ref = self.imp().item.borrow();
let Some(item) = item_ref.as_ref() else {
return MessageState::None;
};
if let Some(send_state) = item.send_state() {
match send_state {
EventSendState::NotSentYet => return MessageState::Sending,
EventSendState::SendingFailed {
error,
is_recoverable,
} => {
if !matches!(
self.state(),
MessageState::PermanentError | MessageState::RecoverableError,
) {
error!("Could not send message: {error}");
}
let new_state = if *is_recoverable {
MessageState::RecoverableError
} else {
MessageState::PermanentError
};
return new_state;
}
EventSendState::Sent { .. } => {}
}
}
match item.content() {
TimelineItemContent::Message(msg) if msg.is_edited() => MessageState::Edited,
_ => MessageState::None,
}
}
fn update_state(&self) {
let state = self.compute_state();
if self.state() == state {
return;
}
self.imp().state.set(state);
self.notify_state();
}
fn update_read_receipts(&self, new_read_receipts: &IndexMap<OwnedUserId, Receipt>) {
let read_receipts = &self.imp().read_receipts;
let old_count = read_receipts.n_items();
let new_count = new_read_receipts.len() as u32;
if old_count == new_count {
let mut is_all_same = true;
for (i, new_user_id) in new_read_receipts.keys().enumerate() {
let Some(old_receipt) = read_receipts
.item(i as u32)
.and_downcast::<glib::BoxedAnyObject>()
else {
is_all_same = false;
break;
};
if old_receipt.borrow::<UserReadReceipt>().user_id != *new_user_id {
is_all_same = false;
break;
}
}
if is_all_same {
return;
}
}
let new_read_receipts = new_read_receipts
.into_iter()
.map(|(user_id, receipt)| {
glib::BoxedAnyObject::new(UserReadReceipt {
user_id: user_id.clone(),
receipt: receipt.clone(),
})
})
.collect::<Vec<_>>();
read_receipts.splice(0, old_count, &new_read_receipts);
let prev_has_read_receipts = old_count > 0;
let has_read_receipts = new_count > 0;
if prev_has_read_receipts != has_read_receipts {
self.notify_has_read_receipts();
}
}
pub fn reply_to_id(&self) -> Option<OwnedEventId> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(message) => {
message.in_reply_to().map(|d| d.event_id.clone())
}
_ => None,
}
}
pub fn is_reply(&self) -> bool {
self.reply_to_id().is_some()
}
pub fn reply_to_event_content(&self) -> Option<TimelineDetails<Box<RepliedToEvent>>> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(message) => message.in_reply_to().map(|d| d.event.clone()),
_ => None,
}
}
pub fn reply_to_event(&self) -> Option<Event> {
let event_id = self.reply_to_id()?;
self.room()
.timeline()
.event_by_key(&EventKey::EventId(event_id))
}
pub async fn fetch_missing_details(&self) -> Result<(), TimelineError> {
let Some(event_id) = self.event_id() else {
return Ok(());
};
let timeline = self.room().timeline().matrix_timeline();
spawn_tokio!(async move { timeline.fetch_details_for_event(&event_id).await })
.await
.unwrap()
}
pub fn is_message(&self) -> bool {
matches!(
self.content(),
TimelineItemContent::Message(_) | TimelineItemContent::Sticker(_)
)
}
pub fn as_message(&self) -> Option<OriginalSyncRoomMessageEvent> {
self.raw()?
.deserialize_as::<OriginalSyncRoomMessageEvent>()
.ok()
}
pub fn is_room_create_event(&self) -> bool {
match self.content() {
TimelineItemContent::OtherState(other_state) => matches!(
other_state.content(),
AnyOtherFullStateEventContent::RoomCreate(_)
),
_ => false,
}
}
pub fn is_redactable(&self) -> bool {
let Some(raw) = self.raw() else {
return false;
};
let is_redacted = match raw.get_field::<UnsignedRedactedDeHelper>("unsigned") {
Ok(Some(unsigned)) => unsigned.redacted_because.is_some(),
Ok(None) => {
debug!("Missing unsigned field in event");
false
}
Err(error) => {
error!("Could not deserialize unsigned field in event: {error}");
false
}
};
if is_redacted {
return false;
}
match raw.get_field::<TimelineEventType>("type") {
Ok(Some(t)) => !NON_REDACTABLE_EVENTS.contains(&t),
Ok(None) => {
debug!("Missing type field in event");
true
}
Err(error) => {
error!("Could not deserialize type field in event: {error}");
true
}
}
}
pub fn counts_as_unread(&self) -> bool {
count_as_unread(self.imp().item.borrow().as_ref().unwrap().content())
}
pub async fn matrix_to_uri(&self) -> Option<MatrixToUri> {
Some(self.room().matrix_to_event_uri(self.event_id()?).await)
}
pub async fn matrix_uri(&self) -> Option<MatrixUri> {
Some(self.room().matrix_event_uri(self.event_id()?).await)
}
pub fn connect_item_changed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
self.connect_closure(
"item-changed",
true,
closure_local!(move |obj: Self| {
f(&obj);
}),
)
}
}
pub fn count_as_unread(content: &TimelineItemContent) -> bool {
match content {
TimelineItemContent::Message(message) => {
!matches!(message.msgtype(), MessageType::Notice(_))
}
TimelineItemContent::Sticker(_) => true,
TimelineItemContent::OtherState(state) => matches!(
state.content(),
AnyOtherFullStateEventContent::RoomTombstone(_)
),
_ => false,
}
}
pub fn content_can_show_header(content: &TimelineItemContent) -> bool {
match content {
TimelineItemContent::Message(message) => {
matches!(
message.msgtype(),
MessageType::Audio(_)
| MessageType::File(_)
| MessageType::Image(_)
| MessageType::Location(_)
| MessageType::Notice(_)
| MessageType::Text(_)
| MessageType::Video(_)
)
}
TimelineItemContent::Sticker(_) => true,
_ => false,
}
}
fn raw_to_pretty_string<T>(raw: &Raw<T>) -> String {
let json = serde_json::to_value(raw).unwrap();
serde_json::to_string_pretty(&json).unwrap()
}
#[derive(Debug, Clone, Deserialize)]
struct RawUnsigned {
#[serde(rename = "m.relations")]
relations: Option<RawBundledRelations>,
}
#[derive(Debug, Clone, Deserialize)]
struct RawBundledRelations {
#[serde(rename = "m.replace")]
replace: Option<Raw<AnySyncTimelineEvent>>,
}
const NON_REDACTABLE_EVENTS: &[TimelineEventType] = &[
TimelineEventType::RoomCreate,
TimelineEventType::RoomEncryption,
TimelineEventType::RoomServerAcl,
];
#[derive(Deserialize)]
struct UnsignedRedactedDeHelper {
redacted_because: Option<IgnoredAny>,
}