[fractal/fractal-next] room-details: Implement user invitiation



commit a2fd4de50102efdb6fab1127a7eb2f82aaae4bbd
Author: Julian Sparber <julian sparber net>
Date:   Mon Dec 6 11:40:47 2021 +0100

    room-details: Implement user invitiation

 data/resources/resources.gresource.xml             |   3 +
 data/resources/style.css                           |   2 +-
 data/resources/ui/content-invite-subpage.ui        | 152 ++++++++
 data/resources/ui/content-invitee-item.ui          |  15 +
 data/resources/ui/content-invitee-row.ui           |  68 ++++
 data/resources/ui/content-member-page.ui           |   2 -
 data/resources/ui/pill.ui                          |   1 -
 po/POTFILES.in                                     |   4 +
 src/meson.build                                    |   4 +
 .../content/room_details/invite_subpage/invitee.rs | 136 ++++++++
 .../room_details/invite_subpage/invitee_list.rs    | 386 +++++++++++++++++++++
 .../room_details/invite_subpage/invitee_row.rs     | 117 +++++++
 .../content/room_details/invite_subpage/mod.rs     | 344 ++++++++++++++++++
 src/session/content/room_details/member_page.rs    |  14 +-
 src/session/content/room_details/mod.rs            |  15 +-
 src/session/room/mod.rs                            |  60 ++++
 16 files changed, 1317 insertions(+), 6 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 8dd8a9e9..8840f519 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -13,6 +13,9 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-file.ui">ui/content-message-file.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-page.ui">ui/content-member-page.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-row.ui">ui/content-member-row.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-invite-subpage.ui">ui/content-invite-subpage.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-invitee-item.ui">ui/content-invitee-item.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-invitee-row.ui">ui/content-invitee-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-row.ui">ui/content-message-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-divider-row.ui">ui/content-divider-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-room-details.ui">ui/content-room-details.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index 07a5249d..23b12be7 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -237,7 +237,7 @@ headerbar.flat {
   color: @theme_text_color;
 }
 
