atagen

macltok

public
Star0
Watch0

Commit 9bd34e25bb86

tarball

atagen <boss@atagen.co>2026-10-24 07:35 UTC

15 files changed+897-73
commands: add favourites, msc3230 space order, room profile, mute, ignore, notices and plain media mxc

Every one of these is protocol policy malice would otherwise reimplement. reorder_spaces keeps the longest run of existing orders that already sorts right and only rebalances when no string fits between neighbours, and it remembers its own writes until sync echoes them so a quick second drag doesn't plan against stale orders. Room profile changes rewrite the member event from the server's copy, because the store lags a sync behind and would drop a change made just before. Ignoring reads the list from the server for the same reason, unlike Account::ignore_user.
Modified file: Cargo.toml+1 additions−1 deletions
@@ -33,6 +33,7 @@ matrix-sdk-store-encryption = { git = "ssh://brewery@pls.lobotomise.me:33/atagen
3333 matrix-sdk-ui = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "cc2b13ea185c0f694523a3a48d8aadfe366dd767", default-features = false }
3434 ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545", "unstable-msc4095"] }
3535 serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }
36+serde_json = "1.0.143"
3637 thiserror = "2.0.16"
3738 mime = "0.3.17"
3839 sha2 = "0.10.9"
@@ -64,7 +65,6 @@ web-sys = { version = "0.3.105", optional = true, features = [
6465 [dev-dependencies]
6566 matrix-sdk = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "cc2b13ea185c0f694523a3a48d8aadfe366dd767", default-features = false, features = ["testing"] }
6667 matrix-sdk-test = { git = "ssh://brewery@pls.lobotomise.me:33/atagen/matrix-rust-sdk", rev = "cc2b13ea185c0f694523a3a48d8aadfe366dd767" }
67-serde_json = "1.0.143"
6868 tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread", "signal", "time"] }
6969 wiremock = "0.6.5"
7070
Modified file: src/engine/commands.rs+24 additions−1 deletions
@@ -6,7 +6,7 @@ use matrix_sdk::{
66 Client,
77 notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode as SdkMode},
88 ruma::{
9- OwnedDeviceId, OwnedEventId, RoomId, TransactionId,
9+ OwnedDeviceId, OwnedEventId, OwnedMxcUri, RoomId, TransactionId,
1010 api::client::receipt::create_receipt::v3::ReceiptType, events::receipt::ReceiptThread,
1111 },
1212 send_queue::{LocalEchoContent, SendHandle},
@@ -288,6 +288,29 @@ pub(super) async fn upload_avatar(
288288 .map_err(|source| Error::from_sdk("uploading avatar failed", source))
289289 }
290290
291+pub(super) async fn upload_media(
292+ client: &Client,
293+ mimetype: &str,
294+ bytes: Vec<u8>,
295+ max_bytes: usize,
296+) -> Result<OwnedMxcUri, Error> {
297+ if bytes.len() > max_bytes {
298+ return Err(Error::new(
299+ ErrorKind::MediaTooLarge,
300+ "media exceeds the configured byte limit",
301+ ));
302+ }
303+ let mimetype: mime::Mime = mimetype
304+ .parse()
305+ .map_err(|_| Error::new(ErrorKind::Invalid, "media MIME type is invalid"))?;
306+ client
307+ .media()
308+ .upload(&mimetype, bytes, None)
309+ .await
310+ .map(|response| response.content_uri)
311+ .map_err(|source| Error::from_sdk("uploading media failed", source))
312+}
313+
291314 pub(super) async fn rename_device(
292315 client: &Client,
293316 device_id: &OwnedDeviceId,
Modified file: src/engine/media.rs+4 additions−1 deletions
@@ -710,7 +710,10 @@ async fn check_reply_thread(
710710 thread_root: &OwnedEventId,
711711 ) -> Result<(), Error> {
712712 let content = room
713- .make_reply_event(RoomMessageEventContentWithoutRelation::text_plain(""), reply)
713+ .make_reply_event(
714+ RoomMessageEventContentWithoutRelation::text_plain(""),
715+ reply,
716+ )
714717 .await
715718 .map_err(|source| {
716719 Error::with_source(ErrorKind::Timeline, "reply operation failed", source)
Modified file: src/engine/mod.rs+39 additions−3 deletions
@@ -39,9 +39,9 @@ use self::{
3939 timeline::{SessionContext, Supervisor},
4040 };
4141 use crate::{
42- AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,
43- EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,
44- NotificationCommand, RoomCommand, Termination, TimelineCommand,
42+ AccountCommand, ClientHandle, Command, CommandOutput, ConnectionState, EncryptionCommand,
43+ EngineErrorKind, EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand,
44+ MediaCommand, NotificationCommand, RoomCommand, Termination, TimelineCommand,
4545 normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},
4646 transport::{DriverPort, Request, Responder, transport_pair},
4747 };
@@ -653,6 +653,42 @@ impl Engine {
653653 commands::upload_avatar(&client, &mimetype, bytes, max_bytes).await
654654 });
655655 }
656+ Command::Account(AccountCommand::UploadMedia { mimetype, bytes }) => {
657+ let max_bytes = self.config.max_media_bytes;
658+ self.tasks
659+ .spawn(TaskContext::Command("upload media"), async move {
660+ let uploaded =
661+ commands::upload_media(&client, &mimetype, bytes, max_bytes).await;
662+ reply.send(uploaded.map(CommandOutput::Mxc));
663+ });
664+ }
665+ Command::Account(AccountCommand::IgnoreUser(user_id)) => enqueue(
666+ &self.mutations,
667+ Job {
668+ command: Mutation::Ignore {
669+ user_id,
670+ ignored: true,
671+ },
672+ reply,
673+ },
674+ ),
675+ Command::Account(AccountCommand::UnignoreUser(user_id)) => enqueue(
676+ &self.mutations,
677+ Job {
678+ command: Mutation::Ignore {
679+ user_id,
680+ ignored: false,
681+ },
682+ reply,
683+ },
684+ ),
685+ Command::Account(AccountCommand::ReorderSpaces(order)) => enqueue(
686+ &self.mutations,
687+ Job {
688+ command: Mutation::ReorderSpaces(order),
689+ reply,
690+ },
691+ ),
656692 Command::Account(AccountCommand::RenameDevice {
657693 device_id,
658694 display_name,
Modified file: src/engine/mutations.rs+285 additions−19 deletions
@@ -7,25 +7,32 @@ use matrix_sdk::{
77 Client, Room,
88 ruma::{
99 Int,
10- api::client::room::{
11- Visibility as SdkVisibility,
12- create_room::v3::{Request as CreateRoomRequest, RoomPreset as SdkPreset},
10+ api::client::{
11+ profile::{AvatarUrl, DisplayName},
12+ room::{
13+ Visibility as SdkVisibility,
14+ create_room::v3::{Request as CreateRoomRequest, RoomPreset as SdkPreset},
15+ },
16+ state::get_state_event_for_key,
1317 },
1418 events::{
1519 AnyGlobalAccountDataEventContent, GlobalAccountDataEventType, MessageLikeEventType,
16- StateEventType, room::power_levels::RoomPowerLevelsEventContent,
20+ StateEventType,
21+ ignored_user_list::{IgnoredUser, IgnoredUserListEventContent},
22+ room::power_levels::RoomPowerLevelsEventContent,
1723 },
18- serde::Raw,
24+ serde::{JsonObject, Raw},
1925 },
2026 };
2127 use ruma::{OwnedRoomId, OwnedUserId};
28+use serde_json::Value as JsonValue;
2229 use tokio::sync::mpsc;
2330
2431 use super::commands::{Job, guarded};
2532 use super::executor::TaskSet;
2633 use crate::{
2734 CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,
28- RoomVisibility,
35+ RoomVisibility, normalise::stored_space_order, space_order,
2936 };
3037
3138 /// state changes that must not race each other. same room, same alias, same dm
@@ -36,6 +43,11 @@ pub(super) enum Mutation {
3643 event_type: GlobalAccountDataEventType,
3744 content: Raw<AnyGlobalAccountDataEventContent>,
3845 },
46+ Ignore {
47+ user_id: OwnedUserId,
48+ ignored: bool,
49+ },
50+ ReorderSpaces(Vec<OwnedRoomId>),
3951 }
4052
4153 pub(super) type MutationJob = Job<Mutation>;
@@ -54,7 +66,7 @@ pub(super) async fn run_scheduler(
5466 mut jobs: mpsc::Receiver<MutationJob>,
5567 queue_limit: usize,
5668 ) {
57- let fresh_dms = FreshDms::default();
69+ let fresh = Fresh::default();
5870 let mut waiting = HashMap::<Key, VecDeque<MutationJob>>::new();
5971 let mut running = HashSet::<Key>::new();
6072 let mut tasks = TaskSet::<Key>::new();
@@ -74,7 +86,7 @@ pub(super) async fn run_scheduler(
7486 match next {
7587 Some(job) => {
7688 queued -= 1;
77- spawn(&mut tasks, &client, &fresh_dms, key, job);
89+ spawn(&mut tasks, &client, &fresh, key, job);
7890 }
7991 None => {
8092 running.remove(&key);
@@ -91,7 +103,7 @@ pub(super) async fn run_scheduler(
91103 Key::Independent(independent)
92104 });
93105 if running.insert(key.clone()) {
94- spawn(&mut tasks, &client, &fresh_dms, key, job);
106+ spawn(&mut tasks, &client, &fresh, key, job);
95107 } else if queued < queue_limit {
96108 waiting.entry(key).or_default().push_back(job);
97109 queued += 1;
@@ -112,6 +124,14 @@ fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
112124 Mutation::GlobalAccountData { event_type, .. } => {
113125 return Some(Key::AccountData(event_type.to_string()));
114126 }
127+ Mutation::Ignore { .. } => {
128+ return Some(Key::AccountData(
129+ GlobalAccountDataEventType::IgnoredUserList.to_string(),
130+ ));
131+ }
132+ Mutation::ReorderSpaces(_) => {
133+ return Some(Key::AccountData(space_order::EVENT_TYPE.to_owned()));
134+ }
115135 Mutation::Room(RoomCommand::Join { room, .. }) => {
116136 return Some(match OwnedRoomId::try_from(room.clone()) {
117137 Ok(room_id) => Key::Room(room_id),
@@ -139,6 +159,10 @@ fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
139159 | RoomCommand::UnbanUser { room_id, .. }
140160 | RoomCommand::SetPowerLevels { room_id, .. }
141161 | RoomCommand::SetUserPowerLevel { room_id, .. }
162+ | RoomCommand::MuteUser { room_id, .. }
163+ | RoomCommand::SetFavourite { room_id, .. }
164+ | RoomCommand::SetOwnDisplayName { room_id, .. }
165+ | RoomCommand::SetOwnAvatar { room_id, .. }
142166 | RoomCommand::SetAccountData { room_id, .. },
143167 ) => room_id,
144168 Mutation::Room(_) => return None,
@@ -150,25 +174,28 @@ fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
150174 /// only knows `m.direct` once it comes back round
151175 type FreshDms = Arc<std::sync::Mutex<HashMap<OwnedUserId, OwnedRoomId>>>;
152176
153-fn spawn(
154- tasks: &mut TaskSet<Key>,
155- client: &Client,
156- fresh_dms: &FreshDms,
157- key: Key,
158- job: MutationJob,
159-) {
177+/// space orders written here that sync hasn't echoed into the store yet
178+type FreshOrders = Arc<std::sync::Mutex<HashMap<OwnedRoomId, String>>>;
179+
180+#[derive(Clone, Default)]
181+struct Fresh {
182+ dms: FreshDms,
183+ orders: FreshOrders,
184+}
185+
186+fn spawn(tasks: &mut TaskSet<Key>, client: &Client, fresh: &Fresh, key: Key, job: MutationJob) {
160187 let client = client.clone();
161- let fresh_dms = fresh_dms.clone();
188+ let fresh = fresh.clone();
162189 tasks.spawn(async move {
163190 let Job { command, reply } = job;
164- reply.send(guarded("room command", execute(&client, &fresh_dms, command)).await);
191+ reply.send(guarded("room command", execute(&client, &fresh, command)).await);
165192 key
166193 });
167194 }
168195
169196 async fn execute(
170197 client: &Client,
171- fresh_dms: &FreshDms,
198+ fresh: &Fresh,
172199 mutation: Mutation,
173200 ) -> Result<CommandOutput, Error> {
174201 let command = match mutation {
@@ -183,8 +210,15 @@ async fn execute(
183210 .map_err(|source| Error::from_sdk("writing account data failed", source))?;
184211 return Ok(CommandOutput::Done);
185212 }
213+ Mutation::Ignore { user_id, ignored } => {
214+ return set_ignored(client, user_id, ignored).await.map(done);
215+ }
216+ Mutation::ReorderSpaces(order) => {
217+ return reorder_spaces(client, &fresh.orders, order).await.map(done);
218+ }
186219 Mutation::Room(command) => command,
187220 };
221+ let fresh_dms = &fresh.dms;
188222 let room = |room_id: &OwnedRoomId| client.get_room(room_id).ok_or_else(Error::room_unknown);
189223 let joined = |room: Room| CommandOutput::Room(room.room_id().to_owned());
190224 let sdk = |message: &'static str| move |source| Error::from_sdk(message, source);
@@ -260,6 +294,31 @@ async fn execute(
260294 .await
261295 .map(done)
262296 .map_err(sdk("updating user power level failed")),
297+ RoomCommand::MuteUser {
298+ room_id,
299+ user_id,
300+ muted,
301+ } => mute_user(client, &room(&room_id)?, &user_id, muted)
302+ .await
303+ .map(done),
304+ RoomCommand::SetFavourite { room_id, favourite } => room(&room_id)?
305+ .set_is_favourite(favourite, None)
306+ .await
307+ .map(done)
308+ .map_err(sdk("setting favourite failed")),
309+ RoomCommand::SetOwnDisplayName { room_id, name } => {
310+ set_own_member_field(client, &room(&room_id)?, ProfileField::DisplayName, name)
311+ .await
312+ .map(done)
313+ }
314+ RoomCommand::SetOwnAvatar { room_id, avatar } => set_own_member_field(
315+ client,
316+ &room(&room_id)?,
317+ ProfileField::Avatar,
318+ avatar.map(String::from),
319+ )
320+ .await
321+ .map(done),
263322 RoomCommand::SetAccountData {
264323 room_id,
265324 event_type,
@@ -345,6 +404,213 @@ async fn apply_power_level_changes(room: &Room, changes: PowerLevelChanges) -> R
345404 .map_err(|source| Error::from_sdk("updating power levels failed", source))
346405 }
347406
407+/// reads the list from the server, since the store only catches up on the next sync
408+async fn set_ignored(client: &Client, user_id: OwnedUserId, ignored: bool) -> Result<(), Error> {
409+ if ignored && client.user_id() == Some(&*user_id) {
410+ return Err(Error::new(ErrorKind::Invalid, "you can't ignore yourself"));
411+ }
412+ let account = client.account();
413+ let mut list = account
414+ .fetch_account_data_static::<IgnoredUserListEventContent>()
415+ .await
416+ .map_err(|source| Error::from_sdk("reading the ignore list failed", source))?
417+ .map(|raw| raw.deserialize())
418+ .transpose()
419+ .map_err(|source| {
420+ Error::with_source(ErrorKind::Invalid, "the ignore list is malformed", source)
421+ })?
422+ .unwrap_or_default();
423+ let changed = if ignored {
424+ list.ignored_users
425+ .insert(user_id, IgnoredUser::new())
426+ .is_none()
427+ } else {
428+ list.ignored_users.remove(&user_id).is_some()
429+ };
430+ if !changed {
431+ return Ok(());
432+ }
433+ account
434+ .set_account_data(list)
435+ .await
436+ .map(drop)
437+ .map_err(|source| Error::from_sdk("writing the ignore list failed", source))
438+}
439+
440+async fn reorder_spaces(
441+ client: &Client,
442+ fresh: &FreshOrders,
443+ order: Vec<OwnedRoomId>,
444+) -> Result<(), Error> {
445+ if order.iter().collect::<HashSet<_>>().len() != order.len() {
446+ return Err(Error::new(ErrorKind::Invalid, "a space is listed twice"));
447+ }
448+ let mut rooms = HashMap::new();
449+ let mut spaces = Vec::with_capacity(order.len());
450+ for room_id in order {
451+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;
452+ let stored = stored_space_order(&room).await;
453+ let current = {
454+ let mut fresh = fresh.lock().expect("nothing panics holding this");
455+ match fresh.get(&room_id) {
456+ Some(written) if stored.as_ref() != Some(written) => Some(written.clone()),
457+ Some(_) => fresh.remove(&room_id),
458+ None => stored,
459+ }
460+ };
461+ rooms.insert(room_id.clone(), room);
462+ spaces.push((room_id, current));
463+ }
464+ for (room_id, order) in space_order::plan(&spaces) {
465+ let content = Raw::new(&serde_json::json!({ "order": order }))
466+ .expect("a string map always serialises")
467+ .cast_unchecked();
468+ rooms[&room_id]
469+ .set_account_data_raw(space_order::EVENT_TYPE.into(), content)
470+ .await
471+ .map_err(|source| Error::from_sdk("writing a space order failed", source))?;
472+ fresh
473+ .lock()
474+ .expect("nothing panics holding this")
475+ .insert(room_id, order);
476+ }
477+ Ok(())
478+}
479+
480+/// below the cheapest of the levels a message could need, so neither plain nor encrypted
481+/// sends get through
482+async fn mute_user(
483+ client: &Client,
484+ room: &Room,
485+ user_id: &OwnedUserId,
486+ muted: bool,
487+) -> Result<(), Error> {
488+ let own_user_id = client
489+ .user_id()
490+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?;
491+ let mut levels = room
492+ .power_levels()
493+ .await
494+ .map_err(|source| Error::from_sdk("reading power levels failed", source.into()))?;
495+ if !levels.user_can_change_user_power_level(own_user_id, user_id) {
496+ return Err(Error::new(
497+ ErrorKind::Forbidden,
498+ "you can't change this user's power level",
499+ ));
500+ }
501+ let speaking = [
502+ levels.events_default,
503+ levels.for_message(MessageLikeEventType::RoomMessage),
504+ levels.for_message(MessageLikeEventType::RoomEncrypted),
505+ ]
506+ .into_iter()
507+ .min()
508+ .expect("the list isn't empty");
509+ let current = levels
510+ .users
511+ .get(user_id)
512+ .copied()
513+ .unwrap_or(levels.users_default);
514+ match (muted, current < speaking) {
515+ (true, true) | (false, false) => return Ok(()),
516+ (true, false) => {
517+ let level = speaking.checked_sub(Int::from(1)).ok_or_else(|| {
518+ Error::new(
519+ ErrorKind::Invalid,
520+ "there's no power level below this room's lowest",
521+ )
522+ })?;
523+ levels.users.insert(user_id.clone(), level);
524+ }
525+ (false, true) => {
526+ levels.users.remove(user_id);
527+ }
528+ }
529+ let content = RoomPowerLevelsEventContent::try_from(levels).map_err(|source| {
530+ Error::with_source(
531+ ErrorKind::Invalid,
532+ "normalising power levels failed",
533+ source,
534+ )
535+ })?;
536+ room.send_state_event(content)
537+ .await
538+ .map(drop)
539+ .map_err(|source| Error::from_sdk("updating power levels failed", source))
540+}
541+
542+#[derive(Clone, Copy)]
543+enum ProfileField {
544+ DisplayName,
545+ Avatar,
546+}
547+
548+/// rewrites your member event from the server's copy so membership and unknown keys survive,
549+/// and an earlier change this sync hasn't delivered yet isn't lost
550+async fn set_own_member_field(
551+ client: &Client,
552+ room: &Room,
553+ field: ProfileField,
554+ value: Option<String>,
555+) -> Result<(), Error> {
556+ let own_user_id = client
557+ .user_id()
558+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?
559+ .to_owned();
560+ let key = match field {
561+ ProfileField::DisplayName => "displayname",
562+ ProfileField::Avatar => "avatar_url",
563+ };
564+ let value = match value {
565+ Some(value) => Some(value),
566+ None => {
567+ let profile = client
568+ .account()
569+ .fetch_user_profile()
570+ .await
571+ .map_err(|source| Error::from_sdk("fetching the profile failed", source))?;
572+ let global = match field {
573+ ProfileField::DisplayName => profile.get_static::<DisplayName>(),
574+ ProfileField::Avatar => profile
575+ .get_static::<AvatarUrl>()
576+ .map(|avatar| avatar.map(String::from)),
577+ };
578+ global.map_err(|source| {
579+ Error::with_source(
580+ ErrorKind::Server,
581+ "the server sent a malformed profile",
582+ source,
583+ )
584+ })?
585+ }
586+ };
587+ let request = get_state_event_for_key::v3::Request::new(
588+ room.room_id().to_owned(),
589+ StateEventType::RoomMember,
590+ own_user_id.to_string(),
591+ );
592+ let response = client
593+ .send(request)
594+ .await
595+ .map_err(|source| Error::from_http("reading your member event failed", source))?;
596+ let mut content: JsonObject =
597+ serde_json::from_str(response.event_or_content.get()).map_err(|source| {
598+ Error::with_source(ErrorKind::Invalid, "your member event is malformed", source)
599+ })?;
600+ match value {
601+ Some(value) => content.insert(key.to_owned(), JsonValue::String(value)),
602+ None => content.remove(key),
603+ };
604+ room.send_state_event_raw(
605+ "m.room.member",
606+ own_user_id.as_str(),
607+ JsonValue::Object(content),
608+ )
609+ .await
610+ .map(drop)
611+ .map_err(|source| Error::from_sdk("updating your member event failed", source))
612+}
613+
348614 fn done<T>(_: T) -> CommandOutput {
349615 CommandOutput::Done
350616 }
Modified file: src/engine/timeline.rs+32 additions−17 deletions
@@ -223,6 +223,7 @@ impl TimelineSession {
223223 let body = match &content {
224224 MessageContent::Text { content, .. }
225225 | MessageContent::Emote { content, .. }
226+ | MessageContent::Notice { content, .. }
226227 | MessageContent::Reply { content, .. } => &content.body,
227228 };
228229 if body.trim().is_empty() {
@@ -230,10 +231,13 @@ impl TimelineSession {
230231 }
231232 let content = match content {
232233 MessageContent::Text { content, mentions } => {
233- message_content(content, mentions, false).with_relation(None)
234+ message_content(content, mentions, Msgtype::Text).with_relation(None)
234235 }
235236 MessageContent::Emote { content, mentions } => {
236- message_content(content, mentions, true).with_relation(None)
237+ message_content(content, mentions, Msgtype::Emote).with_relation(None)
238+ }
239+ MessageContent::Notice { content, mentions } => {
240+ message_content(content, mentions, Msgtype::Notice).with_relation(None)
237241 }
238242 MessageContent::Reply {
239243 event_id,
@@ -254,9 +258,13 @@ impl TimelineSession {
254258 TimelineCommand::Edit {
255259 target, content, ..
256260 } => {
257- let (content, mentions, emote) = match content {
258- MessageEditContent::Text { content, mentions } => (content, mentions, false),
259- MessageEditContent::Emote { content, mentions } => (content, mentions, true),
261+ let (content, mentions, msgtype) = match content {
262+ MessageEditContent::Text { content, mentions } => {
263+ (content, mentions, Msgtype::Text)
264+ }
265+ MessageEditContent::Emote { content, mentions } => {
266+ (content, mentions, Msgtype::Emote)
267+ }
260268 };
261269 if content.body.trim().is_empty() {
262270 return Err(Error::new(ErrorKind::EmptyBody, "message body is empty"));
@@ -264,7 +272,7 @@ impl TimelineSession {
264272 timeline
265273 .edit(
266274 &item_id(target),
267- EditedContent::RoomMessage(message_content(content, mentions, emote)),
275+ EditedContent::RoomMessage(message_content(content, mentions, msgtype)),
268276 )
269277 .await
270278 .map_err(timeline_error)
@@ -288,7 +296,7 @@ impl TimelineSession {
288296 .timeline
289297 .room()
290298 .make_reply_event(
291- message_content(content, mentions, false),
299+ message_content(content, mentions, Msgtype::Text),
292300 SdkReply {
293301 event_id,
294302 enforce_thread: if thread_root.is_some() {
@@ -438,20 +446,27 @@ async fn run_pagination(
438446 }
439447 }
440448
449+#[derive(Clone, Copy)]
450+enum Msgtype {
451+ Text,
452+ Emote,
453+ Notice,
454+}
455+
441456 fn message_content(
442457 content: RichText,
443458 mentions: MessageMentions,
444- emote: bool,
459+ msgtype: Msgtype,
445460 ) -> RoomMessageEventContentWithoutRelation {
446- let content = match content.formatted_body {
447- Some(formatted_body) if emote => {
448- RoomMessageEventContentWithoutRelation::emote_html(content.body, formatted_body)
449- }
450- Some(formatted_body) => {
451- RoomMessageEventContentWithoutRelation::text_html(content.body, formatted_body)
452- }
453- None if emote => RoomMessageEventContentWithoutRelation::emote_plain(content.body),
454- None => RoomMessageEventContentWithoutRelation::text_plain(content.body),
461+ type Content = RoomMessageEventContentWithoutRelation;
462+ let (body, formatted_body) = (content.body, content.formatted_body);
463+ let content = match (msgtype, formatted_body) {
464+ (Msgtype::Text, Some(html)) => Content::text_html(body, html),
465+ (Msgtype::Text, None) => Content::text_plain(body),
466+ (Msgtype::Emote, Some(html)) => Content::emote_html(body, html),
467+ (Msgtype::Emote, None) => Content::emote_plain(body),
468+ (Msgtype::Notice, Some(html)) => Content::notice_html(body, html),
469+ (Msgtype::Notice, None) => Content::notice_plain(body),
455470 };
456471 content.add_mentions(sdk_mentions(mentions))
457472 }
Modified file: src/engine/watchers.rs+20 additions−4 deletions
@@ -14,7 +14,7 @@ use matrix_sdk_base::RoomInfoNotableUpdateReasons;
1414 use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};
1515 use ruma::{
1616 OwnedRoomId, OwnedTransactionId,
17- events::{AnyGlobalAccountDataEvent, AnySyncStateEvent},
17+ events::{AnyGlobalAccountDataEvent, AnyRoomAccountDataEvent, AnySyncStateEvent},
1818 serde::Raw,
1919 time::Instant,
2020 };
@@ -26,6 +26,7 @@ use crate::{
2626 encryption::encryption_state,
2727 error::classify_sdk_error,
2828 normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},
29+ space_order,
2930 transport::EventSender,
3031 };
3132
@@ -400,22 +401,37 @@ pub(super) async fn encryption(client: Client, events: EventSender) {
400401 }
401402 }
402403
404+/// space orders are room account data, which never touches room info
403405 pub(super) async fn rooms(client: Client, events: EventSender) {
404406 let (_, mut rooms) = client.rooms_stream();
405407 let mut info = client.room_info_notable_update_receiver();
408+ let order_changed = Arc::new(Notify::new());
409+ let handler = client.add_event_handler({
410+ let order_changed = order_changed.clone();
411+ move |event: Raw<AnyRoomAccountDataEvent>| {
412+ let order_changed = order_changed.clone();
413+ async move {
414+ if event_type(&event).as_deref() == Some(space_order::EVENT_TYPE) {
415+ order_changed.notify_one();
416+ }
417+ }
418+ }
419+ });
406420 loop {
407421 if events.send(Event::RoomsChanged).await.is_err() {
408- return;
422+ break;
409423 }
410424 tokio::select! {
411- update = rooms.next() => if update.is_none() { return },
412- update = info.recv() => if matches!(update, Err(RecvError::Closed)) { return },
425+ update = rooms.next() => if update.is_none() { break },
426+ update = info.recv() => if matches!(update, Err(RecvError::Closed)) { break },
427+ () = order_changed.notified() => {}
413428 }
414429 // a sync touching fifty rooms is one invalidation, not fifty
415430 while let Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) =
416431 info.try_recv()
417432 {}
418433 }
434+ client.remove_event_handler(handler);
419435 }
420436
421437 /// packs live in room state and account data, and leaving a room takes its packs with it
Modified file: src/error.rs+2 additions
@@ -49,6 +49,8 @@ pub enum ErrorKind {
4949 Unauthenticated,
5050 InteractiveAuthRequired,
5151 Invalid,
52+ /// the room's power levels don't let you
53+ Forbidden,
5254 RoomUnknown,
5355 RoomNotOpen,
5456 TimelineNotOpen,
Modified file: src/lib.rs+1 additions
@@ -7,6 +7,7 @@ mod error;
77 mod mirror;
88 mod model;
99 mod normalise;
10+mod space_order;
1011 mod transport;
1112 mod uiaa;
1213 mod upload;
Modified file: src/model.rs+46 additions
@@ -45,6 +45,15 @@ pub enum AccountCommand {
4545 mimetype: String,
4646 bytes: Vec<u8>,
4747 },
48+ /// unencrypted, answers with `CommandOutput::Mxc`. same byte cap as any upload
49+ UploadMedia {
50+ mimetype: String,
51+ bytes: Vec<u8>,
52+ },
53+ IgnoreUser(OwnedUserId),
54+ UnignoreUser(OwnedUserId),
55+ /// writes msc3230 orders so these spaces sort in exactly this sequence, touching as few as it can
56+ ReorderSpaces(Vec<OwnedRoomId>),
4857 RenameDevice {
4958 device_id: OwnedDeviceId,
5059 display_name: String,
@@ -98,6 +107,28 @@ pub enum RoomCommand {
98107 user_id: OwnedUserId,
99108 level: i64,
100109 },
110+ /// muting drops them below what sending a message takes, unmuting puts a muted user back
111+ /// at users_default and leaves anyone else alone
112+ MuteUser {
113+ room_id: OwnedRoomId,
114+ user_id: OwnedUserId,
115+ muted: bool,
116+ },
117+ /// adding m.favourite drops m.lowpriority, the way Element does
118+ SetFavourite {
119+ room_id: OwnedRoomId,
120+ favourite: bool,
121+ },
122+ /// your name in one room. None goes back to your global name
123+ SetOwnDisplayName {
124+ room_id: OwnedRoomId,
125+ name: Option<String>,
126+ },
127+ /// your avatar in one room. None goes back to your global avatar
128+ SetOwnAvatar {
129+ room_id: OwnedRoomId,
130+ avatar: Option<OwnedMxcUri>,
131+ },
101132 Create(CreateRoomOptions),
102133 /// hands back the existing dm if there is one
103134 CreateDirect(OwnedUserId),
@@ -479,6 +510,7 @@ pub enum LifecycleCommand {
479510 pub enum CommandOutput {
480511 Done,
481512 Room(OwnedRoomId),
513+ Mxc(OwnedMxcUri),
482514 }
483515
484516 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -493,6 +525,11 @@ pub enum MessageContent {
493525 content: RichText,
494526 mentions: MessageMentions,
495527 },
528+ /// m.notice, for bots. clients render it muted and never answer it automatically
529+ Notice {
530+ content: RichText,
531+ mentions: MessageMentions,
532+ },
496533 Reply {
497534 event_id: OwnedEventId,
498535 content: RichText,
@@ -524,6 +561,11 @@ impl MessageContent {
524561 }
525562
526563 #[must_use]
564+ pub fn notice(content: RichText, mentions: MessageMentions) -> Self {
565+ Self::Notice { content, mentions }
566+ }
567+
568+ #[must_use]
527569 pub fn reply(event_id: OwnedEventId, content: RichText, mentions: MessageMentions) -> Self {
528570 Self::Reply {
529571 event_id,
@@ -814,6 +856,8 @@ pub struct RoomSummary {
814856 pub parent_spaces: Vec<OwnedRoomId>,
815857 /// for a space, the children it lists, joined or not
816858 pub children: Vec<OwnedRoomId>,
859+ /// a space's msc3230 order. ordered spaces sort first by (order, room id), the rest by room id
860+ pub space_order: Option<String>,
817861 }
818862
819863 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -1736,6 +1780,8 @@ pub enum PackUsage {
17361780 pub struct MediaDescriptor {
17371781 pub id: MediaId,
17381782 pub kind: MediaKind,
1783+ /// only for unencrypted media, an encrypted file's mxc is ciphertext without the keys
1784+ pub mxc: Option<OwnedMxcUri>,
17391785 pub filename: String,
17401786 pub caption: Option<String>,
17411787 pub mimetype: Option<String>,
Modified file: src/normalise/media.rs+58 additions−22 deletions
@@ -2,12 +2,15 @@ use std::sync::atomic::{AtomicU64, Ordering};
22
33 use matrix_sdk::{media::MediaEventContent, ruma::events::room::MediaSource};
44 use matrix_sdk_ui::timeline::{MsgLikeKind, TimelineItem as SdkTimelineItem, TimelineItemContent};
5-use ruma::events::{
6- room::{
7- ImageInfo, ThumbnailInfo,
8- message::{FileInfo, MessageType, VideoInfo},
5+use ruma::{
6+ OwnedMxcUri,
7+ events::{
8+ room::{
9+ ImageInfo, ThumbnailInfo,
10+ message::{FileInfo, MessageType, VideoInfo},
11+ },
12+ sticker::{StickerEventContent, StickerMediaSource},
913 },
10- sticker::StickerEventContent,
1114 };
1215
1316 use super::link_preview::sender_previews;
@@ -87,19 +90,21 @@ pub(super) fn media_identity(item: &SdkTimelineItem, scope: &MediaScope) -> Stri
8790 }
8891
8992 pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Option<MediaDescriptor> {
90- let descriptor = |kind, filename: &str, caption: Option<&str>| MediaDescriptor {
91- id: media_id(identity, "media"),
92- kind,
93- filename: filename.to_owned(),
94- caption: caption.map(str::to_owned),
95- mimetype: None,
96- size_bytes: None,
97- width: None,
98- height: None,
99- duration_ms: None,
100- blurhash: None,
101- has_thumbnail: false,
102- };
93+ let descriptor =
94+ |kind, source: &MediaSource, filename: &str, caption: Option<&str>| MediaDescriptor {
95+ id: media_id(identity, "media"),
96+ kind,
97+ mxc: plain_mxc(source),
98+ filename: filename.to_owned(),
99+ caption: caption.map(str::to_owned),
100+ mimetype: None,
101+ size_bytes: None,
102+ width: None,
103+ height: None,
104+ duration_ms: None,
105+ blurhash: None,
106+ has_thumbnail: false,
107+ };
103108 let millis = |duration: std::time::Duration| u64::try_from(duration.as_millis()).ok();
104109 Some(match message {
105110 MessageType::Image(content) => {
@@ -111,7 +116,12 @@ pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Optio
111116 height: info.and_then(|info| info.height).map(u64::from),
112117 blurhash: info.and_then(|info| info.blurhash.clone()),
113118 has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),
114- ..descriptor(MediaKind::Image, content.filename(), content.caption())
119+ ..descriptor(
120+ MediaKind::Image,
121+ &content.source,
122+ content.filename(),
123+ content.caption(),
124+ )
115125 }
116126 }
117127 MessageType::Video(content) => {
@@ -124,7 +134,12 @@ pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Optio
124134 duration_ms: info.and_then(|info| info.duration).and_then(millis),
125135 blurhash: info.and_then(|info| info.blurhash.clone()),
126136 has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),
127- ..descriptor(MediaKind::Video, content.filename(), content.caption())
137+ ..descriptor(
138+ MediaKind::Video,
139+ &content.source,
140+ content.filename(),
141+ content.caption(),
142+ )
128143 }
129144 }
130145 MessageType::Audio(content) => {
@@ -133,7 +148,12 @@ pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Optio
133148 mimetype: info.and_then(|info| info.mimetype.clone()),
134149 size_bytes: info.and_then(|info| info.size).map(u64::from),
135150 duration_ms: info.and_then(|info| info.duration).and_then(millis),
136- ..descriptor(MediaKind::Audio, content.filename(), content.caption())
151+ ..descriptor(
152+ MediaKind::Audio,
153+ &content.source,
154+ content.filename(),
155+ content.caption(),
156+ )
137157 }
138158 }
139159 MessageType::File(content) => {
@@ -142,7 +162,12 @@ pub(super) fn media_from_message(identity: &str, message: &MessageType) -> Optio
142162 mimetype: info.and_then(|info| info.mimetype.clone()),
143163 size_bytes: info.and_then(|info| info.size).map(u64::from),
144164 has_thumbnail: info.is_some_and(|info| info.thumbnail_source.is_some()),
145- ..descriptor(MediaKind::File, content.filename(), content.caption())
165+ ..descriptor(
166+ MediaKind::File,
167+ &content.source,
168+ content.filename(),
169+ content.caption(),
170+ )
146171 }
147172 }
148173 _ => return None,
@@ -156,6 +181,10 @@ pub(super) fn media_descriptor_for_sticker(
156181 MediaDescriptor {
157182 id: media_id(identity, "sticker"),
158183 kind: MediaKind::Sticker,
184+ mxc: match &content.source {
185+ StickerMediaSource::Plain(uri) => Some(uri.clone()),
186+ _ => None,
187+ },
159188 filename: content.body.clone(),
160189 caption: None,
161190 mimetype: content.info.mimetype.clone(),
@@ -168,6 +197,13 @@ pub(super) fn media_descriptor_for_sticker(
168197 }
169198 }
170199
200+fn plain_mxc(source: &MediaSource) -> Option<OwnedMxcUri> {
201+ match source {
202+ MediaSource::Plain(uri) => Some(uri.clone()),
203+ MediaSource::Encrypted(_) => None,
204+ }
205+}
206+
171207 pub(super) fn media_id(identity: &str, suffix: &str) -> MediaId {
172208 MediaId::new(format!("{identity}#{suffix}"))
173209 }
Modified file: src/normalise/mod.rs+1 additions−1 deletions
@@ -10,5 +10,5 @@ pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_al
1010 pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};
1111 pub(crate) use member::member_summary;
1212 pub(crate) use room::notification_mode;
13-pub(crate) use room::{room_summaries, space_child};
13+pub(crate) use room::{room_summaries, space_child, stored_space_order};
1414 pub(crate) use timeline::{message_kind, normalise_timeline, normalise_timeline_diffs};
Modified file: src/normalise/room.rs+22 additions−2 deletions
@@ -10,12 +10,15 @@ use matrix_sdk::{
1010 },
1111 };
1212 use matrix_sdk_ui::spaces::{SpaceRoom, SpaceService};
13-use ruma::OwnedRoomId;
13+use ruma::{
14+ OwnedRoomId,
15+ serde::{JsonObject, Raw},
16+};
1417
1518 use super::message_kind;
1619 use crate::{
1720 LatestEvent, MediaId, MessageKind, RoomHero, RoomMembership, RoomNotificationMode, RoomSummary,
18- SendState, SpaceChild,
21+ SendState, SpaceChild, space_order,
1922 };
2023
2124 pub(crate) async fn room_summaries(client: &Client, spaces: &SpaceService) -> Vec<RoomSummary> {
@@ -99,9 +102,26 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
99102 .map(|parent| parent.room_id)
100103 .collect(),
101104 children,
105+ space_order: if room.is_space() {
106+ stored_space_order(room).await
107+ } else {
108+ None
109+ },
102110 }
103111 }
104112
113+/// None when unset or when another client wrote something msc3230 doesn't allow
114+pub(crate) async fn stored_space_order(room: &Room) -> Option<String> {
115+ room.account_data(space_order::EVENT_TYPE.into())
116+ .await
117+ .ok()??
118+ .get_field::<Raw<JsonObject>>("content")
119+ .ok()??
120+ .get_field::<String>("order")
121+ .ok()?
122+ .filter(|order| space_order::valid(order))
123+}
124+
105125 const fn membership(state: RoomState) -> RoomMembership {
106126 match state {
107127 RoomState::Joined => RoomMembership::Joined,
Added file: src/space_order.rs+269 additions
@@ -0,0 +1,269 @@
1+use std::cmp::Ordering;
2+
3+use ruma::OwnedRoomId;
4+
5+const FIRST: u8 = 0x20;
6+const LAST: u8 = 0x7e;
7+const BASE: u64 = (LAST - FIRST + 1) as u64;
8+const MAX_LEN: usize = 50;
9+
10+pub(crate) const EVENT_TYPE: &str = "org.matrix.msc3230.space_order";
11+
12+pub(crate) fn valid(order: &str) -> bool {
13+ (1..=MAX_LEN).contains(&order.len()) && order.bytes().all(|b| (FIRST..=LAST).contains(&b))
14+}
15+
16+/// how msc3230 clients sort spaces: ordered ones by (order, room id), then the rest by room id
17+pub(crate) fn compare(
18+ (a_id, a_order): (&OwnedRoomId, Option<&str>),
19+ (b_id, b_order): (&OwnedRoomId, Option<&str>),
20+) -> Ordering {
21+ match (a_order, b_order) {
22+ (Some(a), Some(b)) => a.cmp(b).then_with(|| a_id.cmp(b_id)),
23+ (Some(_), None) => Ordering::Less,
24+ (None, Some(_)) => Ordering::Greater,
25+ (None, None) => a_id.cmp(b_id),
26+ }
27+}
28+
29+/// the orders to write so `spaces`, given with their current valid orders, sort in
30+/// exactly this sequence. keeps the longest run of orders that already sorts right
31+pub(crate) fn plan(spaces: &[(OwnedRoomId, Option<String>)]) -> Vec<(OwnedRoomId, String)> {
32+ let key = |index: usize| (&spaces[index].0, spaces[index].1.as_deref());
33+ let n = spaces.len();
34+
35+ let mut unordered_tail = n;
36+ while unordered_tail > 0
37+ && spaces[unordered_tail - 1].1.is_none()
38+ && (unordered_tail == n
39+ || compare(key(unordered_tail - 1), key(unordered_tail)) == Ordering::Less)
40+ {
41+ unordered_tail -= 1;
42+ }
43+
44+ let mut chain = vec![0usize; n];
45+ let mut previous = vec![None; n];
46+ for i in (0..n).filter(|&i| spaces[i].1.is_some()) {
47+ chain[i] = 1;
48+ for j in 0..i {
49+ if chain[j] > 0 && chain[j] + 1 > chain[i] && compare(key(j), key(i)) == Ordering::Less
50+ {
51+ chain[i] = chain[j] + 1;
52+ previous[i] = Some(j);
53+ }
54+ }
55+ }
56+
57+ let mut longest: Option<usize> = None;
58+ let mut best: Option<(usize, usize, Option<usize>)> = None;
59+ for split in 0..=n {
60+ if split > 0 && chain[split - 1] > longest.map_or(0, |end| chain[end]) {
61+ longest = Some(split - 1);
62+ }
63+ if split < unordered_tail {
64+ continue;
65+ }
66+ let cost = split - longest.map_or(0, |end| chain[end]);
67+ if best.is_none_or(|(best_cost, _, _)| cost < best_cost) {
68+ best = Some((cost, split, longest));
69+ }
70+ }
71+ let Some((_, split, end)) = best else {
72+ return Vec::new();
73+ };
74+
75+ let mut kept = vec![false; n];
76+ let mut cursor = end;
77+ while let Some(index) = cursor {
78+ kept[index] = true;
79+ cursor = previous[index];
80+ }
81+
82+ let mut writes = Vec::new();
83+ let mut run_start = 0;
84+ for index in 0..=split {
85+ if index < split && !kept[index] {
86+ continue;
87+ }
88+ if index > run_start {
89+ let lo = run_start
90+ .checked_sub(1)
91+ .and_then(|i| spaces[i].1.as_deref());
92+ let hi = (index < split)
93+ .then(|| spaces[index].1.as_deref())
94+ .flatten();
95+ let Some(orders) = between(lo, hi, index - run_start) else {
96+ return rebalance(&spaces[..split]);
97+ };
98+ writes.extend(
99+ spaces[run_start..index]
100+ .iter()
101+ .map(|(id, _)| id.clone())
102+ .zip(orders),
103+ );
104+ }
105+ run_start = index + 1;
106+ }
107+ writes
108+}
109+
110+fn rebalance(spaces: &[(OwnedRoomId, Option<String>)]) -> Vec<(OwnedRoomId, String)> {
111+ let orders = between(None, None, spaces.len()).unwrap_or_default();
112+ spaces
113+ .iter()
114+ .zip(orders)
115+ .filter(|((_, current), order)| current.as_ref() != Some(order))
116+ .map(|((id, _), order)| (id.clone(), order))
117+ .collect()
118+}
119+
120+/// `count` evenly spread orders strictly between `lo` and `hi`, as short as they can be
121+fn between(lo: Option<&str>, hi: Option<&str>, count: usize) -> Option<Vec<String>> {
122+ let lo = lo.unwrap_or("").as_bytes();
123+ (lo.len().max(1)..=MAX_LEN).find_map(|len| {
124+ let lo = digits(lo, len);
125+ let hi = match hi {
126+ Some(hi) => digits(&hi.as_bytes()[..hi.len().min(len)], len),
127+ None => {
128+ let mut one = vec![0; len + 1];
129+ one[0] = 1;
130+ one
131+ }
132+ };
133+ if hi <= lo {
134+ return None;
135+ }
136+ let step = divide(&subtract(&hi, &lo), count as u64 + 1);
137+ if step.iter().all(|&digit| digit == 0) {
138+ return None;
139+ }
140+ let mut point = lo;
141+ Some(
142+ (0..count)
143+ .map(|_| {
144+ point = add(&point, &step);
145+ let mut order: Vec<u8> = point[1..].iter().map(|&d| FIRST + d as u8).collect();
146+ while order.last() == Some(&FIRST) {
147+ order.pop();
148+ }
149+ String::from_utf8(order).expect("orders are ascii")
150+ })
151+ .collect(),
152+ )
153+ })
154+}
155+
156+/// big-endian base-95 digits with one leading digit of headroom, zero padded to `len`
157+fn digits(order: &[u8], len: usize) -> Vec<u64> {
158+ let mut out = vec![0; len + 1];
159+ for (slot, byte) in out[1..].iter_mut().zip(order) {
160+ *slot = u64::from(byte - FIRST);
161+ }
162+ out
163+}
164+
165+fn subtract(a: &[u64], b: &[u64]) -> Vec<u64> {
166+ let mut out = vec![0; a.len()];
167+ let mut borrow = 0;
168+ for i in (0..a.len()).rev() {
169+ let (value, under) = (a[i] + BASE - b[i] - borrow, a[i] < b[i] + borrow);
170+ out[i] = value % BASE;
171+ borrow = u64::from(under);
172+ }
173+ out
174+}
175+
176+fn add(a: &[u64], b: &[u64]) -> Vec<u64> {
177+ let mut out = vec![0; a.len()];
178+ let mut carry = 0;
179+ for i in (0..a.len()).rev() {
180+ let sum = a[i] + b[i] + carry;
181+ out[i] = sum % BASE;
182+ carry = sum / BASE;
183+ }
184+ out
185+}
186+
187+fn divide(a: &[u64], divisor: u64) -> Vec<u64> {
188+ let mut remainder = 0;
189+ a.iter()
190+ .map(|&digit| {
191+ let current = remainder * BASE + digit;
192+ remainder = current % divisor;
193+ current / divisor
194+ })
195+ .collect()
196+}
197+
198+#[cfg(test)]
199+mod tests {
200+ use ruma::owned_room_id;
201+
202+ use super::*;
203+
204+ fn apply(spaces: &[(OwnedRoomId, Option<String>)]) -> (Vec<OwnedRoomId>, usize) {
205+ let writes = plan(spaces);
206+ let mut after: Vec<_> = spaces
207+ .iter()
208+ .map(|(id, order)| {
209+ let written = writes.iter().find(|(w, _)| w == id).map(|(_, o)| o.clone());
210+ (id.clone(), written.or_else(|| order.clone()))
211+ })
212+ .collect();
213+ assert!(after.iter().all(|(_, o)| o.as_deref().is_none_or(valid)));
214+ after.sort_by(|a, b| compare((&a.0, a.1.as_deref()), (&b.0, b.1.as_deref())));
215+ (after.into_iter().map(|(id, _)| id).collect(), writes.len())
216+ }
217+
218+ fn ids(spaces: &[(OwnedRoomId, Option<String>)]) -> Vec<OwnedRoomId> {
219+ spaces.iter().map(|(id, _)| id.clone()).collect()
220+ }
221+
222+ #[test]
223+ fn already_sorted_spaces_need_no_writes() {
224+ let spaces = vec![
225+ (owned_room_id!("!z:x"), Some("a".to_owned())),
226+ (owned_room_id!("!a:x"), None),
227+ (owned_room_id!("!b:x"), None),
228+ ];
229+ assert_eq!(apply(&spaces), (ids(&spaces), 0));
230+ }
231+
232+ #[test]
233+ fn moving_one_space_writes_one_order() {
234+ let spaces = vec![
235+ (owned_room_id!("!c:x"), Some("c".to_owned())),
236+ (owned_room_id!("!a:x"), Some("a".to_owned())),
237+ (owned_room_id!("!b:x"), Some("b".to_owned())),
238+ (owned_room_id!("!d:x"), Some("d".to_owned())),
239+ ];
240+ assert_eq!(apply(&spaces), (ids(&spaces), 1));
241+ }
242+
243+ #[test]
244+ fn unordered_spaces_out_of_id_order_get_orders() {
245+ let spaces = vec![
246+ (owned_room_id!("!c:x"), None),
247+ (owned_room_id!("!a:x"), None),
248+ (owned_room_id!("!b:x"), None),
249+ ];
250+ assert_eq!(apply(&spaces), (ids(&spaces), 1));
251+ }
252+
253+ #[test]
254+ fn orders_with_no_room_between_them_rebalance() {
255+ let spaces = vec![
256+ (owned_room_id!("!a:x"), Some("a".to_owned())),
257+ (owned_room_id!("!c:x"), None),
258+ (owned_room_id!("!b:x"), Some("a ".to_owned())),
259+ ];
260+ assert_eq!(apply(&spaces).0, ids(&spaces));
261+ let prefix = "~".repeat(MAX_LEN - 1);
262+ let spaces = vec![
263+ (owned_room_id!("!b:x"), Some(format!("{prefix}!"))),
264+ (owned_room_id!("!a:x"), None),
265+ (owned_room_id!("!c:x"), Some(format!("{prefix}\""))),
266+ ];
267+ assert_eq!(apply(&spaces).0, ids(&spaces));
268+ }
269+}
Modified file: src/transport.rs+93 additions−2 deletions
@@ -653,6 +653,89 @@ impl ClientHandle {
653653 .await
654654 }
655655
656+ /// unencrypted, for room avatars and anything else that only needs the uri
657+ pub async fn upload_media(
658+ &self,
659+ mimetype: impl Into<String>,
660+ bytes: Vec<u8>,
661+ ) -> Result<OwnedMxcUri, Error> {
662+ let command = Command::Account(AccountCommand::UploadMedia {
663+ mimetype: mimetype.into(),
664+ bytes,
665+ });
666+ match self.execute(command).await? {
667+ CommandOutput::Mxc(uri) => Ok(uri),
668+ output => Err(Error::new(
669+ ErrorKind::Other,
670+ format!("expected an mxc but got {output:?}"),
671+ )),
672+ }
673+ }
674+
675+ pub async fn ignore_user(&self, user_id: OwnedUserId) -> Result<(), Error> {
676+ self.run(Command::Account(AccountCommand::IgnoreUser(user_id)))
677+ .await
678+ }
679+
680+ pub async fn unignore_user(&self, user_id: OwnedUserId) -> Result<(), Error> {
681+ self.run(Command::Account(AccountCommand::UnignoreUser(user_id)))
682+ .await
683+ }
684+
685+ /// writes msc3230 orders so these spaces sort in exactly this sequence, touching as few as it can
686+ pub async fn reorder_spaces(&self, order: Vec<OwnedRoomId>) -> Result<(), Error> {
687+ self.run(Command::Account(AccountCommand::ReorderSpaces(order)))
688+ .await
689+ }
690+
691+ /// adding m.favourite drops m.lowpriority, the way Element does
692+ pub async fn set_favourite(&self, room_id: OwnedRoomId, favourite: bool) -> Result<(), Error> {
693+ self.run(Command::Room(RoomCommand::SetFavourite {
694+ room_id,
695+ favourite,
696+ }))
697+ .await
698+ }
699+
700+ /// None goes back to your global name
701+ pub async fn set_room_display_name(
702+ &self,
703+ room_id: OwnedRoomId,
704+ name: Option<String>,
705+ ) -> Result<(), Error> {
706+ self.run(Command::Room(RoomCommand::SetOwnDisplayName {
707+ room_id,
708+ name,
709+ }))
710+ .await
711+ }
712+
713+ /// None goes back to your global avatar
714+ pub async fn set_room_avatar(
715+ &self,
716+ room_id: OwnedRoomId,
717+ avatar: Option<OwnedMxcUri>,
718+ ) -> Result<(), Error> {
719+ self.run(Command::Room(RoomCommand::SetOwnAvatar { room_id, avatar }))
720+ .await
721+ }
722+
723+ /// muting drops them below what sending a message takes, unmuting puts a muted user back
724+ /// at users_default and leaves anyone else alone. `ErrorKind::Forbidden` if you can't
725+ pub async fn mute_user(
726+ &self,
727+ room_id: OwnedRoomId,
728+ user_id: OwnedUserId,
729+ muted: bool,
730+ ) -> Result<(), Error> {
731+ self.run(Command::Room(RoomCommand::MuteUser {
732+ room_id,
733+ user_id,
734+ muted,
735+ }))
736+ .await
737+ }
738+
656739 pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {
657740 let matcher = filter.matcher();
658741 self.rooms()
@@ -756,7 +839,12 @@ impl ClientHandle {
756839 let room = self.room(room_id)?;
757840 // stores have been seen with members_synced persisted over a partial
758841 // roster, and the sdk never asks /members again once it's set
759- if self.members_checked.lock().unwrap().insert(room_id.to_owned()) {
842+ if self
843+ .members_checked
844+ .lock()
845+ .unwrap()
846+ .insert(room_id.to_owned())
847+ {
760848 room.mark_members_missing();
761849 }
762850 list_members(&room, filter).await
@@ -770,7 +858,10 @@ impl ClientHandle {
770858 ) -> Result<Vec<MemberSummary>, Error> {
771859 let room = self.room(room_id)?;
772860 room.mark_members_missing();
773- self.members_checked.lock().unwrap().insert(room_id.to_owned());
861+ self.members_checked
862+ .lock()
863+ .unwrap()
864+ .insert(room_id.to_owned());
774865 list_members(&room, filter).await
775866 }