Commit 9bd34e25bb86
tarball15 files changed+897-73
@@ -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
@@ -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 = mimetype304
+ .parse()305
+ .map_err(|_| Error::new(ErrorKind::Invalid, "media MIME type is invalid"))?;306
+ client307
+ .media()308
+ .upload(&mimetype, bytes, None)309
+ .await310
+ .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,@@ -710,7 +710,10 @@ async fn check_reply_thread(
710710
thread_root: &OwnedEventId,711711
) -> Result<(), Error> {712712
let content = room713
- .make_reply_event(RoomMessageEventContentWithoutRelation::text_plain(""), reply)713
+ .make_reply_event(714
+ RoomMessageEventContentWithoutRelation::text_plain(""),715
+ reply,716
+ )714717
.await715718
.map_err(|source| {716719
Error::with_source(ErrorKind::Timeline, "reply operation failed", source)@@ -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).await654654
});655655
}656
+ Command::Account(AccountCommand::UploadMedia { mimetype, bytes }) => {657
+ let max_bytes = self.config.max_media_bytes;658
+ self.tasks659
+ .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,@@ -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 round151175
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 yet178
+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
key166193
});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
.await261295
.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
+ .await303
+ .map(done),304
+ RoomCommand::SetFavourite { room_id, favourite } => room(&room_id)?305
+ .set_is_favourite(favourite, None)306
+ .await307
+ .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
+ .await312
+ .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
+ .await321
+ .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 sync408
+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 = account414
+ .fetch_account_data_static::<IgnoredUserListEventContent>()415
+ .await416
+ .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_users425
+ .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
+ account434
+ .set_account_data(list)435
+ .await436
+ .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
+ .await471
+ .map_err(|source| Error::from_sdk("writing a space order failed", source))?;472
+ fresh473
+ .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 encrypted481
+/// sends get through482
+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 = client489
+ .user_id()490
+ .ok_or_else(|| Error::new(ErrorKind::Unauthenticated, "the client is not logged in"))?;491
+ let mut levels = room492
+ .power_levels()493
+ .await494
+ .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 = levels510
+ .users511
+ .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
+ .await538
+ .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 lost550
+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 = client557
+ .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 = client568
+ .account()569
+ .fetch_user_profile()570
+ .await571
+ .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 => profile575
+ .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 = client593
+ .send(request)594
+ .await595
+ .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
+ .await610
+ .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::Done350616
}@@ -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
timeline265273
.edit(266274
&item_id(target),267
- EditedContent::RoomMessage(message_content(content, mentions, emote)),275
+ EditedContent::RoomMessage(message_content(content, mentions, msgtype)),268276
)269277
.await270278
.map_err(timeline_error)@@ -288,7 +296,7 @@ impl TimelineSession {
288296
.timeline289297
.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
}@@ -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 info403405
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 fifty415430
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@@ -49,6 +49,8 @@ pub enum ErrorKind {
4949
Unauthenticated,5050
InteractiveAuthRequired,5151
Invalid,52
+ /// the room's power levels don't let you53
+ Forbidden,5254
RoomUnknown,5355
RoomNotOpen,5456
TimelineNotOpen,@@ -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;@@ -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 upload49
+ 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 can56
+ 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 back111
+ /// at users_default and leaves anyone else alone112
+ MuteUser {113
+ room_id: OwnedRoomId,114
+ user_id: OwnedUserId,115
+ muted: bool,116
+ },117
+ /// adding m.favourite drops m.lowpriority, the way Element does118
+ SetFavourite {119
+ room_id: OwnedRoomId,120
+ favourite: bool,121
+ },122
+ /// your name in one room. None goes back to your global name123
+ SetOwnDisplayName {124
+ room_id: OwnedRoomId,125
+ name: Option<String>,126
+ },127
+ /// your avatar in one room. None goes back to your global avatar128
+ SetOwnAvatar {129
+ room_id: OwnedRoomId,130
+ avatar: Option<OwnedMxcUri>,131
+ },101132
Create(CreateRoomOptions),102133
/// hands back the existing dm if there is one103134
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 automatically529
+ 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 not816858
pub children: Vec<OwnedRoomId>,859
+ /// a space's msc3230 order. ordered spaces sort first by (order, room id), the rest by room id860
+ 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 keys1784
+ pub mxc: Option<OwnedMxcUri>,17391785
pub filename: String,17401786
pub caption: Option<String>,17411787
pub mimetype: Option<String>,@@ -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
}@@ -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};@@ -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).await107
+ } else {108
+ None109
+ },102110
}103111
}104112
113
+/// None when unset or when another client wrote something msc3230 doesn't allow114
+pub(crate) async fn stored_space_order(room: &Room) -> Option<String> {115
+ room.account_data(space_order::EVENT_TYPE.into())116
+ .await117
+ .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,@@ -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 id17
+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 in30
+/// exactly this sequence. keeps the longest run of orders that already sorts right31
+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 > 037
+ && spaces[unordered_tail - 1].1.is_none()38
+ && (unordered_tail == n39
+ || 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::Less50
+ {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_start90
+ .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
+ writes108
+}109
+110
+fn rebalance(spaces: &[(OwnedRoomId, Option<String>)]) -> Vec<(OwnedRoomId, String)> {111
+ let orders = between(None, None, spaces.len()).unwrap_or_default();112
+ spaces113
+ .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 be121
+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
+ one131
+ }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
+ out163
+}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
+ out174
+}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
+ out185
+}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 / divisor194
+ })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<_> = spaces207
+ .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
+}@@ -653,6 +653,89 @@ impl ClientHandle {
653653
.await654654
}655655
656
+ /// unencrypted, for room avatars and anything else that only needs the uri657
+ 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
+ .await678
+ }679
+680
+ pub async fn unignore_user(&self, user_id: OwnedUserId) -> Result<(), Error> {681
+ self.run(Command::Account(AccountCommand::UnignoreUser(user_id)))682
+ .await683
+ }684
+685
+ /// writes msc3230 orders so these spaces sort in exactly this sequence, touching as few as it can686
+ pub async fn reorder_spaces(&self, order: Vec<OwnedRoomId>) -> Result<(), Error> {687
+ self.run(Command::Account(AccountCommand::ReorderSpaces(order)))688
+ .await689
+ }690
+691
+ /// adding m.favourite drops m.lowpriority, the way Element does692
+ 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
+ .await698
+ }699
+700
+ /// None goes back to your global name701
+ 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
+ .await711
+ }712
+713
+ /// None goes back to your global avatar714
+ 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
+ .await721
+ }722
+723
+ /// muting drops them below what sending a message takes, unmuting puts a muted user back724
+ /// at users_default and leaves anyone else alone. `ErrorKind::Forbidden` if you can't725
+ 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
+ .await737
+ }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 partial758841
// roster, and the sdk never asks /members again once it's set759
- if self.members_checked.lock().unwrap().insert(room_id.to_owned()) {842
+ if self843
+ .members_checked844
+ .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_checked862
+ .lock()863
+ .unwrap()864
+ .insert(room_id.to_owned());774865
list_members(&room, filter).await775866
}