-.message-entry .view {
+.view {
   padding: 7px 0;
 }
 
diff --git a/data/resources/ui/content-invite-subpage.ui b/data/resources/ui/content-invite-subpage.ui
new file mode 100644
index 00000000..1bc2c339
--- /dev/null
+++ b/data/resources/ui/content-invite-subpage.ui
@@ -0,0 +1,152 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentInviteSubpage" parent="AdwBin">
+    <property name="child">
+      <object class="GtkBox">
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="GtkHeaderBar">
+            <property name="show-title-buttons">false</property>
+            <child type="start">
+              <object class="GtkButton" id="cancel_button">
+                <property name="label" translatable="yes">_Cancel</property>
+                <property name="use_underline">True</property>
+              </object>
+            </child>
+            <child type="end">
+              <object class="SpinnerButton" id="invite_button">
+                <property name="label" translatable="yes">I_nvite</property>
+                <property name="use_underline">True</property>
+                <property name="sensitive">False</property>
+                <style>
+                  <class name="suggested-action"/>
+                </style>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child>
+          <object class="GtkSearchBar">
+            <property name="search-mode-enabled">True</property>
+            <child>
+              <object class="AdwClamp">
+                <property name="margin-bottom">6</property>
+                <property name="margin-end">30</property>
+                <property name="margin-start">30</property>
+                <property name="margin-top">6</property>
+                <property name="hexpand">true</property>
+                <child>
+                  <object class="CustomEntry">
+                   <!-- FIXME: inserting a Pill makes the Entry grow, therefore we force more height so that 
it doens't grow visually
+                        Would be nice to fix it properly. Including the vertical alignment of Pills in the 
textview
+                    -->
+                    <property name="height-request">74</property>
+                    <child>
+                      <object class="GtkBox">
+                        <property name="spacing">6</property>
+                        <child>
+                          <object class="GtkImage">
+                            <property name="icon-name">system-search-symbolic</property>
+                          </object>
+                        </child>
+                        <child>
+                          <object class="GtkScrolledWindow">
+                            <child>
+                              <object class="GtkTextView" id="text_view">
+                                <property name="hexpand">true</property>
+                                <property name="justification">left</property>
+                                <property name="wrap-mode">word-char</property>
+                                <property name="accepts-tab">False</property>
+                                <property name="pixels_above_lines">3</property>
+                                <property name="pixels_below_lines">3</property>
+                                <property name="pixels_inside_wrap">6</property>
+                                <property name="editable" bind-source="invite_button" 
bind-property="loading" bind-flags="sync-create | invert-boolean"/>
+                                <property name="buffer">
+                                  <object class="GtkTextBuffer" id="text_buffer"/>
+                                </property>
+                              </object>
+                            </child>
+                          </object>
+                        </child>
+                      </object>
+                    </child>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child>
+          <object class="GtkStack" id="stack">
+            <child>
+              <object class="AdwStatusPage" id="no_search_page">
+                <property name="visible">True</property>
+                <property name="hexpand">True</property>
+                <property name="vexpand">True</property>
+                <property name="icon-name">system-search-symbolic</property>
+                <property name="description" translatable="yes">Search for users to invite them to this 
room.</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkScrolledWindow" id="matching_page">
+                <property name="propagate-natural-height">True</property>
+                <property name="child">
+                  <object class="AdwClampScrollable">
+                    <property name="child">
+                      <object class="GtkListView" id="list_view">
+                        <property name="margin-bottom">24</property>
+                        <property name="margin-end">12</property>
+                        <property name="margin-start">12</property>
+                        <property name="margin-top">24</property>
+                        <property name="show-separators">True</property>
+                        <property name="single-click-activate">True</property>
+                        <property name="factory">
+                          <object class="GtkBuilderListItemFactory">
+                            <property 
name="resource">/org/gnome/FractalNext/content-invitee-item.ui</property>
+                          </object>
+                        </property>
+                        <style>
+                          <class name="content"/>
+                        </style>
+                      </object>
+                    </property>
+                  </object>
+                </property>
+              </object>
+            </child>
+            <child>
+              <object class="AdwStatusPage" id="no_matching_page">
+                <property name="visible">True</property>
+                <property name="hexpand">True</property>
+                <property name="vexpand">True</property>
+                <property name="icon-name">system-search-symbolic</property>
+                <property name="description" translatable="yes">No users matching the search where 
found.</property>
+              </object>
+            </child>
+            <child>
+              <object class="AdwStatusPage" id="error_page">
+                <property name="visible">True</property>
+                <property name="hexpand">True</property>
+                <property name="vexpand">True</property>
+                <property name="icon-name">dialog-error-symbolic</property>
+                <property name="description" translatable="yes">An error occured while searching for 
matches</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkSpinner" id="loading_page">
+                <property name="spinning">True</property>
+                <property name="valign">center</property>
+                <property name="halign">center</property>
+                <property name="vexpand">True</property>
+                <style>
+                  <class name="session-loading-spinner"/>
+                </style>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </property>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content-invitee-item.ui b/data/resources/ui/content-invitee-item.ui
new file mode 100644
index 00000000..07f1ad3c
--- /dev/null
+++ b/data/resources/ui/content-invitee-item.ui
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="GtkListItem">
+    <property name="activatable">True</property>
+    <property name="selectable">False</property>
+    <property name="child">
+      <object class="ContentInviteInviteeRow" id="row">
+        <binding name="user">
+          <lookup name="item">GtkListItem</lookup>
+        </binding>
+      </object>
+    </property>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content-invitee-row.ui b/data/resources/ui/content-invitee-row.ui
new file mode 100644
index 00000000..b721bfa5
--- /dev/null
+++ b/data/resources/ui/content-invitee-row.ui
@@ -0,0 +1,68 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentInviteInviteeRow" parent="AdwBin">
+    <property name="margin-top">12</property>
+    <property name="margin-bottom">12</property>
+    <property name="margin-start">12</property>
+    <property name="margin-end">12</property>
+    <property name="child">
+      <object class="GtkBox" id="header">
+        <property name="spacing">12</property>
+        <style>
+          <class name="header"/>
+        </style>
+        <child>
+          <object class="ComponentsAvatar">
+            <property name="size">32</property>
+            <binding name="item">
+              <lookup name="avatar" type="Invitee">
+                <lookup name="user">ContentInviteInviteeRow</lookup>
+              </lookup>
+            </binding>
+          </object>
+        </child>
+        <child>
+          <object class="GtkBox">
+            <property name="orientation">vertical</property>
+            <style>
+              <class name="title"/>
+            </style>
+            <child>
+              <object class="GtkLabel" id="display-name">
+                <property name="halign">start</property>
+                <property name="ellipsize">end</property>
+                <binding name="label">
+                  <lookup name="display-name" type="Invitee">
+                    <lookup name="user">ContentInviteInviteeRow</lookup>
+                  </lookup>
+                </binding>
+                <style>
+                  <class name="title"/>
+                </style>
+              </object>
+            </child>
+            <child>
+              <object class="GtkLabel" id="subtitle">
+                <property name="hexpand">True</property>
+                <property name="halign">start</property>
+                <property name="ellipsize">end</property>
+                <binding name="label">
+                  <lookup name="user-id" type="Invitee">
+                    <lookup name="user">ContentInviteInviteeRow</lookup>
+                  </lookup>
+                </binding>
+                <style>
+                  <class name="subtitle"/>
+                </style>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child>
+          <object class="GtkCheckButton" id="check_button" />
+        </child>
+      </object>
+    </property>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content-member-page.ui b/data/resources/ui/content-member-page.ui
index 91b97611..b7672eb0 100644
--- a/data/resources/ui/content-member-page.ui
+++ b/data/resources/ui/content-member-page.ui
@@ -23,8 +23,6 @@
               <object class="GtkButton" id="invite_button">
                 <property name="label" translatable="yes">Invite new member</property>
                 <property name="halign">end</property>
-                <!-- Make the invite button invisible for now till we implement the invite dialog -->
-                <property name="visible">False</property>
               </object>
             </child>
           </object>
diff --git a/data/resources/ui/pill.ui b/data/resources/ui/pill.ui
index 08c3d298..4592c9c4 100644
--- a/data/resources/ui/pill.ui
+++ b/data/resources/ui/pill.ui
@@ -16,7 +16,6 @@
         </child>
         <child>
           <object class="GtkLabel" id="display_name">
-            <property name="ellipsize">middle</property>
             <property name="max-width-chars">30</property>
           </object>
         </child>
diff --git a/po/POTFILES.in b/po/POTFILES.in
index eabeb42a..ff8fc197 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -77,6 +77,10 @@ src/session/categories/mod.rs
 src/session/content/invite.rs
 src/session/content/markdown_popover.rs
 src/session/content/mod.rs
+src/session/content/room_details/invite_subpage/invitee.rs
+src/session/content/room_details/invite_subpage/mod.rs
+src/session/content/room_details/invite_subpage/invitee_list.rs
+src/session/content/room_details/invite_subpage/invitee_row.rs
 src/session/content/room_details/member_page.rs
 src/session/content/room_details/mod.rs
 src/session/content/room_history/divider_row.rs
diff --git a/src/meson.build b/src/meson.build
index 8de9ad9c..5b1a394d 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -71,6 +71,10 @@ sources = files(
   'session/content/room_history/state_row/mod.rs',
   'session/content/room_history/state_row/tombstone.rs',
   'session/content/mod.rs',
+  'session/content/room_details/invite_subpage/invitee.rs',
+  'session/content/room_details/invite_subpage/mod.rs',
+  'session/content/room_details/invite_subpage/invitee_list.rs',
+  'session/content/room_details/invite_subpage/invitee_row.rs',
   'session/content/room_details/member_page.rs',
   'session/content/room_details/mod.rs',
   'session/media_viewer.rs',
diff --git a/src/session/content/room_details/invite_subpage/invitee.rs 
b/src/session/content/room_details/invite_subpage/invitee.rs
new file mode 100644
index 00000000..16928116
--- /dev/null
+++ b/src/session/content/room_details/invite_subpage/invitee.rs
@@ -0,0 +1,136 @@
+use gtk::glib;
+use gtk::prelude::*;
+use gtk::subclass::prelude::*;
+use matrix_sdk::ruma::identifiers::{MxcUri, UserId};
+
+use crate::session::user::UserExt;
+use crate::session::{Session, User};
+
+mod imp {
+    use super::*;
+    use once_cell::sync::Lazy;
+    use std::cell::{Cell, RefCell};
+
+    #[derive(Debug, Default)]
+    pub struct Invitee {
+        pub invited: Cell<bool>,
+        pub anchor: RefCell<Option<gtk::TextChildAnchor>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for Invitee {
+        const NAME: &'static str = "Invitee";
+        type Type = super::Invitee;
+        type ParentType = User;
+    }
+
+    impl ObjectImpl for Invitee {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpec::new_boolean(
+                        "invited",
+                        "Invited",
+                        "Whether this Invitee is actually invited",
+                        false,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
+                    glib::ParamSpec::new_object(
+                        "anchor",
+                        "Anchor",
+                        "The anchor location in the text buffer",
+                        gtk::TextChildAnchor::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "invited" => obj.set_invited(value.get().unwrap()),
+                "anchor" => obj.set_anchor(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "invited" => obj.is_invited().to_value(),
+                "anchor" => obj.anchor().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+}
+
+glib::wrapper! {
+    /// A User in the context of a given room.
+    pub struct Invitee(ObjectSubclass<imp::Invitee>) @extends User;
+}
+
+impl Invitee {
+    pub fn new(
+        session: &Session,
+        user_id: &UserId,
+        display_name: Option<&str>,
+        avatar_url: Option<MxcUri>,
+    ) -> Self {
+        let obj: Self = glib::Object::new(&[
+            ("session", session),
+            ("user-id", &user_id.as_str()),
+            ("display-name", &display_name),
+        ])
+        .expect("Failed to create Invitee");
+        // FIXME: we should make the avatar_url settable as property
+        obj.set_avatar_url(avatar_url);
+        obj
+    }
+
+    pub fn is_invited(&self) -> bool {
+        let priv_ = imp::Invitee::from_instance(self);
+        priv_.invited.get()
+    }
+
+    pub fn set_invited(&self, invited: bool) {
+        let priv_ = imp::Invitee::from_instance(self);
+
+        if self.is_invited() == invited {
+            return;
+        }
+
+        priv_.invited.set(invited);
+        self.notify("invited");
+    }
+
+    pub fn anchor(&self) -> Option<gtk::TextChildAnchor> {
+        let priv_ = imp::Invitee::from_instance(self);
+        priv_.anchor.borrow().clone()
+    }
+
+    pub fn take_anchor(&self) -> Option<gtk::TextChildAnchor> {
+        let priv_ = imp::Invitee::from_instance(self);
+        let anchor = priv_.anchor.take();
+        self.notify("anchor");
+        anchor
+    }
+
+    pub fn set_anchor(&self, anchor: Option<gtk::TextChildAnchor>) {
+        let priv_ = imp::Invitee::from_instance(self);
+
+        if self.anchor() == anchor {
+            return;
+        }
+
+        priv_.anchor.replace(anchor);
+        self.notify("anchor");
+    }
+}
diff --git a/src/session/content/room_details/invite_subpage/invitee_list.rs 
b/src/session/content/room_details/invite_subpage/invitee_list.rs
new file mode 100644
index 00000000..cac22810
--- /dev/null
+++ b/src/session/content/room_details/invite_subpage/invitee_list.rs
@@ -0,0 +1,386 @@
+use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
+use log::error;
+use matrix_sdk::ruma::{api::client::r0::user_directory::search_users, identifiers::UserId};
+use matrix_sdk::HttpError;
+
+use crate::session::user::UserExt;
+use crate::{session::Room, spawn, spawn_tokio};
+
+use super::Invitee;
+
+#[derive(Debug, Eq, PartialEq, Clone, Copy, glib::GEnum)]
+#[repr(u32)]
+#[genum(type_name = "ContentInviteeListState")]
+pub enum InviteeListState {
+    Initial = 0,
+    Loading = 1,
+    NoMatching = 2,
+    Matching = 3,
+    Error = 4,
+}
+
+impl Default for InviteeListState {
+    fn default() -> Self {
+        Self::Initial
+    }
+}
+
+mod imp {
+    use futures::future::AbortHandle;
+    use glib::subclass::Signal;
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+    use std::cell::{Cell, RefCell};
+    use std::collections::HashMap;
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct InviteeList {
+        pub list: RefCell<Vec<Invitee>>,
+        pub room: OnceCell<Room>,
+        pub state: Cell<InviteeListState>,
+        pub search_term: RefCell<Option<String>>,
+        pub invitee_list: RefCell<HashMap<UserId, Invitee>>,
+        pub abort_handle: RefCell<Option<AbortHandle>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for InviteeList {
+        const NAME: &'static str = "InviteeList";
+        type Type = super::InviteeList;
+        type ParentType = glib::Object;
+        type Interfaces = (gio::ListModel,);
+    }
+
+    impl ObjectImpl for InviteeList {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpec::new_object(
+                        "room",
+                        "Room",
+                        "The room this invitee list refers to",
+                        Room::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpec::new_string(
+                        "search-term",
+                        "Search Term",
+                        "The search term",
+                        None,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
+                    glib::ParamSpec::new_boolean(
+                        "has-selected",
+                        "Has Selected",
+                        "Whether the user has selected some users",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_enum(
+                        "state",
+                        "InviteeListState",
+                        "The state of the list",
+                        InviteeListState::static_type(),
+                        InviteeListState::default() as i32,
+                        glib::ParamFlags::READABLE,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn signals() -> &'static [Signal] {
+            static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
+                vec![
+                    Signal::builder(
+                        "invitee-added",
+                        &[Invitee::static_type().into()],
+                        <()>::static_type().into(),
+                    )
+                    .build(),
+                    Signal::builder(
+                        "invitee-removed",
+                        &[Invitee::static_type().into()],
+                        <()>::static_type().into(),
+                    )
+                    .build(),
+                ]
+            });
+            SIGNALS.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "room" => self.room.set(value.get::<Room>().unwrap()).unwrap(),
+                "search-term" => obj.set_search_term(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "room" => obj.room().to_value(),
+                "search-term" => obj.search_term().to_value(),
+                "has-selected" => obj.has_selected().to_value(),
+                "state" => obj.state().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl ListModelImpl for InviteeList {
+        fn item_type(&self, _list_model: &Self::Type) -> glib::Type {
+            Invitee::static_type()
+        }
+        fn n_items(&self, _list_model: &Self::Type) -> u32 {
+            self.list.borrow().len() as u32
+        }
+        fn item(&self, _list_model: &Self::Type, position: u32) -> Option<glib::Object> {
+            self.list
+                .borrow()
+                .get(position as usize)
+                .map(glib::object::Cast::upcast_ref::<glib::Object>)
+                .cloned()
+        }
+    }
+}
+
+glib::wrapper! {
+    /// List of users matching the `search term`.
+    pub struct InviteeList(ObjectSubclass<imp::InviteeList>)
+        @implements gio::ListModel;
+}
+
+impl InviteeList {
+    pub fn new(room: &Room) -> Self {
+        glib::Object::new(&[("room", room)]).expect("Failed to create InviteeList")
+    }
+
+    pub fn room(&self) -> &Room {
+        let priv_ = imp::InviteeList::from_instance(self);
+        priv_.room.get().unwrap()
+    }
+
+    pub fn set_search_term(&self, search_term: Option<String>) {
+        let priv_ = imp::InviteeList::from_instance(self);
+
+        if search_term.as_ref() == priv_.search_term.borrow().as_ref() {
+            return;
+        }
+
+        if search_term.as_ref().map_or(false, |s| s.is_empty()) {
+            priv_.search_term.replace(None);
+        } else {
+            priv_.search_term.replace(search_term);
+        }
+
+        self.search_users();
+        self.notify("search_term");
+    }
+
+    fn search_term(&self) -> Option<String> {
+        let priv_ = imp::InviteeList::from_instance(self);
+        priv_.search_term.borrow().clone()
+    }
+
+    fn set_state(&self, state: InviteeListState) {
+        let priv_ = imp::InviteeList::from_instance(self);
+
+        if state == self.state() {
+            return;
+        }
+
+        priv_.state.set(state);
+        self.notify("state");
+    }
+
+    pub fn state(&self) -> InviteeListState {
+        let priv_ = imp::InviteeList::from_instance(self);
+        priv_.state.get()
+    }
+
+    fn set_list(&self, users: Vec<Invitee>) {
+        let priv_ = imp::InviteeList::from_instance(self);
+        let added = users.len();
+
+        let prev_users = priv_.list.replace(users);
+
+        self.items_changed(0, prev_users.len() as u32, added as u32);
+    }
+
+    fn clear_list(&self) {
+        self.set_list(Vec::new());
+    }
+
+    fn finish_search(
+        &self,
+        search_term: String,
+        response: Result<search_users::Response, HttpError>,
+    ) {
+        let session = self.room().session();
+
+        if Some(search_term) != self.search_term() {
+            return;
+        }
+
+        match response {
+            Ok(response) if response.results.len() == 0 => {
+                self.set_state(InviteeListState::NoMatching);
+                self.clear_list();
+            }
+            Ok(response) => {
+                let users: Vec<Invitee> = response
+                    .results
+                    .into_iter()
+                    .map(|item| {
+                        if let Some(user) = self.get_invitee(&item.user_id) {
+                            // The avatar or the display name may have changed in the mean time
+                            user.set_avatar_url(item.avatar_url);
+                            user.set_display_name(item.display_name);
+                            user
+                        } else {
+                            let user = Invitee::new(
+                                &session,
+                                &item.user_id,
+                                item.display_name.as_deref(),
+                                item.avatar_url,
+                            );
+
+                            user.connect_notify_local(
+                                Some("invited"),
+                                clone!(@weak self as obj => move |user, _| {
+                                    if user.is_invited() {
+                                        obj.add_invitee(user.clone());
+                                    } else {
+                                        obj.remove_invitee(user.user_id())
+                                    }
+                                }),
+                            );
+
+                            user
+                        }
+                    })
+                    .collect();
+
+                self.set_list(users);
+                self.set_state(InviteeListState::Matching);
+            }
+            Err(error) => {
+                error!("Couldn't load matching users: {}", error);
+                self.set_state(InviteeListState::Error);
+                self.clear_list();
+            }
+        }
+    }
+
+    fn search_users(&self) {
+        let priv_ = imp::InviteeList::from_instance(self);
+        let client = self.room().session().client();
+        let search_term = if let Some(search_term) = self.search_term() {
+            search_term
+        } else {
+            // Do nothing for no search term execpt when currently loading
+            if self.state() == InviteeListState::Loading {
+                self.set_state(InviteeListState::Initial);
+            }
+            return;
+        };
+
+        self.set_state(InviteeListState::Loading);
+        self.clear_list();
+
+        let search_term_clone = search_term.clone();
+        let handle = spawn_tokio!(async move {
+            let request = search_users::Request::new(&search_term_clone);
+            client.send(request, None).await
+        });
+
+        let (future, handle) = futures::future::abortable(handle);
+
+        if let Some(abort_handle) = priv_.abort_handle.replace(Some(handle)) {
+            abort_handle.abort();
+        }
+
+        spawn!(clone!(@weak self as obj => async move {
+            match future.await {
+                Ok(result) => obj.finish_search(search_term, result.unwrap()),
+                Err(_) => {},
+            }
+        }));
+    }
+
+    fn get_invitee(&self, user_id: &UserId) -> Option<Invitee> {
+        let priv_ = imp::InviteeList::from_instance(self);
+        priv_.invitee_list.borrow().get(user_id).cloned()
+    }
+
+    pub fn add_invitee(&self, user: Invitee) {
+        let priv_ = imp::InviteeList::from_instance(self);
+        user.set_invited(true);
+        priv_
+            .invitee_list
+            .borrow_mut()
+            .insert(user.user_id().to_owned(), user.clone());
+        self.emit_by_name("invitee-added", &[&user]).unwrap();
+        self.notify("has-selected");
+    }
+
+    pub fn invitees(&self) -> Vec<Invitee> {
+        let priv_ = imp::InviteeList::from_instance(self);
+        priv_
+            .invitee_list
+            .borrow()
+            .values()
+            .map(Clone::clone)
+            .collect()
+    }
+
+    fn remove_invitee(&self, user_id: &UserId) {
+        let priv_ = imp::InviteeList::from_instance(self);
+        let removed = priv_.invitee_list.borrow_mut().remove(user_id);
+        if let Some(user) = removed {
+            user.set_invited(false);
+            self.emit_by_name("invitee-removed", &[&user]).unwrap();
+            self.notify("has-selected");
+        }
+    }
+
+    pub fn has_selected(&self) -> bool {
+        let priv_ = imp::InviteeList::from_instance(self);
+        !priv_.invitee_list.borrow().is_empty()
+    }
+
+    pub fn connect_invitee_added<F: Fn(&Self, &Invitee) + 'static>(
+        &self,
+        f: F,
+    ) -> glib::SignalHandlerId {
+        self.connect_local("invitee-added", true, move |values| {
+            let obj = values[0].get::<Self>().unwrap();
+            let invitee = values[1].get::<Invitee>().unwrap();
+            f(&obj, &invitee);
+            None
+        })
+        .unwrap()
+    }
+
+    pub fn connect_invitee_removed<F: Fn(&Self, &Invitee) + 'static>(
+        &self,
+        f: F,
+    ) -> glib::SignalHandlerId {
+        self.connect_local("invitee-removed", true, move |values| {
+            let obj = values[0].get::<Self>().unwrap();
+            let invitee = values[1].get::<Invitee>().unwrap();
+            f(&obj, &invitee);
+            None
+        })
+        .unwrap()
+    }
+}
diff --git a/src/session/content/room_details/invite_subpage/invitee_row.rs 
b/src/session/content/room_details/invite_subpage/invitee_row.rs
new file mode 100644
index 00000000..40c4a21e
--- /dev/null
+++ b/src/session/content/room_details/invite_subpage/invitee_row.rs
@@ -0,0 +1,117 @@
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+use super::Invitee;
+use adw::subclass::prelude::BinImpl;
+
+mod imp {
+    use super::*;
+    use glib::subclass::InitializingObject;
+    use once_cell::sync::Lazy;
+    use std::cell::RefCell;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-invitee-row.ui")]
+    pub struct InviteeRow {
+        pub user: RefCell<Option<Invitee>>,
+        pub binding: RefCell<Option<glib::Binding>>,
+        #[template_child]
+        pub check_button: TemplateChild<gtk::CheckButton>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for InviteeRow {
+        const NAME: &'static str = "ContentInviteInviteeRow";
+        type Type = super::InviteeRow;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for InviteeRow {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpec::new_object(
+                    "user",
+                    "User",
+                    "The user this row is showing",
+                    Invitee::static_type(),
+                    glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                )]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "user" => {
+                    obj.set_user(value.get().unwrap());
+                }
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "user" => obj.user().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+    impl WidgetImpl for InviteeRow {}
+    impl BinImpl for InviteeRow {}
+}
+
+glib::wrapper! {
+    pub struct InviteeRow(ObjectSubclass<imp::InviteeRow>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl InviteeRow {
+    pub fn new(user: &Invitee) -> Self {
+        glib::Object::new(&[("user", user)]).expect("Failed to create InviteeRow")
+    }
+
+    pub fn user(&self) -> Option<Invitee> {
+        let priv_ = imp::InviteeRow::from_instance(self);
+        priv_.user.borrow().clone()
+    }
+
+    pub fn set_user(&self, user: Option<Invitee>) {
+        let priv_ = imp::InviteeRow::from_instance(self);
+
+        if self.user() == user {
+            return;
+        }
+
+        if let Some(binding) = priv_.binding.take() {
+            binding.unbind();
+        }
+
+        if let Some(ref user) = user {
+            // We can't use `gtk::Expression` because we need a bidirectional binding
+            let binding = user
+                .bind_property("invited", &*priv_.check_button, "active")
+                .flags(glib::BindingFlags::BIDIRECTIONAL | glib::BindingFlags::SYNC_CREATE)
+                .build()
+                .unwrap();
+
+            priv_.binding.replace(Some(binding));
+        }
+
+        priv_.user.replace(user);
+        self.notify("user");
+    }
+}
diff --git a/src/session/content/room_details/invite_subpage/mod.rs 
b/src/session/content/room_details/invite_subpage/mod.rs
new file mode 100644
index 00000000..a8490b74
--- /dev/null
+++ b/src/session/content/room_details/invite_subpage/mod.rs
@@ -0,0 +1,344 @@
+use adw::subclass::prelude::*;
+use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod invitee;
+use self::invitee::Invitee;
+mod invitee_list;
+mod invitee_row;
+use self::invitee_list::{InviteeList, InviteeListState};
+use self::invitee_row::InviteeRow;
+use crate::components::Pill;
+
+use crate::components::SpinnerButton;
+use crate::session::User;
+use crate::spawn;
+
+use crate::session::content::RoomDetails;
+use crate::session::Room;
+
+mod imp {
+    use super::*;
+    use glib::subclass::InitializingObject;
+    use std::cell::RefCell;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-invite-subpage.ui")]
+    pub struct InviteSubpage {
+        pub room: RefCell<Option<Room>>,
+        #[template_child]
+        pub list_view: TemplateChild<gtk::ListView>,
+        #[template_child]
+        pub text_buffer: TemplateChild<gtk::TextBuffer>,
+        #[template_child]
+        pub invite_button: TemplateChild<SpinnerButton>,
+        #[template_child]
+        pub cancel_button: TemplateChild<gtk::Button>,
+        #[template_child]
+        pub text_view: TemplateChild<gtk::TextView>,
+        #[template_child]
+        pub stack: TemplateChild<gtk::Stack>,
+        #[template_child]
+        pub matching_page: TemplateChild<gtk::ScrolledWindow>,
+        #[template_child]
+        pub no_matching_page: TemplateChild<adw::StatusPage>,
+        #[template_child]
+        pub no_search_page: TemplateChild<adw::StatusPage>,
+        #[template_child]
+        pub error_page: TemplateChild<adw::StatusPage>,
+        #[template_child]
+        pub loading_page: TemplateChild<gtk::Spinner>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for InviteSubpage {
+        const NAME: &'static str = "ContentInviteSubpage";
+        type Type = super::InviteSubpage;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            InviteeRow::static_type();
+            Self::bind_template(klass);
+
+            klass.add_binding(
+                gdk::keys::constants::Escape,
+                gdk::ModifierType::empty(),
+                |obj, _| {
+                    obj.close();
+                    true
+                },
+                None,
+            );
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for InviteSubpage {
+        fn properties() -> &'static [glib::ParamSpec] {
+            use once_cell::sync::Lazy;
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpec::new_object(
+                    "room",
+                    "Room",
+                    "The room users will be invited to",
+                    Room::static_type(),
+                    glib::ParamFlags::READWRITE,
+                )]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "room" => obj.set_room(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "room" => obj.room().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            self.cancel_button
+                .connect_clicked(clone!(@weak obj => move |_| {
+                    obj.close();
+                }));
+
+            self.text_buffer.connect_delete_range(clone!(@weak obj => move |_, start, end| {
+                let mut current = start.clone();
+                loop {
+                    if let Some(anchor) = current.child_anchor() {
+                        let user = 
anchor.widgets()[0].downcast_ref::<Pill>().unwrap().user().unwrap().downcast::<Invitee>().unwrap();
+                        user.take_anchor();
+                        user.set_invited(false);
+                    }
+
+                    current.forward_char();
+
+                    if &current == end {
+                        break;
+                    }
+                }
+            }));
+
+            self.text_buffer.connect_insert_text(
+                clone!(@weak obj => move |text_buffer, location, text| {
+                    let mut changed = false;
+
+                    // We don't allow adding chars before and between pills
+                    loop {
+                        if location.child_anchor().is_some() {
+                            changed = true;
+                            if !location.forward_char() {
+                                break;
+                            }
+                        } else {
+                            break;
+                        }
+                    }
+
+                    if changed {
+                        text_buffer.place_cursor(location);
+                        text_buffer.stop_signal_emission("insert-text");
+                        text_buffer.insert(location, text);
+                    }
+                }),
+            );
+
+            self.invite_button
+                .connect_clicked(clone!(@weak obj => move |_| {
+                    obj.invite();
+                }));
+
+            self.list_view.connect_activate(|list_view, index| {
+                let invitee = list_view
+                    .model()
+                    .unwrap()
+                    .item(index)
+                    .unwrap()
+                    .downcast::<Invitee>()
+                    .unwrap();
+
+                invitee.set_invited(!invitee.is_invited());
+            });
+        }
+    }
+
+    impl WidgetImpl for InviteSubpage {}
+    impl BinImpl for InviteSubpage {}
+}
+
+glib::wrapper! {
+    /// Preference Window to display and update room details.
+    pub struct InviteSubpage(ObjectSubclass<imp::InviteSubpage>)
+        @extends gtk::Widget, gtk::Window, adw::Window, adw::Bin, @implements gtk::Accessible;
+}
+
+impl InviteSubpage {
+    pub fn new(room: &Room) -> Self {
+        glib::Object::new(&[("room", room)]).expect("Failed to create InviteSubpage")
+    }
+
+    pub fn room(&self) -> Option<Room> {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+        priv_.room.borrow().clone()
+    }
+
+    fn set_room(&self, room: Option<Room>) {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+
+        if self.room() == room {
+            return;
+        }
+
+        if let Some(ref room) = room {
+            let user_list = InviteeList::new(&room);
+            user_list.connect_invitee_added(clone!(@weak self as obj => move |_, invitee| {
+                obj.add_user_pill(invitee);
+            }));
+
+            user_list.connect_invitee_removed(clone!(@weak self as obj => move |_, invitee| {
+                obj.remove_user_pill(invitee);
+            }));
+
+            user_list.connect_notify_local(
+                Some("state"),
+                clone!(@weak self as obj => move |_, _| {
+                    obj.update_view();
+                }),
+            );
+
+            priv_
+                .text_buffer
+                .bind_property("text", &user_list, "search-term")
+                .flags(glib::BindingFlags::SYNC_CREATE)
+                .build()
+                .unwrap();
+
+            user_list
+                .bind_property("has-selected", &*priv_.invite_button, "sensitive")
+                .flags(glib::BindingFlags::SYNC_CREATE)
+                .build()
+                .unwrap();
+
+            priv_
+                .list_view
+                .set_model(Some(&gtk::NoSelection::new(Some(&user_list))));
+        } else {
+            priv_.list_view.set_model(gtk::NONE_SELECTION_MODEL);
+        }
+
+        priv_.room.replace(room);
+        self.notify("room");
+    }
+
+    fn close(&self) {
+        let window = self.root().unwrap().downcast::<RoomDetails>().unwrap();
+        window.close_invite_subpage();
+    }
+
+    fn add_user_pill(&self, user: &Invitee) {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+
+        let pill = Pill::new();
+        pill.set_margin_start(3);
+        pill.set_margin_end(3);
+        pill.set_user(Some(user.clone().upcast()));
+
+        let (mut start_iter, mut end_iter) = priv_.text_buffer.bounds();
+
+        // We don't allow adding chars before and between pills
+        loop {
+            if start_iter.child_anchor().is_some() {
+                start_iter.forward_char();
+            } else {
+                break;
+            }
+        }
+
+        priv_.text_buffer.delete(&mut start_iter, &mut end_iter);
+        let anchor = priv_.text_buffer.create_child_anchor(&mut start_iter);
+        priv_.text_view.add_child_at_anchor(&pill, &anchor);
+        user.set_anchor(Some(anchor));
+
+        priv_.text_view.grab_focus();
+    }
+
+    fn remove_user_pill(&self, user: &Invitee) {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+
+        if let Some(anchor) = user.take_anchor() {
+            if !anchor.is_deleted() {
+                let mut start_iter = priv_.text_buffer.iter_at_child_anchor(&anchor);
+                let mut end_iter = start_iter.clone();
+                end_iter.forward_char();
+                priv_.text_buffer.delete(&mut start_iter, &mut end_iter);
+            }
+        }
+    }
+
+    fn invitee_list(&self) -> Option<InviteeList> {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+
+        priv_
+            .list_view
+            .model()?
+            .downcast::<gtk::NoSelection>()
+            .unwrap()
+            .model()
+            .unwrap()
+            .downcast::<InviteeList>()
+            .ok()
+    }
+
+    fn invite(&self) {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+
+        priv_.invite_button.set_loading(true);
+        if let Some(room) = self.room() {
+            if let Some(user_list) = self.invitee_list() {
+                let invitees: Vec<User> = user_list
+                    .invitees()
+                    .into_iter()
+                    .map(glib::object::Cast::upcast)
+                    .collect();
+                spawn!(clone!(@weak self as obj => async move {
+                    let priv_ = imp::InviteSubpage::from_instance(&obj);
+                    room.invite(invitees.as_slice()).await;
+                    obj.close();
+                    priv_.invite_button.set_loading(false);
+                }));
+            }
+        }
+    }
+
+    fn update_view(&self) {
+        let priv_ = imp::InviteSubpage::from_instance(self);
+        match self
+            .invitee_list()
+            .expect("Can't update view without an InviteeList")
+            .state()
+        {
+            InviteeListState::Initial => priv_.stack.set_visible_child(&*priv_.no_search_page),
+            InviteeListState::Loading => priv_.stack.set_visible_child(&*priv_.loading_page),
+            InviteeListState::NoMatching => priv_.stack.set_visible_child(&*priv_.no_matching_page),
+            InviteeListState::Matching => priv_.stack.set_visible_child(&*priv_.matching_page),
+            InviteeListState::Error => priv_.stack.set_visible_child(&*priv_.error_page),
+        }
+    }
+}
diff --git a/src/session/content/room_details/member_page.rs b/src/session/content/room_details/member_page.rs
index 8c464a29..155d9fda 100644
--- a/src/session/content/room_details/member_page.rs
+++ b/src/session/content/room_details/member_page.rs
@@ -1,12 +1,13 @@
+use adw::prelude::*;
 use adw::subclass::prelude::*;
 use gettextrs::ngettext;
 use gtk::glib::{self, clone};
-use gtk::prelude::*;
 use gtk::subclass::prelude::*;
 use gtk::CompositeTemplate;
 
 use crate::components::{Avatar, Badge};
 use crate::prelude::*;
+use crate::session::content::RoomDetails;
 use crate::session::room::{Member, RoomAction};
 use crate::session::Room;
 
@@ -194,5 +195,16 @@ impl MemberPage {
         let invite_possible = self.room().new_allowed_expr(RoomAction::Invite);
         const NONE_OBJECT: Option<&glib::Object> = None;
         invite_possible.bind(&*priv_.invite_button, "sensitive", NONE_OBJECT);
+
+        priv_
+            .invite_button
+            .connect_clicked(clone!(@weak self as obj => move |_| {
+                let window = obj
+                .root()
+                .unwrap()
+                .downcast::<RoomDetails>()
+                .unwrap();
+                window.present_invite_subpage();
+            }));
     }
 }
diff --git a/src/session/content/room_details/mod.rs b/src/session/content/room_details/mod.rs
index a1d1ec7c..14d85bce 100644
--- a/src/session/content/room_details/mod.rs
+++ b/src/session/content/room_details/mod.rs
@@ -1,3 +1,4 @@
+mod invite_subpage;
 mod member_page;
 
 use adw::prelude::*;
@@ -11,6 +12,7 @@ use gtk::{
 };
 use matrix_sdk::ruma::events::EventType;
 
+pub use self::invite_subpage::InviteSubpage;
 pub use self::member_page::MemberPage;
 use crate::components::CustomEntry;
 use crate::session::room::RoomAction;
@@ -117,7 +119,7 @@ mod imp {
 glib::wrapper! {
     /// Preference Window to display and update room details.
     pub struct RoomDetails(ObjectSubclass<imp::RoomDetails>)
-        @extends gtk::Widget, gtk::Window, adw::Window, adw::PreferencesWindow, @implements gtk::Accessible;
+        @extends gtk::Widget, gtk::Window, adw::Window, gtk::Root, adw::PreferencesWindow, @implements 
gtk::Accessible;
 }
 
 impl RoomDetails {
@@ -245,4 +247,15 @@ impl RoomDetails {
     fn open_avatar_chooser(&self) {
         self.avatar_chooser().show();
     }
+
+    pub fn present_invite_subpage(&self) {
+        self.set_title(Some(&gettext("Invite new Members")));
+        let subpage = InviteSubpage::new(self.room());
+        self.present_subpage(&subpage);
+    }
+
+    pub fn close_invite_subpage(&self) {
+        self.set_title(Some(&gettext("Room Details")));
+        self.close_subpage();
+    }
 }
diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs
index 89c3209d..085536d8 100644
--- a/src/session/room/mod.rs
+++ b/src/session/room/mod.rs
@@ -21,6 +21,7 @@ pub use self::power_levels::{
 };
 pub use self::room_type::RoomType;
 pub use self::timeline::Timeline;
+use crate::session::User;
 
 use gettextrs::gettext;
 use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
@@ -1086,6 +1087,65 @@ impl Room {
 
         Some(())
     }
+
+    pub async fn invite(&self, users: &[User]) {
+        let matrix_room = self.matrix_room();
+        let user_ids: Vec<UserId> = users.iter().map(|user| user.user_id().to_owned()).collect();
+
+        if let MatrixRoom::Joined(matrix_room) = matrix_room {
+            let handle = spawn_tokio!(async move {
+                let invitiations = user_ids
+                    .iter()
+                    .map(|user_id| matrix_room.invite_user_by_id(user_id));
+                futures::future::join_all(invitiations).await
+            });
+
+            let mut failed_invites: Vec<User> = Vec::new();
+            for (index, result) in handle.await.unwrap().iter().enumerate() {
+                match result {
+                    Ok(_) => {}
+                    Err(error) => {
+                        error!(
+                            "Failed to invite user with id {}: {}",
+                            users[index].user_id(),
+                            error
+                        );
+                        failed_invites.push(users[index].clone());
+                    }
+                }
+            }
+
+            if !failed_invites.is_empty() {
+                let no_failed = failed_invites.len();
+                let first_failed = failed_invites.first().unwrap();
+                let error = Error::new(
+                    clone!(@strong self as room, @strong first_failed => move |_| {
+                            // TODO: should we show all the failed users?
+                            let error_message = if no_failed == 1 {
+                                gettext("Failed to invite <widget> to <widget>. Try again later.")
+                            } else if no_failed == 2 {
+                                gettext("Failed to invite <widget> and some other user to <widget>. Try 
again later.")
+                            } else {
+                               gettext("Failed to invite <widget> and some other users to <widget>. Try 
again later.")
+                            };
+
+                            let user_pill = Pill::new();
+                            user_pill.set_user(Some(first_failed.clone()));
+                            let room_pill = Pill::new();
+                            room_pill.set_room(Some(room.clone()));
+                            let error_label = LabelWithWidgets::new(&error_message, vec![user_pill, 
room_pill]);
+                            Some(error_label.upcast())
+                    }),
+                );
+
+                if let Some(window) = self.session().parent_window() {
+                    window.append_error(&error);
+                }
+            }
+        } else {
+            error!("Can’t invite users, because this room isn’t a joined room");
+        }
+    }
 }
 
 trait GlibDateTime {


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]