matrix-sdk-base 0.17.0

The base component to build a Matrix client library.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
// Copyright 2025 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

#![allow(clippy::assign_op_pattern)] // Triggered by bitflags! usage

mod call;
mod create;
mod display_name;
mod encryption;
mod knock;
mod latest_event;
mod members;
mod room_info;
mod state;
mod tags;
mod tombstone;

use std::collections::{BTreeMap, BTreeSet, HashSet};

pub use call::CallIntentConsensus;
pub use create::*;
pub use display_name::{RoomDisplayName, RoomHero};
pub(crate) use display_name::{RoomSummary, UpdatedRoomDisplayName};
pub use encryption::EncryptionState;
use eyeball::{AsyncLock, SharedObservable};
use futures_util::{Stream, StreamExt};
pub use members::{RoomMember, RoomMembersUpdate, RoomMemberships};
pub(crate) use room_info::SyncInfo;
pub use room_info::{
    BaseRoomInfo, RoomInfo, RoomInfoNotableUpdate, RoomInfoNotableUpdateReasons, RoomRecencyStamp,
    apply_redaction,
};
use ruma::{
    EventId, OwnedEventId, OwnedMxcUri, OwnedRoomAliasId, OwnedRoomId, OwnedUserId, RoomId,
    RoomVersionId, UserId,
    events::{
        direct::OwnedDirectUserIdentifier,
        receipt::{Receipt, ReceiptThread, ReceiptType},
        room::{
            avatar,
            guest_access::GuestAccess,
            history_visibility::HistoryVisibility,
            join_rules::JoinRule,
            member::MembershipState,
            power_levels::{RoomPowerLevels, RoomPowerLevelsEventContent, RoomPowerLevelsSource},
        },
    },
    room::RoomType,
};
use serde::{Deserialize, Serialize};
pub use state::{RoomState, RoomStateFilter};
pub(crate) use tags::RoomNotableTags;
use tokio::sync::broadcast;
pub use tombstone::{PredecessorRoom, SuccessorRoom};
use tracing::{info, instrument, trace, warn};

use crate::{
    DmRoomDefinition, Error, StateStore,
    deserialized_responses::MemberEvent,
    notification_settings::RoomNotificationMode,
    read_receipts::RoomReadReceipts,
    store::{Result as StoreResult, SaveLockedStateStore, StateStoreExt},
    sync::UnreadNotificationsCount,
};

/// The underlying room data structure collecting state for joined, left and
/// invited rooms.
#[derive(Debug, Clone)]
pub struct Room {
    /// The room ID.
    pub(super) room_id: OwnedRoomId,

    /// Our own user ID.
    pub(super) own_user_id: OwnedUserId,

    pub(super) info: SharedObservable<RoomInfo>,

    /// A clone of the [`BaseStateStore::room_info_notable_update_sender`].
    ///
    /// [`BaseStateStore::room_info_notable_update_sender`]: crate::store::BaseStateStore::room_info_notable_update_sender
    pub(super) room_info_notable_update_sender: broadcast::Sender<RoomInfoNotableUpdate>,

    /// A clone of the state store.
    pub(super) store: SaveLockedStateStore,

    /// A map for ids of room membership events in the knocking state linked to
    /// the user id of the user affected by the member event, that the current
    /// user has marked as seen so they can be ignored.
    pub seen_knock_request_ids_map:
        SharedObservable<Option<BTreeMap<OwnedEventId, OwnedUserId>>, AsyncLock>,

    /// A sender that will notify receivers when room member updates happen.
    pub room_member_updates_sender: broadcast::Sender<RoomMembersUpdate>,
}

impl Room {
    pub(crate) fn new(
        own_user_id: &UserId,
        store: SaveLockedStateStore,
        room_id: &RoomId,
        room_state: RoomState,
        room_info_notable_update_sender: broadcast::Sender<RoomInfoNotableUpdate>,
    ) -> Self {
        let room_info = RoomInfo::new(room_id, room_state);
        Self::restore(own_user_id, store, room_info, room_info_notable_update_sender)
    }

