[fractal] explore: Allow to explore custom matrix servers



commit 0232d98dc1813a97acfc785b9bc3525d201abd46
Author: Kévin Commaille <zecakeh tedomum fr>
Date:   Sun Sep 25 17:31:35 2022 +0200

    explore: Allow to explore custom matrix servers

 data/org.gnome.Fractal.gschema.xml.in              |   5 +
 data/resources/resources.gresource.xml             |   2 +
 data/resources/style.css                           |  19 ++
 data/resources/ui/content-explore-server-row.ui    |  37 ++++
 .../ui/content-explore-servers-popover.ui          |  51 +++++
 data/resources/ui/content-explore.ui               |  12 +-
 po/POTFILES.in                                     |   1 +
 src/secret.rs                                      |  13 +-
 src/session/content/explore/mod.rs                 |  79 +++----
 src/session/content/explore/public_room_list.rs    |  18 +-
 src/session/content/explore/server.rs              | 149 +++++++++++++
 src/session/content/explore/server_list.rs         | 209 ++++++++++++++++++
 src/session/content/explore/server_row.rs          |  98 +++++++++
 src/session/content/explore/servers_popover.rs     | 243 +++++++++++++++++++++
 src/session/mod.rs                                 |  21 ++
 src/session/settings.rs                            | 183 ++++++++++++++++
 16 files changed, 1081 insertions(+), 59 deletions(-)
---
diff --git a/data/org.gnome.Fractal.gschema.xml.in b/data/org.gnome.Fractal.gschema.xml.in
index 3a843448c..bd59bfbbb 100644
--- a/data/org.gnome.Fractal.gschema.xml.in
+++ b/data/org.gnome.Fractal.gschema.xml.in
@@ -18,5 +18,10 @@
       <summary>Enable markdown formatting</summary>
       <description>Whether messages should be processed as markdown when sending them</description>
     </key>
+    <key name="sessions" type="s">
+      <default>'[]'</default>
+      <summary>Session settings</summary>
+      <description>Serialized list of settings per session</description>
+    </key>
   </schema>
 </schemalist>
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 817437260..58d0a9fb1 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -76,6 +76,8 @@
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-completion-row.ui">ui/content-completion-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-divider-row.ui">ui/content-divider-row.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-explore-item.ui">ui/content-explore-item.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-explore-server-row.ui">ui/content-explore-server-row.ui</file>
+    <file compressed="true" preprocess="xml-stripblanks" 
alias="content-explore-servers-popover.ui">ui/content-explore-servers-popover.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-invite-subpage.ui">ui/content-invite-subpage.ui</file>
     <file compressed="true" preprocess="xml-stripblanks" 
