[fractal] room-details: Rework navigation and fix listview styles



commit 15bda14f0574e3461dc91f4d5917cb0e11f5af07
Author: Julian Sparber <julian sparber net>
Date:   Thu Sep 8 10:55:25 2022 +0200

    room-details: Rework navigation and fix listview styles
    
    Fixes: https://gitlab.gnome.org/GNOME/fractal/-/issues/900

 data/resources/resources.gresource.xml             |   2 +
 data/resources/style.css                           |  14 +
 data/resources/ui/content-invite-subpage.ui        |  21 +-
 data/resources/ui/content-member-item.ui           |   4 +-
 data/resources/ui/content-member-page-list-view.ui |  32 ++
 .../content-member-page-membership-subpage-row.ui  |  27 ++
 data/resources/ui/content-member-page.ui           | 109 +++---
 data/resources/ui/content-room-details.ui          | 309 +++++++++-------
 po/POTFILES.in                                     |   1 +
 .../content/room_details/invite_subpage/mod.rs     |   5 +-
 .../member_page/members_list_view/extra_lists.rs   | 233 ++++++++++++
 .../member_page/members_list_view/item_row.rs      | 121 ++++++
 .../{ => members_list_view}/member_row.rs          |  34 +-
 .../members_list_view/membership_subpage_item.rs   | 101 +++++
 .../members_list_view/membership_subpage_row.rs    | 172 +++++++++
 .../member_page/members_list_view/mod.rs           | 119 ++++++
 .../content/room_details/member_page/mod.rs        | 406 ++++++++++++---------
 src/session/content/room_details/mod.rs            | 214 +++++++++--
 src/session/content/room_history/mod.rs            |  19 +-
 src/session/room/member.rs                         |  18 +-
 20 files changed, 1521 insertions(+), 440 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 2339f435d..1f0edb68b 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -54,6 +54,8 @@
     <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-markdown-popover.ui">ui/content-markdown-popover.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-item.ui">ui/content-member-item.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-page-list-view.ui">ui/content-member-page-list-view.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-member-page-membership-subpage-row.ui">ui/content-member-page-membership-subpage-row.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-message-audio.ui">ui/content-message-audio.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index c5727594a..9ec717352 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -62,6 +62,9 @@ button.row {
   border-radius: 12px;
 }
 
+.round-corners {
+  border-radius: 6px;
+}
 
 /* Components */
 
@@ -545,6 +548,13 @@ message-reactions .reaction-count {
   font-size: 1.6em;
 }
 
+.invite-search-results {
+       padding: 12px 0px;
+}
+
+.invite-search-results > row {
+       border-radius: 6px;
+}
 
 /* Room Details */
 
@@ -552,6 +562,10 @@ message-reactions .reaction-count {
   margin-bottom: 6px;
 }
 
