[fractal/fractal-next] content: Add room explore



commit ec7720c0fd12ed093cd32ba437a57f4da5870347
Author: Julian Sparber <julian sparber net>
Date:   Thu Jun 10 16:53:09 2021 +0200

    content: Add room explore

 data/resources/resources.gresource.xml          |   3 +
 data/resources/style.css                        |   8 +
 data/resources/ui/content-explore-item.ui       |  14 ++
 data/resources/ui/content-explore.ui            |  99 ++++++++
 data/resources/ui/content-public-room-row.ui    |  89 +++++++
 data/resources/ui/content.ui                    |   6 +
 data/resources/ui/session.ui                    |   1 +
 src/components/spinner_button.rs                |   2 +-
 src/meson.build                                 |   5 +
 src/session/content/content.rs                  |  34 ++-
 src/session/content/explore/explore.rs          | 239 ++++++++++++++++++
 src/session/content/explore/mod.rs              |   9 +
 src/session/content/explore/public_room.rs      | 212 ++++++++++++++++
 src/session/content/explore/public_room_list.rs | 313 ++++++++++++++++++++++++
 src/session/content/explore/public_room_row.rs  | 231 +++++++++++++++++
 src/session/content/mod.rs                      |   2 +
 src/session/room_list.rs                        | 133 +++++++++-
 17 files changed, 1389 insertions(+), 11 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index ebcba03e..e3fa5bdb 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -4,6 +4,9 @@
     <file compressed="true" preprocess="xml-stripblanks" alias="shortcuts.ui">ui/shortcuts.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" alias="content.ui">ui/content.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-room-history.ui">ui/content-room-history.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-explore.ui">ui/content-explore.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-explore-item.ui">ui/content-explore-item.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-public-room-row.ui">ui/content-public-room-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" alias="content-item.ui">ui/content-item.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-item-row-menu.ui">ui/content-item-row-menu.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-message-row.ui">ui/content-message-row.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index ee1a5c59..4b64ba80 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -161,3 +161,11 @@ headerbar.flat {
   background-color: @theme_base_color;
   padding: 6px;
 }
+
+.explore listview {
+  padding: 12px;
+}
+
+.bold {
+  font-weight: bold;
+}
diff --git a/data/resources/ui/content-explore-item.ui b/data/resources/ui/content-explore-item.ui
new file mode 100644
index 00000000..bd83d29e
--- /dev/null
+++ b/data/resources/ui/content-explore-item.ui
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="GtkListItem">
+    <property name="activatable">False</property>
+    <property name="selectable">False</property>
+    <property name="child">
+      <object class="ContentPublicRoomRow">
+        <binding name="public-room">
+            <lookup name="item">GtkListItem</lookup>
+        </binding>
+      </object>
+    </property>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-explore.ui b/data/resources/ui/content-explore.ui
new file mode 100644
index 00000000..76dc2fab
--- /dev/null
+++ b/data/resources/ui/content-explore.ui
@@ -0,0 +1,99 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentExplore" parent="AdwBin">
+    <property name="vexpand">True</property>
+    <property name="hexpand">True</property>
+    <child>
+      <object class="GtkBox">
+        <property name="orientation">vertical</property>
+        <child>
+          <object class="AdwHeaderBar" id="headerbar">
+            <property name="show-start-title-buttons" bind-source="ContentExplore" bind-property="compact" 
bind-flags="sync-create"/>
+            <child type="start">
+              <object class="GtkButton" id="back">
+                <property name="visible" bind-source="ContentExplore" bind-property="compact" 
bind-flags="sync-create"/>
+                <property name="icon-name">go-previous-symbolic</property>
+                <property name="action-name">content.go-back</property>
+              </object>
+            </child>
+            <child type="title">
+              <object class="AdwClamp">
+                <property name="maximum-size">400</property>
+                <property name="hexpand">True</property>
+                <property name="child">
+                  <object class="GtkSearchEntry" id="search_entry">
+                  </object>
+                </property>
+              </object>
+            </child>
+            <child type="end">
+              <object class="GtkComboBoxText" id="network_menu">
+                <property name="active-id">matrix</property>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child>
+          <object class="GtkStack" id="stack">
+            <property name="visible-child">spinner</property>
+            <property name="transition-type">crossfade</property>
+            <style>
+              <class name="explore"/>
+            </style>
+            <child>
+              <object class="GtkSpinner" id="spinner">
+                <property name="spinning">True</property>
+                <property name="valign">center</property>
+                <property name="halign">center</property>
+                <property name="vexpand">True</property>
+                <style>
+                  <class name="session-loading-spinner"/>
+                </style>
+              </object>
+            </child>
+            <child>
+              <object class="GtkLabel" id="empty_label">
+                <property name="valign">center</property>
+                <property name="halign">center</property>
+                <property name="vexpand">True</property>
+                <property name="label" translatable="yes">No rooms matching the search where found</property>
+                <style>
+                  <class name="bold"/>
+                </style>
+              </object>
+            </child>
+            <child>
+              <object class="GtkScrolledWindow" id="scrolled_window">
+                <property name="vexpand">True</property>
+                <property name="hscrollbar-policy">never</property>
+                <property name="child">
+                  <object class="AdwClampScrollable">
+                    <property name="vexpand">True</property>
+                    <property name="hexpand">True</property>
+                    <property name="maximum-size">800</property>
+                    <property name="child">
+                      <object class="GtkListView" id="listview">
+                        <style>
+                          <class name="content"/>
+                        </style>
+                        <property name="factory">
+                          <object class="GtkBuilderListItemFactory">
+                            <property 
name="resource">/org/gnome/FractalNext/content-explore-item.ui</property>
+                          </object>
+                        </property>
+                        <accessibility>
+                          <property name="label" translatable="yes">Room List</property>
+                        </accessibility>
+                      </object>
+                    </property>
+                  </object>
+                </property>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </child>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content-public-room-row.ui b/data/resources/ui/content-public-room-row.ui
new file mode 100644
index 00000000..ce440919
--- /dev/null
+++ b/data/resources/ui/content-public-room-row.ui
@@ -0,0 +1,89 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentPublicRoomRow" parent="AdwBin">
+    <property name="child">
+      <object class="GtkBox">
+        <property name="spacing">12</property>
+        <property name="margin-start">12</property>
+        <property name="margin-end">12</property>
+        <property name="margin-top">12</property>
+        <property name="margin-bottom">12</property>
+        <child>
+          <object class="ComponentsAvatar" id="avatar">
+            <property name="size">48</property>
+            <property name="valign">start</property>
+          </object>
+        </child>
+        <child>
+          <object class="GtkBox">
+            <property name="orientation">vertical</property>
+            <property name="hexpand">True</property>
+            <property name="halign">start</property>
+            <child>
+              <object class="GtkLabel" id="display_name">
+                <property name="halign">start</property>
+                <property name="ellipsize">end</property>
+                <property name="xalign">0</property>
+                <style>
+                  <class name="bold"/>
+                </style>
+              </object>
+            </child>
+            <child>
+              <object class="GtkLabel" id="description">
+                <property name="halign">start</property>
+                <property name="ellipsize">end</property>
+                <property name="lines">4</property>
+                <property name="wrap">True</property>
+                <property name="wrap-mode">word-char</property>
+                <property name="xalign">0</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkLabel" id="alias">
+                <property name="ellipsize">end</property>
+                <property name="halign">start</property>
+                <property name="xalign">0</property>
+                <style>
+                  <class name="dim-label"/>
+                </style>
+              </object>
+            </child>
+          </object>
+        </child>
+        <child type="end">
+          <object class="GtkBox">
+            <property name="orientation">vertical</property>
+            <property name="spacing">12</property>
+            <child>
+              <object class="SpinnerButton" id="button">
+                <property name="valign">center</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkBox">
+                <property name="halign">center</property>
+                <child>
+                  <object class="GtkImage">
+                    <property name="icon-name">system-users-symbolic</property>
+                    <style>
+                      <class name="dim-label"/>
+                    </style>
+                  </object>
+                </child>
+                <child>
+                  <object class="GtkLabel" id="members_count">
+                    <style>
+                      <class name="dim-label"/>
+                    </style>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+        </child>
+      </object>
+    </property>
+  </template>
+</interface>
+
diff --git a/data/resources/ui/content.ui b/data/resources/ui/content.ui
index a8c96a95..7c1a8086 100644
--- a/data/resources/ui/content.ui
+++ b/data/resources/ui/content.ui
@@ -25,6 +25,12 @@
                 <property name="room" bind-source="Content" bind-property="room" bind-flags="sync-create"/>
               </object>
             </child>
