content: Add MessageFile widget
Display m.file messages so the user can open or save them.
This commit is contained in:
parent
b653ca7933
commit
b3e3a7c5f7
11 changed files with 335 additions and 9 deletions
|
@ -11,6 +11,7 @@
|
|||
<file compressed="true" preprocess="xml-stripblanks" alias="content-public-room-row.ui">ui/content-public-room-row.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks" alias="content-item.ui">ui/content-item.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks" alias="content-item-row-menu.ui">ui/content-item-row-menu.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks" alias="content-message-file.ui">ui/content-message-file.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks" alias="content-message-row.ui">ui/content-message-row.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks" alias="content-divider-row.ui">ui/content-divider-row.ui</file>
|
||||
<file compressed="true" preprocess="xml-stripblanks" alias="content-room-details.ui">ui/content-room-details.ui</file>
|
||||
|
|
41
data/resources/ui/content-message-file.ui
Normal file
41
data/resources/ui/content-message-file.ui
Normal file
|
@ -0,0 +1,41 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<interface>
|
||||
<template class="ContentMessageFile" parent="AdwBin">
|
||||
<property name="focusable">True</property>
|
||||
<property name="valign">center</property>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<property name="spacing">6</property>
|
||||
<child>
|
||||
<object class="GtkLabel">
|
||||
<property name="ellipsize">end</property>
|
||||
<binding name="label">
|
||||
<lookup name="filename">ContentMessageFile</lookup>
|
||||
</binding>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkBox">
|
||||
<child>
|
||||
<object class="GtkButton" id="open">
|
||||
<property name="icon-name">document-open-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Open</property>
|
||||
<property name="action-name">item-row.file-open</property>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkButton" id="save">
|
||||
<property name="icon-name">document-save-symbolic</property>
|
||||
<property name="tooltip-text" translatable="yes">Save</property>
|
||||
<property name="action-name">item-row.file-save</property>
|
||||
</object>
|
||||
</child>
|
||||
<style>
|
||||
<class name="linked"/>
|
||||
</style>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</template>
|
||||
</interface>
|
|
@ -18,6 +18,7 @@ data/resources/ui/content-item-row-menu.ui
|
|||
data/resources/ui/content-item.ui
|
||||
data/resources/ui/content-invite.ui
|
||||
data/resources/ui/content-markdown-popover.ui
|
||||
data/resources/ui/content-message-file.ui
|
||||
data/resources/ui/content-message-row.ui
|
||||
data/resources/ui/content-room-details.ui
|
||||
data/resources/ui/content-room-history.ui
|
||||
|
@ -74,6 +75,7 @@ src/session/content/divider_row.rs
|
|||
src/session/content/item_row.rs
|
||||
src/session/content/invite.rs
|
||||
src/session/content/markdown_popover.rs
|
||||
src/session/content/message_row/file.rs
|
||||
src/session/content/message_row/mod.rs
|
||||
src/session/content/message_row/text.rs
|
||||
src/session/content/mod.rs
|
||||
|
|
|
@ -47,6 +47,7 @@ impl UserFacingError for HttpError {
|
|||
impl UserFacingError for Error {
|
||||
fn to_user_facing(self) -> String {
|
||||
match self {
|
||||
Error::DecryptorError(_) => gettext("Could not decrypt the event"),
|
||||
Error::Http(http_error) => http_error.to_user_facing(),
|
||||
_ => gettext("An unknown error occurred."),
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ sources = files(
|
|||
'session/content/item_row.rs',
|
||||
'session/content/invite.rs',
|
||||
'session/content/markdown_popover.rs',
|
||||
'session/content/message_row/file.rs',
|
||||
'session/content/message_row/mod.rs',
|
||||
'session/content/message_row/text.rs',
|
||||
'session/content/mod.rs',
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
use adw::{prelude::*, subclass::prelude::*};
|
||||
use gettextrs::gettext;
|
||||
use gtk::{gio, glib, glib::clone, subclass::prelude::*};
|
||||
use gtk::{gio, glib, glib::clone, subclass::prelude::*, FileChooserAction, ResponseType};
|
||||
use log::error;
|
||||
use matrix_sdk::ruma::events::{
|
||||
room::message::MessageType, AnyMessageEventContent, AnySyncRoomEvent,
|
||||
};
|
||||
|
||||
use crate::components::{ContextMenuBin, ContextMenuBinExt, ContextMenuBinImpl};
|
||||
use crate::session::content::{DividerRow, MessageRow, StateRow};
|
||||
use crate::matrix_error::UserFacingError;
|
||||
use crate::session::content::{message_row::MessageRow, DividerRow, StateRow};
|
||||
use crate::session::event_source_dialog::EventSourceDialog;
|
||||
use crate::session::room::{Event, Item, ItemType};
|
||||
use matrix_sdk::ruma::events::AnySyncRoomEvent;
|
||||
use crate::utils::cache_dir;
|
||||
use crate::{spawn, spawn_tokio, Error, Window};
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
|
@ -34,6 +40,26 @@ mod imp {
|
|||
EventSourceDialog::new(&window, widget.item().unwrap().event().unwrap());
|
||||
dialog.show();
|
||||
});
|
||||
|
||||
// Save message's file
|
||||
klass.install_action("item-row.file-save", None, move |widget, _, _| {
|
||||
spawn!(
|
||||
glib::PRIORITY_LOW,
|
||||
clone!(@weak widget as obj => async move {
|
||||
obj.save_file().await;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Open message's file
|
||||
klass.install_action("item-row.file-open", None, move |widget, _, _| {
|
||||
spawn!(
|
||||
glib::PRIORITY_LOW,
|
||||
clone!(@weak widget as obj => async move {
|
||||
obj.open_file().await;
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -240,6 +266,125 @@ impl ItemRow {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn save_file(&self) {
|
||||
let (filename, data) = match self.get_media_content().await {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
error!("Could not get file: {}", err);
|
||||
|
||||
let error_message = err.to_user_facing();
|
||||
let error = Error::new(move |_| {
|
||||
let error_label = gtk::LabelBuilder::new()
|
||||
.label(&error_message)
|
||||
.wrap(true)
|
||||
.build();
|
||||
Some(error_label.upcast())
|
||||
});
|
||||
if let Some(window) = self.root().and_then(|root| root.downcast::<Window>().ok()) {
|
||||
window.append_error(&error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let window: gtk::Window = self.root().unwrap().downcast().unwrap();
|
||||
let dialog = gtk::FileChooserDialog::new(
|
||||
Some(&gettext("Save File")),
|
||||
Some(&window),
|
||||
FileChooserAction::Save,
|
||||
&[
|
||||
(&gettext("Save"), ResponseType::Accept),
|
||||
(&gettext("Cancel"), ResponseType::Cancel),
|
||||
],
|
||||
);
|
||||
dialog.set_current_name(&filename);
|
||||
|
||||
let response = dialog.run_future().await;
|
||||
if response == ResponseType::Accept {
|
||||
if let Some(file) = dialog.file() {
|
||||
file.replace_contents(
|
||||
&data,
|
||||
None,
|
||||
false,
|
||||
gio::FileCreateFlags::REPLACE_DESTINATION,
|
||||
gio::NONE_CANCELLABLE,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
pub async fn open_file(&self) {
|
||||
let (filename, data) = match self.get_media_content().await {
|
||||
Ok(res) => res,
|
||||
Err(err) => {
|
||||
error!("Could not get file: {}", err);
|
||||
|
||||
let error_message = err.to_user_facing();
|
||||
let error = Error::new(move |_| {
|
||||
let error_label = gtk::LabelBuilder::new()
|
||||
.label(&error_message)
|
||||
.wrap(true)
|
||||
.build();
|
||||
Some(error_label.upcast())
|
||||
});
|
||||
if let Some(window) = self.root().and_then(|root| root.downcast::<Window>().ok()) {
|
||||
window.append_error(&error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut path = cache_dir();
|
||||
path.push(filename);
|
||||
let file = gio::File::for_path(path);
|
||||
|
||||
file.replace_contents(
|
||||
&data,
|
||||
None,
|
||||
false,
|
||||
gio::FileCreateFlags::REPLACE_DESTINATION,
|
||||
gio::NONE_CANCELLABLE,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
if let Err(error) = gio::AppInfo::launch_default_for_uri_async_future(
|
||||
&file.uri(),
|
||||
gio::NONE_APP_LAUNCH_CONTEXT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Error opening file '{}': {}", file.uri(), error);
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_media_content(&self) -> Result<(String, Vec<u8>), matrix_sdk::Error> {
|
||||
let item = self.item().unwrap();
|
||||
let event = item.event().unwrap();
|
||||
|
||||
if let AnySyncRoomEvent::Message(message_event) = event.matrix_event().unwrap() {
|
||||
if let AnyMessageEventContent::RoomMessage(content) = message_event.content() {
|
||||
let client = event.room().session().client();
|
||||
match content.msgtype {
|
||||
MessageType::File(file_content) => {
|
||||
let content = file_content.clone();
|
||||
let handle =
|
||||
spawn_tokio!(async move { client.get_file(content, true).await });
|
||||
let data = handle.await.unwrap()?.unwrap();
|
||||
return Ok((file_content.filename.unwrap_or(file_content.body), data));
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
panic!("Trying to get the media content of an event of incompatible type");
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ItemRow {
|
||||
|
|
111
src/session/content/message_row/file.rs
Normal file
111
src/session/content/message_row/file.rs
Normal file
|
@ -0,0 +1,111 @@
|
|||
use adw::subclass::prelude::*;
|
||||
use gtk::{glib, prelude::*, subclass::prelude::*, CompositeTemplate};
|
||||
|
||||
mod imp {
|
||||
use super::*;
|
||||
use glib::subclass::InitializingObject;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::cell::RefCell;
|
||||
|
||||
#[derive(Debug, Default, CompositeTemplate)]
|
||||
#[template(resource = "/org/gnome/FractalNext/content-message-file.ui")]
|
||||
pub struct MessageFile {
|
||||
/// The filename of the file
|
||||
pub filename: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
#[glib::object_subclass]
|
||||
impl ObjectSubclass for MessageFile {
|
||||
const NAME: &'static str = "ContentMessageFile";
|
||||
type Type = super::MessageFile;
|
||||
type ParentType = adw::Bin;
|
||||
|
||||
fn class_init(klass: &mut Self::Class) {
|
||||
Self::bind_template(klass);
|
||||
}
|
||||
|
||||
fn instance_init(obj: &InitializingObject<Self>) {
|
||||
obj.init_template();
|
||||
}
|
||||
}
|
||||
|
||||
impl ObjectImpl for MessageFile {
|
||||
fn properties() -> &'static [glib::ParamSpec] {
|
||||
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = Lazy::new(|| {
|
||||
vec![glib::ParamSpec::new_string(
|
||||
"filename",
|
||||
"Filename",
|
||||
"The filename of the file",
|
||||
None,
|
||||
glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY,
|
||||
)]
|
||||
});
|
||||
|
||||
PROPERTIES.as_ref()
|
||||
}
|
||||
|
||||
fn set_property(
|
||||
&self,
|
||||
obj: &Self::Type,
|
||||
_id: usize,
|
||||
value: &glib::Value,
|
||||
pspec: &glib::ParamSpec,
|
||||
) {
|
||||
match pspec.name() {
|
||||
"filename" => obj.set_filename(value.get().unwrap()),
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value {
|
||||
match pspec.name() {
|
||||
"filename" => obj.filename().to_value(),
|
||||
_ => unimplemented!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn constructed(&self, obj: &Self::Type) {
|
||||
self.parent_constructed(obj);
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetImpl for MessageFile {}
|
||||
|
||||
impl BinImpl for MessageFile {}
|
||||
}
|
||||
|
||||
glib::wrapper! {
|
||||
/// A widget displaying an interface to download or open the content of a file message.
|
||||
pub struct MessageFile(ObjectSubclass<imp::MessageFile>)
|
||||
@extends gtk::Widget, adw::Bin, @implements gtk::Accessible;
|
||||
}
|
||||
|
||||
impl MessageFile {
|
||||
pub fn new(filename: Option<String>) -> Self {
|
||||
glib::Object::new(&[("filename", &filename)]).expect("Failed to create MessageFile")
|
||||
}
|
||||
|
||||
pub fn set_filename(&self, filename: Option<String>) {
|
||||
let priv_ = imp::MessageFile::from_instance(self);
|
||||
|
||||
let name = filename.filter(|name| !name.is_empty());
|
||||
|
||||
if name.as_ref() == priv_.filename.borrow().as_ref() {
|
||||
return;
|
||||
}
|
||||
|
||||
priv_.filename.replace(name);
|
||||
self.notify("filename");
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> Option<String> {
|
||||
let priv_ = imp::MessageFile::from_instance(self);
|
||||
priv_.filename.borrow().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MessageFile {
|
||||
fn default() -> Self {
|
||||
Self::new(None)
|
||||
}
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
mod file;
|
||||
mod text;
|
||||
|
||||
use crate::components::Avatar;
|
||||
|
@ -13,7 +14,7 @@ use matrix_sdk::ruma::events::{
|
|||
AnyMessageEventContent, AnySyncMessageEvent, AnySyncRoomEvent,
|
||||
};
|
||||
|
||||
use self::text::MessageText;
|
||||
use self::{file::MessageFile, text::MessageText};
|
||||
use crate::prelude::*;
|
||||
use crate::session::room::Event;
|
||||
|
||||
|
@ -261,7 +262,11 @@ impl MessageRow {
|
|||
MessageText::emote(message.formatted, message.body, event.sender());
|
||||
priv_.content.set_child(Some(&child));
|
||||
}
|
||||
MessageType::File(_message) => {}
|
||||
MessageType::File(message) => {
|
||||
let filename = message.filename.unwrap_or(message.body);
|
||||
let child = MessageFile::new(Some(filename));
|
||||
priv_.content.set_child(Some(&child));
|
||||
}
|
||||
MessageType::Image(_message) => {}
|
||||
MessageType::Location(_message) => {}
|
||||
MessageType::Notice(message) => {
|
||||
|
|
|
@ -15,7 +15,6 @@ use self::explore::Explore;
|
|||
use self::invite::Invite;
|
||||
use self::item_row::ItemRow;
|
||||
use self::markdown_popover::MarkdownPopover;
|
||||
use self::message_row::MessageRow;
|
||||
use self::room_details::RoomDetails;
|
||||
use self::room_history::RoomHistory;
|
||||
use self::state_row::StateRow;
|
||||
|
|
|
@ -137,7 +137,7 @@ mod imp {
|
|||
match pspec.name() {
|
||||
"source" => obj.source().to_value(),
|
||||
"sender" => obj.sender().to_value(),
|
||||
"room" => self.room.get().unwrap().to_value(),
|
||||
"room" => obj.room().to_value(),
|
||||
"show-header" => obj.show_header().to_value(),
|
||||
"can-hide-header" => obj.can_hide_header().to_value(),
|
||||
"time" => obj.time().to_value(),
|
||||
|
@ -170,6 +170,11 @@ impl Event {
|
|||
.member_by_id(&self.matrix_sender())
|
||||
}
|
||||
|
||||
pub fn room(&self) -> &Room {
|
||||
let priv_ = imp::Event::from_instance(self);
|
||||
priv_.room.get().unwrap()
|
||||
}
|
||||
|
||||
/// Get the matrix event
|
||||
///
|
||||
/// If the `SyncRoomEvent` couldn't be deserialized this is `None`
|
||||
|
|
19
src/utils.rs
19
src/utils.rs
|
@ -58,8 +58,10 @@ macro_rules! spawn_tokio {
|
|||
};
|
||||
}
|
||||
|
||||
use gtk::gio::prelude::*;
|
||||
use gtk::glib::Object;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use gtk::gio::{self, prelude::*};
|
||||
use gtk::glib::{self, Object};
|
||||
|
||||
/// Returns an expression looking up the given property on `object`.
|
||||
pub fn prop_expr<T: IsA<Object>>(object: &T, prop: &str) -> gtk::Expression {
|
||||
|
@ -106,3 +108,16 @@ pub fn not_expr(a_expr: gtk::Expression) -> gtk::Expression {
|
|||
)
|
||||
.upcast()
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
let mut path = glib::user_cache_dir();
|
||||
path.push("fractal");
|
||||
|
||||
if !path.exists() {
|
||||
let dir = gio::File::for_path(path.clone());
|
||||
dir.make_directory_with_parents(gio::NONE_CANCELLABLE)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
path
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue