atagen

macltok

public
Star0
Watch0

Commit 960ac0721079

tarball

atagen <boss@atagen.co>2026-09-25 00:24 UTC

14 files changed+710-314
timeline: say what each event is instead of flattening it into a message

Every event used to arrive as a message whose body read [m.room.member] or <message redacted>, so clients had to parse English strings to tell a redaction from a real message. TimelineContent now carries messages with their msgtype, membership and profile changes, room state and undecryptable events with their cause. Replies fetch their target, since the SDK leaves it Unavailable until asked, and edit, redact and react take an EventRef so local echoes can be targeted.
Modified file: docs/design-decisions.md+29 additions
@@ -75,6 +75,20 @@ out, so no stale diff can land after it
7575
7676 `TimelineId` tells a room from a thread by its root event
7777
78+every item is an event, a day divider, the read marker or the start. an event
79+carries its sender with avatar and name ambiguity, and a `TimelineContent`
80+saying what it actually is. messages keep their msgtype, membership and profile
81+changes and the common room state changes get their own variants, and anything
82+else is `OtherState` or `Other` with its type. nothing is flattened into english
83+in a body field
84+
85+replies carry the replied-to event once it's loaded. the watcher asks the sdk
86+for any it hasn't got, once per event, since the sdk won't on its own
87+
88+edit, redact and react take an `EventRef`, so your own message can be edited or
89+reacted to before the server has echoed it back. redacting an unsent one just
90+cancels it
91+
7892 ## mutations
7993
8094 membership, moderation, power levels, room creation and account data writes go
@@ -116,6 +130,21 @@ replace it atomically with `ClientHandle::session_record()` on every
116130 store. a soft logout keeps the store for a same-device login, or the e2ee keys
117131 go with it. a bare `Stopped` kills nothing. one store directory per login
118132
133+## room lists, spaces and profiles
134+
135+`RoomSummary` has what a room list row needs without a second query. topic,
136+alias, member counts, heroes for nameless rooms, the inviter, where an upgraded
137+room went, a latest event preview, the joined spaces listing it, and for a
138+space the children it lists
139+
140+parents come from one lazily built `SpaceService` shared by every handle.
141+children come straight from the space's local state, joined or not.
142+`space_hierarchy` pages through the server's view for browsing rooms you
143+haven't joined, and holds its place between pages
144+
145+`own_profile` and `user_profile` read profiles. name and avatar changes are
146+commands, and uploading an avatar gets the same byte cap as any upload
147+
119148 ## notifications and room data
120149
121150 the notification client is built lazily and shares the engine's sync service.
Modified file: docs/review-2026-09-24.md+3 additions−3 deletions
@@ -210,7 +210,7 @@ fetch changes no state, yet it is a `MediaCommand` whose bytes arrive as a
210210 other frontend's image bytes, up to 20 MiB each, and the requester has to
211211 filter by `MediaId`.
212212
213-### A3. Every timeline item is a `Message`, with presentation strings in `body`. open, q3
213+### A3. Every timeline item is a `Message`, with presentation strings in `body`. done
214214
215215 `normalise_timeline_item` (`src/normalise/timeline.rs`) flattens every event
216216 into `TimelineMessage`. The consequences for a client are these.
@@ -235,7 +235,7 @@ This needs a content enum, for example `Message { msgtype, ... }`, `Redacted`,
235235 `UnableToDecrypt`, `Membership`, `ProfileChange`, `RoomState`, `Poll` and
236236 `Other { event_type }`. It is the largest single rendering gap.
237237
238-### A4. Timeline commands cannot target local echoes. open, lands with q3
238+### A4. Timeline commands cannot target local echoes. done
239239
240240 `Edit`, `Redact` and `ToggleReaction` take an `OwnedEventId` and wrap it in
241241 `TimelineEventItemId::EventId`. The SDK accepts transaction ids for all three,
@@ -243,7 +243,7 @@ but a client cannot edit, redact or react to a message it sent until the
243243 server echo arrives. The commands should take the identifier that the model
244244 already exposes.
245245
246-### A5. `RoomSummary` is too thin for a room list. open, lands with the malice queries and q2
246+### A5. `RoomSummary` is too thin for a room list. done
247247
248248 Missing fields, all cheap SDK reads. Topic, canonical alias, joined and invited
249249 member counts, heroes, tombstone and successor, inviter for invites, a latest
Modified file: examples/headless.rs+6 additions−4 deletions
@@ -1,6 +1,6 @@
11 use macltok::{
2- EngineConfig, Event, PasswordLogin, RoomMembership, Session, StoreConfig, TimelineId,
3- TimelineItem,
2+ EngineConfig, Event, PasswordLogin, RoomMembership, Session, StoreConfig, TimelineContent,
3+ TimelineId, TimelineItem,
44 };
55
66 #[tokio::main]
@@ -82,8 +82,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
8282
8383 fn print_messages(items: &[TimelineItem]) {
8484 for item in items {
85- if let TimelineItem::Message(message) = item {
86- println!("{} {}", message.sender_name, message.body);
85+ if let TimelineItem::Event(event) = item
86+ && let TimelineContent::Message(message) = &event.content
87+ {
88+ println!("{} {}", event.sender.name(), message.body);
8789 }
8890 }
8991 }
Modified file: src/engine/timeline.rs+53 additions−12 deletions
@@ -1,4 +1,4 @@
1-use std::{future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc};
1+use std::{collections::HashSet, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc};
22
33 use futures_util::{FutureExt, StreamExt};
44 use matrix_sdk::{
@@ -22,8 +22,8 @@ use tokio::{
2222 };
2323
2424 use crate::{
25- Error, ErrorKind, Event, HistoryState, MessageContent, MessageEditContent, MessageMentions,
26- RichText, TimelineCommand, TimelineId,
25+ Error, ErrorKind, Event, EventRef, HistoryState, MessageContent, MessageEditContent,
26+ MessageMentions, RichText, TimelineCommand, TimelineId,
2727 engine::MediaService,
2828 normalise::{MediaScope, normalise_timeline, normalise_timeline_diffs},
2929 transport::{EventSender, Responder, SendEventError},
@@ -163,17 +163,15 @@ impl TimelineSession {
163163 let background: Pin<Box<dyn Future<Output = Result<(), Error>> + Send>> = match command {
164164 TimelineCommand::Paginate { limit, .. } => return self.paginate(limit, Some(reply)),
165165 // network-bound, so it runs beside the actor instead of queueing the room behind it
166- TimelineCommand::Redact {
167- event_id, reason, ..
168- } => Box::pin(async move {
166+ TimelineCommand::Redact { target, reason, .. } => Box::pin(async move {
169167 timeline
170- .redact(&TimelineEventItemId::EventId(event_id), reason.as_deref())
168+ .redact(&item_id(target), reason.as_deref())
171169 .await
172170 .map_err(timeline_error)
173171 }),
174- TimelineCommand::ToggleReaction { event_id, key, .. } => Box::pin(async move {
172+ TimelineCommand::ToggleReaction { target, key, .. } => Box::pin(async move {
175173 timeline
176- .toggle_reaction(&TimelineEventItemId::EventId(event_id), &key)
174+ .toggle_reaction(&item_id(target), &key)
177175 .await
178176 .map(drop)
179177 .map_err(timeline_error)
@@ -235,7 +233,7 @@ impl TimelineSession {
235233 .map_err(timeline_error)
236234 }
237235 TimelineCommand::Edit {
238- event_id, content, ..
236+ target, content, ..
239237 } => {
240238 let (content, mentions, emote) = match content {
241239 MessageEditContent::Text { content, mentions } => (content, mentions, false),
@@ -246,7 +244,7 @@ impl TimelineSession {
246244 }
247245 timeline
248246 .edit(
249- &TimelineEventItemId::EventId(event_id),
247+ &item_id(target),
250248 EditedContent::RoomMessage(message_content(content, mentions, emote)),
251249 )
252250 .await
@@ -312,6 +310,35 @@ impl Drop for TimelineSession {
312310 }
313311 }
314312
313+fn item_id(target: EventRef) -> TimelineEventItemId {
314+ match target {
315+ EventRef::Remote(event_id) => TimelineEventItemId::EventId(event_id),
316+ EventRef::Local(transaction_id) => TimelineEventItemId::TransactionId(transaction_id),
317+ }
318+}
319+
320+/// the sdk leaves a reply's target unloaded until someone asks, so the watcher asks, once per event
321+struct ReplyFetcher {
322+ timeline: Arc<Timeline>,
323+ asked: HashSet<OwnedEventId>,
324+ fetches: JoinSet<()>,
325+}
326+
327+impl ReplyFetcher {
328+ fn fetch(&mut self, missing: Vec<OwnedEventId>) {
329+ for event_id in missing {
330+ if self.asked.insert(event_id.clone()) {
331+ let timeline = self.timeline.clone();
332+ self.fetches.spawn(async move {
333+ if let Err(error) = timeline.fetch_details_for_event(&event_id).await {
334+ tracing::debug!(%event_id, %error, "fetching reply details failed");
335+ }
336+ });
337+ }
338+ }
339+ }
340+}
341+
315342 async fn start_watcher(
316343 id: &TimelineId,
317344 timeline: &Arc<Timeline>,
@@ -329,14 +356,28 @@ async fn start_watcher(
329356 items: normalised.items,
330357 })
331358 .await?;
359+ let mut replies = ReplyFetcher {
360+ timeline: timeline.clone(),
361+ asked: HashSet::new(),
362+ fetches: JoinSet::new(),
363+ };
364+ replies.fetch(normalised.missing_replies);
332365 let id = id.clone();
333366 let events = context.events.clone();
334367 let media = context.media.clone();
335368 let scope = scope.clone();
336369 Ok(context.supervisor.spawn("timeline watcher", async move {
337- while let Some(diffs) = stream.next().await {
370+ loop {
371+ let diffs = tokio::select! {
372+ diffs = stream.next() => match diffs {
373+ Some(diffs) => diffs,
374+ None => return,
375+ },
376+ Some(_) = replies.fetches.join_next(), if !replies.fetches.is_empty() => continue,
377+ };
338378 let normalised = normalise_timeline_diffs(diffs, current_user_id.as_deref(), &scope);
339379 media.register(normalised.media).await;
380+ replies.fetch(normalised.missing_replies);
340381 let event = Event::TimelineChanged {
341382 timeline_id: id.clone(),
342383 diffs: normalised.diffs,
Modified file: src/lib.rs+13 additions−11 deletions
@@ -21,18 +21,20 @@ pub use account::{
2121 pub use engine::{Engine, EngineConfig, EngineError};
2222 pub use error::{Error, ErrorKind};
2323 pub use model::{
24- AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
25- EncryptionCommand, EncryptionState, EngineErrorKind, EngineFailure, Event, HistoryState,
26- KeyBackupState, LatestEvent, LifecycleCommand, Media, MediaCommand, MediaDescriptor, MediaId,
27- MediaKind, MediaSize, MemberFilter, MemberMembership, MemberSummary, MessageContent,
28- MessageEditContent, MessageMentions, NotificationCommand, OwnDevice, PowerLevelChanges,
29- Profile, PushNotification, PushNotificationEvent, PushNotificationRoom, PushNotificationSender,
30- PushNotificationStatus, Reaction, RecoveryState, ReplyReference, RichText, RoomCategory,
31- RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
24+ AccountCommand, AttachmentUpload, Change, Command, CommandOutput, ConnectionState,
25+ CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind, EngineFailure, Event,
26+ EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand, Media, MediaCommand,
27+ MediaDescriptor, MediaId, MediaKind, MediaSize, MemberFilter, MemberMembership, MemberSummary,
28+ MembershipChange, Message, MessageContent, MessageEditContent, MessageKind, MessageMentions,
29+ NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,
30+ PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
31+ Reaction, RecoveryState, ReplyDetails, ReplyReference, RichText, RoomCategory, RoomCommand,
32+ RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,
3233 RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,
33- SpaceChild, ThreadSummary, TimelineCommand, TimelineDiff, TimelineDiffError, TimelineId,
34- TimelineItem, TimelineItemId, TimelineMessage, TypingState, UploadDetails, UploadThumbnail,
35- VerificationAction, VerificationFailure, VerificationState, VerificationTarget,
34+ Sender, SpaceChild, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,
35+ TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,
36+ UploadDetails, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,
37+ VerificationState, VerificationTarget,
3638 };
3739 pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};
3840 pub use uiaa::{
Modified file: src/model.rs+188 additions−17 deletions
@@ -219,17 +219,18 @@ pub enum TimelineCommand {
219219 },
220220 Edit {
221221 timeline_id: TimelineId,
222- event_id: OwnedEventId,
222+ target: EventRef,
223223 content: MessageEditContent,
224224 },
225+ /// redacting your own unsent message just cancels it
225226 Redact {
226227 timeline_id: TimelineId,
227- event_id: OwnedEventId,
228+ target: EventRef,
228229 reason: Option<String>,
229230 },
230231 ToggleReaction {
231232 timeline_id: TimelineId,
232- event_id: OwnedEventId,
233+ target: EventRef,
233234 key: String,
234235 },
235236 /// works on a closed room too, it just can't refuse to move the marker backwards
@@ -1041,7 +1042,7 @@ pub struct OwnDevice {
10411042 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
10421043 #[non_exhaustive]
10431044 pub enum TimelineItem {
1044- Message(Box<TimelineMessage>),
1045+ Event(Box<TimelineEvent>),
10451046 DayDivider { timestamp_ms: u64 },
10461047 ReadMarker,
10471048 TimelineStart,
@@ -1220,26 +1221,183 @@ impl TimelineItemId {
12201221 #[derive(Clone, Debug, Eq, PartialEq)]
12211222 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
12221223 #[non_exhaustive]
1223-pub struct TimelineMessage {
1224+pub struct TimelineEvent {
12241225 pub item_id: TimelineItemId,
12251226 pub event_id: Option<OwnedEventId>,
12261227 pub transaction_id: Option<OwnedTransactionId>,
1227- pub sender_id: OwnedUserId,
1228- pub sender_name: String,
1229- pub body: String,
1230- pub formatted_body: Option<String>,
1231- pub mentions: Option<MessageMentions>,
1232- pub thread_root: Option<OwnedEventId>,
1233- pub thread_summary: Option<ThreadSummary>,
1228+ pub sender: Sender,
12341229 pub timestamp_ms: u64,
12351230 pub own: bool,
12361231 pub send_state: SendState,
1237- pub edited: bool,
1238- pub unable_to_decrypt: bool,
1239- pub media: Option<MediaDescriptor>,
1232+ pub content: TimelineContent,
12401233 pub reply_to: Option<ReplyReference>,
1234+ pub thread_root: Option<OwnedEventId>,
1235+ pub thread_summary: Option<ThreadSummary>,
12411236 pub reactions: Vec<Reaction>,
12421237 pub read_by: Vec<OwnedUserId>,
1238+ pub editable: bool,
1239+}
1240+
1241+impl TimelineEvent {
1242+ /// what to hand edit, redact and react. works before the server has echoed it back
1243+ #[must_use]
1244+ pub fn target(&self) -> Option<EventRef> {
1245+ self.event_id
1246+ .clone()
1247+ .map(EventRef::Remote)
1248+ .or_else(|| self.transaction_id.clone().map(EventRef::Local))
1249+ }
1250+}
1251+
1252+#[derive(Clone, Debug, Eq, Hash, PartialEq)]
1253+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1254+#[non_exhaustive]
1255+pub enum EventRef {
1256+ Remote(OwnedEventId),
1257+ /// a local echo, by the transaction id it was queued under
1258+ Local(OwnedTransactionId),
1259+}
1260+
1261+impl From<OwnedEventId> for EventRef {
1262+ fn from(event_id: OwnedEventId) -> Self {
1263+ Self::Remote(event_id)
1264+ }
1265+}
1266+
1267+#[derive(Clone, Debug, Eq, PartialEq)]
1268+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1269+#[non_exhaustive]
1270+pub struct Sender {
1271+ pub user_id: OwnedUserId,
1272+ pub display_name: Option<String>,
1273+ pub avatar: Option<OwnedMxcUri>,
1274+ /// someone else in the room goes by the same name, so show the user id too
1275+ pub name_ambiguous: bool,
1276+}
1277+
1278+impl Sender {
1279+ #[must_use]
1280+ pub fn name(&self) -> &str {
1281+ self.display_name
1282+ .as_deref()
1283+ .unwrap_or_else(|| self.user_id.as_str())
1284+ }
1285+}
1286+
1287+#[derive(Clone, Debug, Eq, PartialEq)]
1288+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1289+#[non_exhaustive]
1290+pub enum TimelineContent {
1291+ Message(Message),
1292+ Sticker {
1293+ body: String,
1294+ media: MediaDescriptor,
1295+ },
1296+ Poll {
1297+ question: String,
1298+ },
1299+ Redacted,
1300+ UnableToDecrypt(UtdCause),
1301+ Membership {
1302+ user_id: OwnedUserId,
1303+ display_name: Option<String>,
1304+ change: MembershipChange,
1305+ },
1306+ Profile {
1307+ user_id: OwnedUserId,
1308+ display_name: Option<Change<Option<String>>>,
1309+ avatar: Option<Change<Option<OwnedMxcUri>>>,
1310+ },
1311+ /// `None` means it was cleared
1312+ RoomName(Option<String>),
1313+ RoomTopic(Option<String>),
1314+ RoomAvatar(Option<OwnedMxcUri>),
1315+ EncryptionEnabled,
1316+ RoomCreated,
1317+ RoomUpgraded {
1318+ successor: OwnedRoomId,
1319+ },
1320+ OtherState {
1321+ event_type: String,
1322+ state_key: String,
1323+ },
1324+ Other {
1325+ event_type: String,
1326+ },
1327+}
1328+
1329+#[derive(Clone, Debug, Eq, PartialEq)]
1330+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1331+#[non_exhaustive]
1332+pub struct Message {
1333+ pub kind: MessageKind,
1334+ pub body: String,
1335+ pub formatted_body: Option<String>,
1336+ pub mentions: Option<MessageMentions>,
1337+ pub edited: bool,
1338+ pub media: Option<MediaDescriptor>,
1339+}
1340+
1341+#[derive(Clone, Debug, Eq, PartialEq)]
1342+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1343+#[non_exhaustive]
1344+pub enum MessageKind {
1345+ Text,
1346+ Emote,
1347+ Notice,
1348+ ServerNotice,
1349+ Image,
1350+ Video,
1351+ Audio,
1352+ File,
1353+ Location,
1354+ VerificationRequest,
1355+ Other(String),
1356+}
1357+
1358+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1359+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1360+#[non_exhaustive]
1361+pub enum MembershipChange {
1362+ Joined,
1363+ Left,
1364+ Banned,
1365+ Unbanned,
1366+ Kicked,
1367+ KickedAndBanned,
1368+ Invited,
1369+ InvitationAccepted,
1370+ InvitationRejected,
1371+ InvitationRevoked,
1372+ Knocked,
1373+ KnockAccepted,
1374+ KnockRetracted,
1375+ KnockDenied,
1376+ /// nothing a person would call a change, or something the sdk couldn't make sense of
1377+ Other,
1378+}
1379+
1380+#[derive(Clone, Debug, Eq, PartialEq)]
1381+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1382+pub struct Change<T> {
1383+ pub old: T,
1384+ pub new: T,
1385+}
1386+
1387+/// why a message couldn't be read, worth telling the user since some of these they can fix
1388+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1389+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1390+#[non_exhaustive]
1391+pub enum UtdCause {
1392+ Unknown,
1393+ SentBeforeWeJoined,
1394+ VerificationViolation,
1395+ UnsignedDevice,
1396+ UnknownDevice,
1397+ HistoricalMessageAndBackupIsDisabled,
1398+ WithheldForUnverifiedOrInsecureDevice,
1399+ WithheldBySender,
1400+ HistoricalMessageAndDeviceIsUnverified,
12431401 }
12441402
12451403 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1266,8 +1424,21 @@ pub enum SendState {
12661424 #[non_exhaustive]
12671425 pub struct ReplyReference {
12681426 pub event_id: OwnedEventId,
1269- pub sender_id: Option<OwnedUserId>,
1270- pub sender_name: Option<String>,
1427+ pub details: ReplyDetails,
1428+}
1429+
1430+/// the replied-to event. macltok asks for it when it isn't loaded, `Pending` then turns into one of the others
1431+#[derive(Clone, Debug, Eq, PartialEq)]
1432+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
1433+#[non_exhaustive]
1434+pub enum ReplyDetails {
1435+ Pending,
1436+ Ready {
1437+ sender: Sender,
1438+ content: Box<TimelineContent>,
1439+ timestamp_ms: u64,
1440+ },
1441+ Unavailable,
12711442 }
12721443
12731444 #[derive(Clone, Debug, Eq, PartialEq)]
Modified file: src/normalise/timeline.rs+354 additions−222 deletions
@@ -1,21 +1,27 @@
11 use std::sync::Arc;
22
3+use matrix_sdk_crypto::types::events::UtdCause as SdkUtdCause;
34 use matrix_sdk_ui::{
45 eyeball_im::VectorDiff,
56 timeline::{
6- EventSendState, MsgLikeKind, TimelineDetails, TimelineEventItemId,
7- TimelineItem as SdkTimelineItem, TimelineItemContent, TimelineItemKind,
8- VirtualTimelineItem,
7+ AnyOtherStateEventContentChange, EncryptedMessage, EventSendState, EventTimelineItem,
8+ MembershipChange as SdkMembershipChange, MsgLikeContent, MsgLikeKind, Profile,
9+ TimelineDetails, TimelineEventItemId, TimelineItem as SdkTimelineItem, TimelineItemContent,
10+ TimelineItemKind, VirtualTimelineItem,
911 },
1012 };
1113 use ruma::{
12- UserId,
13- events::room::message::{MessageFormat, MessageType},
14+ OwnedEventId, UserId,
15+ events::{
16+ StateEventContentChange,
17+ room::message::{MessageFormat, MessageType},
18+ },
1419 };
1520
1621 use crate::{
17- MessageMentions, Reaction, ReplyReference, SendState, ThreadSummary, TimelineDiff,
18- TimelineItem, TimelineItemId, TimelineMessage,
22+ Change, MembershipChange, Message, MessageKind, MessageMentions, Reaction, ReplyDetails,
23+ ReplyReference, SendState, Sender, ThreadSummary, TimelineContent, TimelineDiff, TimelineEvent,
24+ TimelineItem, TimelineItemId, UtdCause,
1925 };
2026
2127 use super::media::{
@@ -23,9 +29,98 @@ use super::media::{
2329 media_identity, media_registration_for_item,
2430 };
2531
32+#[derive(Default)]
2633 pub(crate) struct NormalisedTimeline {
2734 pub(crate) items: Vec<TimelineItem>,
2835 pub(crate) media: Vec<MediaRegistration>,
36+ /// replies whose target isn't loaded, so somebody should go and ask for it
37+ pub(crate) missing_replies: Vec<OwnedEventId>,
38+}
39+
40+pub(crate) struct NormalisedTimelineDiffs {
41+ pub(crate) diffs: Vec<TimelineDiff>,
42+ pub(crate) media: Vec<MediaRegistration>,
43+ pub(crate) missing_replies: Vec<OwnedEventId>,
44+}
45+
46+struct Normaliser<'a> {
47+ current_user_id: Option<&'a UserId>,
48+ scope: &'a MediaScope,
49+ media: Vec<MediaRegistration>,
50+ missing_replies: Vec<OwnedEventId>,
51+}
52+
53+impl Normaliser<'_> {
54+ fn item(&mut self, item: &SdkTimelineItem) -> TimelineItem {
55+ if let Some(media) = media_registration_for_item(item, self.scope) {
56+ self.media.push(media);
57+ }
58+ match item.kind() {
59+ TimelineItemKind::Event(event) => {
60+ TimelineItem::Event(Box::new(self.event(item, event)))
61+ }
62+ TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
63+ TimelineItem::DayDivider {
64+ timestamp_ms: timestamp.get().into(),
65+ }
66+ }
67+ TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,
68+ TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {
69+ TimelineItem::TimelineStart
70+ }
71+ }
72+ }
73+
74+ fn event(&mut self, item: &SdkTimelineItem, event: &EventTimelineItem) -> TimelineEvent {
75+ let msg_like = match event.content() {
76+ TimelineItemContent::MsgLike(content) => Some(content),
77+ _ => None,
78+ };
79+ let reply_to = msg_like
80+ .and_then(|content| content.in_reply_to.as_ref())
81+ .map(|reply| {
82+ let details = match &reply.event {
83+ TimelineDetails::Ready(embedded) => ReplyDetails::Ready {
84+ sender: sender(&embedded.sender, &embedded.sender_profile),
85+ content: Box::new(content(&embedded.content, None)),
86+ timestamp_ms: embedded.timestamp.get().into(),
87+ },
88+ TimelineDetails::Unavailable => {
89+ self.missing_replies.push(reply.event_id.clone());
90+ ReplyDetails::Pending
91+ }
92+ TimelineDetails::Pending => ReplyDetails::Pending,
93+ TimelineDetails::Error(_) => ReplyDetails::Unavailable,
94+ };
95+ ReplyReference {
96+ event_id: reply.event_id.clone(),
97+ details,
98+ }
99+ });
100+ let mut read_by: Vec<_> = event
101+ .read_receipts()
102+ .keys()
103+ .filter(|user_id| *user_id != event.sender())
104+ .cloned()
105+ .collect();
106+ read_by.sort();
107+ TimelineEvent {
108+ item_id: TimelineItemId::new(item.unique_id().0.clone()),
109+ event_id: event.event_id().map(ToOwned::to_owned),
110+ transaction_id: event.transaction_id().map(ToOwned::to_owned),
111+ sender: sender(event.sender(), event.sender_profile()),
112+ timestamp_ms: event.timestamp().get().into(),
113+ own: event.is_own(),
114+ send_state: send_state(event.send_state()),
115+ content: content(event.content(), Some(&media_identity(item, self.scope))),
116+ reply_to,
117+ thread_root: msg_like.and_then(|content| content.thread_root.clone()),
118+ thread_summary: msg_like.and_then(thread_summary),
119+ reactions: reactions(event, self.current_user_id),
120+ read_by,
121+ editable: event.is_editable(),
122+ }
123+ }
29124 }
30125
31126 pub(crate) fn normalise_timeline<'a>(
@@ -33,19 +128,18 @@ pub(crate) fn normalise_timeline<'a>(
33128 current_user_id: Option<&UserId>,
34129 scope: &MediaScope,
35130 ) -> NormalisedTimeline {
36- let mut normalised = NormalisedTimeline {
37- items: Vec::new(),
131+ let mut normaliser = Normaliser {
132+ current_user_id,
133+ scope,
38134 media: Vec::new(),
135+ missing_replies: Vec::new(),
39136 };
40- for item in items {
41- if let Some(media) = media_registration_for_item(item, scope) {
42- normalised.media.push(media);
43- }
44- normalised
45- .items
46- .push(normalise_timeline_item(item, current_user_id, scope));
137+ let items = items.map(|item| normaliser.item(item)).collect();
138+ NormalisedTimeline {
139+ items,
140+ media: normaliser.media,
141+ missing_replies: normaliser.missing_replies,
47142 }
48- normalised
49143 }
50144
51145 pub(crate) fn normalise_timeline_diffs(
@@ -53,171 +147,265 @@ pub(crate) fn normalise_timeline_diffs(
53147 current_user_id: Option<&UserId>,
54148 scope: &MediaScope,
55149 ) -> NormalisedTimelineDiffs {
56- let mut media = Vec::new();
150+ let mut normaliser = Normaliser {
151+ current_user_id,
152+ scope,
153+ media: Vec::new(),
154+ missing_replies: Vec::new(),
155+ };
57156 let diffs = diffs
58157 .into_iter()
59- .map(|diff| {
60- let diff = diff.map(|item| {
61- if let Some(registration) = media_registration_for_item(&item, scope) {
62- media.push(registration);
63- }
64- normalise_timeline_item(&item, current_user_id, scope)
65- });
66- match diff {
67- VectorDiff::Append { values } => TimelineDiff::Append {
68- values: values.into_iter().collect(),
69- },
70- VectorDiff::Clear => TimelineDiff::Clear,
71- VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },
72- VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },
73- VectorDiff::PopFront => TimelineDiff::PopFront,
74- VectorDiff::PopBack => TimelineDiff::PopBack,
75- VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },
76- VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },
77- VectorDiff::Remove { index } => TimelineDiff::Remove { index },
78- VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },
79- VectorDiff::Reset { values } => TimelineDiff::Reset {
80- values: values.into_iter().collect(),
81- },
82- }
158+ .map(|diff| match diff.map(|item| normaliser.item(&item)) {
159+ VectorDiff::Append { values } => TimelineDiff::Append {
160+ values: values.into_iter().collect(),
161+ },
162+ VectorDiff::Clear => TimelineDiff::Clear,
163+ VectorDiff::PushFront { value } => TimelineDiff::PushFront { value },
164+ VectorDiff::PushBack { value } => TimelineDiff::PushBack { value },
165+ VectorDiff::PopFront => TimelineDiff::PopFront,
166+ VectorDiff::PopBack => TimelineDiff::PopBack,
167+ VectorDiff::Insert { index, value } => TimelineDiff::Insert { index, value },
168+ VectorDiff::Set { index, value } => TimelineDiff::Set { index, value },
169+ VectorDiff::Remove { index } => TimelineDiff::Remove { index },
170+ VectorDiff::Truncate { length } => TimelineDiff::Truncate { length },
171+ VectorDiff::Reset { values } => TimelineDiff::Reset {
172+ values: values.into_iter().collect(),
173+ },
83174 })
84175 .collect();
85- NormalisedTimelineDiffs { diffs, media }
176+ NormalisedTimelineDiffs {
177+ diffs,
178+ media: normaliser.media,
179+ missing_replies: normaliser.missing_replies,
180+ }
86181 }
87182
88-pub(crate) struct NormalisedTimelineDiffs {
89- pub(crate) diffs: Vec<TimelineDiff>,
90- pub(crate) media: Vec<MediaRegistration>,
183+/// `media_identity` is `None` for embedded reply content, which never gets fetchable media of its own
184+fn content(content: &TimelineItemContent, media_identity: Option<&str>) -> TimelineContent {
185+ match content {
186+ TimelineItemContent::MsgLike(content) => msg_like(content, media_identity),
187+ TimelineItemContent::MembershipChange(change) => TimelineContent::Membership {
188+ user_id: change.user_id().to_owned(),
189+ display_name: change.display_name(),
190+ change: change
191+ .change()
192+ .map_or(MembershipChange::Other, membership_change),
193+ },
194+ TimelineItemContent::ProfileChange(change) => TimelineContent::Profile {
195+ user_id: change.user_id().to_owned(),
196+ display_name: change.displayname_change().map(|change| Change {
197+ old: change.old.clone(),
198+ new: change.new.clone(),
199+ }),
200+ avatar: change.avatar_url_change().map(|change| Change {
201+ old: change.old.clone(),
202+ new: change.new.clone(),
203+ }),
204+ },
205+ TimelineItemContent::OtherState(state) => match state.content() {
206+ AnyOtherStateEventContentChange::RoomName(change) => {
207+ TimelineContent::RoomName(original(change).map(|content| content.name.clone()))
208+ }
209+ AnyOtherStateEventContentChange::RoomTopic(change) => {
210+ TimelineContent::RoomTopic(original(change).map(|content| content.topic.clone()))
211+ }
212+ AnyOtherStateEventContentChange::RoomAvatar(change) => TimelineContent::RoomAvatar(
213+ original(change).and_then(|content| content.url.clone()),
214+ ),
215+ AnyOtherStateEventContentChange::RoomEncryption(_) => {
216+ TimelineContent::EncryptionEnabled
217+ }
218+ AnyOtherStateEventContentChange::RoomCreate(_) => TimelineContent::RoomCreated,
219+ AnyOtherStateEventContentChange::RoomTombstone(change) => match original(change) {
220+ Some(content) => TimelineContent::RoomUpgraded {
221+ successor: content.replacement_room.clone(),
222+ },
223+ None => other_state(state.content().event_type().to_string(), state.state_key()),
224+ },
225+ other => other_state(other.event_type().to_string(), state.state_key()),
226+ },
227+ TimelineItemContent::FailedToParseMessageLike { event_type, .. } => {
228+ TimelineContent::Other {
229+ event_type: event_type.to_string(),
230+ }
231+ }
232+ TimelineItemContent::FailedToParseState {
233+ event_type,
234+ state_key,
235+ ..
236+ } => other_state(event_type.to_string(), state_key),
237+ TimelineItemContent::CallInvite => TimelineContent::Other {
238+ event_type: "m.call.invite".to_owned(),
239+ },
240+ TimelineItemContent::RtcNotification { .. } => TimelineContent::Other {
241+ event_type: "org.matrix.msc4075.rtc.notification".to_owned(),
242+ },
243+ }
91244 }
92245
93-fn normalise_timeline_item(
94- item: &SdkTimelineItem,
95- current_user_id: Option<&UserId>,
96- scope: &MediaScope,
97-) -> TimelineItem {
98- match item.kind() {
99- TimelineItemKind::Event(event) => TimelineItem::Message(Box::new(timeline_message(
100- item,
101- event,
102- current_user_id,
103- scope,
104- ))),
105- TimelineItemKind::Virtual(VirtualTimelineItem::DateDivider(timestamp)) => {
106- TimelineItem::DayDivider {
107- timestamp_ms: timestamp.get().into(),
246+fn other_state(event_type: String, state_key: &str) -> TimelineContent {
247+ TimelineContent::OtherState {
248+ event_type,
249+ state_key: state_key.to_owned(),
250+ }
251+}
252+
253+fn original<C>(change: &StateEventContentChange<C>) -> Option<&C>
254+where
255+ C: ruma::events::StaticStateEventContent + ruma::events::RedactContent,
256+{
257+ match change {
258+ StateEventContentChange::Original { content, .. } => Some(content),
259+ StateEventContentChange::Redacted(_) => None,
260+ }
261+}
262+
263+fn msg_like(content: &MsgLikeContent, media_identity: Option<&str>) -> TimelineContent {
264+ match &content.kind {
265+ MsgLikeKind::Message(message) => TimelineContent::Message(Message {
266+ kind: message_kind(message.msgtype()),
267+ body: message.body().to_owned(),
268+ formatted_body: formatted_body(message.msgtype()),
269+ mentions: message.mentions().map(|mentions| MessageMentions {
270+ user_ids: mentions.user_ids.iter().cloned().collect(),
271+ room: mentions.room,
272+ }),
273+ edited: message.is_edited(),
274+ media: media_identity
275+ .and_then(|identity| media_from_message(identity, message.msgtype())),
276+ }),
277+ MsgLikeKind::Sticker(sticker) => {
278+ let sticker = sticker.content();
279+ match media_identity {
280+ Some(identity) => TimelineContent::Sticker {
281+ body: sticker.body.clone(),
282+ media: media_descriptor_for_sticker(identity, sticker),
283+ },
284+ None => TimelineContent::Other {
285+ event_type: "m.sticker".to_owned(),
286+ },
108287 }
109288 }
110- TimelineItemKind::Virtual(VirtualTimelineItem::ReadMarker) => TimelineItem::ReadMarker,
111- TimelineItemKind::Virtual(VirtualTimelineItem::TimelineStart) => {
112- TimelineItem::TimelineStart
289+ MsgLikeKind::Poll(poll) => TimelineContent::Poll {
290+ question: poll.results().question,
291+ },
292+ MsgLikeKind::Redacted => TimelineContent::Redacted,
293+ MsgLikeKind::UnableToDecrypt(encrypted) => {
294+ TimelineContent::UnableToDecrypt(match encrypted {
295+ EncryptedMessage::MegolmV1AesSha2 { cause, .. } => utd_cause(*cause),
296+ _ => UtdCause::Unknown,
297+ })
113298 }
299+ MsgLikeKind::Other(other) => TimelineContent::Other {
300+ event_type: other.event_type().to_string(),
301+ },
302+ MsgLikeKind::LiveLocation(_) => TimelineContent::Other {
303+ event_type: "m.beacon_info".to_owned(),
304+ },
114305 }
115306 }
116307
117-fn timeline_message(
118- item: &SdkTimelineItem,
119- event: &matrix_sdk_ui::timeline::EventTimelineItem,
120- current_user_id: Option<&UserId>,
121- scope: &MediaScope,
122-) -> TimelineMessage {
123- let (body, formatted_body, mentions, edited, unable_to_decrypt, media) = match event.content() {
124- TimelineItemContent::MsgLike(content) => match &content.kind {
125- MsgLikeKind::Message(message) => (
126- message.body().to_owned(),
127- formatted_body(message.msgtype()),
128- message.mentions().map(message_mentions),
129- message.is_edited(),
130- false,
131- media_from_message(&media_identity(item, scope), message.msgtype()),
132- ),
133- MsgLikeKind::Sticker(sticker) => {
134- let content = sticker.content();
135- (
136- content.body.clone(),
137- None,
138- None,
139- false,
140- false,
141- Some(media_descriptor_for_sticker(
142- &media_identity(item, scope),
143- content,
144- )),
145- )
146- }
147- MsgLikeKind::Redacted => ("<message redacted>".into(), None, None, false, false, None),
148- MsgLikeKind::UnableToDecrypt(_) => (
149- "<unable to decrypt message>".into(),
150- None,
151- None,
152- false,
153- true,
154- None,
155- ),
156- MsgLikeKind::Poll(_) => ("[poll]".into(), None, None, false, false, None),
157- MsgLikeKind::LiveLocation(_) => {
158- ("[live location]".into(), None, None, false, false, None)
159- }
160- MsgLikeKind::Other(other) => (
161- format!("[{}]", other.event_type()),
162- None,
163- None,
164- false,
165- false,
166- None,
167- ),
168- },
169- other => (
170- format!(
171- "[{}]",
172- other.event_type_str().unwrap_or_else(|| "event".into())
173- ),
174- None,
175- None,
176- false,
177- false,
178- None,
179- ),
180- };
181- let reply_to = match event.content() {
182- TimelineItemContent::MsgLike(content) => content.in_reply_to.as_ref().map(|reply| {
183- let event_id = reply.event_id.to_owned();
184- let embedded_sender = match &reply.event {
185- TimelineDetails::Ready(event) => Some((
186- event.sender.clone(),
187- sender_name(&event.sender, &event.sender_profile),
188- )),
189- _ => None,
190- };
191- ReplyReference {
192- event_id,
193- sender_id: embedded_sender.as_ref().map(|(id, _)| id.clone()),
194- sender_name: embedded_sender.map(|(_, name)| name),
195- }
196- }),
308+fn message_kind(message: &MessageType) -> MessageKind {
309+ match message {
310+ MessageType::Text(_) => MessageKind::Text,
311+ MessageType::Emote(_) => MessageKind::Emote,
312+ MessageType::Notice(_) => MessageKind::Notice,
313+ MessageType::ServerNotice(_) => MessageKind::ServerNotice,
314+ MessageType::Image(_) => MessageKind::Image,
315+ MessageType::Video(_) => MessageKind::Video,
316+ MessageType::Audio(_) => MessageKind::Audio,
317+ MessageType::File(_) => MessageKind::File,
318+ MessageType::Location(_) => MessageKind::Location,
319+ MessageType::VerificationRequest(_) => MessageKind::VerificationRequest,
320+ other => MessageKind::Other(other.msgtype().to_owned()),
321+ }
322+}
323+
324+const fn membership_change(change: SdkMembershipChange) -> MembershipChange {
325+ match change {
326+ SdkMembershipChange::Joined => MembershipChange::Joined,
327+ SdkMembershipChange::Left => MembershipChange::Left,
328+ SdkMembershipChange::Banned => MembershipChange::Banned,
329+ SdkMembershipChange::Unbanned => MembershipChange::Unbanned,
330+ SdkMembershipChange::Kicked => MembershipChange::Kicked,
331+ SdkMembershipChange::KickedAndBanned => MembershipChange::KickedAndBanned,
332+ SdkMembershipChange::Invited => MembershipChange::Invited,
333+ SdkMembershipChange::InvitationAccepted => MembershipChange::InvitationAccepted,
334+ SdkMembershipChange::InvitationRejected => MembershipChange::InvitationRejected,
335+ SdkMembershipChange::InvitationRevoked => MembershipChange::InvitationRevoked,
336+ SdkMembershipChange::Knocked => MembershipChange::Knocked,
337+ SdkMembershipChange::KnockAccepted => MembershipChange::KnockAccepted,
338+ SdkMembershipChange::KnockRetracted => MembershipChange::KnockRetracted,
339+ SdkMembershipChange::KnockDenied => MembershipChange::KnockDenied,
340+ SdkMembershipChange::None
341+ | SdkMembershipChange::Error
342+ | SdkMembershipChange::NotImplemented => MembershipChange::Other,
343+ }
344+}
345+
346+const fn utd_cause(cause: SdkUtdCause) -> UtdCause {
347+ match cause {
348+ SdkUtdCause::Unknown => UtdCause::Unknown,
349+ SdkUtdCause::SentBeforeWeJoined => UtdCause::SentBeforeWeJoined,
350+ SdkUtdCause::VerificationViolation => UtdCause::VerificationViolation,
351+ SdkUtdCause::UnsignedDevice => UtdCause::UnsignedDevice,
352+ SdkUtdCause::UnknownDevice => UtdCause::UnknownDevice,
353+ SdkUtdCause::HistoricalMessageAndBackupIsDisabled => {
354+ UtdCause::HistoricalMessageAndBackupIsDisabled
355+ }
356+ SdkUtdCause::WithheldForUnverifiedOrInsecureDevice => {
357+ UtdCause::WithheldForUnverifiedOrInsecureDevice
358+ }
359+ SdkUtdCause::WithheldBySender => UtdCause::WithheldBySender,
360+ SdkUtdCause::HistoricalMessageAndDeviceIsUnverified => {
361+ UtdCause::HistoricalMessageAndDeviceIsUnverified
362+ }
363+ }
364+}
365+
366+fn sender(user_id: &UserId, profile: &TimelineDetails<Profile>) -> Sender {
367+ let profile = match profile {
368+ TimelineDetails::Ready(profile) => Some(profile),
197369 _ => None,
198370 };
199- let (thread_root, thread_summary) = match event.content() {
200- TimelineItemContent::MsgLike(content) => (
201- content.thread_root.clone(),
202- content.thread_summary.as_ref().map(|summary| {
203- let latest_event = match &summary.latest_event {
204- TimelineDetails::Ready(event) => Some(event.as_ref()),
205- _ => None,
206- };
207- ThreadSummary {
208- reply_count: summary.num_replies,
209- latest_event_id: latest_event.and_then(|event| match &event.identifier {
210- TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),
211- TimelineEventItemId::TransactionId(_) => None,
212- }),
213- latest_event_timestamp_ms: latest_event
214- .map(|event| event.timestamp.get().into()),
215- }
371+ Sender {
372+ user_id: user_id.to_owned(),
373+ display_name: profile.and_then(|profile| profile.display_name.clone()),
374+ avatar: profile.and_then(|profile| profile.avatar_url.clone()),
375+ name_ambiguous: profile.is_some_and(|profile| profile.display_name_ambiguous),
376+ }
377+}
378+
379+fn send_state(state: Option<&EventSendState>) -> SendState {
380+ match state {
381+ None | Some(EventSendState::Sent { .. }) => SendState::Sent,
382+ Some(EventSendState::NotSentYet { progress: Some(_) }) => SendState::Sending,
383+ Some(EventSendState::NotSentYet { progress: None }) => SendState::Queued,
384+ Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {
385+ retriable: *is_recoverable,
386+ },
387+ }
388+}
389+
390+fn thread_summary(content: &MsgLikeContent) -> Option<ThreadSummary> {
391+ content.thread_summary.as_ref().map(|summary| {
392+ let latest_event = match &summary.latest_event {
393+ TimelineDetails::Ready(event) => Some(event.as_ref()),
394+ _ => None,
395+ };
396+ ThreadSummary {
397+ reply_count: summary.num_replies,
398+ latest_event_id: latest_event.and_then(|event| match &event.identifier {
399+ TimelineEventItemId::EventId(event_id) => Some(event_id.clone()),
400+ TimelineEventItemId::TransactionId(_) => None,
216401 }),
217- ),
218- _ => (None, None),
219- };
220- let reactions = event
402+ latest_event_timestamp_ms: latest_event.map(|event| event.timestamp.get().into()),
403+ }
404+ })
405+}
406+
407+fn reactions(event: &EventTimelineItem, current_user_id: Option<&UserId>) -> Vec<Reaction> {
408+ event
221409 .content()
222410 .reactions()
223411 .into_iter()
@@ -228,56 +416,7 @@ fn timeline_message(
228416 own: current_user_id.is_some_and(|user_id| senders.contains_key(user_id)),
229417 senders: senders.keys().cloned().collect(),
230418 })
231- .collect();
232- let mut read_by: Vec<_> = event
233- .read_receipts()
234- .keys()
235- .filter(|user_id| *user_id != event.sender())
236- .cloned()
237- .collect();
238- read_by.sort();
239-
240- TimelineMessage {
241- item_id: TimelineItemId::new(item.unique_id().0.clone()),
242- event_id: event.event_id().map(ToOwned::to_owned),
243- transaction_id: event.transaction_id().map(ToOwned::to_owned),
244- sender_id: event.sender().to_owned(),
245- sender_name: sender_name(event.sender(), event.sender_profile()),
246- body,
247- formatted_body,
248- mentions,
249- thread_root,
250- thread_summary,
251- timestamp_ms: event.timestamp().get().into(),
252- own: event.is_own(),
253- send_state: match event.send_state() {
254- None | Some(EventSendState::Sent { .. }) => SendState::Sent,
255- Some(EventSendState::NotSentYet { progress: Some(_) }) => SendState::Sending,
256- Some(EventSendState::NotSentYet { progress: None }) => SendState::Queued,
257- Some(EventSendState::SendingFailed { is_recoverable, .. }) => SendState::Failed {
258- retriable: *is_recoverable,
259- },
260- },
261- edited,
262- unable_to_decrypt,
263- media,
264- reply_to,
265- reactions,
266- read_by,
267- }
268-}
269-
270-fn sender_name(
271- sender: &UserId,
272- sender_profile: &TimelineDetails<matrix_sdk_ui::timeline::Profile>,
273-) -> String {
274- match sender_profile {
275- TimelineDetails::Ready(profile) => profile
276- .display_name
277- .clone()
278- .unwrap_or_else(|| sender.to_string()),
279- _ => sender.to_string(),
280- }
419+ .collect()
281420 }
282421
283422 fn formatted_body(message: &MessageType) -> Option<String> {
@@ -293,10 +432,3 @@ fn formatted_body(message: &MessageType) -> Option<String> {
293432 }?;
294433 (formatted.format == MessageFormat::Html).then(|| formatted.body.clone())
295434 }
296-
297-fn message_mentions(mentions: &ruma::events::Mentions) -> MessageMentions {
298- MessageMentions {
299- user_ids: mentions.user_ids.iter().cloned().collect(),
300- room: mentions.room,
301- }
302-}
Modified file: src/transport.rs+7 additions−7 deletions
@@ -27,7 +27,7 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2727
2828 use crate::{
2929 AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,
30- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, InteractiveAuth,
30+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,
3131 InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaSize,
3232 MemberFilter, MemberSummary, MessageContent, MessageEditContent, NotificationCommand,
3333 OwnDevice, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,
@@ -303,12 +303,12 @@ impl ClientHandle {
303303 pub async fn edit_message(
304304 &self,
305305 timeline_id: TimelineId,
306- event_id: OwnedEventId,
306+ target: impl Into<EventRef>,
307307 content: MessageEditContent,
308308 ) -> Result<(), Error> {
309309 self.run(Command::Timeline(TimelineCommand::Edit {
310310 timeline_id,
311- event_id,
311+ target: target.into(),
312312 content,
313313 }))
314314 .await
@@ -317,12 +317,12 @@ impl ClientHandle {
317317 pub async fn redact(
318318 &self,
319319 timeline_id: TimelineId,
320- event_id: OwnedEventId,
320+ target: impl Into<EventRef>,
321321 reason: Option<String>,
322322 ) -> Result<(), Error> {
323323 self.run(Command::Timeline(TimelineCommand::Redact {
324324 timeline_id,
325- event_id,
325+ target: target.into(),
326326 reason,
327327 }))
328328 .await
@@ -331,12 +331,12 @@ impl ClientHandle {
331331 pub async fn toggle_reaction(
332332 &self,
333333 timeline_id: TimelineId,
334- event_id: OwnedEventId,
334+ target: impl Into<EventRef>,
335335 key: impl Into<String>,
336336 ) -> Result<(), Error> {
337337 self.run(Command::Timeline(TimelineCommand::ToggleReaction {
338338 timeline_id,
339- event_id,
339+ target: target.into(),
340340 key: key.into(),
341341 }))
342342 .await
Modified file: tests/commands.rs+1 additions−1 deletions
@@ -38,7 +38,7 @@ async fn empty_message_bodies_fail_with_empty_body() {
3838 },
3939 TimelineCommand::Edit {
4040 timeline_id: room_id.to_owned().into(),
41- event_id: event_id!("$edit:localhost").to_owned(),
41+ target: event_id!("$edit:localhost").to_owned().into(),
4242 content: MessageEditContent::text("\t"),
4343 },
4444 ];
Modified file: tests/engine.rs+21 additions−19 deletions
@@ -91,12 +91,12 @@ async fn opens_a_room_and_sends_messages() {
9191 let media_event = receive_until(&mut events, |event| {
9292 event_messages(event)
9393 .into_iter()
94- .any(|message| message.media.is_some())
94+ .any(|event| support::media_of(event).is_some())
9595 })
9696 .await;
9797 let media_id = event_messages(&media_event)
9898 .into_iter()
99- .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))
99+ .find_map(|event| support::media_of(event).map(|media| media.id.clone()))
100100 .unwrap();
101101 let media = handle.media(&media_id, MediaSize::Full).await.unwrap();
102102 assert!(!media.bytes.is_empty());
@@ -164,13 +164,15 @@ async fn keeps_open_room_timelines_independent() {
164164 let event = events.recv().await.expect("event stream closed");
165165 if let Event::TimelineChanged { timeline_id, .. } = &event {
166166 first_updated |= timeline_id.room_id() == first_id
167- && event_messages(&event)
168- .into_iter()
169- .any(|message| message.body == "first update");
167+ && event_messages(&event).into_iter().any(|event| {
168+ support::message(event)
169+ .is_some_and(|message| message.body == "first update")
170+ });
170171 second_updated |= timeline_id.room_id() == second_id
171- && event_messages(&event)
172- .into_iter()
173- .any(|message| message.body == "second update");
172+ && event_messages(&event).into_iter().any(|event| {
173+ support::message(event)
174+ .is_some_and(|message| message.body == "second update")
175+ });
174176 }
175177 }
176178 })
@@ -187,9 +189,9 @@ async fn keeps_open_room_timelines_independent() {
187189 .await;
188190 receive_until(&mut events, |event| {
189191 matches!(event, Event::TimelineChanged { timeline_id, .. } if timeline_id.room_id() == second_id)
190- && event_messages(event)
191- .into_iter()
192- .any(|message| message.body == "still live")
192+ && event_messages(event).into_iter().any(|event| {
193+ support::message(event).is_some_and(|message| message.body == "still live")
194+ })
193195 })
194196 .await;
195197
@@ -228,16 +230,16 @@ async fn resync_replaces_timeline_before_ack_and_preserves_following_diffs() {
228230 )
229231 .await;
230232 receive_until(&mut events, |event| {
231- event_messages(event)
232- .into_iter()
233- .any(|message| message.body == "before reset")
233+ event_messages(event).into_iter().any(|event| {
234+ support::message(event).is_some_and(|message| message.body == "before reset")
235+ })
234236 })
235237 .await;
236238
237239 let mut items =
238240 resync_and_confirm_reset_first(&handle, &mut events, room_id.to_owned().into()).await;
239241 assert!(items.iter().any(|item| matches!(item,
240- TimelineItem::Message(message) if message.body == "before reset"
242+ TimelineItem::Event(event) if support::message(event).is_some_and(|message| message.body == "before reset")
241243 )));
242244
243245 server
@@ -259,7 +261,7 @@ async fn resync_replaces_timeline_before_ack_and_preserves_following_diffs() {
259261 }
260262 if items.iter().any(|item| {
261263 matches!(item,
262- TimelineItem::Message(message) if message.body == "after reset"
264+ TimelineItem::Event(event) if support::message(event).is_some_and(|message| message.body == "after reset")
263265 )
264266 }) {
265267 break;
@@ -462,9 +464,9 @@ async fn pagination_reports_and_remembers_start_of_history() {
462464 .await;
463465 receive_until(&mut events, |event| {
464466 matches!(event, Event::TimelineChanged { timeline_id: changed, .. } if changed.room_id() == room_id)
465- && event_messages(event)
466- .into_iter()
467- .any(|message| message.body == "latest")
467+ && event_messages(event).into_iter().any(|event| {
468+ support::message(event).is_some_and(|message| message.body == "latest")
469+ })
468470 })
469471 .await;
470472
Modified file: tests/media.rs+8 additions−11 deletions
@@ -292,20 +292,17 @@ async fn queued_offline_attachment_exposes_fetchable_local_media() {
292292 });
293293 harness.handle.upload(upload).await.unwrap();
294294 let event = support::receive_until(&mut harness.events, |event| {
295- support::event_messages(event).into_iter().any(|message| {
296- message.own
297- && message.transaction_id.is_some()
298- && message
299- .media
300- .as_ref()
301- .is_some_and(|media| media.filename == "pending.png")
295+ support::event_messages(event).into_iter().any(|event| {
296+ event.own
297+ && event.transaction_id.is_some()
298+ && support::media_of(event).is_some_and(|media| media.filename == "pending.png")
302299 })
303300 })
304301 .await;
305302 let media = support::event_messages(&event)
306303 .into_iter()
307- .find(|message| message.own && message.media.is_some())
308- .and_then(|message| message.media.as_ref())
304+ .find(|event| event.own && support::media_of(event).is_some())
305+ .and_then(support::media_of)
309306 .unwrap();
310307 assert_eq!((media.width, media.height), (Some(32), Some(16)));
311308 assert_eq!(media.size_bytes, Some(original.len() as u64));
@@ -714,12 +711,12 @@ async fn start_with_config(content: RoomMessageEventContent, config: EngineConfi
714711 let event = support::receive_until(&mut events, |event| {
715712 support::event_messages(event)
716713 .into_iter()
717- .any(|message| message.media.is_some())
714+ .any(|event| support::media_of(event).is_some())
718715 })
719716 .await;
720717 let media_id = support::event_messages(&event)
721718 .into_iter()
722- .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))
719+ .find_map(|event| support::media_of(event).map(|media| media.id.clone()))
723720 .unwrap();
724721
725722 MediaHarness {
Modified file: tests/nonblocking.rs+2 additions−2 deletions
@@ -53,12 +53,12 @@ async fn delayed_media_fetch_does_not_block_timeline_command() {
5353 let media_event = receive_until(&mut events, |event| {
5454 event_messages(event)
5555 .into_iter()
56- .any(|message| message.media.is_some())
56+ .any(|event| support::media_of(event).is_some())
5757 })
5858 .await;
5959 let media_id = event_messages(&media_event)
6060 .into_iter()
61- .find_map(|message| message.media.as_ref().map(|media| media.id.clone()))
61+ .find_map(|event| support::media_of(event).map(|media| media.id.clone()))
6262 .unwrap();
6363
6464 let delayed_image = || {
Modified file: tests/send_queue.rs+4 additions−2 deletions
@@ -175,8 +175,10 @@ async fn queue_message(
175175 let event = events.recv().await.expect("event stream closed");
176176 let transaction = support::event_messages(&event)
177177 .into_iter()
178- .find(|message| message.body == body)
179- .and_then(|message| message.transaction_id.clone());
178+ .find(|event| {
179+ support::message(event).is_some_and(|message| message.body == body)
180+ })
181+ .and_then(|event| event.transaction_id.clone());
180182 if let Some(transaction) = transaction {
181183 return transaction;
182184 }
Modified file: tests/support/mod.rs+21 additions−3 deletions
@@ -2,7 +2,10 @@
22
33 use std::time::Duration;
44
5-use macltok::{ClientHandle, Event, EventStream, TimelineDiff, TimelineItem, TimelineMessage};
5+use macltok::{
6+ ClientHandle, Event, EventStream, MediaDescriptor, Message, TimelineContent, TimelineDiff,
7+ TimelineEvent, TimelineItem,
8+};
69
710 pub async fn receive_until(events: &mut EventStream, predicate: impl Fn(&Event) -> bool) -> Event {
811 tokio::time::timeout(Duration::from_secs(5), async {
@@ -30,7 +33,7 @@ pub async fn shut_down(
3033 running.await.unwrap().unwrap();
3134 }
3235
33-pub fn event_messages(event: &Event) -> Vec<&TimelineMessage> {
36+pub fn event_messages(event: &Event) -> Vec<&TimelineEvent> {
3437 let items = match event {
3538 Event::TimelineReset { items, .. } => items.iter().collect::<Vec<_>>(),
3639 Event::TimelineChanged { diffs, .. } => diffs.iter().flat_map(diff_items).collect(),
@@ -39,12 +42,27 @@ pub fn event_messages(event: &Event) -> Vec<&TimelineMessage> {
3942 items
4043 .into_iter()
4144 .filter_map(|item| match item {
42- TimelineItem::Message(message) => Some(message.as_ref()),
45+ TimelineItem::Event(event) => Some(event.as_ref()),
4346 _ => None,
4447 })
4548 .collect()
4649 }
4750
51+pub fn message(event: &TimelineEvent) -> Option<&Message> {
52+ match &event.content {
53+ TimelineContent::Message(message) => Some(message),
54+ _ => None,
55+ }
56+}
57+
58+pub fn media_of(event: &TimelineEvent) -> Option<&MediaDescriptor> {
59+ match &event.content {
60+ TimelineContent::Message(message) => message.media.as_ref(),
61+ TimelineContent::Sticker { media, .. } => Some(media),
62+ _ => None,
63+ }
64+}
65+
4866 pub fn diff_items(diff: &TimelineDiff) -> Vec<&TimelineItem> {
4967 match diff {
5068 TimelineDiff::Append { values } | TimelineDiff::Reset { values } => values.iter().collect(),