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