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