Skip to main content

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://spec.matrix.org/latest/client-server-api/#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://spec.matrix.org/latest/client-server-api/#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://spec.matrix.org/latest/client-server-api/#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::{
548                    PossiblyRedactedRoomCanonicalAliasEventContent, RoomCanonicalAliasEventContent,
549                },
550                member::{MembershipState, RoomMemberEventContent, StrippedRoomMemberEvent},
551                name::{PossiblyRedactedRoomNameEventContent, RoomNameEventContent},
552            },
553        },
554        owned_room_alias_id, owned_user_id, room_alias_id, room_id,
555        serde::Raw,
556        user_id,
557    };
558    use serde_json::json;
559
560    use super::{Room, RoomDisplayName, compute_display_name_from_heroes};
561    use crate::{
562        MinimalStateEvent, RoomHero, RoomState, StateChanges, StateStore, store::MemoryStore,
563    };
564
565    fn make_room_test_helper(room_type: RoomState) -> (Arc<MemoryStore>, Room) {
566        let store = Arc::new(MemoryStore::new());
567        let user_id = user_id!("@me:example.org");
568        let room_id = room_id!("!test:localhost");
569        let (sender, _receiver) = tokio::sync::broadcast::channel(1);
570
571        (store.clone(), Room::new(user_id, store, room_id, room_type, sender))
572    }
573
574    fn make_stripped_member_event(user_id: &UserId, name: &str) -> Raw<StrippedRoomMemberEvent> {
575        let ev_json = json!({
576            "type": "m.room.member",
577            "content": assign!(RoomMemberEventContent::new(MembershipState::Join), {
578                displayname: Some(name.to_owned())
579            }),
580            "sender": user_id,
581            "state_key": user_id,
582        });
583
584        Raw::new(&ev_json).unwrap().cast_unchecked()
585    }
586
587    fn make_canonical_alias_event() -> MinimalStateEvent<RoomCanonicalAliasEventContent> {
588        MinimalStateEvent {
589            content: assign!(PossiblyRedactedRoomCanonicalAliasEventContent::new(), {
590                alias: Some(owned_room_alias_id!("#test:example.com")),
591            }),
592            event_id: None,
593        }
594    }
595
596    fn make_name_event_with(name: &str) -> MinimalStateEvent<PossiblyRedactedRoomNameEventContent> {
597        MinimalStateEvent {
598            content: RoomNameEventContent::new(name.to_owned()).into(),
599            event_id: None,
600        }
601    }
602
603    fn make_name_event() -> MinimalStateEvent<PossiblyRedactedRoomNameEventContent> {
604        make_name_event_with("Test Room")
605    }
606
607    #[async_test]
608    async fn test_display_name_for_joined_room_is_empty_if_no_info() {
609        let (_, room) = make_room_test_helper(RoomState::Joined);
610        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
611    }
612
613    #[test]
614    fn test_display_name_compute_fields_empty() {
615        assert_eq!(
616            Room::compute_display_name_with_fields(None, None, vec![], 0),
617            RoomDisplayName::Empty
618        );
619    }
620
621    #[async_test]
622    async fn test_display_name_for_joined_room_is_empty_if_name_empty() {
623        let (_, room) = make_room_test_helper(RoomState::Joined);
624        room.info.update(|info| info.base_info.name = Some(make_name_event_with("")));
625
626        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
627    }
628
629    #[test]
630    fn test_display_name_compute_fields_empty_if_name_empty() {
631        assert_eq!(
632            Room::compute_display_name_with_fields(Some("".to_owned()), None, vec![], 0),
633            RoomDisplayName::Empty
634        );
635    }
636
637    #[async_test]
638    async fn test_display_name_for_joined_room_uses_canonical_alias_if_available() {
639        let (_, room) = make_room_test_helper(RoomState::Joined);
640        room.info
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    }
647
648    #[test]
649    fn test_display_name_compute_fields_alias() {
650        assert_eq!(
651            Room::compute_display_name_with_fields(
652                None,
653                Some(room_alias_id!("#test:example.com")),
654                vec![],
655                0,
656            ),
657            RoomDisplayName::Aliased("test".to_owned())
658        );
659    }
660
661    #[async_test]
662    async fn test_display_name_for_joined_room_prefers_name_over_alias() {
663        let (_, room) = make_room_test_helper(RoomState::Joined);
664        room.info
665            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
666        assert_eq!(
667            room.compute_display_name().await.unwrap().into_inner(),
668            RoomDisplayName::Aliased("test".to_owned())
669        );
670        room.info.update(|info| info.base_info.name = Some(make_name_event()));
671        // Display name wasn't cached when we asked for it above, and name overrides
672        assert_eq!(
673            room.compute_display_name().await.unwrap().into_inner(),
674            RoomDisplayName::Named("Test Room".to_owned())
675        );
676    }
677
678    #[test]
679    fn test_display_name_compute_fields_name_over_alias() {
680        assert_eq!(
681            Room::compute_display_name_with_fields(
682                Some("Test Room".to_owned()),
683                Some(room_alias_id!("#test:example.com")),
684                vec![],
685                0
686            ),
687            RoomDisplayName::Named("Test Room".to_owned())
688        );
689    }
690
691    #[async_test]
692    async fn test_display_name_for_invited_room_is_empty_if_no_info() {
693        let (_, room) = make_room_test_helper(RoomState::Invited);
694        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
695    }
696
697    #[async_test]
698    async fn test_display_name_for_invited_room_is_empty_if_room_name_empty() {
699        let (_, room) = make_room_test_helper(RoomState::Invited);
700
701        let room_name = make_name_event_with("");
702        room.info.update(|info| info.base_info.name = Some(room_name));
703
704        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
705    }
706
707    #[async_test]
708    async fn test_display_name_for_invited_room_uses_canonical_alias_if_available() {
709        let (_, room) = make_room_test_helper(RoomState::Invited);
710        room.info
711            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
712        assert_eq!(
713            room.compute_display_name().await.unwrap().into_inner(),
714            RoomDisplayName::Aliased("test".to_owned())
715        );
716    }
717
718    #[async_test]
719    async fn test_display_name_for_invited_room_prefers_name_over_alias() {
720        let (_, room) = make_room_test_helper(RoomState::Invited);
721        room.info
722            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
723        assert_eq!(
724            room.compute_display_name().await.unwrap().into_inner(),
725            RoomDisplayName::Aliased("test".to_owned())
726        );
727        room.info.update(|info| info.base_info.name = Some(make_name_event()));
728        // Display name wasn't cached when we asked for it above, and name overrides
729        assert_eq!(
730            room.compute_display_name().await.unwrap().into_inner(),
731            RoomDisplayName::Named("Test Room".to_owned())
732        );
733    }
734
735    #[async_test]
736    async fn test_display_name_dm_invited() {
737        let (store, room) = make_room_test_helper(RoomState::Invited);
738        let room_id = room_id!("!test:localhost");
739        let matthew = user_id!("@matthew:example.org");
740        let me = user_id!("@me:example.org");
741        let mut changes = StateChanges::new("".to_owned());
742        let summary = assign!(RumaSummary::new(), {
743            heroes: vec![me.to_owned(), matthew.to_owned()],
744        });
745
746        changes.add_stripped_member(
747            room_id,
748            matthew,
749            make_stripped_member_event(matthew, "Matthew"),
750        );
751        changes.add_stripped_member(room_id, me, make_stripped_member_event(me, "Me"));
752        store.save_changes(&changes).await.unwrap();
753
754        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
755        assert_eq!(
756            room.compute_display_name().await.unwrap().into_inner(),
757            RoomDisplayName::Calculated("Matthew".to_owned())
758        );
759    }
760
761    #[async_test]
762    async fn test_display_name_dm_invited_no_heroes() {
763        let (store, room) = make_room_test_helper(RoomState::Invited);
764        let room_id = room_id!("!test:localhost");
765        let matthew = user_id!("@matthew:example.org");
766        let me = user_id!("@me:example.org");
767        let mut changes = StateChanges::new("".to_owned());
768
769        changes.add_stripped_member(
770            room_id,
771            matthew,
772            make_stripped_member_event(matthew, "Matthew"),
773        );
774        changes.add_stripped_member(room_id, me, make_stripped_member_event(me, "Me"));
775        store.save_changes(&changes).await.unwrap();
776
777        assert_eq!(
778            room.compute_display_name().await.unwrap().into_inner(),
779            RoomDisplayName::Calculated("Matthew".to_owned())
780        );
781    }
782
783    #[async_test]
784    async fn test_display_name_dm_joined() {
785        let (store, room) = make_room_test_helper(RoomState::Joined);
786        let room_id = room_id!("!test:localhost");
787        let matthew = user_id!("@matthew:example.org");
788        let me = user_id!("@me: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(), matthew.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(matthew.into(), f.member(matthew).display_name("Matthew").into());
805        members.insert(me.into(), f.member(me).display_name("Me").into());
806
807        store.save_changes(&changes).await.unwrap();
808
809        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
810        assert_eq!(
811            room.compute_display_name().await.unwrap().into_inner(),
812            RoomDisplayName::Calculated("Matthew".to_owned())
813        );
814    }
815
816    #[async_test]
817    async fn test_display_name_dm_joined_service_members() {
818        let (store, room) = make_room_test_helper(RoomState::Joined);
819        let room_id = room_id!("!test:localhost");
820
821        let matthew = user_id!("@sahasrhala:example.org");
822        let me = user_id!("@me:example.org");
823        let bot = user_id!("@bot:example.org");
824
825        let mut changes = StateChanges::new("".to_owned());
826        let summary = assign!(RumaSummary::new(), {
827            joined_member_count: Some(3u32.into()),
828            heroes: vec![me.to_owned(), matthew.to_owned(), bot.to_owned()],
829        });
830
831        let f = EventFactory::new().room(room_id!("!test:localhost"));
832
833        let members = changes
834            .state
835            .entry(room_id.to_owned())
836            .or_default()
837            .entry(StateEventType::RoomMember)
838            .or_default();
839        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
840        members.insert(me.into(), f.member(me).display_name("Me").into());
841        members.insert(bot.into(), f.member(bot).display_name("Bot").into());
842
843        let member_hints_content =
844            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into();
845        changes
846            .state
847            .entry(room_id.to_owned())
848            .or_default()
849            .entry(StateEventType::MemberHints)
850            .or_default()
851            .insert("".to_owned(), member_hints_content);
852
853        store.save_changes(&changes).await.unwrap();
854
855        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
856        // Bot should not contribute to the display name.
857        assert_eq!(
858            room.compute_display_name().await.unwrap().into_inner(),
859            RoomDisplayName::Calculated("Matthew".to_owned())
860        );
861    }
862
863    #[async_test]
864    async fn test_display_name_dm_joined_alone_with_service_members() {
865        let (store, room) = make_room_test_helper(RoomState::Joined);
866        let room_id = room_id!("!test:localhost");
867
868        let me = user_id!("@me:example.org");
869        let bot = user_id!("@bot:example.org");
870
871        let mut changes = StateChanges::new("".to_owned());
872        let summary = assign!(RumaSummary::new(), {
873            joined_member_count: Some(2u32.into()),
874            heroes: vec![me.to_owned(), bot.to_owned()],
875        });
876
877        let f = EventFactory::new().room(room_id!("!test:localhost"));
878
879        let members = changes
880            .state
881            .entry(room_id.to_owned())
882            .or_default()
883            .entry(StateEventType::RoomMember)
884            .or_default();
885        members.insert(me.into(), f.member(me).display_name("Me").into());
886        members.insert(bot.into(), f.member(bot).display_name("Bot").into());
887
888        let member_hints_content =
889            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into();
890        changes
891            .state
892            .entry(room_id.to_owned())
893            .or_default()
894            .entry(StateEventType::MemberHints)
895            .or_default()
896            .insert("".to_owned(), member_hints_content);
897
898        store.save_changes(&changes).await.unwrap();
899
900        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
901        // Bot should not contribute to the display name.
902        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
903    }
904
905    #[async_test]
906    async fn test_display_name_dm_joined_no_heroes() {
907        let (store, room) = make_room_test_helper(RoomState::Joined);
908        let room_id = room_id!("!test:localhost");
909        let matthew = user_id!("@matthew:example.org");
910        let me = user_id!("@me:example.org");
911        let mut changes = StateChanges::new("".to_owned());
912
913        let f = EventFactory::new().room(room_id!("!test:localhost"));
914
915        let members = changes
916            .state
917            .entry(room_id.to_owned())
918            .or_default()
919            .entry(StateEventType::RoomMember)
920            .or_default();
921        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
922        members.insert(me.into(), f.member(me).display_name("Me").into());
923
924        store.save_changes(&changes).await.unwrap();
925
926        assert_eq!(
927            room.compute_display_name().await.unwrap().into_inner(),
928            RoomDisplayName::Calculated("Matthew".to_owned())
929        );
930    }
931
932    #[async_test]
933    async fn test_display_name_dm_joined_no_heroes_service_members() {
934        let (store, room) = make_room_test_helper(RoomState::Joined);
935        let room_id = room_id!("!test:localhost");
936
937        let matthew = user_id!("@matthew:example.org");
938        let me = user_id!("@me:example.org");
939        let bot = user_id!("@bot:example.org");
940
941        let mut changes = StateChanges::new("".to_owned());
942
943        let f = EventFactory::new().room(room_id!("!test:localhost"));
944
945        let members = changes
946            .state
947            .entry(room_id.to_owned())
948            .or_default()
949            .entry(StateEventType::RoomMember)
950            .or_default();
951        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
952        members.insert(me.into(), f.member(me).display_name("Me").into());
953        members.insert(bot.into(), f.member(bot).display_name("Bot").into());
954
955        let member_hints_content =
956            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into();
957        changes
958            .state
959            .entry(room_id.to_owned())
960            .or_default()
961            .entry(StateEventType::MemberHints)
962            .or_default()
963            .insert("".to_owned(), member_hints_content);
964
965        store.save_changes(&changes).await.unwrap();
966
967        assert_eq!(
968            room.compute_display_name().await.unwrap().into_inner(),
969            RoomDisplayName::Calculated("Matthew".to_owned())
970        );
971    }
972
973    #[async_test]
974    async fn test_display_name_deterministic() {
975        let (store, room) = make_room_test_helper(RoomState::Joined);
976
977        let alice = user_id!("@alice:example.org");
978        let bob = user_id!("@bob:example.org");
979        let carol = user_id!("@carol:example.org");
980        let denis = user_id!("@denis:example.org");
981        let erica = user_id!("@erica:example.org");
982        let fred = user_id!("@fred:example.org");
983        let me = user_id!("@me:example.org");
984
985        let mut changes = StateChanges::new("".to_owned());
986
987        let f = EventFactory::new().room(room_id!("!test:localhost"));
988
989        // Save members in two batches, so that there's no implied ordering in the
990        // store.
991        {
992            let members = changes
993                .state
994                .entry(room.room_id().to_owned())
995                .or_default()
996                .entry(StateEventType::RoomMember)
997                .or_default();
998            members.insert(carol.into(), f.member(carol).display_name("Carol").into());
999            members.insert(bob.into(), f.member(bob).display_name("Bob").into());
1000            members.insert(fred.into(), f.member(fred).display_name("Fred").into());
1001            members.insert(me.into(), f.member(me).display_name("Me").into());
1002            store.save_changes(&changes).await.unwrap();
1003        }
1004
1005        {
1006            let members = changes
1007                .state
1008                .entry(room.room_id().to_owned())
1009                .or_default()
1010                .entry(StateEventType::RoomMember)
1011                .or_default();
1012            members.insert(alice.into(), f.member(alice).display_name("Alice").into());
1013            members.insert(erica.into(), f.member(erica).display_name("Erica").into());
1014            members.insert(denis.into(), f.member(denis).display_name("Denis").into());
1015            store.save_changes(&changes).await.unwrap();
1016        }
1017
1018        let summary = assign!(RumaSummary::new(), {
1019            joined_member_count: Some(7u32.into()),
1020            heroes: vec![denis.to_owned(), carol.to_owned(), bob.to_owned(), erica.to_owned()],
1021        });
1022        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
1023
1024        assert_eq!(
1025            room.compute_display_name().await.unwrap().into_inner(),
1026            RoomDisplayName::Calculated("Bob, Carol, Denis, Erica, and 3 others".to_owned())
1027        );
1028    }
1029
1030    #[test]
1031    fn test_display_name_compute_fields_name_deterministic() {
1032        assert_eq!(
1033            Room::compute_display_name_with_fields(
1034                None,
1035                None,
1036                vec![
1037                    RoomHero {
1038                        user_id: owned_user_id!("@alice:example.org"),
1039                        display_name: Some("Alice".to_owned()),
1040                        avatar_url: None,
1041                    },
1042                    RoomHero {
1043                        user_id: owned_user_id!("@bob:example.org"),
1044                        display_name: Some("Bob".to_owned()),
1045                        avatar_url: None,
1046                    },
1047                    RoomHero {
1048                        user_id: owned_user_id!("@carol:example.org"),
1049                        display_name: Some("Carol".to_owned()),
1050                        avatar_url: None,
1051                    },
1052                    RoomHero {
1053                        user_id: owned_user_id!("@denis:example.org"),
1054                        display_name: Some("Denis".to_owned()),
1055                        avatar_url: None,
1056                    },
1057                    RoomHero {
1058                        user_id: owned_user_id!("@erica:example.org"),
1059                        display_name: Some("Erica".to_owned()),
1060                        avatar_url: None,
1061                    },
1062                ],
1063                1234,
1064            ),
1065            RoomDisplayName::Calculated(
1066                "Alice, Bob, Carol, Denis, Erica, and 1229 others".to_owned()
1067            )
1068        );
1069    }
1070
1071    #[async_test]
1072    async fn test_display_name_deterministic_no_heroes() {
1073        let (store, room) = make_room_test_helper(RoomState::Joined);
1074
1075        let alice = user_id!("@alice:example.org");
1076        let bob = user_id!("@bob:example.org");
1077        let carol = user_id!("@carol:example.org");
1078        let denis = user_id!("@denis:example.org");
1079        let erica = user_id!("@erica:example.org");
1080        let fred = user_id!("@fred:example.org");
1081        let me = user_id!("@me:example.org");
1082
1083        let f = EventFactory::new().room(room_id!("!test:localhost"));
1084
1085        let mut changes = StateChanges::new("".to_owned());
1086
1087        // Save members in two batches, so that there's no implied ordering in the
1088        // store.
1089        {
1090            let members = changes
1091                .state
1092                .entry(room.room_id().to_owned())
1093                .or_default()
1094                .entry(StateEventType::RoomMember)
1095                .or_default();
1096            members.insert(carol.into(), f.member(carol).display_name("Carol").into());
1097            members.insert(bob.into(), f.member(bob).display_name("Bob").into());
1098            members.insert(fred.into(), f.member(fred).display_name("Fred").into());
1099            members.insert(me.into(), f.member(me).display_name("Me").into());
1100
1101            store.save_changes(&changes).await.unwrap();
1102        }
1103
1104        {
1105            let members = changes
1106                .state
1107                .entry(room.room_id().to_owned())
1108                .or_default()
1109                .entry(StateEventType::RoomMember)
1110                .or_default();
1111            members.insert(alice.into(), f.member(alice).display_name("Alice").into());
1112            members.insert(erica.into(), f.member(erica).display_name("Erica").into());
1113            members.insert(denis.into(), f.member(denis).display_name("Denis").into());
1114            store.save_changes(&changes).await.unwrap();
1115        }
1116
1117        assert_eq!(
1118            room.compute_display_name().await.unwrap().into_inner(),
1119            RoomDisplayName::Calculated("Alice, Bob, Carol, Denis, Erica, and 2 others".to_owned())
1120        );
1121    }
1122
1123    #[async_test]
1124    async fn test_display_name_dm_alone() {
1125        let (store, room) = make_room_test_helper(RoomState::Joined);
1126        let room_id = room_id!("!test:localhost");
1127        let matthew = user_id!("@matthew:example.org");
1128        let me = user_id!("@me:example.org");
1129        let mut changes = StateChanges::new("".to_owned());
1130        let summary = assign!(RumaSummary::new(), {
1131            joined_member_count: Some(1u32.into()),
1132            heroes: vec![me.to_owned(), matthew.to_owned()],
1133        });
1134
1135        let f = EventFactory::new().room(room_id!("!test:localhost"));
1136
1137        let members = changes
1138            .state
1139            .entry(room_id.to_owned())
1140            .or_default()
1141            .entry(StateEventType::RoomMember)
1142            .or_default();
1143        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
1144        members.insert(me.into(), f.member(me).display_name("Me").into());
1145
1146        store.save_changes(&changes).await.unwrap();
1147
1148        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
1149        assert_eq!(
1150            room.compute_display_name().await.unwrap().into_inner(),
1151            RoomDisplayName::EmptyWas("Matthew".to_owned())
1152        );
1153    }
1154
1155    #[test]
1156    fn test_calculate_room_name() {
1157        let mut actual = compute_display_name_from_heroes(2, vec!["a"]);
1158        assert_eq!(RoomDisplayName::Calculated("a".to_owned()), actual);
1159
1160        actual = compute_display_name_from_heroes(3, vec!["a", "b"]);
1161        assert_eq!(RoomDisplayName::Calculated("a, b".to_owned()), actual);
1162
1163        actual = compute_display_name_from_heroes(4, vec!["a", "b", "c"]);
1164        assert_eq!(RoomDisplayName::Calculated("a, b, c".to_owned()), actual);
1165
1166        actual = compute_display_name_from_heroes(5, vec!["a", "b", "c"]);
1167        assert_eq!(RoomDisplayName::Calculated("a, b, c, and 2 others".to_owned()), actual);
1168
1169        actual = compute_display_name_from_heroes(5, vec![]);
1170        assert_eq!(RoomDisplayName::Calculated("5 people".to_owned()), actual);
1171
1172        actual = compute_display_name_from_heroes(0, vec![]);
1173        assert_eq!(RoomDisplayName::Empty, actual);
1174
1175        actual = compute_display_name_from_heroes(1, vec![]);
1176        assert_eq!(RoomDisplayName::Empty, actual);
1177
1178        actual = compute_display_name_from_heroes(1, vec!["a"]);
1179        assert_eq!(RoomDisplayName::EmptyWas("a".to_owned()), actual);
1180
1181        actual = compute_display_name_from_heroes(1, vec!["a", "b"]);
1182        assert_eq!(RoomDisplayName::EmptyWas("a, b".to_owned()), actual);
1183
1184        actual = compute_display_name_from_heroes(1, vec!["a", "b", "c"]);
1185        assert_eq!(RoomDisplayName::EmptyWas("a, b, c".to_owned()), actual);
1186    }
1187
1188    #[test]
1189    fn test_room_alias_from_room_display_name_lowercases() {
1190        assert_eq!(
1191            "roomalias",
1192            RoomDisplayName::Named("RoomAlias".to_owned()).to_room_alias_name()
1193        );
1194    }
1195
1196    #[test]
1197    fn test_room_alias_from_room_display_name_removes_whitespace() {
1198        assert_eq!(
1199            "room-alias",
1200            RoomDisplayName::Named("Room Alias".to_owned()).to_room_alias_name()
1201        );
1202    }
1203
1204    #[test]
1205    fn test_room_alias_from_room_display_name_removes_non_ascii_symbols() {
1206        assert_eq!(
1207            "roomalias",
1208            RoomDisplayName::Named("Room±Alias√".to_owned()).to_room_alias_name()
1209        );
1210    }
1211
1212    #[test]
1213    fn test_room_alias_from_room_display_name_removes_invalid_ascii_symbols() {
1214        assert_eq!(
1215            "roomalias",
1216            RoomDisplayName::Named("#Room,{Alias}:".to_owned()).to_room_alias_name()
1217        );
1218    }
1219}