    pub(crate) fn restore(
        own_user_id: &UserId,
        store: SaveLockedStateStore,
        room_info: RoomInfo,
        room_info_notable_update_sender: broadcast::Sender<RoomInfoNotableUpdate>,
    ) -> Self {
        let (room_member_updates_sender, _) = broadcast::channel(10);
        Self {
            own_user_id: own_user_id.into(),
            room_id: room_info.room_id.clone(),
            store,
            info: SharedObservable::new(room_info),
            room_info_notable_update_sender,
            seen_knock_request_ids_map: SharedObservable::new_async(None),
            room_member_updates_sender,
        }
    }

    /// Get the unique room id of the room.
    pub fn room_id(&self) -> &RoomId {
        &self.room_id
    }

    /// Get a copy of the room creators.
    pub fn creators(&self) -> Option<Vec<OwnedUserId>> {
        self.info.read().creators()
    }

    /// Get our own user id.
    pub fn own_user_id(&self) -> &UserId {
        &self.own_user_id
    }

    /// Whether this room's [`RoomType`] is `m.space`.
    pub fn is_space(&self) -> bool {
        self.info.read().room_type().is_some_and(|t| *t == RoomType::Space)
    }

    /// Whether this room is a Call room as defined by [MSC3417].
    ///
    /// [MSC3417]: <https://github.com/matrix-org/matrix-spec-proposals/pull/3417>
    pub fn is_call(&self) -> bool {
        self.info.read().room_type().is_some_and(|t| *t == RoomType::Call)
    }

    /// Returns the room's type as defined in its creation event
    /// (`m.room.create`).
    pub fn room_type(&self) -> Option<RoomType> {
        self.info.read().room_type().map(ToOwned::to_owned)
    }

    /// Get the unread notification counts computed server-side.
    ///
    /// Note: these might be incorrect for encrypted rooms, since the server
    /// doesn't know which events are relevant standalone messages or not,
    /// nor can it inspect mentions. If you need more precise counts for
    /// encrypted rooms, consider using the client-side computed counts in
    /// [`Self::num_unread_messages`], [`Self::num_unread_notifications`] and
    /// [`Self::num_unread_mentions`].
    pub fn unread_notification_counts(&self) -> UnreadNotificationsCount {
        self.info.read().notification_counts
    }

    /// Get the number of unread messages (computed client-side).
    ///
    /// This might be more precise than [`Self::unread_notification_counts`] for
    /// encrypted rooms.
    pub fn num_unread_messages(&self) -> u64 {
        self.info.read().read_receipts.num_unread
    }

    /// Get the number of unread notifications (computed client-side).
    ///
    /// This might be more precise than [`Self::unread_notification_counts`] for
    /// encrypted rooms.
    pub fn num_unread_notifications(&self) -> u64 {
        self.info.read().read_receipts.num_notifications
    }

    /// Get the number of unread mentions (computed client-side), that is,
    /// messages causing a highlight in a room.
    ///
    /// This might be more precise than [`Self::unread_notification_counts`] for
    /// encrypted rooms.
    pub fn num_unread_mentions(&self) -> u64 {
        self.info.read().read_receipts.num_mentions
    }

    /// Get the detailed information about read receipts for the room.
    pub fn read_receipts(&self) -> RoomReadReceipts {
        self.info.read().read_receipts.clone()
    }

    /// Check if the room states have been synced
    ///
    /// States might be missing if we have only seen the room_id of this Room
    /// so far, for example as the response for a `create_room` request without
    /// being synced yet.
    ///
    /// Returns true if the state is fully synced, false otherwise.
    pub fn is_state_fully_synced(&self) -> bool {
        self.info.read().sync_info == SyncInfo::FullySynced
    }