+.room-details listview {
+       background: transparent;
+}
+
 .room-details-group avatar * {
   /* Undo non-sensitive style. */
   filter: none;
diff --git a/data/resources/ui/content-invite-subpage.ui b/data/resources/ui/content-invite-subpage.ui
index 8a0af478c..b97a78499 100644
--- a/data/resources/ui/content-invite-subpage.ui
+++ b/data/resources/ui/content-invite-subpage.ui
@@ -34,10 +34,10 @@
                 <property name="margin-end">30</property>
                 <property name="margin-start">30</property>
                 <property name="margin-top">6</property>
-                <property name="hexpand">true</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 doesn't grow visually
+                    <!-- FIXME: inserting a Pill makes the Entry grow, therefore we force more height so 
that it doesn'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>
@@ -53,7 +53,7 @@
                           <object class="GtkScrolledWindow">
                             <child>
                               <object class="GtkTextView" id="text_view">
-                                <property name="hexpand">true</property>
+                                <property name="hexpand">True</property>
                                 <property name="justification">left</property>
                                 <property name="wrap-mode">word-char</property>
                                 <property name="accepts-tab">False</property>
@@ -80,8 +80,6 @@
           <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>
@@ -89,16 +87,12 @@
             </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">
@@ -106,7 +100,7 @@
                           </object>
                         </property>
                         <style>
-                          <class name="content"/>
+                          <class name="invite-search-results"/>
                         </style>
                       </object>
                     </property>
@@ -116,18 +110,12 @@
             </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 were 
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 occurred while searching for 
matches</property>
               </object>
@@ -137,7 +125,6 @@
                 <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>
diff --git a/data/resources/ui/content-member-item.ui b/data/resources/ui/content-member-item.ui
index 376d6af57..3e15849d6 100644
--- a/data/resources/ui/content-member-item.ui
+++ b/data/resources/ui/content-member-item.ui
@@ -4,8 +4,8 @@
     <property name="activatable">False</property>
     <property name="selectable">False</property>
     <property name="child">
-      <object class="ContentMemberRow">
-        <binding name="member">
+      <object class="ContentMemberItemRow">
+        <binding name="item">
           <lookup name="item">GtkListItem</lookup>
         </binding>
       </object>
diff --git a/data/resources/ui/content-member-page-list-view.ui 
b/data/resources/ui/content-member-page-list-view.ui
new file mode 100644
index 000000000..7315ec0cc
--- /dev/null
+++ b/data/resources/ui/content-member-page-list-view.ui
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentMembersListView" parent="AdwBin">
+    <child>
+      <object class="GtkScrolledWindow">
+        <property name="hexpand">True</property>
+        <property name="vexpand">True</property>
+        <property name="hscrollbar-policy">never</property>
+        <property name="propagate-natural-height">True</property>
+        <property name="child">
+          <object class="AdwClampScrollable">
+            <property name="tightening-threshold">300</property>
+            <property name="maximum-size">400</property>
+            <property name="margin-start">12</property>
+            <property name="margin-end">12</property>
+            <property name="child">
+              <object class="GtkListView" id="members_list_view">
+                <property name="single-click-activate">True</property>
+                <property name="factory">
+                  <object class="GtkBuilderListItemFactory">
+                    <property name="resource">/org/gnome/Fractal/content-member-item.ui</property>
+                  </object>
+                </property>
+              </object>
+            </property>
+          </object>
+        </property>
+      </object>
+    </child>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content-member-page-membership-subpage-row.ui 
b/data/resources/ui/content-member-page-membership-subpage-row.ui
new file mode 100644
index 000000000..ec975dfb1
--- /dev/null
+++ b/data/resources/ui/content-member-page-membership-subpage-row.ui
@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentMemberPageMembershipSubpageRow" parent="AdwActionRow">
+    <property name="title" bind-source="ContentMemberPageMembershipSubpageRow" bind-property="label" 
bind-flags="sync-create"/>
+    <property name="icon-name">system-users-symbolic</property>
+    <property name="activatable">True</property>
+    <property name="margin-top">6</property>
+    <property name="margin-bottom">6</property>
+    <child type="suffix">
+      <object class="GtkLabel" id="members_count">
+        <property name="valign">center</property>
+        <property name="halign">center</property>
+      </object>
+    </child>
+    <child type="suffix">
+      <object class="GtkImage">
+        <property name="valign">center</property>
+        <property name="halign">center</property>
+        <property name="icon-name">go-next-symbolic</property>
+      </object>
+    </child>
+    <style>
+      <class name="round-corners"/>
+    </style>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content-member-page.ui b/data/resources/ui/content-member-page.ui
index 2a1ee02bf..9dc23cc7a 100644
--- a/data/resources/ui/content-member-page.ui
+++ b/data/resources/ui/content-member-page.ui
@@ -1,75 +1,79 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <interface>
-  <template class="ContentMemberPage" parent="AdwPreferencesPage">
-    <property name="icon-name">system-users-symbolic</property>
-    <property name="title" translatable="yes">Members</property>
-    <property name="name">members</property>
+  <template class="ContentMemberPage" parent="AdwBin">
     <child>
-      <object class="AdwPreferencesGroup">
+      <object class="GtkBox">
+        <property name="orientation">vertical</property>
         <child>
-          <object class="GtkBox">
-            <property name="margin-bottom">12</property>
-            <child>
-              <object class="GtkLabel" id="member_count">
-                <property name="halign">start</property>
-                <property name="hexpand">True</property>
-                <style>
-                  <class name="heading"/>
-                  <class name="h4"/>
-                </style>
+          <object class="GtkHeaderBar">
+            <child type="start">
+              <object class="GtkButton">
+                <property name="icon-name">go-previous-symbolic</property>
+                <property name="action_name">members.previous</property>
               </object>
             </child>
-            <child>
-              <object class="GtkButton" id="invite_button">
-                <property name="label" translatable="yes">Invite new member</property>
-                <property name="halign">end</property>
+            <child type="end">
+              <object class="GtkToggleButton" id="search_button">
+                <property name="icon-name">system-search-symbolic</property>
+                <accessibility>
+                  <property name="label" translatable="yes">Search for Room Members</property>
+                </accessibility>
               </object>
             </child>
           </object>
         </child>
         <child>
-          <object class="GtkSearchEntry" id="members_search_entry">
-            <property name="margin-bottom">12</property>
-            <property name="placeholder-text" translatable="yes">Search for room members</property>
-          </object>
-        </child>
-        <child>
-          <object class="GtkScrolledWindow" id="members_scroll">
-            <property name="propagate-natural-height">True</property>
-            <property name="max-content-height">300</property>
-            <child>
-              <object class="GtkListView" id="members_list_view">
-                <property name="show-separators">True</property>
-                <property name="factory">
-                  <object class="GtkBuilderListItemFactory">
-                    <property name="resource">/org/gnome/Fractal/content-member-item.ui</property>
+          <object class="GtkSearchBar">
+            <property name="search-mode-enabled" bind-source="search_button" bind-property="active"/>
+            <property name="child">
+              <object class="AdwClamp">
+                <property name="hexpand">True</property>
+                <property name="maximum-size">750</property>
+                <property name="tightening-threshold">550</property>
+                <child>
+                  <object class="GtkSearchEntry" id="members_search_entry">
+                    <property name="placeholder-text" translatable="yes">Search for room members</property>
                   </object>
-                </property>
-                <style>
-                  <class name="content"/>
-                </style>
+                </child>
+                <accessibility>
+                  <property name="label" translatable="yes">Search for room members</property>
+                </accessibility>
               </object>
-            </child>
+            </property>
           </object>
         </child>
-      </object>
-    </child>
-    <child>
-      <object class="AdwPreferencesGroup" id="invited_section">
         <child>
-          <object class="GtkScrolledWindow" id="invited_scroll">
-            <property name="propagate-natural-height">True</property>
-            <property name="max-content-height">300</property>
+          <object class="GtkOverlay">
             <child>
-              <object class="GtkListView" id="invited_list_view">
-                <property name="show-separators">True</property>
-                <property name="factory">
-                  <object class="GtkBuilderListItemFactory">
-                    <property name="resource">/org/gnome/Fractal/content-member-item.ui</property>
+              <object class="GtkStack" id="list_stack">
+                <property name="transition-type">slide-left</property>
+              </object>
+            </child>
+            <child type="overlay">
+              <object class="GtkButton" id="invite_button">
+                <property name="valign">end</property>
+                <property name="halign">center</property>
+                <property name="margin-bottom">24</property>
+                <property name="action-name">details.next-page</property>
+                <property name="action-target">&apos;invite&apos;</property>
+                <property name="child">
+                  <object class="GtkBox">
+                    <property name="spacing">6</property>
+                    <child>
+                      <object class="GtkImage">
+                        <property name="icon-name">system-users-symbolic</property>
+                      </object>
+                    </child>
+                    <child>
+                      <object class="GtkLabel">
+                        <property name="label" translatable="yes">Invite</property>
+                      </object>
+                    </child>
                   </object>
                 </property>
                 <style>
-                  <class name="content"/>
+                  <class name="pill"/>
+                  <class name="suggested-action"/>
                 </style>
               </object>
             </child>
@@ -79,3 +83,4 @@
     </child>
   </template>
 </interface>
+
diff --git a/data/resources/ui/content-room-details.ui b/data/resources/ui/content-room-details.ui
index f8292bf95..3432f810c 100644
--- a/data/resources/ui/content-room-details.ui
+++ b/data/resources/ui/content-room-details.ui
@@ -1,151 +1,200 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <interface>
-  <template class="RoomDetails" parent="AdwPreferencesWindow">
+  <template class="RoomDetails" parent="AdwWindow">
     <property name="title" translatable="yes">Room Details</property>
     <property name="default-widget">edit_toggle</property>
-    <property name="search-enabled">False</property>
-    <child>
-      <object class="AdwPreferencesPage">
-        <property name="icon-name">applications-system-symbolic</property>
-        <property name="title" translatable="yes">General</property>
-        <property name="name">general</property>
+    <property name="modal">True</property>
+    <property name="destroy_with_parent">True</property>
+    <property name="default-width">640</property>
+    <property name="default-height">576</property>
+    <property name="content">
+      <object class="GtkStack" id="main_stack">
         <child>
-          <object class="AdwPreferencesGroup">
-            <style>
-              <class name="room-details-group"/>
-            </style>
-            <child>
-              <object class="GtkOverlay">
-                <property name="halign">center</property>
+          <object class="GtkStackPage">
+            <property name="icon-name">applications-system-symbolic</property>
+            <property name="title" translatable="yes">General</property>
+            <property name="name">general</property>
+            <property name="child">
+              <object class="GtkBox">
+                <property name="orientation">vertical</property>
                 <child>
-                  <object class="ComponentsAvatar">
-                    <property name="size">128</property>
-                    <binding name="item">
-                      <lookup name="avatar">
-                        <lookup name="room">RoomDetails</lookup>
-                      </lookup>
-                    </binding>
-                  </object>
+                  <object class="GtkHeaderBar"/>
                 </child>
-                <child type="overlay">
-                  <object class="AdwBin" id="avatar_remove_button">
-                    <style>
-                      <class name="cutout-button"/>
-                    </style>
-                    <property name="halign">end</property>
-                    <property name="valign">start</property>
-                    <child>
-                      <object class="GtkButton">
-                        <property name="icon-name">user-trash-symbolic</property>
-                        <property name="action-name">details.remove-avatar</property>
-                        <style>
-                          <class name="circular"/>
-                        </style>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-                <child type="overlay">
-                  <object class="AdwBin" id="avatar_edit_button">
-                    <style>
-                      <class name="cutout-button"/>
-                    </style>
-                    <property name="halign">end</property>
-                    <property name="valign">end</property>
-                    <child>
-                      <object class="GtkButton">
-                        <property name="icon-name">document-edit-symbolic</property>
-                        <property name="action-name">details.choose-avatar</property>
-                        <style>
-                          <class name="circular"/>
-                        </style>
-                      </object>
-                    </child>
-                  </object>
-                </child>
-              </object>
-            </child>
-            <child>
-              <object class="AdwClamp">
-                <property name="maximum-size">400</property>
-                <property name="tightening-threshold">400</property>
-                <property name="margin-top">12</property>
-                <property name="child">
-                  <object class="GtkBox">
-                    <property name="spacing">6</property>
-                    <property name="orientation">vertical</property>
-                    <child>
-                      <object class="GtkEntry" id="room_name_entry">
-                        <property name="sensitive">false</property>
-                        <property name="activates-default">True</property>
-                        <property name="xalign">0.5</property>
-                        <property name="buffer">
-                          <object class="GtkEntryBuffer" id="room_name_buffer">
-                            <binding name="text">
-                              <lookup name="display-name">
-                                <lookup name="room">RoomDetails</lookup>
-                              </lookup>
-                            </binding>
+                <child>
+                  <object class="AdwClamp">
+                    <property name="maximum-size">400</property>
+                    <property name="tightening-threshold">400</property>
+                    <property name="margin-top">12</property>
+                    <property name="child">
+                      <object class="GtkBox">
+                        <property name="orientation">vertical</property>
+                        <property name="spacing">24</property>
+                        <child>
+                          <object class="AdwPreferencesGroup">
+                            <style>
+                              <class name="room-details-group"/>
+                            </style>
+                            <child>
+                              <object class="GtkOverlay">
+                                <property name="halign">center</property>
+                                <child>
+                                  <object class="ComponentsAvatar">
+                                    <property name="size">128</property>
+                                    <binding name="item">
+                                      <lookup name="avatar">
+                                        <lookup name="room">RoomDetails</lookup>
+                                      </lookup>
+                                    </binding>
+                                  </object>
+                                </child>
+                                <child type="overlay">
+                                  <object class="AdwBin" id="avatar_remove_button">
+                                    <style>
+                                      <class name="cutout-button"/>
+                                    </style>
+                                    <property name="halign">end</property>
+                                    <property name="valign">start</property>
+                                    <child>
+                                      <object class="GtkButton">
+                                        <property name="icon-name">user-trash-symbolic</property>
+                                        <property name="action-name">details.remove-avatar</property>
+                                        <style>
+                                          <class name="circular"/>
+                                        </style>
+                                      </object>
+                                    </child>
+                                  </object>
+                                </child>
+                                <child type="overlay">
+                                  <object class="AdwBin" id="avatar_edit_button">
+                                    <style>
+                                      <class name="cutout-button"/>
+                                    </style>
+                                    <property name="halign">end</property>
+                                    <property name="valign">end</property>
+                                    <child>
+                                      <object class="GtkButton">
+                                        <property name="icon-name">document-edit-symbolic</property>
+                                        <property name="action-name">details.choose-avatar</property>
+                                        <style>
+                                          <class name="circular"/>
+                                        </style>
+                                      </object>
+                                    </child>
+                                  </object>
+                                </child>
+                              </object>
+                            </child>
+                            <child>
+                              <object class="GtkBox">
+                                <property name="spacing">6</property>
+                                <property name="orientation">vertical</property>
+                                <child>
+                                  <object class="GtkEntry" id="room_name_entry">
+                                    <property name="sensitive">false</property>
+                                    <property name="activates-default">True</property>
+                                    <property name="xalign">0.5</property>
+                                    <property name="buffer">
+                                      <object class="GtkEntryBuffer" id="room_name_buffer">
+                                        <binding name="text">
+                                          <lookup name="display-name">
+                                            <lookup name="room">RoomDetails</lookup>
+                                          </lookup>
+                                        </binding>
+                                      </object>
+                                    </property>
+                                    <style>
+                                      <class name="room-details-name"/>
+                                    </style>
+                                  </object>
+                                </child>
+                                <child>
+                                  <object class="GtkLabel" id="room_topic_label">
+                                    <property name="visible">false</property>
+                                    <property name="margin-top">12</property>
+                                    <property name="label" translatable="yes">Description</property>
+                                    <property name="halign">start</property>
+                                    <style>
+                                      <class name="dim-label"/>
+                                      <class name="caption-heading"/>
+                                    </style>
+                                  </object>
+                                </child>
+                                <child>
+                                  <object class="CustomEntry" id="room_topic_entry">
+                                    <property name="sensitive">false</property>
+                                    <property name="margin-bottom">18</property>
+                                    <child>
+                                      <object class="GtkTextView" id="room_topic_text_view">
+                                        <property name="justification">center</property>
+                                        <property name="wrap-mode">word-char</property>
+                                        <property name="accepts-tab">False</property>
+                                        <property name="top-margin">7</property>
+                                        <property name="bottom-margin">7</property>
+                                        <property name="buffer">
+                                          <object class="GtkTextBuffer" id="room_topic_buffer">
+                                            <binding name="text">
+                                              <lookup name="topic">
+                                                <lookup name="room">RoomDetails</lookup>
+                                              </lookup>
+                                            </binding>
+                                          </object>
+                                        </property>
+                                      </object>
+                                    </child>
+                                    <style>
+                                      <class name="room-details-topic"/>
+                                    </style>
+                                  </object>
+                                </child>
+                                <child>
+                                  <object class="GtkButton" id="edit_toggle">
+                                    <property name="halign">center</property>
+                                  </object>
+                                </child>
+                              </object>
+                            </child>
                           </object>
-                        </property>
-                        <style>
-                          <class name="room-details-name"/>
-                        </style>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="GtkLabel" id="room_topic_label">
-                        <property name="visible">false</property>
-                        <property name="margin-top">12</property>
-                        <property name="label" translatable="yes">Description</property>
-                        <property name="halign">start</property>
-                        <style>
-                          <class name="dim-label"/>
-                          <class name="caption-heading"/>
-                        </style>
-                      </object>
-                    </child>
-                    <child>
-                      <object class="CustomEntry" id="room_topic_entry">
-                        <property name="sensitive">false</property>
-                        <property name="margin-bottom">18</property>
+                        </child>
                         <child>
-                          <object class="GtkTextView" id="room_topic_text_view">
-                            <property name="justification">center</property>
-                            <property name="wrap-mode">word-char</property>
-                            <property name="accepts-tab">False</property>
-                            <property name="top-margin">7</property>
-                            <property name="bottom-margin">7</property>
-                            <property name="buffer">
-                              <object class="GtkTextBuffer" id="room_topic_buffer">
-                                <binding name="text">
-                                  <lookup name="topic">
-                                    <lookup name="room">RoomDetails</lookup>
-                                  </lookup>
-                                </binding>
+                          <object class="AdwPreferencesGroup">
+                            <child>
+                              <object class="AdwActionRow">
+                                <property name="title" translatable="yes">Members</property>
+                                <property name="icon-name">system-users-symbolic</property>
+                                <property name="action-name">details.next-page</property>
+                                <property name="action-target">&apos;members&apos;</property>
+                                <property name="activatable">True</property>
+                                <child type="suffix">
+                                  <object class="GtkLabel" id="members_count">
+                                    <property name="valign">center</property>
+                                    <property name="halign">center</property>
+                                  </object>
+                                </child>
+                                <child type="suffix">
+                                  <object class="GtkImage">
+                                    <property name="valign">center</property>
+                                    <property name="halign">center</property>
+                                    <property name="icon-name">go-next-symbolic</property>
+                                  </object>
+                                </child>
                               </object>
-                            </property>
+                            </child>
                           </object>
                         </child>
-                        <style>
-                          <class name="room-details-topic"/>
-                        </style>
                       </object>
-                    </child>
-                    <child>
-                      <object class="GtkButton" id="edit_toggle">
-                        <property name="halign">center</property>
-                      </object>
-                    </child>
+                    </property>
                   </object>
-                </property>
+                </child>
               </object>
-            </child>
+            </property>
           </object>
         </child>
       </object>
-    </child>
-    <!-- ContentMemberPage goes here -->
+    </property>
+    <style>
+      <class name="room-details"/>
+    </style>
   </template>
 </interface>
 
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 6c9b4d2c0..c75a4fbdb 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -70,6 +70,7 @@ src/session/content/verification/identity_verification_widget.rs
 src/session/content/verification/session_verification.rs
 src/session/mod.rs
 src/session/room/event_actions.rs
+src/session/room/member.rs
 src/session/room/member_role.rs
 src/session/room/mod.rs
 src/session/room/timeline/timeline_day_divider.rs
diff --git a/src/session/content/room_details/invite_subpage/mod.rs 
b/src/session/content/room_details/invite_subpage/mod.rs
index ca3e8c842..42e86a670 100644
--- a/src/session/content/room_details/invite_subpage/mod.rs
+++ b/src/session/content/room_details/invite_subpage/mod.rs
@@ -11,7 +11,7 @@ use self::{
 };
 use crate::{
     components::{Pill, SpinnerButton},
-    session::{content::RoomDetails, Room, User},
+    session::{Room, User},
     spawn,
 };
 
@@ -246,8 +246,7 @@ impl InviteSubpage {
     }
 
     fn close(&self) {
-        let window = self.root().unwrap().downcast::<RoomDetails>().unwrap();
-        window.close_invite_subpage();
+        self.activate_action("details.previous-page", None).unwrap();
     }
 
     fn add_user_pill(&self, user: &Invitee) {
diff --git a/src/session/content/room_details/member_page/members_list_view/extra_lists.rs 
b/src/session/content/room_details/member_page/members_list_view/extra_lists.rs
new file mode 100644
index 000000000..49ec05079
--- /dev/null
+++ b/src/session/content/room_details/member_page/members_list_view/extra_lists.rs
@@ -0,0 +1,233 @@
+use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
+
+use crate::session::content::room_details::member_page::MembershipSubpageItem;
+
+mod imp {
+    use std::cell::Cell;
+
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct ExtraLists {
+        pub joined: OnceCell<gio::ListModel>,
+        pub invited: OnceCell<MembershipSubpageItem>,
+        pub banned: OnceCell<MembershipSubpageItem>,
+        pub invited_is_empty: Cell<bool>,
+        pub banned_is_empty: Cell<bool>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ExtraLists {
+        const NAME: &'static str = "ContentMembersExtraLists";
+        type Type = super::ExtraLists;
+        type Interfaces = (gio::ListModel,);
+    }
+
+    impl ObjectImpl for ExtraLists {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpecObject::new(
+                        "joined",
+                        "Joined",
+                        "The item for the subpage of joined members",
+                        gio::ListModel::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecObject::new(
+                        "invited",
+                        "Invited",
+                        "The item for the subpage of invited members",
+                        MembershipSubpageItem::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecObject::new(
+                        "banned",
+                        "Banned",
+                        "The item for the subpage of banned members",
+                        MembershipSubpageItem::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "joined" => obj.set_joined(value.get().unwrap()),
+                "invited" => obj.set_invited(value.get().unwrap()),
+                "banned" => obj.set_banned(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "joined" => obj.joined().to_value(),
+                "invited" => obj.invited().to_value(),
+                "banned" => obj.banned().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            let joined_members = obj.joined();
+            let invited_members = obj.invited().model();
+            let banned_members = obj.banned().model();
+
+            joined_members.connect_items_changed(
+                clone!(@weak obj => move |_, position, removed, added| {
+                    obj.items_changed(position + obj.n_visible_extras(), removed, added)
+                }),
+            );
+
+            invited_members.connect_items_changed(clone!(@weak obj => move |_, _, _, _| {
+                obj.update_items();
+            }));
+
+            banned_members.connect_items_changed(clone!(@weak obj => move |_, _, _, _| {
+                obj.update_items();
+            }));
+
+            self.invited_is_empty.set(invited_members.n_items() == 0);
+            self.banned_is_empty.set(banned_members.n_items() == 0);
+        }
+    }
+
+    impl ListModelImpl for ExtraLists {
+        fn item_type(&self, _list_model: &Self::Type) -> glib::Type {
+            glib::Object::static_type()
+        }
+
+        fn n_items(&self, list_model: &Self::Type) -> u32 {
+            list_model.joined().n_items() + list_model.n_visible_extras()
+        }
+
+        fn item(&self, list_model: &Self::Type, position: u32) -> Option<glib::Object> {
+            if position == 0 && !self.invited_is_empty.get() {
+                let invited = self.invited.get().unwrap();
+                return Some(invited.clone().upcast());
+            }
+
+            if (position == 0 && self.invited_is_empty.get() && !self.banned_is_empty.get())
+                || (position == 1 && !self.banned_is_empty.get())
+            {
+                let banned = self.banned.get().unwrap();
+                return Some(banned.clone().upcast());
+            }
+
+            list_model
+                .joined()
+                .item(position - list_model.n_visible_extras())
+        }
+    }
+}
+
+glib::wrapper! {
+    pub struct ExtraLists(ObjectSubclass<imp::ExtraLists>)
+        @implements gio::ListModel;
+}
+
+impl ExtraLists {
+    pub fn new(
+        joined: &impl IsA<gio::ListModel>,
+        invited: &MembershipSubpageItem,
+        banned: &MembershipSubpageItem,
+    ) -> Self {
+        glib::Object::new(&[("joined", joined), ("invited", invited), ("banned", banned)])
+            .expect("Failed to create ExtraLists")
+    }
+
+    pub fn joined(&self) -> &gio::ListModel {
+        self.imp().joined.get().unwrap()
+    }
+
+    fn set_joined(&self, model: gio::ListModel) {
+        self.imp().joined.set(model).unwrap();
+    }
+
+    pub fn invited(&self) -> &MembershipSubpageItem {
+        self.imp().invited.get().unwrap()
+    }
+
+    fn set_invited(&self, item: MembershipSubpageItem) {
+        self.imp().invited.set(item).unwrap();
+    }
+
+    pub fn banned(&self) -> &MembershipSubpageItem {
+        self.imp().banned.get().unwrap()
+    }
+
+    fn set_banned(&self, item: MembershipSubpageItem) {
+        self.imp().banned.set(item).unwrap();
+    }
+
+    fn update_items(&self) {
+        let priv_ = self.imp();
+
+        let invited_was_empty = priv_.invited_is_empty.get();
+        let banned_was_empty = priv_.banned_is_empty.get();
+
+        let invited_is_empty = self.invited().model().n_items() == 0;
+        let banned_is_empty = self.banned().model().n_items() == 0;
+
+        let invited_changed = invited_was_empty != invited_is_empty;
+        let banned_changed = banned_was_empty != banned_is_empty;
+
+        if !invited_changed && !banned_changed {
+            // Nothing changed so don't do anything
+            return;
+        }
+
+        let mut position = 0;
+        let mut removed = 0;
+        let mut added = 0;
+
+        if invited_changed {
+            if invited_is_empty {
+                removed = 1;
+            } else {
+                added = 1;
+            }
+        } else if !invited_is_empty {
+            position = 1;
+        }
+
+        if banned_changed {
+            if banned_is_empty {
+                removed += 1;
+            } else {
+                added += 1;
+            }
+        }
+
+        priv_.invited_is_empty.set(invited_is_empty);
+        priv_.banned_is_empty.set(banned_is_empty);
+
+        self.items_changed(position, removed, added);
+    }
+
+    fn n_visible_extras(&self) -> u32 {
+        let priv_ = self.imp();
+        let mut len = 0;
+        if !priv_.invited_is_empty.get() {
+            len += 1;
+        }
+        if !priv_.banned_is_empty.get() {
+            len += 1;
+        }
+        len
+    }
+}
diff --git a/src/session/content/room_details/member_page/members_list_view/item_row.rs 
b/src/session/content/room_details/member_page/members_list_view/item_row.rs
new file mode 100644
index 000000000..4cfc4dbd6
--- /dev/null
+++ b/src/session/content/room_details/member_page/members_list_view/item_row.rs
@@ -0,0 +1,121 @@
+use adw::{prelude::BinExt, subclass::prelude::*};
+use gtk::{glib, glib::prelude::*};
+
+use super::{MemberRow, MembershipSubpageItem, MembershipSubpageRow};
+use crate::session::room::Member;
+
+mod imp {
+    use std::cell::RefCell;
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct ItemRow {
+        pub item: RefCell<Option<glib::Object>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ItemRow {
+        const NAME: &'static str = "ContentMemberItemRow";
+        type Type = super::ItemRow;
+        type ParentType = adw::Bin;
+    }
+
+    impl ObjectImpl for ItemRow {
+        fn properties() -> &'static [glib::ParamSpec] {
+            use once_cell::sync::Lazy;
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpecObject::new(
+                    "item",
+                    "Item",
+                    "The membership subpage item represented by this row",
+                    glib::Object::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() {
+                "item" => obj.set_item(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "item" => obj.item().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl WidgetImpl for ItemRow {}
+    impl BinImpl for ItemRow {}
+}
+
+glib::wrapper! {
+    pub struct ItemRow(ObjectSubclass<imp::ItemRow>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl ItemRow {
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create ItemRow")
+    }
+
+    pub fn item(&self) -> Option<glib::Object> {
+        self.imp().item.borrow().clone()
+    }
+
+    fn set_item(&self, item: Option<glib::Object>) {
+        if self.item() == item {
+            return;
+        }
+
+        if let Some(item) = item.as_ref() {
+            if let Some(member) = item.downcast_ref::<Member>() {
+                let child = if let Some(Ok(child)) = self.child().map(|w| w.downcast::<MemberRow>())
+                {
+                    child
+                } else {
+                    let child = MemberRow::new();
+                    self.set_child(Some(&child));
+                    child
+                };
+                child.set_member(Some(member.clone()));
+            } else if let Some(item) = item.downcast_ref::<MembershipSubpageItem>() {
+                let child = if let Some(Ok(child)) =
+                    self.child().map(|w| w.downcast::<MembershipSubpageRow>())
+                {
+                    child
+                } else {
+                    let child = MembershipSubpageRow::new();
+                    self.set_child(Some(&child));
+                    child
+                };
+
+                child.set_item(Some(item.clone()));
+            } else {
+                unimplemented!("The object {:?} doesn't have a widget implementation", item);
+            }
+        }
+
+        self.imp().item.replace(item);
+        self.notify("item");
+    }
+}
+
+impl Default for ItemRow {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/session/content/room_details/member_page/member_row.rs 
b/src/session/content/room_details/member_page/members_list_view/member_row.rs
similarity index 82%
rename from src/session/content/room_details/member_page/member_row.rs
rename to src/session/content/room_details/member_page/members_list_view/member_row.rs
index 681be76cf..6388e4648 100644
--- a/src/session/content/room_details/member_page/member_row.rs
+++ b/src/session/content/room_details/member_page/members_list_view/member_row.rs
@@ -1,7 +1,10 @@
 use adw::subclass::prelude::BinImpl;
 use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
 
-use crate::session::{content::RoomDetails, room::Member};
+use crate::session::{
+    content::room_details::{member_page::MemberMenu, MemberPage},
+    room::Member,
+};
 
 mod imp {
     use std::cell::RefCell;
@@ -76,10 +79,8 @@ mod imp {
 
             self.menu_btn
                 .connect_toggled(clone!(@weak obj => move |btn| {
-                    if let Some(details) = obj.details() {
-                        let page = details.member_page();
-                        let menu = page.member_menu();
-                        if btn.is_active() {
+                    if btn.is_active() {
+                        if let Some(menu) = obj.member_menu() {
                             menu.present_popover(btn, obj.member());
                         }
                     }
@@ -96,8 +97,8 @@ glib::wrapper! {
 }
 
 impl MemberRow {
-    pub fn new(member: &Member) -> Self {
-        glib::Object::new(&[("member", member)]).expect("Failed to create MemberRow")
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create MemberRow")
     }
 
     pub fn member(&self) -> Option<Member> {
@@ -113,10 +114,7 @@ impl MemberRow {
 
         // We need to update the member of the menu if it's shown for this row
         if priv_.menu_btn.is_active() {
-            if let Some(details) = self.details() {
-                let page = details.member_page();
-                let menu = page.member_menu();
-
+            if let Some(menu) = self.member_menu() {
                 menu.set_member(member.clone());
             }
         }
@@ -125,7 +123,17 @@ impl MemberRow {
         self.notify("member");
     }
 
-    fn details(&self) -> Option<RoomDetails> {
-        Some(self.root()?.downcast::<RoomDetails>().unwrap())
+    fn member_menu(&self) -> Option<MemberMenu> {
+        let member_page = self
+            .ancestor(MemberPage::static_type())?
+            .downcast::<MemberPage>()
+            .unwrap();
+        Some(member_page.member_menu().clone())
+    }
+}
+
+impl Default for MemberRow {
+    fn default() -> Self {
+        Self::new()
     }
 }
diff --git a/src/session/content/room_details/member_page/members_list_view/membership_subpage_item.rs 
b/src/session/content/room_details/member_page/members_list_view/membership_subpage_item.rs
new file mode 100644
index 000000000..5624f94d2
--- /dev/null
+++ b/src/session/content/room_details/member_page/members_list_view/membership_subpage_item.rs
@@ -0,0 +1,101 @@
+use gtk::{
+    gio, glib,
+    glib::{prelude::*, subclass::prelude::*},
+};
+
+use crate::session::room::Membership;
+
+mod imp {
+    use std::cell::Cell;
+
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct MembershipSubpageItem {
+        pub state: Cell<Membership>,
+        pub model: OnceCell<gio::ListModel>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MembershipSubpageItem {
+        const NAME: &'static str = "ContentMemberPageMembershipSubpageItem";
+        type Type = super::MembershipSubpageItem;
+    }
+
+    impl ObjectImpl for MembershipSubpageItem {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpecEnum::new(
+                        "state",
+                        "State",
+                        "The membership state this list contains",
+                        Membership::static_type(),
+                        Membership::default() as i32,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecObject::new(
+                        "model",
+                        "Model",
+                        "The model used for this subview",
+                        gio::ListModel::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "state" => obj.set_state(value.get().unwrap()),
+                "model" => obj.set_model(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "state" => obj.state().to_value(),
+                "model" => obj.model().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+}
+
+glib::wrapper! {
+    pub struct MembershipSubpageItem(ObjectSubclass<imp::MembershipSubpageItem>);
+}
+
+impl MembershipSubpageItem {
+    pub fn new(state: Membership, model: &impl IsA<gio::ListModel>) -> Self {
+        glib::Object::new(&[("state", &state), ("model", model)])
+            .expect("Failed to create MembershipSubpageItem")
+    }
+
+    pub fn state(&self) -> Membership {
+        self.imp().state.get()
+    }
+
+    fn set_state(&self, state: Membership) {
+        self.imp().state.set(state);
+    }
+
+    pub fn model(&self) -> &gio::ListModel {
+        self.imp().model.get().unwrap()
+    }
+
+    fn set_model(&self, model: gio::ListModel) {
+        self.imp().model.set(model).unwrap();
+    }
+}
diff --git a/src/session/content/room_details/member_page/members_list_view/membership_subpage_row.rs 
b/src/session/content/room_details/member_page/members_list_view/membership_subpage_row.rs
new file mode 100644
index 000000000..e0f4ac96b
--- /dev/null
+++ b/src/session/content/room_details/member_page/members_list_view/membership_subpage_row.rs
@@ -0,0 +1,172 @@
+use adw::subclass::prelude::*;
+use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate};
+
+use crate::session::content::room_details::member_page::MembershipSubpageItem;
+
+mod imp {
+    use std::cell::RefCell;
+
+    use glib::{signal::SignalHandlerId, subclass::InitializingObject};
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/Fractal/content-member-page-membership-subpage-row.ui")]
+    pub struct MembershipSubpageRow {
+        /// The item of this row.
+        pub item: RefCell<Option<MembershipSubpageItem>>,
+        pub gesture: gtk::GestureClick,
+        #[template_child]
+        pub members_count: TemplateChild<gtk::Label>,
+        pub members_count_handler_id: RefCell<Option<SignalHandlerId>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MembershipSubpageRow {
+        const NAME: &'static str = "ContentMemberPageMembershipSubpageRow";
+        type Type = super::MembershipSubpageRow;
+        type ParentType = adw::ActionRow;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for MembershipSubpageRow {
+        fn properties() -> &'static [glib::ParamSpec] {
+            use once_cell::sync::Lazy;
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpecObject::new(
+                        "item",
+                        "Item",
+                        "The item of this row",
+                        MembershipSubpageItem::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
+                    glib::ParamSpecString::new(
+                        "label",
+                        "Label",
+                        "The label to show for this row",
+                        None,
+                        glib::ParamFlags::READABLE,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "item" => obj.set_item(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "item" => obj.item().to_value(),
+                "label" => obj.label().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            self.gesture.set_touch_only(false);
+            self.gesture.set_button(gdk::BUTTON_PRIMARY);
+
+            self.gesture
+                .connect_released(clone!(@weak obj => move |_, _, _, _| {
+                    if let Some(item) = obj.item() {
+                        obj.activate_action(
+                                        "members.subpage",
+                                        Some(&(item.state() as u32).to_variant()),
+                                    )
+                                    .unwrap();
+                    }
+                }));
+
+            self.gesture
+                .set_propagation_phase(gtk::PropagationPhase::Capture);
+            obj.add_controller(&self.gesture);
+        }
+    }
+
+    impl WidgetImpl for MembershipSubpageRow {}
+    impl ListBoxRowImpl for MembershipSubpageRow {}
+    impl PreferencesRowImpl for MembershipSubpageRow {}
+    impl ActionRowImpl for MembershipSubpageRow {}
+}
+
+glib::wrapper! {
+    pub struct MembershipSubpageRow(ObjectSubclass<imp::MembershipSubpageRow>)
+        @extends gtk::Widget, adw::ActionRow, @implements gtk::Accessible;
+}
+
+impl MembershipSubpageRow {
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create MembershipSubpageRow")
+    }
+
+    pub fn item(&self) -> Option<MembershipSubpageItem> {
+        self.imp().item.borrow().clone()
+    }
+
+    pub fn set_item(&self, item: Option<MembershipSubpageItem>) {
+        let priv_ = self.imp();
+        let prev_item = self.item();
+
+        if prev_item == item {
+            return;
+        }
+
+        if let Some(signal_id) = priv_.members_count_handler_id.take() {
+            if let Some(prev_item) = prev_item {
+                prev_item.disconnect(signal_id);
+            }
+        }
+
+        if let Some(item) = item.as_ref() {
+            let model = item.model();
+            let signal_id =
+                model.connect_items_changed(clone!(@weak self as obj => move |model, _, _, _| {
+                    obj.member_count_changed(model.n_items());
+                }));
+
+            self.member_count_changed(model.n_items());
+
+            self.imp().members_count_handler_id.replace(Some(signal_id));
+        }
+
+        self.imp().item.replace(item);
+        self.notify("item");
+        self.notify("label");
+    }
+
+    pub fn label(&self) -> Option<String> {
+        Some(self.item()?.state().to_string())
+    }
+
+    fn member_count_changed(&self, n: u32) {
+        self.imp().members_count.set_text(&format!("{}", n));
+    }
+}
+
+impl Default for MembershipSubpageRow {
+    fn default() -> Self {
+        Self::new()
+    }
+}
diff --git a/src/session/content/room_details/member_page/members_list_view/mod.rs 
b/src/session/content/room_details/member_page/members_list_view/mod.rs
new file mode 100644
index 000000000..286e0c70f
--- /dev/null
+++ b/src/session/content/room_details/member_page/members_list_view/mod.rs
@@ -0,0 +1,119 @@
+use adw::{
+    prelude::*,
+    subclass::{bin::BinImpl, prelude::*},
+};
+use gtk::{gio, glib, CompositeTemplate};
+
+use crate::components::{Avatar, Badge};
+
+pub mod extra_lists;
+mod item_row;
+mod member_row;
+mod membership_subpage_item;
+mod membership_subpage_row;
+
+use item_row::ItemRow;
+use member_row::MemberRow;
+pub use membership_subpage_item::MembershipSubpageItem;
+use membership_subpage_row::MembershipSubpageRow;
+
+mod imp {
+    use glib::subclass::InitializingObject;
+    use once_cell::sync::Lazy;
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/Fractal/content-member-page-list-view.ui")]
+    pub struct MembersListView {
+        #[template_child]
+        pub members_list_view: TemplateChild<gtk::ListView>,
+        pub model: glib::WeakRef<gio::ListModel>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for MembersListView {
+        const NAME: &'static str = "ContentMembersListView";
+        type Type = super::MembersListView;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            Avatar::static_type();
+            Badge::static_type();
+            MemberRow::static_type();
+            ItemRow::static_type();
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for MembersListView {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpecObject::new(
+                    "model",
+                    "Model",
+                    "The model used for this view",
+                    gio::ListModel::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() {
+                "model" => obj.set_model(value.get::<Option<&gio::ListModel>>().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "model" => obj.model().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+    impl WidgetImpl for MembersListView {}
+    impl BinImpl for MembersListView {}
+}
+
+glib::wrapper! {
+    pub struct MembersListView(ObjectSubclass<imp::MembersListView>)
+        @extends gtk::Widget, adw::Bin;
+}
+
+impl MembersListView {
+    pub fn new(model: &impl IsA<gio::ListModel>) -> Self {
+        glib::Object::new(&[("model", model)]).expect("Failed to create MembersListView")
+    }
+
+    pub fn model(&self) -> Option<gio::ListModel> {
+        self.imp().model.upgrade()
+    }
+
+    pub fn set_model(&self, model: Option<&impl IsA<gio::ListModel>>) {
+        let model: Option<&gio::ListModel> = model.map(|model| model.upcast_ref());
+        if self.model().as_ref() == model {
+            return;
+        }
+
+        self.imp()
+            .members_list_view
+            .set_model(Some(&gtk::NoSelection::new(model)));
+
+        self.imp().model.set(model);
+        self.notify("model");
+    }
+}
diff --git a/src/session/content/room_details/member_page/mod.rs 
b/src/session/content/room_details/member_page/mod.rs
index 795a287a5..2a58f825f 100644
--- a/src/session/content/room_details/member_page/mod.rs
+++ b/src/session/content/room_details/member_page/mod.rs
@@ -1,29 +1,37 @@
-use adw::{prelude::*, subclass::prelude::*};
+use adw::{
+    prelude::*,
+    subclass::{bin::BinImpl, prelude::*},
+};
+use gettextrs::gettext;
 use gtk::{
+    gio,
     glib::{self, clone, closure},
     CompositeTemplate,
 };
 use log::warn;
 
 mod member_menu;
-mod member_row;
+mod members_list_view;
+
+use members_list_view::{MembersListView, MembershipSubpageItem};
 
-use self::{member_menu::MemberMenu, member_row::MemberRow};
+use self::member_menu::MemberMenu;
 use crate::{
-    components::{Avatar, Badge},
-    ngettext_f,
     prelude::*,
     session::{
-        content::RoomDetails,
+        content::room_details::member_page::members_list_view::extra_lists::ExtraLists,
         room::{Member, Membership, RoomAction},
         Room, User, UserActions,
     },
     spawn,
 };
 
-const MAX_LIST_HEIGHT: i32 = 300;
-
 mod imp {
+    use std::{
+        cell::{Cell, RefCell},
+        collections::HashMap,
+    };
+
     use glib::subclass::InitializingObject;
     use once_cell::{sync::Lazy, unsync::OnceCell};
 
@@ -32,36 +40,27 @@ mod imp {
     #[derive(Debug, Default, CompositeTemplate)]
     #[template(resource = "/org/gnome/Fractal/content-member-page.ui")]
     pub struct MemberPage {
-        pub room: OnceCell<Room>,
-        #[template_child]
-        pub member_count: TemplateChild<gtk::Label>,
-        #[template_child]
-        pub invite_button: TemplateChild<gtk::Button>,
+        pub room: RefCell<Option<Room>>,
         #[template_child]
         pub members_search_entry: TemplateChild<gtk::SearchEntry>,
         #[template_child]
-        pub members_list_view: TemplateChild<gtk::ListView>,
+        pub list_stack: TemplateChild<gtk::Stack>,
         #[template_child]
-        pub members_scroll: TemplateChild<gtk::ScrolledWindow>,
+        pub invite_button: TemplateChild<gtk::Button>,
         pub member_menu: OnceCell<MemberMenu>,
-        #[template_child]
-        pub invited_section: TemplateChild<adw::PreferencesGroup>,
-        #[template_child]
-        pub invited_list_view: TemplateChild<gtk::ListView>,
-        #[template_child]
-        pub invited_scroll: TemplateChild<gtk::ScrolledWindow>,
+        pub list_stack_children: RefCell<HashMap<Membership, glib::WeakRef<MembersListView>>>,
+        pub state: Cell<Membership>,
+        pub invite_action_watch: RefCell<Option<gtk::ExpressionWatch>>,
     }
 
     #[glib::object_subclass]
     impl ObjectSubclass for MemberPage {
         const NAME: &'static str = "ContentMemberPage";
         type Type = super::MemberPage;
-        type ParentType = adw::PreferencesPage;
+        type ParentType = adw::Bin;
 
         fn class_init(klass: &mut Self::Class) {
-            Avatar::static_type();
-            Badge::static_type();
-            MemberRow::static_type();
+            MembersListView::static_type();
             Self::bind_template(klass);
 
             klass.install_action("member.verify", None, move |widget, _, _| {
@@ -71,6 +70,28 @@ mod imp {
                     warn!("No member was selected to be verified");
                 }
             });
+
+            klass.install_action("members.subpage", Some("u"), move |widget, _, param| {
+                use std::convert::TryFrom;
+
+                let state = param
+                    .and_then(|variant| variant.get::<u32>())
+                    .and_then(|u| Membership::try_from(u).ok());
+
+                if let Some(state) = state {
+                    widget.set_state(state);
+                }
+            });
+
+            klass.install_action("members.previous", None, move |widget, _, _| {
+                if widget.state() == Membership::Join {
+                    widget
+                        .activate_action("details.previous-page", None)
+                        .unwrap();
+                } else {
+                    widget.set_state(Membership::Join);
+                }
+            });
         }
 
         fn instance_init(obj: &InitializingObject<Self>) {
@@ -87,7 +108,7 @@ mod imp {
                         "Room",
                         "The room backing all details of the member page",
                         Room::static_type(),
-                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
                     ),
                     glib::ParamSpecObject::new(
                         "member-menu",
@@ -96,6 +117,14 @@ mod imp {
                         MemberMenu::static_type(),
                         glib::ParamFlags::READABLE,
                     ),
+                    glib::ParamSpecEnum::new(
+                        "state",
+                        "State",
+                        "The membership state of the displayed members",
+                        Membership::static_type(),
+                        Membership::default() as i32,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
                 ]
             });
 
@@ -111,33 +140,35 @@ mod imp {
         ) {
             match pspec.name() {
                 "room" => obj.set_room(value.get().unwrap()),
+                "state" => obj.set_state(value.get().unwrap()),
+
                 _ => unimplemented!(),
             }
         }
 
         fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
             match pspec.name() {
-                "room" => self.room.get().to_value(),
+                "room" => obj.room().to_value(),
                 "member-menu" => obj.member_menu().to_value(),
+                "state" => obj.state().to_value(),
                 _ => unimplemented!(),
             }
         }
 
-        fn constructed(&self, obj: &Self::Type) {
-            self.parent_constructed(obj);
-
-            obj.init_members_list();
-            obj.init_invited_list();
-            obj.init_invite_button();
+        fn dispose(&self, _: &Self::Type) {
+            if let Some(invite_action) = self.invite_action_watch.take() {
+                invite_action.unwatch();
+            }
         }
     }
+
     impl WidgetImpl for MemberPage {}
-    impl PreferencesPageImpl for MemberPage {}
+    impl BinImpl for MemberPage {}
 }
 
 glib::wrapper! {
     pub struct MemberPage(ObjectSubclass<imp::MemberPage>)
-        @extends gtk::Widget, adw::PreferencesPage;
+        @extends gtk::Widget, adw::Bin;
 }
 
 impl MemberPage {
@@ -145,35 +176,34 @@ impl MemberPage {
         glib::Object::new(&[("room", room)]).expect("Failed to create MemberPage")
     }
 
-    pub fn room(&self) -> &Room {
-        self.imp().room.get().unwrap()
-    }
-
-    fn set_room(&self, room: Room) {
-        self.imp().room.set(room).expect("Room already initialized");
+    pub fn room(&self) -> Option<Room> {
+        self.imp().room.borrow().as_ref().cloned()
     }
 
-    fn init_members_list(&self) {
+    pub fn set_room(&self, room: Option<Room>) {
         let priv_ = self.imp();
-        let members = self.room().members();
+        let prev_room = self.room();
 
-        // Only keep the members that are in the join membership state
-        let joined_expression = gtk::PropertyExpression::new(
-            Member::static_type(),
-            gtk::Expression::NONE,
-            "membership",
-        )
-        .chain_closure::<bool>(closure!(
-            |_: Option<glib::Object>, membership: Membership| { membership == Membership::Join }
-        ));
-        let joined_filter = gtk::BoolFilter::new(Some(joined_expression));
-        let joined_members = gtk::FilterListModel::new(Some(members), Some(&joined_filter));
+        if prev_room == room {
+            return;
+        }
 
-        // Set up the members count.
-        self.member_count_changed(joined_members.n_items());
-        joined_members.connect_items_changed(clone!(@weak self as obj => move |members, _, _, _| {
-            obj.member_count_changed(members.n_items());
-        }));
+        if let Some(invite_action) = priv_.invite_action_watch.take() {
+            invite_action.unwatch();
+        }
+
+        if let Some(room) = room.as_ref() {
+            self.init_members_list(room);
+            self.init_invite_button(room);
+            self.set_state(Membership::Join);
+        }
+
+        priv_.room.replace(room);
+        self.notify("room");
+    }
+
+    fn init_members_list(&self, room: &Room) {
+        let priv_ = self.imp();
 
         // Sort the members list by power level, then display name.
         let sorter = gtk::MultiSorter::new();
@@ -187,6 +217,7 @@ impl MemberPage {
                 .sort_order(gtk::SortType::Descending)
                 .build(),
         );
+
         sorter.append(&gtk::StringSorter::new(Some(
             &gtk::PropertyExpression::new(
                 Member::static_type(),
@@ -194,134 +225,29 @@ impl MemberPage {
                 "display-name",
             ),
         )));
-        let sorted_members = gtk::SortListModel::new(Some(&joined_members), Some(&sorter));
-
-        fn search_string(member: Member) -> String {
-            format!(
-                "{} {} {} {}",
-                member.display_name(),
-                member.user_id(),
-                member.role(),
-                member.power_level(),
-            )
-        }
-
-        let member_expr = gtk::ClosureExpression::new::<String, &[gtk::Expression], _>(
-            &[],
-            closure!(|member: Option<Member>| { member.map(search_string).unwrap_or_default() }),
-        );
-        let filter = gtk::StringFilter::builder()
-            .match_mode(gtk::StringFilterMatchMode::Substring)
-            .expression(&member_expr)
-            .ignore_case(true)
-            .build();
-        priv_
-            .members_search_entry
-            .bind_property("text", &filter, "search")
-            .flags(glib::BindingFlags::SYNC_CREATE)
-            .build();
 
-        let filter_model = gtk::FilterListModel::new(Some(&sorted_members), Some(&filter));
-        let model = gtk::NoSelection::new(Some(&filter_model));
-        priv_.members_list_view.set_model(Some(&model));
-    }
+        let members = gtk::SortListModel::new(Some(room.members()), Some(&sorter));
 
-    fn member_count_changed(&self, n: u32) {
-        let priv_ = self.imp();
-        priv_
-            .member_count
-            // Translators: Do NOT translate the content between '{' and '}', this is a variable
-            // name.
-            .set_text(&ngettext_f(
-                "1 Member",
-                "{n} Members",
-                n,
-                &[("n", &n.to_string())],
-            ));
-        // FIXME: This won't be needed when we can request the natural height
-        // on AdwPreferencesPage
-        // See: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/77
-        if n > 5 {
-            priv_.members_scroll.set_min_content_height(MAX_LIST_HEIGHT);
-        } else {
-            priv_.members_scroll.set_min_content_height(-1);
-        }
-    }
-
-    fn init_invited_list(&self) {
-        let priv_ = self.imp();
-        let members = self.room().members();
+        let joined_members = self.build_filtered_list(&members, Membership::Join);
+        let invited_members = self.build_filtered_list(&members, Membership::Invite);
+        let banned_members = self.build_filtered_list(&members, Membership::Ban);
 
-        // Only keep the members that are in the join membership state
-        let invited_expression = gtk::PropertyExpression::new(
-            Member::static_type(),
-            gtk::Expression::NONE,
-            "membership",
-        )
-        .chain_closure::<bool>(closure!(
-            |_: Option<glib::Object>, membership: Membership| { membership == Membership::Invite }
-        ));
-        let invited_filter = gtk::BoolFilter::new(Some(invited_expression));
-        let invited_members = gtk::FilterListModel::new(Some(members), Some(&invited_filter));
-
-        // Set up the invited section visibility and the invited count.
-        self.invited_count_changed(invited_members.n_items());
-        invited_members.connect_items_changed(
-            clone!(@weak self as obj => move |members, _, _, _| {
-                obj.invited_count_changed(members.n_items());
-            }),
+        let main_list = ExtraLists::new(
+            &joined_members,
+            &MembershipSubpageItem::new(Membership::Invite, &invited_members),
+            &MembershipSubpageItem::new(Membership::Ban, &banned_members),
         );
 
-        // Sort the invited list by display name.
-        let sorter = gtk::StringSorter::new(Some(&gtk::PropertyExpression::new(
-            Member::static_type(),
-            gtk::Expression::NONE,
-            "display-name",
-        )));
-        let sorted_invited = gtk::SortListModel::new(Some(&invited_members), Some(&sorter));
-
-        let model = gtk::NoSelection::new(Some(&sorted_invited));
-        priv_.invited_list_view.set_model(Some(&model));
-    }
-
-    fn invited_count_changed(&self, n: u32) {
-        let priv_ = self.imp();
-        priv_.invited_section.set_visible(n > 0);
-        priv_
-            .invited_section
-            // Translators: Do NOT translate the content between '{' and '}', this is a variable
-            // name.
-            .set_title(&ngettext_f(
-                "1 Invited",
-                "{n} Invited",
-                n,
-                &[("n", &n.to_string())],
-            ));
-        // FIXME: This won't be needed when we can request the natural height
-        // on AdwPreferencesPage
-        // See: https://gitlab.gnome.org/GNOME/libadwaita/-/issues/77
-        if n > 5 {
-            priv_.invited_scroll.set_min_content_height(MAX_LIST_HEIGHT);
-        } else {
-            priv_.invited_scroll.set_min_content_height(-1);
-        }
-    }
-
-    fn init_invite_button(&self) {
-        let invite_button = &*self.imp().invite_button;
-
-        let invite_possible = self.room().new_allowed_expr(RoomAction::Invite);
-        const NONE_OBJECT: Option<&glib::Object> = None;
-        invite_possible.bind(invite_button, "sensitive", NONE_OBJECT);
-
-        invite_button.connect_clicked(clone!(@weak self as obj => move |_| {
-            let window = obj
-            .root()
-            .unwrap()
-            .downcast::<RoomDetails>()
-            .unwrap();
-            window.present_invite_subpage();
-        }));
+        let mut list_stack_children = priv_.list_stack_children.borrow_mut();
+        let joined_view = MembersListView::new(&main_list);
+        priv_.list_stack.add_child(&joined_view);
+        list_stack_children.insert(Membership::Join, joined_view.downgrade());
+        let invited_view = MembersListView::new(&invited_members);
+        priv_.list_stack.add_child(&invited_view);
+        list_stack_children.insert(Membership::Invite, invited_view.downgrade());
+        let banned_view = MembersListView::new(&banned_members);
+        priv_.list_stack.add_child(&banned_view);
+        list_stack_children.insert(Membership::Ban, banned_view.downgrade());
     }
 
     pub fn member_menu(&self) -> &MemberMenu {
@@ -352,4 +278,120 @@ impl MemberPage {
             member.upcast::<User>().verify_identity().await;
         }));
     }
+
+    pub fn state(&self) -> Membership {
+        self.imp().state.get()
+    }
+
+    pub fn set_state(&self, state: Membership) {
+        let priv_ = self.imp();
+
+        if self.state() == state {
+            return;
+        }
+
+        if state == Membership::Join {
+            priv_
+                .list_stack
+                .set_transition_type(gtk::StackTransitionType::SlideRight)
+        } else {
+            priv_
+                .list_stack
+                .set_transition_type(gtk::StackTransitionType::SlideLeft)
+        }
+
+        if let Some(window) = self.root().and_then(|w| w.downcast::<adw::Window>().ok()) {
+            match state {
+                Membership::Invite => window.set_title(Some(&gettext("Invited Room Members"))),
+                Membership::Ban => window.set_title(Some(&gettext("Banned Room Members"))),
+                _ => window.set_title(Some(&gettext("Room Members"))),
+            }
+        }
+
+        if let Some(view) = priv_
+            .list_stack_children
+            .borrow()
+            .get(&state)
+            .and_then(glib::WeakRef::upgrade)
+        {
+            priv_.list_stack.set_visible_child(&view);
+        }
+
+        self.imp().state.set(state);
+        self.notify("state");
+    }
+
+    fn build_filtered_list(
+        &self,
+        model: &impl IsA<gio::ListModel>,
+        state: Membership,
+    ) -> gio::ListModel {
+        let membership_expression = gtk::PropertyExpression::new(
+            Member::static_type(),
+            gtk::Expression::NONE,
+            "membership",
+        )
+        .chain_closure::<bool>(closure!(
+            |_: Option<glib::Object>, this_state: Membership| this_state == state
+        ));
+
+        let membership_filter = gtk::BoolFilter::new(Some(&membership_expression));
+
+        fn search_string(member: Member) -> String {
+            format!(
+                "{} {} {} {}",
+                member.display_name(),
+                member.user_id(),
+                member.role(),
+                member.power_level(),
+            )
+        }
+
+        let member_expr = gtk::ClosureExpression::new::<String, &[gtk::Expression], _>(
+            &[],
+            closure!(|member: Option<Member>| { member.map(search_string).unwrap_or_default() }),
+        );
+        let search_filter = gtk::StringFilter::builder()
+            .match_mode(gtk::StringFilterMatchMode::Substring)
+            .expression(&member_expr)
+            .ignore_case(true)
+            .build();
+
+        self.imp()
+            .members_search_entry
+            .bind_property("text", &search_filter, "search")
+            .flags(glib::BindingFlags::SYNC_CREATE)
+            .build();
+
+        let filter = gtk::EveryFilter::new();
+
+        filter.append(&membership_filter);
+        filter.append(&search_filter);
+
+        let filter_model = gtk::FilterListModel::new(Some(model), Some(&filter));
+        filter_model.upcast()
+    }
+
+    fn init_invite_button(&self, room: &Room) {
+        let invite_possible = room.new_allowed_expr(RoomAction::Invite);
+
+        let watch = invite_possible.watch(
+            glib::Object::NONE,
+            clone!(@weak self as obj => move || {
+                obj.update_invite_button();
+            }),
+        );
+
+        self.imp().invite_action_watch.replace(Some(watch));
+        self.update_invite_button();
+    }
+
+    fn update_invite_button(&self) {
+        if let Some(invite_action) = &*self.imp().invite_action_watch.borrow() {
+            let allow_invite = invite_action
+                .evaluate_as::<bool>()
+                .expect("Created expression needs to be valid and a boolean");
+            self.imp().invite_button.set_visible(allow_invite);
+        };
+    }
 }
diff --git a/src/session/content/room_details/mod.rs b/src/session/content/room_details/mod.rs
index 9fc0e9904..e5043991c 100644
--- a/src/session/content/room_details/mod.rs
+++ b/src/session/content/room_details/mod.rs
@@ -1,6 +1,8 @@
 mod invite_subpage;
 mod member_page;
 
+use std::convert::From;
+
 use adw::{prelude::*, subclass::prelude::*};
 use gettextrs::gettext;
 use gtk::{
@@ -17,8 +19,54 @@ use crate::{
     utils::{and_expr, or_expr},
 };
 
+#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
+#[repr(u32)]
+#[enum_type(name = "RoomDetailsPageName")]
+pub enum PageName {
+    General,
+    Members,
+    Invite,
+}
+
+impl Default for PageName {
+    fn default() -> Self {
+        Self::General
+    }
+}
+
+impl glib::variant::StaticVariantType for PageName {
+    fn static_variant_type() -> std::borrow::Cow<'static, glib::VariantTy> {
+        String::static_variant_type()
+    }
+}
+
+impl glib::variant::FromVariant for PageName {
+    fn from_variant(variant: &glib::variant::Variant) -> Option<Self> {
+        match variant.str()? {
+            "general" => Some(PageName::General),
+            "members" => Some(PageName::Members),
+            "invite" => Some(PageName::Invite),
+            _ => None,
+        }
+    }
+}
+
+impl glib::variant::ToVariant for PageName {
+    fn to_variant(&self) -> glib::variant::Variant {
+        match self {
+            PageName::General => "general",
+            PageName::Members => "members",
+            PageName::Invite => "invite",
+        }
+        .to_variant()
+    }
+}
+
 mod imp {
-    use std::cell::Cell;
+    use std::{
+        cell::{Cell, RefCell},
+        collections::HashMap,
+    };
 
     use glib::subclass::InitializingObject;
     use once_cell::unsync::OnceCell;
@@ -31,6 +79,8 @@ mod imp {
         pub room: OnceCell<Room>,
         pub avatar_chooser: OnceCell<gtk::FileChooserNative>,
         #[template_child]
+        pub main_stack: TemplateChild<gtk::Stack>,
+        #[template_child]
         pub avatar_remove_button: TemplateChild<adw::Bin>,
         #[template_child]
         pub avatar_edit_button: TemplateChild<adw::Bin>,
@@ -44,15 +94,19 @@ mod imp {
         pub room_topic_entry: TemplateChild<CustomEntry>,
         #[template_child]
         pub room_topic_label: TemplateChild<gtk::Label>,
-        pub member_page: OnceCell<MemberPage>,
+        #[template_child]
+        pub members_count: TemplateChild<gtk::Label>,
         pub edit_mode: Cell<bool>,
+        pub list_stack_children: RefCell<HashMap<PageName, glib::WeakRef<gtk::Widget>>>,
+        pub visible_page: Cell<PageName>,
+        pub previous_visible_page: RefCell<Vec<PageName>>,
     }
 
     #[glib::object_subclass]
     impl ObjectSubclass for RoomDetails {
         const NAME: &'static str = "RoomDetails";
         type Type = super::RoomDetails;
-        type ParentType = adw::PreferencesWindow;
+        type ParentType = adw::Window;
 
         fn class_init(klass: &mut Self::Class) {
             CustomEntry::static_type();
@@ -63,6 +117,16 @@ mod imp {
             klass.install_action("details.remove-avatar", None, move |widget, _, _| {
                 widget.room().store_avatar(None)
             });
+            klass.install_action("details.next-page", Some("s"), move |widget, _, param| {
+                let page = param
+                    .and_then(|variant| variant.get::<PageName>())
+                    .expect("The parameter need to be a valid PageName");
+
+                widget.next_page(page);
+            });
+            klass.install_action("details.previous-page", None, move |widget, _, _| {
+                widget.previous_page();
+            });
         }
 
         fn instance_init(obj: &InitializingObject<Self>) {
@@ -74,13 +138,23 @@ mod imp {
         fn properties() -> &'static [glib::ParamSpec] {
             use once_cell::sync::Lazy;
             static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
-                vec![glib::ParamSpecObject::new(
-                    "room",
-                    "Room",
-                    "The room backing all details of the preference window",
-                    Room::static_type(),
-                    glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
-                )]
+                vec![
+                    glib::ParamSpecObject::new(
+                        "room",
+                        "Room",
+                        "The room backing all details of the preference window",
+                        Room::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecEnum::new(
+                        "visible-page",
+                        "Visible Page",
+                        "The page currently visible",
+                        PageName::static_type(),
+                        PageName::default() as i32,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
+                ]
             });
 
             PROPERTIES.as_ref()
@@ -95,13 +169,15 @@ mod imp {
         ) {
             match pspec.name() {
                 "room" => obj.set_room(value.get().unwrap()),
+                "visible-page" => obj.set_visible_page(value.get().unwrap()),
                 _ => unimplemented!(),
             }
         }
 
-        fn property(&self, _obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
             match pspec.name() {
                 "room" => self.room.get().to_value(),
+                "visible-page" => obj.visible_page().to_value(),
                 _ => unimplemented!(),
             }
         }
@@ -109,26 +185,34 @@ mod imp {
         fn constructed(&self, obj: &Self::Type) {
             self.parent_constructed(obj);
 
-            let member_page = MemberPage::new(obj.room());
-            obj.add(&member_page);
-            self.member_page.set(member_page).unwrap();
-
             obj.init_avatar();
             obj.init_edit_toggle();
             obj.init_avatar_chooser();
+            obj.init_member_action();
+
+            self.main_stack
+                .connect_visible_child_notify(clone!(@weak obj => move |_| {
+                    obj.notify("visible-page");
+                }));
+
+            let members = obj.room().members();
+            members.connect_items_changed(clone!(@weak obj => move |members, _, _, _| {
+                obj.member_count_changed(members.n_items());
+            }));
+
+            obj.member_count_changed(members.n_items());
         }
     }
 
     impl WidgetImpl for RoomDetails {}
     impl WindowImpl for RoomDetails {}
     impl AdwWindowImpl for RoomDetails {}
-    impl PreferencesWindowImpl for RoomDetails {}
 }
 
 glib::wrapper! {
     /// Preference Window to display and update room details.
     pub struct RoomDetails(ObjectSubclass<imp::RoomDetails>)
-        @extends gtk::Widget, gtk::Window, adw::Window, gtk::Root, adw::PreferencesWindow, @implements 
gtk::Accessible;
+        @extends gtk::Widget, gtk::Window, adw::Window, gtk::Root, @implements gtk::Accessible;
 }
 
 impl RoomDetails {
@@ -146,6 +230,63 @@ impl RoomDetails {
         self.imp().room.set(room).expect("Room already initialized");
     }
 
+    pub fn visible_page(&self) -> PageName {
+        self.imp().visible_page.get()
+    }
+
+    pub fn set_visible_page(&self, name: PageName) {
+        let priv_ = self.imp();
+        let prev_name = self.visible_page();
+        let mut list_stack_children = priv_.list_stack_children.borrow_mut();
+
+        if prev_name == name {
+            return;
+        }
+
+        match name {
+            PageName::General => {
+                self.set_title(Some(&gettext("Room Details")));
+                priv_.main_stack.set_visible_child_name("general");
+            }
+            PageName::Members => {
+                let members_page = if let Some(members_page) = list_stack_children
+                    .get(&PageName::Members)
+                    .and_then(glib::object::WeakRef::upgrade)
+                {
+                    members_page
+                } else {
+                    let members_page = MemberPage::new(self.room()).upcast::<gtk::Widget>();
+                    list_stack_children.insert(PageName::Members, members_page.downgrade());
+                    self.imp().main_stack.add_child(&members_page);
+                    members_page
+                };
+
+                self.set_title(Some(&gettext("Room Members")));
+                priv_.main_stack.set_visible_child(&members_page);
+            }
+            PageName::Invite => {
+                priv_.main_stack.set_visible_child_name("general");
+                let invite_page = if let Some(invite_page) = list_stack_children
+                    .get(&PageName::Invite)
+                    .and_then(glib::object::WeakRef::upgrade)
+                {
+                    invite_page
+                } else {
+                    let invite_page = InviteSubpage::new(self.room()).upcast::<gtk::Widget>();
+                    list_stack_children.insert(PageName::Invite, invite_page.downgrade());
+                    priv_.main_stack.add_child(&invite_page);
+                    invite_page
+                };
+
+                self.set_title(Some(&gettext("Invite new Members")));
+                priv_.main_stack.set_visible_child(&invite_page);
+            }
+        }
+
+        priv_.visible_page.set(name);
+        self.notify("visible-page");
+    }
+
     fn init_avatar(&self) {
         let priv_ = self.imp();
         let avatar_remove_button = &priv_.avatar_remove_button;
@@ -254,18 +395,39 @@ impl RoomDetails {
         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);
+    fn member_count_changed(&self, n: u32) {
+        self.imp().members_count.set_text(&format!("{}", n));
     }
 
-    pub fn close_invite_subpage(&self) {
-        self.set_title(Some(&gettext("Room Details")));
-        self.close_subpage();
+    fn next_page(&self, next_page: PageName) {
+        let priv_ = self.imp();
+        let prev_page = self.visible_page();
+
+        if prev_page == next_page {
+            return;
+        }
+
+        priv_
+            .main_stack
+            .set_transition_type(gtk::StackTransitionType::SlideLeft);
+
+        priv_.previous_visible_page.borrow_mut().push(prev_page);
+        self.set_visible_page(next_page);
     }
 
-    pub fn member_page(&self) -> &MemberPage {
-        self.imp().member_page.get().unwrap()
+    fn previous_page(&self) {
+        let priv_ = self.imp();
+
+        priv_
+            .main_stack
+            .set_transition_type(gtk::StackTransitionType::SlideRight);
+
+        if let Some(prev_page) = priv_.previous_visible_page.borrow_mut().pop() {
+            self.set_visible_page(prev_page);
+        } else {
+            // If there isn't any previous page close the dialog since it was opened on a
+            // specific page
+            self.close();
+        };
     }
 }
diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs
index bd0e0eadb..d27bffe43 100644
--- a/src/session/content/room_history/mod.rs
+++ b/src/session/content/room_history/mod.rs
@@ -37,7 +37,7 @@ use crate::{
     components::{CustomEntry, DragOverlay, Pill, ReactionChooser, RoomTitle},
     i18n::gettext_f,
     session::{
-        content::{MarkdownPopover, RoomDetails},
+        content::{room_details, MarkdownPopover, RoomDetails},
         room::{Room, RoomType, SupportedEvent, Timeline, TimelineItem, TimelineState},
         user::UserExt,
     },
@@ -135,10 +135,10 @@ mod imp {
             });
 
             klass.install_action("room-history.details", None, move |widget, _, _| {
-                widget.open_room_details("general");
+                widget.open_room_details(room_details::PageName::General);
             });
             klass.install_action("room-history.invite-members", None, move |widget, _, _| {
-                widget.open_invite_members();
+                widget.open_room_details(room_details::PageName::Invite);
             });
 
             klass.install_action("room-history.scroll-down", None, move |widget, _, _| {
@@ -574,19 +574,10 @@ impl RoomHistory {
     }
 
     /// Opens the room details on the page with the given name.
-    pub fn open_room_details(&self, page_name: &str) {
+    pub fn open_room_details(&self, page_name: room_details::PageName) {
         if let Some(room) = self.room() {
             let window = RoomDetails::new(&self.parent_window(), &room);
-            window.set_property("visible-page-name", page_name);
-            window.show();
-        }
-    }
-
-    pub fn open_invite_members(&self) {
-        if let Some(room) = self.room() {
-            let window = RoomDetails::new(&self.parent_window(), &room);
-            window.set_property("visible-page-name", "members");
-            window.present_invite_subpage();
+            window.set_visible_page(page_name);
             window.show();
         }
     }
diff --git a/src/session/room/member.rs b/src/session/room/member.rs
index 00630d537..488265a77 100644
--- a/src/session/room/member.rs
+++ b/src/session/room/member.rs
@@ -1,3 +1,4 @@
+use gettextrs::gettext;
 use gtk::{glib, prelude::*, subclass::prelude::*};
 use matrix_sdk::{
     room::RoomMember,
@@ -9,6 +10,7 @@ use matrix_sdk::{
         OwnedMxcUri, UserId,
     },
 };
+use num_enum::{IntoPrimitive, TryFromPrimitive};
 
 use crate::{
     prelude::*,
@@ -21,7 +23,7 @@ use crate::{
     },
 };
 
-#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
+#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum, IntoPrimitive, TryFromPrimitive)]
 #[repr(u32)]
 #[enum_type(name = "Membership")]
 pub enum Membership {
@@ -39,6 +41,20 @@ impl Default for Membership {
     }
 }
 
+impl std::fmt::Display for Membership {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        let label = match self {
+            Membership::Leave => gettext("Left"),
+            Membership::Join => gettext("Joined"),
+            Membership::Invite => gettext("Invited"),
+            Membership::Ban => gettext("Banned"),
+            Membership::Knock => gettext("Knocked"),
+            Membership::Custom => gettext("Custom"),
+        };
+        f.write_str(&label)
+    }
+}
+
 impl From<&MembershipState> for Membership {
     fn from(state: &MembershipState) -> Self {
         match state {


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