[fractal/fractal-next] Implement attachments
- From: Julian Sparber <jsparber src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [fractal/fractal-next] Implement attachments
- Date: Tue, 22 Mar 2022 14:41:28 +0000 (UTC)
commit 57b26400356d319c0e924bdbb4777cbf79d5bbce
Author: Maximiliano Sandoval R <msandova gnome org>
Date: Fri Dec 10 18:11:13 2021 +0100
Implement attachments
With drag and drop.
Fixes: https://gitlab.gnome.org/GNOME/fractal/-/issues/121,
https://gitlab.gnome.org/GNOME/fractal/-/issues/764.
data/resources/resources.gresource.xml | 1 +
data/resources/style.css | 7 +-
data/resources/ui/attachment-dialog.ui | 63 ++++++
data/resources/ui/content-room-history.ui | 16 ++
po/POTFILES.in | 2 +
.../content/room_history/attachment_dialog.rs | 74 +++++++
src/session/content/room_history/mod.rs | 244 ++++++++++++++++++++-
src/session/room/mod.rs | 22 +-
8 files changed, 424 insertions(+), 5 deletions(-)
---
diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml
index 766d01c7b..8d1d2cd8d 100644
--- a/data/resources/resources.gresource.xml
+++ b/data/resources/resources.gresource.xml
@@ -18,6 +18,7 @@
<file compressed="true" preprocess="xml-stripblanks"
alias="account-settings-user-page.ui">ui/account-settings-user-page.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="account-settings.ui">ui/account-settings.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="add-account-row.ui">ui/add-account-row.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks"
alias="attachment-dialog.ui">ui/attachment-dialog.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="avatar-with-selection.ui">ui/avatar-with-selection.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="components-action-button.ui">ui/components-action-button.ui</file>
<file compressed="true" preprocess="xml-stripblanks"
alias="components-audio-player.ui">ui/components-audio-player.ui</file>
diff --git a/data/resources/style.css b/data/resources/style.css
index f6ce190a9..526933700 100644
--- a/data/resources/style.css
+++ b/data/resources/style.css
@@ -507,4 +507,9 @@ message-reactions .reaction-count {
background-color: @window_bg_color;
border-radius: 9999px;
padding: 2px;
-}
\ No newline at end of file
+}
+
+dragoverlay statuspage {
+ background-color: alpha(@accent_bg_color, 0.5);
+ color: @accent_fg_color;
+}
diff --git a/data/resources/ui/attachment-dialog.ui b/data/resources/ui/attachment-dialog.ui
new file mode 100644
index 000000000..674aea5d5
--- /dev/null
+++ b/data/resources/ui/attachment-dialog.ui
@@ -0,0 +1,63 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<interface>
+ <template class="AttachmentDialog" parent="GtkWindow">
+ <property name="modal">True</property>
+ <property name="title"></property>
+ <property name="default-width">400</property>
+ <property name="default-height">400</property>
+ <property name="destroy-with-parent">True</property>
+ <property name="titlebar">
+ <object class="GtkHeaderBar">
+ <property name="show-title-buttons">False</property>
+ <child>
+ <object class="GtkButton">
+ <property name="label" translatable="yes">_Cancel</property>
+ <property name="use-underline">True</property>
+ <property name="action-name">window.close</property>
+ </object>
+ </child>
+ <child type="end">
+ <object class="GtkButton">
+ <property name="label" translatable="yes">_Send</property>
+ <property name="use-underline">True</property>
+ <property name="action-name">attachment-dialog.send</property>
+ <style>
+ <class name="suggested-action"/>
+ </style>
+ </object>
+ </child>
+ </object>
+ </property>
+ <property name="child">
+ <object class="GtkStack" id="stack">
+ <child>
+ <object class="AdwStatusPage">
+ <property name="icon-name">face-sick-symbolic</property>
+ <property name="title" translatable="yes">No Preview Available</property>
+ <style>
+ <class name="compact"/>
+ </style>
+ </object>
+ </child>
+ <child>
+ <object class="GtkStackPage">
+ <property name="name">preview</property>
+ <property name="child">
+ <object class="GtkImage" id="preview"/>
+ </property>
+ </object>
+ </child>
+ </object>
+ </property>
+ <child>
+ <object class="GtkShortcutController">
+ <child>
+ <object class="GtkShortcut">
+ <property name="trigger">Escape</property>
+ <property name="action">action(window.close)</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </template>
+</interface>
diff --git a/data/resources/ui/content-room-history.ui b/data/resources/ui/content-room-history.ui
index abefde088..0a729ba89 100644
--- a/data/resources/ui/content-room-history.ui
+++ b/data/resources/ui/content-room-history.ui
@@ -129,6 +129,22 @@
</child>
<child>
<object class="GtkOverlay" id="content">
+ <child type="overlay">
+ <object class="GtkRevealer" id="drag_revealer">
+ <property name="can-target">False</property>
+ <property name="transition_type">crossfade</property>
+ <property name="reveal_child">False</property>
+ <child>
+ <object class="AdwStatusPage">
+ <property name="icon-name">document-send-symbolic</property>
+ <property name="title" translatable="yes">Drop Here to Send</property>
+ <style>
+ <class name="drag-n-drop-overlay"/>
+ </style>
+ </object>
+ </child>
+ </object>
+ </child>
<child type="overlay">
<object class="GtkRevealer" id="scroll_btn_revealer">
<property name="transition_type">crossfade</property>
diff --git a/po/POTFILES.in b/po/POTFILES.in
index 84f604743..5b43e68d1 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -11,6 +11,7 @@ data/resources/ui/account-settings-device-row.ui
data/resources/ui/account-settings-devices-page.ui
data/resources/ui/account-settings-user-page.ui
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.ui
@@ -57,6 +58,7 @@ src/session/content/room_history/item_row.rs
src/session/content/room_history/message_row/audio.rs
src/session/content/room_history/message_row/media.rs
src/session/content/room_history/message_row/mod.rs
+src/session/content/room_history/mod.rs
src/session/content/room_history/state_row/creation.rs
src/session/content/room_history/state_row/mod.rs
src/session/content/room_history/verification_info_bar.rs
diff --git a/src/session/content/room_history/attachment_dialog.rs
b/src/session/content/room_history/attachment_dialog.rs
new file mode 100644
index 000000000..8525c104e
--- /dev/null
+++ b/src/session/content/room_history/attachment_dialog.rs
@@ -0,0 +1,74 @@
+use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*, CompositeTemplate};
+use once_cell::sync::Lazy;
+
+mod imp {
+ use std::cell::RefCell;
+
+ use super::*;
+
+ #[derive(Debug, Default, CompositeTemplate)]
+ #[template(resource = "/org/gnome/FractalNext/attachment-dialog.ui")]
+ pub struct AttachmentDialog {
+ pub file: RefCell<Option<gio::File>>,
+ pub texture: RefCell<Option<gdk::Texture>>,
+ #[template_child]
+ pub preview: TemplateChild<gtk::Image>,
+ #[template_child]
+ pub stack: TemplateChild<gtk::Stack>,
+ }
+
+ #[glib::object_subclass]
+ impl ObjectSubclass for AttachmentDialog {
+ const NAME: &'static str = "AttachmentDialog";
+ type Type = super::AttachmentDialog;
+ type ParentType = gtk::Window;
+
+ fn class_init(klass: &mut Self::Class) {
+ Self::bind_template(klass);
+
+ klass.install_action("attachment-dialog.send", None, move |window, _, _| {
+ window.emit_by_name::<()>("send", &[]);
+ window.close();
+ });
+ }
+
+ fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
+ obj.init_template();
+ }
+ }
+
+ impl ObjectImpl for AttachmentDialog {
+ fn signals() -> &'static [glib::subclass::Signal] {
+ static SIGNALS: Lazy<Vec<glib::subclass::Signal>> = Lazy::new(|| {
+ vec![
+ glib::subclass::Signal::builder("send", &[], glib::Type::UNIT.into())
+ .flags(glib::SignalFlags::RUN_FIRST)
+ .build(),
+ ]
+ });
+ SIGNALS.as_ref()
+ }
+ }
+ impl WidgetImpl for AttachmentDialog {}
+ impl WindowImpl for AttachmentDialog {}
+}
+
+glib::wrapper! {
+ pub struct AttachmentDialog(ObjectSubclass<imp::AttachmentDialog>)
+ @extends gtk::Widget, gtk::Window;
+}
+
+impl AttachmentDialog {
+ pub fn new(window: >k::Window) -> Self {
+ glib::Object::new(&[("transient-for", window)]).unwrap()
+ }
+
+ pub fn set_texture(&self, texture: &gdk::Texture) {
+ let priv_ = self.imp();
+ priv_.stack.set_visible_child_name("preview");
+
+ priv_
+ .preview
+ .set_paintable(Some(texture.upcast_ref::<gdk::Paintable>()));
+ }
+}
diff --git a/src/session/content/room_history/mod.rs b/src/session/content/room_history/mod.rs
index f738475ad..9410994f5 100644
--- a/src/session/content/room_history/mod.rs
+++ b/src/session/content/room_history/mod.rs
@@ -1,3 +1,4 @@
+mod attachment_dialog;
mod divider_row;
mod item_row;
mod message_row;
@@ -5,8 +6,9 @@ mod state_row;
mod verification_info_bar;
use adw::subclass::prelude::*;
+use gettextrs::gettext;
use gtk::{
- gdk, glib,
+ gdk, gio, glib,
glib::{clone, signal::Inhibit},
prelude::*,
subclass::prelude::*,
@@ -19,9 +21,10 @@ use matrix_sdk::ruma::events::room::message::{
use sourceview::prelude::*;
use self::{
- divider_row::DividerRow, item_row::ItemRow, state_row::StateRow,
- verification_info_bar::VerificationInfoBar,
+ attachment_dialog::AttachmentDialog, divider_row::DividerRow, item_row::ItemRow,
+ state_row::StateRow, verification_info_bar::VerificationInfoBar,
};
+use crate::spawn;
use crate::{
components::{CustomEntry, Pill, RoomTitle},
session::{
@@ -32,6 +35,14 @@ use crate::{
spawn,
};
+const MIME_TYPES: &[&str] = &[
+ "image/png",
+ "image/jpeg",
+ "image/tiff",
+ "image/svg+xml",
+ "image/bmp",
+];
+
mod imp {
use std::cell::{Cell, RefCell};
@@ -78,6 +89,8 @@ mod imp {
#[template_child]
pub stack: TemplateChild<gtk::Stack>,
pub is_loading: Cell<bool>,
+ #[template_child]
+ pub drag_revealer: TemplateChild<gtk::Revealer>,
}
#[glib::object_subclass]
@@ -119,6 +132,10 @@ mod imp {
klass.install_action("room-history.scroll-down", None, move |widget, _, _| {
widget.scroll_down();
});
+
+ klass.install_action("room-history.select-file", None, move |widget, _, _| {
+ widget.select_file();
+ });
}
fn instance_init(obj: &InitializingObject<Self>) {
@@ -258,6 +275,23 @@ mod imp {
let key_events = gtk::EventControllerKey::new();
self.message_entry.add_controller(&key_events);
+ self.message_entry
+ .connect_paste_clipboard(clone!(@weak obj => move |entry| {
+ spawn!(
+ glib::PRIORITY_DEFAULT_IDLE,
+ clone!(@weak obj => async move {
+ obj.read_clipboard().await;
+ }));
+ let clip = obj.clipboard();
+
+ // TODO Check if this is the most general condition on which
+ // the clipboard contains more than text.
+ let formats = clip.formats();
+ let contains_mime = MIME_TYPES.iter().any(|mime| formats.contain_mime_type(mime));
+ if formats.contains_type(gio::File::static_type()) || contains_mime {
+ entry.stop_signal_emission_by_name("paste-clipboard");
+ }
+ }));
key_events
.connect_key_pressed(clone!(@weak obj => @default-return Inhibit(false), move |_, key, _,
modifier| {
@@ -295,6 +329,8 @@ mod imp {
.bind("markdown-enabled", obj, "markdown-enabled")
.build();
+ obj.setup_drop_target();
+
self.parent_constructed(obj);
}
}
@@ -309,6 +345,50 @@ glib::wrapper! {
}
impl RoomHistory {
+ async fn read_clipboard(&self) {
+ let clipboard = self.clipboard();
+
+ // Check if there is a png/jpg in the clipboard.
+ let res = clipboard
+ .read_future(MIME_TYPES, glib::PRIORITY_DEFAULT)
+ .await;
+ let body = match clipboard.read_text_future().await {
+ Ok(Some(body)) => std::path::Path::new(&body)
+ .file_name()
+ .unwrap()
+ .to_str()
+ .unwrap()
+ .to_string(),
+ _ => gettext("Image"),
+ };
+ if let Ok((stream, mime)) = res {
+ log::debug!("Found a {} in the clipboard", &mime);
+ if let Ok(bytes) = read_stream(&stream).await {
+ self.open_attach_dialog(bytes, &mime, &body);
+
+ return;
+ }
+ }
+
+ // Check if there is a file in the clipboard.
+ let res = clipboard
+ .read_value_future(gio::File::static_type(), glib::PRIORITY_DEFAULT)
+ .await;
+ if let Ok(value) = res {
+ if let Ok(file) = value.get::<gio::File>() {
+ log::debug!("Found a file in the clipboard");
+
+ // Under some circumstances, the file will be
+ // under a path we don't have access to.
+ if !file.query_exists(gio::Cancellable::NONE) {
+ return;
+ }
+
+ self.read_file(&file).await;
+ }
+ }
+ }
+
pub fn new() -> Self {
glib::Object::new(&[]).expect("Failed to create RoomHistory")
}
@@ -625,6 +705,148 @@ impl RoomHistory {
fn try_again(&self) {
self.start_loading();
}
+
+ fn setup_drop_target(&self) {
+ let priv_ = imp::RoomHistory::from_instance(self);
+
+ let target = gtk::DropTarget::new(
+ gio::File::static_type(),
+ gdk::DragAction::COPY | gdk::DragAction::MOVE,
+ );
+
+ target.connect_drop(
+ glib::clone!(@weak self as obj => @default-return false, move |target, value, _, _| {
+ let drop = target.current_drop().unwrap();
+
+ // We first try to read if we get a serialized image. In general
+ // we get files, but this is useful when reading a drag-n-drop
+ // from another sandboxed app.
+ let formats = drop.formats();
+ for mime in MIME_TYPES {
+ if formats.contain_mime_type(mime) {
+ log::debug!("Received drag & drop with mime type: {}", mime);
+ drop.read_async(&[mime], glib::PRIORITY_DEFAULT, gio::Cancellable::NONE,
glib::clone!(@weak obj => move |res| {
+ if let Ok((stream, mime)) = res {
+ crate::spawn!(glib::clone!(@weak obj => async move {
+ if let Ok(bytes) = read_stream(&stream).await {
+ // TODO Get the actual name of the file by reading
+ // the text/plain mime type.
+ let body = gettext("Image");
+ obj.open_attach_dialog(bytes, &mime, &body);
+ }
+ }));
+ }
+ }));
+
+ return true;
+ }
+ }
+
+ if let Ok(file) = value.get::<gio::File>() {
+ if !file.query_exists(gio::Cancellable::NONE) {
+ log::debug!("Received drag & drop file, but don't have permissions: {:?}",
file.path());
+ return false;
+ }
+ log::debug!("Received drag & drop file: {:?}", file.path());
+ crate::spawn!(glib::clone!(@weak obj, @strong file => async move {
+ obj.read_file(&file).await;
+ }));
+
+ return true;
+ }
+ false
+ }),
+ );
+
+ target.connect_current_drop_notify(glib::clone!(@weak self as obj => move |target| {
+ let priv_ = imp::RoomHistory::from_instance(&obj);
+ priv_.drag_revealer.set_reveal_child(target.current_drop().is_some());
+ }));
+
+ priv_.scrolled_window.add_controller(&target);
+ }
+
+ fn open_attach_dialog(&self, bytes: Vec<u8>, mime: &str, title: &str) {
+ let window = self.root().unwrap().downcast::<gtk::Window>().unwrap();
+ let dialog = AttachmentDialog::new(&window);
+ let gbytes = glib::Bytes::from_owned(bytes.clone());
+ if let Ok(texture) = gdk::Texture::from_bytes(&gbytes) {
+ dialog.set_texture(&texture);
+ }
+
+ let mime = mime.to_string();
+ dialog.set_title(Some(title));
+ let title = title.to_string();
+ dialog
+ .connect_local(
+ "send",
+ false,
+ glib::clone!(@weak self as obj => @default-return None, move |_| {
+ if let Some(room) = obj.room() {
+ room.send_attachment(&gbytes, &mime, &title);
+ }
+
+ None
+ }),
+ )
+ .unwrap();
+ dialog.present();
+ }
+
+ pub fn select_file(&self) {
+ let window = self.root().unwrap().downcast::<gtk::Window>().unwrap();
+ let dialog = gtk::FileChooserNative::new(
+ None,
+ Some(&window),
+ gtk::FileChooserAction::Open,
+ None,
+ None,
+ );
+ dialog.set_modal(true);
+
+ dialog.connect_response(
+ glib::clone!(@weak self as obj, @strong dialog => move |_, response| {
+ dialog.destroy();
+ if response == gtk::ResponseType::Accept {
+ let file = dialog.file().unwrap();
+
+ crate::spawn!(glib::clone!(@weak obj, @strong file => async move {
+ obj.read_file(&file).await;
+ }));
+ }
+ }),
+ );
+
+ dialog.show();
+ }
+
+ async fn read_file(&self, file: &gio::File) {
+ let filename = file
+ .basename()
+ .unwrap()
+ .into_os_string()
+ .to_str()
+ .unwrap()
+ .to_string();
+
+ // Read mime type.
+ let mime = if let Ok(file_info) = file.query_info(
+ "standard::content-type",
+ gio::FileQueryInfoFlags::NONE,
+ gio::Cancellable::NONE,
+ ) {
+ file_info
+ .content_type()
+ .map_or("text/plain".to_string(), |x| x.to_string())
+ } else {
+ "text/plain".to_string()
+ };
+
+ match file.load_contents_future().await {
+ Ok((bytes, _tag)) => self.open_attach_dialog(bytes, &mime, &filename),
+ Err(err) => log::debug!("Could not read file: {}", err),
+ }
+ }
}
impl Default for RoomHistory {
@@ -632,3 +854,19 @@ impl Default for RoomHistory {
Self::new()
}
}
+
+async fn read_stream(stream: &gio::InputStream) -> Result<Vec<u8>, glib::Error> {
+ let mut buffer = Vec::<u8>::with_capacity(4096);
+
+ loop {
+ let bytes = stream
+ .read_bytes_future(4096, glib::PRIORITY_DEFAULT)
+ .await?;
+ if bytes.is_empty() {
+ break;
+ }
+ buffer.extend_from_slice(&bytes);
+ }
+
+ Ok(buffer)
+}
diff --git a/src/session/room/mod.rs b/src/session/room/mod.rs
index 5e65acbff..fb107f9af 100644
--- a/src/session/room/mod.rs
+++ b/src/session/room/mod.rs
@@ -11,12 +11,13 @@ mod reaction_list;
mod room_type;
mod timeline;
-use std::{cell::RefCell, convert::TryInto, path::PathBuf, sync::Arc};
+use std::{cell::RefCell, convert::TryInto, ops::Deref, path::PathBuf, str::FromStr, sync::Arc};
use gettextrs::gettext;
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*};
use log::{debug, error, info, warn};
use matrix_sdk::{
+ attachment::AttachmentConfig,
deserialized_responses::{JoinedRoom, LeftRoom, SyncRoomEvent},
room::Room as MatrixRoom,
ruma::{
@@ -1183,6 +1184,25 @@ impl Room {
Some(())
}
+ pub fn send_attachment(&self, bytes: &glib::Bytes, mime: &str, body: &str) {
+ let matrix_room = self.matrix_room();
+
+ if let MatrixRoom::Joined(matrix_room) = matrix_room {
+ let mime = mime::Mime::from_str(&mime.to_owned()).unwrap();
+ let body = body.to_string();
+ spawn_tokio!(glib::clone!(@strong bytes => async move {
+ let config = AttachmentConfig::default();
+ let mut cursor = std::io::Cursor::new(bytes.deref());
+ matrix_room
+ // TODO This should be added to pending messages instead of
+ // sending it directly.
+ .send_attachment(&body, &mime, &mut cursor, config)
+ .await
+ .unwrap();
+ }));
+ }
+ }
+
pub async fn invite(&self, users: &[User]) {
let matrix_room = self.matrix_room();
let user_ids: Vec<Arc<UserId>> = users.iter().map(|user| user.user_id()).collect();
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]