[fractal/fractal-next] sidebar: Enable changing room category with drag-and-drop



commit ec3f7cfeae43bfc4bbb86b4218579ae7222d46cd
Author: Kévin Commaille <zecakeh tedomum fr>
Date:   Tue Jan 18 15:37:09 2022 +0100

    sidebar: Enable changing room category with drag-and-drop
    
    Part of #757

 Cargo.lock                                |  22 ++++
 Cargo.toml                                |  23 +++-
 data/resources/style.css                  | 113 +++++++++++++----
 data/resources/ui/sidebar-category-row.ui |   6 +-
 src/session/room/mod.rs                   | 202 +++++++++++++++++++++---------
 src/session/room/room_type.rs             |  62 ++++++++-
 src/session/room_list.rs                  |   3 +
 src/session/sidebar/category_row.rs       | 100 +++++++++++++--
 src/session/sidebar/category_type.rs      |   4 +-
 src/session/sidebar/entry.rs              |   1 +
 src/session/sidebar/entry_type.rs         |   2 +
 src/session/sidebar/item_list.rs          |  94 +++++++-------
 src/session/sidebar/mod.rs                | 184 ++++++++++++++++++++++++++-
 src/session/sidebar/room_row.rs           |  50 +++++++-
 src/session/sidebar/row.rs                | 111 +++++++++++++++-
 15 files changed, 818 insertions(+), 159 deletions(-)
