matrix_sdk_base/room/
display_name.rs

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