Commit fa51490b6ae9
tarball10 files changed+1148-48
@@ -353,6 +353,19 @@ fn too_large(message: &'static str) -> Error {
353353
Error::new(ErrorKind::MediaTooLarge, message)354354
}355355
356
+pub(crate) async fn download_file(357
+ client: &Client,358
+ source: MediaSource,359
+ mimetype: Option<String>,360
+ max_bytes: usize,361
+) -> Result<Media, Error> {362
+ let request = MediaRequestParameters {363
+ source,364
+ format: MediaFormat::File,365
+ };366
+ fetch(client.clone(), request, mimetype, max_bytes).await367
+}368
+356369
async fn fetch(357370
client: Client,358371
request: MediaRequestParameters,@@ -2,6 +2,7 @@ mod commands;
22
pub(crate) mod executor;33
mod media;44
mod mutations;5
+mod packs;56
mod prepare;67
mod room;78
mod session;@@ -35,13 +36,15 @@ use self::{
3536
commands::Job,3637
executor::{EngineBoxFuture, TaskSet},3738
mutations::{Mutation, MutationJob},39
+ packs::PackEdit,3840
room::{ActorNote, ActorRequest, RoomActor},3941
timeline::{SessionContext, Supervisor},4042
};4143
use crate::{4244
AccountCommand, ClientHandle, Command, CommandOutput, ConnectionState, EncryptionCommand,43
- EngineErrorKind, EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand,44
- MediaCommand, NotificationCommand, RoomCommand, Termination, TimelineCommand,45
+ EngineErrorKind, EngineFailure, Error, ErrorKind, Event, EventStream, ImagePackSource,46
+ LifecycleCommand, MediaCommand, NotificationCommand, PackCommand, RoomCommand, Termination,47
+ TimelineCommand,4548
normalise::{ROOM_PACKS, ROOM_PREVIEW_URLS},4649
transport::{DriverPort, Request, Responder, transport_pair},4750
};@@ -744,6 +747,7 @@ impl Engine {
744747
Command::Notification(command) => {745748
enqueue(&self.notifications, Job { command, reply });746749
}750
+ Command::Pack(command) => self.pack(command, reply),747751
}748752
None749753
}@@ -767,6 +771,82 @@ impl Engine {
767771
});768772
}769773
774
+ fn pack(&mut self, command: PackCommand, reply: Responder) {775
+ let edit = |pack, edit| Mutation::Pack { pack, edit };776
+ let mutation = match command {777
+ PackCommand::CreateRoomPack {778
+ room_id,779
+ state_key,780
+ display_name,781
+ } => edit(782
+ ImagePackSource::Room { room_id, state_key },783
+ PackEdit::Create { display_name },784
+ ),785
+ PackCommand::Delete(pack) => edit(pack, PackEdit::Delete),786
+ PackCommand::SetMeta {787
+ pack,788
+ display_name,789
+ avatar,790
+ usage,791
+ } => edit(792
+ pack,793
+ PackEdit::Meta {794
+ display_name,795
+ avatar,796
+ usage,797
+ },798
+ ),799
+ PackCommand::PutImage {800
+ pack,801
+ shortcode,802
+ mxc,803
+ body,804
+ usage,805
+ info,806
+ } => edit(807
+ pack,808
+ PackEdit::Put {809
+ shortcode,810
+ mxc,811
+ body,812
+ usage,813
+ info,814
+ },815
+ ),816
+ PackCommand::RenameImage { pack, from, to } => {817
+ edit(pack, PackEdit::Rename { from, to })818
+ }819
+ PackCommand::RemoveImage { pack, shortcode } => {820
+ edit(pack, PackEdit::Remove { shortcode })821
+ }822
+ PackCommand::SetEnabledEverywhere {823
+ room_id,824
+ state_key,825
+ enabled,826
+ } => Mutation::PackEnabledEverywhere {827
+ room_id,828
+ state_key,829
+ enabled,830
+ },831
+ command => {832
+ let client = self.client.clone();833
+ let mutations = self.mutations.clone();834
+ let max_bytes = self.config.max_media_bytes;835
+ self.tasks.spawn(TaskContext::Command("pack"), async move {836
+ reply.send(packs::run_detached(client, mutations, max_bytes, command).await);837
+ });838
+ return;839
+ }840
+ };841
+ enqueue(842
+ &self.mutations,843
+ Job {844
+ command: mutation,845
+ reply,846
+ },847
+ );848
+ }849
+770850
fn upload(&mut self, upload: crate::AttachmentUpload, reply: Responder) {771851
let Some(room) = self.client.get_room(upload.timeline_id.room_id()) else {772852
return reply.done(Err(Error::room_unknown()));@@ -30,9 +30,12 @@ use tokio::sync::mpsc;
3030
3131
use super::commands::{Job, guarded};3232
use super::executor::TaskSet;33
+use super::packs::{self, PackEdit};3334
use crate::{34
- CommandOutput, CreateRoomOptions, Error, ErrorKind, PowerLevelChanges, RoomCommand, RoomPreset,35
- RoomVisibility, normalise::stored_space_order, space_order,35
+ CommandOutput, CreateRoomOptions, Error, ErrorKind, ImagePackSource, PowerLevelChanges,36
+ RoomCommand, RoomPreset, RoomVisibility,37
+ normalise::{ENABLED_ROOMS, USER_PACK, stored_space_order},38
+ space_order,3639
};3740
3841
/// state changes that must not race each other. same room, same alias, same dm@@ -48,6 +51,15 @@ pub(super) enum Mutation {
4851
ignored: bool,4952
},5053
ReorderSpaces(Vec<OwnedRoomId>),54
+ Pack {55
+ pack: ImagePackSource,56
+ edit: PackEdit,57
+ },58
+ PackEnabledEverywhere {59
+ room_id: OwnedRoomId,60
+ state_key: String,61
+ enabled: bool,62
+ },5163
}5264
5365
pub(super) type MutationJob = Job<Mutation>;@@ -132,6 +144,17 @@ fn key(mutation: &Mutation, server: Option<&ruma::ServerName>) -> Option<Key> {
132144
Mutation::ReorderSpaces(_) => {133145
return Some(Key::AccountData(space_order::EVENT_TYPE.to_owned()));134146
}147
+ Mutation::Pack {148
+ pack: ImagePackSource::User,149
+ ..150
+ } => return Some(Key::AccountData(USER_PACK.to_owned())),151
+ Mutation::Pack {152
+ pack: ImagePackSource::Room { room_id, .. },153
+ ..154
+ } => room_id,155
+ Mutation::PackEnabledEverywhere { .. } => {156
+ return Some(Key::AccountData(ENABLED_ROOMS.to_owned()));157
+ }135158
Mutation::Room(RoomCommand::Join { room, .. }) => {136159
return Some(match OwnedRoomId::try_from(room.clone()) {137160
Ok(room_id) => Key::Room(room_id),@@ -216,6 +239,16 @@ async fn execute(
216239
Mutation::ReorderSpaces(order) => {217240
return reorder_spaces(client, &fresh.orders, order).await.map(done);218241
}242
+ Mutation::Pack { pack, edit } => return packs::edit_pack(client, &pack, edit).await,243
+ Mutation::PackEnabledEverywhere {244
+ room_id,245
+ state_key,246
+ enabled,247
+ } => {248
+ return packs::set_enabled_everywhere(client, room_id, state_key, enabled)249
+ .await250
+ .map(done);251
+ }219252
Mutation::Room(command) => command,220253
};221254
let fresh_dms = &fresh.dms;@@ -0,0 +1,585 @@
1
+use futures_util::{StreamExt, stream};2
+use matrix_sdk::{3
+ Client,4
+ ruma::{5
+ api::{client::state::get_state_event_for_key, error::ErrorKind as ApiErrorKind},6
+ events::{GlobalAccountDataEventType, StateEventType, room::MediaSource},7
+ serde::{JsonObject, Raw},8
+ },9
+};10
+use ruma::{OwnedEventId, OwnedMxcUri, OwnedRoomId};11
+use serde_json::{Value as JsonValue, json};12
+use tokio::sync::mpsc;13
+14
+use super::{15
+ commands::{self, Job},16
+ enqueue, media,17
+ mutations::{Mutation, MutationJob},18
+};19
+use crate::{20
+ CommandOutput, Error, ErrorKind, ImagePackSource, PackCommand, PackImageInfo, PackUsage, Patch,21
+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, resolve_pack_id, stored_pack},22
+ transport::Responder,23
+};24
+25
+/// read-modify-writes of one pack, which run in order per pack26
+pub(super) enum PackEdit {27
+ Create {28
+ display_name: String,29
+ },30
+ Delete,31
+ Meta {32
+ display_name: Patch<String>,33
+ avatar: Patch<OwnedMxcUri>,34
+ usage: Patch<Vec<PackUsage>>,35
+ },36
+ Put {37
+ shortcode: String,38
+ mxc: OwnedMxcUri,39
+ body: Option<String>,40
+ usage: Option<Vec<PackUsage>>,41
+ info: Option<PackImageInfo>,42
+ },43
+ Rename {44
+ from: String,45
+ to: String,46
+ },47
+ Remove {48
+ shortcode: String,49
+ },50
+ /// copies already on our homeserver. `create` is the pack meta for a room pack that doesn't exist yet51
+ Insert {52
+ images: Vec<(String, JsonObject)>,53
+ create: Option<JsonObject>,54
+ },55
+}56
+57
+pub(super) async fn edit_pack(58
+ client: &Client,59
+ pack: &ImagePackSource,60
+ edit: PackEdit,61
+) -> Result<CommandOutput, Error> {62
+ let current = fetch_pack(client, pack).await?;63
+ let mut content = current.clone().unwrap_or_default();64
+ let mut output = CommandOutput::Done;65
+ match edit {66
+ PackEdit::Create { display_name } => {67
+ if !content.is_empty() {68
+ return Err(Error::new(69
+ ErrorKind::Invalid,70
+ "that state key already holds a pack",71
+ ));72
+ }73
+ content = object(json!({ "pack": { "display_name": display_name }, "images": {} }));74
+ }75
+ PackEdit::Delete => content.clear(),76
+ PackEdit::Meta {77
+ display_name,78
+ avatar,79
+ usage,80
+ } => {81
+ let meta = child(&mut content, "pack");82
+ patch(meta, "display_name", display_name, JsonValue::String);83
+ patch(meta, "avatar_url", avatar, |uri| uri.to_string().into());84
+ patch(meta, "usage", usage, usage_json);85
+ }86
+ PackEdit::Put {87
+ shortcode,88
+ mxc,89
+ body,90
+ usage,91
+ info,92
+ } => {93
+ let images = child(&mut content, "images");94
+ let mut image = match images.remove(&shortcode) {95
+ Some(JsonValue::Object(image)) => image,96
+ _ => JsonObject::new(),97
+ };98
+ let same_media = image.get("url").and_then(JsonValue::as_str) == Some(mxc.as_str());99
+ image.insert("url".to_owned(), mxc.to_string().into());100
+ if let Some(body) = body {101
+ image.insert("body".to_owned(), body.into());102
+ }103
+ if let Some(usage) = usage {104
+ image.insert("usage".to_owned(), usage_json(usage));105
+ }106
+ match info {107
+ Some(info) => {108
+ image.insert("info".to_owned(), info_json(info));109
+ }110
+ None if !same_media => {111
+ image.remove("info");112
+ }113
+ None => {}114
+ }115
+ images.insert(shortcode, image.into());116
+ }117
+ PackEdit::Rename { from, to } => {118
+ let images = child(&mut content, "images");119
+ if images.contains_key(&to) {120
+ return Err(Error::new(ErrorKind::Invalid, "that shortcode is taken"));121
+ }122
+ let image = images123
+ .remove(&from)124
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no image has that shortcode"))?;125
+ images.insert(to, image);126
+ }127
+ PackEdit::Remove { shortcode } => {128
+ child(&mut content, "images").remove(&shortcode);129
+ }130
+ PackEdit::Insert { images, create } => {131
+ if content.is_empty() {132
+ match (create, pack) {133
+ (Some(meta), _) => {134
+ content.insert("pack".to_owned(), meta.into());135
+ }136
+ (None, ImagePackSource::Room { .. }) => {137
+ return Err(Error::new(138
+ ErrorKind::Invalid,139
+ "there's no pack there to copy into",140
+ ));141
+ }142
+ (None, ImagePackSource::User) => {}143
+ }144
+ }145
+ let target = child(&mut content, "images");146
+ let shortcodes = images147
+ .into_iter()148
+ .map(|(shortcode, image)| {149
+ let free = free_shortcode(target, &shortcode);150
+ target.insert(free.clone(), image.into());151
+ free152
+ })153
+ .collect();154
+ output = CommandOutput::Cloned {155
+ shortcodes,156
+ skipped: Vec::new(),157
+ };158
+ }159
+ }160
+ if current.as_ref() != Some(&content) {161
+ write_pack(client, pack, content).await?;162
+ }163
+ Ok(output)164
+}165
+166
+pub(super) async fn set_enabled_everywhere(167
+ client: &Client,168
+ room_id: OwnedRoomId,169
+ state_key: String,170
+ enabled: bool,171
+) -> Result<(), Error> {172
+ let account = client.account();173
+ let current = account174
+ .fetch_account_data(GlobalAccountDataEventType::from(ENABLED_ROOMS))175
+ .await176
+ .map_err(|source| Error::from_sdk("reading enabled packs failed", source))?177
+ .map(|raw| parse(raw.json().get()))178
+ .transpose()?;179
+ let mut content = current.clone().unwrap_or_default();180
+ let rooms = child(&mut content, "rooms");181
+ if enabled {182
+ child(rooms, room_id.as_str()).insert(state_key, JsonObject::new().into());183
+ } else {184
+ let state_keys = child(rooms, room_id.as_str());185
+ state_keys.remove(&state_key);186
+ if state_keys.is_empty() {187
+ rooms.remove(room_id.as_str());188
+ }189
+ }190
+ if current.as_ref() == Some(&content) {191
+ return Ok(());192
+ }193
+ account194
+ .set_account_data_raw(ENABLED_ROOMS.into(), raw(&content))195
+ .await196
+ .map(drop)197
+ .map_err(|source| Error::from_sdk("writing enabled packs failed", source))198
+}199
+200
+/// the commands that download, upload or send, so they stay off the per-pack queue and only201
+/// take their turn for the final write202
+pub(super) async fn run_detached(203
+ client: Client,204
+ mutations: mpsc::Sender<MutationJob>,205
+ max_bytes: usize,206
+ command: PackCommand,207
+) -> Result<CommandOutput, Error> {208
+ match command {209
+ PackCommand::SendSticker {210
+ room_id,211
+ pack_id,212
+ shortcode,213
+ } => send_sticker(&client, room_id, &pack_id, &shortcode)214
+ .await215
+ .map(|()| CommandOutput::Done),216
+ PackCommand::Rehost(mxc) => rehost(&client, mxc, None, max_bytes)217
+ .await218
+ .map(CommandOutput::Mxc),219
+ PackCommand::CloneImage {220
+ from_pack,221
+ shortcode,222
+ to,223
+ } => {224
+ let image = pack_image(&client, &from_pack, &shortcode).await?;225
+ let image = rehost_image(&client, image, max_bytes).await?;226
+ insert(&mutations, to, vec![(shortcode, image)], None, Vec::new()).await227
+ }228
+ PackCommand::CloneSticker {229
+ room_id,230
+ event_id,231
+ to,232
+ shortcode,233
+ } => {234
+ let image = sticker_image(&client, room_id, event_id).await?;235
+ let image = rehost_image(&client, image, max_bytes).await?;236
+ insert(&mutations, to, vec![(shortcode, image)], None, Vec::new()).await237
+ }238
+ PackCommand::ClonePack {239
+ from_pack,240
+ to,241
+ display_name,242
+ } => clone_pack(&client, &mutations, max_bytes, &from_pack, to, display_name).await,243
+ _ => Err(Error::new(ErrorKind::Other, "pack command was misrouted")),244
+ }245
+}246
+247
+async fn clone_pack(248
+ client: &Client,249
+ mutations: &mpsc::Sender<MutationJob>,250
+ max_bytes: usize,251
+ from_pack: &str,252
+ to: ImagePackSource,253
+ display_name: Option<String>,254
+) -> Result<CommandOutput, Error> {255
+ let source = resolve_pack_id(client, from_pack)256
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no pack has that id"))?;257
+ let content = stored_pack(client, &source)258
+ .await?259
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "that pack is empty"))?;260
+ let mut meta = match content.get("pack") {261
+ Some(JsonValue::Object(meta)) => meta.clone(),262
+ _ => JsonObject::new(),263
+ };264
+ let images: Vec<(String, JsonObject)> = match content.get("images") {265
+ Some(JsonValue::Object(images)) => images266
+ .iter()267
+ .filter_map(|(shortcode, image)| {268
+ let image = with_pack_usage(image.as_object()?.clone(), &meta);269
+ Some((shortcode.clone(), image))270
+ })271
+ .collect(),272
+ _ => Vec::new(),273
+ };274
+ let results: Vec<_> = stream::iter(images)275
+ .map(|(shortcode, image)| async move {276
+ match rehost_image(client, image, max_bytes).await {277
+ Ok(image) => Ok((shortcode, image)),278
+ Err(error) => {279
+ tracing::debug!(%error, shortcode, "skipping an image that wouldn't rehost");280
+ Err(shortcode)281
+ }282
+ }283
+ })284
+ .buffered(4)285
+ .collect()286
+ .await;287
+ let (copies, skipped): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);288
+ let copies = copies.into_iter().filter_map(Result::ok).collect();289
+ let skipped = skipped.into_iter().filter_map(Result::err).collect();290
+ let create = match to {291
+ ImagePackSource::User => None,292
+ ImagePackSource::Room { .. } => {293
+ if let Some(display_name) = display_name {294
+ meta.insert("display_name".to_owned(), display_name.into());295
+ }296
+ meta.remove("usage");297
+ if let Some(avatar) = meta.remove("avatar_url").and_then(|uri| mxc(&uri))298
+ && let Ok(avatar) = rehost(client, avatar, None, max_bytes).await299
+ {300
+ meta.insert("avatar_url".to_owned(), avatar.to_string().into());301
+ }302
+ Some(meta)303
+ }304
+ };305
+ insert(mutations, to, copies, create, skipped).await306
+}307
+308
+async fn insert(309
+ mutations: &mpsc::Sender<MutationJob>,310
+ pack: ImagePackSource,311
+ images: Vec<(String, JsonObject)>,312
+ create: Option<JsonObject>,313
+ skipped: Vec<String>,314
+) -> Result<CommandOutput, Error> {315
+ let (reply, answer) = Responder::channel();316
+ let command = Mutation::Pack {317
+ pack,318
+ edit: PackEdit::Insert { images, create },319
+ };320
+ enqueue(mutations, Job { command, reply });321
+ match answer.await? {322
+ CommandOutput::Cloned { shortcodes, .. } => Ok(CommandOutput::Cloned {323
+ shortcodes,324
+ skipped,325
+ }),326
+ output => Ok(output),327
+ }328
+}329
+330
+async fn send_sticker(331
+ client: &Client,332
+ room_id: OwnedRoomId,333
+ pack_id: &str,334
+ shortcode: &str,335
+) -> Result<(), Error> {336
+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;337
+ let image = pack_image(client, pack_id, shortcode).await?;338
+ let url = image339
+ .get("url")340
+ .and_then(mxc)341
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "that image has no usable url"))?;342
+ let content = json!({343
+ "body": image.get("body").and_then(JsonValue::as_str).unwrap_or(shortcode),344
+ "url": url,345
+ "info": image.get("info").cloned().unwrap_or_else(|| json!({})),346
+ });347
+ room.send_queue()348
+ .send_raw(raw(&object(content)), "m.sticker".to_owned())349
+ .await350
+ .map(drop)351
+ .map_err(|source| {352
+ Error::with_source(ErrorKind::Store, "queueing the sticker failed", source)353
+ })354
+}355
+356
+/// the image with its pack's usage folded in, so it means the same once it leaves the pack357
+async fn pack_image(client: &Client, pack_id: &str, shortcode: &str) -> Result<JsonObject, Error> {358
+ let source = resolve_pack_id(client, pack_id)359
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no pack has that id"))?;360
+ let content = stored_pack(client, &source).await?.unwrap_or_default();361
+ let image = content362
+ .get("images")363
+ .and_then(|images| images.get(shortcode))364
+ .and_then(JsonValue::as_object)365
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "no image has that shortcode"))?366
+ .clone();367
+ let meta = content368
+ .get("pack")369
+ .and_then(JsonValue::as_object)370
+ .cloned()371
+ .unwrap_or_default();372
+ Ok(with_pack_usage(image, &meta))373
+}374
+375
+async fn sticker_image(376
+ client: &Client,377
+ room_id: OwnedRoomId,378
+ event_id: OwnedEventId,379
+) -> Result<JsonObject, Error> {380
+ let room = client.get_room(&room_id).ok_or_else(Error::room_unknown)?;381
+ let event = room382
+ .load_or_fetch_event(&event_id, None)383
+ .await384
+ .map_err(|source| Error::from_sdk("loading the sticker failed", source))?;385
+ let raw = event.raw();386
+ if raw.get_field::<String>("type").ok().flatten().as_deref() != Some("m.sticker") {387
+ return Err(Error::new(ErrorKind::Invalid, "that event isn't a sticker"));388
+ }389
+ let content = raw390
+ .get_field::<JsonObject>("content")391
+ .ok()392
+ .flatten()393
+ .unwrap_or_default();394
+ let mut image = JsonObject::new();395
+ for key in ["url", "body", "info"] {396
+ if let Some(value) = content.get(key) {397
+ image.insert(key.to_owned(), value.clone());398
+ }399
+ }400
+ image.insert("usage".to_owned(), usage_json(vec![PackUsage::Sticker]));401
+ Ok(image)402
+}403
+404
+async fn rehost_image(405
+ client: &Client,406
+ mut image: JsonObject,407
+ max_bytes: usize,408
+) -> Result<JsonObject, Error> {409
+ let url = image410
+ .get("url")411
+ .and_then(mxc)412
+ .ok_or_else(|| Error::new(ErrorKind::Invalid, "that image has no usable url"))?;413
+ let mimetype = image414
+ .get("info")415
+ .and_then(|info| info.get("mimetype"))416
+ .and_then(JsonValue::as_str)417
+ .map(str::to_owned);418
+ let url = rehost(client, url, mimetype, max_bytes).await?;419
+ image.insert("url".to_owned(), url.to_string().into());420
+ Ok(image)421
+}422
+423
+pub(super) async fn rehost(424
+ client: &Client,425
+ mxc: OwnedMxcUri,426
+ mimetype: Option<String>,427
+ max_bytes: usize,428
+) -> Result<OwnedMxcUri, Error> {429
+ let homeserver = client.user_id().map(ruma::UserId::server_name);430
+ if mxc431
+ .server_name()432
+ .ok()433
+ .is_some_and(|server| Some(server) == homeserver)434
+ {435
+ return Ok(mxc);436
+ }437
+ let media = media::download_file(client, MediaSource::Plain(mxc), mimetype, max_bytes).await?;438
+ commands::upload_media(client, &media.mimetype, media.bytes.to_vec(), max_bytes).await439
+}440
+441
+/// the server's copy, since the store lags a sync behind and an edit made just before would be lost442
+async fn fetch_pack(client: &Client, pack: &ImagePackSource) -> Result<Option<JsonObject>, Error> {443
+ match pack {444
+ ImagePackSource::User => client445
+ .account()446
+ .fetch_account_data(GlobalAccountDataEventType::from(USER_PACK))447
+ .await448
+ .map_err(|source| Error::from_sdk("reading the pack failed", source))?449
+ .map(|raw| parse(raw.json().get()))450
+ .transpose(),451
+ ImagePackSource::Room { room_id, state_key } => {452
+ let request = get_state_event_for_key::v3::Request::new(453
+ room_id.clone(),454
+ StateEventType::from(ROOM_PACKS),455
+ state_key.clone(),456
+ );457
+ match client.send(request).await {458
+ Ok(response) => parse(response.event_or_content.get()).map(Some),459
+ Err(error) if error.client_api_error_kind() == Some(&ApiErrorKind::NotFound) => {460
+ Ok(None)461
+ }462
+ Err(source) => Err(Error::from_http("reading the pack failed", source)),463
+ }464
+ }465
+ }466
+}467
+468
+async fn write_pack(469
+ client: &Client,470
+ pack: &ImagePackSource,471
+ content: JsonObject,472
+) -> Result<(), Error> {473
+ let written = match pack {474
+ ImagePackSource::User => client475
+ .account()476
+ .set_account_data_raw(USER_PACK.into(), raw(&content))477
+ .await478
+ .map(drop),479
+ ImagePackSource::Room { room_id, state_key } => client480
+ .get_room(room_id)481
+ .ok_or_else(Error::room_unknown)?482
+ .send_state_event_raw(ROOM_PACKS, state_key, JsonValue::Object(content))483
+ .await484
+ .map(drop),485
+ };486
+ written.map_err(|source| Error::from_sdk("writing the pack failed", source))487
+}488
+489
+fn with_pack_usage(mut image: JsonObject, meta: &JsonObject) -> JsonObject {490
+ if !image.contains_key("usage")491
+ && let Some(usage) = meta.get("usage")492
+ {493
+ image.insert("usage".to_owned(), usage.clone());494
+ }495
+ image496
+}497
+498
+fn free_shortcode(images: &JsonObject, wanted: &str) -> String {499
+ if !images.contains_key(wanted) {500
+ return wanted.to_owned();501
+ }502
+ (2..)503
+ .map(|n| format!("{wanted}-{n}"))504
+ .find(|candidate| !images.contains_key(candidate))505
+ .expect("an unbounded range finds a free name")506
+}507
+508
+/// the object at `key`, made or replaced if it was missing or malformed509
+fn child<'a>(parent: &'a mut JsonObject, key: &str) -> &'a mut JsonObject {510
+ let slot = parent511
+ .entry(key.to_owned())512
+ .or_insert_with(|| JsonObject::new().into());513
+ if !slot.is_object() {514
+ *slot = JsonObject::new().into();515
+ }516
+ slot.as_object_mut().expect("just made it an object")517
+}518
+519
+fn patch<T>(520
+ meta: &mut JsonObject,521
+ key: &str,522
+ change: Patch<T>,523
+ to_json: impl FnOnce(T) -> JsonValue,524
+) {525
+ match change {526
+ Patch::Keep => {}527
+ Patch::Clear => {528
+ meta.remove(key);529
+ }530
+ Patch::Set(value) => {531
+ meta.insert(key.to_owned(), to_json(value));532
+ }533
+ }534
+}535
+536
+fn usage_json(usage: Vec<PackUsage>) -> JsonValue {537
+ usage538
+ .into_iter()539
+ .map(|usage| match usage {540
+ PackUsage::Emoticon => "emoticon",541
+ PackUsage::Sticker => "sticker",542
+ })543
+ .collect::<Vec<_>>()544
+ .into()545
+}546
+547
+fn info_json(info: PackImageInfo) -> JsonValue {548
+ let mut out = JsonObject::new();549
+ if let Some(mimetype) = info.mimetype {550
+ out.insert("mimetype".to_owned(), mimetype.into());551
+ }552
+ for (key, value) in [553
+ ("size", info.size_bytes),554
+ ("w", info.width),555
+ ("h", info.height),556
+ ] {557
+ if let Some(value) = value {558
+ out.insert(key.to_owned(), value.into());559
+ }560
+ }561
+ out.into()562
+}563
+564
+fn mxc(value: &JsonValue) -> Option<OwnedMxcUri> {565
+ let uri = OwnedMxcUri::from(value.as_str()?);566
+ uri.is_valid().then_some(uri)567
+}568
+569
+fn object(value: JsonValue) -> JsonObject {570
+ match value {571
+ JsonValue::Object(object) => object,572
+ _ => JsonObject::new(),573
+ }574
+}575
+576
+fn parse(json: &str) -> Result<JsonObject, Error> {577
+ serde_json::from_str(json)578
+ .map_err(|source| Error::with_source(ErrorKind::Server, "the pack is malformed", source))579
+}580
+581
+fn raw<T>(content: &JsonObject) -> Raw<T> {582
+ Raw::new(content)583
+ .expect("a json object always serialises")584
+ .cast_unchecked()585
+}@@ -53,16 +53,16 @@ pub use model::{
5353
KeyBackupState, LatestEvent, LifecycleCommand, LinkPreview, LinkPreviewImage, Media,5454
MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,5555
MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,56
- MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackImage,57
- PackUsage, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,58
- PushNotificationRoom, PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState,59
- ReplyDetails, ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero,60
- RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings,61
- RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild,62
- Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,63
- TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,64
- UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,65
- VerificationState, VerificationTarget,56
+ MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackCommand,57
+ PackImage, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile, PushNotification,58
+ PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,59
+ Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText, RoomCategory,60
+ RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership, RoomNotificationMode,61
+ RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary, RoomVisibility, SendState,62
+ Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff,63
+ TimelineDiffError, TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState,64
+ UploadDetails, UploadOutcome, UploadThumbnail, UtdCause, VerificationAction,65
+ VerificationFailure, VerificationState, VerificationTarget,6666
};6767
pub use ruma;6868
pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};@@ -27,6 +27,7 @@ pub enum Command {
2727
Encryption(EncryptionCommand),2828
Notification(NotificationCommand),2929
Lifecycle(LifecycleCommand),30
+ Pack(PackCommand),3031
}3132
3233
#[derive(Debug)]@@ -357,6 +358,114 @@ pub enum MediaCommand {
357358
Upload(AttachmentUpload),358359
}359360
361
+/// msc2545 pack editing. edits read the server's copy and keep keys macltok doesn't model362
+#[derive(Clone, Debug, Eq, PartialEq)]363
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]364
+#[non_exhaustive]365
+pub enum PackCommand {366
+ /// body defaults to the shortcode367
+ SendSticker {368
+ room_id: OwnedRoomId,369
+ pack_id: String,370
+ shortcode: String,371
+ },372
+ /// fails if the state key already holds a pack373
+ CreateRoomPack {374
+ room_id: OwnedRoomId,375
+ state_key: String,376
+ display_name: String,377
+ },378
+ /// empties the content, which is all deleting a pack can mean379
+ Delete(ImagePackSource),380
+ SetMeta {381
+ pack: ImagePackSource,382
+ display_name: Patch<String>,383
+ avatar: Patch<OwnedMxcUri>,384
+ usage: Patch<Vec<PackUsage>>,385
+ },386
+ /// adds or replaces by shortcode. a replaced image keeps its info unless the url changed387
+ PutImage {388
+ pack: ImagePackSource,389
+ shortcode: String,390
+ mxc: OwnedMxcUri,391
+ body: Option<String>,392
+ usage: Option<Vec<PackUsage>>,393
+ info: Option<PackImageInfo>,394
+ },395
+ /// fails if `to` is taken396
+ RenameImage {397
+ pack: ImagePackSource,398
+ from: String,399
+ to: String,400
+ },401
+ RemoveImage {402
+ pack: ImagePackSource,403
+ shortcode: String,404
+ },405
+ SetEnabledEverywhere {406
+ room_id: OwnedRoomId,407
+ state_key: String,408
+ enabled: bool,409
+ },410
+ /// answers with `CommandOutput::Mxc`, the same uri when it already lives on your homeserver411
+ Rehost(OwnedMxcUri),412
+ /// the rest answer with `CommandOutput::Cloned`. clashes get `-2`, `-3` and so on413
+ CloneImage {414
+ from_pack: String,415
+ shortcode: String,416
+ to: ImagePackSource,417
+ },418
+ CloneSticker {419
+ room_id: OwnedRoomId,420
+ event_id: OwnedEventId,421
+ to: ImagePackSource,422
+ shortcode: String,423
+ },424
+ /// makes the room pack if its state key is free, else merges. images that can't be425
+ /// fetched are skipped and listed, the rest are written426
+ ClonePack {427
+ from_pack: String,428
+ to: ImagePackSource,429
+ display_name: Option<String>,430
+ },431
+}432
+433
+#[derive(Clone, Debug, Default, Eq, PartialEq)]434
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]435
+pub enum Patch<T> {436
+ #[default]437
+ Keep,438
+ Clear,439
+ Set(T),440
+}441
+442
+#[derive(Clone, Debug, Default, Eq, PartialEq)]443
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]444
+#[non_exhaustive]445
+pub struct PackImageInfo {446
+ pub mimetype: Option<String>,447
+ pub size_bytes: Option<u64>,448
+ pub width: Option<u64>,449
+ pub height: Option<u64>,450
+}451
+452
+impl PackImageInfo {453
+ #[must_use]454
+ pub fn new(455
+ mimetype: Option<String>,456
+ size_bytes: Option<u64>,457
+ width: Option<u64>,458
+ height: Option<u64>,459
+ ) -> Self {460
+ Self {461
+ mimetype,462
+ size_bytes,463
+ width,464
+ height,465
+ }466
+ }467
+}468
+360469
#[derive(Clone, Debug, Eq, PartialEq)]361470
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]362471
#[non_exhaustive]@@ -511,6 +620,11 @@ pub enum CommandOutput {
511620
Done,512621
Room(OwnedRoomId),513622
Mxc(OwnedMxcUri),623
+ /// the shortcodes the copies ended up under, and the ones that couldn't be fetched624
+ Cloned {625
+ shortcodes: Vec<String>,626
+ skipped: Vec<String>,627
+ },514628
}515629
516630
#[derive(Clone, Debug, Eq, PartialEq)]@@ -858,6 +972,8 @@ pub struct RoomSummary {
858972
pub children: Vec<OwnedRoomId>,859973
/// a space's msc3230 order. ordered spaces sort first by (order, room id), the rest by room id860974
pub space_order: Option<String>,975
+ /// you can send im.ponies.room_emotes here, so you can make and edit its packs976
+ pub can_edit_packs: bool,861977
}862978
863979
#[derive(Clone, Debug, Eq, PartialEq)]@@ -1737,6 +1853,10 @@ pub struct ImagePack {
17371853
pub attribution: Option<String>,17381854
pub source: ImagePackSource,17391855
pub images: Vec<PackImage>,1856
+ /// your own pack, or a room pack whose state you're allowed to send1857
+ pub editable: bool,1858
+ /// listed in im.ponies.emote_rooms. always true for your own pack1859
+ pub enabled_everywhere: bool,17401860
}17411861
17421862
#[derive(Clone, Debug, Eq, PartialEq)]@@ -1,6 +1,6 @@
11
use std::collections::{BTreeMap, BTreeSet};22
3
-use matrix_sdk::{Client, Room, deserialized_responses::RawAnySyncOrStrippedState};3
+use matrix_sdk::{Client, Room, RoomState, deserialized_responses::RawAnySyncOrStrippedState};44
use ruma::{55
OwnedRoomId,66
events::{@@ -18,41 +18,94 @@ pub(crate) const ENABLED_ROOMS: &str = "im.ponies.emote_rooms";
1818
1919
/// rooms enabled everywhere that the user hasn't joined are skipped, we hold no state for them2020
pub(crate) async fn image_packs(client: &Client) -> Result<ImagePacks, Error> {21
+ let enabled = match account_data(client, ENABLED_ROOMS).await? {22
+ Some(content) => content23
+ .get_field::<EnabledRooms>("rooms")24
+ .ok()25
+ .flatten()26
+ .unwrap_or_default(),27
+ None => EnabledRooms::new(),28
+ };2129
let mut rooms = imbl::HashMap::new();2230
for room in client.joined_rooms() {23
- let packs = room_packs(&room).await?;31
+ let packs = room_packs(&room, enabled.get(room.room_id())).await?;2432
if !packs.is_empty() {2533
rooms.insert(room.room_id().to_owned(), packs);2634
}2735
}2836
let mut global = Vec::new();2937
if let Some(content) = account_data(client, USER_PACK).await? {30
- global.extend(pack("user".to_owned(), ImagePackSource::User, &content));38
+ global.extend(pack(39
+ "user".to_owned(),40
+ ImagePackSource::User,41
+ &content,42
+ true,43
+ true,44
+ ));3145
}32
- if let Some(content) = account_data(client, ENABLED_ROOMS).await? {33
- let enabled = content34
- .get_field::<BTreeMap<OwnedRoomId, BTreeMap<String, Raw<JsonObject>>>>("rooms")35
- .ok()36
- .flatten()37
- .unwrap_or_default();38
- for (room_id, state_keys) in enabled {39
- let packs = rooms.get(&room_id).into_iter().flatten();40
- global.extend(41
- packs42
- .filter(|pack| match &pack.source {43
- ImagePackSource::Room { state_key, .. } => {44
- state_keys.contains_key(state_key)45
- }46
- ImagePackSource::User => false,47
- })48
- .cloned(),49
- );50
- }46
+ for room_id in enabled.keys() {47
+ let packs = rooms.get(room_id).into_iter().flatten();48
+ global.extend(packs.filter(|pack| pack.enabled_everywhere).cloned());5149
}5250
Ok(ImagePacks { global, rooms })5351
}5452
55
-async fn room_packs(room: &Room) -> Result<Vec<ImagePack>, Error> {53
+type EnabledRooms = BTreeMap<OwnedRoomId, BTreeMap<String, Raw<JsonObject>>>;54
+55
+pub(crate) async fn can_edit_packs(room: &Room) -> bool {56
+ room.state() == RoomState::Joined57
+ && room.power_levels().await.is_ok_and(|levels| {58
+ levels.user_can_send_state(room.own_user_id(), StateEventType::from(ROOM_PACKS))59
+ })60
+}61
+62
+/// what an id from `ImagePack::id` points at. room ids hold colons, so it's matched63
+/// against the rooms we know rather than split64
+pub(crate) fn resolve_pack_id(client: &Client, id: &str) -> Option<ImagePackSource> {65
+ if id == "user" {66
+ return Some(ImagePackSource::User);67
+ }68
+ let rest = id.strip_prefix("room:")?;69
+ client.joined_rooms().into_iter().find_map(|room| {70
+ let state_key = rest71
+ .strip_prefix(room.room_id().as_str())?72
+ .strip_prefix(':')?;73
+ Some(ImagePackSource::Room {74
+ room_id: room.room_id().to_owned(),75
+ state_key: state_key.to_owned(),76
+ })77
+ })78
+}79
+80
+/// the pack's raw content as the store has it, for reading. edits fetch the server's copy81
+pub(crate) async fn stored_pack(82
+ client: &Client,83
+ source: &ImagePackSource,84
+) -> Result<Option<JsonObject>, Error> {85
+ let content = match source {86
+ ImagePackSource::User => account_data(client, USER_PACK).await?,87
+ ImagePackSource::Room { room_id, state_key } => {88
+ let room = client.get_room(room_id).ok_or_else(Error::room_unknown)?;89
+ match room90
+ .get_state_event(StateEventType::from(ROOM_PACKS), state_key)91
+ .await92
+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?93
+ {94
+ Some(RawAnySyncOrStrippedState::Sync(event)) => {95
+ event.get_field::<Raw<JsonObject>>("content").ok().flatten()96
+ }97
+ _ => None,98
+ }99
+ }100
+ };101
+ Ok(content.and_then(|content| content.deserialize().ok()))102
+}103
+104
+async fn room_packs(105
+ room: &Room,106
+ enabled: Option<&BTreeMap<String, Raw<JsonObject>>>,107
+) -> Result<Vec<ImagePack>, Error> {108
+ let editable = can_edit_packs(room).await;56109
let events = room57110
.get_state_events(StateEventType::from(ROOM_PACKS))58111
.await@@ -74,10 +127,14 @@ async fn room_packs(room: &Room) -> Result<Vec<ImagePack>, Error> {
74127
room_id: room.room_id().to_owned(),75128
state_key: state_key.clone(),76129
};130
+ let enabled_everywhere =131
+ enabled.is_some_and(|enabled| enabled.contains_key(&state_key));77132
pack(78133
format!("room:{}:{state_key}", room.room_id()),79134
source,80135
&content,136
+ editable,137
+ enabled_everywhere,81138
)82139
})83140
.collect())@@ -92,8 +149,15 @@ async fn account_data(client: &Client, event_type: &str) -> Result<Option<Raw<Js
92149
.map(|content| content.cast_unchecked()))93150
}94151
95
-/// one bad image costs that image, not its pack. a pack with nothing usable left is dropped96
-fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Option<ImagePack> {152
+/// one bad image costs that image, not its pack. only an emptied pack, which is how packs153
+/// get deleted, is dropped, so a fresh one shows up in a pack manager before it has images154
+fn pack(155
+ id: String,156
+ source: ImagePackSource,157
+ content: &Raw<JsonObject>,158
+ editable: bool,159
+ enabled_everywhere: bool,160
+) -> Option<ImagePack> {97161
let info = content98162
.get_field::<PackInfo>("pack")99163
.ok()@@ -126,7 +190,12 @@ fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Optio
126190
})127191
})128192
.collect();129
- (!images.is_empty()).then(|| ImagePack {193
+ let has_meta = content194
+ .get_field::<JsonObject>("pack")195
+ .ok()196
+ .flatten()197
+ .is_some();198
+ (has_meta || !images.is_empty()).then(|| ImagePack {130199
id,131200
display_name: info.display_name,132201
avatar: info@@ -136,6 +205,8 @@ fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Optio
136205
attribution: info.attribution,137206
source,138207
images,208
+ editable,209
+ enabled_everywhere,139210
})140211
}141212
@@ -5,7 +5,9 @@ mod member;
55
mod room;66
mod timeline;77
8
-pub(crate) use image_pack::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, image_packs};8
+pub(crate) use image_pack::{9
+ ENABLED_ROOMS, ROOM_PACKS, USER_PACK, can_edit_packs, image_packs, resolve_pack_id, stored_pack,10
+};911
pub(crate) use link_preview::{ROOM_PREVIEW_URLS, server_preview, url_previews_allowed};1012
pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};1113
pub(crate) use member::member_summary;@@ -107,6 +107,7 @@ async fn room_summary(room: &Room, spaces: &SpaceService) -> RoomSummary {
107107
} else {108108
None109109
},110
+ can_edit_packs: super::can_edit_packs(room).await,110111
}111112
}112113
@@ -28,13 +28,14 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2828
2929
use crate::{3030
AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,31
- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePacks,32
- InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media, MediaCommand,33
- MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,34
- MessageEditContent, NotificationCommand, OwnDevice, PowerLevelChanges, Profile,35
- PushNotification, PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,36
- RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,37
- TimelineCommand, TimelineId, VerificationAction, VerificationTarget,31
+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePackSource,32
+ ImagePacks, InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, LinkPreview, Media,33
+ MediaCommand, MediaId, MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent,34
+ MessageEditContent, NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage,35
+ Patch, PowerLevelChanges, Profile, PushNotification, PushNotificationEvent,36
+ PushNotificationStatus, RoomCommand, RoomListFilter, RoomNotificationMode,37
+ RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild, TimelineCommand,38
+ TimelineId, VerificationAction, VerificationTarget,3839
encryption::encryption_state,3940
engine::{MediaService, StoreLock},4041
model::{PushNotificationRoom, PushNotificationSender},@@ -736,6 +737,188 @@ impl ClientHandle {
736737
.await737738
}738739
740
+ /// body defaults to the shortcode741
+ pub async fn send_sticker(742
+ &self,743
+ room_id: OwnedRoomId,744
+ pack_id: String,745
+ shortcode: String,746
+ ) -> Result<(), Error> {747
+ self.run(Command::Pack(PackCommand::SendSticker {748
+ room_id,749
+ pack_id,750
+ shortcode,751
+ }))752
+ .await753
+ }754
+755
+ /// fails if the state key already holds a pack756
+ pub async fn create_room_pack(757
+ &self,758
+ room_id: OwnedRoomId,759
+ state_key: String,760
+ display_name: String,761
+ ) -> Result<(), Error> {762
+ self.run(Command::Pack(PackCommand::CreateRoomPack {763
+ room_id,764
+ state_key,765
+ display_name,766
+ }))767
+ .await768
+ }769
+770
+ pub async fn delete_pack(&self, pack: ImagePackSource) -> Result<(), Error> {771
+ self.run(Command::Pack(PackCommand::Delete(pack))).await772
+ }773
+774
+ pub async fn set_pack_meta(775
+ &self,776
+ pack: ImagePackSource,777
+ display_name: Patch<String>,778
+ avatar: Patch<OwnedMxcUri>,779
+ usage: Patch<Vec<PackUsage>>,780
+ ) -> Result<(), Error> {781
+ self.run(Command::Pack(PackCommand::SetMeta {782
+ pack,783
+ display_name,784
+ avatar,785
+ usage,786
+ }))787
+ .await788
+ }789
+790
+ /// adds or replaces by shortcode. a replaced image keeps its info unless the url changed791
+ pub async fn put_pack_image(792
+ &self,793
+ pack: ImagePackSource,794
+ shortcode: String,795
+ mxc: OwnedMxcUri,796
+ body: Option<String>,797
+ usage: Option<Vec<PackUsage>>,798
+ info: Option<PackImageInfo>,799
+ ) -> Result<(), Error> {800
+ self.run(Command::Pack(PackCommand::PutImage {801
+ pack,802
+ shortcode,803
+ mxc,804
+ body,805
+ usage,806
+ info,807
+ }))808
+ .await809
+ }810
+811
+ pub async fn rename_pack_image(812
+ &self,813
+ pack: ImagePackSource,814
+ from: String,815
+ to: String,816
+ ) -> Result<(), Error> {817
+ self.run(Command::Pack(PackCommand::RenameImage { pack, from, to }))818
+ .await819
+ }820
+821
+ pub async fn remove_pack_image(822
+ &self,823
+ pack: ImagePackSource,824
+ shortcode: String,825
+ ) -> Result<(), Error> {826
+ self.run(Command::Pack(PackCommand::RemoveImage { pack, shortcode }))827
+ .await828
+ }829
+830
+ pub async fn set_pack_enabled_everywhere(831
+ &self,832
+ room_id: OwnedRoomId,833
+ state_key: String,834
+ enabled: bool,835
+ ) -> Result<(), Error> {836
+ self.run(Command::Pack(PackCommand::SetEnabledEverywhere {837
+ room_id,838
+ state_key,839
+ enabled,840
+ }))841
+ .await842
+ }843
+844
+ /// the same uri when it already lives on your homeserver845
+ pub async fn rehost_media(&self, mxc: OwnedMxcUri) -> Result<OwnedMxcUri, Error> {846
+ match self847
+ .execute(Command::Pack(PackCommand::Rehost(mxc)))848
+ .await?849
+ {850
+ CommandOutput::Mxc(uri) => Ok(uri),851
+ output => Err(unexpected("an mxc", &output)),852
+ }853
+ }854
+855
+ /// hands back the shortcode it ended up under856
+ pub async fn clone_pack_image(857
+ &self,858
+ from_pack: String,859
+ shortcode: String,860
+ to: ImagePackSource,861
+ ) -> Result<String, Error> {862
+ let (mut shortcodes, _) = self863
+ .run_clone(PackCommand::CloneImage {864
+ from_pack,865
+ shortcode,866
+ to,867
+ })868
+ .await?;869
+ shortcodes870
+ .pop()871
+ .ok_or_else(|| unexpected("a shortcode", &CommandOutput::Done))872
+ }873
+874
+ /// hands back the shortcode it ended up under875
+ pub async fn clone_sticker(876
+ &self,877
+ room_id: OwnedRoomId,878
+ event_id: OwnedEventId,879
+ to: ImagePackSource,880
+ shortcode: String,881
+ ) -> Result<String, Error> {882
+ let (mut shortcodes, _) = self883
+ .run_clone(PackCommand::CloneSticker {884
+ room_id,885
+ event_id,886
+ to,887
+ shortcode,888
+ })889
+ .await?;890
+ shortcodes891
+ .pop()892
+ .ok_or_else(|| unexpected("a shortcode", &CommandOutput::Done))893
+ }894
+895
+ /// hands back the shortcodes that couldn't be fetched, everything else is written896
+ pub async fn clone_pack(897
+ &self,898
+ from_pack: String,899
+ to: ImagePackSource,900
+ display_name: Option<String>,901
+ ) -> Result<Vec<String>, Error> {902
+ let (_, skipped) = self903
+ .run_clone(PackCommand::ClonePack {904
+ from_pack,905
+ to,906
+ display_name,907
+ })908
+ .await?;909
+ Ok(skipped)910
+ }911
+912
+ async fn run_clone(&self, command: PackCommand) -> Result<(Vec<String>, Vec<String>), Error> {913
+ match self.execute(Command::Pack(command)).await? {914
+ CommandOutput::Cloned {915
+ shortcodes,916
+ skipped,917
+ } => Ok((shortcodes, skipped)),918
+ output => Err(unexpected("a clone", &output)),919
+ }920
+ }921
+739922
pub async fn rooms_filtered(&self, filter: &RoomListFilter) -> Vec<RoomSummary> {740923
let matcher = filter.matcher();741924
self.rooms()@@ -1047,6 +1230,11 @@ impl Request {
10471230
pub(crate) struct Responder(oneshot::Sender<Result<CommandOutput, Error>>);10481231
10491232
impl Responder {1233
+ pub(crate) fn channel() -> (Self, Pending) {1234
+ let (reply, pending) = oneshot::channel();1235
+ (Self(reply), Pending(pending))1236
+ }1237
+10501238
pub(crate) fn send(self, result: Result<CommandOutput, Error>) {10511239
if let Err(error) = &result {10521240
tracing::debug!(%error, kind = ?error.kind, "command failed");@@ -1241,3 +1429,10 @@ fn normalise_push_notification(
12411429
NotificationStatus::EventRedacted => PushNotificationStatus::Redacted,12421430
}12431431
}1432
+1433
+fn unexpected(wanted: &str, output: &CommandOutput) -> Error {1434
+ Error::new(1435
+ ErrorKind::Other,1436
+ format!("expected {wanted} but got {output:?}"),1437
+ )1438
+}