+            <child>
+              <object class="ContentExplore" id="explore">
+                <property name="compact" bind-source="Content" bind-property="compact" 
bind-flags="sync-create"/>
+                <property name="session" bind-source="Content" bind-property="session" 
bind-flags="sync-create"/>
+              </object>
+            </child>
           </object>
         </child>
       </object>
diff --git a/data/resources/ui/session.ui b/data/resources/ui/session.ui
index 363738c9..7d5f4d59 100644
--- a/data/resources/ui/session.ui
+++ b/data/resources/ui/session.ui
@@ -59,6 +59,7 @@
                     <property name="compact" bind-source="content" bind-property="folded" 
bind-flags="sync-create"/>
                     <property name="room" bind-source="Session" bind-property="selected-room" 
bind-flags="sync-create | bidirectional"/>
                     <property name="content-type" bind-source="Session" 
bind-property="selected-content-type" bind-flags="sync-create | bidirectional"/>
+                    <property name="session">Session</property>
                     <property name="error-list">error_list</property>
                   </object>
                 </child>
diff --git a/src/components/spinner_button.rs b/src/components/spinner_button.rs
index 92dcab40..edf0b387 100644
--- a/src/components/spinner_button.rs
+++ b/src/components/spinner_button.rs
@@ -89,7 +89,7 @@ mod imp {
 
 glib::wrapper! {
     pub struct SpinnerButton(ObjectSubclass<imp::SpinnerButton>)
-        @extends gtk::Widget, gtk::Button, @implements gtk::Accessible;
+        @extends gtk::Widget, gtk::Button, @implements gtk::Accessible, gtk::Actionable;
 }
 
 /// A widget displaying a `User`
diff --git a/src/meson.build b/src/meson.build
index d0734274..db9f9599 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -40,6 +40,11 @@ sources = files(
   'session/mod.rs',
   'session/content/content.rs',
   'session/content/divider_row.rs',
+  'session/content/explore/explore.rs',
+  'session/content/explore/mod.rs',
+  'session/content/explore/public_room.rs',
+  'session/content/explore/public_room_list.rs',
+  'session/content/explore/public_room_row.rs',
   'session/content/item_row.rs',
   'session/content/invite.rs',
   'session/content/markdown_popover.rs',
diff --git a/src/session/content/content.rs b/src/session/content/content.rs
index ac16a977..74b0eeeb 100644
--- a/src/session/content/content.rs
+++ b/src/session/content/content.rs
@@ -1,8 +1,7 @@
 use crate::session::{
-    content::ContentType,
-    content::Invite,
-    content::RoomHistory,
+    content::{ContentType, Explore, Invite, RoomHistory},
     room::{Room, RoomType},
+    Session,
 };
 use adw::subclass::prelude::*;
 use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
@@ -10,12 +9,14 @@ use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTem
 mod imp {
     use super::*;
     use glib::{signal::SignalHandlerId, subclass::InitializingObject};
+    use once_cell::sync::Lazy;
     use std::cell::{Cell, RefCell};
 
     #[derive(Debug, Default, CompositeTemplate)]
     #[template(resource = "/org/gnome/FractalNext/content.ui")]
     pub struct Content {
         pub compact: Cell<bool>,
+        pub session: RefCell<Option<Session>>,
         pub room: RefCell<Option<Room>>,
         pub content_type: Cell<ContentType>,
         pub error_list: RefCell<Option<gio::ListStore>>,
@@ -26,6 +27,8 @@ mod imp {
         pub room_history: TemplateChild<RoomHistory>,
         #[template_child]
         pub invite: TemplateChild<Invite>,
+        #[template_child]
+        pub explore: TemplateChild<Explore>,
     }
 
     #[glib::object_subclass]
@@ -37,6 +40,7 @@ mod imp {
         fn class_init(klass: &mut Self::Class) {
             RoomHistory::static_type();
             Invite::static_type();
+            Explore::static_type();
             Self::bind_template(klass);
             klass.set_accessible_role(gtk::AccessibleRole::Group);
 
@@ -52,9 +56,15 @@ mod imp {
 
     impl ObjectImpl for Content {
         fn properties() -> &'static [glib::ParamSpec] {
-            use once_cell::sync::Lazy;
             static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
                 vec![
+                    glib::ParamSpec::new_object(
+                        "session",
+                        "Session",
+                        "The session",
+                        Session::static_type(),
+                        glib::ParamFlags::READWRITE,
+                    ),
                     glib::ParamSpec::new_boolean(
                         "compact",
                         "Compact",
@@ -102,6 +112,9 @@ mod imp {
                     let compact = value.get().unwrap();
                     self.compact.set(compact);
                 }
+                "session" => {
+                    let _ = self.session.replace(value.get().unwrap());
+                }
                 "room" => {
                     let room = value.get().unwrap();
                     obj.set_room(room);
@@ -117,6 +130,7 @@ mod imp {
         fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
             match pspec.name() {
                 "compact" => self.compact.get().to_value(),
+                "session" => obj.session().to_value(),
                 "room" => obj.room().to_value(),
                 "error-list" => self.error_list.borrow().to_value(),
                 "content-type" => obj.content_type().to_value(),
@@ -135,8 +149,13 @@ glib::wrapper! {
 }
 
 impl Content {
-    pub fn new() -> Self {
-        glib::Object::new(&[]).expect("Failed to create Content")
+    pub fn new(session: &Session) -> Self {
+        glib::Object::new(&[("session", session)]).expect("Failed to create Content")
+    }
+
+    pub fn session(&self) -> Option<Session> {
+        let priv_ = imp::Content::from_instance(self);
+        priv_.session.borrow().to_owned()
     }
 
     pub fn content_type(&self) -> ContentType {
@@ -208,7 +227,8 @@ impl Content {
                 }
             }
             ContentType::Explore => {
-                todo!("Display explore");
+                priv_.explore.init();
+                priv_.stack.set_visible_child(&*priv_.explore);
             }
         }
     }
diff --git a/src/session/content/explore/explore.rs b/src/session/content/explore/explore.rs
new file mode 100644
index 00000000..8d3e3e08
--- /dev/null
+++ b/src/session/content/explore/explore.rs
@@ -0,0 +1,239 @@
+use crate::{
+    session::content::explore::{PublicRoom, PublicRoomList, PublicRoomRow},
+    session::Session,
+};
+
+use matrix_sdk::api::r0::thirdparty::get_protocols;
+
+use crate::utils::do_async;
+use adw::subclass::prelude::*;
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+use log::error;
+
+mod imp {
+    use super::*;
+    use glib::subclass::InitializingObject;
+    use once_cell::sync::Lazy;
+    use std::cell::{Cell, RefCell};
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-explore.ui")]
+    pub struct Explore {
+        pub compact: Cell<bool>,
+        pub session: RefCell<Option<Session>>,
+        #[template_child]
+        pub stack: TemplateChild<gtk::Stack>,
+        #[template_child]
+        pub spinner: TemplateChild<gtk::Spinner>,
+        #[template_child]
+        pub empty_label: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub search_entry: TemplateChild<gtk::SearchEntry>,
+        #[template_child]
+        pub network_menu: TemplateChild<gtk::ComboBoxText>,
+        #[template_child]
+        pub listview: TemplateChild<gtk::ListView>,
+        #[template_child]
+        pub scrolled_window: TemplateChild<gtk::ScrolledWindow>,
+        pub public_room_list: RefCell<Option<PublicRoomList>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for Explore {
+        const NAME: &'static str = "ContentExplore";
+        type Type = super::Explore;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            PublicRoom::static_type();
+            PublicRoomList::static_type();
+            PublicRoomRow::static_type();
+            Self::bind_template(klass);
+            klass.set_accessible_role(gtk::AccessibleRole::Group);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for Explore {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpec::new_boolean(
+                        "compact",
+                        "Compact",
+                        "Wheter a compact view is used or not",
+                        false,
+                        glib::ParamFlags::READWRITE,
+                    ),
+                    glib::ParamSpec::new_object(
+                        "session",
+                        "Session",
+                        "The session",
+                        Session::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() {
+                "compact" => self.compact.set(value.get().unwrap()),
+                "session" => obj.set_session(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "compact" => self.compact.get().to_value(),
+                "session" => obj.session().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+            let adj = self.scrolled_window.vadjustment().unwrap();
+
+            adj.connect_value_changed(clone!(@weak obj => move |adj| {
+                if adj.upper() - adj.value() < adj.page_size() * 2.0 {
+                    let priv_ = imp::Explore::from_instance(&obj);
+                    if let Some(public_room_list) = &*priv_.public_room_list.borrow() {
+                        public_room_list.load_public_rooms(false);
+                    }
+                }
+            }));
+
+            self.search_entry
+                .connect_search_changed(clone!(@weak obj => move |_| {
+                    let priv_ = imp::Explore::from_instance(&obj);
+                    if let Some(public_room_list) = &*priv_.public_room_list.borrow() {
+                        let text = priv_.search_entry.text().as_str().to_string();
+                        let network = priv_.network_menu.active_id().map(|id| id.as_str().to_owned());
+                        public_room_list.search(Some(text), None, network);
+                    };
+                }));
+        }
+    }
+
+    impl WidgetImpl for Explore {}
+    impl BinImpl for Explore {}
+}
+
+glib::wrapper! {
+    pub struct Explore(ObjectSubclass<imp::Explore>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl Explore {
+    pub fn new(session: &Session) -> Self {
+        glib::Object::new(&[("session", session)]).expect("Failed to create Explore")
+    }
+
+    pub fn session(&self) -> Option<Session> {
+        let priv_ = imp::Explore::from_instance(self);
+        priv_.session.borrow().to_owned()
+    }
+
+    pub fn init(&self) {
+        let priv_ = imp::Explore::from_instance(self);
+        self.load_protocols();
+        if let Some(public_room_list) = &*priv_.public_room_list.borrow() {
+            public_room_list.load_public_rooms(true);
+        }
+    }
+
+    pub fn set_session(&self, session: Option<Session>) {
+        let priv_ = imp::Explore::from_instance(self);
+
+        if session == self.session() {
+            return;
+        }
+
+        if let Some(ref session) = session {
+            let public_room_list = PublicRoomList::new(session);
+            priv_
+                .listview
+                .set_model(Some(&gtk::NoSelection::new(Some(&public_room_list))));
+
+            public_room_list.connect_notify_local(
+                Some("loading"),
+                clone!(@weak self as obj => move |_, _| {
+                    obj.set_visible_child();
+                }),
+            );
+
+            public_room_list.connect_notify_local(
+                Some("empty"),
+                clone!(@weak self as obj => move |_, _| {
+                    obj.set_visible_child();
+                }),
+            );
+
+            priv_.public_room_list.replace(Some(public_room_list));
+        }
+
+        priv_.session.replace(session);
+        self.notify("session");
+    }
+
+    fn set_visible_child(&self) {
+        let priv_ = imp::Explore::from_instance(self);
+        if let Some(public_room_list) = &*priv_.public_room_list.borrow() {
+            if public_room_list.loading() {
+                priv_.stack.set_visible_child(&*priv_.spinner);
+            } else if public_room_list.empty() {
+                priv_.stack.set_visible_child(&*priv_.empty_label);
+            } else {
+                priv_.stack.set_visible_child(&*priv_.scrolled_window);
+            }
+        }
+    }
+
+    fn set_protocols(&self, protocols: get_protocols::Response) {
+        let priv_ = imp::Explore::from_instance(self);
+
+        for protocol in protocols
+            .protocols
+            .into_iter()
+            .flat_map(|(_, protocol)| protocol.instances)
+        {
+            priv_
+                .network_menu
+                .append(Some(&protocol.instance_id), &protocol.desc);
+        }
+    }
+
+    fn load_protocols(&self) {
+        let priv_ = imp::Explore::from_instance(self);
+        let client = self.session().unwrap().client().clone();
+
+        priv_.network_menu.remove_all();
+        priv_.network_menu.append(Some("matrix"), "Matrix");
+        priv_.network_menu.append(Some("all"), "All rooms");
+        priv_.network_menu.set_active(Some(0));
+
+        do_async(
+            glib::PRIORITY_DEFAULT_IDLE,
+            async move { client.send(get_protocols::Request::new(), None).await },
+            clone!(@weak self as obj => move |result| async move {
+                match result {
+                 Ok(response) => obj.set_protocols(response),
+                 Err(error) => error!("Error loading supported protocols: {}", error),
+                }
+            }),
+        );
+    }
+}
diff --git a/src/session/content/explore/mod.rs b/src/session/content/explore/mod.rs
new file mode 100644
index 00000000..000b8877
--- /dev/null
+++ b/src/session/content/explore/mod.rs
@@ -0,0 +1,9 @@
+mod explore;
+mod public_room;
+mod public_room_list;
+mod public_room_row;
+
+pub use self::explore::Explore;
+pub use self::public_room::PublicRoom;
+pub use self::public_room_list::PublicRoomList;
+pub use self::public_room_row::PublicRoomRow;
diff --git a/src/session/content/explore/public_room.rs b/src/session/content/explore/public_room.rs
new file mode 100644
index 00000000..6869cf92
--- /dev/null
+++ b/src/session/content/explore/public_room.rs
@@ -0,0 +1,212 @@
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
+use matrix_sdk::directory::PublicRoomsChunk;
+
+use crate::session::{room::Room, Avatar, Session};
+
+mod imp {
+    use super::*;
+    use glib::signal::SignalHandlerId;
+    use once_cell::sync::{Lazy, OnceCell};
+    use std::cell::{Cell, RefCell};
+
+    #[derive(Debug, Default)]
+    pub struct PublicRoom {
+        pub session: OnceCell<Session>,
+        pub matrix_public_room: OnceCell<PublicRoomsChunk>,
+        pub avatar: OnceCell<Avatar>,
+        pub room: OnceCell<Room>,
+        pub is_pending: Cell<bool>,
+        pub room_handler: RefCell<Option<SignalHandlerId>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for PublicRoom {
+        const NAME: &'static str = "PublicRoom";
+        type Type = super::PublicRoom;
+        type ParentType = glib::Object;
+    }
+
+    impl ObjectImpl for PublicRoom {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpec::new_object(
+                        "session",
+                        "Session",
+                        "The session",
+                        Session::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpec::new_object(
+                        "room",
+                        "Room",
+                        "The room, this is only set if the user is alerady a member",
+                        Room::static_type(),
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_boolean(
+                        "pending",
+                        "Pending",
+                        "A room is pending when the user already clicked to join a room",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_object(
+                        "avatar",
+                        "Avatar",
+                        "The Avatar of this room",
+                        Avatar::static_type(),
+                        glib::ParamFlags::READABLE,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            _obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "session" => self.session.set(value.get().unwrap()).unwrap(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "session" => obj.session().to_value(),
+                "avatar" => obj.avatar().to_value(),
+                "room" => obj.room().to_value(),
+                "pending" => obj.is_pending().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            self.avatar.set(Avatar::new(obj.session(), None)).unwrap();
+
+            obj.session()
+                .room_list()
+                .connect_pending_rooms_changed(clone!(@weak obj => move |_| {
+                    if let Some(matrix_public_room) = obj.matrix_public_room() {
+                        obj.set_pending(obj.session()
+                        .room_list()
+                        .is_pending_room(&matrix_public_room.room_id.clone().into()));
+                    }
+                }));
+        }
+
+        fn dispose(&self, obj: &Self::Type) {
+            if let Some(handler_id) = self.room_handler.take() {
+                obj.session().room_list().disconnect(handler_id);
+            }
+        }
+    }
+}
+
+glib::wrapper! {
+    pub struct PublicRoom(ObjectSubclass<imp::PublicRoom>);
+}
+
+impl PublicRoom {
+    pub fn new(session: &Session) -> Self {
+        glib::Object::new(&[("session", session)]).expect("Failed to create Room")
+    }
+
+    pub fn session(&self) -> &Session {
+        let priv_ = imp::PublicRoom::from_instance(&self);
+        priv_.session.get().unwrap()
+    }
+
+    pub fn avatar(&self) -> &Avatar {
+        let priv_ = imp::PublicRoom::from_instance(self);
+        priv_.avatar.get().unwrap()
+    }
+
+    /// The room if the user is already a member of this room.
+    pub fn room(&self) -> Option<&Room> {
+        let priv_ = imp::PublicRoom::from_instance(self);
+        priv_.room.get()
+    }
+
+    fn set_room(&self, room: Room) {
+        let priv_ = imp::PublicRoom::from_instance(self);
+        priv_.room.set(room).unwrap();
+        self.notify("room");
+    }
+
+    fn set_pending(&self, is_pending: bool) {
+        let priv_ = imp::PublicRoom::from_instance(self);
+
+        if self.is_pending() == is_pending {
+            return;
+        }
+
+        priv_.is_pending.set(is_pending);
+        self.notify("pending");
+    }
+
+    pub fn is_pending(&self) -> bool {
+        let priv_ = imp::PublicRoom::from_instance(self);
+        priv_.is_pending.get()
+    }
+
+    pub fn set_matrix_public_room(&self, room: PublicRoomsChunk) {
+        let priv_ = imp::PublicRoom::from_instance(self);
+
+        self.avatar().set_display_name(room.name.clone());
+        self.avatar().set_url(room.avatar_url.clone());
+
+        if let Some(room) = self.session().room_list().get(&room.room_id) {
+            self.set_room(room);
+        } else {
+            let room_id = room.room_id.clone();
+            let handler_id = self.session().room_list().connect_items_changed(
+                clone!(@weak self as obj => move |room_list, _, _, _| {
+                    if let Some(room) = room_list.get(&room_id) {
+                        let priv_ = imp::PublicRoom::from_instance(&obj);
+                        if let Some(handler_id) = priv_.room_handler.take() {
+                            obj.set_room(room);
+                            room_list.disconnect(handler_id);
+                        }
+                    }
+                }),
+            );
+
+            priv_.room_handler.replace(Some(handler_id));
+        }
+
+        self.set_pending(
+            self.session()
+                .room_list()
+                .is_pending_room(&room.room_id.clone().into()),
+        );
+
+        priv_.matrix_public_room.set(room).unwrap();
+    }
+
+    pub fn matrix_public_room(&self) -> Option<&PublicRoomsChunk> {
+        let priv_ = imp::PublicRoom::from_instance(self);
+        priv_.matrix_public_room.get()
+    }
+
+    pub fn join_or_view(&self) {
+        let session = self.session();
+        if let Some(room) = self.room() {
+            session.set_selected_room(Some(room.clone()));
+        } else {
+            if let Some(matrix_public_room) = self.matrix_public_room() {
+                session
+                    .room_list()
+                    .join_by_id_or_alias(matrix_public_room.room_id.clone().into());
+            }
+        }
+    }
+}
diff --git a/src/session/content/explore/public_room_list.rs b/src/session/content/explore/public_room_list.rs
new file mode 100644
index 00000000..2a63e0f6
--- /dev/null
+++ b/src/session/content/explore/public_room_list.rs
@@ -0,0 +1,313 @@
+use crate::{
+    session::{content::explore::PublicRoom, Session},
+    utils::do_async,
+};
+use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
+use log::error;
+use matrix_sdk::{
+    api::r0::directory::{
+        get_public_rooms_filtered::Request as PublicRoomsRequest,
+        get_public_rooms_filtered::Response as PublicRoomsResponse,
+    },
+    assign,
+    directory::{Filter, RoomNetwork},
+    identifiers::ServerNameBox,
+    uint,
+};
+use std::convert::TryFrom;
+
+mod imp {
+    use once_cell::sync::Lazy;
+    use std::cell::{Cell, RefCell};
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct PublicRoomList {
+        pub list: RefCell<Vec<PublicRoom>>,
+        pub search_term: RefCell<Option<String>>,
+        pub network: RefCell<Option<String>>,
+        pub server: RefCell<Option<String>>,
+        pub next_batch: RefCell<Option<String>>,
+        pub loading: Cell<bool>,
+        pub request_sent: Cell<bool>,
+        pub total_room_count_estimate: Cell<Option<u64>>,
+        pub session: RefCell<Option<Session>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for PublicRoomList {
+        const NAME: &'static str = "PublicRoomList";
+        type Type = super::PublicRoomList;
+        type ParentType = glib::Object;
+        type Interfaces = (gio::ListModel,);
+    }
+
+    impl ObjectImpl for PublicRoomList {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpec::new_object(
+                        "session",
+                        "Session",
+                        "The session",
+                        Session::static_type(),
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpec::new_boolean(
+                        "loading",
+                        "Loading",
+                        "Whether a response is loaded or not",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_boolean(
+                        "empty",
+                        "Empty",
+                        "Whether matching rooms are found or not",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_boolean(
+                        "complete",
+                        "Complete",
+                        "Whether the every search result is loaded or not",
+                        false,
+                        glib::ParamFlags::READABLE,
+                    ),
+                ]
+            });
+
+            PROPERTIES.as_ref()
+        }
+
+        fn set_property(
+            &self,
+            _obj: &Self::Type,
+            _id: usize,
+            value: &glib::Value,
+            pspec: &glib::ParamSpec,
+        ) {
+            match pspec.name() {
+                "session" => {
+                    let _ = self.session.replace(value.get().unwrap());
+                }
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "session" => obj.session().to_value(),
+                "loading" => obj.loading().to_value(),
+                "empty" => obj.empty().to_value(),
+                "complete" => obj.complete().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl ListModelImpl for PublicRoomList {
+        fn item_type(&self, _list_model: &Self::Type) -> glib::Type {
+            PublicRoom::static_type()
+        }
+        fn n_items(&self, _list_model: &Self::Type) -> u32 {
+            self.list.borrow().len() as u32
+        }
+        fn item(&self, _list_model: &Self::Type, position: u32) -> Option<glib::Object> {
+            self.list
+                .borrow()
+                .get(position as usize)
+                .map(glib::object::Cast::upcast_ref::<glib::Object>)
+                .cloned()
+        }
+    }
+}
+
+glib::wrapper! {
+    pub struct PublicRoomList(ObjectSubclass<imp::PublicRoomList>)
+        @implements gio::ListModel;
+}
+
+impl PublicRoomList {
+    pub fn new(session: &Session) -> Self {
+        glib::Object::new(&[("session", session)]).expect("Failed to create PublicRoomList")
+    }
+
+    pub fn session(&self) -> Option<Session> {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        priv_.session.borrow().to_owned()
+    }
+
+    pub fn loading(&self) -> bool {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        self.request_sent() && priv_.list.borrow().is_empty()
+    }
+
+    pub fn empty(&self) -> bool {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        !self.request_sent() && priv_.list.borrow().is_empty()
+    }
+
+    pub fn complete(&self) -> bool {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        priv_.next_batch.borrow().is_none()
+    }
+
+    fn request_sent(&self) -> bool {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        priv_.request_sent.get()
+    }
+
+    fn set_request_sent(&self, request_sent: bool) {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        priv_.request_sent.set(request_sent);
+
+        self.notify("loading");
+        self.notify("empty");
+        self.notify("complete");
+    }
+
+    pub fn search(
+        &self,
+        search_term: Option<String>,
+        server: Option<String>,
+        network: Option<String>,
+    ) {
+        let priv_ = imp::PublicRoomList::from_instance(&self);
+
+        if priv_.search_term.borrow().as_ref() == search_term.as_ref()
+            && priv_.server.borrow().as_ref() == server.as_ref()
+            && priv_.network.borrow().as_ref() == network.as_ref()
+        {
+            return;
+        }
+
+        priv_.search_term.replace(search_term);
+        priv_.server.replace(server);
+        priv_.network.replace(network);
+        self.load_public_rooms(true);
+    }
+
+    fn handle_public_rooms_response(&self, response: PublicRoomsResponse) {
+        let priv_ = imp::PublicRoomList::from_instance(&self);
+        let session = &self.session().unwrap();
+
+        priv_.next_batch.replace(response.next_batch.to_owned());
+        priv_
+            .total_room_count_estimate
+            .replace(response.total_room_count_estimate.map(Into::into));
+
+        let (position, removed, added) = {
+            let mut list = priv_.list.borrow_mut();
+            let position = list.len();
+            let added = response.chunk.len();
+            let mut new_rooms = response
+                .chunk
+                .into_iter()
+                .map(|matrix_room| {
+                    let room = PublicRoom::new(session);
+                    room.set_matrix_public_room(matrix_room);
+                    room
+                })
+                .collect();
+
+            let empty_row = list.pop().unwrap_or(PublicRoom::new(session));
+            list.append(&mut new_rooms);
+
+            if !self.complete() {
+                list.push(empty_row);
+                if position == 0 {
+                    (position, 0, added + 1)
+                } else {
+                    (position - 1, 0, added)
+                }
+            } else {
+                (position, 1, added)
+            }
+        };
+
+        if added > 0 {
+            self.items_changed(position as u32, removed as u32, added as u32);
+        }
+        self.set_request_sent(false);
+    }
+
+    fn is_valid_response(
+        &self,
+        search_term: Option<String>,
+        server: Option<String>,
+        network: Option<String>,
+    ) -> bool {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+        priv_.search_term.borrow().as_ref() == search_term.as_ref()
+            && priv_.server.borrow().as_ref() == server.as_ref()
+            && priv_.network.borrow().as_ref() == network.as_ref()
+    }
+
+    pub fn load_public_rooms(&self, clear: bool) {
+        let priv_ = imp::PublicRoomList::from_instance(self);
+
+        if self.request_sent() && !clear {
+            return;
+        }
+
+        if clear {
+            // Clear the previous list
+            let removed = priv_.list.borrow().len();
+            priv_.list.borrow_mut().clear();
+            let _ = priv_.next_batch.take();
+            self.items_changed(0, removed as u32, 0);
+        }
+
+        self.set_request_sent(true);
+
+        let next_batch = priv_.next_batch.borrow().clone();
+
+        if next_batch.is_none() && !clear {
+            return;
+        }
+
+        let client = self.session().unwrap().client().clone();
+        let search_term = priv_.search_term.borrow().to_owned();
+        let server = priv_.server.borrow().to_owned();
+        let network = priv_.network.borrow().to_owned();
+        let current_search_term = search_term.clone();
+        let current_server = server.clone();
+        let current_network = network.clone();
+
+        do_async(
+            glib::PRIORITY_DEFAULT_IDLE,
+            async move {
+                let room_network = match network.as_deref() {
+                    Some("matrix") => RoomNetwork::Matrix,
+                    Some("all") => RoomNetwork::All,
+                    Some(custom) => RoomNetwork::ThirdParty(custom),
+                    _ => RoomNetwork::default(),
+                };
+                let server = server.and_then(|server| ServerNameBox::try_from(server).ok());
+
+                let request = assign!(PublicRoomsRequest::new(), {
+                  limit: Some(uint!(20)),
+                  since: next_batch.as_deref(),
+                  room_network,
+                  server: server.as_deref(),
+                  filter: assign!(Filter::new(), { generic_search_term: search_term.as_deref() }),
+                });
+                client.public_rooms_filtered(request).await
+            },
+            clone!(@weak self as obj => move |result| async move {
+                // If the search term changed we ignore the response
+                if obj.is_valid_response(current_search_term, current_server, current_network) {
+                    match result {
+                     Ok(response) => obj.handle_public_rooms_response(response),
+                     Err(error) => {
+                        obj.set_request_sent(false);
+                        error!("Error loading public rooms: {}", error)
+                     },
+                    }
+                }
+            }),
+        );
+    }
+}
diff --git a/src/session/content/explore/public_room_row.rs b/src/session/content/explore/public_room_row.rs
new file mode 100644
index 00000000..2027b1ad
--- /dev/null
+++ b/src/session/content/explore/public_room_row.rs
@@ -0,0 +1,231 @@
+use crate::components::Avatar;
+use crate::{components::SpinnerButton, session::content::explore::PublicRoom};
+use adw::prelude::BinExt;
+use adw::subclass::prelude::BinImpl;
+use gettextrs::gettext;
+use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+mod imp {
+    use super::*;
+    use glib::{signal::SignalHandlerId, subclass::InitializingObject};
+    use once_cell::sync::Lazy;
+    use std::cell::RefCell;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/FractalNext/content-public-room-row.ui")]
+    pub struct PublicRoomRow {
+        pub public_room: RefCell<Option<PublicRoom>>,
+        #[template_child]
+        pub avatar: TemplateChild<Avatar>,
+        #[template_child]
+        pub display_name: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub description: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub alias: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub members_count: TemplateChild<gtk::Label>,
+        #[template_child]
+        pub button: TemplateChild<SpinnerButton>,
+        pub original_child: RefCell<Option<gtk::Widget>>,
+        pub pending_handler: RefCell<Option<SignalHandlerId>>,
+        pub room_handler: RefCell<Option<SignalHandlerId>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for PublicRoomRow {
+        const NAME: &'static str = "ContentPublicRoomRow";
+        type Type = super::PublicRoomRow;
+        type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            Avatar::static_type();
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for PublicRoomRow {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpec::new_object(
+                    "public-room",
+                    "Public Room",
+                    "The public room displayed by this row",
+                    PublicRoom::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() {
+                "public-room" => obj.set_public_room(value.get().unwrap()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "public-room" => obj.public_room().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+            self.button.connect_clicked(clone!(@weak obj => move |_| {
+                let priv_ = imp::PublicRoomRow::from_instance(&obj);
+                if let Some(public_room) = &*priv_.public_room.borrow() {
+                    public_room.join_or_view();
+                };
+            }));
+        }
+
+        fn dispose(&self, obj: &Self::Type) {
+            if let Some(ref old_public_room) = obj.public_room() {
+                if let Some(handler) = self.pending_handler.take() {
+                    old_public_room.disconnect(handler);
+                }
+                if let Some(handler_id) = self.room_handler.take() {
+                    old_public_room.disconnect(handler_id);
+                }
+            }
+        }
+    }
+
+    impl WidgetImpl for PublicRoomRow {}
+    impl BinImpl for PublicRoomRow {}
+}
+
+glib::wrapper! {
+    pub struct PublicRoomRow(ObjectSubclass<imp::PublicRoomRow>)
+        @extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
+}
+
+impl PublicRoomRow {
+    pub fn new() -> Self {
+        glib::Object::new(&[]).expect("Failed to create PublicRoomRow")
+    }
+
+    pub fn public_room(&self) -> Option<PublicRoom> {
+        let priv_ = imp::PublicRoomRow::from_instance(&self);
+        priv_.public_room.borrow().clone()
+    }
+
+    pub fn set_public_room(&self, public_room: Option<PublicRoom>) {
+        let priv_ = imp::PublicRoomRow::from_instance(&self);
+        let old_public_room = self.public_room();
+
+        if old_public_room == public_room {
+            return;
+        }
+
+        if let Some(ref old_public_room) = old_public_room {
+            if let Some(handler) = priv_.room_handler.take() {
+                old_public_room.disconnect(handler);
+            }
+            if let Some(handler) = priv_.pending_handler.take() {
+                old_public_room.disconnect(handler);
+            }
+        }
+
+        if let Some(ref public_room) = public_room {
+            if let Some(child) = priv_.original_child.take() {
+                self.set_child(Some(&child));
+            }
+            if let Some(matrix_public_room) = public_room.matrix_public_room() {
+                priv_
+                    .avatar
+                    .set_item(Some(public_room.avatar().clone().upcast()));
+
+                if let Some(ref name) = matrix_public_room.name {
+                    priv_.display_name.set_text(name);
+                } else {
+                    // FIXME: display some other identification for this room
+                    priv_.display_name.set_text("Room without name");
+                }
+
+                let has_topic = if let Some(ref topic) = matrix_public_room.topic {
+                    priv_.description.set_text(topic);
+                    true
+                } else {
+                    false
+                };
+
+                priv_.description.set_visible(has_topic);
+
+                let has_alias = if let Some(ref alias) = matrix_public_room.canonical_alias {
+                    priv_.alias.set_text(alias.as_str());
+                    true
+                } else if let Some(ref alias) = matrix_public_room.aliases.get(0) {
+                    priv_.alias.set_text(&alias.as_str());
+                    true
+                } else {
+                    false
+                };
+
+                priv_.alias.set_visible(has_alias);
+                priv_
+                    .members_count
+                    .set_text(&matrix_public_room.num_joined_members.to_string());
+
+                let pending_handler = public_room.connect_notify_local(
+                    Some("pending"),
+                    clone!(@weak self as obj => move |public_room, _| {
+                            obj.update_button(public_room);
+                    }),
+                );
+
+                priv_.pending_handler.replace(Some(pending_handler));
+
+                let room_handler = public_room.connect_notify_local(
+                    Some("room"),
+                    clone!(@weak self as obj => move |public_room, _| {
+                        obj.update_button(public_room);
+                    }),
+                );
+
+                priv_.room_handler.replace(Some(room_handler));
+
+                self.update_button(public_room);
+            } else {
+                if priv_.original_child.borrow().is_none() {
+                    let spinner = gtk::SpinnerBuilder::new()
+                        .spinning(true)
+                        .margin_top(12)
+                        .margin_bottom(12)
+                        .build();
+                    priv_.original_child.replace(self.child());
+                    self.set_child(Some(&spinner));
+                }
+            }
+        }
+        priv_
+            .avatar
+            .set_item(public_room.clone().map(|room| room.avatar().clone()));
+        priv_.public_room.replace(public_room);
+        self.notify("public-room");
+    }
+
+    fn update_button(&self, public_room: &PublicRoom) {
+        let priv_ = imp::PublicRoomRow::from_instance(&self);
+        if public_room.room().is_some() {
+            priv_.button.set_label(&gettext("View"));
+        } else {
+            priv_.button.set_label(&gettext("Join"));
+        }
+
+        priv_.button.set_loading(public_room.is_pending());
+    }
+}
diff --git a/src/session/content/mod.rs b/src/session/content/mod.rs
index 0de3f250..1b59348d 100644
--- a/src/session/content/mod.rs
+++ b/src/session/content/mod.rs
@@ -1,6 +1,7 @@
 mod content;
 mod content_type;
 mod divider_row;
+mod explore;
 mod invite;
 mod item_row;
 mod markdown_popover;
@@ -11,6 +12,7 @@ mod state_row;
 pub use self::content::Content;
 pub use self::content_type::ContentType;
 use self::divider_row::DividerRow;
+use self::explore::Explore;
 use self::invite::Invite;
 use self::item_row::ItemRow;
 use self::markdown_popover::MarkdownPopover;
diff --git a/src/session/room_list.rs b/src/session/room_list.rs
index 51228741..c3d1f739 100644
--- a/src/session/room_list.rs
+++ b/src/session/room_list.rs
@@ -1,10 +1,22 @@
 use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
 use indexmap::map::IndexMap;
-use matrix_sdk::{deserialized_responses::Rooms as ResponseRooms, identifiers::RoomId};
-
-use crate::session::{room::Room, Session};
+use matrix_sdk::{
+    deserialized_responses::Rooms as ResponseRooms,
+    identifiers::{RoomId, RoomIdOrAliasId},
+};
+
+use crate::{
+    session::{room::Room, Session},
+    utils::do_async,
+    Error,
+};
+use gettextrs::gettext;
+use log::error;
+use std::cell::Cell;
+use std::collections::HashSet;
 
 mod imp {
+    use glib::subclass::Signal;
     use once_cell::sync::{Lazy, OnceCell};
     use std::cell::RefCell;
 
@@ -13,6 +25,7 @@ mod imp {
     #[derive(Debug, Default)]
     pub struct RoomList {
         pub list: RefCell<IndexMap<RoomId, Room>>,
+        pub pending_rooms: RefCell<HashSet<RoomIdOrAliasId>>,
         pub session: OnceCell<Session>,
     }
 
@@ -58,6 +71,16 @@ mod imp {
                 _ => unimplemented!(),
             }
         }
+
+        fn signals() -> &'static [Signal] {
+            static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
+                vec![
+                    Signal::builder("pending-rooms-changed", &[], <()>::static_type().into())
+                        .build(),
+                ]
+            });
+            SIGNALS.as_ref()
+        }
     }
 
     impl ListModelImpl for RoomList {
@@ -93,11 +116,64 @@ impl RoomList {
         priv_.session.get().unwrap()
     }
 
+    pub fn is_pending_room(&self, identifier: &RoomIdOrAliasId) -> bool {
+        let priv_ = imp::RoomList::from_instance(&self);
+        priv_.pending_rooms.borrow().contains(identifier)
+    }
+
+    fn pending_rooms_remove(&self, identifier: &RoomIdOrAliasId) {
+        let priv_ = imp::RoomList::from_instance(&self);
+        priv_.pending_rooms.borrow_mut().remove(identifier);
+        self.emit_by_name("pending-rooms-changed", &[]).unwrap();
+    }
+
+    fn pending_rooms_insert(&self, identifier: RoomIdOrAliasId) {
+        let priv_ = imp::RoomList::from_instance(&self);
+        priv_.pending_rooms.borrow_mut().insert(identifier);
+        self.emit_by_name("pending-rooms-changed", &[]).unwrap();
+    }
+
+    fn pending_rooms_replace_or_remove(&self, identifier: &RoomIdOrAliasId, room_id: RoomId) {
+        let priv_ = imp::RoomList::from_instance(&self);
+        {
+            let mut pending_rooms = priv_.pending_rooms.borrow_mut();
+            pending_rooms.remove(identifier);
+            if !self.contains_key(&room_id) {
+                pending_rooms.insert(room_id.into());
+            }
+        }
+        self.emit_by_name("pending-rooms-changed", &[]).unwrap();
+    }
+
     pub fn get(&self, room_id: &RoomId) -> Option<Room> {
         let priv_ = imp::RoomList::from_instance(&self);
         priv_.list.borrow().get(room_id).cloned()
     }
 
+    /// Waits till the Room becomes available
+    pub async fn get_wait(&self, room_id: RoomId) -> Option<Room> {
+        let priv_ = imp::RoomList::from_instance(&self);
+        if let Some(room) = priv_.list.borrow().get(&room_id) {
+            Some(room.clone())
+        } else {
+            let (sender, receiver) = futures::channel::oneshot::channel();
+
+            let sender = Cell::new(Some(sender));
+            // FIXME: add a timeout
+            let handler_id = self.connect_items_changed(move |obj, _, _, _| {
+                if let Some(room) = obj.get(&room_id) {
+                    if let Some(sender) = sender.take() {
+                        sender.send(Some(room)).unwrap();
+                    }
+                }
+            });
+
+            let room = receiver.await.unwrap();
+            self.disconnect(handler_id);
+            room
+        }
+    }
+
     fn get_full(&self, room_id: &RoomId) -> Option<(usize, RoomId, Room)> {
         let priv_ = imp::RoomList::from_instance(&self);
         priv_
@@ -189,6 +265,7 @@ impl RoomList {
                 })
                 .clone();
 
+            self.pending_rooms_remove(&room_id.into());
             room.handle_left_response(left_room);
         }
 
@@ -203,6 +280,7 @@ impl RoomList {
                 })
                 .clone();
 
+            self.pending_rooms_remove(&room_id.into());
             room.handle_joined_response(joined_room);
         }
 
@@ -217,6 +295,7 @@ impl RoomList {
                 })
                 .clone();
 
+            self.pending_rooms_remove(&room_id.into());
             room.handle_invited_response(invited_room);
         }
 
@@ -224,4 +303,52 @@ impl RoomList {
             self.items_added(added);
         }
     }
+
+    pub fn join_by_id_or_alias(&self, identifier: RoomIdOrAliasId) {
+        let client = self.session().client().clone();
+        let identifier_clone = identifier.clone();
+
+        self.pending_rooms_insert(identifier.clone());
+
+        do_async(
+            glib::PRIORITY_DEFAULT_IDLE,
+            async move {
+                client
+                    .join_room_by_id_or_alias(&identifier_clone, &[])
+                    .await
+            },
+            clone!(@weak self as obj => move |response| async move {
+                match response {
+                    Ok(response) => obj.pending_rooms_replace_or_remove(&identifier, response.room_id),
+                    Err(error) => {
+                        obj.pending_rooms_remove(&identifier);
+                        error!("Joining room {} failed: {}", identifier, error);
+                        let error = Error::new(
+                            error,
+                            clone!(@strong obj => move |_| {
+                                    let error_message = gettext(format!("Failed to join room {}. Try again 
later.", identifier));
+                                    let error_label = 
gtk::LabelBuilder::new().label(&error_message).wrap(true).build();
+                                    Some(error_label.upcast())
+                            }),
+                        );
+                        obj.session().append_error(&error);
+                    }
+                }
+            }),
+        );
+    }
+
+    pub fn connect_pending_rooms_changed<F: Fn(&Self) + 'static>(
+        &self,
+        f: F,
+    ) -> glib::SignalHandlerId {
+        self.connect_local("pending-rooms-changed", true, move |values| {
+            let obj = values[0].get::<Self>().unwrap();
+
+            f(&obj);
+
+            None
+        })
+        .unwrap()
+    }
 }


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