    /// Check if the room state has been at least partially synced.
    ///
    /// See [`Room::is_state_fully_synced`] for more info.
    pub fn is_state_partially_or_fully_synced(&self) -> bool {
        self.info.read().sync_info != SyncInfo::NoState
    }

    /// Get the `prev_batch` token that was received from the last sync. May be
    /// `None` if the last sync contained the full room history.
    pub fn last_prev_batch(&self) -> Option<String> {
        self.info.read().last_prev_batch.clone()
    }

    /// Get the avatar url of this room.
    pub fn avatar_url(&self) -> Option<OwnedMxcUri> {
        self.info.read().avatar_url().map(ToOwned::to_owned)
    }

    /// Get information about the avatar of this room.
    pub fn avatar_info(&self) -> Option<avatar::ImageInfo> {
        self.info.read().avatar_info().map(ToOwned::to_owned)
    }

    /// Get the canonical alias of this room.
    pub fn canonical_alias(&self) -> Option<OwnedRoomAliasId> {
        self.info.read().canonical_alias().map(ToOwned::to_owned)
    }

    /// Get the canonical alias of this room.
    pub fn alt_aliases(&self) -> Vec<OwnedRoomAliasId> {
        self.info.read().alt_aliases().to_owned()
    }

    /// Get the `m.room.create` content of this room.
    ///
    /// This usually isn't optional but some servers might not send an
    /// `m.room.create` event as the first event for a given room, thus this can
    /// be optional.
    ///
    /// For room versions earlier than room version 11, if the event is
    /// redacted, all fields except `creator` will be set to their default
    /// value.
    pub fn create_content(&self) -> Option<RoomCreateWithCreatorEventContent> {
        Some(self.info.read().base_info.create.as_ref()?.content.clone())
    }

    /// Is this room considered a direct message.
    ///
    /// Async because it can read room info from storage.
    #[instrument(skip_all, fields(room_id = ?self.room_id))]
    pub async fn is_direct(&self) -> StoreResult<bool> {
        match self.state() {
            RoomState::Joined | RoomState::Left | RoomState::Banned => {
                Ok(!self.info.read().base_info.dm_targets.is_empty())
            }

            RoomState::Invited => {
                let member = self.get_member(self.own_user_id()).await?;

                match member {
                    None => {
                        info!("RoomMember not found for the user's own id");
                        Ok(false)
                    }
                    Some(member) => match member.event.as_ref() {
                        MemberEvent::Sync(_) => {
                            warn!("Got MemberEvent::Sync in an invited room");
                            Ok(false)
                        }
                        MemberEvent::Stripped(event) => {
                            Ok(event.content.is_direct.unwrap_or(false))
                        }
                    },
                }
            }

            // TODO: implement logic once we have the stripped events as we'd have with an Invite
            RoomState::Knocked => Ok(false),
        }
    }

    /// Computes if the current room is a DM based on the rules from the
    /// [`DmRoomDefinition`], updating the active service members.
    pub async fn compute_is_dm(&self, dm_room_definition: &DmRoomDefinition) -> StoreResult<bool> {
        let is_direct = self.is_direct().await?;

        match *dm_room_definition {
            DmRoomDefinition::MatrixSpec => Ok(is_direct),
            DmRoomDefinition::TwoMembers => {
                if !is_direct {
                    return Ok(false);
                }
                let active_service_member_count =
                    self.update_active_service_members().await?.unwrap_or_default().len() as u64;
                let has_at_most_two_members =
                    self.active_members_count().saturating_sub(active_service_member_count) <= 2;
                Ok(has_at_most_two_members)
            }
        }
    }

    /// If this room is a direct message, get the members that we're sharing the
    /// room with.
    ///
    /// *Note*: The member list might have been modified in the meantime and
    /// the targets might not even be in the room anymore. This setting should
    /// only be considered as guidance. We leave members in this list to allow
    /// us to re-find a DM with a user even if they have left, since we may
    /// want to re-invite them.
    pub fn direct_targets(&self) -> HashSet<OwnedDirectUserIdentifier> {
        self.info.read().base_info.dm_targets.clone()
    }

