matrix_sdk_ui/timeline/mod.rs
1// Copyright 2022 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A high-level view into a room's contents.
16//!
17//! See [`Timeline`] for details.
18
19use std::{fs, iter, path::PathBuf, sync::Arc};
20
21use algorithms::rfind_event_by_item_id;
22use event_item::TimelineItemHandle;
23use eyeball_im::VectorDiff;
24#[cfg(feature = "unstable-msc4274")]
25use futures::SendGallery;
26use futures_core::Stream;
27use imbl::Vector;
28use matrix_sdk::{
29 Result,
30 attachment::{AttachmentInfo, Thumbnail},
31 deserialized_responses::TimelineEvent,
32 event_cache::{EventCacheDropHandles, EventFocusThreadMode},
33 room::{
34 Receipts, Room,
35 edit::EditedContent,
36 reply::{EnforceThread, Reply},
37 },
38 send_queue::{RoomSendQueueError, SendHandle},
39 task_monitor::BackgroundTaskHandle,
40};
41use mime::Mime;
42use ruma::{
43 EventId, OwnedEventId, OwnedTransactionId, UserId,
44 api::client::receipt::create_receipt::v3::ReceiptType,
45 events::{
46 AnyMessageLikeEventContent, AnySyncTimelineEvent, Mentions,
47 location::{AssetType, LocationContent, ZoomLevel},
48 poll::unstable_start::{NewUnstablePollStartEventContent, UnstablePollStartEventContent},
49 receipt::{Receipt, ReceiptThread},
50 relation::{RelationType, Thread},
51 room::message::{
52 AddMentions, LocationMessageEventContent, MessageType, Relation,
53 RelationWithoutReplacement, ReplyWithinThread, RoomMessageEventContent,
54 RoomMessageEventContentWithoutRelation, TextMessageEventContent,
55 },
56 },
57 room_version_rules::RoomVersionRules,
58};
59use subscriber::TimelineWithDropHandle;
60use thiserror::Error;
61use tracing::{instrument, trace, warn};
62
63use self::{
64 algorithms::rfind_event_by_id, controller::TimelineController, futures::SendAttachment,
65};
66use crate::timeline::controller::{CryptoDropHandles, SendReceiptDecision};
67
68mod algorithms;
69mod builder;
70mod controller;
71mod date_dividers;
72mod error;
73pub mod event_filter;
74mod event_handler;
75mod event_item;
76pub mod futures;
77mod item;
78mod latest_event;
79mod pagination;
80mod subscriber;
81mod tasks;
82#[cfg(test)]
83mod tests;
84pub mod thread_list_service;
85mod traits;
86mod virtual_item;
87
88pub use self::{
89 builder::TimelineBuilder,
90 controller::default_event_filter,
91 error::*,
92 event_filter::{TimelineEventCondition, TimelineEventFilter},
93 event_item::{
94 AnyOtherStateEventContentChange, BeaconInfo, EditRevision, EmbeddedEvent, EncryptedMessage,
95 EventItemOrigin, EventSendState, EventTimelineItem, InReplyToDetails, LiveLocationState,
96 MediaUploadProgress, MemberProfileChange, MembershipChange, Message, MsgLikeContent,
97 MsgLikeKind, OtherMessageLike, OtherState, PollResult, PollState, Profile, ReactionInfo,
98 ReactionStatus, ReactionsByKeyBySender, RoomMembershipChange, RoomPinnedEventsChange,
99 Sticker, ThreadSummary, TimelineDetails, TimelineEventItemId, TimelineEventShieldState,
100 TimelineEventShieldStateCode, TimelineItemContent,
101 },
102 item::{TimelineItem, TimelineItemKind, TimelineUniqueId},
103 latest_event::{LatestEventValue, LatestEventValueLocalState},
104 thread_list_service::{ThreadListPaginationState, ThreadListService},
105 traits::RoomExt,
106 virtual_item::VirtualTimelineItem,
107};
108
109/// A high-level view into a regular¹ room's contents.
110///
111/// ¹ This type is meant to be used in the context of rooms without a
112/// `room_type`, that is rooms that are primarily used to exchange text
113/// messages.
114#[derive(Debug)]
115pub struct Timeline {
116 /// Cloneable, inner fields of the `Timeline`, shared with some background
117 /// tasks.
118 controller: TimelineController,
119
120 /// References to long-running tasks held by the timeline.
121 drop_handle: Arc<TimelineDropHandle>,
122}
123
124/// What should the timeline focus on?
125#[derive(Clone, Debug, PartialEq)]
126pub enum TimelineFocus {
127 /// Focus on live events, i.e. receive events from sync and append them in
128 /// real-time.
129 Live {
130 /// Whether to hide in-thread replies from the live timeline.
131 ///
132 /// This should be set to true when the client can create
133 /// [`Self::Thread`]-focused timelines from the thread roots themselves.
134 hide_threaded_events: bool,
135 },
136
137 /// Focus on a specific event, e.g. after clicking a permalink.
138 Event {
139 target: OwnedEventId,
140 num_context_events: u16,
141 /// How to handle threaded events.
142 thread_mode: TimelineEventFocusThreadMode,
143 },
144
145 /// Focus on a specific thread
146 Thread { root_event_id: OwnedEventId },
147
148 /// Only show pinned events.
149 PinnedEvents,
150}
151
152/// Options for controlling the behaviour of [`TimelineFocus::Event`]
153/// for threaded events.
154#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
155#[derive(Clone, Copy, Debug, PartialEq)]
156pub enum TimelineEventFocusThreadMode {
157 /// Force the timeline into threaded mode.
158 ///
159 /// When the focused event is part of a thread, the timeline will be focused
160 /// on that thread's root. Otherwise, the timeline will treat the target
161 /// event itself as the thread root. Threaded events will never be
162 /// hidden.
163 ForceThread,
164
165 /// Automatically determine if the target event is part of a thread or not.
166 ///
167 /// If the event is part of a thread, the timeline
168 /// will be filtered to on-thread events.
169 Automatic {
170 /// When the target event is not part of a thread, whether to
171 /// hide in-thread replies from the live timeline.
172 ///
173 /// Has no effect when the target event is part of a thread.
174 ///
175 /// This should be set to true when the client can create
176 /// [`TimelineFocus::Thread`]-focused timelines from the thread roots
177 /// themselves and doesn't use the [`Self::ForceThread`] mode.
178 hide_threaded_events: bool,
179 },
180}
181
182impl From<TimelineEventFocusThreadMode> for EventFocusThreadMode {
183 fn from(val: TimelineEventFocusThreadMode) -> Self {
184 match val {
185 TimelineEventFocusThreadMode::ForceThread => EventFocusThreadMode::ForceThread,
186 TimelineEventFocusThreadMode::Automatic { .. } => EventFocusThreadMode::Automatic,
187 }
188 }
189}
190
191impl TimelineFocus {
192 pub(super) fn debug_string(&self) -> String {
193 match self {
194 TimelineFocus::Live { .. } => "live".to_owned(),
195 TimelineFocus::Event { target, .. } => format!("permalink:{target}"),
196 TimelineFocus::Thread { root_event_id, .. } => format!("thread:{root_event_id}"),
197 TimelineFocus::PinnedEvents => "pinned-events".to_owned(),
198 }
199 }
200}
201
202/// Changes how dividers get inserted, either in between each day or in between
203/// each month
204#[derive(Debug, Clone)]
205pub enum DateDividerMode {
206 Daily,
207 Monthly,
208}
209
210/// Configuration for sending an attachment.
211///
212/// Like [`matrix_sdk::attachment::AttachmentConfig`], but instead of the
213/// `reply` field, there's only a `in_reply_to` event id; it's the timeline
214/// deciding to fill the rest of the reply parameters.
215#[derive(Debug, Default)]
216pub struct AttachmentConfig {
217 pub txn_id: Option<OwnedTransactionId>,
218 pub info: Option<AttachmentInfo>,
219 pub thumbnail: Option<Thumbnail>,
220 pub caption: Option<TextMessageEventContent>,
221 pub mentions: Option<Mentions>,
222 pub in_reply_to: Option<OwnedEventId>,
223 pub extra_content: Option<serde_json::Map<String, serde_json::Value>>,
224}
225
226impl Timeline {
227 /// Returns the room for this timeline.
228 pub fn room(&self) -> &Room {
229 self.controller.room()
230 }
231
232 /// Clear all timeline items.
233 pub async fn clear(&self) {
234 self.controller.clear().await;
235 }
236
237 /// Retry decryption of previously un-decryptable events given a list of
238 /// session IDs whose keys have been imported.
239 ///
240 /// # Examples
241 ///
242 /// ```no_run
243 /// # use std::{path::PathBuf, time::Duration};
244 /// # use matrix_sdk::{Client, config::SyncSettings, ruma::room_id};
245 /// # use matrix_sdk_ui::Timeline;
246 /// # async {
247 /// # let mut client: Client = todo!();
248 /// # let room_id = ruma::room_id!("!example:example.org");
249 /// # let timeline: Timeline = todo!();
250 /// let path = PathBuf::from("/home/example/e2e-keys.txt");
251 /// let result =
252 /// client.encryption().import_room_keys(path, "secret-passphrase").await?;
253 ///
254 /// // Given a timeline for a specific room_id
255 /// if let Some(keys_for_users) = result.keys.get(room_id) {
256 /// let session_ids = keys_for_users.values().flatten();
257 /// timeline.retry_decryption(session_ids).await;
258 /// }
259 /// # anyhow::Ok(()) };
260 /// ```
261 pub async fn retry_decryption<S: Into<String>>(
262 &self,
263 session_ids: impl IntoIterator<Item = S>,
264 ) {
265 self.controller
266 .retry_event_decryption(Some(session_ids.into_iter().map(Into::into).collect()))
267 .await;
268 }
269
270 #[tracing::instrument(skip(self))]
271 async fn retry_decryption_for_all_events(&self) {
272 self.controller.retry_event_decryption(None).await;
273 }
274
275 /// Get the current timeline item for the given event ID, if any.
276 ///
277 /// Will return a remote event, *or* a local echo that has been sent but not
278 /// yet replaced by a remote echo.
279 ///
280 /// It's preferable to store the timeline items in the model for your UI, if
281 /// possible, instead of just storing IDs and coming back to the timeline
282 /// object to look up items.
283 pub async fn item_by_event_id(&self, event_id: &EventId) -> Option<EventTimelineItem> {
284 let items = self.controller.items().await;
285 let (_, item) = rfind_event_by_id(&items, event_id)?;
286 Some(item.to_owned())
287 }
288
289 /// Get the edit history for the given event.
290 ///
291 /// Returns all revisions of the event, in chronological order.
292 /// The first entry is the original event content, followed by each
293 /// edit in the order they were applied.
294 ///
295 /// This looks up the event and all `m.replace` relations targeting it,
296 /// first in the event cache and falling back to the homeserver if needed.
297 /// This works regardless of the timeline's focus kind (live, thread,
298 /// permalink, or pinned events).
299 pub async fn edit_revisions(&self, event_id: &EventId) -> Result<Vec<EditRevision>, Error> {
300 let Ok((original_event, edit_events)) = self
301 .controller
302 .find_event_with_relations(event_id, Some(vec![RelationType::Replacement]))
303 .await
304 else {
305 return Ok(Vec::new());
306 };
307
308 let room = self.room();
309 let mut revisions = Vec::with_capacity(edit_events.len() + 1);
310
311 for event in iter::once(original_event).chain(edit_events) {
312 let timestamp = event.timestamp();
313 if let Some(content) = TimelineItemContent::from_event(room, event).await {
314 revisions.push(EditRevision { content, timestamp });
315 }
316 }
317
318 Ok(revisions)
319 }
320
321 /// Get the latest of the timeline's remote event ids.
322 pub async fn latest_event_id(&self) -> Option<OwnedEventId> {
323 self.controller.latest_event_id().await
324 }
325
326 /// Get the current timeline items, along with a stream of updates of
327 /// timeline items.
328 ///
329 /// The stream produces `Vec<VectorDiff<_>>`, which means multiple updates
330 /// at once. There are no delays, it consumes as many updates as possible
331 /// and batches them.
332 pub async fn subscribe(
333 &self,
334 ) -> (Vector<Arc<TimelineItem>>, impl Stream<Item = Vec<VectorDiff<Arc<TimelineItem>>>> + use<>)
335 {
336 let (items, stream) = self.controller.subscribe().await;
337 let stream = TimelineWithDropHandle::new(stream, self.drop_handle.clone());
338 (items, stream)
339 }
340
341 /// Send a message to the room, and add it to the timeline as a local echo.
342 ///
343 /// For simplicity, this method doesn't currently allow custom message
344 /// types.
345 ///
346 /// If the encryption feature is enabled, this method will transparently
347 /// encrypt the room message if the room is encrypted.
348 ///
349 /// If sending the message fails, the local echo item will change its
350 /// `send_state` to [`EventSendState::SendingFailed`].
351 ///
352 /// This will do the right thing in the presence of threads:
353 /// - if this timeline is not focused on a thread, then it will send the
354 /// event as is.
355 /// - if this is a threaded timeline, and the event to send is a room
356 /// message without a relationship, it will automatically mark it as a
357 /// thread reply with the correct reply fallback, and send it.
358 ///
359 /// # Arguments
360 ///
361 /// * `content` - The content of the message event.
362 #[instrument(skip(self, content), fields(room_id = ?self.room().room_id()))]
363 pub async fn send(&self, content: AnyMessageLikeEventContent) -> Result<SendHandle, Error> {
364 self.send_with_extra_content(content, None).await
365 }
366
367 /// Queues an event in this room's send queue, with additional top-level
368 /// fields merged into its content. The event's own fields take precedence
369 /// on conflicts.
370 ///
371 /// See [`Self::send`] for more details.
372 #[instrument(skip(self, content, extra_content), fields(room_id = ?self.room().room_id()))]
373 pub async fn send_with_extra_content(
374 &self,
375 mut content: AnyMessageLikeEventContent,
376 extra_content: Option<serde_json::Map<String, serde_json::Value>>,
377 ) -> Result<SendHandle, Error> {
378 // If this is a room event we're sending in a threaded timeline, we add the
379 // thread relation ourselves.
380 if content.relation().is_none()
381 && let Some(reply) = self.infer_reply(None).await
382 {
383 match &mut content {
384 AnyMessageLikeEventContent::RoomMessage(room_msg_content) => {
385 content = self
386 .room()
387 .make_reply_event(
388 // Note: this `.into()` gets rid of the relation, but we've checked
389 // previously that the `relates_to` field wasn't
390 // set.
391 room_msg_content.clone().into(),
392 reply,
393 )
394 .await?
395 .into();
396 }
397
398 AnyMessageLikeEventContent::UnstablePollStart(
399 UnstablePollStartEventContent::New(poll),
400 ) => {
401 if let Some(thread_root) = self.controller.thread_root() {
402 poll.relates_to = Some(RelationWithoutReplacement::Thread(Thread::plain(
403 thread_root,
404 reply.event_id,
405 )));
406 }
407 }
408
409 AnyMessageLikeEventContent::Sticker(sticker) => {
410 if let Some(thread_root) = self.controller.thread_root() {
411 sticker.relates_to =
412 Some(Relation::Thread(Thread::plain(thread_root, reply.event_id)));
413 }
414 }
415
416 _ => {}
417 }
418 }
419
420 let queue = self.room().send_queue();
421 let send = queue.send(content);
422 let send = match extra_content {
423 Some(extra_content) => send.with_extra_content(extra_content),
424 None => send,
425 };
426 Ok(send.await?)
427 }
428
429 /// Send a reply to the given event.
430 ///
431 /// Currently it only supports events with an event ID and JSON being
432 /// available (which can be removed by local redactions). This is subject to
433 /// change. Use [`EventTimelineItem::can_be_replied_to`] to decide whether
434 /// to render a reply button.
435 ///
436 /// The sender will be added to the mentions of the reply if
437 /// and only if the event has not been written by the sender.
438 ///
439 /// This will do the right thing in the presence of threads:
440 /// - if this timeline is not focused on a thread, then it will forward the
441 /// thread relationship of the replied-to event, if present.
442 /// - if this is a threaded timeline, it will mark the reply as an in-thread
443 /// reply.
444 ///
445 /// # Arguments
446 ///
447 /// * `content` - The content of the reply.
448 ///
449 /// * `in_reply_to` - The ID of the event to reply to.
450 #[instrument(skip(self, content))]
451 pub async fn send_reply(
452 &self,
453 content: RoomMessageEventContentWithoutRelation,
454 in_reply_to: OwnedEventId,
455 ) -> Result<SendHandle, Error> {
456 let reply = self
457 .infer_reply(Some(in_reply_to))
458 .await
459 .expect("the reply will always be set because we provided a replied-to event id");
460 let content = self.room().make_reply_event(content, reply).await?;
461 self.send(content.into()).await
462 }
463
464 /// Send a location event to the room, with `body` as the plain-text
465 /// fallback and `geo_uri` its RFC 5870 representation. With `in_reply_to`,
466 /// the location is sent as a reply, with [`Self::send_reply`] semantics.
467 #[instrument(skip(self, body, geo_uri, description))]
468 pub async fn send_location(
469 &self,
470 body: String,
471 geo_uri: String,
472 description: Option<String>,
473 zoom_level: Option<ZoomLevel>,
474 asset_type: Option<AssetType>,
475 in_reply_to: Option<OwnedEventId>,
476 ) -> Result<SendHandle, Error> {
477 let mut content = LocationMessageEventContent::new(body, geo_uri.clone());
478
479 if let Some(asset_type) = asset_type {
480 content = content.with_asset_type(asset_type);
481 }
482
483 let mut location = LocationContent::new(geo_uri);
484 location.description = description;
485 location.zoom_level = zoom_level;
486 content.location = Some(location);
487
488 let msgtype = MessageType::Location(content);
489
490 match in_reply_to {
491 Some(event_id) => {
492 self.send_reply(RoomMessageEventContentWithoutRelation::new(msgtype), event_id)
493 .await
494 }
495 None => self.send(RoomMessageEventContent::new(msgtype).into()).await,
496 }
497 }
498
499 /// Given a message or media to send, and an optional `in_reply_to` event,
500 /// automatically fills the [`Reply`] information based on the current
501 /// timeline focus.
502 pub(crate) async fn infer_reply(&self, in_reply_to: Option<OwnedEventId>) -> Option<Reply> {
503 // If there's a replied-to event id, the reply is pretty straightforward, and we
504 // should only infer the `EnforceThread` based on the current focus.
505 if let Some(in_reply_to) = in_reply_to {
506 let enforce_thread = if self.controller.is_threaded() {
507 EnforceThread::Threaded(ReplyWithinThread::Yes)
508 } else {
509 EnforceThread::MaybeThreaded
510 };
511 return Some(Reply {
512 event_id: in_reply_to,
513 enforce_thread,
514 add_mentions: AddMentions::Yes,
515 });
516 }
517
518 let thread_root = self.controller.thread_root()?;
519
520 // The latest event id is used for the reply-to fallback, for clients which
521 // don't handle threads. It should be correctly set to the latest
522 // event in the thread, which the timeline instance might or might
523 // not know about; in this case, we do a best effort of filling it, and resort
524 // to using the thread root if we don't know about any event.
525 //
526 // Note: we could trigger a back-pagination if the timeline is empty, and wait
527 // for the results, if the timeline is too often empty.
528
529 let latest_event_id = self
530 .controller
531 .items()
532 .await
533 .iter()
534 .rev()
535 .find_map(|item| {
536 if let TimelineItemKind::Event(event) = item.kind() {
537 event.event_id().map(ToOwned::to_owned)
538 } else {
539 None
540 }
541 })
542 .unwrap_or(thread_root);
543
544 Some(Reply {
545 event_id: latest_event_id,
546 enforce_thread: EnforceThread::Threaded(ReplyWithinThread::No),
547 add_mentions: AddMentions::Yes,
548 })
549 }
550
551 /// Edit an event given its [`TimelineEventItemId`] and some new content.
552 ///
553 /// Only supports events for which [`EventTimelineItem::is_editable()`]
554 /// returns `true`.
555 #[instrument(skip(self, new_content))]
556 pub async fn edit(
557 &self,
558 item_id: &TimelineEventItemId,
559 new_content: EditedContent,
560 ) -> Result<(), Error> {
561 let items = self.items().await;
562 let Some((_pos, item)) = rfind_event_by_item_id(&items, item_id) else {
563 return Err(Error::EventNotInTimeline(item_id.clone()));
564 };
565
566 match item.handle() {
567 TimelineItemHandle::Remote(event_id) => {
568 let content = self
569 .room()
570 .make_edit_event(event_id, new_content)
571 .await
572 .map_err(EditError::RoomError)?;
573 self.send(content).await?;
574 Ok(())
575 }
576
577 TimelineItemHandle::Local(handle) => {
578 let new_content: AnyMessageLikeEventContent = match new_content {
579 EditedContent::RoomMessage(message) => {
580 if item.content.is_message() {
581 // The replacement becomes the pending event itself, so restore its
582 // relations, which the payload can't carry by type.
583 AnyMessageLikeEventContent::RoomMessage(
584 message.with_relation(item.content.relation()),
585 )
586 } else {
587 return Err(EditError::ContentMismatch {
588 original: item.content.debug_string().to_owned(),
589 new: "a message".to_owned(),
590 }
591 .into());
592 }
593 }
594
595 EditedContent::PollStart { new_content, .. } => {
596 if item.content.is_poll() {
597 AnyMessageLikeEventContent::UnstablePollStart(
598 UnstablePollStartEventContent::New(
599 NewUnstablePollStartEventContent::new(new_content),
600 ),
601 )
602 } else {
603 return Err(EditError::ContentMismatch {
604 original: item.content.debug_string().to_owned(),
605 new: "a poll".to_owned(),
606 }
607 .into());
608 }
609 }
610
611 EditedContent::MediaCaption { caption, formatted_caption, mentions } => {
612 if handle
613 .edit_media_caption(caption, formatted_caption, mentions)
614 .await
615 .map_err(RoomSendQueueError::StorageError)?
616 {
617 return Ok(());
618 }
619 return Err(EditError::InvalidLocalEchoState.into());
620 }
621 };
622
623 if !handle.edit(new_content).await.map_err(RoomSendQueueError::StorageError)? {
624 return Err(EditError::InvalidLocalEchoState.into());
625 }
626
627 Ok(())
628 }
629 }
630 }
631
632 /// Toggle a reaction on an event.
633 ///
634 /// Adds or redacts a reaction based on the state of the reaction at the
635 /// time it is called.
636 ///
637 /// When redacting a previous reaction, the redaction reason is not set.
638 ///
639 /// Ensures that only one reaction is sent at a time to avoid race
640 /// conditions and spamming the homeserver with requests.
641 ///
642 /// Returns `true` if the reaction was added, `false` if it was removed.
643 pub async fn toggle_reaction(
644 &self,
645 item_id: &TimelineEventItemId,
646 reaction_key: &str,
647 ) -> Result<bool, Error> {
648 self.controller.toggle_reaction_local(item_id, reaction_key, None).await
649 }
650
651 /// Same as [`Timeline::toggle_reaction`], merging `extra_content`'s fields
652 /// into the reaction's content when one is added.
653 ///
654 /// The reaction's own fields take precedence on conflicts. Removing a
655 /// reaction is a redaction, which carries no content, so `extra_content` is
656 /// only used when adding one — and only for reactions to remote events,
657 /// since a local echo is the user's own not-yet-sent event.
658 pub async fn toggle_reaction_with_extra_content(
659 &self,
660 item_id: &TimelineEventItemId,
661 reaction_key: &str,
662 extra_content: Option<serde_json::Map<String, serde_json::Value>>,
663 ) -> Result<bool, Error> {
664 self.controller.toggle_reaction_local(item_id, reaction_key, extra_content).await
665 }
666
667 /// Sends an attachment to the room.
668 ///
669 /// It does not currently support local echoes.
670 ///
671 /// If the encryption feature is enabled, this method will transparently
672 /// encrypt the room message if the room is encrypted.
673 ///
674 /// The attachment and its optional thumbnail are stored in the media cache
675 /// and can be retrieved at any time, by calling
676 /// [`Media::get_media_content()`] with the `MediaSource` that can be found
677 /// in the corresponding `TimelineEventItem`, and using a
678 /// `MediaFormat::File`.
679 ///
680 /// # Arguments
681 ///
682 /// * `source` - The source of the attachment to send.
683 ///
684 /// * `mime_type` - The attachment's mime type.
685 ///
686 /// * `config` - An attachment configuration object containing details about
687 /// the attachment like a thumbnail, its size, duration etc.
688 ///
689 /// [`Media::get_media_content()`]: matrix_sdk::Media::get_media_content
690 #[instrument(skip_all)]
691 pub fn send_attachment(
692 &self,
693 source: impl Into<AttachmentSource>,
694 mime_type: Mime,
695 config: AttachmentConfig,
696 ) -> SendAttachment<'_> {
697 SendAttachment::new(self, source.into(), mime_type, config)
698 }
699
700 /// Sends a media gallery to the room.
701 ///
702 /// If the encryption feature is enabled, this method will transparently
703 /// encrypt the room message if the room is encrypted.
704 ///
705 /// The attachments and their optional thumbnails are stored in the media
706 /// cache and can be retrieved at any time, by calling
707 /// [`Media::get_media_content()`] with the `MediaSource` that can be found
708 /// in the corresponding `TimelineEventItem`, and using a
709 /// `MediaFormat::File`.
710 ///
711 /// # Arguments
712 /// * `gallery` - A configuration object containing details about the
713 /// gallery like files, thumbnails, etc.
714 ///
715 /// [`Media::get_media_content()`]: matrix_sdk::Media::get_media_content
716 #[cfg(feature = "unstable-msc4274")]
717 #[instrument(skip_all)]
718 pub fn send_gallery(&self, gallery: GalleryConfig) -> SendGallery<'_> {
719 SendGallery::new(self, gallery)
720 }
721
722 /// Redact an event given its [`TimelineEventItemId`] and an optional
723 /// reason.
724 pub async fn redact(
725 &self,
726 item_id: &TimelineEventItemId,
727 reason: Option<&str>,
728 ) -> Result<(), Error> {
729 let items = self.items().await;
730 let Some((_pos, event)) = rfind_event_by_item_id(&items, item_id) else {
731 return Err(RedactError::ItemNotFound(item_id.clone()).into());
732 };
733
734 match event.handle() {
735 TimelineItemHandle::Remote(event_id) => {
736 self.room()
737 .send_queue()
738 .redact(event_id.to_owned(), reason)
739 .await
740 .map_err(|_| Error::FailedSendingRedaction)?;
741 Ok(())
742 }
743 TimelineItemHandle::Local(handle) => {
744 // Forward the reason: if the local echo was being sent and the send wins the
745 // race, the server-side redaction that materializes the abort carries it.
746 if !handle
747 .abort_with_reason(reason.map(ToOwned::to_owned))
748 .await
749 .map_err(RoomSendQueueError::StorageError)?
750 {
751 return Err(RedactError::InvalidLocalEchoState.into());
752 }
753 Ok(())
754 }
755 }
756 }
757
758 /// Fetch unavailable details about the event with the given ID.
759 ///
760 /// This method only works for IDs of remote [`EventTimelineItem`]s,
761 /// to prevent losing details when a local echo is replaced by its
762 /// remote echo.
763 ///
764 /// This method tries to make all the requests it can. If an error is
765 /// encountered for a given request, it is forwarded with the
766 /// [`TimelineDetails::Error`] variant.
767 ///
768 /// # Arguments
769 ///
770 /// * `event_id` - The event ID of the event to fetch details for.
771 ///
772 /// # Errors
773 ///
774 /// Returns an error if the identifier doesn't match any event with a remote
775 /// echo in the timeline, or if the event is removed from the timeline
776 /// before all requests are handled.
777 #[instrument(skip(self), fields(room_id = ?self.room().room_id()))]
778 pub async fn fetch_details_for_event(&self, event_id: &EventId) -> Result<(), Error> {
779 self.controller.fetch_in_reply_to_details(event_id).await
780 }
781
782 /// Fetch all member events for the room this timeline is displaying.
783 ///
784 /// If the full member list is not known, sender profiles are currently
785 /// likely not going to be available. This will be fixed in the future.
786 ///
787 /// If fetching the members fails, any affected timeline items will have
788 /// the `sender_profile` set to [`TimelineDetails::Error`].
789 #[instrument(skip_all)]
790 pub async fn fetch_members(&self) {
791 self.controller.set_sender_profiles_pending().await;
792 match self.room().sync_members().await {
793 Ok(_) => {
794 self.controller.update_missing_sender_profiles().await;
795 }
796 Err(e) => {
797 self.controller.set_sender_profiles_error(Arc::new(e)).await;
798 }
799 }
800 }
801
802 /// Get the latest read receipt for the given user.
803 ///
804 /// Contrary to [`Room::load_user_receipt()`] that only keeps track of read
805 /// receipts received from the homeserver, this keeps also track of implicit
806 /// read receipts in this timeline, i.e. when a room member sends an event.
807 #[instrument(skip(self))]
808 pub async fn latest_user_read_receipt(
809 &self,
810 user_id: &UserId,
811 ) -> Option<(OwnedEventId, Receipt)> {
812 self.controller.latest_user_read_receipt(user_id).await
813 }
814
815 /// Get the ID of the timeline event with the latest read receipt for the
816 /// given user.
817 ///
818 /// In contrary to [`Self::latest_user_read_receipt()`], this allows to know
819 /// the position of the read receipt in the timeline even if the event it
820 /// applies to is not visible in the timeline, unless the event is unknown
821 /// by this timeline.
822 #[instrument(skip(self))]
823 pub async fn latest_user_read_receipt_timeline_event_id(
824 &self,
825 user_id: &UserId,
826 ) -> Option<OwnedEventId> {
827 self.controller.latest_user_read_receipt_timeline_event_id(user_id).await
828 }
829
830 /// Subscribe to changes in the read receipts of our own user.
831 pub async fn subscribe_own_user_read_receipts_changed(&self) -> impl Stream<Item = ()> + use<> {
832 self.controller.subscribe_own_user_read_receipts_changed().await
833 }
834
835 /// Send the given receipt.
836 ///
837 /// This uses [`Room::send_single_receipt`] internally, but checks
838 /// first if the receipt points to an event in this timeline that is more
839 /// recent than the current ones, to avoid unnecessary requests.
840 ///
841 /// If an unthreaded receipt is sent, this will also unset the unread flag
842 /// of the room if necessary.
843 ///
844 /// The thread of the receipt is determined by the timeline instance's
845 /// focus mode and `hide_threaded_events` flag.
846 ///
847 /// Returns a boolean indicating if it sent the receipt or not.
848 #[instrument(skip(self), fields(room_id = ?self.room().room_id()))]
849 pub async fn send_single_receipt(
850 &self,
851 receipt_type: ReceiptType,
852 event_id: OwnedEventId,
853 ) -> Result<bool> {
854 self.send_single_receipt_inner(receipt_type, event_id, false).await
855 }
856
857 /// Same as [`Self::send_single_receipt`], but lets the caller state whether
858 /// this is part of marking the whole room as read.
859 ///
860 /// When it is, and the only candidate event is one of the user's own, a
861 /// receipt is still sent against it so the homeserver recomputes its
862 /// push/badge count. See [`TimelineController::should_send_receipt`].
863 async fn send_single_receipt_inner(
864 &self,
865 receipt_type: ReceiptType,
866 event_id: OwnedEventId,
867 is_marking_room_as_read: bool,
868 ) -> Result<bool> {
869 let thread = self.controller.infer_thread_for_read_receipt(&receipt_type);
870
871 let event_id = match self
872 .controller
873 .should_send_receipt(&receipt_type, &thread, &event_id, is_marking_room_as_read)
874 .await
875 {
876 SendReceiptDecision::SendTo(event_id) => event_id,
877 SendReceiptDecision::DoNotSend => {
878 trace!("not sending receipt, because it wouldn't move the real receipt forwards");
879
880 if thread == ReceiptThread::Unthreaded {
881 // Unset the read marker.
882 self.room().set_unread_flag(false).await?;
883 }
884
885 return Ok(false);
886 }
887 };
888
889 trace!("sending receipt");
890 self.room().send_single_receipt(receipt_type, thread, event_id).await?;
891 Ok(true)
892 }
893
894 /// Send the given receipts.
895 ///
896 /// This uses [`Room::send_multiple_receipts`] internally, but
897 /// checks first if the receipts point to events in this timeline that
898 /// are more recent than the current ones, to avoid unnecessary
899 /// requests.
900 ///
901 /// This also unsets the unread marker of the room if necessary.
902 #[instrument(skip(self))]
903 pub async fn send_multiple_receipts(&self, mut receipts: Receipts) -> Result<()> {
904 if let Some(fully_read) = &receipts.fully_read {
905 receipts.fully_read = match self
906 .controller
907 .should_send_receipt(
908 &ReceiptType::FullyRead,
909 &ReceiptThread::Unthreaded,
910 fully_read,
911 false,
912 )
913 .await
914 {
915 SendReceiptDecision::SendTo(event_id) => Some(event_id),
916 SendReceiptDecision::DoNotSend => None,
917 };
918 }
919
920 if let Some(read_receipt) = &receipts.public_read_receipt {
921 receipts.public_read_receipt = match self
922 .controller
923 .should_send_receipt(
924 &ReceiptType::Read,
925 &ReceiptThread::Unthreaded,
926 read_receipt,
927 false,
928 )
929 .await
930 {
931 SendReceiptDecision::SendTo(event_id) => Some(event_id),
932 SendReceiptDecision::DoNotSend => None,
933 };
934 }
935
936 if let Some(private_read_receipt) = &receipts.private_read_receipt {
937 receipts.private_read_receipt = match self
938 .controller
939 .should_send_receipt(
940 &ReceiptType::ReadPrivate,
941 &ReceiptThread::Unthreaded,
942 private_read_receipt,
943 false,
944 )
945 .await
946 {
947 SendReceiptDecision::SendTo(event_id) => Some(event_id),
948 SendReceiptDecision::DoNotSend => None,
949 };
950 }
951
952 let room = self.room();
953
954 if !receipts.is_empty() {
955 room.send_multiple_receipts(receipts).await?;
956 } else {
957 room.set_unread_flag(false).await?;
958 }
959
960 Ok(())
961 }
962
963 /// Mark the timeline as read by attempting to send a read receipt on the
964 /// latest visible event.
965 ///
966 /// The latest visible event is determined from the timeline's focus kind
967 /// and whether or not it hides threaded events. If no latest event can
968 /// be determined and the timeline is live, the room's unread marker is
969 /// unset instead.
970 ///
971 /// # Arguments
972 ///
973 /// * `receipt_type` - The type of receipt to send. When using
974 /// [`ReceiptType::FullyRead`], an unthreaded receipt will be sent. This
975 /// works even if the latest event belongs to a thread, as a threaded
976 /// reply also belongs to the unthreaded timeline. Otherwise the
977 /// [`ReceiptThread`] will be determined based on the timeline's focus
978 /// kind.
979 ///
980 /// # Returns
981 ///
982 /// A boolean indicating if the receipt was sent or not.
983 #[instrument(skip(self), fields(room_id = ?self.room().room_id()))]
984 pub async fn mark_as_read(&self, receipt_type: ReceiptType) -> Result<bool> {
985 if let Some(event_id) = self.controller.latest_event_id().await {
986 self.send_single_receipt_inner(receipt_type, event_id, true).await
987 } else {
988 trace!("can't mark room as read because there's no latest event id");
989
990 // For live timelines, unset the read marker in this case.
991 if self.controller.is_live() {
992 self.room().set_unread_flag(false).await?;
993 }
994
995 Ok(false)
996 }
997 }
998
999 /// Create a [`EmbeddedEvent`] from an arbitrary event, be it in the
1000 /// timeline or not.
1001 ///
1002 /// Can be `None` if the event cannot be represented as a standalone item,
1003 /// because it's an aggregation.
1004 pub async fn make_replied_to(
1005 &self,
1006 event: TimelineEvent,
1007 ) -> Result<Option<EmbeddedEvent>, Error> {
1008 self.controller.make_replied_to(event).await
1009 }
1010
1011 /// Returns whether this timeline is focused on a thread (be it live, or
1012 /// from a permalink to a threaded event).
1013 pub fn is_threaded(&self) -> bool {
1014 self.controller.is_threaded()
1015 }
1016}
1017
1018/// Test helpers, likely not very useful in production.
1019#[doc(hidden)]
1020impl Timeline {
1021 /// Get the current list of timeline items.
1022 pub async fn items(&self) -> Vector<Arc<TimelineItem>> {
1023 self.controller.items().await
1024 }
1025
1026 pub async fn subscribe_filter_map<U: Clone>(
1027 &self,
1028 f: impl Fn(Arc<TimelineItem>) -> Option<U>,
1029 ) -> (Vector<U>, impl Stream<Item = VectorDiff<U>>) {
1030 let (items, stream) = self.controller.subscribe_filter_map(f).await;
1031 let stream = TimelineWithDropHandle::new(stream, self.drop_handle.clone());
1032 (items, stream)
1033 }
1034}
1035
1036#[derive(Debug)]
1037struct TimelineDropHandle {
1038 _room_update_join_handle: BackgroundTaskHandle,
1039 #[cfg(feature = "unstable-msc4426")]
1040 _global_profile_updates_handle: BackgroundTaskHandle,
1041 _local_echo_listener_handle: BackgroundTaskHandle,
1042 _rtc_membership_listener_handle: BackgroundTaskHandle,
1043 _event_cache_drop_handle: Arc<EventCacheDropHandles>,
1044 _focus_drop_handle: Option<BackgroundTaskHandle>,
1045 _crypto_drop_handles: CryptoDropHandles,
1046}
1047
1048#[cfg(not(target_family = "wasm"))]
1049pub type TimelineEventFilterFn =
1050 dyn Fn(&AnySyncTimelineEvent, &RoomVersionRules) -> bool + Send + Sync;
1051#[cfg(target_family = "wasm")]
1052pub type TimelineEventFilterFn = dyn Fn(&AnySyncTimelineEvent, &RoomVersionRules) -> bool;
1053
1054/// A source for sending an attachment.
1055///
1056/// The [`AttachmentSource::File`] variant can be constructed from any type that
1057/// implements `Into<PathBuf>`.
1058#[derive(Debug, Clone)]
1059pub enum AttachmentSource {
1060 /// The data of the attachment.
1061 Data {
1062 /// The bytes of the attachment.
1063 bytes: Vec<u8>,
1064
1065 /// The filename of the attachment.
1066 filename: String,
1067 },
1068
1069 /// An attachment loaded from a file.
1070 ///
1071 /// The bytes and the filename will be read from the file at the given path.
1072 File(PathBuf),
1073}
1074
1075impl AttachmentSource {
1076 /// Try to convert this attachment source into a `(bytes, filename)` tuple.
1077 pub(crate) fn try_into_bytes_and_filename(self) -> Result<(Vec<u8>, String), Error> {
1078 match self {
1079 Self::Data { bytes, filename } => Ok((bytes, filename)),
1080 Self::File(path) => {
1081 let filename = path
1082 .file_name()
1083 .ok_or(Error::InvalidAttachmentFileName)?
1084 .to_str()
1085 .ok_or(Error::InvalidAttachmentFileName)?
1086 .to_owned();
1087 let bytes = fs::read(&path).map_err(|_| Error::InvalidAttachmentData)?;
1088 Ok((bytes, filename))
1089 }
1090 }
1091 }
1092}
1093
1094impl<P> From<P> for AttachmentSource
1095where
1096 P: Into<PathBuf>,
1097{
1098 fn from(value: P) -> Self {
1099 Self::File(value.into())
1100 }
1101}
1102
1103/// Configuration for sending a gallery.
1104///
1105/// This duplicates [`matrix_sdk::attachment::GalleryConfig`] but uses an
1106/// `AttachmentSource` so that we can delay loading the actual data until we're
1107/// inside the SendGallery future. This allows [`Timeline::send_gallery`] to
1108/// return early without blocking the caller.
1109#[cfg(feature = "unstable-msc4274")]
1110#[derive(Debug, Default)]
1111pub struct GalleryConfig {
1112 pub(crate) txn_id: Option<OwnedTransactionId>,
1113 pub(crate) items: Vec<GalleryItemInfo>,
1114 pub(crate) caption: Option<TextMessageEventContent>,
1115 pub(crate) mentions: Option<Mentions>,
1116 pub(crate) in_reply_to: Option<OwnedEventId>,
1117}
1118
1119#[cfg(feature = "unstable-msc4274")]
1120impl GalleryConfig {
1121 /// Create a new empty `GalleryConfig`.
1122 pub fn new() -> Self {
1123 Self::default()
1124 }
1125
1126 /// Set the transaction ID to send.
1127 ///
1128 /// # Arguments
1129 ///
1130 /// * `txn_id` - A unique ID that can be attached to a `MessageEvent` held
1131 /// in its unsigned field as `transaction_id`. If not given, one is
1132 /// created for the message.
1133 #[must_use]
1134 pub fn txn_id(mut self, txn_id: OwnedTransactionId) -> Self {
1135 self.txn_id = Some(txn_id);
1136 self
1137 }
1138
1139 /// Adds a media item to the gallery.
1140 ///
1141 /// # Arguments
1142 ///
1143 /// * `item` - Information about the item to be added.
1144 #[must_use]
1145 pub fn add_item(mut self, item: GalleryItemInfo) -> Self {
1146 self.items.push(item);
1147 self
1148 }
1149
1150 /// Set the optional caption.
1151 ///
1152 /// # Arguments
1153 ///
1154 /// * `caption` - The optional caption.
1155 pub fn caption(mut self, caption: Option<TextMessageEventContent>) -> Self {
1156 self.caption = caption;
1157 self
1158 }
1159
1160 /// Set the mentions of the message.
1161 ///
1162 /// # Arguments
1163 ///
1164 /// * `mentions` - The mentions of the message.
1165 pub fn mentions(mut self, mentions: Option<Mentions>) -> Self {
1166 self.mentions = mentions;
1167 self
1168 }
1169
1170 /// Set the reply information of the message.
1171 ///
1172 /// # Arguments
1173 ///
1174 /// * `event_id` - The event ID to reply to.
1175 pub fn in_reply_to(mut self, event_id: Option<OwnedEventId>) -> Self {
1176 self.in_reply_to = event_id;
1177 self
1178 }
1179
1180 /// Returns the number of media items in the gallery.
1181 pub fn len(&self) -> usize {
1182 self.items.len()
1183 }
1184
1185 /// Checks whether the gallery contains any media items or not.
1186 pub fn is_empty(&self) -> bool {
1187 self.items.is_empty()
1188 }
1189}
1190
1191#[cfg(feature = "unstable-msc4274")]
1192#[derive(Debug)]
1193/// Metadata for a gallery item
1194pub struct GalleryItemInfo {
1195 /// The attachment source.
1196 pub source: AttachmentSource,
1197 /// The mime type.
1198 pub content_type: Mime,
1199 /// The attachment info.
1200 pub attachment_info: AttachmentInfo,
1201 /// The caption.
1202 pub caption: Option<TextMessageEventContent>,
1203 /// The thumbnail.
1204 pub thumbnail: Option<Thumbnail>,
1205}
1206
1207#[cfg(feature = "unstable-msc4274")]
1208impl TryFrom<GalleryItemInfo> for matrix_sdk::attachment::GalleryItemInfo {
1209 type Error = Error;
1210
1211 fn try_from(value: GalleryItemInfo) -> Result<Self, Self::Error> {
1212 let (data, filename) = value.source.try_into_bytes_and_filename()?;
1213 Ok(matrix_sdk::attachment::GalleryItemInfo {
1214 filename,
1215 content_type: value.content_type,
1216 data,
1217 attachment_info: value.attachment_info,
1218 caption: value.caption,
1219 thumbnail: value.thumbnail,
1220 })
1221 }
1222}
1223
1224#[derive(Clone, Debug)]
1225#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
1226/// The level of read receipt tracking for the timeline.
1227pub enum TimelineReadReceiptTracking {
1228 /// Track read receipts for all events.
1229 AllEvents,
1230 /// Track read receipts only for message-like events.
1231 MessageLikeEvents,
1232 /// Disable read receipt tracking.
1233 Disabled,
1234}
1235
1236impl TimelineReadReceiptTracking {
1237 /// Whether or not read receipt tracking is enabled.
1238 pub fn is_enabled(&self) -> bool {
1239 match self {
1240 TimelineReadReceiptTracking::AllEvents
1241 | TimelineReadReceiptTracking::MessageLikeEvents => true,
1242 TimelineReadReceiptTracking::Disabled => false,
1243 }
1244 }
1245}