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, StateStore,
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    /// The number of active (joined/invited) service members in the room, if
382    /// known.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub active_service_members: Option<u64>,
385}
386
387#[cfg(test)]
388impl RoomSummary {
389    pub(crate) fn heroes(&self) -> &[RoomHero] {
390        &self.room_heroes
391    }
392}
393
394/// Information about a member considered to be a room hero.
395#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
396pub struct RoomHero {
397    /// The user id of the hero.
398    pub user_id: OwnedUserId,
399    /// The display name of the hero.
400    pub display_name: Option<String>,
401    /// The avatar url of the hero.
402    pub avatar_url: Option<OwnedMxcUri>,
403}
404
405/// The number of heroes chosen to compute a room's name, if the room didn't
406/// have a name set by the users themselves.
407///
408/// A server must return at most 5 heroes, according to the paragraph below
409/// https://spec.matrix.org/v1.10/client-server-api/#get_matrixclientv3sync (grep for "heroes"). We
410/// try to behave similarly here.
411const NUM_HEROES: usize = 5;
412
413/// The name of the room, either from the metadata or calculated
414/// according to [matrix specification](https://spec.matrix.org/latest/client-server-api/#calculating-the-display-name-for-a-room)
415#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
416pub enum RoomDisplayName {
417    /// The room has been named explicitly as
418    Named(String),
419    /// The room has a canonical alias that should be used
420    Aliased(String),
421    /// The room has not given an explicit name but a name could be
422    /// calculated
423    Calculated(String),
424    /// The room doesn't have a name right now, but used to have one
425    /// e.g. because it was a DM and everyone has left the room
426    EmptyWas(String),
427    /// No useful name could be calculated or ever found
428    Empty,
429}
430
431/// An internal representing whether a room display name is new or not when
432/// computed.
433pub(crate) enum UpdatedRoomDisplayName {
434    New(RoomDisplayName),
435    Same(RoomDisplayName),
436}
437
438impl UpdatedRoomDisplayName {
439    /// Get the inner [`RoomDisplayName`].
440    pub fn into_inner(self) -> RoomDisplayName {
441        match self {
442            UpdatedRoomDisplayName::New(room_display_name) => room_display_name,
443            UpdatedRoomDisplayName::Same(room_display_name) => room_display_name,
444        }
445    }
446}
447
448const WHITESPACE_REGEX: &str = r"\s+";
449const INVALID_SYMBOLS_REGEX: &str = r"[#,:\{\}\\]+";
450
451impl RoomDisplayName {
452    /// Transforms the current display name into the name part of a
453    /// `RoomAliasId`.
454    pub fn to_room_alias_name(&self) -> String {
455        let room_name = match self {
456            Self::Named(name) => name,
457            Self::Aliased(name) => name,
458            Self::Calculated(name) => name,
459            Self::EmptyWas(name) => name,
460            Self::Empty => "",
461        };
462
463        let whitespace_regex =
464            Regex::new(WHITESPACE_REGEX).expect("`WHITESPACE_REGEX` should be valid");
465        let symbol_regex =
466            Regex::new(INVALID_SYMBOLS_REGEX).expect("`INVALID_SYMBOLS_REGEX` should be valid");
467
468        // Replace whitespaces with `-`
469        let sanitised = whitespace_regex.replace_all(room_name, "-");
470        // Remove non-ASCII characters and ASCII control characters
471        let sanitised =
472            String::from_iter(sanitised.chars().filter(|c| c.is_ascii() && !c.is_ascii_control()));
473        // Remove other problematic ASCII symbols
474        let sanitised = symbol_regex.replace_all(&sanitised, "");
475        // Lowercased
476        sanitised.to_lowercase()
477    }
478}
479
480impl fmt::Display for RoomDisplayName {
481    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482        match self {
483            RoomDisplayName::Named(s)
484            | RoomDisplayName::Calculated(s)
485            | RoomDisplayName::Aliased(s) => {
486                write!(f, "{s}")
487            }
488            RoomDisplayName::EmptyWas(s) => write!(f, "Empty Room (was {s})"),
489            RoomDisplayName::Empty => write!(f, "Empty Room"),
490        }
491    }
492}
493
494/// Calculate room name according to step 3 of the [naming algorithm].
495///
496/// [naming algorithm]: https://spec.matrix.org/latest/client-server-api/#calculating-the-display-name-for-a-room
497fn compute_display_name_from_heroes(
498    num_joined_invited: u64,
499    mut heroes: Vec<&str>,
500) -> RoomDisplayName {
501    let num_heroes = heroes.len() as u64;
502    let num_joined_invited_except_self = num_joined_invited.saturating_sub(1);
503
504    // Stabilize ordering.
505    heroes.sort_unstable();
506
507    let names = if num_heroes == 0 && num_joined_invited > 1 {
508        format!("{num_joined_invited} people")
509    } else if num_heroes >= num_joined_invited_except_self {
510        heroes.join(", ")
511    } else if num_heroes < num_joined_invited_except_self && num_joined_invited > 1 {
512        // TODO: What length does the spec want us to use here and in
513        // the `else`?
514        format!("{}, and {} others", heroes.join(", "), (num_joined_invited - num_heroes))
515    } else {
516        "".to_owned()
517    };
518
519    // User is alone.
520    if num_joined_invited <= 1 {
521        if names.is_empty() { RoomDisplayName::Empty } else { RoomDisplayName::EmptyWas(names) }
522    } else {
523        RoomDisplayName::Calculated(names)
524    }
525}
526
527/// A filter to remove our own user and the users specified in the member hints
528/// state event, so called service members, from the list of heroes.
529///
530/// The heroes will then be used to calculate a display name for the room if one
531/// wasn't explicitly defined.
532fn heroes_filter<'a>(
533    own_user_id: &'a UserId,
534    member_hints: &'a MemberHintsEventContent,
535) -> impl Fn(&UserId) -> bool + use<'a> {
536    move |user_id| user_id != own_user_id && !member_hints.service_members.contains(user_id)
537}
538
539#[cfg(test)]
540mod tests {
541    use std::{collections::BTreeSet, sync::Arc};
542
543    use matrix_sdk_test::{async_test, event_factory::EventFactory};
544    use ruma::{
545        UserId,
546        api::client::sync::sync_events::v3::RoomSummary as RumaSummary,
547        assign,
548        events::{
549            StateEventType,
550            room::{
551                canonical_alias::{
552                    PossiblyRedactedRoomCanonicalAliasEventContent, RoomCanonicalAliasEventContent,
553                },
554                member::{MembershipState, RoomMemberEventContent, StrippedRoomMemberEvent},
555                name::{PossiblyRedactedRoomNameEventContent, RoomNameEventContent},
556            },
557        },
558        owned_room_alias_id, owned_user_id, room_alias_id, room_id,
559        serde::Raw,
560        user_id,
561    };
562    use serde_json::json;
563
564    use super::{Room, RoomDisplayName, compute_display_name_from_heroes};
565    use crate::{
566        MinimalStateEvent, RoomHero, RoomState, StateChanges, StateStore,
567        store::{MemoryStore, SaveLockedStateStore},
568    };
569
570    fn make_room_test_helper(room_type: RoomState) -> (Arc<MemoryStore>, Room) {
571        let store = Arc::new(MemoryStore::new());
572        let user_id = user_id!("@me:example.org");
573        let room_id = room_id!("!test:localhost");
574        let (sender, _receiver) = tokio::sync::broadcast::channel(1);
575
576        (
577            store.clone(),
578            Room::new(user_id, SaveLockedStateStore::new(store), room_id, room_type, sender),
579        )
580    }
581
582    fn make_stripped_member_event(user_id: &UserId, name: &str) -> Raw<StrippedRoomMemberEvent> {
583        let ev_json = json!({
584            "type": "m.room.member",
585            "content": assign!(RoomMemberEventContent::new(MembershipState::Join), {
586                displayname: Some(name.to_owned())
587            }),
588            "sender": user_id,
589            "state_key": user_id,
590        });
591
592        Raw::new(&ev_json).unwrap().cast_unchecked()
593    }
594
595    fn make_canonical_alias_event() -> MinimalStateEvent<RoomCanonicalAliasEventContent> {
596        MinimalStateEvent {
597            content: assign!(PossiblyRedactedRoomCanonicalAliasEventContent::new(), {
598                alias: Some(owned_room_alias_id!("#test:example.com")),
599            }),
600            event_id: None,
601        }
602    }
603
604    fn make_name_event_with(name: &str) -> MinimalStateEvent<PossiblyRedactedRoomNameEventContent> {
605        MinimalStateEvent {
606            content: RoomNameEventContent::new(name.to_owned()).into(),
607            event_id: None,
608        }
609    }
610
611    fn make_name_event() -> MinimalStateEvent<PossiblyRedactedRoomNameEventContent> {
612        make_name_event_with("Test Room")
613    }
614
615    #[async_test]
616    async fn test_display_name_for_joined_room_is_empty_if_no_info() {
617        let (_, room) = make_room_test_helper(RoomState::Joined);
618        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
619    }
620
621    #[test]
622    fn test_display_name_compute_fields_empty() {
623        assert_eq!(
624            Room::compute_display_name_with_fields(None, None, vec![], 0),
625            RoomDisplayName::Empty
626        );
627    }
628
629    #[async_test]
630    async fn test_display_name_for_joined_room_is_empty_if_name_empty() {
631        let (_, room) = make_room_test_helper(RoomState::Joined);
632        room.info.update(|info| info.base_info.name = Some(make_name_event_with("")));
633
634        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
635    }
636
637    #[test]
638    fn test_display_name_compute_fields_empty_if_name_empty() {
639        assert_eq!(
640            Room::compute_display_name_with_fields(Some("".to_owned()), None, vec![], 0),
641            RoomDisplayName::Empty
642        );
643    }
644
645    #[async_test]
646    async fn test_display_name_for_joined_room_uses_canonical_alias_if_available() {
647        let (_, room) = make_room_test_helper(RoomState::Joined);
648        room.info
649            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
650        assert_eq!(
651            room.compute_display_name().await.unwrap().into_inner(),
652            RoomDisplayName::Aliased("test".to_owned())
653        );
654    }
655
656    #[test]
657    fn test_display_name_compute_fields_alias() {
658        assert_eq!(
659            Room::compute_display_name_with_fields(
660                None,
661                Some(room_alias_id!("#test:example.com")),
662                vec![],
663                0,
664            ),
665            RoomDisplayName::Aliased("test".to_owned())
666        );
667    }
668
669    #[async_test]
670    async fn test_display_name_for_joined_room_prefers_name_over_alias() {
671        let (_, room) = make_room_test_helper(RoomState::Joined);
672        room.info
673            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
674        assert_eq!(
675            room.compute_display_name().await.unwrap().into_inner(),
676            RoomDisplayName::Aliased("test".to_owned())
677        );
678        room.info.update(|info| info.base_info.name = Some(make_name_event()));
679        // Display name wasn't cached when we asked for it above, and name overrides
680        assert_eq!(
681            room.compute_display_name().await.unwrap().into_inner(),
682            RoomDisplayName::Named("Test Room".to_owned())
683        );
684    }
685
686    #[test]
687    fn test_display_name_compute_fields_name_over_alias() {
688        assert_eq!(
689            Room::compute_display_name_with_fields(
690                Some("Test Room".to_owned()),
691                Some(room_alias_id!("#test:example.com")),
692                vec![],
693                0
694            ),
695            RoomDisplayName::Named("Test Room".to_owned())
696        );
697    }
698
699    #[async_test]
700    async fn test_display_name_for_invited_room_is_empty_if_no_info() {
701        let (_, room) = make_room_test_helper(RoomState::Invited);
702        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
703    }
704
705    #[async_test]
706    async fn test_display_name_for_invited_room_is_empty_if_room_name_empty() {
707        let (_, room) = make_room_test_helper(RoomState::Invited);
708
709        let room_name = make_name_event_with("");
710        room.info.update(|info| info.base_info.name = Some(room_name));
711
712        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
713    }
714
715    #[async_test]
716    async fn test_display_name_for_invited_room_uses_canonical_alias_if_available() {
717        let (_, room) = make_room_test_helper(RoomState::Invited);
718        room.info
719            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
720        assert_eq!(
721            room.compute_display_name().await.unwrap().into_inner(),
722            RoomDisplayName::Aliased("test".to_owned())
723        );
724    }
725
726    #[async_test]
727    async fn test_display_name_for_invited_room_prefers_name_over_alias() {
728        let (_, room) = make_room_test_helper(RoomState::Invited);
729        room.info
730            .update(|info| info.base_info.canonical_alias = Some(make_canonical_alias_event()));
731        assert_eq!(
732            room.compute_display_name().await.unwrap().into_inner(),
733            RoomDisplayName::Aliased("test".to_owned())
734        );
735        room.info.update(|info| info.base_info.name = Some(make_name_event()));
736        // Display name wasn't cached when we asked for it above, and name overrides
737        assert_eq!(
738            room.compute_display_name().await.unwrap().into_inner(),
739            RoomDisplayName::Named("Test Room".to_owned())
740        );
741    }
742
743    #[async_test]
744    async fn test_display_name_dm_invited() {
745        let (store, room) = make_room_test_helper(RoomState::Invited);
746        let room_id = room_id!("!test:localhost");
747        let matthew = user_id!("@matthew:example.org");
748        let me = user_id!("@me:example.org");
749        let mut changes = StateChanges::new("".to_owned());
750        let summary = assign!(RumaSummary::new(), {
751            heroes: vec![me.to_owned(), matthew.to_owned()],
752        });
753
754        changes.add_stripped_member(
755            room_id,
756            matthew,
757            make_stripped_member_event(matthew, "Matthew"),
758        );
759        changes.add_stripped_member(room_id, me, make_stripped_member_event(me, "Me"));
760        store.save_changes(&changes).await.unwrap();
761
762        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
763        assert_eq!(
764            room.compute_display_name().await.unwrap().into_inner(),
765            RoomDisplayName::Calculated("Matthew".to_owned())
766        );
767    }
768
769    #[async_test]
770    async fn test_display_name_dm_invited_no_heroes() {
771        let (store, room) = make_room_test_helper(RoomState::Invited);
772        let room_id = room_id!("!test:localhost");
773        let matthew = user_id!("@matthew:example.org");
774        let me = user_id!("@me:example.org");
775        let mut changes = StateChanges::new("".to_owned());
776
777        changes.add_stripped_member(
778            room_id,
779            matthew,
780            make_stripped_member_event(matthew, "Matthew"),
781        );
782        changes.add_stripped_member(room_id, me, make_stripped_member_event(me, "Me"));
783        store.save_changes(&changes).await.unwrap();
784
785        assert_eq!(
786            room.compute_display_name().await.unwrap().into_inner(),
787            RoomDisplayName::Calculated("Matthew".to_owned())
788        );
789    }
790
791    #[async_test]
792    async fn test_display_name_dm_joined() {
793        let (store, room) = make_room_test_helper(RoomState::Joined);
794        let room_id = room_id!("!test:localhost");
795        let matthew = user_id!("@matthew:example.org");
796        let me = user_id!("@me:example.org");
797
798        let mut changes = StateChanges::new("".to_owned());
799        let summary = assign!(RumaSummary::new(), {
800            joined_member_count: Some(2u32.into()),
801            heroes: vec![me.to_owned(), matthew.to_owned()],
802        });
803
804        let f = EventFactory::new().room(room_id!("!test:localhost"));
805
806        let members = changes
807            .state
808            .entry(room_id.to_owned())
809            .or_default()
810            .entry(StateEventType::RoomMember)
811            .or_default();
812        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
813        members.insert(me.into(), f.member(me).display_name("Me").into());
814
815        store.save_changes(&changes).await.unwrap();
816
817        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
818        assert_eq!(
819            room.compute_display_name().await.unwrap().into_inner(),
820            RoomDisplayName::Calculated("Matthew".to_owned())
821        );
822    }
823
824    #[async_test]
825    async fn test_display_name_dm_joined_service_members() {
826        let (store, room) = make_room_test_helper(RoomState::Joined);
827        let room_id = room_id!("!test:localhost");
828
829        let matthew = user_id!("@sahasrhala:example.org");
830        let me = user_id!("@me:example.org");
831        let bot = user_id!("@bot:example.org");
832
833        let mut changes = StateChanges::new("".to_owned());
834        let summary = assign!(RumaSummary::new(), {
835            joined_member_count: Some(3u32.into()),
836            heroes: vec![me.to_owned(), matthew.to_owned(), bot.to_owned()],
837        });
838
839        let f = EventFactory::new().room(room_id!("!test:localhost"));
840
841        let members = changes
842            .state
843            .entry(room_id.to_owned())
844            .or_default()
845            .entry(StateEventType::RoomMember)
846            .or_default();
847        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
848        members.insert(me.into(), f.member(me).display_name("Me").into());
849        members.insert(bot.into(), f.member(bot).display_name("Bot").into());
850
851        let member_hints_content =
852            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into();
853        changes
854            .state
855            .entry(room_id.to_owned())
856            .or_default()
857            .entry(StateEventType::MemberHints)
858            .or_default()
859            .insert("".to_owned(), member_hints_content);
860
861        store.save_changes(&changes).await.unwrap();
862
863        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
864        // Bot should not contribute to the display name.
865        assert_eq!(
866            room.compute_display_name().await.unwrap().into_inner(),
867            RoomDisplayName::Calculated("Matthew".to_owned())
868        );
869    }
870
871    #[async_test]
872    async fn test_display_name_dm_joined_alone_with_service_members() {
873        let (store, room) = make_room_test_helper(RoomState::Joined);
874        let room_id = room_id!("!test:localhost");
875
876        let me = user_id!("@me:example.org");
877        let bot = user_id!("@bot:example.org");
878
879        let mut changes = StateChanges::new("".to_owned());
880        let summary = assign!(RumaSummary::new(), {
881            joined_member_count: Some(2u32.into()),
882            heroes: vec![me.to_owned(), bot.to_owned()],
883        });
884
885        let f = EventFactory::new().room(room_id!("!test:localhost"));
886
887        let members = changes
888            .state
889            .entry(room_id.to_owned())
890            .or_default()
891            .entry(StateEventType::RoomMember)
892            .or_default();
893        members.insert(me.into(), f.member(me).display_name("Me").into());
894        members.insert(bot.into(), f.member(bot).display_name("Bot").into());
895
896        let member_hints_content =
897            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into();
898        changes
899            .state
900            .entry(room_id.to_owned())
901            .or_default()
902            .entry(StateEventType::MemberHints)
903            .or_default()
904            .insert("".to_owned(), member_hints_content);
905
906        store.save_changes(&changes).await.unwrap();
907
908        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
909        // Bot should not contribute to the display name.
910        assert_eq!(room.compute_display_name().await.unwrap().into_inner(), RoomDisplayName::Empty);
911    }
912
913    #[async_test]
914    async fn test_display_name_dm_joined_no_heroes() {
915        let (store, room) = make_room_test_helper(RoomState::Joined);
916        let room_id = room_id!("!test:localhost");
917        let matthew = user_id!("@matthew:example.org");
918        let me = user_id!("@me:example.org");
919        let mut changes = StateChanges::new("".to_owned());
920
921        let f = EventFactory::new().room(room_id!("!test:localhost"));
922
923        let members = changes
924            .state
925            .entry(room_id.to_owned())
926            .or_default()
927            .entry(StateEventType::RoomMember)
928            .or_default();
929        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
930        members.insert(me.into(), f.member(me).display_name("Me").into());
931
932        store.save_changes(&changes).await.unwrap();
933
934        assert_eq!(
935            room.compute_display_name().await.unwrap().into_inner(),
936            RoomDisplayName::Calculated("Matthew".to_owned())
937        );
938    }
939
940    #[async_test]
941    async fn test_display_name_dm_joined_no_heroes_service_members() {
942        let (store, room) = make_room_test_helper(RoomState::Joined);
943        let room_id = room_id!("!test:localhost");
944
945        let matthew = user_id!("@matthew:example.org");
946        let me = user_id!("@me:example.org");
947        let bot = user_id!("@bot:example.org");
948
949        let mut changes = StateChanges::new("".to_owned());
950
951        let f = EventFactory::new().room(room_id!("!test:localhost"));
952
953        let members = changes
954            .state
955            .entry(room_id.to_owned())
956            .or_default()
957            .entry(StateEventType::RoomMember)
958            .or_default();
959        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
960        members.insert(me.into(), f.member(me).display_name("Me").into());
961        members.insert(bot.into(), f.member(bot).display_name("Bot").into());
962
963        let member_hints_content =
964            f.member_hints(BTreeSet::from([bot.to_owned()])).sender(me).into();
965        changes
966            .state
967            .entry(room_id.to_owned())
968            .or_default()
969            .entry(StateEventType::MemberHints)
970            .or_default()
971            .insert("".to_owned(), member_hints_content);
972
973        store.save_changes(&changes).await.unwrap();
974
975        assert_eq!(
976            room.compute_display_name().await.unwrap().into_inner(),
977            RoomDisplayName::Calculated("Matthew".to_owned())
978        );
979    }
980
981    #[async_test]
982    async fn test_display_name_deterministic() {
983        let (store, room) = make_room_test_helper(RoomState::Joined);
984
985        let alice = user_id!("@alice:example.org");
986        let bob = user_id!("@bob:example.org");
987        let carol = user_id!("@carol:example.org");
988        let denis = user_id!("@denis:example.org");
989        let erica = user_id!("@erica:example.org");
990        let fred = user_id!("@fred:example.org");
991        let me = user_id!("@me:example.org");
992
993        let mut changes = StateChanges::new("".to_owned());
994
995        let f = EventFactory::new().room(room_id!("!test:localhost"));
996
997        // Save members in two batches, so that there's no implied ordering in the
998        // store.
999        {
1000            let members = changes
1001                .state
1002                .entry(room.room_id().to_owned())
1003                .or_default()
1004                .entry(StateEventType::RoomMember)
1005                .or_default();
1006            members.insert(carol.into(), f.member(carol).display_name("Carol").into());
1007            members.insert(bob.into(), f.member(bob).display_name("Bob").into());
1008            members.insert(fred.into(), f.member(fred).display_name("Fred").into());
1009            members.insert(me.into(), f.member(me).display_name("Me").into());
1010            store.save_changes(&changes).await.unwrap();
1011        }
1012
1013        {
1014            let members = changes
1015                .state
1016                .entry(room.room_id().to_owned())
1017                .or_default()
1018                .entry(StateEventType::RoomMember)
1019                .or_default();
1020            members.insert(alice.into(), f.member(alice).display_name("Alice").into());
1021            members.insert(erica.into(), f.member(erica).display_name("Erica").into());
1022            members.insert(denis.into(), f.member(denis).display_name("Denis").into());
1023            store.save_changes(&changes).await.unwrap();
1024        }
1025
1026        let summary = assign!(RumaSummary::new(), {
1027            joined_member_count: Some(7u32.into()),
1028            heroes: vec![denis.to_owned(), carol.to_owned(), bob.to_owned(), erica.to_owned()],
1029        });
1030        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
1031
1032        assert_eq!(
1033            room.compute_display_name().await.unwrap().into_inner(),
1034            RoomDisplayName::Calculated("Bob, Carol, Denis, Erica, and 3 others".to_owned())
1035        );
1036    }
1037
1038    #[test]
1039    fn test_display_name_compute_fields_name_deterministic() {
1040        assert_eq!(
1041            Room::compute_display_name_with_fields(
1042                None,
1043                None,
1044                vec![
1045                    RoomHero {
1046                        user_id: owned_user_id!("@alice:example.org"),
1047                        display_name: Some("Alice".to_owned()),
1048                        avatar_url: None,
1049                    },
1050                    RoomHero {
1051                        user_id: owned_user_id!("@bob:example.org"),
1052                        display_name: Some("Bob".to_owned()),
1053                        avatar_url: None,
1054                    },
1055                    RoomHero {
1056                        user_id: owned_user_id!("@carol:example.org"),
1057                        display_name: Some("Carol".to_owned()),
1058                        avatar_url: None,
1059                    },
1060                    RoomHero {
1061                        user_id: owned_user_id!("@denis:example.org"),
1062                        display_name: Some("Denis".to_owned()),
1063                        avatar_url: None,
1064                    },
1065                    RoomHero {
1066                        user_id: owned_user_id!("@erica:example.org"),
1067                        display_name: Some("Erica".to_owned()),
1068                        avatar_url: None,
1069                    },
1070                ],
1071                1234,
1072            ),
1073            RoomDisplayName::Calculated(
1074                "Alice, Bob, Carol, Denis, Erica, and 1229 others".to_owned()
1075            )
1076        );
1077    }
1078
1079    #[async_test]
1080    async fn test_display_name_deterministic_no_heroes() {
1081        let (store, room) = make_room_test_helper(RoomState::Joined);
1082
1083        let alice = user_id!("@alice:example.org");
1084        let bob = user_id!("@bob:example.org");
1085        let carol = user_id!("@carol:example.org");
1086        let denis = user_id!("@denis:example.org");
1087        let erica = user_id!("@erica:example.org");
1088        let fred = user_id!("@fred:example.org");
1089        let me = user_id!("@me:example.org");
1090
1091        let f = EventFactory::new().room(room_id!("!test:localhost"));
1092
1093        let mut changes = StateChanges::new("".to_owned());
1094
1095        // Save members in two batches, so that there's no implied ordering in the
1096        // store.
1097        {
1098            let members = changes
1099                .state
1100                .entry(room.room_id().to_owned())
1101                .or_default()
1102                .entry(StateEventType::RoomMember)
1103                .or_default();
1104            members.insert(carol.into(), f.member(carol).display_name("Carol").into());
1105            members.insert(bob.into(), f.member(bob).display_name("Bob").into());
1106            members.insert(fred.into(), f.member(fred).display_name("Fred").into());
1107            members.insert(me.into(), f.member(me).display_name("Me").into());
1108
1109            store.save_changes(&changes).await.unwrap();
1110        }
1111
1112        {
1113            let members = changes
1114                .state
1115                .entry(room.room_id().to_owned())
1116                .or_default()
1117                .entry(StateEventType::RoomMember)
1118                .or_default();
1119            members.insert(alice.into(), f.member(alice).display_name("Alice").into());
1120            members.insert(erica.into(), f.member(erica).display_name("Erica").into());
1121            members.insert(denis.into(), f.member(denis).display_name("Denis").into());
1122            store.save_changes(&changes).await.unwrap();
1123        }
1124
1125        assert_eq!(
1126            room.compute_display_name().await.unwrap().into_inner(),
1127            RoomDisplayName::Calculated("Alice, Bob, Carol, Denis, Erica, and 2 others".to_owned())
1128        );
1129    }
1130
1131    #[async_test]
1132    async fn test_display_name_dm_alone() {
1133        let (store, room) = make_room_test_helper(RoomState::Joined);
1134        let room_id = room_id!("!test:localhost");
1135        let matthew = user_id!("@matthew:example.org");
1136        let me = user_id!("@me:example.org");
1137        let mut changes = StateChanges::new("".to_owned());
1138        let summary = assign!(RumaSummary::new(), {
1139            joined_member_count: Some(1u32.into()),
1140            heroes: vec![me.to_owned(), matthew.to_owned()],
1141        });
1142
1143        let f = EventFactory::new().room(room_id!("!test:localhost"));
1144
1145        let members = changes
1146            .state
1147            .entry(room_id.to_owned())
1148            .or_default()
1149            .entry(StateEventType::RoomMember)
1150            .or_default();
1151        members.insert(matthew.into(), f.member(matthew).display_name("Matthew").into());
1152        members.insert(me.into(), f.member(me).display_name("Me").into());
1153
1154        store.save_changes(&changes).await.unwrap();
1155
1156        room.info.update_if(|info| info.update_from_ruma_summary(&summary));
1157        assert_eq!(
1158            room.compute_display_name().await.unwrap().into_inner(),
1159            RoomDisplayName::EmptyWas("Matthew".to_owned())
1160        );
1161    }
1162
1163    #[test]
1164    fn test_calculate_room_name() {
1165        let mut actual = compute_display_name_from_heroes(2, vec!["a"]);
1166        assert_eq!(RoomDisplayName::Calculated("a".to_owned()), actual);
1167
1168        actual = compute_display_name_from_heroes(3, vec!["a", "b"]);
1169        assert_eq!(RoomDisplayName::Calculated("a, b".to_owned()), actual);
1170
1171        actual = compute_display_name_from_heroes(4, vec!["a", "b", "c"]);
1172        assert_eq!(RoomDisplayName::Calculated("a, b, c".to_owned()), actual);
1173
1174        actual = compute_display_name_from_heroes(5, vec!["a", "b", "c"]);
1175        assert_eq!(RoomDisplayName::Calculated("a, b, c, and 2 others".to_owned()), actual);
1176
1177        actual = compute_display_name_from_heroes(5, vec![]);
1178        assert_eq!(RoomDisplayName::Calculated("5 people".to_owned()), actual);
1179
1180        actual = compute_display_name_from_heroes(0, vec![]);
1181        assert_eq!(RoomDisplayName::Empty, actual);
1182
1183        actual = compute_display_name_from_heroes(1, vec![]);
1184        assert_eq!(RoomDisplayName::Empty, actual);
1185
1186        actual = compute_display_name_from_heroes(1, vec!["a"]);
1187        assert_eq!(RoomDisplayName::EmptyWas("a".to_owned()), actual);
1188
1189        actual = compute_display_name_from_heroes(1, vec!["a", "b"]);
1190        assert_eq!(RoomDisplayName::EmptyWas("a, b".to_owned()), actual);
1191
1192        actual = compute_display_name_from_heroes(1, vec!["a", "b", "c"]);
1193        assert_eq!(RoomDisplayName::EmptyWas("a, b, c".to_owned()), actual);
1194    }
1195
1196    #[test]
1197    fn test_room_alias_from_room_display_name_lowercases() {
1198        assert_eq!(
1199            "roomalias",
1200            RoomDisplayName::Named("RoomAlias".to_owned()).to_room_alias_name()
1201        );
1202    }
1203
1204    #[test]
1205    fn test_room_alias_from_room_display_name_removes_whitespace() {
1206        assert_eq!(
1207            "room-alias",
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_non_ascii_symbols() {
1214        assert_eq!(
1215            "roomalias",
1216            RoomDisplayName::Named("Room±Alias√".to_owned()).to_room_alias_name()
1217        );
1218    }
1219
1220    #[test]
1221    fn test_room_alias_from_room_display_name_removes_invalid_ascii_symbols() {
1222        assert_eq!(
1223            "roomalias",
1224            RoomDisplayName::Named("#Room,{Alias}:".to_owned()).to_room_alias_name()
1225        );
1226    }
1227}