    /// If this room is a direct message, returns the number of members that
    /// we're sharing the room with.
    pub fn direct_targets_length(&self) -> usize {
        self.info.read().base_info.dm_targets.len()
    }

    /// Get the guest access policy of this room.
    pub fn guest_access(&self) -> GuestAccess {
        self.info.read().guest_access().clone()
    }

    /// Get the history visibility policy of this room.
    pub fn history_visibility(&self) -> Option<HistoryVisibility> {
        self.info.read().history_visibility().cloned()
    }

    /// Get the history visibility policy of this room, or a sensible default if
    /// the event is missing.
    pub fn history_visibility_or_default(&self) -> HistoryVisibility {
        self.info.read().history_visibility_or_default().clone()
    }

    /// Is the room considered to be public.
    ///
    /// May return `None` if the join rule event is not available.
    pub fn is_public(&self) -> Option<bool> {
        self.info.read().join_rule().map(|join_rule| matches!(join_rule, JoinRule::Public))
    }

    /// Get the join rule policy of this room, if available.
    pub fn join_rule(&self) -> Option<JoinRule> {
        self.info.read().join_rule().cloned()
    }

    /// Get the maximum power level that this room contains.
    ///
    /// This is useful if one wishes to normalize the power levels, e.g. from
    /// 0-100 where 100 would be the max power level.
    pub fn max_power_level(&self) -> i64 {
        self.info.read().base_info.max_power_level
    }

    /// Get the service members in this room, if available.
    pub fn service_members(&self) -> Option<BTreeSet<OwnedUserId>> {
        self.info.read().service_members().cloned()
    }

    /// Get the current power levels of this room.
    pub async fn power_levels(&self) -> Result<RoomPowerLevels, Error> {
        let power_levels_content = self
            .store
            .get_state_event_static::<RoomPowerLevelsEventContent>(self.room_id())
            .await?
            .ok_or(Error::InsufficientData)?
            .deserialize()?;
        let creators = self.creators().ok_or(Error::InsufficientData)?;
        let rules = self.info.read().room_version_rules_or_default();

        Ok(power_levels_content.power_levels(&rules.authorization, creators))
    }

    /// Get the current power levels of this room, or a sensible default if they
    /// are not known.
    pub async fn power_levels_or_default(&self) -> RoomPowerLevels {
        if let Ok(power_levels) = self.power_levels().await {
            return power_levels;
        }

        // As a fallback, create the default power levels of a room.
        let rules = self.info.read().room_version_rules_or_default();
        RoomPowerLevels::new(
            RoomPowerLevelsSource::None,
            &rules.authorization,
            self.creators().into_iter().flatten(),
        )
    }

    /// Get the `m.room.name` of this room.
    ///
    /// The returned string may be empty if the event has been redacted, or it's
    /// missing from storage.
    pub fn name(&self) -> Option<String> {
        self.info.read().name().map(ToOwned::to_owned)
    }

    /// Get the topic of the room.
    pub fn topic(&self) -> Option<String> {
        self.info.read().topic().map(ToOwned::to_owned)
    }

    /// Update the cached user defined notification mode.
    ///
    /// This is automatically recomputed on every successful sync, and the
    /// cached result can be retrieved in
    /// [`Self::cached_user_defined_notification_mode`].
    pub fn update_cached_user_defined_notification_mode(&self, mode: RoomNotificationMode) {
        self.info.update_if(|info| {
            if info.cached_user_defined_notification_mode.as_ref() != Some(&mode) {
                info.cached_user_defined_notification_mode = Some(mode);

                true
            } else {
                false
            }
        });
    }

    /// Returns the cached user defined notification mode, if available.
    ///
    /// This cache is refilled every time we call
    /// [`Self::update_cached_user_defined_notification_mode`].
    pub fn cached_user_defined_notification_mode(&self) -> Option<RoomNotificationMode> {
        self.info.read().cached_user_defined_notification_mode
    }