---
diff --git a/Cargo.lock b/Cargo.lock
index fd7abb20..b4bf1918 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -999,6 +999,7 @@ dependencies = [
  "matrix-sdk",
  "mime",
  "mime_guess",
+ "num_enum",
  "once_cell",
  "qrcode",
  "rand 0.8.4",
@@ -2603,6 +2604,27 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "num_enum"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "720d3ea1055e4e4574c0c0b0f8c3fd4f24c4cdaf465948206dea090b57b526ad"
+dependencies = [
+ "num_enum_derive",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index";
+checksum = "0d992b768490d7fe0d8586d9b5745f6c49f557da6d81dc982b1d167ad4edbb21"
+dependencies = [
+ "proc-macro-crate 1.1.0",
+ "proc-macro2 1.0.30",
+ "quote 1.0.10",
+ "syn 1.0.80",
+]
+
 [[package]]
 name = "objc"
 version = "0.2.7"
diff --git a/Cargo.toml b/Cargo.toml
index 5da11da3..bebe547d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -29,13 +29,18 @@ futures = "0.3"
 rand = "0.8"
 indexmap = "1.6.2"
 qrcode = "0.12.0"
-ashpd =  {git = "https://github.com/bilelmoussaoui/ashpd";, rev="66d4dc0020181a7174451150ecc711344082b5ce", 
features=["feature_gtk4", "feature_pipewire", "log"]}
-gst = {version = "0.17", package = "gstreamer"}
-gst_base = {version = "0.17", package = "gstreamer-base"}
-gst_video = {version = "0.17", package = "gstreamer-video"}
-image = {version = "0.23", default-features = false, features=["png"]}
+ashpd = { git = "https://github.com/bilelmoussaoui/ashpd";, rev = "66d4dc0020181a7174451150ecc711344082b5ce", 
features = [
+    "feature_gtk4",
+    "feature_pipewire",
+    "log",
+] }
+gst = { version = "0.17", package = "gstreamer" }
+gst_base = { version = "0.17", package = "gstreamer-base" }
+gst_video = { version = "0.17", package = "gstreamer-video" }
+image = { version = "0.23", default-features = false, features = ["png"] }
 regex = "1.5.4"
 mime_guess = "2.0.3"
+num_enum = "0.5.6"
 
 [dependencies.sourceview]
 package = "sourceview5"
@@ -52,4 +57,10 @@ version = "0.1.0-alpha-6"
 [dependencies.matrix-sdk]
 git = "https://github.com/jsparber/matrix-rust-sdk.git";
 branch = "messages-api"
-features = ["socks", "encryption", "sled_cryptostore", "sled_state_store", "markdown"]
+features = [
+    "socks",
+    "encryption",
+    "sled_cryptostore",
+    "sled_state_store",
+    "markdown",
+]
diff --git a/data/resources/style.css b/data/resources/style.css
index e0dcec31..812c53e3 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -135,43 +135,80 @@ headerbar.flat {
 
 /* Sidebar List */
 .sidebar-list row {
-  padding-left: 10px;
-  padding-right: 10px;
+  margin: 0;
+  padding: 0;
+  border-radius: 0;
 }
 
-.sidebar-list .category {
-  margin-top: 4px;
-  font-size: 0.8em;
-  font-weight: bold;
+.sidebar-list row:focus-visible:focus-within {
+  outline: 0;
 }
 
-.sidebar-list .entry {
-  margin-top: 4px;
-  font-weight: bold;
+.sidebar-list row:selected {
+  background: none;
 }
 
-.sidebar-list .category image.arrow {
-  transition: 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
+.sidebar-list row > * {
+  margin: 0 12px;
+  padding: 12px 6px;
+  border-radius: 6px;
+  transition-property: outline, outline-width, outline-offset, outline-color;
+  transition-duration: 300ms;
+  animation-timing-function: ease-in-out;
+  outline: 0 solid transparent;
+  outline-offset: 2px;
 }
 
-.sidebar-list .category .category-row:not(:checked) image.arrow:dir(ltr) {
-  transform: rotate(-0.25turn);
+.sidebar-list row:focus-visible:focus-within > * {
+  outline-color: alpha(@accent_color, 0.5);
+  outline-width: 2px;
+  outline-offset: -2px;
 }
 
-.sidebar-list .category .category-row:not(:checked) image.arrow:dir(rtl) {
-  transform: rotate(0.25turn);
+.sidebar-list:not(.drop-mode) row:hover > * {
+  background-color: alpha(currentColor, 0.07);
+}
+
+.sidebar-list row:active > * {
+  background-color: alpha(currentColor, 0.16);
+}
+
+.sidebar-list row:selected > * {
+  background-color: alpha(currentColor, 0.1);
+}
+
+.sidebar-list:not(.drop-mode) row:selected:hover > *,
+.sidebar-list row:selected.has-open-popup > * {
+  background-color: alpha(currentColor, 0.13);
 }
 
-.sidebar-list .room {
-  padding-top: 4px;
-  padding-bottom: 4px;
+.sidebar-list row:selected:active > * {
+  background-color: alpha(currentColor, 0.19);
 }
 
-.sidebar-list .room .bold {
+.sidebar-list row.entry {
   font-weight: bold;
 }
 
-.sidebar-list .room .notification_count {
+.sidebar-list row.category {
+  margin-top: 6px;
+  font-size: 0.8em;
+  font-weight: bold;
+}
+
+.sidebar-list row.category image.arrow {
+  transition: 200ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
+}
+
+.sidebar-list row.category .category-row:not(:checked) image.arrow:dir(ltr) {
+  transform: rotate(-0.25turn);
+}
+
+.sidebar-list row.category .category-row:not(:checked) image.arrow:dir(rtl) {
+  transform: rotate(0.25turn);
+}
+
+.sidebar-list row.room .notification_count {
   font-weight: bold;
   font-size: 0.8em;
   border-radius: 10px;
@@ -179,10 +216,44 @@ headerbar.flat {
   padding: 2px 5px;
 }
 
-.sidebar-list .room .highlight {
+.sidebar-list row.room .highlight {
+  color: @accent_fg_color;
   background-color: @accent_bg_color;
 }
 
+.sidebar-list sidebar-row.drag {
+  color: @accent_fg_color;
+  background-color: @accent_bg_color;
+  opacity: 0.6;
+}
+
+.sidebar-list sidebar-row.drop-disabled > * {
+  opacity: 0.6;
+}
+
+.sidebar-list sidebar-row.drop-empty {
+  color: @accent_color;
+}
+
+.sidebar-list sidebar-row.forget {
+  color: @error_color;
+  background: none;
+}
+
+.sidebar-list row.drop-active {
+  background-color: alpha(@accent_color, 0.1);
+}
+
+.sidebar-list row.category.drop-active,
+.sidebar-list row.drop-active sidebar-row.forget {
+  color: @accent_color;
+}
+
+.sidebar-list row.drop-active .dim-label,
+.sidebar-list row.drop-active sidebar-row.drop-empty .dim-label {
+  opacity: 1;
+}
+
 /* Content */
 .room-history {
   background: @theme_base_color;
diff --git a/data/resources/ui/sidebar-category-row.ui b/data/resources/ui/sidebar-category-row.ui
index 4b7d6341..bab9f9a6 100644
--- a/data/resources/ui/sidebar-category-row.ui
+++ b/data/resources/ui/sidebar-category-row.ui
@@ -12,11 +12,7 @@
             <property name="halign">start</property>
             <property name="hexpand">True</property>
             <property name="ellipsize">end</property>
-            <binding name="label">
-              <lookup name="display-name">
-                <lookup name="category">SidebarCategoryRow</lookup>
-              </lookup>
-            </binding>
+            <property name="label" bind-source="SidebarCategoryRow" bind-property="label" 
bind-flags="sync-create"/>
             <style>
               <class name="dim-label"/>
             </style>
diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs
index ad902f83..71b6bace 100644
--- a/src/session/room/mod.rs
+++ b/src/session/room/mod.rs
@@ -36,7 +36,7 @@ use matrix_sdk::{
                 member::MembershipState, message::RoomMessageEventContent,
                 name::RoomNameEventContent, topic::RoomTopicEventContent,
             },
-            tag::TagName,
+            tag::{TagInfo, TagName},
             AnyRoomAccountDataEvent, AnyStateEventContent, AnyStrippedStateEvent,
             AnySyncMessageEvent, AnySyncRoomEvent, AnySyncStateEvent, EventType, SyncMessageEvent,
             Unsigned,
@@ -284,7 +284,10 @@ mod imp {
 
         fn signals() -> &'static [Signal] {
             static SIGNALS: Lazy<Vec<Signal>> = Lazy::new(|| {
-                vec![Signal::builder("order-changed", &[], <()>::static_type().into()).build()]
+                vec![
+                    Signal::builder("order-changed", &[], <()>::static_type().into()).build(),
+                    Signal::builder("room-forgotten", &[], <()>::static_type().into()).build(),
+                ]
             });
             SIGNALS.as_ref()
         }
@@ -366,6 +369,56 @@ impl Room {
         self.load_category();
     }
 
+    /// Forget a room that is left.
+    pub fn forget(&self) {
+        if self.category() != RoomType::Left {
+            warn!("Cannot forget a room that is not left");
+            return;
+        }
+
+        let matrix_room = self.matrix_room();
+
+        let handle = spawn_tokio!(async move {
+            match matrix_room {
+                MatrixRoom::Left(room) => room.forget().await,
+                _ => unimplemented!(),
+            }
+        });
+
+        spawn!(
+            glib::PRIORITY_DEFAULT_IDLE,
+            clone!(@weak self as obj => async move {
+                match handle.await.unwrap() {
+                    Ok(_) => {
+                        obj.emit_by_name("room-forgotten", &[]).unwrap();
+                    }
+                    Err(error) => {
+                            error!("Couldn’t forget the room: {}", error);
+                            let error = Error::new(
+                                    clone!(@weak obj => @default-return None, move |_| {
+                                            let error_message = gettext(
+                                                "Failed to forget <widget>."
+                                            );
+                                            let room_pill = Pill::new();
+                                            room_pill.set_room(Some(obj));
+                                            let label = LabelWithWidgets::new(&error_message, 
vec![room_pill]);
+
+                                            Some(label.upcast())
+                                    }),
+                            );
+
+                            if let Some(window) = obj.session().parent_window() {
+                                window.append_error(&error);
+                            }
+
+                            // Load the previous category
+                            obj.load_category();
+                    },
+                };
+            })
+        );
+    }
+
     pub fn category(&self) -> RoomType {
         let priv_ = imp::Room::from_instance(self);
         priv_.category.get()
@@ -386,7 +439,9 @@ impl Room {
     /// Set the category of this room.
     ///
     /// This makes the necessary to propagate the category to the homeserver.
-    /// Note: Rooms can't be moved to the invite category and they can't be moved once they are upgraded
+    ///
+    /// Note: Rooms can't be moved to the invite category and they can't be
+    /// moved once they are upgraded.
     pub fn set_category(&self, category: RoomType) {
         let matrix_room = self.matrix_room();
         let previous_category = self.category();
@@ -395,78 +450,93 @@ impl Room {
             return;
         }
 
-        if category == RoomType::Invited {
-            warn!("Rooms can’t be moved to the invite Category");
-            return;
-        }
-
-        if self.category() == RoomType::Outdated {
+        if previous_category == RoomType::Outdated {
             warn!("Can't set the category of an upgraded room");
             return;
         }
 
-        // Outdated rooms don't need to propagate anything to the server
-        if category == RoomType::Outdated {
-            self.set_category_internal(category);
-            return;
+        match category {
+            RoomType::Invited => {
+                warn!("Rooms can’t be moved to the invite Category");
+                return;
+            }
+            RoomType::Outdated => {
+                // Outdated rooms don't need to propagate anything to the server
+                self.set_category_internal(category);
+                return;
+            }
+            _ => {}
         }
 
         let handle = spawn_tokio!(async move {
             match matrix_room {
-                MatrixRoom::Invited(room) => {
-                    match category {
-                        RoomType::Invited => Ok(()),
-                        RoomType::Favorite => {
-                            room.accept_invitation().await
-                            // TODO: set favorite tag
-                        }
-                        RoomType::Normal => room.accept_invitation().await,
-                        RoomType::LowPriority => {
-                            room.accept_invitation().await
-                            // TODO: set low priority tag
-                        }
-                        RoomType::Left => room.reject_invitation().await,
-                        RoomType::Outdated => unimplemented!(),
+                MatrixRoom::Invited(room) => match category {
+                    RoomType::Invited => Ok(()),
+                    RoomType::Favorite => {
+                        room.accept_invitation().await
+                        // TODO: set favorite tag
                     }
-                }
-                MatrixRoom::Joined(room) => {
-                    match category {
-                        RoomType::Invited => Ok(()),
-                        RoomType::Favorite => {
-                            // TODO: set favorite tag
-                            Ok(())
-                        }
-                        RoomType::Normal => {
-                            // TODO: remove tags
-                            Ok(())
-                        }
-                        RoomType::LowPriority => {
-                            // TODO: set low priority tag
-                            Ok(())
-                        }
-                        RoomType::Left => room.leave().await,
-                        RoomType::Outdated => unimplemented!(),
+                    RoomType::Normal => {
+                        room.accept_invitation().await
+                        // TODO: remove tags
                     }
-                }
-                MatrixRoom::Left(room) => {
-                    match category {
-                        RoomType::Invited => Ok(()),
-                        RoomType::Favorite => {
-                            room.join().await
-                            // TODO: set favorite tag
+                    RoomType::LowPriority => {
+                        room.accept_invitation().await
+                        // TODO: set low priority tag
+                    }
+                    RoomType::Left => room.reject_invitation().await,
+                    RoomType::Outdated => unimplemented!(),
+                },
+                MatrixRoom::Joined(room) => match category {
+                    RoomType::Invited => Ok(()),
+                    RoomType::Favorite => {
+                        room.set_tag(TagName::Favorite.as_ref(), TagInfo::new())
+                            .await?;
+                        if previous_category == RoomType::LowPriority {
+                            room.remove_tag(TagName::LowPriority.as_ref()).await?;
                         }
-                        RoomType::Normal => {
-                            room.join().await
-                            // TODO: remove tags
+                        Ok(())
+                    }
+                    RoomType::Normal => {
+                        match previous_category {
+                            RoomType::Favorite => {
+                                room.remove_tag(TagName::Favorite.as_ref()).await?;
+                            }
+                            RoomType::LowPriority => {
+                                room.remove_tag(TagName::LowPriority.as_ref()).await?;
+                            }
+                            _ => {}
                         }
-                        RoomType::LowPriority => {
-                            room.join().await
-                            // TODO: set low priority tag
+                        Ok(())
+                    }
+                    RoomType::LowPriority => {
+                        room.set_tag(TagName::LowPriority.as_ref(), TagInfo::new())
+                            .await?;
+                        if previous_category == RoomType::Favorite {
+                            room.remove_tag(TagName::Favorite.as_ref()).await?;
                         }
-                        RoomType::Left => Ok(()),
-                        RoomType::Outdated => unimplemented!(),
+                        Ok(())
                     }
-                }
+                    RoomType::Left => room.leave().await,
+                    RoomType::Outdated => unimplemented!(),
+                },
+                MatrixRoom::Left(room) => match category {
+                    RoomType::Invited => Ok(()),
+                    RoomType::Favorite => {
+                        room.join().await
+                        // TODO: set favorite tag
+                    }
+                    RoomType::Normal => {
+                        room.join().await
+                        // TODO: remove tags
+                    }
+                    RoomType::LowPriority => {
+                        room.join().await
+                        // TODO: set low priority tag
+                    }
+                    RoomType::Left => Ok(()),
+                    RoomType::Outdated => unimplemented!(),
+                },
             }
         });
 
@@ -1033,6 +1103,16 @@ impl Room {
         .unwrap()
     }
 
+    /// Connect to the signal sent when a room was forgotten.
+    pub fn connect_room_forgotten<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
+        self.connect_local("room-forgotten", true, move |values| {
+            let obj = values[0].get::<Self>().unwrap();
+            f(&obj);
+            None
+        })
+        .unwrap()
+    }
+
     pub fn predecessor(&self) -> Option<&RoomId> {
         let priv_ = imp::Room::from_instance(self);
         priv_.predecessor.get().map(std::ops::Deref::deref)
diff --git a/src/session/room/room_type.rs b/src/session/room/room_type.rs
index 16057d8e..100f89bf 100644
--- a/src/session/room/room_type.rs
+++ b/src/session/room/room_type.rs
@@ -1,8 +1,12 @@
-use crate::session::sidebar::CategoryType;
+use std::convert::TryFrom;
+
 use gtk::glib;
+use num_enum::{IntoPrimitive, TryFromPrimitive};
+
+use crate::session::sidebar::CategoryType;
 
 // TODO: do we also want the category `People` and a custom category support?
-#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::GEnum)]
+#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::GEnum, IntoPrimitive, TryFromPrimitive)]
 #[repr(u32)]
 #[genum(type_name = "RoomType")]
 pub enum RoomType {
@@ -14,6 +18,33 @@ pub enum RoomType {
     Outdated = 5,
 }
 
+impl RoomType {
+    /// Check whether this `RoomType` can be changed to `category`.
+    pub fn can_change_to(&self, category: &RoomType) -> bool {
+        match self {
+            Self::Invited => {
+                matches!(
+                    category,
+                    Self::Favorite | Self::Normal | Self::LowPriority | Self::Left
+                )
+            }
+            Self::Favorite => {
+                matches!(category, Self::Normal | Self::LowPriority | Self::Left)
+            }
+            Self::Normal => {
+                matches!(category, Self::Favorite | Self::LowPriority | Self::Left)
+            }
+            Self::LowPriority => {
+                matches!(category, Self::Favorite | Self::Normal | Self::Left)
+            }
+            Self::Left => {
+                matches!(category, Self::Favorite | Self::Normal | Self::LowPriority)
+            }
+            Self::Outdated => false,
+        }
+    }
+}
+
 impl Default for RoomType {
     fn default() -> Self {
         RoomType::Normal
@@ -25,3 +56,30 @@ impl ToString for RoomType {
         CategoryType::from(self).to_string()
     }
 }
+
+impl TryFrom<CategoryType> for RoomType {
+    type Error = &'static str;
+
+    fn try_from(category_type: CategoryType) -> Result<Self, Self::Error> {
+        Self::try_from(&category_type)
+    }
+}
+
+impl TryFrom<&CategoryType> for RoomType {
+    type Error = &'static str;
+
+    fn try_from(category_type: &CategoryType) -> Result<Self, Self::Error> {
+        match category_type {
+            CategoryType::None => Err("CategoryType::None cannot be a RoomType"),
+            CategoryType::Invited => Ok(Self::Invited),
+            CategoryType::Favorite => Ok(Self::Favorite),
+            CategoryType::Normal => Ok(Self::Normal),
+            CategoryType::LowPriority => Ok(Self::LowPriority),
+            CategoryType::Left => Ok(Self::Left),
+            CategoryType::Outdated => Ok(Self::Outdated),
+            CategoryType::VerificationRequest => {
+                Err("CategoryType::VerificationRequest cannot be a RoomType")
+            }
+        }
+    }
+}
diff --git a/src/session/room_list.rs b/src/session/room_list.rs
index 934989d6..9be478ec 100644
--- a/src/session/room_list.rs
+++ b/src/session/room_list.rs
@@ -226,6 +226,9 @@ impl RoomList {
                     obj.items_changed(position as u32, 1, 1);
                 }
             }));
+            room.connect_room_forgotten(clone!(@weak self as obj => move |room| {
+                obj.remove(room.room_id());
+            }));
         }
 
         self.items_changed(position as u32, 0, added as u32);
diff --git a/src/session/sidebar/category_row.rs b/src/session/sidebar/category_row.rs
index fd102c71..f1c04624 100644
--- a/src/session/sidebar/category_row.rs
+++ b/src/session/sidebar/category_row.rs
@@ -1,9 +1,10 @@
 use adw::subclass::prelude::BinImpl;
+use gettextrs::gettext;
 use gtk::subclass::prelude::*;
 use gtk::{self, prelude::*};
 use gtk::{glib, CompositeTemplate};
 
-use crate::session::sidebar::Category;
+use crate::session::sidebar::{Category, CategoryType};
 
 mod imp {
     use super::*;
@@ -13,8 +14,13 @@ mod imp {
     #[derive(Debug, Default, CompositeTemplate)]
     #[template(resource = "/org/gnome/FractalNext/sidebar-category-row.ui")]
     pub struct CategoryRow {
+        /// The category of this row.
         pub category: RefCell<Option<Category>>,
+        /// The expanded state of this row.
         pub expanded: Cell<bool>,
+        /// The `CategoryType` to show a label for during a drag-and-drop
+        /// operation.
+        pub show_label_for_category: Cell<CategoryType>,
     }
 
     #[glib::object_subclass]
@@ -51,6 +57,21 @@ mod imp {
                         true,
                         glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
                     ),
+                    glib::ParamSpec::new_string(
+                        "label",
+                        "Label",
+                        "The label to show for this row",
+                        None,
+                        glib::ParamFlags::READABLE,
+                    ),
+                    glib::ParamSpec::new_enum(
+                        "show-label-for-category",
+                        "Show Label for Category",
+                        "The CategoryType to show a label for",
+                        CategoryType::static_type(),
+                        CategoryType::None as i32,
+                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
                 ]
             });
 
@@ -65,14 +86,9 @@ mod imp {
             pspec: &glib::ParamSpec,
         ) {
             match pspec.name() {
-                "category" => {
-                    let category = value.get().unwrap();
-                    obj.set_category(category);
-                }
-                "expanded" => {
-                    let expanded = value.get().unwrap();
-                    obj.set_expanded(expanded);
-                }
+                "category" => obj.set_category(value.get().unwrap()),
+                "expanded" => obj.set_expanded(value.get().unwrap()),
+                "show-label-for-category" => obj.set_show_label_for_category(value.get().unwrap()),
                 _ => unimplemented!(),
             }
         }
@@ -81,6 +97,8 @@ mod imp {
             match pspec.name() {
                 "category" => obj.category().to_value(),
                 "expanded" => obj.expanded().to_value(),
+                "label" => obj.label().to_value(),
+                "show-label-for-category" => obj.show_label_for_category().to_value(),
                 _ => unimplemented!(),
             }
         }
@@ -97,7 +115,8 @@ glib::wrapper! {
 
 impl CategoryRow {
     pub fn new() -> Self {
-        glib::Object::new(&[]).expect("Failed to create CategoryRow")
+        glib::Object::new(&[("show-label-for-category", &CategoryType::None)])
+            .expect("Failed to create CategoryRow")
     }
 
     pub fn category(&self) -> Option<Category> {
@@ -114,6 +133,7 @@ impl CategoryRow {
 
         priv_.category.replace(category);
         self.notify("category");
+        self.notify("label");
     }
 
     fn expanded(&self) -> bool {
@@ -137,6 +157,66 @@ impl CategoryRow {
         priv_.expanded.set(expanded);
         self.notify("expanded");
     }
+
+    pub fn label(&self) -> Option<String> {
+        let to_type = self.category()?.type_();
+        let from_type = self.show_label_for_category();
+
+        let label = match from_type {
+            CategoryType::Invited => match to_type {
+                CategoryType::Favorite => gettext("Join Room as Favorite"),
+                CategoryType::Normal => gettext("Join Room"),
+                CategoryType::LowPriority => gettext("Join Room as Low Priority"),
+                CategoryType::Left => gettext("Reject Invite"),
+                _ => to_type.to_string(),
+            },
+            CategoryType::Favorite => match to_type {
+                CategoryType::Normal => gettext("Unmark as Favorite"),
+                CategoryType::LowPriority => gettext("Mark as Low Priority"),
+                CategoryType::Left => gettext("Leave Room"),
+                _ => to_type.to_string(),
+            },
+            CategoryType::Normal => match to_type {
+                CategoryType::Favorite => gettext("Mark as Favorite"),
+                CategoryType::LowPriority => gettext("Mark as Low Priority"),
+                CategoryType::Left => gettext("Leave Room"),
+                _ => to_type.to_string(),
+            },
+            CategoryType::LowPriority => match to_type {
+                CategoryType::Favorite => gettext("Mark as Favorite"),
+                CategoryType::Normal => gettext("Unmark as Low Priority"),
+                CategoryType::Left => gettext("Leave Room"),
+                _ => to_type.to_string(),
+            },
+            CategoryType::Left => match to_type {
+                CategoryType::Favorite => gettext("Rejoin Room as Favorite"),
+                CategoryType::Normal => gettext("Rejoin Room"),
+                CategoryType::LowPriority => gettext("Rejoin Room as Low Priority"),
+                _ => to_type.to_string(),
+            },
+            _ => to_type.to_string(),
+        };
+
+        Some(label)
+    }
+
+    pub fn show_label_for_category(&self) -> CategoryType {
+        let priv_ = imp::CategoryRow::from_instance(self);
+        priv_.show_label_for_category.get()
+    }
+
+    pub fn set_show_label_for_category(&self, category: CategoryType) {
+        let priv_ = imp::CategoryRow::from_instance(self);
+
+        if category == self.show_label_for_category() {
+            return;
+        }
+
+        priv_.show_label_for_category.set(category);
+
+        self.notify("show-label-for-category");
+        self.notify("label");
+    }
 }
 
 impl Default for CategoryRow {
diff --git a/src/session/sidebar/category_type.rs b/src/session/sidebar/category_type.rs
index f64edc20..bd3d20cb 100644
--- a/src/session/sidebar/category_type.rs
+++ b/src/session/sidebar/category_type.rs
@@ -3,9 +3,10 @@ use gettextrs::gettext;
 use gtk::glib;
 
 #[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::GEnum)]
-#[repr(u32)]
+#[repr(i32)]
 #[genum(type_name = "CategoryType")]
 pub enum CategoryType {
+    None = -1,
     VerificationRequest = 0,
     Invited = 1,
     Favorite = 2,
@@ -24,6 +25,7 @@ impl Default for CategoryType {
 impl ToString for CategoryType {
     fn to_string(&self) -> String {
         match self {
+            CategoryType::None => unimplemented!(),
             CategoryType::VerificationRequest => gettext("Verifications"),
             CategoryType::Invited => gettext("Invited"),
             CategoryType::Favorite => gettext("Favorite"),
diff --git a/src/session/sidebar/entry.rs b/src/session/sidebar/entry.rs
index 4a0572f6..6e57fe16 100644
--- a/src/session/sidebar/entry.rs
+++ b/src/session/sidebar/entry.rs
@@ -103,6 +103,7 @@ impl Entry {
     pub fn icon_name(&self) -> Option<&str> {
         match self.type_() {
             EntryType::Explore => Some("explore-symbolic"),
+            EntryType::Forget => Some("user-trash-symbolic"),
         }
     }
 }
diff --git a/src/session/sidebar/entry_type.rs b/src/session/sidebar/entry_type.rs
index fcbdfd17..791b6722 100644
--- a/src/session/sidebar/entry_type.rs
+++ b/src/session/sidebar/entry_type.rs
@@ -6,6 +6,7 @@ use gtk::glib;
 #[genum(type_name = "EntryType")]
 pub enum EntryType {
     Explore = 0,
+    Forget = 1,
 }
 
 impl Default for EntryType {
@@ -18,6 +19,7 @@ impl ToString for EntryType {
     fn to_string(&self) -> String {
         match self {
             EntryType::Explore => gettext("Explore"),
+            EntryType::Forget => gettext("Forget Room"),
         }
     }
 }
diff --git a/src/session/sidebar/item_list.rs b/src/session/sidebar/item_list.rs
index 5a583aba..b47df0b5 100644
--- a/src/session/sidebar/item_list.rs
+++ b/src/session/sidebar/item_list.rs
@@ -1,6 +1,9 @@
+use std::convert::TryFrom;
+
 use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*};
 
 use crate::session::{
+    room::RoomType,
     room_list::RoomList,
     sidebar::CategoryType,
     sidebar::EntryType,
@@ -17,10 +20,13 @@ mod imp {
 
     #[derive(Debug, Default)]
     pub struct ItemList {
-        pub list: OnceCell<[(glib::Object, Cell<bool>); 7]>,
+        pub list: OnceCell<[(glib::Object, Cell<bool>); 8]>,
         pub room_list: OnceCell<RoomList>,
         pub verification_list: OnceCell<VerificationList>,
-        pub show_all: Cell<bool>,
+        /// The `CategoryType` to show all compatible categories for.
+        ///
+        /// Uses `RoomType::can_change_to` to find compatible categories.
+        pub show_all_for_category: Cell<CategoryType>,
     }
 
     #[glib::object_subclass]
@@ -49,11 +55,12 @@ mod imp {
                         VerificationList::static_type(),
                         glib::ParamFlags::READWRITE | glib::ParamFlags::CONSTRUCT_ONLY,
                     ),
-                    glib::ParamSpec::new_boolean(
-                        "show-all",
-                        "Show All",
-                        "Whether all room categories should be shown",
-                        false,
+                    glib::ParamSpec::new_enum(
+                        "show-all-for-category",
+                        "Show All For Category",
+                        "The `CategoryType` to show all compatible categories for",
+                        CategoryType::static_type(),
+                        CategoryType::None as i32,
                         glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
                     ),
                 ]
@@ -72,7 +79,7 @@ mod imp {
             match pspec.name() {
                 "room-list" => obj.set_room_list(value.get().unwrap()),
                 "verification-list" => obj.set_verification_list(value.get().unwrap()),
-                "show-all" => obj.set_show_all(value.get().unwrap()),
+                "show-all-for-category" => obj.set_show_all_for_category(value.get().unwrap()),
                 _ => unimplemented!(),
             }
         }
@@ -81,7 +88,7 @@ mod imp {
             match pspec.name() {
                 "room-list" => obj.room_list().to_value(),
                 "verification-list" => obj.verification_list().to_value(),
-                "show-all" => obj.show_all().to_value(),
+                "show-all-for-category" => obj.show_all_for_category().to_value(),
                 _ => unimplemented!(),
             }
         }
@@ -101,6 +108,7 @@ mod imp {
                 Category::new(CategoryType::Normal, room_list).upcast::<glib::Object>(),
                 Category::new(CategoryType::LowPriority, room_list).upcast::<glib::Object>(),
                 Category::new(CategoryType::Left, room_list).upcast::<glib::Object>(),
+                Entry::new(EntryType::Forget).upcast::<glib::Object>(),
             ];
 
             for (index, item) in list.iter().enumerate() {
@@ -108,7 +116,7 @@ mod imp {
                     category.connect_notify_local(
                         Some("empty"),
                         clone!(@weak obj => move |_, _| {
-                            obj.update_category(index);
+                            obj.update_item(index);
                         }),
                     );
                 }
@@ -118,7 +126,9 @@ mod imp {
                 let visible = if let Some(category) = item.downcast_ref::<Category>() {
                     !category.is_empty()
                 } else {
-                    true
+                    item.downcast_ref::<Entry>()
+                        .filter(|entry| entry.type_() == EntryType::Forget)
+                        .is_none()
                 };
                 (item, Cell::new(visible))
             });
@@ -177,12 +187,30 @@ impl ItemList {
         .expect("Failed to create ItemList")
     }
 
-    fn update_category(&self, position: usize) {
+    fn update_item(&self, position: usize) {
         let priv_ = imp::ItemList::from_instance(self);
         let (item, old_visible) = priv_.list.get().unwrap().get(position).unwrap();
-        let category = item.downcast_ref::<Category>().unwrap();
 
-        let visible = !category.is_empty() || (self.show_all() && is_show_all_category(category));
+        let visible = if let Some(category) = item.downcast_ref::<Category>() {
+            !category.is_empty()
+                || RoomType::try_from(self.show_all_for_category())
+                    .ok()
+                    .and_then(|room_type| {
+                        RoomType::try_from(category.type_())
+                            .ok()
+                            .filter(|category| room_type.can_change_to(category))
+                    })
+                    .is_some()
+        } else if item
+            .downcast_ref::<Entry>()
+            .filter(|entry| entry.type_() == EntryType::Forget)
+            .is_some()
+        {
+            self.show_all_for_category() == CategoryType::Left
+        } else {
+            return;
+        };
+
         if visible != old_visible.get() {
             old_visible.set(visible);
             let hidden_before_position = priv_
@@ -201,32 +229,24 @@ impl ItemList {
         }
     }
 
-    // Whether all room categories are shown
-    // This doesn't include `CategoryType::Invite` since the user can't move rooms to it.
-    pub fn show_all(&self) -> bool {
+    pub fn show_all_for_category(&self) -> CategoryType {
         let priv_ = imp::ItemList::from_instance(self);
-        priv_.show_all.get()
+        priv_.show_all_for_category.get()
     }
 
-    // Set whether all room categories should be shown
-    // This doesn't include `CategoryType::Invite` since the user can't move rooms to it.
-    pub fn set_show_all(&self, show_all: bool) {
+    pub fn set_show_all_for_category(&self, category: CategoryType) {
         let priv_ = imp::ItemList::from_instance(self);
-        if show_all == self.show_all() {
+
+        if category == self.show_all_for_category() {
             return;
         }
 
-        priv_.show_all.set(show_all);
-
-        for (index, (item, _)) in priv_.list.get().unwrap().iter().enumerate() {
-            if let Some(category) = item.downcast_ref::<Category>() {
-                if is_show_all_category(category) {
-                    self.update_category(index);
-                }
-            }
+        priv_.show_all_for_category.set(category);
+        for i in 0..priv_.list.get().unwrap().len() {
+            self.update_item(i);
         }
 
-        self.notify("show-all");
+        self.notify("show-all-for-category");
     }
 
     fn set_room_list(&self, room_list: RoomList) {
@@ -249,15 +269,3 @@ impl ItemList {
         priv_.verification_list.get().unwrap()
     }
 }
-
-// Wheter this category should be shown when `show-all` is `true`
-// This doesn't include `CategoryType::Invite` since the user can't move rooms to it.
-fn is_show_all_category(category: &Category) -> bool {
-    matches!(
-        category.type_(),
-        CategoryType::Favorite
-            | CategoryType::Normal
-            | CategoryType::LowPriority
-            | CategoryType::Left
-    )
-}
diff --git a/src/session/sidebar/mod.rs b/src/session/sidebar/mod.rs
index fe3bd4b2..45622c6e 100644
--- a/src/session/sidebar/mod.rs
+++ b/src/session/sidebar/mod.rs
@@ -23,11 +23,11 @@ use self::row::Row;
 use self::selection::Selection;
 use self::verification_row::VerificationRow;
 
-use adw::subclass::prelude::BinImpl;
-use gtk::{gio, glib, prelude::*, subclass::prelude::*, CompositeTemplate, SelectionModel};
+use adw::{prelude::*, subclass::prelude::*};
+use gtk::{gio, glib, subclass::prelude::*, CompositeTemplate, SelectionModel};
 
 use crate::components::Avatar;
-use crate::session::room::Room;
+use crate::session::room::{Room, RoomType};
 use crate::session::verification::IdentityVerification;
 use crate::session::Session;
 use crate::session::User;
@@ -37,7 +37,10 @@ mod imp {
     use super::*;
     use glib::subclass::InitializingObject;
     use once_cell::sync::Lazy;
-    use std::cell::{Cell, RefCell};
+    use std::{
+        cell::{Cell, RefCell},
+        convert::TryFrom,
+    };
 
     #[derive(Debug, Default, CompositeTemplate)]
     #[template(resource = "/org/gnome/FractalNext/sidebar.ui")]
@@ -55,6 +58,9 @@ mod imp {
         #[template_child]
         pub room_search: TemplateChild<gtk::SearchBar>,
         pub user: RefCell<Option<User>>,
+        /// The type of the source that activated drop mode.
+        pub drop_source_type: Cell<Option<RoomType>>,
+        pub drop_binding: RefCell<Option<glib::Binding>>,
     }
 
     #[glib::object_subclass]
@@ -68,6 +74,35 @@ mod imp {
             Row::static_type();
             Avatar::static_type();
             Self::bind_template(klass);
+            klass.set_css_name("sidebar");
+
+            klass.install_action(
+                "sidebar.set-drop-source-type",
+                Some("u"),
+                move |obj, _, variant| {
+                    obj.set_drop_source_type(
+                        variant
+                            .and_then(|variant| variant.get::<Option<u32>>().flatten())
+                            .and_then(|u| RoomType::try_from(u).ok()),
+                    );
+                },
+            );
+            klass.install_action("sidebar.update-drop-targets", None, move |obj, _, _| {
+                if obj.drop_source_type().is_some() {
+                    obj.update_drop_targets();
+                }
+            });
+            klass.install_action(
+                "sidebar.set-active-drop-category",
+                Some("mu"),
+                move |obj, _, variant| {
+                    obj.update_active_drop_targets(
+                        variant
+                            .and_then(|variant| variant.get::<Option<u32>>().flatten())
+                            .and_then(|u| RoomType::try_from(u).ok()),
+                    );
+                },
+            );
         }
 
         fn instance_init(obj: &InitializingObject<Self>) {
@@ -98,7 +133,7 @@ mod imp {
                         "Item List",
                         "The list of items in the sidebar",
                         ItemList::static_type(),
-                        glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                        glib::ParamFlags::WRITABLE | glib::ParamFlags::EXPLICIT_NOTIFY,
                     ),
                     glib::ParamSpec::new_object(
                         "selected-item",
@@ -107,6 +142,14 @@ mod imp {
                         glib::Object::static_type(),
                         glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
                     ),
+                    glib::ParamSpec::new_enum(
+                        "drop-source-type",
+                        "Drop Source Type",
+                        "The type of the source that activated drop mode",
+                        CategoryType::static_type(),
+                        CategoryType::None as i32,
+                        glib::ParamFlags::READABLE | glib::ParamFlags::EXPLICIT_NOTIFY,
+                    ),
                 ]
             });
 
@@ -144,6 +187,11 @@ mod imp {
                 "compact" => self.compact.get().to_value(),
                 "user" => obj.user().to_value(),
                 "selected-item" => obj.selected_item().to_value(),
+                "drop-source-type" => obj
+                    .drop_source_type()
+                    .map(CategoryType::from)
+                    .unwrap_or(CategoryType::None)
+                    .to_value(),
                 _ => unimplemented!(),
             }
         }
@@ -151,7 +199,7 @@ mod imp {
         fn constructed(&self, obj: &Self::Type) {
             self.parent_constructed(obj);
 
-            self.listview.get().connect_activate(move |listview, pos| {
+            self.listview.connect_activate(move |listview, pos| {
                 let model: Option<Selection> = listview.model().and_then(|o| o.downcast().ok());
                 let row: Option<gtk::TreeListRow> = model
                     .as_ref()
@@ -200,6 +248,11 @@ impl Sidebar {
 
     pub fn set_item_list(&self, item_list: Option<ItemList>) {
         let priv_ = imp::Sidebar::from_instance(self);
+
+        if let Some(binding) = priv_.drop_binding.take() {
+            binding.unbind();
+        }
+
         let item_list = match item_list {
             Some(item_list) => item_list,
             None => {
@@ -208,6 +261,12 @@ impl Sidebar {
             }
         };
 
+        priv_.drop_binding.replace(
+            self.bind_property("drop-source-type", &item_list, "show-all-for-category")
+                .flags(glib::BindingFlags::SYNC_CREATE)
+                .build(),
+        );
+
         let tree_model = gtk::TreeListModel::new(&item_list, false, true, |item| {
             item.clone().downcast::<gio::ListModel>().ok()
         });
@@ -280,6 +339,119 @@ impl Sidebar {
             .account_switcher
             .set_logged_in_users(sessions_stack_pages, session_root);
     }
+
+    pub fn drop_source_type(&self) -> Option<RoomType> {
+        let priv_ = imp::Sidebar::from_instance(self);
+        priv_.drop_source_type.get()
+    }
+
+    pub fn set_drop_source_type(&self, source_type: Option<RoomType>) {
+        let priv_ = imp::Sidebar::from_instance(self);
+
+        if self.drop_source_type() == source_type {
+            return;
+        }
+
+        priv_.drop_source_type.set(source_type);
+
+        if source_type.is_some() {
+            priv_.listview.add_css_class("drop-mode");
+        } else {
+            priv_.listview.remove_css_class("drop-mode");
+        }
+
+        self.notify("drop-source-type");
+        self.update_drop_targets();
+    }
+
+    /// Update the disabled or empty state of drop targets.
+    fn update_drop_targets(&self) {
+        let priv_ = imp::Sidebar::from_instance(self);
+        let mut child = priv_.listview.first_child();
+
+        while let Some(widget) = child {
+            if let Some(row) = widget
+                .first_child()
+                .and_then(|widget| widget.downcast::<Row>().ok())
+            {
+                if let Some(source_type) = self.drop_source_type() {
+                    if row
+                        .room_type()
+                        .filter(|row_type| source_type.can_change_to(row_type))
+                        .is_some()
+                    {
+                        row.remove_css_class("drop-disabled");
+
+                        if row
+                            .item()
+                            .and_then(|object| object.downcast::<Category>().ok())
+                            .filter(|category| category.is_empty())
+                            .is_some()
+                        {
+                            row.add_css_class("drop-empty");
+                        } else {
+                            row.remove_css_class("drop-empty");
+                        }
+                    } else {
+                        let is_forget_entry = row
+                            .entry_type()
+                            .filter(|entry_type| entry_type == &EntryType::Forget)
+                            .is_some();
+                        if is_forget_entry && source_type == RoomType::Left {
+                            row.remove_css_class("drop-disabled");
+                        } else {
+                            row.add_css_class("drop-disabled");
+                            row.remove_css_class("drop-empty");
+                        }
+                    }
+                } else {
+                    // Clear style
+                    row.remove_css_class("drop-disabled");
+                    row.remove_css_class("drop-empty");
+                    row.parent().unwrap().remove_css_class("drop-active");
+                };
+
+                if let Some(category_row) = row
+                    .child()
+                    .and_then(|child| child.downcast::<CategoryRow>().ok())
+                {
+                    category_row.set_show_label_for_category(
+                        self.drop_source_type()
+                            .map(CategoryType::from)
+                            .unwrap_or(CategoryType::None),
+                    );
+                }
+            }
+            child = widget.next_sibling();
+        }
+    }
+
+    /// Update the active state of drop targets.
+    fn update_active_drop_targets(&self, target_type: Option<RoomType>) {
+        let priv_ = imp::Sidebar::from_instance(self);
+        let mut child = priv_.listview.first_child();
+
+        while let Some(widget) = child {
+            if let Some((row, row_type)) = widget
+                .first_child()
+                .and_then(|widget| widget.downcast::<Row>().ok())
+                .and_then(|row| {
+                    let row_type = row.room_type()?;
+                    Some((row, row_type))
+                })
+            {
+                if target_type
+                    .filter(|target_type| target_type == &row_type)
+                    .is_some()
+                {
+                    row.parent().unwrap().add_css_class("drop-active");
+                } else {
+                    row.parent().unwrap().remove_css_class("drop-active");
+                }
+            }
+            child = widget.next_sibling();
+        }
+    }
 }
 
 impl Default for Sidebar {
diff --git a/src/session/sidebar/room_row.rs b/src/session/sidebar/room_row.rs
index 5b6540bb..f08b8593 100644
--- a/src/session/sidebar/room_row.rs
+++ b/src/session/sidebar/room_row.rs
@@ -1,7 +1,7 @@
 use adw::subclass::prelude::BinImpl;
-use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
+use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
 
-use crate::session::room::{HighlightFlags, Room};
+use crate::session::room::{HighlightFlags, Room, RoomType};
 
 mod imp {
     use super::*;
@@ -74,6 +74,27 @@ mod imp {
             }
         }
 
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            // Allow to drag rooms
+            let drag = gtk::DragSource::builder()
+                .actions(gdk::DragAction::MOVE)
+                .build();
+            drag.connect_prepare(
+                clone!(@weak obj => @default-return None, move |drag, x, y| {
+                    obj.drag_prepare(drag, x, y)
+                }),
+            );
+            drag.connect_drag_begin(clone!(@weak obj => move |_, _| {
+                obj.drag_begin();
+            }));
+            drag.connect_drag_end(clone!(@weak obj => move |_, _, _| {
+                obj.drag_end();
+            }));
+            obj.add_controller(&drag);
+        }
+
         fn dispose(&self, _obj: &Self::Type) {
             if let Some(room) = self.room.take() {
                 if let Some(id) = self.signal_handler.take() {
@@ -116,6 +137,7 @@ impl RoomRow {
             if let Some(binding) = priv_.binding.take() {
                 binding.unbind();
             }
+            priv_.display_name.remove_css_class("dim-label");
         }
 
         if let Some(ref room) = room {
@@ -138,6 +160,10 @@ impl RoomRow {
                 }),
             )));
 
+            if room.category() == RoomType::Left {
+                priv_.display_name.add_css_class("dim-label");
+            }
+
             self.set_highlight();
         }
         priv_.room.replace(room);
@@ -168,6 +194,26 @@ impl RoomRow {
             };
         }
     }
+
+    fn drag_prepare(&self, drag: &gtk::DragSource, x: f64, y: f64) -> Option<gdk::ContentProvider> {
+        let paintable = gtk::WidgetPaintable::new(Some(&self.parent().unwrap()));
+        // FIXME: The hotspot coordinates don't work.
+        // See https://gitlab.gnome.org/GNOME/gtk/-/issues/2341
+        drag.set_icon(Some(&paintable), x as i32, y as i32);
+        self.room()
+            .map(|room| gdk::ContentProvider::for_value(&room.to_value()))
+    }
+
+    fn drag_begin(&self) {
+        self.parent().unwrap().add_css_class("drag");
+        let category = Some(u32::from(self.room().unwrap().category()));
+        self.activate_action("sidebar.set-drop-source-type", Some(&category.to_variant()));
+    }
+
+    fn drag_end(&self) {
+        self.activate_action("sidebar.set-drop-source-type", None);
+        self.parent().unwrap().remove_css_class("drag");
+    }
 }
 
 impl Default for RoomRow {
diff --git a/src/session/sidebar/row.rs b/src/session/sidebar/row.rs
index d993d91e..41f71f05 100644
--- a/src/session/sidebar/row.rs
+++ b/src/session/sidebar/row.rs
@@ -1,12 +1,16 @@
+use std::convert::TryFrom;
+
 use adw::{prelude::*, subclass::prelude::*};
-use gtk::{glib, subclass::prelude::*};
+use gtk::{gdk, glib, glib::clone, subclass::prelude::*};
 
 use crate::session::{
-    room::Room,
+    room::{Room, RoomType},
     sidebar::{Category, CategoryRow, Entry, EntryRow, RoomRow, VerificationRow},
     verification::IdentityVerification,
 };
 
+use super::EntryType;
+
 mod imp {
     use super::*;
     use once_cell::sync::Lazy;
@@ -23,6 +27,10 @@ mod imp {
         const NAME: &'static str = "SidebarRow";
         type Type = super::Row;
         type ParentType = adw::Bin;
+
+        fn class_init(klass: &mut Self::Class) {
+            klass.set_css_name("sidebar-row");
+        }
     }
 
     impl ObjectImpl for Row {
@@ -72,6 +80,28 @@ mod imp {
                 _ => unimplemented!(),
             }
         }
+
+        fn constructed(&self, obj: &Self::Type) {
+            self.parent_constructed(obj);
+
+            // Set up drop controller
+            let drop = gtk::DropTarget::builder()
+                .actions(gdk::DragAction::MOVE)
+                .formats(&gdk::ContentFormats::for_type(Room::static_type()))
+                .build();
+            drop.connect_accept(clone!(@weak obj => @default-return false, move |_, drop| {
+                obj.drop_accept(drop)
+            }));
+            drop.connect_leave(clone!(@weak obj => move |_| {
+                obj.drop_leave();
+            }));
+            drop.connect_drop(
+                clone!(@weak obj => @default-return false, move |_, v, _, _| {
+                    obj.drop_end(v)
+                }),
+            );
+            obj.add_controller(&drop);
+        }
     }
 
     impl WidgetImpl for Row {}
@@ -162,6 +192,10 @@ impl Row {
                     child
                 };
 
+                if entry.type_() == EntryType::Forget {
+                    self.add_css_class("forget");
+                }
+
                 child.set_entry(Some(entry.clone()));
 
                 if let Some(list_item) = self.parent() {
@@ -186,11 +220,84 @@ impl Row {
             } else {
                 panic!("Wrong row item: {:?}", item);
             }
+            self.activate_action("sidebar.update-drop-targets", None);
         }
 
         self.notify("item");
         self.notify("list-row");
     }
+
+    /// Get the `RoomType` of this item.
+    ///
+    /// If this is not a `Category` or one of its children, returns `None`.
+    pub fn room_type(&self) -> Option<RoomType> {
+        let item = self.item()?;
+
+        if let Some(room) = item.downcast_ref::<Room>() {
+            Some(room.category())
+        } else {
+            item.downcast_ref::<Category>()
+                .and_then(|category| RoomType::try_from(category.type_()).ok())
+        }
+    }
+
+    /// Get the `EntryType` of this item.
+    ///
+    /// If this is not a `Entry`, returns `None`.
+    pub fn entry_type(&self) -> Option<EntryType> {
+        let item = self.item()?;
+        item.downcast_ref::<Entry>().map(|entry| entry.type_())
+    }
+
+    fn drop_accept(&self, drop: &gdk::Drop) -> bool {
+        let room = drop
+            .drag()
+            .and_then(|drag| drag.content())
+            .and_then(|content| content.value(Room::static_type()).ok())
+            .and_then(|value| value.get::<Room>().ok());
+        if let Some(room) = room {
+            if let Some(target_type) = self.room_type() {
+                if room.category().can_change_to(&target_type) {
+                    self.activate_action(
+                        "sidebar.set-active-drop-category",
+                        Some(&Some(u32::from(target_type)).to_variant()),
+                    );
+                    return true;
+                }
+            } else if let Some(entry_type) = self.entry_type() {
+                if room.category() == RoomType::Left && entry_type == EntryType::Forget {
+                    self.parent().unwrap().add_css_class("drop-active");
+                    self.activate_action("sidebar.set-active-drop-category", None);
+                    return true;
+                }
+            }
+        }
+        false
+    }
+
+    fn drop_leave(&self) {
+        self.parent().unwrap().remove_css_class("drop-active");
+        self.activate_action("sidebar.set-active-drop-category", None);
+    }
+
+    fn drop_end(&self, value: &glib::Value) -> bool {
+        let mut ret = false;
+        if let Ok(room) = value.get::<Room>() {
+            if let Some(target_type) = self.room_type() {
+                if room.category().can_change_to(&target_type) {
+                    room.set_category(target_type);
+                    ret = true;
+                }
+            } else if let Some(entry_type) = self.entry_type() {
+                if room.category() == RoomType::Left && entry_type == EntryType::Forget {
+                    room.forget();
+                    ret = true;
+                }
+            }
+        }
+        self.activate_action("sidebar.set-drop-source-type", None);
+        ret
+    }
 }
 
 impl Default for Row {


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