alias="content-invite.ui">ui/content-invite.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index 1e2974c21..5131931c7 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -549,6 +549,7 @@ message-reactions .reaction-count {
 
 
 /* Explore */
+
 .explore listview {
        background: transparent;
 }
@@ -557,6 +558,24 @@ message-reactions .reaction-count {
   min-width: 64px;
 }
 
+.explore-servers-popover list {
+  background-color: transparent;
+  color: inherit;
+}
+
+.explore-servers-popover list row {
+  min-height: 36px;
+  padding: 0 8px;
+  border-radius: 6px;
+  margin: 0 0 2px;
+}
+
+.explore-servers-popover list row button {
+  min-height: 24px;
+  min-width: 24px;
+}
+
+
 /* Invite */
 
 .invite-room-name {
diff --git a/data/resources/ui/content-explore-server-row.ui b/data/resources/ui/content-explore-server-row.ui
new file mode 100644
index 000000000..e9fd987c6
--- /dev/null
+++ b/data/resources/ui/content-explore-server-row.ui
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ExploreServerRow" parent="GtkListBoxRow">
+    <property name="child">
+      <object class="GtkBox">
+        <property name="spacing">10</property>
+        <child>
+          <object class="GtkLabel">
+            <property name="xalign">0.0</property>
+            <property name="hexpand">True</property>
+            <binding name="label">
+              <lookup name="name" type="Server">
+                <lookup name="server">ExploreServerRow</lookup>
+              </lookup>
+            </binding>
+          </object>
+        </child>
+        <child>
+          <object class="GtkButton" id="remove_button">
+            <binding name="visible">
+              <lookup name="deletable" type="Server">
+                <lookup name="server">ExploreServerRow</lookup>
+              </lookup>
+            </binding>
+            <property name="icon-name">window-close-symbolic</property>
+            <property name="valign">center</property>
+            <property name="halign">center</property>
+            <style>
+              <class name="circular"/>
+              <class name="flat"/>
+            </style>
+          </object>
+        </child>
+      </object>
+    </property>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-explore-servers-popover.ui 
b/data/resources/ui/content-explore-servers-popover.ui
new file mode 100644
index 000000000..cdaa57798
--- /dev/null
+++ b/data/resources/ui/content-explore-servers-popover.ui
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+  <template class="ContentExploreServersPopover" parent="GtkPopover">
+    <property name="has-arrow">false</property>
+    <property name="position">bottom</property>
+    <property name="width-request">260</property>
+    <property name="child">
+      <object class="GtkScrolledWindow" id="scrolled_window">
+        <property name="propagate-natural-height">true</property>
+        <property name="hscrollbar-policy">never</property>
+        <property name="max-content-height">280</property>
+        <property name="child">
+          <object class="GtkBox">
+            <style>
+              <class name="explore-servers-popover"/>
+            </style>
+            <property name="orientation">vertical</property>
+            <property name="spacing">8</property>
+            <child>
+              <object class="GtkListBox" id="listbox">
+                <property name="selection-mode">browse</property>
+              </object>
+            </child>
+            <child>
+              <object class="GtkSeparator"/>
+            </child>
+            <child>
+              <object class="GtkBox">
+                <style>
+                  <class name="linked"/>
+                </style>
+                <child>
+                  <object class="GtkEntry" id="server_entry">
+                    <property name="hexpand">true</property>
+                    <property name="placeholder-text" translatable="yes">Add server…</property>
+                  </object>
+                </child>
+                <child>
+                  <object class="GtkButton">
+                    <property name="icon-name">list-add-symbolic</property>
+                    <property name="action-name">explore-servers-popover.add-server</property>
+                  </object>
+                </child>
+              </object>
+            </child>
+          </object>
+        </property>
+      </object>
+    </property>
+  </template>
+</interface>
diff --git a/data/resources/ui/content-explore.ui b/data/resources/ui/content-explore.ui
index ffa4504c0..f07ed24b9 100644
--- a/data/resources/ui/content-explore.ui
+++ b/data/resources/ui/content-explore.ui
@@ -32,8 +32,15 @@
               </object>
             </child>
             <child type="end">
-              <object class="GtkComboBoxText" id="network_menu">
-                <property name="active-id">matrix</property>
+              <object class="GtkMenuButton" id="servers_button">
+                <property name="valign">center</property>
+                <property name="direction">down</property>
+                <property name="icon-name">format-justify-left-symbolic</property>
+                <property name="popover">
+                  <object class="ContentExploreServersPopover" id="servers_popover">
+                    <property name="session" bind-source="ContentExplore" bind-property="session" 
bind-flags="sync-create"/>
+                  </object>
+                </property>
               </object>
             </child>
           </object>
@@ -101,4 +108,3 @@
     </child>
   </template>
 </interface>
-
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 73ed8c11d..78987dfa3 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -16,6 +16,7 @@ data/resources/ui/account-settings.ui
 data/resources/ui/attachment-dialog.ui
 data/resources/ui/components-auth-dialog.ui
 data/resources/ui/components-loading-listbox-row.ui
+data/resources/ui/content-explore-servers-popover.ui
 data/resources/ui/content-explore.ui
 data/resources/ui/content-invite-subpage.ui
 data/resources/ui/content-invite.ui
diff --git a/src/secret.rs b/src/secret.rs
index 89fdfe3af..e8f5f124b 100644
--- a/src/secret.rs
+++ b/src/secret.rs
@@ -1,4 +1,4 @@
-use std::{collections::HashMap, fmt, path::PathBuf, string::FromUtf8Error};
+use std::{collections::HashMap, ffi::OsStr, fmt, path::PathBuf, string::FromUtf8Error};
 
 use gettextrs::gettext;
 use gtk::{gio, glib};
@@ -186,6 +186,17 @@ impl StoredSession {
 
         (attributes, secret)
     }
+
+    /// Get the unique ID for this `StoredSession`.
+    ///
+    /// This is the name of the folder where the DB is stored.
+    pub fn id(&self) -> &str {
+        self.path
+            .iter()
+            .next_back()
+            .and_then(OsStr::to_str)
+            .unwrap()
+    }
 }
 
 /// A possible error value when converting a `Secret` from a UTF-8 byte vector.
diff --git a/src/session/content/explore/mod.rs b/src/session/content/explore/mod.rs
index 4b94dc16d..a334a1a68 100644
--- a/src/session/content/explore/mod.rs
+++ b/src/session/content/explore/mod.rs
@@ -1,16 +1,19 @@
 mod public_room;
 mod public_room_list;
 mod public_room_row;
+mod server;
+mod server_list;
+mod server_row;
+mod servers_popover;
 
 use adw::subclass::prelude::*;
 use gtk::{glib, glib::clone, prelude::*, CompositeTemplate};
-use log::error;
-use ruma::api::client::thirdparty::get_protocols;
 
 pub use self::{
     public_room::PublicRoom, public_room_list::PublicRoomList, public_room_row::PublicRoomRow,
+    servers_popover::ExploreServersPopover,
 };
-use crate::{session::Session, spawn, spawn_tokio};
+use crate::session::Session;
 
 mod imp {
     use std::cell::{Cell, RefCell};
@@ -34,7 +37,9 @@ mod imp {
         #[template_child]
         pub search_entry: TemplateChild<gtk::SearchEntry>,
         #[template_child]
-        pub network_menu: TemplateChild<gtk::ComboBoxText>,
+        pub servers_button: TemplateChild<gtk::MenuButton>,
+        #[template_child]
+        pub servers_popover: TemplateChild<ExploreServersPopover>,
         #[template_child]
         pub listview: TemplateChild<gtk::ListView>,
         #[template_child]
@@ -121,13 +126,17 @@ mod imp {
 
             self.search_entry
                 .connect_search_changed(clone!(@weak obj => move |_| {
-                    let priv_ = obj.imp();
-                    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);
-                    };
+                    obj.trigger_search();
                 }));
+
+            self.servers_popover.connect_selected_server_changed(
+                clone!(@weak obj => move |_, server| {
+                    if let Some(server) = server {
+                        obj.imp().servers_button.set_label(server.name());
+                        obj.trigger_search();
+                    }
+                }),
+            );
         }
     }
 