    /// Removes any existing cached value for the user defined notification
    /// mode.
    pub fn clear_user_defined_notification_mode(&self) {
        self.info.update_if(|info| {
            if info.cached_user_defined_notification_mode.is_some() {
                info.cached_user_defined_notification_mode = None;
                true
            } else {
                false
            }
        })
    }

    /// Get the list of users ids that are considered to be joined members of
    /// this room.
    pub async fn joined_user_ids(&self) -> StoreResult<Vec<OwnedUserId>> {
        self.store.get_user_ids(self.room_id(), RoomMemberships::JOIN).await
    }

    /// Get the heroes for this room.
    ///
    /// This also filters out possible service members from the list of heroes
    /// returned by the homeserver.
    pub fn heroes(&self) -> Vec<RoomHero> {
        let guard = self.info.read();
        let heroes = guard.heroes();

        if let Some(service_members) = guard.service_members() {
            heroes.iter().filter(|hero| !service_members.contains(&hero.user_id)).cloned().collect()
        } else {
            heroes.to_vec()
        }
    }

    /// Get the receipt as an `OwnedEventId` and `Receipt` tuple for the given
    /// `receipt_type`, `thread` and `user_id` in this room.
    pub async fn load_user_receipt(
        &self,
        receipt_type: ReceiptType,
        thread: ReceiptThread,
        user_id: &UserId,
    ) -> StoreResult<Option<(OwnedEventId, Receipt)>> {
        self.store.get_user_room_receipt_event(self.room_id(), receipt_type, thread, user_id).await
    }

    /// Load from storage the receipts as a list of `OwnedUserId` and `Receipt`
    /// tuples for the given `receipt_type`, `thread` and `event_id` in this
    /// room.
    pub async fn load_event_receipts(
        &self,
        receipt_type: ReceiptType,
        thread: ReceiptThread,
        event_id: &EventId,
    ) -> StoreResult<Vec<(OwnedUserId, Receipt)>> {
        self.store
            .get_event_room_receipt_events(self.room_id(), receipt_type, thread, event_id)
            .await
    }

    /// Returns a boolean indicating if this room has been manually marked as
    /// unread
    pub fn is_marked_unread(&self) -> bool {
        self.info.read().base_info.is_marked_unread
    }

    /// Returns the [`RoomVersionId`] of the room, if known.
    pub fn version(&self) -> Option<RoomVersionId> {
        self.info.read().room_version().cloned()
    }

    /// Returns the recency stamp of the room.
    ///
    /// Please read `RoomInfo::recency_stamp` to learn more.
    pub fn recency_stamp(&self) -> Option<RoomRecencyStamp> {
        self.info.read().recency_stamp
    }

    /// Get a `Stream` of loaded pinned events for this room.
    /// If no pinned events are found a single empty `Vec` will be returned.
    pub fn pinned_event_ids_stream(&self) -> impl Stream<Item = Vec<OwnedEventId>> + use<> {
        self.info
            .subscribe()
            .map(|i| i.base_info.pinned_events.and_then(|c| c.pinned).unwrap_or_default())
    }

    /// Returns the current pinned event ids for this room.
    pub fn pinned_event_ids(&self) -> Option<Vec<OwnedEventId>> {
        self.info.read().pinned_event_ids()
    }

