matrix_sdk_base/room/
display_name.rs

1// Copyright 2025 The Matrix.org Foundation C.I.C.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::fmt;
16
17use as_variant::as_variant;
18use regex::Regex;
19use ruma::{
20    OwnedMxcUri, OwnedUserId, UserId,
21    events::{SyncStateEvent, member_hints::MemberHintsEventContent},
22};
23use serde::{Deserialize, Serialize};
24use tracing::{debug, trace, warn};
25
26use super::{Room, RoomMemberships};
27use crate::{
28    RoomMember, RoomState,
29    deserialized_responses::SyncOrStrippedState,
30    store::{Result as StoreResult, StateStoreExt},
31};
32
33impl Room {
34    /// Calculate a room's display name, or return the cached value, taking into
35    /// account its name, aliases and members.
36    ///
37    /// The display name is calculated according to [this algorithm][spec].
38    ///
39    /// While the underlying computation can be slow, the result is cached and
40    /// returned on the following calls. The cache is also filled on every
41    /// successful sync, since a sync may cause a change in the display
42    /// name.
43    ///
44    /// If you need a variant that's sync (but with the drawback that it returns
45    /// an `Option`), consider using [`Room::cached_display_name`].
46    ///
47    /// [spec]: <https://matrix.org/docs/spec/client_server/latest#calculating-the-display-name-for-a-room>
48    pub async fn display_name(&self) -> StoreResult<RoomDisplayName> {
49        if let Some(name) = self.cached_display_name() {
50            Ok(name)
51        } else {
52            Ok(self.compute_display_name().await?.into_inner())
53        }
54    }
55
56    /// Returns the cached computed display name, if available.
57    ///
58    /// This cache is refilled every time we call [`Self::display_name`].
59    pub fn cached_display_name(&self) -> Option<RoomDisplayName> {
60        self.inner.read().cached_display_name.clone()
61    }
62
63    /// Force recalculating a room's display name, taking into account its name,
64    /// aliases and members.
65    ///
66    /// The display name is calculated according to [this algorithm][spec].
67    ///
68    /// ⚠ This may be slowish to compute. As such, the result is cached and can
69    /// be retrieved via [`Room::cached_display_name`] (sync, returns an option)
70    /// or [`Room::display_name`] (async, always returns a value), which should
71    /// be preferred in general.
72    ///
73    /// [spec]: <https://matrix.org/docs/spec/client_server/latest#calculating-the-display-name-for-a-room>
74    pub(crate) async fn compute_display_name(&self) -> StoreResult<UpdatedRoomDisplayName> {
75        enum DisplayNameOrSummary {
76            Summary(RoomSummary),
77            DisplayName(RoomDisplayName),
78        }
79
80        let display_name_or_summary = {
81            let inner = self.inner.read();
82
83            match (inner.name(), inner.canonical_alias()) {
84                (Some(name), _) => {
85                    let name = RoomDisplayName::Named(name.trim().to_owned());
86                    DisplayNameOrSummary::DisplayName(name)
87                }
88                (None, Some(alias)) => {
89                    let name = RoomDisplayName::Aliased(alias.alias().trim().to_owned());
90                    DisplayNameOrSummary::DisplayName(name)
91                }
92                // We can't directly compute the display name from the summary here because Rust
93                // thinks that the `inner` lock is still held even if we explicitly call `drop()`
94                // on it. So we introduced the DisplayNameOrSummary type and do the computation in
95                // two steps.
96                (None, None) => DisplayNameOrSummary::Summary(inner.summary.clone()),
97            }
98        };
99
100        let display_name = match display_name_or_summary {
101            DisplayNameOrSummary::Summary(summary) => {
102                self.compute_display_name_from_summary(summary).await?
103            }
104            DisplayNameOrSummary::DisplayName(display_name) => display_name,
105        };
106
107        // Update the cached display name before we return the newly computed value.
108        let mut updated = false;
109
110        self.inner.update_if(|info| {
111            if info.cached_display_name.as_ref() != Some(&display_name) {
112                info.cached_display_name = Some(display_name.clone());
113                updated = true;
114
115                true
116            } else {
117                false
118            }
119        });
120
121        Ok(if updated {
122            UpdatedRoomDisplayName::New(display_name)
123        } else {
124            UpdatedRoomDisplayName::Same(display_name)
125        })
126    }
127
128    /// Compute a [`RoomDisplayName`] from the given [`RoomSummary`].
129    async fn compute_display_name_from_summary(
130        &self,
131        summary: RoomSummary,
132    ) -> StoreResult<RoomDisplayName> {
133        let computed_summary = if !summary.room_heroes.is_empty() {
134            self.extract_and_augment_summary(&summary).await?
135        } else {
136            self.compute_summary().await?
137        };
138
139        let ComputedSummary { heroes, num_service_members, num_joined_invited_guess } =
140            computed_summary;
141
142        let summary_member_count = (summary.joined_member_count + summary.invited_member_count)
143            .saturating_sub(num_service_members);
144
145        let num_joined_invited = if self.state() == RoomState::Invited {
146            // when we were invited we don't have a proper summary, we have to do best
147            // guessing
148            heroes.len() as u64 + 1
149        } else if summary_member_count == 0 {
150            num_joined_invited_guess
151        } else {
152            summary_member_count
153        };
154
155        debug!(
156            room_id = ?self.room_id(),
157            own_user = ?self.own_user_id,
158            num_joined_invited,
159            heroes = ?heroes,
160            "Calculating name for a room based on heroes",
161        );
162
163        let display_name = compute_display_name_from_heroes(
164            num_joined_invited,
165            heroes.iter().map(|hero| hero.as_str()).collect(),
166        );
167
168        Ok(display_name)
169    }
170
171    /// Extracts and enhances the [`RoomSummary`] provided by the homeserver.
172    ///
173    /// This method extracts the relevant data from the [`RoomSummary`] and
174    /// augments it with additional information that may not be included in
175    /// the initial response, such as details about service members in the
176    /// room.
177    ///
178    /// Returns a [`ComputedSummary`].
179    async fn extract_and_augment_summary(
180        &self,
181        summary: &RoomSummary,
182    ) -> StoreResult<ComputedSummary> {
183        let heroes = &summary.room_heroes;
184
185        let mut names = Vec::with_capacity(heroes.len());
186        let own_user_id = self.own_user_id();
187        let member_hints = self.get_member_hints().await?;
188
189        // If we have some service members in the heroes, that means that they are also
190        // part of the joined member counts. They shouldn't be so, otherwise
191        // we'll wrongly assume that there are more members in the room than
192        // they are for the "Bob and 2 others" case.
193        let num_service_members = heroes
194            .iter()
195            .filter(|hero| member_hints.service_members.contains(&hero.user_id))
196            .count() as u64;
197
198        // Construct a filter that is specific to this own user id, set of member hints,
199        // and accepts a `RoomHero` type.
200        let heroes_filter = heroes_filter(own_user_id, &member_hints);
201        let heroes_filter = |hero: &&RoomHero| heroes_filter(&hero.user_id);
202
203        for hero in heroes.iter().filter(heroes_filter) {
204            if let Some(display_name) = &hero.display_name {
205                names.push(display_name.clone());
206            } else {
207                match self.get_member(&hero.user_id).await {
208                    Ok(Some(member)) => {
209                        names.push(member.name().to_owned());
210                    }
211                    Ok(None) => {
212                        warn!(user_id = ?hero.user_id, "Ignoring hero, no member info");
213                    }
214                    Err(error) => {
215                        warn!("Ignoring hero, error getting member: {error}");
216                    }
217                }
218            }
219        }
220
221        let num_joined_invited_guess = summary.joined_member_count + summary.invited_member_count;
222
223        // If the summary doesn't provide the number of joined/invited members, let's
224        // guess something.
225        let num_joined_invited_guess = if num_joined_invited_guess == 0 {
226            let guess = self
227                .store
228                .get_user_ids(self.room_id(), RoomMemberships::JOIN | RoomMemberships::INVITE)
229                .await?
230                .len() as u64;
231
232            guess.saturating_sub(num_service_members)
233        } else {
234            // Otherwise, accept the numbers provided by the summary as the guess.
235            num_joined_invited_guess
236        };
237
238        Ok(ComputedSummary { heroes: names, num_service_members, num_joined_invited_guess })
239    }
240
241    /// Compute the room summary with the data present in the store.
242    ///
243    /// The summary might be incorrect if the database info is outdated.
244    ///
245    /// Returns the [`ComputedSummary`].
246    async fn compute_summary(&self) -> StoreResult<ComputedSummary> {
247        let member_hints = self.get_member_hints().await?;
248
249        // Construct a filter that is specific to this own user id, set of member hints,
250        // and accepts a `RoomMember` type.
251        let heroes_filter = heroes_filter(&self.own_user_id, &member_hints);
252        let heroes_filter = |u: &RoomMember| heroes_filter(u.user_id());
253
254        let mut members = self.members(RoomMemberships::JOIN | RoomMemberships::INVITE).await?;
255
256        // If we have some service members, they shouldn't count to the number of
257        // joined/invited members, otherwise we'll wrongly assume that there are more
258        // members in the room than they are for the "Bob and 2 others" case.
259        let num_service_members = members
260            .iter()
261            .filter(|member| member_hints.service_members.contains(member.user_id()))
262            .count();
263
264        // We can make a good prediction of the total number of joined and invited
265        // members here. This might be incorrect if the database info is
266        // outdated.
267        //
268        // Note: Subtracting here is fine because `num_service_members` is a subset of
269        // `members.len()` due to the above filter operation.
270        let num_joined_invited = members.len() - num_service_members;
271
272        if num_joined_invited == 0
273            || (num_joined_invited == 1 && members[0].user_id() == self.own_user_id)
274        {
275            // No joined or invited members, heroes should be banned and left members.
276            members = self.members(RoomMemberships::LEAVE | RoomMemberships::BAN).await?;
277        }
278
279        // Make the ordering deterministic.
280        members.sort_unstable_by(|lhs, rhs| lhs.name().cmp(rhs.name()));
281
282        let heroes = members
283            .into_iter()
284            .filter(heroes_filter)
285            .take(NUM_HEROES)
286            .map(|u| u.name().to_owned())
287            .collect();
288
289        trace!(
290            ?heroes,
291            num_joined_invited,
292            num_service_members,
293            "Computed a room summary since we didn't receive one."
294        );
295
296        let num_service_members = num_service_members as u64;
297        let num_joined_invited_guess = num_joined_invited as u64;
298
299        Ok(ComputedSummary { heroes, num_service_members, num_joined_invited_guess })
300    }
301
302    async fn get_member_hints(&self) -> StoreResult<MemberHintsEventContent> {
303        Ok(self
304            .store
305            .get_state_event_static::<MemberHintsEventContent>(self.room_id())
306            .await?
307            .and_then(|event| {
308                event
309                    .deserialize()
310                    .inspect_err(|e| warn!("Couldn't deserialize the member hints event: {e}"))
311                    .ok()
312            })
313            .and_then(|event| as_variant!(event, SyncOrStrippedState::Sync(SyncStateEvent::Original(e)) => e.content))
314            .unwrap_or_default())
315    }
316}
317
318/// The result of a room summary computation.
319///
320/// If the homeserver does not provide a room summary, we perform a best-effort
321/// computation to generate one ourselves. If the homeserver does provide the
322/// summary, we augment it with additional information about the service members
323/// in the room.
324struct ComputedSummary {
325    /// The list of display names that will be used to calculate the room
326    /// display name.
327    heroes: Vec<String>,
328    /// The number of joined service members in the room.
329    num_service_members: u64,
330    /// The number of joined and invited members, not including any service
331    /// members.
332    num_joined_invited_guess: u64,
333}
334
335/// The room summary containing member counts and members that should be used to
336/// calculate the room display name.
337#[derive(Clone, Debug, Default, Serialize, Deserialize)]
338pub(crate) struct RoomSummary {
339    /// The heroes of the room, members that can be used as a fallback for the
340    /// room's display name or avatar if these haven't been set.
341    ///
342    /// This was called `heroes` and contained raw `String`s of the `UserId`
343    /// before. Following this it was called `heroes_user_ids` and a
344    /// complimentary `heroes_names` existed too; changing the field's name
345    /// helped with avoiding a migration.
346    #[serde(default, skip_serializing_if = "Vec::is_empty")]
347    pub room_heroes: Vec<RoomHero>,
348    /// The number of members that are considered to be joined to the room.
349    pub joined_member_count: u64,
350    /// The number of members that are considered to be invited to the room.
351    pub invited_member_count: u64,
352}
353
354#[cfg(test)]
355impl RoomSummary {
356    pub(crate) fn heroes(&self) -> &[RoomHero] {
357        &self.room_heroes
358    }
359}
360
361/// Information about a member considered to be a room hero.
362#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
363pub struct RoomHero {
364    /// The user id of the hero.
365    pub user_id: OwnedUserId,
366    /// The display name of the hero.
367    pub display_name: Option<String>,
368    /// The avatar url of the hero.
369    pub avatar_url: Option<OwnedMxcUri>,
370}
371
372/// The number of heroes chosen to compute a room's name, if the room didn't
373/// have a name set by the users themselves.
374///
375/// A server must return at most 5 heroes, according to the paragraph below
376/// https://spec.matrix.org/v1.10/client-server-api/#get_matrixclientv3sync (grep for "heroes"). We
377/// try to behave similarly here.
378const NUM_HEROES: usize = 5;
379
380/// The name of the room, either from the metadata or calculated
381/// according to [matrix specification](https://matrix.org/docs/spec/client_server/latest#calculating-the-display-name-for-a-room)
382#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
383pub enum RoomDisplayName {
384    /// The room has been named explicitly as
385    Named(String),
386    /// The room has a canonical alias that should be used
387    Aliased(String),
388    /// The room has not given an explicit name but a name could be
389    /// calculated
390    Calculated(String),
391    /// The room doesn't have a name right now, but used to have one
392    /// e.g. because it was a DM and everyone has left the room
393    EmptyWas(String),
394    /// No useful name could be calculated or ever found
395    Empty,
396}
397
398/// An internal representing whether a room display name is new or not when
399/// computed.
400pub(crate) enum UpdatedRoomDisplayName {
401    New(RoomDisplayName),
402    Same(RoomDisplayName),
403}
404
405impl UpdatedRoomDisplayName {
406    /// Get the inner [`RoomDisplayName`].
407    pub fn into_inner(self) -> RoomDisplayName {
408        match self {
409            UpdatedRoomDisplayName::New(room_display_name) => room_display_name,
410            UpdatedRoomDisplayName::Same(room_display_name) => room_display_name,
411        }
412    }
413}
414
415const WHITESPACE_REGEX: &str = r"\s+";
416const INVALID_SYMBOLS_REGEX: &str = r"[#,:\{\}\\]+";
417
418impl RoomDisplayName {
419    /// Transforms the current display name into the name part of a
420    /// `RoomAliasId`.
421    pub fn to_room_alias_name(&self) -> String {
422        let room_name = match self {
423            Self::Named(name) => name,
424            Self::Aliased(name) => name,
425            Self::Calculated(name) => name,
426            Self::EmptyWas(name) => name,
427            Self::Empty => "",
428        };
429
430        let whitespace_regex =
431            Regex::new(WHITESPACE_REGEX).expect("`WHITESPACE_REGEX` should be valid");
432        let symbol_regex =
433            Regex::new(INVALID_SYMBOLS_REGEX).expect("`INVALID_SYMBOLS_REGEX` should be valid");
434
435        // Replace whitespaces with `-`
436        let sanitised = whitespace_regex.replace_all(room_name, "-");
437        // Remove non-ASCII characters and ASCII control characters
438        let sanitised =
439            String::from_iter(sanitised.chars().filter(|c| c.is_ascii() && !c.is_ascii_control()));
440        // Remove other problematic ASCII symbols
441        let sanitised = symbol_regex.replace_all(&sanitised, "");
442        // Lowercased
443        sanitised.to_lowercase()
444    }
445}
446
447impl fmt::Display for RoomDisplayName {
448    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
449        match self {
450            RoomDisplayName::Named(s)
451            | RoomDisplayName::Calculated(s)
452            | RoomDisplayName::Aliased(s) => {
453                write!(f, "{s}")
454            }
455            RoomDisplayName::EmptyWas(s) => write!(f, "Empty Room (was {s})"),
456            RoomDisplayName::Empty => write!(f, "Empty Room"),
457        }
458    }
459}
460
461/// Calculate room name according to step 3 of the [naming algorithm].
462///
463/// [naming algorithm]: https://spec.matrix.org/latest/client-server-api/#calculating-the-display-name-for-a-room
464fn compute_display_name_from_heroes(
465    num_joined_invited: u64,
466    mut heroes: Vec<&str>,
467) -> RoomDisplayName {
468    let num_heroes = heroes.len() as u64;
469    let num_joined_invited_except_self = num_joined_invited.saturating_sub(1);
470
471    // Stabilize ordering.
472    heroes.sort_unstable();
473
474    let names = if num_heroes == 0 && num_joined_invited > 1 {
475        format!("{num_joined_invited} people")
476    } else if num_heroes >= num_joined_invited_except_self {
477        heroes.join(", ")
478    } else if num_heroes < num_joined_invited_except_self && num_joined_invited > 1 {
479        // TODO: What length does the spec want us to use here and in
480        // the `else`?
481        format!("{}, and {} others", heroes.join(", "), (num_joined_invited - num_heroes))
482    } else {
483        "".to_owned()
484    };
485
486    // User is alone.
487    if num_joined_invited <= 1 {
488        if names.is_empty() { RoomDisplayName::Empty } else { RoomDisplayName::EmptyWas(names) }
489    } else {
490        RoomDisplayName::Calculated(names)
491    }
492}
493
494/// A filter to remove our own user and the users specified in the member hints
495/// state event, so called service members, from the list of heroes.
496///
497/// The heroes will then be used to calculate a display name for the room if one
498/// wasn't explicitly defined.
499fn heroes_filter<'a>(
500    own_user_id: &'a UserId,
501    member_hints: &'a MemberHintsEventContent,
502) -> impl Fn(&UserId) -> bool + use<'a> {
503    move |user_id| user_id != own_user_id && !member_hints.service_members.contains(user_id)
504}
505
506#[cfg(test)]
507mod tests {
508    use std::{collections::BTreeSet, sync::Arc};
509
510    use matrix_sdk_test::{async_test, event_factory::EventFactory};
511    use ruma::{
512        UserId,
513        api::client::sync::sync_events::v3::RoomSummary as RumaSummary,
514        assign,
515        events::{
516            StateEventType,
517            room::{
518                canonical_alias::RoomCanonicalAliasEventContent,
519                member::{MembershipState, RoomMemberEventContent, StrippedRoomMemberEvent},
520                name::RoomNameEventContent,
521            },
522        },
523        room_alias_id, room_id,
524        serde::Raw,
525        user_id,
526    };
527    use serde_json::json;
528
529    use super::{Room, RoomDisplayName, compute_display_name_from_heroes};
530    use crate::{
531        MinimalStateEvent, OriginalMinimalStateEvent, RoomState, StateChanges, StateStore,
532        store::MemoryStore,
533    };
534
535    fn make_room_test_helper(room_type: RoomState) -> (Arc<MemoryStore>, Room) {
536        let store = Arc::new(MemoryStore::new());
537        let user_id = user_id!("@me:example.org");
538        let room_id = room_id!("!test:localhost");
539        let (sender, _receiver) = tokio::sync::broadcast::channel(1);
540
541        (store.clone(), Room::new(user_id, store, room_id, room_type, sender))
542    }
543
544    fn make_stripped_member_event(user_id: &UserId, name: &str) -> Raw<StrippedRoomMemberEvent> {
545        let ev_json = json!({
546            "type": "m.room.member",
547            "content": assign!(RoomMemberEventContent::new(MembershipState::Join), {
548                displayname: Some(name.to_owned())
549            }),
550            "sender": user_id,
551            "state_key": user_id,
552        });
553
554        Raw::new(&ev_json).unwrap().cast_unchecked()
555    }
556
557    fn make_canonical_alias_event() -> MinimalStateEvent<RoomCanonicalAliasEventContent> {
558        MinimalStateEvent::Original(OriginalMinimalStateEvent {
559            content: assign!(RoomCanonicalAliasEventContent::new(), {
560                alias: Some(room_alias_id!("#test:example.com").to_owned()),
561            }),
562            event_id: None,
563        })
564    }
565
566    fn make_name_event() -> MinimalStateEvent<RoomNameEventContent> {
567        MinimalStateEvent::Original(OriginalMinimalStateEvent {
568            content: RoomNameEventContent::new("Test Room".to_owned()),
569            event_id: None,
570        })
571    }
572
573    #[async_test]
574    async fn test_display_name_for_joined_room_is_empty_if_no_info() {
575        let (_, room) = make_room_test_helper(RoomState::Joined);
576        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
577    }
578
579    #[async_test]
580    async fn test_display_name_for_joined_room_uses_canonical_alias_if_available() {
581        let (_, room) = make_room_test_helper(RoomState::Joined);
582        room.inner
583            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
584        assert_eq!(
585            room.compute_display_name().await.unwrap().into_inner(),
586            RoomDisplayName::Aliased("test".to_owned())
587        );
588    }
589
590    #[async_test]
591    async fn test_display_name_for_joined_room_prefers_name_over_alias() {
592        let (_, room) = make_room_test_helper(RoomState::Joined);
593        room.inner
594            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
595        assert_eq!(
596            room.compute_display_name().await.unwrap().into_inner(),
597            RoomDisplayName::Aliased("test".to_owned())
598        );
599        room.inner.update(|info| info.base_info.name = Some(make_name_event()));
600        // Display name wasn't cached when we asked for it above, and name overrides
601        assert_eq!(
602            room.compute_display_name().await.unwrap().into_inner(),
603            RoomDisplayName::Named("Test Room".to_owned())
604        );
605    }
606
607    #[async_test]
608    async fn test_display_name_for_invited_room_is_empty_if_no_info() {
609        let (_, room) = make_room_test_helper(RoomState::Invited);
610        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
611    }
612
613    #[async_test]
614    async fn test_display_name_for_invited_room_is_empty_if_room_name_empty() {
615        let (_, room) = make_room_test_helper(RoomState::Invited);
616
617        let room_name = MinimalStateEvent::Original(OriginalMinimalStateEvent {
618            content: RoomNameEventContent::new(String::new()),
619            event_id: None,
620        });
621        room.inner.update(|info| info.base_info.name = Some(room_name));
622
623        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
624    }
625
626    #[async_test]
627    async fn test_display_name_for_invited_room_uses_canonical_alias_if_available() {
628        let (_, room) = make_room_test_helper(RoomState::Invited);
629        room.inner
630            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
631        assert_eq!(
632            room.compute_display_name().await.unwrap().into_inner(),
633            RoomDisplayName::Aliased("test".to_owned())
634        );
635    }
636
637    #[async_test]
638    async fn test_display_name_for_invited_room_prefers_name_over_alias() {
639        let (_, room) = make_room_test_helper(RoomState::Invited);
640        room.inner
641            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
642        assert_eq!(
643            room.compute_display_name().await.unwrap().into_inner(),
644            RoomDisplayName::Aliased("test".to_owned())
645        );
646        room.inner.update(|info| info.base_info.name = Some(make_name_event()));
647        // Display name wasn't cached when we asked for it above, and name overrides
648        assert_eq!(
649            room.compute_display_name().await.unwrap().into_inner(),
650            RoomDisplayName::Named("Test Room".to_owned())
651        );
652    }
653
654    #[async_test]
655    async fn test_display_name_dm_invited() {
656        let (store, room) = make_room_test_helper(RoomState::Invited);
657        let room_id = room_id!("!test:localhost");
658        let matthew = user_id!("@matthew:example.org");
659        let me = user_id!("@me:example.org");
660        let mut changes = StateChanges::new("".to_owned());
661        let summary = assign!(RumaSummary::new(), {
662            heroes: vec![me.to_owned(), matthew.to_owned()],
663        });
664
665        changes.add_stripped_member(
666            room_id,
667            matthew,
668            make_stripped_member_event(matthew, "Matthew"),
669        );
670        changes.add_stripped_member(room_id, me, make_stripped_member_event(me, "Me"));
671        store.save_changes(&changes).await.unwrap();
672
673        room.inner.update_if(|info| info.update_from_ruma_summary(&summary));
674        assert_eq!(
675            room.compute_display_name().await.unwrap().into_inner(),
676            RoomDisplayName::Calculated("Matthew".to_owned())
677        );
678    }
679
680    #[async_test]
681    async fn test_display_name_dm_invited_no_heroes() {
682        let (store, room) = make_room_test_helper(RoomState::Invited);
683        let room_id = room_id!("!test:localhost");
684        let matthew = user_id!("@matthew:example.org");
685        let me = user_id!("@me:example.org");
686        let mut changes = StateChanges::new("".to_owned());
687
688        changes.add_stripped_member(
689            room_id,
690            matthew,
691            make_stripped_member_event(matthew, "Matthew"),
692        );
693        changes.add_stripped_member(room_id, me, make_stripped_member_event(me, "Me"));
694        store.save_changes(&changes).await.unwrap();
695
696        assert_eq!(
697            room.compute_display_name().await.unwrap().into_inner(),
698            RoomDisplayName::Calculated("Matthew".to_owned())
699        );
700    }
701
702    #[async_test]
703    async fn test_display_name_dm_joined() {
704        let (store, room) = make_room_test_helper(RoomState::Joined);
705        let room_id = room_id!("!test:localhost");
706        let matthew = user_id!("@matthew:example.org");
707        let me = user_id!("@me:example.org");
708
709        let mut changes = StateChanges::new("".to_owned());
710        let summary = assign!(RumaSummary::new(), {
711            joined_member_count: Some(2u32.into()),
712            heroes: vec![me.to_owned(), matthew.to_owned()],
713        });
714
715        let f = EventFactory::new().room(room_id!("!test:localhost"));
716
717        let members = changes
718            .state
719            .entry(room_id.to_owned())
720            .or_default()
721            .entry(StateEventType::RoomMember)
722            .or_default();
723        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw());
724        members.insert(me.into(), f.member(me).display_name("Me").into_raw());
725
726        store.save_changes(&changes).await.unwrap();
727
728        room.inner.update_if(|info| info.update_from_ruma_summary(&summary));
729        assert_eq!(
730            room.compute_display_name().await.unwrap().into_inner(),
731            RoomDisplayName::Calculated("Matthew".to_owned())
732        );
733    }
734
735    #[async_test]
736    async fn test_display_name_dm_joined_service_members() {
737        let (store, room) = make_room_test_helper(RoomState::Joined);
738        let room_id = room_id!("!test:localhost");
739
740        let matthew = user_id!("@sahasrhala:example.org");
741        let me = user_id!("@me:example.org");
742        let bot = user_id!("@bot:example.org");
743
744        let mut changes = StateChanges::new("".to_owned());
745        let summary = assign!(RumaSummary::new(), {
746            joined_member_count: Some(3u32.into()),
747            heroes: vec![me.to_owned(), matthew.to_owned(), bot.to_owned()],
748        });
749
750        let f = EventFactory::new().room(room_id!("!test:localhost"));
751
752        let members = changes
753            .state
754            .entry(room_id.to_owned())
755            .or_default()
756            .entry(StateEventType::RoomMember)
757            .or_default();
758        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw());
759        members.insert(me.into(), f.member(me).display_name("Me").into_raw());
760        members.insert(bot.into(), f.member(bot).display_name("Bot").into_raw());
761
762        let member_hints_content =
763            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into_raw();
764        changes
765            .state
766            .entry(room_id.to_owned())
767            .or_default()
768            .entry(StateEventType::MemberHints)
769            .or_default()
770            .insert("".to_owned(), member_hints_content);
771
772        store.save_changes(&changes).await.unwrap();
773
774        room.inner.update_if(|info| info.update_from_ruma_summary(&summary));
775        // Bot should not contribute to the display name.
776        assert_eq!(
777            room.compute_display_name().await.unwrap().into_inner(),
778            RoomDisplayName::Calculated("Matthew".to_owned())
779        );
780    }
781
782    #[async_test]
783    async fn test_display_name_dm_joined_alone_with_service_members() {
784        let (store, room) = make_room_test_helper(RoomState::Joined);
785        let room_id = room_id!("!test:localhost");
786
787        let me = user_id!("@me:example.org");
788        let bot = user_id!("@bot:example.org");
789
790        let mut changes = StateChanges::new("".to_owned());
791        let summary = assign!(RumaSummary::new(), {
792            joined_member_count: Some(2u32.into()),
793            heroes: vec![me.to_owned(), bot.to_owned()],
794        });
795
796        let f = EventFactory::new().room(room_id!("!test:localhost"));
797
798        let members = changes
799            .state
800            .entry(room_id.to_owned())
801            .or_default()
802            .entry(StateEventType::RoomMember)
803            .or_default();
804        members.insert(me.into(), f.member(me).display_name("Me").into_raw());
805        members.insert(bot.into(), f.member(bot).display_name("Bot").into_raw());
806
807        let member_hints_content =
808            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into_raw();
809        changes
810            .state
811            .entry(room_id.to_owned())
812            .or_default()
813            .entry(StateEventType::MemberHints)
814            .or_default()
815            .insert("".to_owned(), member_hints_content);
816
817        store.save_changes(&changes).await.unwrap();
818
819        room.inner.update_if(|info| info.update_from_ruma_summary(&summary));
820        // Bot should not contribute to the display name.
821        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
822    }
823
824    #[async_test]
825    async fn test_display_name_dm_joined_no_heroes() {
826        let (store, room) = make_room_test_helper(RoomState::Joined);
827        let room_id = room_id!("!test:localhost");
828        let matthew = user_id!("@matthew:example.org");
829        let me = user_id!("@me:example.org");
830        let mut changes = StateChanges::new("".to_owned());
831
832        let f = EventFactory::new().room(room_id!("!test:localhost"));
833
834        let members = changes
835            .state
836            .entry(room_id.to_owned())
837            .or_default()
838            .entry(StateEventType::RoomMember)
839            .or_default();
840        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw());
841        members.insert(me.into(), f.member(me).display_name("Me").into_raw());
842
843        store.save_changes(&changes).await.unwrap();
844
845        assert_eq!(
846            room.compute_display_name().await.unwrap().into_inner(),
847            RoomDisplayName::Calculated("Matthew".to_owned())
848        );
849    }
850
851    #[async_test]
852    async fn test_display_name_dm_joined_no_heroes_service_members() {
853        let (store, room) = make_room_test_helper(RoomState::Joined);
854        let room_id = room_id!("!test:localhost");
855
856        let matthew = user_id!("@matthew:example.org");
857        let me = user_id!("@me:example.org");
858        let bot = user_id!("@bot:example.org");
859
860        let mut changes = StateChanges::new("".to_owned());
861
862        let f = EventFactory::new().room(room_id!("!test:localhost"));
863
864        let members = changes
865            .state
866            .entry(room_id.to_owned())
867            .or_default()
868            .entry(StateEventType::RoomMember)
869            .or_default();
870        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw());
871        members.insert(me.into(), f.member(me).display_name("Me").into_raw());
872        members.insert(bot.into(), f.member(bot).display_name("Bot").into_raw());
873
874        let member_hints_content =
875            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into_raw();
876        changes
877            .state
878            .entry(room_id.to_owned())
879            .or_default()
880            .entry(StateEventType::MemberHints)
881            .or_default()
882            .insert("".to_owned(), member_hints_content);
883
884        store.save_changes(&changes).await.unwrap();
885
886        assert_eq!(
887            room.compute_display_name().await.unwrap().into_inner(),
888            RoomDisplayName::Calculated("Matthew".to_owned())
889        );
890    }
891
892    #[async_test]
893    async fn test_display_name_deterministic() {
894        let (store, room) = make_room_test_helper(RoomState::Joined);
895
896        let alice = user_id!("@alice:example.org");
897        let bob = user_id!("@bob:example.org");
898        let carol = user_id!("@carol:example.org");
899        let denis = user_id!("@denis:example.org");
900        let erica = user_id!("@erica:example.org");
901        let fred = user_id!("@fred:example.org");
902        let me = user_id!("@me:example.org");
903
904        let mut changes = StateChanges::new("".to_owned());
905
906        let f = EventFactory::new().room(room_id!("!test:localhost"));
907
908        // Save members in two batches, so that there's no implied ordering in the
909        // store.
910        {
911            let members = changes
912                .state
913                .entry(room.room_id().to_owned())
914                .or_default()
915                .entry(StateEventType::RoomMember)
916                .or_default();
917            members.insert(carol.into(), f.member(carol).display_name("Carol").into_raw());
918            members.insert(bob.into(), f.member(bob).display_name("Bob").into_raw());
919            members.insert(fred.into(), f.member(fred).display_name("Fred").into_raw());
920            members.insert(me.into(), f.member(me).display_name("Me").into_raw());
921            store.save_changes(&changes).await.unwrap();
922        }
923
924        {
925            let members = changes
926                .state
927                .entry(room.room_id().to_owned())
928                .or_default()
929                .entry(StateEventType::RoomMember)
930                .or_default();
931            members.insert(alice.into(), f.member(alice).display_name("Alice").into_raw());
932            members.insert(erica.into(), f.member(erica).display_name("Erica").into_raw());
933            members.insert(denis.into(), f.member(denis).display_name("Denis").into_raw());
934            store.save_changes(&changes).await.unwrap();
935        }
936
937        let summary = assign!(RumaSummary::new(), {
938            joined_member_count: Some(7u32.into()),
939            heroes: vec![denis.to_owned(), carol.to_owned(), bob.to_owned(), erica.to_owned()],
940        });
941        room.inner.update_if(|info| info.update_from_ruma_summary(&summary));
942
943        assert_eq!(
944            room.compute_display_name().await.unwrap().into_inner(),
945            RoomDisplayName::Calculated("Bob, Carol, Denis, Erica, and 3 others".to_owned())
946        );
947    }
948
949    #[async_test]
950    async fn test_display_name_deterministic_no_heroes() {
951        let (store, room) = make_room_test_helper(RoomState::Joined);
952
953        let alice = user_id!("@alice:example.org");
954        let bob = user_id!("@bob:example.org");
955        let carol = user_id!("@carol:example.org");
956        let denis = user_id!("@denis:example.org");
957        let erica = user_id!("@erica:example.org");
958        let fred = user_id!("@fred:example.org");
959        let me = user_id!("@me:example.org");
960
961        let f = EventFactory::new().room(room_id!("!test:localhost"));
962
963        let mut changes = StateChanges::new("".to_owned());
964
965        // Save members in two batches, so that there's no implied ordering in the
966        // store.
967        {
968            let members = changes
969                .state
970                .entry(room.room_id().to_owned())
971                .or_default()
972                .entry(StateEventType::RoomMember)
973                .or_default();
974            members.insert(carol.into(), f.member(carol).display_name("Carol").into_raw());
975            members.insert(bob.into(), f.member(bob).display_name("Bob").into_raw());
976            members.insert(fred.into(), f.member(fred).display_name("Fred").into_raw());
977            members.insert(me.into(), f.member(me).display_name("Me").into_raw());
978
979            store.save_changes(&changes).await.unwrap();
980        }
981
982        {
983            let members = changes
984                .state
985                .entry(room.room_id().to_owned())
986                .or_default()
987                .entry(StateEventType::RoomMember)
988                .or_default();
989            members.insert(alice.into(), f.member(alice).display_name("Alice").into_raw());
990            members.insert(erica.into(), f.member(erica).display_name("Erica").into_raw());
991            members.insert(denis.into(), f.member(denis).display_name("Denis").into_raw());
992            store.save_changes(&changes).await.unwrap();
993        }
994
995        assert_eq!(
996            room.compute_display_name().await.unwrap().into_inner(),
997            RoomDisplayName::Calculated("Alice, Bob, Carol, Denis, Erica, and 2 others".to_owned())
998        );
999    }
1000
1001    #[async_test]
1002    async fn test_display_name_dm_alone() {
1003        let (store, room) = make_room_test_helper(RoomState::Joined);
1004        let room_id = room_id!("!test:localhost");
1005        let matthew = user_id!("@matthew:example.org");
1006        let me = user_id!("@me:example.org");
1007        let mut changes = StateChanges::new("".to_owned());
1008        let summary = assign!(RumaSummary::new(), {
1009            joined_member_count: Some(1u32.into()),
1010            heroes: vec![me.to_owned(), matthew.to_owned()],
1011        });
1012
1013        let f = EventFactory::new().room(room_id!("!test:localhost"));
1014
1015        let members = changes
1016            .state
1017            .entry(room_id.to_owned())
1018            .or_default()
1019            .entry(StateEventType::RoomMember)
1020            .or_default();
1021        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into_raw());
1022        members.insert(me.into(), f.member(me).display_name("Me").into_raw());
1023
1024        store.save_changes(&changes).await.unwrap();
1025
1026        room.inner.update_if(|info| info.update_from_ruma_summary(&summary));
1027        assert_eq!(
1028            room.compute_display_name().await.unwrap().into_inner(),
1029            RoomDisplayName::EmptyWas("Matthew".to_owned())
1030        );
1031    }
1032
1033    #[test]
1034    fn test_calculate_room_name() {
1035        let mut actual = compute_display_name_from_heroes(2, vec!["a"]);
1036        assert_eq!(RoomDisplayName::Calculated("a".to_owned()), actual);
1037
1038        actual = compute_display_name_from_heroes(3, vec!["a", "b"]);
1039        assert_eq!(RoomDisplayName::Calculated("a, b".to_owned()), actual);
1040
1041        actual = compute_display_name_from_heroes(4, vec!["a", "b", "c"]);
1042        assert_eq!(RoomDisplayName::Calculated("a, b, c".to_owned()), actual);
1043
1044        actual = compute_display_name_from_heroes(5, vec!["a", "b", "c"]);
1045        assert_eq!(RoomDisplayName::Calculated("a, b, c, and 2 others".to_owned()), actual);
1046
1047        actual = compute_display_name_from_heroes(5, vec![]);
1048        assert_eq!(RoomDisplayName::Calculated("5 people".to_owned()), actual);
1049
1050        actual = compute_display_name_from_heroes(0, vec![]);
1051        assert_eq!(RoomDisplayName::Empty, actual);
1052
1053        actual = compute_display_name_from_heroes(1, vec![]);
1054        assert_eq!(RoomDisplayName::Empty, actual);
1055
1056        actual = compute_display_name_from_heroes(1, vec!["a"]);
1057        assert_eq!(RoomDisplayName::EmptyWas("a".to_owned()), actual);
1058
1059        actual = compute_display_name_from_heroes(1, vec!["a", "b"]);
1060        assert_eq!(RoomDisplayName::EmptyWas("a, b".to_owned()), actual);
1061
1062        actual = compute_display_name_from_heroes(1, vec!["a", "b", "c"]);
1063        assert_eq!(RoomDisplayName::EmptyWas("a, b, c".to_owned()), actual);
1064    }
1065
1066    #[test]
1067    fn test_room_alias_from_room_display_name_lowercases() {
1068        assert_eq!(
1069            "roomalias",
1070            RoomDisplayName::Named("RoomAlias".to_owned()).to_room_alias_name()
1071        );
1072    }
1073
1074    #[test]
1075    fn test_room_alias_from_room_display_name_removes_whitespace() {
1076        assert_eq!(
1077            "room-alias",
1078            RoomDisplayName::Named("Room Alias".to_owned()).to_room_alias_name()
1079        );
1080    }
1081
1082    #[test]
1083    fn test_room_alias_from_room_display_name_removes_non_ascii_symbols() {
1084        assert_eq!(
1085            "roomalias",
1086            RoomDisplayName::Named("Room±Alias√".to_owned()).to_room_alias_name()
1087        );
1088    }
1089
1090    #[test]
1091    fn test_room_alias_from_room_display_name_removes_invalid_ascii_symbols() {
1092        assert_eq!(
1093            "roomalias",
1094            RoomDisplayName::Named("#Room,{Alias}:".to_owned()).to_room_alias_name()
1095        );
1096    }
1097}