fractal/session/view/content/room_details/
edit_details_subpage.rsuse adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
gio,
glib::{self, clone},
CompositeTemplate,
};
use matrix_sdk::RoomState;
use ruma::{assign, events::room::avatar::ImageInfo, OwnedMxcUri};
use tracing::error;
use crate::{
components::{
ActionButton, ActionState, AvatarData, AvatarImage, EditableAvatar, LoadingButton,
},
prelude::*,
session::model::Room,
spawn_tokio, toast,
utils::{
media::{image::ImageInfoLoader, FileInfo},
template_callbacks::TemplateCallbacks,
BoundObjectWeakRef, OngoingAsyncAction,
},
};
mod imp {
use std::cell::RefCell;
use glib::subclass::InitializingObject;
use super::*;
#[derive(Debug, Default, CompositeTemplate, glib::Properties)]
#[template(
resource = "/org/gnome/Fractal/ui/session/view/content/room_details/edit_details_subpage.ui"
)]
#[properties(wrapper_type = super::EditDetailsSubpage)]
pub struct EditDetailsSubpage {
#[template_child]
avatar: TemplateChild<EditableAvatar>,
#[template_child]
name_entry_row: TemplateChild<adw::EntryRow>,
#[template_child]
name_button: TemplateChild<ActionButton>,
#[template_child]
topic_text_view: TemplateChild<gtk::TextView>,
#[template_child]
topic_buffer: TemplateChild<gtk::TextBuffer>,
#[template_child]
save_topic_button: TemplateChild<LoadingButton>,
#[property(get, set = Self::set_room, explicit_notify, nullable)]
room: BoundObjectWeakRef<Room>,
changing_avatar: RefCell<Option<OngoingAsyncAction<OwnedMxcUri>>>,
changing_name: RefCell<Option<OngoingAsyncAction<String>>>,
changing_topic: RefCell<Option<OngoingAsyncAction<String>>>,
expr_watch: RefCell<Option<gtk::ExpressionWatch>>,
}
#[glib::object_subclass]
impl ObjectSubclass for EditDetailsSubpage {
const NAME: &'static str = "RoomDetailsEditDetailsSubpage";
type Type = super::EditDetailsSubpage;
type ParentType = adw::NavigationPage;
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
TemplateCallbacks::bind_template_callbacks(klass);
}
fn instance_init(obj: &InitializingObject<Self>) {
obj.init_template();
}
}
#[glib::derived_properties]
impl ObjectImpl for EditDetailsSubpage {
fn dispose(&self) {
self.disconnect_all();
}
}
impl WidgetImpl for EditDetailsSubpage {}
impl NavigationPageImpl for EditDetailsSubpage {}
#[gtk::template_callbacks]
impl EditDetailsSubpage {
fn set_room(&self, room: Option<&Room>) {
let Some(room) = room else {
return;
};
self.disconnect_all();
let avatar_data = room.avatar_data();
let expr_watch = AvatarData::this_expression("image")
.chain_property::<AvatarImage>("uri-string")
.watch(
Some(&avatar_data),
clone!(
#[weak(rename_to = imp)]
self,
#[weak]
avatar_data,
move || {
imp.avatar_changed(avatar_data.image().and_then(|i| i.uri()).as_ref());
}
),
);
self.expr_watch.replace(Some(expr_watch));
let name_handler = room.connect_name_notify(clone!(
#[weak(rename_to = imp)]
self,
move |room| {
imp.name_changed(room.name().as_deref());
}
));
let topic_handler = room.connect_topic_notify(clone!(
#[weak(rename_to = imp)]
self,
move |room| {
imp.topic_changed(room.topic().as_deref());
}
));
self.room.set(room, vec![name_handler, topic_handler]);
self.obj().notify_room();
}
fn avatar_changed(&self, uri: Option<&OwnedMxcUri>) {
if let Some(action) = self.changing_avatar.borrow().as_ref() {
if uri != action.as_value() {
return;
}
} else {
return;
};
self.changing_avatar.take();
self.avatar.success();
let obj = self.obj();
if uri.is_none() {
toast!(obj, gettext("Avatar removed successfully"));
} else {
toast!(obj, gettext("Avatar changed successfully"));
}
}
#[template_callback]
async fn change_avatar(&self, file: gio::File) {
let Some(room) = self.room.obj() else {
return;
};
let matrix_room = room.matrix_room();
if matrix_room.state() != RoomState::Joined {
error!("Cannot change avatar of room not joined");
return;
}
let obj = self.obj();
let avatar = &self.avatar;
avatar.edit_in_progress();
let info = match FileInfo::try_from_file(&file).await {
Ok(info) => info,
Err(error) => {
error!("Could not load room avatar file info: {error}");
toast!(obj, gettext("Could not load file"));
avatar.reset();
return;
}
};
let data = match file.load_contents_future().await {
Ok((data, _)) => data,
Err(error) => {
error!("Could not load room avatar file: {error}");
toast!(obj, gettext("Could not load file"));
avatar.reset();
return;
}
};
let base_image_info = ImageInfoLoader::from(file).load_info().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 Some(session) = room.session() else {
return;
};
let client = session.client();
let handle =
spawn_tokio!(
async move { client.media().upload(&info.mime, data.into(), None).await }
);
let uri = match handle.await.unwrap() {
Ok(res) => res.content_uri,
Err(error) => {
error!("Could not upload room avatar: {error}");
toast!(obj, gettext("Could not upload avatar"));
avatar.reset();
return;
}
};
let (action, weak_action) = OngoingAsyncAction::set(uri.clone());
self.changing_avatar.replace(Some(action));
let matrix_room = matrix_room.clone();
let handle =
spawn_tokio!(
async move { matrix_room.set_avatar_url(&uri, Some(image_info)).await }
);
if let Err(error) = handle.await.unwrap() {
if weak_action.is_ongoing() {
self.changing_avatar.take();
error!("Could not change room avatar: {error}");
toast!(obj, gettext("Could not change avatar"));
avatar.reset();
}
}
}
#[template_callback]
async fn remove_avatar(&self) {
let Some(room) = self.room.obj() else {
error!("Cannot remove avatar with missing room");
return;
};
let matrix_room = room.matrix_room();
if matrix_room.state() != RoomState::Joined {
error!("Cannot remove avatar of room not joined");
return;
}
let obj = self.obj();
let confirm_dialog = adw::AlertDialog::builder()
.default_response("cancel")
.heading(gettext("Remove Avatar?"))
.body(gettext(
"Do you really want to remove the avatar for this room?",
))
.build();
confirm_dialog.add_responses(&[
("cancel", &gettext("Cancel")),
("remove", &gettext("Remove")),
]);
confirm_dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
if confirm_dialog.choose_future(&*obj).await != "remove" {
return;
}
let avatar = &self.avatar;
avatar.removal_in_progress();
let (action, weak_action) = OngoingAsyncAction::remove();
self.changing_avatar.replace(Some(action));
let matrix_room = matrix_room.clone();
let handle = spawn_tokio!(async move { matrix_room.remove_avatar().await });
if let Err(error) = handle.await.unwrap() {
if weak_action.is_ongoing() {
self.changing_avatar.take();
error!("Could not remove room avatar: {error}");
toast!(obj, gettext("Could not remove avatar"));
avatar.reset();
}
}
}
fn reset_name(&self) {
let Some(room) = self.room.obj() else {
return;
};
self.name_entry_row
.set_sensitive(room.permissions().can_change_name());
self.name_entry_row
.set_text(&room.name().unwrap_or_default());
self.name_button.set_visible(false);
self.name_button.set_state(ActionState::Confirm);
}
fn name_changed(&self, name: Option<&str>) {
if let Some(action) = self.changing_name.borrow().as_ref() {
if name != action.as_value().map(String::as_str) {
return;
}
} else {
return;
};
let obj = self.obj();
toast!(obj, gettext("Room name saved successfully"));
self.changing_name.take();
self.reset_name();
}
fn was_name_edited(&self) -> bool {
let Some(room) = self.room.obj() else {
return false;
};
let text = Some(self.name_entry_row.text()).filter(|t| !t.is_empty());
let trimmed_text = text.as_deref().map(str::trim).filter(|t| !t.is_empty());
let name = room.name();
name.as_deref() != text.as_deref() && name.as_deref() != trimmed_text
}
#[template_callback]
fn name_edited(&self) {
self.name_button.set_visible(self.was_name_edited());
}
#[template_callback]
async fn change_name(&self) {
if !self.was_name_edited() {
return;
}
let Some(room) = self.room.obj() else {
return;
};
let matrix_room = room.matrix_room().clone();
if matrix_room.state() != RoomState::Joined {
error!("Cannot change name of room not joined");
return;
}
self.name_entry_row.set_sensitive(false);
self.name_button.set_state(ActionState::Loading);
let name = Some(self.name_entry_row.text().trim())
.filter(|t| !t.is_empty())
.map(ToOwned::to_owned);
let (action, weak_action) = if let Some(name) = name.clone() {
OngoingAsyncAction::set(name)
} else {
OngoingAsyncAction::remove()
};
self.changing_name.replace(Some(action));
let handle =
spawn_tokio!(async move { matrix_room.set_name(name.unwrap_or_default()).await });
if let Err(error) = handle.await.unwrap() {
if weak_action.is_ongoing() {
self.changing_name.take();
error!("Could not change room name: {error}");
let obj = self.obj();
toast!(obj, gettext("Could not change room name"));
self.name_entry_row.set_sensitive(true);
self.name_button.set_state(ActionState::Retry);
}
}
}
fn reset_topic(&self) {
let Some(room) = self.room.obj() else {
return;
};
self.topic_text_view
.set_sensitive(room.permissions().can_change_topic());
self.topic_buffer
.set_text(&room.topic().unwrap_or_default());
self.save_topic_button.set_is_loading(false);
self.save_topic_button.set_sensitive(false);
}
fn topic_changed(&self, topic: Option<&str>) {
let topic = topic.unwrap_or_default();
if let Some(action) = self.changing_topic.borrow().as_ref() {
if Some(topic) != action.as_value().map(String::as_str) {
return;
}
} else {
return;
};
let obj = self.obj();
toast!(obj, gettext("Room description saved successfully"));
self.changing_topic.take();
self.reset_topic();
}
fn was_topic_edited(&self) -> bool {
let Some(room) = self.room.obj() else {
return false;
};
let (start_iter, end_iter) = self.topic_buffer.bounds();
let text = Some(self.topic_buffer.text(&start_iter, &end_iter, false))
.filter(|t| !t.is_empty());
let trimmed_text = text.as_deref().map(str::trim).filter(|t| !t.is_empty());
let topic = room.topic();
topic.as_deref() != text.as_deref() && topic.as_deref() != trimmed_text
}
#[template_callback]
fn topic_edited(&self) {
self.save_topic_button
.set_sensitive(self.was_topic_edited());
}
#[template_callback]
async fn change_topic(&self) {
if !self.was_topic_edited() {
return;
}
let Some(room) = self.room.obj() else {
return;
};
let matrix_room = room.matrix_room().clone();
if matrix_room.state() != RoomState::Joined {
error!("Cannot change description of room not joined");
return;
}
self.topic_text_view.set_sensitive(false);
self.save_topic_button.set_is_loading(true);
let (start_iter, end_iter) = self.topic_buffer.bounds();
let topic = Some(self.topic_buffer.text(&start_iter, &end_iter, false).trim())
.filter(|t| !t.is_empty())
.map(ToOwned::to_owned);
let (action, weak_action) = if let Some(topic) = topic.clone() {
OngoingAsyncAction::set(topic)
} else {
OngoingAsyncAction::remove()
};
self.changing_topic.replace(Some(action));
let handle = spawn_tokio!(async move {
matrix_room.set_room_topic(&topic.unwrap_or_default()).await
});
if let Err(error) = handle.await.unwrap() {
if weak_action.is_ongoing() {
self.changing_topic.take();
error!("Could not change room description: {error}");
let obj = self.obj();
toast!(obj, gettext("Could not change room description"));
self.topic_text_view.set_sensitive(true);
self.save_topic_button.set_is_loading(false);
}
}
}
#[template_callback]
async fn go_back(&self) {
let obj = self.obj();
let mut reset_after = false;
let has_unsaved_changes = (self.was_name_edited()
&& self.changing_name.borrow().is_none())
|| (self.was_topic_edited() && self.changing_topic.borrow().is_none());
if has_unsaved_changes {
let title = gettext("Discard Unsaved Changes?");
let description = gettext(
"This page contains unsaved changes. Changes which are not saved will be lost.",
);
let dialog = adw::AlertDialog::builder()
.title(title)
.body(description)
.default_response("cancel")
.build();
dialog.add_responses(&[
("cancel", &gettext("Cancel")),
("discard", &gettext("Discard")),
]);
dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive);
match dialog.choose_future(&*obj).await.as_str() {
"discard" => {
reset_after = true;
}
_ => {
return;
}
}
}
obj.activate_action("navigation.pop", None).unwrap();
if reset_after {
self.reset_name();
self.reset_topic();
}
}
fn disconnect_all(&self) {
self.room.disconnect_signals();
if let Some(watch) = self.expr_watch.take() {
watch.unwatch();
}
}
}
}
glib::wrapper! {
pub struct EditDetailsSubpage(ObjectSubclass<imp::EditDetailsSubpage>)
@extends gtk::Widget, adw::NavigationPage, @implements gtk::Accessible;
}
impl EditDetailsSubpage {
pub fn new(room: &Room) -> Self {
glib::Object::builder().property("room", room).build()
}
}