Commit 17e042fbc331
tarball13 files changed+526-44
@@ -1830,6 +1830,7 @@ dependencies = [
18301830
"imbl",18311831
"js-sys",18321832
"matrix-sdk",1833
+ "matrix-sdk-base",18331834
"matrix-sdk-crypto",18341835
"matrix-sdk-store-encryption",18351836
"matrix-sdk-test",@@ -27,10 +27,11 @@ futures-util = { version = "0.3.31", default-features = false, features = ["std"
2727
http = "1"2828
imbl = { version = "6.1.0", default-features = false }2929
matrix-sdk = { version = "0.18.0", default-features = false, features = ["e2e-encryption"] }30
+matrix-sdk-base = { version = "0.18.0", default-features = false }3031
matrix-sdk-crypto = { version = "0.18.0", default-features = false }3132
matrix-sdk-store-encryption = { version = "0.18.0", optional = true }3233
matrix-sdk-ui = { version = "0.18.0", default-features = false }33
-ruma = { version = "0.16.0", default-features = false }34
+ruma = { version = "0.16.0", default-features = false, features = ["unstable-msc2545"] }3435
serde = { version = "1.0.219", features = ["derive", "rc"], optional = true }3536
thiserror = "2.0.16"3637
mime = "0.3.17"@@ -319,6 +319,25 @@ membership until it's forgotten. push rule changes never touch room info, so
319319
`NotificationSettingsChanged` brings a `RoomsChanged` along for the modes in320320
the summaries321321
322
+## image packs323
+324
+msc2545 packs come from the user's account data, from room state, and from the325
+rooms the user enabled everywhere. the room list asks the server for every326
+room's `im.ponies.room_emotes`, through the fork's extra required state, so327
+packs sit in the store like any other state and a change reaches us even across328
+a gap in the timeline. that's every joined room's packs on the first sync,329
+tens of kilobytes for a big one, and only changes after. asking only for open330
+rooms would lose the enabled packs of rooms nobody has open331
+332
+`image_packs` reads all of it from the store and hands back the lot, global333
+packs and each room's own, and `ImagePacks::for_room` puts a picker's list in334
+order. an enabled room the user hasn't joined is skipped, there's no state for335
+it. one bad image costs that image and a pack with nothing usable is dropped,336
+since pack contents are whatever someone's client wrote. `ImagePacksChanged`337
+says a pack moved somewhere, from a state event, an account data change or a338
+membership change taking a room's packs with it. it's an invalidation, and the339
+mirror refetches on it like it does rooms, keeping the packs in its state340
+322341
## verification323342
324343
`Accept`, `Confirm`, `Reject` and `Cancel`. a request another device sends@@ -362,6 +381,8 @@ matrix-sdk-ui for our own layer over matrix-sdk-base and matrix-sdk-crypto,
362381
which keep their public sans-io apis363382
364383
- `Room::prepare_encryption`, the send path's encryption prep made public384
+- `SyncServiceBuilder::with_room_list_extra_required_state`, for state the385
+ sdk's hard-coded list leaves out. image packs use it365386
366387
## public model367388
@@ -1724,6 +1724,7 @@ dependencies = [
17241724
"imbl",17251725
"js-sys",17261726
"matrix-sdk",1727
+ "matrix-sdk-base",17271728
"matrix-sdk-crypto",17281729
"matrix-sdk-store-encryption",17291730
"matrix-sdk-ui",@@ -41,6 +41,7 @@ use crate::{
4141
AccountCommand, ClientHandle, Command, ConnectionState, EncryptionCommand, EngineErrorKind,4242
EngineFailure, Error, ErrorKind, Event, EventStream, LifecycleCommand, MediaCommand,4343
NotificationCommand, RoomCommand, Termination, TimelineCommand,44
+ normalise::ROOM_PACKS,4445
transport::{DriverPort, Request, Responder, transport_pair},4546
};4647
@@ -313,6 +314,7 @@ impl Engine {
313314
let sync = Arc::new(314315
SyncService::builder(client.clone())315316
.with_offline_mode()317
+ .with_room_list_extra_required_state(vec![(ROOM_PACKS.into(), "*".to_owned())])316318
.build()317319
.await318320
.map_err(EngineError::SyncService)?,@@ -462,7 +464,7 @@ impl Engine {
462464
let client = &self.client;463465
let events = || self.port.event_sender();464466
let (notices, notice_queue) = mpsc::unbounded_channel();465
- let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 12] = [467
+ let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 13] = [466468
(467469
"upload tracker",468470
Box::pin(watchers::uploads(@@ -528,6 +530,10 @@ impl Engine {
528530
Box::pin(watchers::rooms(client.clone(), events())),529531
),530532
(533
+ "image pack watcher",534
+ Box::pin(watchers::image_packs(client.clone(), events())),535
+ ),536
+ (531537
"notification settings watcher",532538
Box::pin(watchers::notification_settings(client.clone(), events())),533539
),@@ -6,13 +6,22 @@ use matrix_sdk::{
66
ruma::events::{AnyMessageLikeEventContent, room::message::MessageType},77
send_queue::{LocalEchoContent, RoomSendQueueUpdate, SendQueueUpdate},88
};9
+use matrix_sdk_base::RoomInfoNotableUpdateReasons;910
use matrix_sdk_ui::sync_service::{State as SyncState, SyncService};10
-use ruma::{OwnedRoomId, OwnedTransactionId, time::Instant};11
-use tokio::sync::{broadcast::error::RecvError, mpsc, watch};11
+use ruma::{12
+ OwnedRoomId, OwnedTransactionId,13
+ events::{AnyGlobalAccountDataEvent, AnySyncStateEvent},14
+ serde::Raw,15
+ time::Instant,16
+};17
+use tokio::sync::{Notify, broadcast::error::RecvError, mpsc, watch};1218
1319
use super::executor::TaskSet;1420
use crate::{15
- ConnectionState, Event, UploadOutcome, encryption::encryption_state, error::classify_sdk_error,21
+ ConnectionState, Event, UploadOutcome,22
+ encryption::encryption_state,23
+ error::classify_sdk_error,24
+ normalise::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK},1625
transport::EventSender,1726
};1827
@@ -425,6 +434,61 @@ pub(super) async fn rooms(client: Client, events: EventSender) {
425434
}426435
}427436
437
+/// packs live in room state and account data, and leaving a room takes its packs with it438
+pub(super) async fn image_packs(client: Client, events: EventSender) {439
+ let changed = Arc::new(Notify::new());440
+ let handlers = [441
+ client.add_event_handler({442
+ let changed = changed.clone();443
+ move |event: Raw<AnySyncStateEvent>| {444
+ let changed = changed.clone();445
+ async move {446
+ if event_type(&event).as_deref() == Some(ROOM_PACKS) {447
+ changed.notify_one();448
+ }449
+ }450
+ }451
+ }),452
+ client.add_event_handler({453
+ let changed = changed.clone();454
+ move |event: Raw<AnyGlobalAccountDataEvent>| {455
+ let changed = changed.clone();456
+ async move {457
+ if matches!(458
+ event_type(&event).as_deref(),459
+ Some(USER_PACK | ENABLED_ROOMS)460
+ ) {461
+ changed.notify_one();462
+ }463
+ }464
+ }465
+ }),466
+ ];467
+ let mut info = client.room_info_notable_update_receiver();468
+ loop {469
+ tokio::select! {470
+ () = changed.notified() => {}471
+ update = info.recv() => match update {472
+ Ok(update) if !update.reasons.contains(RoomInfoNotableUpdateReasons::MEMBERSHIP) => {473
+ continue;474
+ }475
+ Ok(_) | Err(RecvError::Lagged(_)) => {}476
+ Err(RecvError::Closed) => break,477
+ },478
+ }479
+ if events.send(Event::ImagePacksChanged).await.is_err() {480
+ break;481
+ }482
+ }483
+ for handler in handlers {484
+ client.remove_event_handler(handler);485
+ }486
+}487
+488
+fn event_type<T>(event: &Raw<T>) -> Option<String> {489
+ event.get_field("type").ok().flatten()490
+}491
+428492
pub(super) async fn notification_settings(client: Client, events: EventSender) {429493
let settings = client.notification_settings().await;430494
let mut changes = settings.subscribe_to_changes();@@ -48,19 +48,19 @@ pub use mirror::{
4848
pub use model::{4949
AccountCommand, AttachmentEncryption, AttachmentUpload, Change, Command, CommandOutput,5050
ConnectionState, CreateRoomOptions, EncryptionCommand, EncryptionState, EngineErrorKind,51
- EngineFailure, Event, EventRef, HistoryState, KeyBackupState, LatestEvent, LifecycleCommand,52
- Media, MediaCommand, MediaDescriptor, MediaFile, MediaId, MediaKind, MediaLocation, MediaSize,53
- MemberFilter, MemberMembership, MemberSummary, MembershipChange, Message, MessageContent,54
- MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice,55
- PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationRoom,56
- PushNotificationSender, PushNotificationStatus, Reaction, RecoveryState, ReplyDetails,57
- ReplyReference, Retain, RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter,58
- RoomMatcher, RoomMembership, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,59
- RoomPreset, RoomSummary, RoomVisibility, SendState, Sender, SpaceChild, Termination,60
- ThreadSummary, TimelineCommand, TimelineContent, TimelineDiff, TimelineDiffError,61
- TimelineEvent, TimelineId, TimelineItem, TimelineItemId, TypingState, UploadDetails,62
- UploadOutcome, UploadThumbnail, UtdCause, VerificationAction, VerificationFailure,63
- VerificationState, VerificationTarget,51
+ EngineFailure, Event, EventRef, HistoryState, ImagePack, ImagePackSource, ImagePacks,52
+ KeyBackupState, LatestEvent, LifecycleCommand, Media, MediaCommand, MediaDescriptor, MediaFile,53
+ MediaId, MediaKind, MediaLocation, MediaSize, MemberFilter, MemberMembership, MemberSummary,54
+ MembershipChange, Message, MessageContent, MessageEditContent, MessageKind, MessageMentions,55
+ NotificationCommand, OwnDevice, PackImage, PackUsage, PowerLevelChanges, Profile,56
+ PushNotification, PushNotificationEvent, PushNotificationRoom, PushNotificationSender,57
+ PushNotificationStatus, Reaction, RecoveryState, ReplyDetails, ReplyReference, Retain,58
+ RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,59
+ RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,60
+ RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadSummary, TimelineCommand,61
+ TimelineContent, TimelineDiff, TimelineDiffError, TimelineEvent, TimelineId, TimelineItem,62
+ TimelineItemId, TypingState, UploadDetails, UploadOutcome, UploadThumbnail, UtdCause,63
+ VerificationAction, VerificationFailure, VerificationState, VerificationTarget,6464
};6565
pub use ruma;6666
pub use transport::{ClientHandle, EventStream, Pending, SpaceHierarchy};@@ -20,7 +20,7 @@ use tokio::sync::broadcast;
2020
2121
use crate::{2222
ClientHandle, Command, ConnectionState, EncryptionState, EngineFailure, ErrorKind, Event,23
- HistoryState, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,23
+ HistoryState, ImagePacks, RoomSummary, TimelineCommand, TimelineDiff, TimelineId, TimelineItem,2424
TypingState, UploadOutcome, VerificationState, VerificationTarget,2525
engine::executor::EngineBoxFuture,2626
};@@ -46,6 +46,7 @@ pub struct State {
4646
pub verification: Option<VerificationState>,4747
pub verification_target: Option<VerificationTarget>,4848
pub rooms: Vector<RoomSummary>,49
+ pub image_packs: ImagePacks,4950
pub timelines: imbl::HashMap<TimelineId, TimelineState>,5051
pub typing: imbl::HashMap<OwnedRoomId, Vec<OwnedUserId>>,5152
pub uploads: imbl::HashMap<OwnedTransactionId, UploadState>,@@ -125,6 +126,7 @@ pub enum StateChange {
125126
/// room modes arrive with the `Rooms` that follows126127
NotificationSettings,127128
Rooms(Vector<RoomSummary>),129
+ ImagePacks(ImagePacks),128130
Timeline {129131
timeline_id: TimelineId,130132
change: TimelineChange,@@ -169,6 +171,7 @@ pub struct Mirror {
169171
shared: Arc<Shared>,170172
resyncs: HashMap<TimelineId, AbortHandle>,171173
rooms_fetch: Option<AbortHandle>,174
+ packs_fetch: Option<AbortHandle>,172175
}173176
174177
impl fmt::Debug for Mirror {@@ -201,6 +204,7 @@ impl Mirror {
201204
verification: None,202205
verification_target: None,203206
rooms: Vector::new(),207
+ image_packs: ImagePacks::default(),204208
timelines: imbl::HashMap::new(),205209
typing: imbl::HashMap::new(),206210
uploads: imbl::HashMap::new(),@@ -212,7 +216,8 @@ impl Mirror {
212216
state: Arc::new(state),213217
sequence: 0,214218
updates: Some(broadcast::channel(capacity.max(1)).0),215
- rooms_fetch: RoomsFetch::Idle,219
+ rooms_fetch: Fetch::Idle,220
+ packs_fetch: Fetch::Idle,216221
}),217222
});218223
#[cfg(not(target_family = "wasm"))]@@ -225,6 +230,7 @@ impl Mirror {
225230
shared: shared.clone(),226231
resyncs: HashMap::new(),227232
rooms_fetch: None,233
+ packs_fetch: None,228234
};229235
(mirror, MirrorHandle { shared })230236
}@@ -233,6 +239,7 @@ impl Mirror {
233239
pub fn run(mut self) -> EngineBoxFuture<'static, ()> {234240
Box::pin(async move {235241
self.refetch_rooms();242
+ self.refetch_image_packs();236243
while let Some(event) = self.events.next().await {237244
self.apply(event);238245
}@@ -286,6 +293,7 @@ impl Mirror {
286293
self.publish(|_| Some(StateChange::NotificationSettings));287294
}288295
Event::RoomsChanged => self.refetch_rooms(),296
+ Event::ImagePacksChanged => self.refetch_image_packs(),289297
Event::EncryptionChanged(encryption) => self.publish(|state| {290298
state.encryption = Some(encryption.clone());291299
Some(StateChange::Encryption(encryption))@@ -451,16 +459,22 @@ impl Mirror {
451459
}452460
453461
fn refetch_rooms(&mut self) {454
- let mut published = self.shared.lock();455
- match published.rooms_fetch {456
- RoomsFetch::Idle => {457
- published.rooms_fetch = RoomsFetch::Running;458
- drop(published);459
- let task = spawn(fetch_rooms(self.handle.clone(), self.shared.clone()));460
- self.rooms_fetch = Some(task.abort_handle());461
- }462
- RoomsFetch::Running => published.rooms_fetch = RoomsFetch::Again,463
- RoomsFetch::Again => {}462
+ let fetch = fetch_rooms(self.handle.clone(), self.shared.clone());463
+ if let Some(task) = self464
+ .shared465
+ .start(|published| &mut published.rooms_fetch, fetch)466
+ {467
+ self.rooms_fetch = Some(task);468
+ }469
+ }470
+471
+ fn refetch_image_packs(&mut self) {472
+ let fetch = fetch_image_packs(self.handle.clone(), self.shared.clone());473
+ if let Some(task) = self474
+ .shared475
+ .start(|published| &mut published.packs_fetch, fetch)476
+ {477
+ self.packs_fetch = Some(task);464478
}465479
}466480
@@ -484,7 +498,10 @@ impl Mirror {
484498
impl Drop for Mirror {485499
fn drop(&mut self) {486500
self.cancel_resyncs();487
- if let Some(task) = self.rooms_fetch.take() {501
+ for task in [self.rooms_fetch.take(), self.packs_fetch.take()]502
+ .into_iter()503
+ .flatten()504
+ {488505
task.abort();489506
}490507
self.shared.lock().updates = None;@@ -518,12 +535,28 @@ async fn fetch_rooms(handle: ClientHandle, shared: Arc<Shared>) {
518535
state.rooms = rooms.clone();519536
Some(StateChange::Rooms(rooms))520537
});521
- let mut published = shared.lock();522
- if published.rooms_fetch != RoomsFetch::Again {523
- published.rooms_fetch = RoomsFetch::Idle;538
+ if !shared.fetched(|published| &mut published.rooms_fetch) {539
+ return;540
+ }541
+ }542
+}543
+544
+/// a failed read keeps the packs from before, the next change asks again545
+async fn fetch_image_packs(handle: ClientHandle, shared: Arc<Shared>) {546
+ loop {547
+ match handle.image_packs().await {548
+ Ok(packs) => shared.publish(|state| {549
+ if state.image_packs == packs {550
+ return None;551
+ }552
+ state.image_packs = packs.clone();553
+ Some(StateChange::ImagePacks(packs))554
+ }),555
+ Err(error) => tracing::warn!(%error, "reading image packs failed"),556
+ }557
+ if !shared.fetched(|published| &mut published.packs_fetch) {524558
return;525559
}526
- published.rooms_fetch = RoomsFetch::Running;527560
}528561
}529562
@@ -592,11 +625,12 @@ struct Published {
592625
sequence: u64,593626
/// gone once the mirror stops, which ends every subscription594627
updates: Option<broadcast::Sender<Update>>,595
- rooms_fetch: RoomsFetch,628
+ rooms_fetch: Fetch,629
+ packs_fetch: Fetch,596630
}597631
598632
#[derive(Clone, Copy, Debug, Eq, PartialEq)]599
-enum RoomsFetch {633
+enum Fetch {600634
Idle,601635
Running,602636
Again,@@ -609,6 +643,40 @@ impl Shared {
609643
.unwrap_or_else(PoisonError::into_inner)610644
}611645
646
+ /// one fetch at a time, with at most one more owed647
+ fn start(648
+ &self,649
+ fetch: impl FnOnce(&mut Published) -> &mut Fetch,650
+ task: impl Future<Output = ()> + SendOutsideWasm + 'static,651
+ ) -> Option<AbortHandle> {652
+ let mut published = self.lock();653
+ let fetch = fetch(&mut published);654
+ match *fetch {655
+ Fetch::Idle => {656
+ *fetch = Fetch::Running;657
+ drop(published);658
+ Some(spawn(task).abort_handle())659
+ }660
+ Fetch::Running => {661
+ *fetch = Fetch::Again;662
+ None663
+ }664
+ Fetch::Again => None,665
+ }666
+ }667
+668
+ /// whether another fetch was asked for while this one ran669
+ fn fetched(&self, fetch: impl FnOnce(&mut Published) -> &mut Fetch) -> bool {670
+ let mut published = self.lock();671
+ let fetch = fetch(&mut published);672
+ if *fetch == Fetch::Again {673
+ *fetch = Fetch::Running;674
+ return true;675
+ }676
+ *fetch = Fetch::Idle;677
+ false678
+ }679
+612680
/// state, sequence and send move together under the one lock, which is what lets613681
/// `subscribe` take a snapshot and a receiver with nothing between them614682
fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {@@ -3,7 +3,7 @@ use std::{fmt, sync::Arc};
33
use imbl::Vector;44
use ruma::{55
MxcUri, OwnedDeviceId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId,6
- OwnedRoomOrAliasId, OwnedServerName, OwnedTransactionId, OwnedUserId,6
+ OwnedRoomOrAliasId, OwnedServerName, OwnedTransactionId, OwnedUserId, RoomId,77
events::{88
AnyGlobalAccountDataEventContent, AnyRoomAccountDataEventContent, AnySyncTimelineEvent,99
GlobalAccountDataEventType, RoomAccountDataEventType,@@ -654,6 +654,8 @@ pub enum Event {
654654
NotificationSettingsChanged,655655
VerificationTargetChanged(VerificationTarget),656656
RoomsChanged,657
+ /// an image pack came, went or changed somewhere. ask `image_packs` again658
+ ImagePacksChanged,657659
/// replaces whatever you had for this timeline. every diff after it applies to it658660
TimelineReset {659661
timeline_id: TimelineId,@@ -1608,6 +1610,84 @@ pub struct Reaction {
16081610
pub senders: Vec<OwnedUserId>,16091611
}16101612
1613
+/// every custom emote and sticker pack the account can reach, from the user's own1614
+/// pack, each joined room's packs, and the rooms the user enabled everywhere1615
+#[derive(Clone, Debug, Default, Eq, PartialEq)]1616
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1617
+#[non_exhaustive]1618
+pub struct ImagePacks {1619
+ /// the user's own pack, then the packs of rooms enabled everywhere1620
+ pub global: Vec<ImagePack>,1621
+ /// each joined room's own packs, rooms without any left out1622
+ pub rooms: imbl::HashMap<OwnedRoomId, Vec<ImagePack>>,1623
+}1624
+1625
+impl ImagePacks {1626
+ /// what a picker in this room offers. the user's pack, the room's own, then the1627
+ /// rest of the enabled ones, each pack once1628
+ #[must_use]1629
+ pub fn for_room(&self, room_id: Option<&RoomId>) -> Vec<&ImagePack> {1630
+ let is_user = |pack: &&ImagePack| pack.source == ImagePackSource::User;1631
+ let own = room_id.and_then(|room_id| self.rooms.get(room_id));1632
+ let mut packs: Vec<&ImagePack> = self.global.iter().filter(is_user).collect();1633
+ packs.extend(own.into_iter().flatten());1634
+ for pack in self.global.iter().filter(|pack| !is_user(pack)) {1635
+ if !packs.iter().any(|seen| seen.id == pack.id) {1636
+ packs.push(pack);1637
+ }1638
+ }1639
+ packs1640
+ }1641
+}1642
+1643
+#[derive(Clone, Debug, Eq, PartialEq)]1644
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1645
+#[non_exhaustive]1646
+pub struct ImagePack {1647
+ /// `user`, or `room:<room id>:<state key>`, the same across calls1648
+ pub id: String,1649
+ pub display_name: Option<String>,1650
+ pub avatar: Option<MediaId>,1651
+ pub attribution: Option<String>,1652
+ pub source: ImagePackSource,1653
+ pub images: Vec<PackImage>,1654
+}1655
+1656
+#[derive(Clone, Debug, Eq, PartialEq)]1657
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1658
+#[non_exhaustive]1659
+pub enum ImagePackSource {1660
+ User,1661
+ Room {1662
+ room_id: OwnedRoomId,1663
+ state_key: String,1664
+ },1665
+}1666
+1667
+#[derive(Clone, Debug, Eq, PartialEq)]1668
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1669
+#[non_exhaustive]1670
+pub struct PackImage {1671
+ pub shortcode: String,1672
+ pub media_id: MediaId,1673
+ /// what `toggle_reaction` takes to react with this image1674
+ pub reaction_key: String,1675
+ pub body: Option<String>,1676
+ pub mimetype: Option<String>,1677
+ pub width: Option<u64>,1678
+ pub height: Option<u64>,1679
+ /// the image's own, else its pack's, else both1680
+ pub usage: Vec<PackUsage>,1681
+}1682
+1683
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]1684
+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]1685
+#[non_exhaustive]1686
+pub enum PackUsage {1687
+ Emoticon,1688
+ Sticker,1689
+}1690
+16111691
#[derive(Clone, Debug, Eq, PartialEq)]16121692
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]16131693
#[non_exhaustive]@@ -0,0 +1,152 @@
1
+use std::collections::{BTreeMap, BTreeSet};2
+3
+use matrix_sdk::{Client, Room, deserialized_responses::RawAnySyncOrStrippedState};4
+use ruma::{5
+ OwnedRoomId,6
+ events::{7
+ GlobalAccountDataEventType, StateEventType,8
+ image_pack::{PackImage as SourceImage, PackInfo, PackUsage as SourceUsage},9
+ },10
+ serde::{JsonObject, Raw},11
+};12
+13
+use crate::{Error, ImagePack, ImagePackSource, ImagePacks, MediaId, PackImage, PackUsage};14
+15
+pub(crate) const ROOM_PACKS: &str = "im.ponies.room_emotes";16
+pub(crate) const USER_PACK: &str = "im.ponies.user_emotes";17
+pub(crate) const ENABLED_ROOMS: &str = "im.ponies.emote_rooms";18
+19
+/// rooms enabled everywhere that the user hasn't joined are skipped, we hold no state for them20
+pub(crate) async fn image_packs(client: &Client) -> Result<ImagePacks, Error> {21
+ let mut rooms = imbl::HashMap::new();22
+ for room in client.joined_rooms() {23
+ let packs = room_packs(&room).await?;24
+ if !packs.is_empty() {25
+ rooms.insert(room.room_id().to_owned(), packs);26
+ }27
+ }28
+ let mut global = Vec::new();29
+ if let Some(content) = account_data(client, USER_PACK).await? {30
+ global.extend(pack("user".to_owned(), ImagePackSource::User, &content));31
+ }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
+ }51
+ }52
+ Ok(ImagePacks { global, rooms })53
+}54
+55
+async fn room_packs(room: &Room) -> Result<Vec<ImagePack>, Error> {56
+ let events = room57
+ .get_state_events(StateEventType::from(ROOM_PACKS))58
+ .await59
+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?;60
+ let by_state_key: BTreeMap<String, Raw<JsonObject>> = events61
+ .into_iter()62
+ .filter_map(|event| match event {63
+ RawAnySyncOrStrippedState::Sync(event) => Some((64
+ event.get_field::<String>("state_key").ok()??,65
+ event.get_field::<Raw<JsonObject>>("content").ok()??,66
+ )),67
+ RawAnySyncOrStrippedState::Stripped(_) => None,68
+ })69
+ .collect();70
+ Ok(by_state_key71
+ .into_iter()72
+ .filter_map(|(state_key, content)| {73
+ let source = ImagePackSource::Room {74
+ room_id: room.room_id().to_owned(),75
+ state_key: state_key.clone(),76
+ };77
+ pack(78
+ format!("room:{}:{state_key}", room.room_id()),79
+ source,80
+ &content,81
+ )82
+ })83
+ .collect())84
+}85
+86
+async fn account_data(client: &Client, event_type: &str) -> Result<Option<Raw<JsonObject>>, Error> {87
+ Ok(client88
+ .account()89
+ .account_data_raw(GlobalAccountDataEventType::from(event_type))90
+ .await91
+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?92
+ .map(|content| content.cast_unchecked()))93
+}94
+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> {97
+ let info = content98
+ .get_field::<PackInfo>("pack")99
+ .ok()100
+ .flatten()101
+ .unwrap_or_default();102
+ let pack_usage = usage(&info.usage);103
+ let images: Vec<PackImage> = content104
+ .get_field::<BTreeMap<String, Raw<SourceImage>>>("images")105
+ .ok()106
+ .flatten()107
+ .unwrap_or_default()108
+ .into_iter()109
+ .filter_map(|(shortcode, image)| {110
+ let image = image.deserialize().ok()?;111
+ if !image.url.is_valid() {112
+ return None;113
+ }114
+ let info = image.info.as_ref();115
+ Some(PackImage {116
+ shortcode,117
+ media_id: MediaId::plain(&image.url),118
+ reaction_key: image.url.to_string(),119
+ body: image.body,120
+ mimetype: info.and_then(|info| info.mimetype.clone()),121
+ width: info.and_then(|info| info.width).map(u64::from),122
+ height: info.and_then(|info| info.height).map(u64::from),123
+ usage: usage(&image.usage)124
+ .or_else(|| pack_usage.clone())125
+ .unwrap_or_else(|| vec![PackUsage::Emoticon, PackUsage::Sticker]),126
+ })127
+ })128
+ .collect();129
+ (!images.is_empty()).then(|| ImagePack {130
+ id,131
+ display_name: info.display_name,132
+ avatar: info133
+ .avatar_url134
+ .filter(|uri| uri.is_valid())135
+ .map(|uri| MediaId::plain(&uri)),136
+ attribution: info.attribution,137
+ source,138
+ images,139
+ })140
+}141
+142
+fn usage(usage: &BTreeSet<SourceUsage>) -> Option<Vec<PackUsage>> {143
+ let usage: Vec<PackUsage> = usage144
+ .iter()145
+ .filter_map(|usage| match usage {146
+ SourceUsage::Emoticon => Some(PackUsage::Emoticon),147
+ SourceUsage::Sticker => Some(PackUsage::Sticker),148
+ _ => None,149
+ })150
+ .collect();151
+ (!usage.is_empty()).then_some(usage)152
+}@@ -1,8 +1,10 @@
1
+mod image_pack;12
mod media;23
mod member;34
mod room;45
mod timeline;56
7
+pub(crate) use image_pack::{ENABLED_ROOMS, ROOM_PACKS, USER_PACK, image_packs};68
pub(crate) use media::{MediaRegistration, MediaScope, MediaSourceInfo};79
pub(crate) use member::member_summary;810
pub(crate) use room::notification_mode;@@ -27,9 +27,9 @@ use tokio::sync::{OnceCell, mpsc, oneshot, watch};
2727
2828
use crate::{2929
AccountCommand, AttachmentUpload, Command, CommandOutput, ConnectionState, CreateRoomOptions,30
- EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, InteractiveAuth,31
- InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId, MediaLocation,32
- MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,30
+ EncryptionCommand, EncryptionState, Error, ErrorKind, Event, EventRef, ImagePacks,31
+ InteractiveAuth, InteractiveAuthOutcome, LifecycleCommand, Media, MediaCommand, MediaId,32
+ MediaLocation, MediaSize, MemberFilter, MemberSummary, MessageContent, MessageEditContent,3333
NotificationCommand, OwnDevice, PowerLevelChanges, Profile, PushNotification,3434
PushNotificationEvent, PushNotificationStatus, RoomCommand, RoomListFilter,3535
RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild,@@ -37,7 +37,7 @@ use crate::{
3737
encryption::encryption_state,3838
engine::{MediaService, StoreLock},3939
model::{PushNotificationRoom, PushNotificationSender},40
- normalise::{member_summary, room_summaries, space_child},40
+ normalise::{image_packs, member_summary, room_summaries, space_child},4141
};4242
4343
#[derive(Clone)]@@ -582,6 +582,10 @@ impl ClientHandle {
582582
room_summaries(&self.client, self.spaces().await).await583583
}584584
585
+ pub async fn image_packs(&self) -> Result<ImagePacks, Error> {586
+ image_packs(&self.client).await587
+ }588
+585589
/// browse a space the way the server sees it, rooms you haven't joined included586590
pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {587591
SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)@@ -3,12 +3,15 @@ mod support;
33
use std::time::Duration;44
55
use macltok::{6
- ConnectionState, Engine, EngineConfig, EngineError, Event, Retain, RoomMembership, Termination,6
+ ConnectionState, Engine, EngineConfig, EngineError, Event, PackUsage, Retain, RoomMembership,7
+ Termination,78
};89
use matrix_sdk::{9
- ruma::{api::client::sync::sync_events::v5, room_id},10
+ ruma::{api::client::sync::sync_events::v5, events::AnySyncStateEvent, room_id, serde::Raw},1011
test_utils::mocks::MatrixMockServer,1112
};13
+use matrix_sdk_test::JoinedRoomBuilder;14
+use serde_json::json;1215
use tokio::sync::watch;1316
1417
#[tokio::test]@@ -151,3 +154,82 @@ async fn wait_for_connection(
151154
.await152155
.expect("expected connection state did not arrive");153156
}157
+158
+#[tokio::test]159
+async fn image_packs_follow_room_state_and_account_data() {160
+ let server = MatrixMockServer::new().await;161
+ server162
+ .mock_sliding_sync()163
+ .ok(v5::Response::new("1".to_owned()))164
+ .mount()165
+ .await;166
+ let client = server.client_builder().build().await;167
+ let room_id = room_id!("!packs:localhost");168
+ server.mock_room_state_encryption().plain().mount().await;169
+ server.sync_joined_room(&client, room_id).await;170
+ let config = EngineConfig::default().with_initial_history_limit(0);171
+ let (engine, handle, mut events) = Engine::from_client(client.clone(), config).await.unwrap();172
+ let running = tokio::spawn(engine.run());173
+174
+ let pack: Raw<AnySyncStateEvent> = serde_json::from_value(json!({175
+ "type": "im.ponies.room_emotes",176
+ "state_key": "cats",177
+ "event_id": "$pack",178
+ "sender": "@example:localhost",179
+ "origin_server_ts": 1,180
+ "content": {181
+ "pack": { "display_name": "cats", "usage": ["emoticon"] },182
+ "images": {183
+ "blep": { "url": "mxc://localhost/blep" },184
+ "broken": { "url": "not an mxc" },185
+ "sticker": { "url": "mxc://localhost/sticker", "usage": ["sticker"] },186
+ },187
+ },188
+ }))189
+ .unwrap();190
+ server191
+ .mock_sync()192
+ .ok_and_run(&client, |builder| {193
+ builder.add_joined_room(JoinedRoomBuilder::new(room_id).add_state_event(pack));194
+ builder.add_custom_global_account_data(json!({195
+ "type": "im.ponies.emote_rooms",196
+ "content": { "rooms": { "!packs:localhost": { "cats": {} } } },197
+ }));198
+ })199
+ .await;200
+ support::receive_until(&mut events, |event| {201
+ matches!(event, Event::ImagePacksChanged)202
+ })203
+ .await;204
+205
+ let packs = handle.image_packs().await.unwrap();206
+ let offered = packs.for_room(Some(room_id));207
+ assert_eq!(offered.len(), 1, "an enabled room's pack was offered twice");208
+ let pack = offered[0];209
+ assert_eq!(pack.id, "room:!packs:localhost:cats");210
+ assert_eq!(packs.global, vec![pack.clone()]);211
+ let images: Vec<_> = pack212
+ .images213
+ .iter()214
+ .map(|image| {215
+ (216
+ image.shortcode.as_str(),217
+ image.reaction_key.as_str(),218
+ image.usage.clone(),219
+ )220
+ })221
+ .collect();222
+ assert_eq!(223
+ images,224
+ [225
+ ("blep", "mxc://localhost/blep", vec![PackUsage::Emoticon]),226
+ (227
+ "sticker",228
+ "mxc://localhost/sticker",229
+ vec![PackUsage::Sticker]230
+ ),231
+ ]232
+ );233
+234
+ support::shut_down(&handle, &mut events, running).await;235
+}