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