From beb9dd4e911153c90bbf2bbe887ea37543f7e81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 16 Mar 2023 20:56:59 +0100 Subject: [PATCH] room-details: Use EditableAvatar --- data/resources/style.css | 21 +- .../ui/content-room-details-general-page.ui | 55 +---- .../content/room_details/general_page/mod.rs | 231 ++++++++++++------ src/session/room/mod.rs | 45 +--- 4 files changed, 171 insertions(+), 181 deletions(-) diff --git a/data/resources/style.css b/data/resources/style.css index 66a7dd2d..8493baab 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -163,6 +163,12 @@ entry .inline-pill { margin-bottom: -0.5em; } +.cutout-button { + background-color: @window_bg_color; + border-radius: 9999px; + padding: 2px; +} + /* Login */ @@ -582,19 +588,10 @@ typing-bar avatar { /* Room Details */ -.room-details-group overlay { - margin-bottom: 6px; -} - .room-details listview { background: transparent; } -.room-details-group avatar * { - /* Undo non-sensitive style. */ - filter: none; -} - .room-details-group entry:disabled { border-color: transparent; /* Undo non-sensitive style. */ @@ -627,12 +624,6 @@ typing-bar avatar { filter: opacity(1); } -.cutout-button { - background-color: @window_bg_color; - border-radius: 9999px; - padding: 2px; -} - dragoverlay statuspage { background-color: alpha(@accent_bg_color, 0.5); color: @accent_fg_color; diff --git a/data/resources/ui/content-room-details-general-page.ui b/data/resources/ui/content-room-details-general-page.ui index 65ee7508..50138482 100644 --- a/data/resources/ui/content-room-details-general-page.ui +++ b/data/resources/ui/content-room-details-general-page.ui @@ -22,54 +22,12 @@ - - center - - - 128 - - - ContentRoomDetailsGeneralPage - - - - - - - - end - start - - - user-trash-symbolic - details.remove-avatar - - - - - - - - - end - end - - - document-edit-symbolic - details.choose-avatar - - - - - + + + + ContentRoomDetailsGeneralPage + + @@ -177,4 +135,3 @@ - diff --git a/src/session/content/room_details/general_page/mod.rs b/src/session/content/room_details/general_page/mod.rs index d4f102c5..00722cc3 100644 --- a/src/session/content/room_details/general_page/mod.rs +++ b/src/session/content/room_details/general_page/mod.rs @@ -3,21 +3,30 @@ use std::convert::From; use adw::{prelude::*, subclass::prelude::*}; use gettextrs::gettext; use gtk::{ - gdk, - glib::{self, clone, closure}, + gio, + glib::{self, clone}, CompositeTemplate, }; use log::error; -use matrix_sdk::ruma::events::StateEventType; +use matrix_sdk::room::Room as MatrixRoom; +use ruma::{ + assign, + events::{room::avatar::ImageInfo, StateEventType}, + OwnedMxcUri, +}; use crate::{ - components::CustomEntry, - session::{self, room::RoomAction, Room}, - utils::{and_expr, or_expr}, + components::{CustomEntry, EditableAvatar}, + session::{room::RoomAction, Room}, + spawn, spawn_tokio, toast, + utils::{ + media::{get_image_info, load_file}, + or_expr, OngoingAsyncAction, + }, }; mod imp { - use std::cell::Cell; + use std::cell::{Cell, RefCell}; use glib::subclass::InitializingObject; use once_cell::unsync::OnceCell; @@ -28,11 +37,8 @@ mod imp { #[template(resource = "/org/gnome/Fractal/content-room-details-general-page.ui")] pub struct GeneralPage { pub room: OnceCell, - pub avatar_chooser: OnceCell, - #[template_child] - pub avatar_remove_button: TemplateChild, #[template_child] - pub avatar_edit_button: TemplateChild, + pub avatar: TemplateChild, #[template_child] pub edit_toggle: TemplateChild, #[template_child] @@ -46,6 +52,7 @@ mod imp { #[template_child] pub members_count: TemplateChild, pub edit_mode: Cell, + pub changing_avatar: RefCell>>, } #[glib::object_subclass] @@ -56,13 +63,6 @@ mod imp { fn class_init(klass: &mut Self::Class) { Self::bind_template(klass); - - klass.install_action("details.choose-avatar", None, move |widget, _, _| { - widget.open_avatar_chooser() - }); - klass.install_action("details.remove-avatar", None, move |widget, _, _| { - widget.room().store_avatar(None) - }); } fn instance_init(obj: &InitializingObject) { @@ -135,30 +135,155 @@ impl GeneralPage { /// Set the room backing all the details of the preference window. fn set_room(&self, room: Room) { + room.avatar().connect_notify_local( + Some("url"), + clone!(@weak self as obj => move |avatar, _| { + obj.avatar_changed(avatar.url()); + }), + ); + self.imp().room.set(room).expect("Room already initialized"); } fn init_avatar(&self) { - let imp = self.imp(); - let avatar_remove_button = &imp.avatar_remove_button; - let avatar_edit_button = &imp.avatar_edit_button; + let avatar = &*self.imp().avatar; + avatar.connect_edit_avatar(clone!(@weak self as obj => move |_, file| { + spawn!( + clone!(@weak obj => async move { + obj.change_avatar(file).await; + }) + ); + })); + avatar.connect_remove_avatar(clone!(@weak self as obj => move |_| { + spawn!( + clone!(@weak obj => async move { + obj.remove_avatar().await; + }) + ); + })); // Hide avatar controls when the user is not eligible to perform the actions. let room = self.room(); - - let room_avatar_exists = room - .property_expression("avatar") - .chain_property::("image") - .chain_closure::(closure!( - |_: Option, image: Option| { image.is_some() } - )); - let room_avatar_changeable = room.new_allowed_expr(RoomAction::StateEvent(StateEventType::RoomAvatar)); - let room_avatar_removable = and_expr(&room_avatar_changeable, &room_avatar_exists); - room_avatar_removable.bind(&avatar_remove_button.get(), "visible", gtk::Widget::NONE); - room_avatar_changeable.bind(&avatar_edit_button.get(), "visible", gtk::Widget::NONE); + room_avatar_changeable.bind(avatar, "editable", gtk::Widget::NONE); + } + + fn avatar_changed(&self, uri: Option) { + let imp = self.imp(); + + if let Some(action) = imp.changing_avatar.borrow().as_ref() { + if uri.as_ref() != action.as_value() { + // This is not the change we expected, maybe another device did a change too. + // Let's wait for another change. + return; + } + } else { + // No action is ongoing, we don't need to do anything. + return; + }; + + // Reset the state. + imp.changing_avatar.take(); + imp.avatar.success(); + if uri.is_none() { + toast!(self, gettext("Avatar removed successfully")); + } else { + toast!(self, gettext("Avatar changed successfully")); + } + } + + async fn change_avatar(&self, file: gio::File) { + let room = self.room(); + let MatrixRoom::Joined(matrix_room) = room.matrix_room() else { + error!("Cannot change avatar of room not joined"); + return; + }; + + let imp = self.imp(); + let avatar = &imp.avatar; + avatar.edit_in_progress(); + + let (data, info) = match load_file(&file).await { + Ok(res) => res, + Err(error) => { + error!("Could not load room avatar file: {error}"); + toast!(self, gettext("Could not load file")); + avatar.reset(); + return; + } + }; + + let base_image_info = get_image_info(&file).await; + let image_info = assign!(ImageInfo::new(), { + width: base_image_info.width, + height: base_image_info.height, + size: info.size.map(Into::into), + mimetype: Some(info.mime.to_string()), + }); + + let client = room.session().client(); + let handle = spawn_tokio!(async move { client.media().upload(&info.mime, data).await }); + + let uri = match handle.await.unwrap() { + Ok(res) => res.content_uri, + Err(error) => { + error!("Could not upload room avatar: {}", error); + toast!(self, gettext("Could not upload avatar")); + avatar.reset(); + return; + } + }; + + let (action, weak_action) = OngoingAsyncAction::set(uri.clone()); + imp.changing_avatar.replace(Some(action)); + + let handle = + spawn_tokio!(async move { matrix_room.set_avatar_url(&uri, Some(image_info)).await }); + + // We don't need to handle the success of the request, we should receive the + // change via sync. + if let Err(error) = handle.await.unwrap() { + // Because this action can finish in avatar_changed, we must only act if this is + // still the current action. + if weak_action.is_ongoing() { + imp.changing_avatar.take(); + error!("Could not change room avatar: {error}"); + toast!(self, gettext("Could not change avatar")); + avatar.reset(); + } + } + } + + async fn remove_avatar(&self) { + let room = self.room(); + let MatrixRoom::Joined(matrix_room) = room.matrix_room() else { + error!("Cannot remove avatar of room not joined"); + return; + }; + + let imp = self.imp(); + let avatar = &*imp.avatar; + avatar.removal_in_progress(); + + let (action, weak_action) = OngoingAsyncAction::remove(); + imp.changing_avatar.replace(Some(action)); + + let handle = spawn_tokio!(async move { matrix_room.remove_avatar().await }); + + // We don't need to handle the success of the request, we should receive the + // change via sync. + if let Err(error) = handle.await.unwrap() { + // Because this action can finish in avatar_changed, we must only act if this is + // still the current action. + if weak_action.is_ongoing() { + imp.changing_avatar.take(); + error!("Could not remove room avatar: {}", error); + toast!(self, gettext("Could not remove avatar")); + avatar.reset(); + } + } } fn init_edit_toggle(&self) { @@ -214,48 +339,6 @@ impl GeneralPage { edit_toggle_visible.bind(&edit_toggle.get(), "visible", gtk::Widget::NONE); } - fn avatar_chooser(&self) -> Option<>k::FileChooserNative> { - if let Some(avatar_chooser) = self.imp().avatar_chooser.get() { - Some(avatar_chooser) - } else { - let window = self.root()?.downcast::().ok()?; - - let avatar_chooser = gtk::FileChooserNative::new( - Some(&gettext("Choose avatar")), - Some(&window), - gtk::FileChooserAction::Open, - None, - None, - ); - avatar_chooser.connect_response( - clone!(@weak self as this => move |chooser, response| { - let file = chooser.file().and_then(|f| f.path()); - if let (gtk::ResponseType::Accept, Some(file)) = (response, file) { - log::debug!("Chose file {:?}", file); - this.room().store_avatar(Some(file)); - } - }), - ); - - // We must keep a reference to FileChooserNative around as it is not - // managed by GTK. - self.imp() - .avatar_chooser - .set(avatar_chooser) - .expect("File chooser already initialized"); - - self.avatar_chooser() - } - } - - fn open_avatar_chooser(&self) { - if let Some(avatar_chooser) = self.avatar_chooser() { - avatar_chooser.show(); - } else { - error!("Failed to create the FileChooserNative"); - } - } - fn member_count_changed(&self, n: u32) { self.imp().members_count.set_text(&format!("{n}")); } diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs index 50170bb0..ca461b8d 100644 --- a/src/session/room/mod.rs +++ b/src/session/room/mod.rs @@ -8,10 +8,10 @@ mod room_type; mod timeline; mod typing_list; -use std::{cell::RefCell, io::Cursor, path::PathBuf}; +use std::{cell::RefCell, io::Cursor}; use gettextrs::{gettext, ngettext}; -use gtk::{gio, glib, glib::clone, prelude::*, subclass::prelude::*}; +use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*}; use log::{debug, error, info, warn}; use matrix_sdk::{ attachment::{generate_image_thumbnail, AttachmentConfig, AttachmentInfo, Thumbnail}, @@ -1332,47 +1332,6 @@ impl Room { self.power_levels().new_allowed_expr(&member, room_action) } - /// Uploads the given file to the server and makes it the room avatar. - /// - /// Removes the avatar if no filename is given. - pub fn store_avatar(&self, filename: Option) { - let MatrixRoom::Joined(joined_room) = self.matrix_room() else { - error!("Cannot change avatar of a room not joined."); - return; - }; - - let handle = spawn_tokio!(async move { - if let Some(filename) = filename { - debug!("Getting mime type of file {:?}", filename); - let image = tokio::fs::read(filename).await?; - let content_type = gio::content_type_guess(Option::::None, &image) - .0 - .to_string(); - joined_room - .upload_avatar( - &content_type - .parse() - .unwrap_or(mime::APPLICATION_OCTET_STREAM), - image, - None, - ) - .await - } else { - joined_room.remove_avatar().await - } - }); - - spawn!( - glib::PRIORITY_DEFAULT_IDLE, - clone!(@weak self as this => async move { - match handle.await.unwrap() { - Ok(_) => info!("Successfully updated room avatar"), - Err(error) => error!("Couldn’t update room avatar: {}", error), - }; - }) - ); - } - pub async fn accept_invite(&self) -> MatrixResult<()> { let matrix_room = self.matrix_room();