atagen

macltok

public
Star0
Watch0

Commit 17e042fbc331

tarball

atagen <boss@atagen.co>2026-10-22 09:31 UTC

13 files changed+526-44
image_packs: hand out custom emote and sticker packs, and say when they change

MSC2545 packs live in account data and in room state the sdk's sliding sync never asks for, so the fork's extra required_state brings every room's im.ponies.room_emotes into the store, and a change arrives even across a timeline gap. Parsing is lenient per image, since pack contents are whatever someone's client wrote.
Modified file: Cargo.lock+1 additions
@@ -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",
Modified file: Cargo.toml+2 additions−1 deletions
@@ -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"
Modified file: docs/design-decisions.md+21 additions
@@ -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 in
320320 the summaries
321321
322+## image packs
323+
324+msc2545 packs come from the user's account data, from room state, and from the
325+rooms the user enabled everywhere. the room list asks the server for every
326+room's `im.ponies.room_emotes`, through the fork's extra required state, so
327+packs sit in the store like any other state and a change reaches us even across
328+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 open
330+rooms would lose the enabled packs of rooms nobody has open
331+
332+`image_packs` reads all of it from the store and hands back the lot, global
333+packs and each room's own, and `ImagePacks::for_room` puts a picker's list in
334+order. an enabled room the user hasn't joined is skipped, there's no state for
335+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 a
338+membership change taking a room's packs with it. it's an invalidation, and the
339+mirror refetches on it like it does rooms, keeping the packs in its state
340+
322341 ## verification
323342
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 apis
363382
364383 - `Room::prepare_encryption`, the send path's encryption prep made public
384+- `SyncServiceBuilder::with_room_list_extra_required_state`, for state the
385+ sdk's hard-coded list leaves out. image packs use it
365386
366387 ## public model
367388
Modified file: harness/web/Cargo.lock+1 additions
@@ -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",
Modified file: src/engine/mod.rs+7 additions−1 deletions
@@ -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 .await
318320 .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 ),
Modified file: src/engine/watchers.rs+67 additions−3 deletions
@@ -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 it
438+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();
Modified file: src/lib.rs+13 additions−13 deletions
@@ -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};
Modified file: src/mirror.rs+87 additions−19 deletions
@@ -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 follows
126127 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) = self
464+ .shared
465+ .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) = self
474+ .shared
475+ .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 again
545+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 subscription
594627 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 owed
647+ 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+ None
663+ }
664+ Fetch::Again => None,
665+ }
666+ }
667+
668+ /// whether another fetch was asked for while this one ran
669+ 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+ false
678+ }
679+
612680 /// state, sequence and send move together under the one lock, which is what lets
613681 /// `subscribe` take a snapshot and a receiver with nothing between them
614682 fn publish(&self, change: impl FnOnce(&mut State) -> Option<StateChange>) {
Modified file: src/model.rs+81 additions−1 deletions
@@ -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` again
658+ ImagePacksChanged,
657659 /// replaces whatever you had for this timeline. every diff after it applies to it
658660 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 own
1614+/// pack, each joined room's packs, and the rooms the user enabled everywhere
1615+#[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 everywhere
1620+ pub global: Vec<ImagePack>,
1621+ /// each joined room's own packs, rooms without any left out
1622+ 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 the
1627+ /// rest of the enabled ones, each pack once
1628+ #[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+ packs
1640+ }
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 calls
1648+ 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 image
1674+ 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 both
1680+ 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]
Added file: src/normalise/image_pack.rs+152 additions
@@ -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 them
20+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 = content
34+ .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+ packs
42+ .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 = room
57+ .get_state_events(StateEventType::from(ROOM_PACKS))
58+ .await
59+ .map_err(|source| Error::from_sdk("reading image packs failed", source))?;
60+ let by_state_key: BTreeMap<String, Raw<JsonObject>> = events
61+ .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_key
71+ .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(client
88+ .account()
89+ .account_data_raw(GlobalAccountDataEventType::from(event_type))
90+ .await
91+ .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 dropped
96+fn pack(id: String, source: ImagePackSource, content: &Raw<JsonObject>) -> Option<ImagePack> {
97+ let info = content
98+ .get_field::<PackInfo>("pack")
99+ .ok()
100+ .flatten()
101+ .unwrap_or_default();
102+ let pack_usage = usage(&info.usage);
103+ let images: Vec<PackImage> = content
104+ .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: info
133+ .avatar_url
134+ .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> = usage
144+ .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+}
Modified file: src/normalise/mod.rs+2 additions
@@ -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;
Modified file: src/transport.rs+8 additions−4 deletions
@@ -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).await
583583 }
584584
585+ pub async fn image_packs(&self) -> Result<ImagePacks, Error> {
586+ image_packs(&self.client).await
587+ }
588+
585589 /// browse a space the way the server sees it, rooms you haven't joined included
586590 pub async fn space_hierarchy(&self, space_id: OwnedRoomId) -> SpaceHierarchy {
587591 SpaceHierarchy(SpaceRoomList::new(self.client.clone(), space_id).await)
Modified file: tests/state.rs+84 additions−2 deletions
@@ -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 .await
152155 .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+ server
162+ .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+ server
191+ .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<_> = pack
212+ .images
213+ .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+}