    /// Computes and stores the list of service members that are either in a
    /// joined or invited state in this room, checking the service member
    /// list against the locally available room members.
    pub async fn update_active_service_members(&self) -> StoreResult<Option<Vec<RoomMember>>> {
        if let Some(service_members) = self.service_members() {
            let mut found = Vec::new();
            for user_id in service_members {
                match self.get_member(&user_id).await {
                    Ok(Some(member)) => {
                        // We only care about active members (joined or invited)
                        if matches!(
                            member.membership(),
                            MembershipState::Join | MembershipState::Invite
                        ) {
                            found.push(member);
                        }
                    }
                    Ok(None) => (),
                    Err(error) => return Err(error),
                }
            }

            trace!("Updating active service members ({}) in room {}", found.len(), self.room_id());

            let new_active_service_member_count = found.len() as u64;
            let current_active_service_member_count =
                self.info.read().summary.active_service_members.unwrap_or_default();
            if new_active_service_member_count != current_active_service_member_count {
                self.update_and_save_room_info(|mut info| {
                    info.update_active_service_member_count(Some(new_active_service_member_count));
                    (info, RoomInfoNotableUpdateReasons::ACTIVE_SERVICE_MEMBERS)
                })
                .await?;
            }

            Ok(Some(found))
        } else {
            if self.info.read().summary.active_service_members.is_some() {
                self.update_and_save_room_info(|mut info| {
                    info.update_active_service_member_count(None);
                    (info, RoomInfoNotableUpdateReasons::ACTIVE_SERVICE_MEMBERS)
                })
                .await?;
            }
            Ok(None)
        }
    }

    /// Returns a cached value containing the active (joined/invited) service
    /// member count, if known.
    pub fn active_service_members_count(&self) -> Option<u64> {
        self.info.read().summary.active_service_members
    }
}

// See https://github.com/matrix-org/matrix-rust-sdk/pull/3749#issuecomment-2312939823.
#[cfg(not(feature = "test-send-sync"))]
unsafe impl Send for Room {}

// See https://github.com/matrix-org/matrix-rust-sdk/pull/3749#issuecomment-2312939823.
#[cfg(not(feature = "test-send-sync"))]
unsafe impl Sync for Room {}

#[cfg(feature = "test-send-sync")]
#[test]
// See https://github.com/matrix-org/matrix-rust-sdk/pull/3749#issuecomment-2312939823.
fn test_send_sync_for_room() {
    fn assert_send_sync<
        T: matrix_sdk_common::SendOutsideWasm + matrix_sdk_common::SyncOutsideWasm,
    >() {
    }

    assert_send_sync::<Room>();
}

/// The possible sources of an account data type.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) enum AccountDataSource {
    /// The source is account data with the stable prefix.
    Stable,

    /// The source is account data with the unstable prefix.
    #[default]
    Unstable,
}

#[cfg(test)]
mod tests {
    use matrix_sdk_test::{
        JoinedRoomBuilder, SyncResponseBuilder, async_test, event_factory::EventFactory,
    };
    use ruma::{room_id, user_id};
    use serde_json::json;

    use super::*;
    use crate::test_utils::logged_in_base_client;

    #[async_test]
    async fn test_room_heroes_filters_out_service_members() {
        let client = logged_in_base_client(None).await;
        let user_id = &client.session_meta().unwrap().user_id;
        let service_member_id = user_id!("@service:example.org");
        let alice_id = user_id!("@alice:example.org");
        let room_id = room_id!("!room:example.org");

        let room = client.get_or_create_room(room_id, RoomState::Joined);

        // Create a room response with 2 heroes, one of them a service member.
        let mut sync_builder = SyncResponseBuilder::new();
        let response = sync_builder
            .add_joined_room(
                JoinedRoomBuilder::new(room_id)
                    .set_room_summary(json!({
                        "m.joined_member_count": 3,
                        "m.invited_member_count": 0,
                        "m.heroes": [alice_id.to_owned(), service_member_id.to_owned()],
                    }))
                    .add_state_event(
                        EventFactory::new()
                            .sender(user_id)
                            .member_hints(BTreeSet::from([service_member_id.to_owned()])),
                    ),
            )
            .build_sync_response();

        client.receive_sync_response(response).await.unwrap();

        // The service member should be filtered out.
        let heroes = room.heroes();
        assert_eq!(heroes.len(), 1);
        assert_eq!(heroes[0].user_id, alice_id);
    }
}