Skip to main content

fractal/utils/matrix/
ext_traits.rs

1//! Extension traits for Matrix types.
2
3use std::borrow::Cow;
4
5use gtk::{glib, prelude::*};
6use matrix_sdk_ui::timeline::{
7    EventTimelineItem, MembershipChange, Message, MsgLikeKind, TimelineEventItemId,
8    TimelineItemContent,
9};
10use ruma::{
11    UserId,
12    events::{
13        AnySyncTimelineEvent,
14        room::message::{FormattedBody, MessageType},
15    },
16    serde::Raw,
17};
18use serde::Deserialize;
19
20use crate::utils::string::StrMutExt;
21
22/// Helper trait for types possibly containing an `@room` mention.
23pub(crate) trait AtMentionExt {
24    /// Whether this event might contain an `@room` mention.
25    ///
26    /// This means that either it does not have intentional mentions, or it has
27    /// intentional mentions and `room` is set to `true`.
28    fn can_contain_at_room(&self) -> bool;
29}
30
31impl AtMentionExt for TimelineItemContent {
32    fn can_contain_at_room(&self) -> bool {
33        match self {
34            TimelineItemContent::MsgLike(msg_like) => match &msg_like.kind {
35                MsgLikeKind::Message(message) => message.can_contain_at_room(),
36                _ => false,
37            },
38            _ => false,
39        }
40    }
41}
42
43impl AtMentionExt for Message {
44    fn can_contain_at_room(&self) -> bool {
45        self.mentions().is_none_or(|mentions| mentions.room)
46    }
47}
48
49/// Extension trait for [`TimelineEventItemId`].
50pub(crate) trait TimelineEventItemIdExt: Sized {
51    /// The type used to represent a [`TimelineEventItemId`] as a `GVariant`.
52    fn static_variant_type() -> Cow<'static, glib::VariantTy>;
53
54    /// Convert this [`TimelineEventItemId`] to a `GVariant`.
55    fn to_variant(&self) -> glib::Variant;
56
57    /// Try to convert a `GVariant` to a [`TimelineEventItemId`].
58    fn from_variant(variant: &glib::Variant) -> Option<Self>;
59}
60
61impl TimelineEventItemIdExt for TimelineEventItemId {
62    fn static_variant_type() -> Cow<'static, glib::VariantTy> {
63        Cow::Borrowed(glib::VariantTy::STRING)
64    }
65
66    fn to_variant(&self) -> glib::Variant {
67        let s = match self {
68            Self::TransactionId(txn_id) => format!("transaction_id:{txn_id}"),
69            Self::EventId(event_id) => format!("event_id:{event_id}"),
70        };
71
72        s.to_variant()
73    }
74
75    fn from_variant(variant: &glib::Variant) -> Option<Self> {
76        let s = variant.str()?;
77
78        if let Some(s) = s.strip_prefix("transaction_id:") {
79            Some(Self::TransactionId(s.into()))
80        } else if let Some(s) = s.strip_prefix("event_id:") {
81            s.try_into().ok().map(Self::EventId)
82        } else {
83            None
84        }
85    }
86}
87
88/// Extension trait for [`TimelineItemContent`].
89pub(crate) trait TimelineItemContentExt {
90    /// Whether this content can count as an unread message.
91    ///
92    /// This follows the algorithm in [MSC2654], excluding events that we do not
93    /// show in the timeline.
94    ///
95    /// [MSC2654]: https://github.com/matrix-org/matrix-spec-proposals/pull/2654
96    fn counts_as_unread(&self) -> bool;
97
98    /// Whether this content can count as the latest activity in a room.
99    ///
100    /// This includes content that counts as unread, plus membership changes for
101    /// our own user towards joining a room, so that freshly joined rooms are at
102    /// the top of the list.
103    fn counts_as_activity(&self, own_user_id: &UserId) -> bool;
104
105    /// Whether we can show the header for this content.
106    fn can_show_header(&self) -> bool;
107
108    /// Whether this content is edited.
109    fn is_edited(&self) -> bool;
110}
111
112impl TimelineItemContentExt for TimelineItemContent {
113    fn counts_as_unread(&self) -> bool {
114        match self {
115            TimelineItemContent::MsgLike(msg_like) => match &msg_like.kind {
116                MsgLikeKind::Message(message) => {
117                    !matches!(message.msgtype(), MessageType::Notice(_))
118                }
119                MsgLikeKind::Sticker(_) => true,
120                _ => false,
121            },
122            _ => false,
123        }
124    }
125
126    fn counts_as_activity(&self, own_user_id: &UserId) -> bool {
127        if self.counts_as_unread() {
128            return true;
129        }
130
131        match self {
132            TimelineItemContent::MembershipChange(membership) => {
133                if membership.user_id() != own_user_id {
134                    return false;
135                }
136
137                // We need to bump the room for every meaningful change towards joining a room.
138                //
139                // The change cannot be computed in two cases:
140                // - This is the first membership event for our user in the room: we need to
141                //   count it.
142                // - The event was redacted: we do not know if we should count it or not, so we
143                //   count it too for simplicity.
144                membership.change().is_none_or(|change| {
145                    matches!(
146                        change,
147                        MembershipChange::Joined
148                            | MembershipChange::Unbanned
149                            | MembershipChange::Invited
150                            | MembershipChange::InvitationAccepted
151                            | MembershipChange::KnockAccepted
152                            | MembershipChange::Knocked
153                    )
154                })
155            }
156            _ => false,
157        }
158    }
159
160    fn can_show_header(&self) -> bool {
161        match self {
162            TimelineItemContent::MsgLike(msg_like) => match &msg_like.kind {
163                MsgLikeKind::Message(message) => {
164                    matches!(
165                        message.msgtype(),
166                        MessageType::Audio(_)
167                            | MessageType::File(_)
168                            | MessageType::Image(_)
169                            | MessageType::Location(_)
170                            | MessageType::Notice(_)
171                            | MessageType::Text(_)
172                            | MessageType::Video(_)
173                    )
174                }
175                MsgLikeKind::Sticker(_) | MsgLikeKind::UnableToDecrypt(_) => true,
176                _ => false,
177            },
178            _ => false,
179        }
180    }
181
182    fn is_edited(&self) -> bool {
183        match self {
184            TimelineItemContent::MsgLike(msg_like) => {
185                matches!(&msg_like.kind, MsgLikeKind::Message(message) if message.is_edited())
186            }
187            _ => false,
188        }
189    }
190}
191
192/// Extension trait for [`EventTimelineItem`].
193pub(crate) trait EventTimelineItemExt {
194    /// The JSON source for the latest edit of this item, if any.
195    fn latest_edit_raw(&self) -> Option<Raw<AnySyncTimelineEvent>>;
196}
197
198impl EventTimelineItemExt for EventTimelineItem {
199    /// The JSON source for the latest edit of this event, if any.
200    fn latest_edit_raw(&self) -> Option<Raw<AnySyncTimelineEvent>> {
201        if let Some(raw) = self.latest_edit_json() {
202            return Some(raw.clone());
203        }
204
205        self.original_json()?
206            .get_field::<RawUnsigned>("unsigned")
207            .ok()
208            .flatten()?
209            .relations?
210            .replace
211    }
212}
213
214/// Raw unsigned event data.
215///
216/// Used as a fallback to get the JSON of the latest edit.
217#[derive(Debug, Clone, Deserialize)]
218struct RawUnsigned {
219    #[serde(rename = "m.relations")]
220    relations: Option<RawBundledRelations>,
221}
222
223/// Raw bundled event relations.
224///
225/// Used as a fallback to get the JSON of the latest edit.
226#[derive(Debug, Clone, Deserialize)]
227struct RawBundledRelations {
228    #[serde(rename = "m.replace")]
229    replace: Option<Raw<AnySyncTimelineEvent>>,
230}
231
232/// Extension trait for `Option<FormattedBody>`.
233pub(crate) trait FormattedBodyExt {
234    /// Clean the body in the `FormattedBody`.
235    ///
236    /// Replaces it with `None` if the body is empty after being cleaned.
237    fn clean_string(&mut self);
238}
239
240impl FormattedBodyExt for Option<FormattedBody> {
241    fn clean_string(&mut self) {
242        self.take_if(|formatted| {
243            formatted.body.clean_string();
244            formatted.body.is_empty()
245        });
246    }
247}