@@ -150,8 +159,14 @@ impl Explore {
     }
 
     pub fn init(&self) {
-        self.load_protocols();
-        if let Some(public_room_list) = &*self.imp().public_room_list.borrow() {
+        let priv_ = self.imp();
+
+        priv_.servers_popover.init();
+        priv_
+            .servers_button
+            .set_label(priv_.servers_popover.selected_server().unwrap().name());
+
+        if let Some(public_room_list) = &*priv_.public_room_list.borrow() {
             public_room_list.load_public_rooms(true);
         }
 
@@ -205,38 +220,12 @@ impl Explore {
         }
     }
 
-    fn set_protocols(&self, protocols: get_protocols::v3::Response) {
-        for protocol in protocols
-            .protocols
-            .into_iter()
-            .flat_map(|(_, protocol)| protocol.instances)
-        {
-            self.imp()
-                .network_menu
-                .append(Some(&protocol.instance_id), &protocol.desc);
-        }
-    }
-
-    fn load_protocols(&self) {
-        let network_menu = &self.imp().network_menu;
-        let client = self.session().unwrap().client();
-
-        network_menu.remove_all();
-        network_menu.append(Some("matrix"), "Matrix");
-        network_menu.append(Some("all"), "All rooms");
-        network_menu.set_active(Some(0));
-
-        let handle =
-            spawn_tokio!(async move { client.send(get_protocols::v3::Request::new(), None).await });
-
-        spawn!(
-            glib::PRIORITY_DEFAULT_IDLE,
-            clone!(@weak self as obj => async move {
-                match handle.await.unwrap() {
-                 Ok(response) => obj.set_protocols(response),
-                 Err(error) => error!("Error loading supported protocols: {}", error),
-                }
-            })
-        );
+    fn trigger_search(&self) {
+        let priv_ = self.imp();
+        if let Some(public_room_list) = &*priv_.public_room_list.borrow() {
+            let text = priv_.search_entry.text().as_str().to_string();
+            let server = priv_.servers_popover.selected_server().unwrap();
+            public_room_list.search(Some(text), server);
+        };
     }
 }
diff --git a/src/session/content/explore/public_room_list.rs b/src/session/content/explore/public_room_list.rs
index 8c72548df..8527cd867 100644
--- a/src/session/content/explore/public_room_list.rs
+++ b/src/session/content/explore/public_room_list.rs
@@ -9,6 +9,7 @@ use matrix_sdk::ruma::{
     uint, ServerName,
 };
 
+use super::server::Server;
 use crate::{
     session::{content::explore::PublicRoom, Session},
     spawn, spawn_tokio,
@@ -168,24 +169,21 @@ impl PublicRoomList {
         self.notify("complete");
     }
 
-    pub fn search(
-        &self,
-        search_term: Option<String>,
-        server: Option<String>,
-        network: Option<String>,
-    ) {
+    pub fn search(&self, search_term: Option<String>, server: Server) {
         let priv_ = self.imp();
+        let network = Some(server.network());
+        let server = server.server();
 
         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()
+            && priv_.server.borrow().as_deref() == server
+            && priv_.network.borrow().as_deref() == network
         {
             return;
         }
 
         priv_.search_term.replace(search_term);
-        priv_.server.replace(server);
-        priv_.network.replace(network);
+        priv_.server.replace(server.map(ToOwned::to_owned));
+        priv_.network.replace(network.map(ToOwned::to_owned));
         self.load_public_rooms(true);
     }
 
diff --git a/src/session/content/explore/server.rs b/src/session/content/explore/server.rs
new file mode 100644
index 000000000..d941d2083
--- /dev/null
+++ b/src/session/content/explore/server.rs
@@ -0,0 +1,149 @@
+use gtk::{glib, prelude::*, subclass::prelude::*};
+use ruma::thirdparty::ProtocolInstance;
+
+mod imp {
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct Server {
+        /// The name of the server that is displayed in the list.
+        pub name: OnceCell<String>,
+
+        /// The ID of the network that is used during search.
+        pub network: OnceCell<String>,
+
+        /// The server name that is used during search.
+        pub server: OnceCell<String>,
+
+        /// Whether this server can be deleted from the list.
+        pub deletable: OnceCell<bool>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for Server {
+        const NAME: &'static str = "Server";
+        type Type = super::Server;
+    }
+
+    impl ObjectImpl for Server {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpecString::new(
+                        "name",
+                        "Name",
+                        "The name of the server",
+                        None,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecString::new(
+                        "network",
+                        "Network",
+                        "The ID of the network that is used during search",
+                        None,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecString::new(
+                        "server",
+                        "Server",
+                        "The server name that is used during search",
+                        None,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
+                    ),
+                    glib::ParamSpecBoolean::new(
+                        "deletable",
+                        "Deletable",
+                        "Whether this server can be deleted from the list",
+                        false,
+                        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() {
+                "name" => self.name.set(value.get().unwrap()).unwrap(),
+                "network" => self.network.set(value.get().unwrap()).unwrap(),
+                "server" => {
+                    if let Some(server) = value.get().unwrap() {
+                        self.server.set(server).unwrap();
+                    }
+                }
+                "deletable" => self.deletable.set(value.get().unwrap()).unwrap(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "name" => obj.name().to_value(),
+                "network" => obj.network().to_value(),
+                "server" => obj.server().to_value(),
+                "deletable" => obj.deletable().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+}
+
+glib::wrapper! {
+    pub struct Server(ObjectSubclass<imp::Server>);
+}
+
+impl Server {
+    pub fn with_default_server(name: &str) -> Self {
+        glib::Object::new(&[
+            ("name", &name),
+            ("network", &"matrix"),
+            ("deletable", &false),
+        ])
+        .expect("Failed to create Server")
+    }
+
+    pub fn with_third_party_protocol(protocol_id: &str, instance: &ProtocolInstance) -> Self {
+        let name = format!("{} ({protocol_id})", instance.desc);
+        glib::Object::new(&[
+            ("name", &name),
+            ("network", &instance.instance_id),
+            ("deletable", &false),
+        ])
+        .expect("Failed to create Server")
+    }
+
+    pub fn with_custom_matrix_server(server: &str) -> Self {
+        glib::Object::new(&[
+            ("name", &server),
+            ("network", &"matrix"),
+            ("server", &server),
+            ("deletable", &true),
+        ])
+        .expect("Failed to create Server")
+    }
+
+    pub fn name(&self) -> &str {
+        self.imp().name.get().unwrap()
+    }
+
+    pub fn network(&self) -> &str {
+        self.imp().network.get().unwrap()
+    }
+
+    pub fn server(&self) -> Option<&str> {
+        self.imp().server.get().map(String::as_ref)
+    }
+
+    pub fn deletable(&self) -> bool {
+        *self.imp().deletable.get().unwrap()
+    }
+}
diff --git a/src/session/content/explore/server_list.rs b/src/session/content/explore/server_list.rs
new file mode 100644
index 000000000..b0282b69d
--- /dev/null
+++ b/src/session/content/explore/server_list.rs
@@ -0,0 +1,209 @@
+use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
+use log::error;
+use ruma::api::client::thirdparty::get_protocols;
+
+use super::server::Server;
+use crate::{prelude::*, session::Session, spawn, spawn_tokio};
+
+mod imp {
+    use std::cell::RefCell;
+
+    use glib::object::WeakRef;
+    use once_cell::sync::Lazy;
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct ServerList {
+        pub session: WeakRef<Session>,
+        pub list: RefCell<Vec<Server>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ServerList {
+        const NAME: &'static str = "ServerList";
+        type Type = super::ServerList;
+        type Interfaces = (gio::ListModel,);
+    }
+
+    impl ObjectImpl for ServerList {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpecObject::new(
+                    "session",
+                    "Session",
+                    "The session",
+                    Session::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() {
+                "session" => obj.set_session(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(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+
+    impl ListModelImpl for ServerList {
+        fn item_type(&self, _list_model: &Self::Type) -> glib::Type {
+            Server::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! {
+    /// The list of servers to explore.
+    pub struct ServerList(ObjectSubclass<imp::ServerList>)
+        @implements gio::ListModel;
+}
+
+impl ServerList {
+    pub fn new(session: &Session) -> Self {
+        glib::Object::new(&[("session", session)]).expect("Failed to create ServerList")
+    }
+
+    fn set_session(&self, session: Session) {
+        let priv_ = self.imp();
+
+        priv_.session.set(Some(&session));
+
+        let user_id = session.user().unwrap().user_id();
+        priv_.list.replace(vec![Server::with_default_server(
+            user_id.server_name().as_str(),
+        )]);
+        self.items_changed(0, 0, 1);
+
+        spawn!(clone!(@weak self as obj => async move {
+            obj.load_servers().await;
+        }));
+    }
+
+    pub fn session(&self) -> Option<Session> {
+        self.imp().session.upgrade()
+    }
+
+    async fn load_servers(&self) {
+        self.load_protocols().await;
+
+        let custom_servers = self.session().unwrap().settings().explore_custom_servers();
+        self.imp().list.borrow_mut().extend(
+            custom_servers
+                .into_iter()
+                .map(|server| Server::with_custom_matrix_server(&server)),
+        );
+
+        let added = self.imp().list.borrow().len();
+        self.items_changed(1, 0, (added - 1) as u32);
+    }
+
+    async fn load_protocols(&self) {
+        let client = self.session().unwrap().client();
+
+        let handle =
+            spawn_tokio!(async move { client.send(get_protocols::v3::Request::new(), None).await });
+
+        match handle.await.unwrap() {
+            Ok(response) => self.add_protocols(response),
+            Err(error) => {
+                error!("Error loading supported protocols: {}", error);
+            }
+        }
+    }
+
+    fn add_protocols(&self, protocols: get_protocols::v3::Response) {
+        let protocols_servers =
+            protocols
+                .protocols
+                .into_iter()
+                .flat_map(|(protocol_id, protocol)| {
+                    protocol.instances.into_iter().map(move |instance| {
+                        Server::with_third_party_protocol(&protocol_id, &instance)
+                    })
+                });
+
+        self.imp().list.borrow_mut().extend(protocols_servers)
+    }
+
+    pub fn contains_matrix_server(&self, server: &str) -> bool {
+        let list = &self.imp().list.borrow();
+        // The user's matrix server is a special case that doesn't have a "server", so
+        // use its name.
+        list[0].name() == server || list.iter().any(|s| s.server() == Some(server))
+    }
+
+    pub fn add_custom_matrix_server(&self, server_name: String) {
+        let server = Server::with_custom_matrix_server(&server_name);
+        let pos = {
+            let mut list = self.imp().list.borrow_mut();
+            let pos = list.len();
+
+            list.push(server);
+            pos
+        };
+
+        let session = self.session().unwrap();
+        let settings = session.settings();
+        let mut servers = settings.explore_custom_servers();
+        servers.push(server_name);
+        settings.set_explore_custom_servers(servers);
+
+        self.items_changed(pos as u32, 0, 1);
+    }
+
+    pub fn remove_custom_matrix_server(&self, server_name: &str) {
+        let pos = {
+            let mut list = self.imp().list.borrow_mut();
+            let pos = list
+                .iter()
+                .position(|s| s.deletable() && s.server() == Some(server_name));
+
+            if let Some(pos) = pos {
+                list.remove(pos);
+            }
+            pos
+        };
+
+        if let Some(pos) = pos {
+            let session = self.session().unwrap();
+            let settings = session.settings();
+            let servers = settings
+                .explore_custom_servers()
+                .into_iter()
+                .filter(|s| *s != server_name)
+                .collect::<Vec<_>>();
+            settings.set_explore_custom_servers(servers);
+
+            self.items_changed(pos as u32, 1, 0);
+        }
+    }
+}
diff --git a/src/session/content/explore/server_row.rs b/src/session/content/explore/server_row.rs
new file mode 100644
index 000000000..26868cc63
--- /dev/null
+++ b/src/session/content/explore/server_row.rs
@@ -0,0 +1,98 @@
+use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+
+use super::server::Server;
+
+mod imp {
+    use glib::subclass::InitializingObject;
+    use once_cell::{sync::Lazy, unsync::OnceCell};
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/Fractal/content-explore-server-row.ui")]
+    pub struct ExploreServerRow {
+        /// The server displayed by this row.
+        pub server: OnceCell<Server>,
+        #[template_child]
+        pub remove_button: TemplateChild<gtk::Button>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ExploreServerRow {
+        const NAME: &'static str = "ExploreServerRow";
+        type Type = super::ExploreServerRow;
+        type ParentType = gtk::ListBoxRow;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for ExploreServerRow {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpecObject::new(
+                    "server",
+                    "Server",
+                    "The server displayed by this row",
+                    Server::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() {
+                "server" => self.server.set(value.get().unwrap()).unwrap(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "server" => obj.server().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            if let Some(server) = obj.server().and_then(|s| s.server()) {
+                self.remove_button.set_action_target(Some(&server));
+                self.remove_button
+                    .set_action_name(Some("explore-servers-popover.remove-server"));
+            }
+        }
+    }
+
+    impl WidgetImpl for ExploreServerRow {}
+    impl ListBoxRowImpl for ExploreServerRow {}
+}
+
+glib::wrapper! {
+    pub struct ExploreServerRow(ObjectSubclass<imp::ExploreServerRow>)
+        @extends gtk::Widget, gtk::ListBoxRow, @implements gtk::Accessible;
+}
+
+impl ExploreServerRow {
+    pub fn new(server: &Server) -> Self {
+        glib::Object::new(&[("server", server)]).expect("Failed to create ExploreServerRow")
+    }
+
+    pub fn server(&self) -> Option<&Server> {
+        self.imp().server.get()
+    }
+}
diff --git a/src/session/content/explore/servers_popover.rs b/src/session/content/explore/servers_popover.rs
new file mode 100644
index 000000000..991cc2e50
--- /dev/null
+++ b/src/session/content/explore/servers_popover.rs
@@ -0,0 +1,243 @@
+use adw::subclass::prelude::*;
+use gtk::{
+    glib,
+    glib::{clone, FromVariant},
+    prelude::*,
+    CompositeTemplate,
+};
+use ruma::ServerName;
+
+use super::{server::Server, server_list::ServerList, server_row::ExploreServerRow};
+use crate::session::Session;
+
+mod imp {
+    use std::cell::RefCell;
+
+    use glib::{object::WeakRef, subclass::InitializingObject};
+    use once_cell::sync::Lazy;
+
+    use super::*;
+
+    #[derive(Debug, Default, CompositeTemplate)]
+    #[template(resource = "/org/gnome/Fractal/content-explore-servers-popover.ui")]
+    pub struct ExploreServersPopover {
+        pub session: WeakRef<Session>,
+        pub server_list: RefCell<Option<ServerList>>,
+        #[template_child]
+        pub listbox: TemplateChild<gtk::ListBox>,
+        #[template_child]
+        pub server_entry: TemplateChild<gtk::Entry>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for ExploreServersPopover {
+        const NAME: &'static str = "ContentExploreServersPopover";
+        type Type = super::ExploreServersPopover;
+        type ParentType = gtk::Popover;
+
+        fn class_init(klass: &mut Self::Class) {
+            Self::bind_template(klass);
+
+            klass.install_action(
+                "explore-servers-popover.add-server",
+                None,
+                move |obj, _, _| {
+                    obj.add_server();
+                },
+            );
+            klass.install_action(
+                "explore-servers-popover.remove-server",
+                Some("s"),
+                move |obj, _, variant| {
+                    if let Some(variant) = variant.and_then(String::from_variant) {
+                        obj.remove_server(&variant);
+                    }
+                },
+            );
+        }
+
+        fn instance_init(obj: &InitializingObject<Self>) {
+            obj.init_template();
+        }
+    }
+
+    impl ObjectImpl for ExploreServersPopover {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![
+                    glib::ParamSpecObject::new(
+                        "server-list",
+                        "Server List",
+                        "The server list",
+                        ServerList::static_type(),
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpecObject::new(
+                        "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() {
+                "session" => obj.set_session(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(),
+                "server-list" => obj.server_list().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            self.server_entry
+                .connect_changed(clone!(@weak obj => move |_| {
+                    obj.update_add_server_state()
+                }));
+            self.server_entry
+                .connect_activate(clone!(@weak obj => move |_| {
+                    obj.add_server()
+                }));
+
+            obj.update_add_server_state();
+        }
+    }
+
+    impl WidgetImpl for ExploreServersPopover {}
+    impl PopoverImpl for ExploreServersPopover {}
+}
+
+glib::wrapper! {
+    pub struct ExploreServersPopover(ObjectSubclass<imp::ExploreServersPopover>)
+        @extends gtk::Widget, gtk::Popover, @implements gtk::Accessible;
+}
+
+impl ExploreServersPopover {
+    pub fn new(session: &Session) -> Self {
+        glib::Object::new(&[("session", session)]).expect("Failed to create ExploreServersPopover")
+    }
+
+    pub fn session(&self) -> Option<Session> {
+        self.imp().session.upgrade()
+    }
+
+    pub fn set_session(&self, session: Option<Session>) {
+        if session == self.session() {
+            return;
+        }
+
+        self.imp().session.set(session.as_ref());
+        self.notify("session");
+    }
+
+    pub fn init(&self) {
+        if let Some(session) = &self.session() {
+            let priv_ = self.imp();
+            let server_list = ServerList::new(session);
+
+            priv_.listbox.bind_model(Some(&server_list), |obj| {
+                ExploreServerRow::new(obj.downcast_ref::<Server>().unwrap()).upcast()
+            });
+
+            // Select the first server by default.
+            priv_
+                .listbox
+                .select_row(priv_.listbox.row_at_index(0).as_ref());
+
+            priv_.server_list.replace(Some(server_list));
+            self.notify("server-list");
+        }
+    }
+
+    pub fn server_list(&self) -> Option<ServerList> {
+        self.imp().server_list.borrow().clone()
+    }
+
+    pub fn selected_server(&self) -> Option<Server> {
+        self.imp()
+            .listbox
+            .selected_row()
+            .and_then(|row| row.downcast::<ExploreServerRow>().ok())
+            .and_then(|row| row.server().cloned())
+    }
+
+    pub fn connect_selected_server_changed<F: Fn(&Self, Option<Server>) + 'static>(
+        &self,
+        f: F,
+    ) -> glib::SignalHandlerId {
+        self.imp()
+            .listbox
+            .connect_row_selected(clone!(@weak self as obj => move |_, row| {
+                f(&obj, row.and_then(|row| row.downcast_ref::<ExploreServerRow>()).and_then(|row| 
row.server().cloned()));
+            }))
+    }
+
+    fn can_add_server(&self) -> bool {
+        let server = self.imp().server_entry.text();
+        ServerName::parse(server.as_str()).is_ok()
+            // Don't allow duplicates
+            && self
+                .server_list()
+                .filter(|l| !l.contains_matrix_server(&server))
+                .is_some()
+    }
+
+    fn update_add_server_state(&self) {
+        self.action_set_enabled("explore-servers-popover.add-server", self.can_add_server())
+    }
+
+    fn add_server(&self) {
+        if !self.can_add_server() {
+            return;
+        }
+
+        if let Some(server_list) = self.server_list() {
+            let priv_ = self.imp();
+
+            let server = priv_.server_entry.text();
+            priv_.server_entry.set_text("");
+
+            server_list.add_custom_matrix_server(server.into());
+            priv_.listbox.select_row(
+                priv_
+                    .listbox
+                    .row_at_index(server_list.n_items() as i32 - 1)
+                    .as_ref(),
+            );
+        }
+    }
+
+    fn remove_server(&self, server: &str) {
+        if let Some(server_list) = self.server_list() {
+            let priv_ = self.imp();
+
+            // If the selected server is gonna be removed, select the first one.
+            if self.selected_server().unwrap().server() == Some(server) {
+                priv_
+                    .listbox
+                    .select_row(priv_.listbox.row_at_index(0).as_ref());
+            }
+
+            server_list.remove_custom_matrix_server(server);
+        }
+    }
+}
diff --git a/src/session/mod.rs b/src/session/mod.rs
index 5f3020786..53265685f 100644
--- a/src/session/mod.rs
+++ b/src/session/mod.rs
@@ -6,6 +6,7 @@ mod media_viewer;
 pub mod room;
 mod room_creation;
 mod room_list;
+mod settings;
 mod sidebar;
 mod user;
 pub mod verification;
@@ -65,6 +66,7 @@ pub use self::{
     avatar::Avatar,
     room::{Room, SupportedEvent},
     room_creation::RoomCreation,
+    settings::SessionSettings,
     user::{User, UserActions, UserExt},
 };
 use crate::{
@@ -127,6 +129,7 @@ mod imp {
         pub sync_tokio_handle: RefCell<Option<JoinHandle<()>>>,
         pub offline_handler_id: RefCell<Option<SignalHandlerId>>,
         pub offline: Cell<bool>,
+        pub settings: OnceCell<SessionSettings>,
     }
 
     #[glib::object_subclass]
@@ -314,6 +317,10 @@ impl Session {
         glib::Object::new(&[]).expect("Failed to create Session")
     }
 
+    pub fn session_id(&self) -> &str {
+        self.imp().info.get().unwrap().id()
+    }
+
     pub fn select_room(&self, room: Option<Room>) {
         self.imp()
             .sidebar
@@ -502,6 +509,11 @@ impl Session {
                     }
                 };
 
+                priv_
+                    .settings
+                    .set(SessionSettings::new(session.id()))
+                    .unwrap();
+
                 priv_.info.set(session).unwrap();
                 self.update_offline().await;
 
@@ -641,6 +653,11 @@ impl Session {
         self.imp().prepared.get()
     }
 
+    /// The current settings for this session.
+    pub fn settings(&self) -> &SessionSettings {
+        self.imp().settings.get().unwrap()
+    }
+
     pub fn room_list(&self) -> &RoomList {
         self.item_list().room_list()
     }
@@ -862,6 +879,10 @@ impl Session {
             handle.abort();
         }
 
+        if let Some(settings) = priv_.settings.get() {
+            settings.delete_settings();
+        }
+
         if let Err(error) = secret::remove_session(info).await {
             error!(
                 "Failed to remove credentials from SecretService after logout: {}",
diff --git a/src/session/settings.rs b/src/session/settings.rs
new file mode 100644
index 000000000..cd927f2ac
--- /dev/null
+++ b/src/session/settings.rs
@@ -0,0 +1,183 @@
+use gtk::{glib, prelude::*, subclass::prelude::*};
+use serde::{Deserialize, Serialize};
+
+use crate::Application;
+
+#[derive(Serialize, Deserialize)]
+struct StoredSessionSettings {
+    /// The ID of the session these settings are for.
+    session_id: String,
+
+    /// Custom servers to explore.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    explore_custom_servers: Vec<String>,
+}
+
+mod imp {
+    use std::cell::RefCell;
+
+    use once_cell::sync::{Lazy, OnceCell};
+
+    use super::*;
+
+    #[derive(Debug, Default)]
+    pub struct SessionSettings {
+        /// The ID of the session these settings are for.
+        pub session_id: OnceCell<String>,
+
+        /// Custom servers to explore.
+        pub explore_custom_servers: RefCell<Vec<String>>,
+    }
+
+    #[glib::object_subclass]
+    impl ObjectSubclass for SessionSettings {
+        const NAME: &'static str = "SessionSettings";
+        type Type = super::SessionSettings;
+    }
+
+    impl ObjectImpl for SessionSettings {
+        fn properties() -> &'static [glib::ParamSpec] {
+            static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
+                vec![glib::ParamSpecString::new(
+                    "session-id",
+                    "Session ID",
+                    "The ID of the session these settings are for",
+                    None,
+                    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() {
+                "session-id" => obj.set_session_id(value.get().ok()),
+                _ => unimplemented!(),
+            }
+        }
+
+        fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
+            match pspec.name() {
+                "session-id" => obj.session_id().to_value(),
+                _ => unimplemented!(),
+            }
+        }
+    }
+}
+
+glib::wrapper! {
+    /// The settings of a `Session`.
+    pub struct SessionSettings(ObjectSubclass<imp::SessionSettings>);
+}
+
+impl SessionSettings {
+    pub fn new(session_id: &str) -> Self {
+        glib::Object::new(&[("session-id", &session_id)]).expect("Failed to create SessionSettings")
+    }
+
+    pub fn session_id(&self) -> &str {
+        self.imp().session_id.get().unwrap()
+    }
+
+    fn set_session_id(&self, session_id: Option<String>) {
+        let priv_ = self.imp();
+
+        let session_id = match session_id {
+            Some(s) => s,
+            None => return,
+        };
+
+        let app_settings = Application::default().settings();
+        let sessions =
+            serde_json::from_str::<Vec<StoredSessionSettings>>(&app_settings.string("sessions"))
+                .unwrap_or_default();
+
+        let index = sessions
+            .iter()
+            .enumerate()
+            .find_map(|(idx, settings)| (settings.session_id == session_id).then(|| idx));
+
+        priv_.session_id.set(session_id).unwrap();
+
+        if let Some(settings) = index.and_then(|idx| sessions.into_iter().nth(idx)) {
+            *priv_.explore_custom_servers.borrow_mut() = settings.explore_custom_servers;
+        } else {
+            self.store_settings();
+        }
+    }
+
+    fn store_settings(&self) {
+        let new_settings = StoredSessionSettings {
+            session_id: self.session_id().to_owned(),
+            explore_custom_servers: self.explore_custom_servers(),
+        };
+        let app_settings = Application::default().settings();
+        let mut sessions =
+            serde_json::from_str::<Vec<StoredSessionSettings>>(&app_settings.string("sessions"))
+                .unwrap_or_default();
+
+        let index = sessions.iter().enumerate().find_map(|(idx, settings)| {
+            (settings.session_id == new_settings.session_id).then(|| idx)
+        });
+        if let Some(index) = index {
+            sessions[index] = new_settings;
+        } else {
+            sessions.push(new_settings);
+        }
+
+        if let Err(error) =
+            app_settings.set_string("sessions", &serde_json::to_string(&sessions).unwrap())
+        {
+            log::error!("Error storing settings for session: {error}");
+        }
+    }
+
+    pub fn delete_settings(&self) {
+        let app_settings = Application::default().settings();
+        if let Ok(sessions) =
+            serde_json::from_str::<Vec<StoredSessionSettings>>(&app_settings.string("sessions"))
+        {
+            let session_id = self.session_id();
+            let mut found = false;
+            let sessions = sessions
+                .into_iter()
+                .filter(|settings| {
+                    if settings.session_id == session_id {
+                        found = true;
+                        false
+                    } else {
+                        true
+                    }
+                })
+                .collect::<Vec<_>>();
+
+            if found {
+                if let Err(error) =
+                    app_settings.set_string("sessions", &serde_json::to_string(&sessions).unwrap())
+                {
+                    log::error!("Error deleting settings for session: {error}");
+                }
+            }
+        }
+    }
+
+    pub fn explore_custom_servers(&self) -> Vec<String> {
+        self.imp().explore_custom_servers.borrow().clone()
+    }
+
+    pub fn set_explore_custom_servers(&self, servers: Vec<String>) {
+        if self.explore_custom_servers() == servers {
+            return;
+        }
+
+        self.imp().explore_custom_servers.replace(servers);
+        self.store_settings();
+    }
+}


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