Browse Source

utils: Create VisualMediaMessage to refactor image and video message codes

And other refactoring.
merge-requests/1714/head
Kévin Commaille 2 years ago
parent
commit
96d55e3ffb
No known key found for this signature in database
GPG Key ID: C971D9DBC9D678D
  1. 1
      po/POTFILES.in
  2. 2
      src/components/avatar/image.rs
  3. 37
      src/session/model/room/event/mod.rs
  4. 35
      src/session/view/content/room_details/history_viewer/audio_row.rs
  5. 16
      src/session/view/content/room_details/history_viewer/event.rs
  6. 8
      src/session/view/content/room_details/history_viewer/file_row.rs
  7. 5
      src/session/view/content/room_details/history_viewer/visual_media.rs
  8. 164
      src/session/view/content/room_details/history_viewer/visual_media_item.rs
  9. 20
      src/session/view/content/room_history/item_row.rs
  10. 36
      src/session/view/content/room_history/message_row/audio.rs
  11. 40
      src/session/view/content/room_history/message_row/content.rs
  12. 293
      src/session/view/content/room_history/message_row/visual_media.rs
  13. 96
      src/session/view/media_viewer.rs
  14. 5
      src/session/view/media_viewer.ui
  15. 6
      src/session/view/session_view.rs
  16. 353
      src/utils/matrix/media_message.rs
  17. 2
      src/utils/matrix/mod.rs
  18. 39
      src/utils/media.rs
  19. 16
      src/utils/mod.rs

1
po/POTFILES.in

@ -184,6 +184,7 @@ src/session/view/sidebar/row.rs
src/session_list/mod.rs
src/shortcuts.ui
src/user_facing_error.rs
src/utils/matrix/media_message.rs
src/utils/matrix/mod.rs
src/utils/media.rs
src/window.ui

2
src/components/avatar/image.rs

@ -119,7 +119,7 @@ impl AvatarImage {
/// Set the content of the image.
fn set_image_data(&self, data: Option<Vec<u8>>) {
let paintable = data
.and_then(|data| ImagePaintable::from_bytes(&glib::Bytes::from(&data), None).ok())
.and_then(|data| ImagePaintable::from_bytes(&data, None).ok())
.map(|texture| texture.upcast());
self.imp().paintable.replace(paintable);
self.notify_paintable();

37
src/session/model/room/event/mod.rs

@ -30,7 +30,7 @@ use super::{
use crate::{
prelude::*,
spawn_tokio,
utils::matrix::{raw_eq, MediaMessage},
utils::matrix::{raw_eq, MediaMessage, VisualMediaMessage},
};
/// The unique key to identify an event in a room.
@ -596,6 +596,14 @@ impl Event {
}
}
/// The visual media message of this `Event`, if any.
pub fn visual_media_message(&self) -> Option<VisualMediaMessage> {
match self.imp().item.borrow().as_ref().unwrap().content() {
TimelineItemContent::Message(msg) => VisualMediaMessage::from_message(msg.msgtype()),
_ => None,
}
}
/// The mentions from this message, if any.
pub fn mentions(&self) -> Option<Mentions> {
match self.imp().item.borrow().as_ref().unwrap().content() {
@ -766,33 +774,6 @@ impl Event {
.unwrap()
}
/// Fetch the content of the media message in this `Event`.
///
/// Compatible events:
///
/// - File message (`MessageType::File`).
/// - Image message (`MessageType::Image`).
/// - Video message (`MessageType::Video`).
/// - Audio message (`MessageType::Audio`).
///
/// Returns `Ok(binary_content)` on success.
///
/// Returns `Err` if an error occurred while fetching the content. Panics on
/// an incompatible event.
pub async fn get_media_content(&self) -> Result<Vec<u8>, matrix_sdk::Error> {
let Some(session) = self.room().session() else {
return Err(matrix_sdk::Error::UnknownError(
"Could not upgrade Session".into(),
));
};
let Some(message) = self.media_message() else {
panic!("Trying to get the media content of an event of incompatible type");
};
let client = session.client();
message.content(client).await
}
/// Whether this `Event` is considered a message.
pub fn is_message(&self) -> bool {
matches!(

35
src/session/view/content/room_details/history_viewer/audio_row.rs

@ -5,7 +5,7 @@ use gtk::{gio, glib, CompositeTemplate};
use tracing::warn;
use super::HistoryViewerEvent;
use crate::{gettext_f, spawn, spawn_tokio, utils::matrix::MediaMessage};
use crate::{gettext_f, spawn, utils::matrix::MediaMessage};
mod imp {
use std::cell::RefCell;
@ -62,9 +62,9 @@ mod imp {
}
if let Some(event) = &event {
let message_content = event.message_content();
if let MediaMessage::Audio(audio) = &message_content {
let filename = message_content.filename();
let media_message = event.media_message();
if let MediaMessage::Audio(audio) = &media_message {
let filename = media_message.filename();
self.title_label.set_label(&filename);
self.play_button
.update_property(&[gtk::accessible::Property::Label(&gettext_f(
@ -112,34 +112,17 @@ mod imp {
let Some(event) = self.event.borrow().clone() else {
return;
};
let MediaMessage::Audio(audio) = event.message_content() else {
return;
};
let Some(session) = event.room().and_then(|r| r.session()) else {
return;
};
let media_message = event.media_message();
let client = session.client();
let handle = spawn_tokio!(async move { client.media().get_file(&audio, true).await });
match handle.await.unwrap() {
Ok(Some(data)) => {
// The GStreamer backend doesn't work with input streams so
// we need to store the file.
// See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062
let (file, _) = gio::File::new_tmp(None::<String>).unwrap();
file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)
.unwrap();
match media_message.into_tmp_file(&client).await {
Ok(file) => {
self.set_media_file(file);
}
Ok(None) => {
warn!("Could not retrieve invalid audio file");
}
Err(error) => {
warn!("Could not retrieve audio file: {error}");
}

16
src/session/view/content/room_details/history_viewer/event.rs

@ -10,7 +10,10 @@ use ruma::{
OwnedEventId,
};
use crate::{session::model::Room, utils::matrix::MediaMessage};
use crate::{
session::model::Room,
utils::matrix::{MediaMessage, VisualMediaMessage},
};
/// The types of events that can be displayer in the history viewers.
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, glib::Enum)]
@ -138,12 +141,17 @@ impl HistoryViewerEvent {
self.matrix_event().event_id.clone()
}
/// The message content of the inner event.
pub fn message_content(&self) -> MediaMessage {
/// The media message content of this event.
pub fn media_message(&self) -> MediaMessage {
MediaMessage::from_message(&self.matrix_event().content.msgtype)
.expect("HistoryViewerEvents are all media messages")
}
/// The visual media message of this event, if any.
pub fn visual_media_message(&self) -> Option<VisualMediaMessage> {
VisualMediaMessage::from_message(&self.matrix_event().content.msgtype)
}
/// Get the binary content of this event.
pub async fn get_file_content(&self) -> Result<Vec<u8>, matrix_sdk::Error> {
let Some(room) = self.room() else {
@ -158,6 +166,6 @@ impl HistoryViewerEvent {
};
let client = session.client();
self.message_content().content(client).await
self.media_message().into_content(&client).await
}
}

8
src/session/view/content/room_details/history_viewer/file_row.rs

@ -61,9 +61,9 @@ mod imp {
}
if let Some(event) = &event {
let message_content = event.message_content();
if let MediaMessage::File(file) = &message_content {
let filename = message_content.filename();
let media_message = event.media_message();
if let MediaMessage::File(file) = &media_message {
let filename = media_message.filename();
self.title_label.set_label(&filename);
self.button
@ -148,7 +148,7 @@ impl FileRow {
return;
}
};
let filename = event.message_content().filename();
let filename = event.media_message().filename();
let parent_window = self.root().and_downcast::<gtk::Window>().unwrap();
let dialog = gtk::FileDialog::builder()

5
src/session/view/content/room_details/history_viewer/visual_media.rs

@ -232,8 +232,11 @@ impl VisualMediaHistoryViewer {
};
let imp = self.imp();
let media_message = event
.visual_media_message()
.expect("Visual media items contain only visual message content");
imp.media_viewer
.set_message(&room, event.event_id(), event.message_content());
.set_message(&room, event.event_id(), media_message);
imp.media_viewer.reveal(item);
}

164
src/session/view/content/room_details/history_viewer/visual_media_item.rs

@ -1,19 +1,17 @@
use gtk::{gdk, glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
use matrix_sdk::media::{MediaEventContent, MediaThumbnailSettings};
use ruma::{
api::client::media::get_content_thumbnail::v3::Method,
events::room::message::{ImageMessageEventContent, VideoMessageEventContent},
};
use gtk::{glib, glib::clone, prelude::*, subclass::prelude::*, CompositeTemplate};
use matrix_sdk::media::MediaThumbnailSettings;
use ruma::api::client::media::get_content_thumbnail::v3::Method;
use tracing::warn;
use super::{HistoryViewerEvent, VisualMediaHistoryViewer};
use crate::{
spawn, spawn_tokio,
utils::{add_activate_binding_action, matrix::MediaMessage},
components::ImagePaintable,
spawn,
utils::{add_activate_binding_action, matrix::VisualMediaMessage},
};
/// The default size requested by a thumbnail.
const THUMBNAIL_SIZE: u32 = 300;
const THUMBNAIL_SIZE: i32 = 300;
mod imp {
use std::cell::RefCell;
@ -100,37 +98,35 @@ mod imp {
/// Update this item for the current state.
fn update(&self) {
let Some(message_content) = self.event.borrow().as_ref().map(|e| e.message_content())
let Some(media_message) = self
.event
.borrow()
.as_ref()
.and_then(|e| e.visual_media_message())
else {
return;
};
let filename = message_content.filename();
match message_content {
MediaMessage::Image(content) => {
self.show_image(content, filename);
}
MediaMessage::Video(content) => {
self.show_video(content, filename);
}
_ => {}
}
}
let show_overlay = matches!(media_message, VisualMediaMessage::Video(_));
self.show_video_overlay(show_overlay);
/// Show the given image with this item.
fn show_image(&self, image: ImageMessageEventContent, filename: String) {
if let Some(icon) = self.overlay_icon.take() {
self.overlay.remove_overlay(&icon);
}
self.obj().set_tooltip_text(Some(&media_message.filename()));
self.obj().set_tooltip_text(Some(&filename));
self.load_thumbnail(image);
spawn!(
glib::Priority::LOW,
clone!(
#[weak(rename_to = imp)]
self,
async move {
imp.load_thumbnail(media_message).await;
}
)
);
}
/// Show the given video with this item.
fn show_video(&self, video: VideoMessageEventContent, filename: String) {
if self.overlay_icon.borrow().is_none() {
/// Set whether to show the video overlay.
fn show_video_overlay(&self, show: bool) {
if show && self.overlay_icon.borrow().is_none() {
let icon = gtk::Image::builder()
.icon_name("media-playback-start-symbolic")
.css_classes(vec!["osd".to_string()])
@ -141,18 +137,15 @@ mod imp {
self.overlay.add_overlay(&icon);
self.overlay_icon.replace(Some(icon));
} else if !show {
if let Some(icon) = self.overlay_icon.take() {
self.overlay.remove_overlay(&icon);
}
}
self.obj().set_tooltip_text(Some(&filename));
self.load_thumbnail(video);
}
/// Load the thumbnail for the given media event content.
fn load_thumbnail<C>(&self, content: C)
where
C: MediaEventContent + Send + Sync + Clone + 'static,
{
/// Load the thumbnail for the given media message.
async fn load_thumbnail(&self, media_message: VisualMediaMessage) {
let Some(session) = self
.event
.borrow()
@ -163,60 +156,45 @@ mod imp {
return;
};
let media = session.client().media();
let handle = spawn_tokio!(async move {
let thumbnail = if content.thumbnail_source().is_some() {
media
.get_thumbnail(
&content,
MediaThumbnailSettings::new(
Method::Scale,
THUMBNAIL_SIZE.into(),
THUMBNAIL_SIZE.into(),
),
true,
)
.await
.ok()
.flatten()
} else {
None
};
if let Some(data) = thumbnail {
Ok(Some(data))
} else {
media.get_file(&content, true).await
}
});
let client = session.client();
spawn!(
glib::Priority::LOW,
clone!(
#[weak(rename_to = imp)]
self,
async move {
match handle.await.unwrap() {
Ok(Some(data)) => {
match gdk::Texture::from_bytes(&glib::Bytes::from(&data)) {
Ok(texture) => {
imp.picture.set_paintable(Some(&texture));
}
Err(error) => {
warn!("Image file not supported: {}", error);
}
}
}
Ok(None) => {
warn!("Could not retrieve invalid media file");
}
Err(error) => {
warn!("Could not retrieve media file: {}", error);
}
}
}
)
let scale_factor = self.obj().scale_factor();
let settings = MediaThumbnailSettings::new(
Method::Scale,
((THUMBNAIL_SIZE * scale_factor) as u32).into(),
((THUMBNAIL_SIZE * scale_factor) as u32).into(),
);
let data = media_message
.thumbnail(&client, settings)
.await
.ok()
.flatten();
if data.is_none() && matches!(media_message, VisualMediaMessage::Video(_)) {
// No image to show for the video.
return;
}
let data = match data {
Some(data) => data,
None => match media_message.into_content(&client).await {
Ok(data) => data,
Err(error) => {
warn!("Could not retrieve media file: {error}");
return;
}
},
};
match ImagePaintable::from_bytes(&data, None) {
Ok(texture) => {
self.picture.set_paintable(Some(&texture));
}
Err(error) => {
warn!("Image file not supported: {error}");
}
}
}
}
}

20
src/session/view/content/room_history/item_row.rs

@ -15,7 +15,7 @@ use crate::{
view::{content::room_history::message_toolbar::ComposerState, EventDetailsDialog},
},
spawn, toast,
utils::{matrix::MediaMessage, media::save_to_file, BoundObjectWeakRef},
utils::{matrix::MediaMessage, BoundObjectWeakRef},
};
mod imp {
@ -852,29 +852,21 @@ impl ItemRow {
self.set_has_context_menu(true);
}
/// Save the file in `event`.
///
/// See [`Event::get_media_content()`] for compatible events.
/// Panics on an incompatible event.
/// Save the media file in the given event.
fn save_event_file(&self, event: Event) {
spawn!(clone!(
#[weak(rename_to = obj)]
self,
async move {
let data = match event.get_media_content().await {
Ok(res) => res,
Err(error) => {
error!("Could not get event file: {error}");
toast!(obj, error.to_user_facing());
return;
}
let Some(session) = event.room().session() else {
return;
};
let Some(media_message) = event.media_message() else {
return;
};
save_to_file(&obj, data, media_message.filename()).await;
let client = session.client();
media_message.save_to_file(&client, &obj).await;
}
));
}

36
src/session/view/content/room_history/message_row/audio.rs

@ -5,7 +5,6 @@ use gtk::{
glib::{self, clone},
CompositeTemplate,
};
use matrix_sdk::ruma::events::room::message::AudioMessageEventContent;
use tracing::warn;
use super::ContentFormat;
@ -13,8 +12,8 @@ use crate::{
components::{AudioPlayer, Spinner},
gettext_f,
session::model::Session,
spawn, spawn_tokio,
utils::LoadingState,
spawn,
utils::{matrix::MediaMessage, LoadingState},
};
mod imp {
@ -151,14 +150,8 @@ impl MessageAudio {
}
/// Display the given `audio` message.
pub fn audio(
&self,
audio: AudioMessageEventContent,
filename: String,
session: &Session,
format: ContentFormat,
) {
self.set_filename(Some(filename));
pub fn audio(&self, message: MediaMessage, session: &Session, format: ContentFormat) {
self.set_filename(Some(message.filename()));
let compact = matches!(format, ContentFormat::Compact | ContentFormat::Ellipsized);
self.set_compact(compact);
@ -170,7 +163,6 @@ impl MessageAudio {
self.set_state(LoadingState::Loading);
let client = session.client();
let handle = spawn_tokio!(async move { client.media().get_file(&audio, true).await });
spawn!(
glib::Priority::LOW,
@ -178,26 +170,10 @@ impl MessageAudio {
#[weak(rename_to = obj)]
self,
async move {
match handle.await.unwrap() {
Ok(Some(data)) => {
// The GStreamer backend doesn't work with input streams so
// we need to store the file.
// See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062
let (file, _) = gio::File::new_tmp(None::<String>).unwrap();
file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)
.unwrap();
match message.into_tmp_file(&client).await {
Ok(file) => {
obj.display_file(file);
}
Ok(None) => {
warn!("Could not retrieve invalid audio file");
obj.set_error(gettext("Could not retrieve audio file"));
}
Err(error) => {
warn!("Could not retrieve audio file: {error}");
obj.set_error(gettext("Could not retrieve audio file"));

40
src/session/view/content/room_history/message_row/content.rs

@ -212,23 +212,19 @@ fn build_content(
detect_at_room: bool,
) {
let room = sender.room();
let Some(session) = room.session() else {
return;
};
match content {
TimelineItemContent::Message(message) => {
build_message_content(parent, &message, format, sender, detect_at_room)
}
TimelineItemContent::Sticker(sticker) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageVisualMedia>() {
child
} else {
let child = MessageVisualMedia::new();
parent.set_child(Some(&child));
child
};
child.sticker(sticker.content().clone(), &session, format);
build_media_message_content(
parent,
sticker.content().clone().into(),
format,
&room,
detect_at_room,
);
}
TimelineItemContent::UnableToDecrypt(_) => {
let child = if let Some(child) = parent.child().and_downcast::<MessageText>() {
@ -405,22 +401,21 @@ fn build_media_content(
format: ContentFormat,
session: &Session,
) -> gtk::Widget {
let filename = media_message.filename();
match media_message {
MediaMessage::Audio(audio) => {
let widget = old_widget
.and_downcast::<MessageAudio>()
.unwrap_or_default();
widget.audio(audio, filename, session, format);
widget.audio(audio.into(), session, format);
widget.upcast()
}
MediaMessage::File(_) => {
MediaMessage::File(file) => {
let widget = old_widget.and_downcast::<MessageFile>().unwrap_or_default();
widget.set_filename(Some(filename));
let media_message = MediaMessage::from(file);
widget.set_filename(Some(media_message.filename()));
widget.set_format(format);
widget.upcast()
@ -430,7 +425,7 @@ fn build_media_content(
.and_downcast::<MessageVisualMedia>()
.unwrap_or_default();
widget.image(image, filename, session, format);
widget.set_media_message(image.into(), session, format);
widget.upcast()
}
@ -439,7 +434,16 @@ fn build_media_content(
.and_downcast::<MessageVisualMedia>()
.unwrap_or_default();
widget.video(video, filename, session, format);
widget.set_media_message(video.into(), session, format);
widget.upcast()
}
MediaMessage::Sticker(sticker) => {
let widget = old_widget
.and_downcast::<MessageVisualMedia>()
.unwrap_or_default();
widget.set_media_message(sticker.into(), session, format);
widget.upcast()
}

293
src/session/view/content/room_history/message_row/visual_media.rs

@ -1,20 +1,12 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
gdk, gio,
gdk,
glib::{self, clone},
CompositeTemplate,
};
use matrix_sdk::{
media::{MediaEventContent, MediaThumbnailSettings},
ruma::{
api::client::media::get_content_thumbnail::v3::Method,
events::{
room::message::{ImageMessageEventContent, VideoMessageEventContent},
sticker::StickerEventContent,
},
},
};
use matrix_sdk::{media::MediaThumbnailSettings, Client};
use ruma::api::client::media::get_content_thumbnail::v3::Method;
use tracing::warn;
use super::ContentFormat;
@ -22,8 +14,8 @@ use crate::{
components::{ImagePaintable, Spinner, VideoPlayer},
gettext_f,
session::model::Session,
spawn, spawn_tokio,
utils::{uint_to_i32, LoadingState},
spawn,
utils::{matrix::VisualMediaMessage, LoadingState},
};
const MAX_THUMBNAIL_WIDTH: i32 = 600;
@ -33,15 +25,6 @@ const FALLBACK_HEIGHT: i32 = 360;
const MAX_COMPACT_THUMBNAIL_WIDTH: i32 = 75;
const MAX_COMPACT_THUMBNAIL_HEIGHT: i32 = 50;
#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)]
#[repr(u32)]
#[enum_type(name = "VisualMediaType")]
pub enum VisualMediaType {
Image = 0,
Sticker = 1,
Video = 2,
}
mod imp {
use std::cell::Cell;
@ -257,113 +240,51 @@ impl MessageVisualMedia {
self.notify_compact();
}
/// Display the given `image`, in a `compact` format or not.
pub fn image(
/// Display the given visual media message.
pub fn set_media_message(
&self,
image: ImageMessageEventContent,
filename: String,
media_message: VisualMediaMessage,
session: &Session,
format: ContentFormat,
) {
let info = image.info.as_deref();
let width = uint_to_i32(info.and_then(|info| info.width));
let height = uint_to_i32(info.and_then(|info| info.height));
let (width, height) = media_message.dimensions().unzip();
let compact = matches!(format, ContentFormat::Compact | ContentFormat::Ellipsized);
self.set_width(width);
self.set_height(height);
self.set_width(width.and_then(|w| w.try_into().ok()).unwrap_or(-1));
self.set_height(height.and_then(|h| h.try_into().ok()).unwrap_or(-1));
self.set_compact(compact);
self.build(image, filename, VisualMediaType::Image, session);
}
/// Display the given `sticker`, in a `compact` format or not.
pub fn sticker(&self, sticker: StickerEventContent, session: &Session, format: ContentFormat) {
let info = &sticker.info;
let width = uint_to_i32(info.width);
let height = uint_to_i32(info.height);
let body = sticker.body.clone();
let compact = matches!(format, ContentFormat::Compact | ContentFormat::Ellipsized);
self.set_width(width);
self.set_height(height);
self.set_compact(compact);
self.build(sticker, body, VisualMediaType::Sticker, session);
self.build(media_message, session);
}
/// Display the given `video`, in a `compact` format or not.
pub fn video(
&self,
video: VideoMessageEventContent,
filename: String,
session: &Session,
format: ContentFormat,
) {
let info = &video.info.as_deref();
let width = uint_to_i32(info.and_then(|info| info.width));
let height = uint_to_i32(info.and_then(|info| info.height));
let compact = matches!(format, ContentFormat::Compact | ContentFormat::Ellipsized);
self.set_width(width);
self.set_height(height);
self.set_compact(compact);
self.build(video, filename, VisualMediaType::Video, session);
}
/// Build the content for the given media message.
fn build(&self, media_message: VisualMediaMessage, session: &Session) {
let filename = media_message.filename();
fn build<C>(&self, content: C, filename: String, media_type: VisualMediaType, session: &Session)
where
C: MediaEventContent + Send + Sync + Clone + 'static,
{
let accessible_label = if !filename.is_empty() {
match media_type {
VisualMediaType::Image => {
match &media_message {
VisualMediaMessage::Image(_) => {
gettext_f("Image: {filename}", &[("filename", &filename)])
}
VisualMediaType::Sticker => {
VisualMediaMessage::Sticker(_) => {
gettext_f("Sticker: {filename}", &[("filename", &filename)])
}
VisualMediaType::Video => {
VisualMediaMessage::Video(_) => {
gettext_f("Video: {filename}", &[("filename", &filename)])
}
}
} else {
match media_type {
VisualMediaType::Image => gettext("Image"),
VisualMediaType::Sticker => gettext("Sticker"),
VisualMediaType::Video => gettext("Video"),
match &media_message {
VisualMediaMessage::Image(_) => gettext("Image"),
VisualMediaMessage::Sticker(_) => gettext("Sticker"),
VisualMediaMessage::Video(_) => gettext("Video"),
}
};
self.update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
self.imp().set_state(LoadingState::Loading);
let scale_factor = self.scale_factor();
let media = session.client().media();
let handle = spawn_tokio!(async move {
let thumbnail =
if media_type != VisualMediaType::Video && content.thumbnail_source().is_some() {
media
.get_thumbnail(
&content,
MediaThumbnailSettings::new(
Method::Scale,
((MAX_THUMBNAIL_WIDTH * scale_factor) as u32).into(),
((MAX_THUMBNAIL_HEIGHT * scale_factor) as u32).into(),
),
true,
)
.await
.ok()
.flatten()
} else {
None
};
if let Some(data) = thumbnail {
Ok(Some(data))
} else {
media.get_file(&content, true).await
}
});
let client = session.client();
spawn!(
glib::Priority::LOW,
@ -371,93 +292,103 @@ impl MessageVisualMedia {
#[weak(rename_to = obj)]
self,
async move {
let imp = obj.imp();
match handle.await.unwrap() {
Ok(Some(data)) => {
match media_type {
VisualMediaType::Image | VisualMediaType::Sticker => {
match ImagePaintable::from_bytes(
&glib::Bytes::from(&data),
None,
) {
Ok(texture) => {
let child = if let Some(child) =
imp.media.child().and_downcast::<gtk::Picture>()
{
child
} else {
let child = gtk::Picture::new();
imp.media.set_child(Some(&child));
child
};
child.set_paintable(Some(&texture));
child.set_tooltip_text(Some(&filename));
if media_type == VisualMediaType::Sticker {
if imp.media.has_css_class("content-thumbnail") {
imp.media.remove_css_class("content-thumbnail");
}
} else if !imp.media.has_css_class("content-thumbnail")
{
imp.media.add_css_class("content-thumbnail");
}
}
Err(error) => {
warn!("Image file not supported: {error}");
imp.overlay_error.set_tooltip_text(Some(&gettext(
"Image file not supported",
)));
imp.set_state(LoadingState::Error);
}
}
}
VisualMediaType::Video => {
// The GStreamer backend of GtkVideo doesn't work with input
// streams so we need to
// store the file. See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062
let (file, _) = gio::File::new_tmp(None::<String>).unwrap();
file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)
.unwrap();
let child = if let Some(child) =
imp.media.child().and_downcast::<VideoPlayer>()
{
child
} else {
let child = VideoPlayer::new();
imp.media.set_child(Some(&child));
child
};
child.set_compact(obj.compact());
child.play_media_file(file)
}
};
obj.build_inner(media_message, &client).await;
}
)
);
}
imp.set_state(LoadingState::Ready);
}
Ok(None) => {
warn!("Could not retrieve invalid media file");
imp.overlay_error
.set_tooltip_text(Some(&gettext("Could not retrieve media")));
imp.set_state(LoadingState::Error);
}
async fn build_inner(&self, media_message: VisualMediaMessage, client: &Client) {
let imp = self.imp();
match &media_message {
VisualMediaMessage::Image(_) | VisualMediaMessage::Sticker(_) => {
let is_sticker = matches!(&media_message, VisualMediaMessage::Sticker(_));
let filename = media_message.filename();
let scale_factor = self.scale_factor();
let settings = MediaThumbnailSettings::new(
Method::Scale,
((MAX_THUMBNAIL_WIDTH * scale_factor) as u32).into(),
((MAX_THUMBNAIL_HEIGHT * scale_factor) as u32).into(),
);
let data = if let Some(data) = media_message
.thumbnail(client, settings)
.await
.ok()
.flatten()
{
data
} else {
match media_message.into_content(client).await {
Ok(data) => data,
Err(error) => {
warn!("Could not retrieve media file: {error}");
imp.overlay_error
.set_tooltip_text(Some(&gettext("Could not retrieve media")));
imp.set_state(LoadingState::Error);
return;
}
}
};
match ImagePaintable::from_bytes(&data, None) {
Ok(texture) => {
let child =
if let Some(child) = imp.media.child().and_downcast::<gtk::Picture>() {
child
} else {
let child = gtk::Picture::new();
imp.media.set_child(Some(&child));
child
};
child.set_paintable(Some(&texture));
child.set_tooltip_text(Some(&filename));
if is_sticker {
if imp.media.has_css_class("content-thumbnail") {
imp.media.remove_css_class("content-thumbnail");
}
} else if !imp.media.has_css_class("content-thumbnail") {
imp.media.add_css_class("content-thumbnail");
}
}
Err(error) => {
warn!("Image file not supported: {error}");
imp.overlay_error
.set_tooltip_text(Some(&gettext("Image file not supported")));
imp.set_state(LoadingState::Error);
}
}
)
);
}
VisualMediaMessage::Video(_) => {
let file = match media_message.into_tmp_file(client).await {
Ok(file) => file,
Err(error) => {
warn!("Could not retrieve media file: {error}");
imp.overlay_error
.set_tooltip_text(Some(&gettext("Could not retrieve media")));
imp.set_state(LoadingState::Error);
return;
}
};
let child = if let Some(child) = imp.media.child().and_downcast::<VideoPlayer>() {
child
} else {
let child = VideoPlayer::new();
imp.media.set_child(Some(&child));
child
};
child.set_compact(self.compact());
child.play_media_file(file)
}
};
imp.set_state(LoadingState::Ready);
}
/// Get the texture displayed by this widget, if any.

96
src/session/view/media_viewer.rs

@ -1,15 +1,14 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{gdk, gio, glib, glib::clone, graphene, CompositeTemplate};
use gtk::{gdk, glib, glib::clone, graphene, CompositeTemplate};
use ruma::OwnedEventId;
use tracing::{error, warn};
use tracing::warn;
use crate::{
components::{ContentType, ImagePaintable, MediaContentViewer, ScaleRevealer},
prelude::*,
session::model::Room,
spawn, toast,
utils::{matrix::MediaMessage, media::save_to_file},
utils::matrix::VisualMediaMessage,
};
const ANIMATION_DURATION: u32 = 250;
@ -39,7 +38,7 @@ mod imp {
#[property(get = Self::event_id, type = Option<String>)]
pub event_id: RefCell<Option<OwnedEventId>>,
/// The media message to display.
pub message: RefCell<Option<MediaMessage>>,
pub message: RefCell<Option<VisualMediaMessage>>,
/// The filename of the media.
#[property(get)]
pub filename: RefCell<Option<String>>,
@ -94,10 +93,6 @@ mod imp {
obj.save_file().await;
});
klass.install_action_async("media-viewer.save-audio", None, |obj, _, _| async move {
obj.save_file().await;
});
klass.install_action_async("media-viewer.permalink", None, |obj, _, _| async move {
obj.copy_permalink().await;
});
@ -327,12 +322,12 @@ impl MediaViewer {
}
/// The media message to display.
pub fn message(&self) -> Option<MediaMessage> {
pub fn message(&self) -> Option<VisualMediaMessage> {
self.imp().message.borrow().clone()
}
/// Set the media message to display in the given room.
pub fn set_message(&self, room: &Room, event_id: OwnedEventId, message: MediaMessage) {
pub fn set_message(&self, room: &Room, event_id: OwnedEventId, message: VisualMediaMessage) {
let imp = self.imp();
imp.room.set(Some(room));
@ -351,16 +346,14 @@ impl MediaViewer {
let borrowed_message = imp.message.borrow();
let message = borrowed_message.as_ref();
let has_image = message.is_some_and(|m| matches!(m, MediaMessage::Image(_)));
let has_video = message.is_some_and(|m| matches!(m, MediaMessage::Video(_)));
let has_audio = message.is_some_and(|m| matches!(m, MediaMessage::Audio(_)));
let has_image = message.is_some_and(|m| matches!(m, VisualMediaMessage::Image(_)));
let has_video = message.is_some_and(|m| matches!(m, VisualMediaMessage::Video(_)));
let has_event_id = imp.event_id.borrow().is_some();
self.action_set_enabled("media-viewer.copy-image", has_image);
self.action_set_enabled("media-viewer.save-image", has_image);
self.action_set_enabled("media-viewer.save-video", has_video);
self.action_set_enabled("media-viewer.save-audio", has_audio);
self.action_set_enabled("media-viewer.permalink", has_event_id);
}
@ -399,54 +392,34 @@ impl MediaViewer {
let client = session.client();
match &message {
MediaMessage::Image(image) => {
VisualMediaMessage::Image(image) => {
let mimetype = image.info.as_ref().and_then(|info| info.mimetype.clone());
match message.content(client).await {
Ok(data) => {
match ImagePaintable::from_bytes(
&glib::Bytes::from(&data),
mimetype.as_deref(),
) {
Ok(texture) => {
imp.media.view_image(&texture);
return;
}
Err(error) => {
warn!("Could not load GdkTexture from file: {error}")
}
match message.into_content(&client).await {
Ok(data) => match ImagePaintable::from_bytes(&data, mimetype.as_deref()) {
Ok(texture) => {
imp.media.view_image(&texture);
return;
}
}
Err(error) => {
warn!("Could not load GdkTexture from file: {error}")
}
},
Err(error) => warn!("Could not retrieve image file: {error}"),
}
imp.media.show_fallback(ContentType::Image);
}
MediaMessage::Video(_) => {
match message.content(client).await {
Ok(data) => {
// The GStreamer backend of GtkVideo doesn't work with input
// streams so we need to
// store the file. See: https://gitlab.gnome.org/GNOME/gtk/-/issues/4062
let (file, _) = gio::File::new_tmp(None::<String>).unwrap();
file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)
.unwrap();
imp.media.view_file(file);
}
Err(error) => {
warn!("Could not retrieve video file: {error}");
imp.media.show_fallback(ContentType::Video);
}
VisualMediaMessage::Video(_) => match message.into_tmp_file(&client).await {
Ok(file) => {
imp.media.view_file(file);
}
}
_ => unreachable!(),
Err(error) => {
warn!("Could not retrieve video file: {error}");
imp.media.show_fallback(ContentType::Video);
}
},
VisualMediaMessage::Sticker(_) => unreachable!(),
}
}
@ -506,7 +479,7 @@ impl MediaViewer {
let Some(room) = self.room() else {
return;
};
let Some(message) = self.message() else {
let Some(media_message) = self.message() else {
return;
};
let Some(session) = room.session() else {
@ -514,18 +487,7 @@ impl MediaViewer {
};
let client = session.client();
let filename = message.filename();
let data = match message.content(client).await {
Ok(res) => res,
Err(error) => {
error!("Could not get event file: {error}");
toast!(self, error.to_user_facing());
return;
}
};
save_to_file(self, data, filename).await;
media_message.save_to_file(&client, self).await;
}
/// Copy the permalink of the event of the media message to the clipboard.

5
src/session/view/media_viewer.ui

@ -17,11 +17,6 @@
<attribute name="action">media-viewer.save-video</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">S_ave Audio</attribute>
<attribute name="action">media-viewer.save-audio</attribute>
<attribute name="hidden-when">action-disabled</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Copy Message _Link</attribute>
<attribute name="action">media-viewer.permalink</attribute>

6
src/session/view/session_view.rs

@ -370,8 +370,10 @@ impl SessionView {
/// Show a media event.
pub fn show_media(&self, event: &Event, source_widget: &impl IsA<gtk::Widget>) {
let Some(media_message) = event.media_message() else {
error!("Trying to open the media viewer with an event that is not a media message");
let Some(media_message) = event.visual_media_message() else {
error!(
"Trying to open the media viewer with an event that is not a visual media message"
);
return;
};

353
src/utils/matrix/media_message.rs

@ -1,8 +1,51 @@
use matrix_sdk::Client;
use ruma::events::room::message::{
AudioMessageEventContent, FileMessageEventContent, FormattedBody, ImageMessageEventContent,
MessageType, VideoMessageEventContent,
use gettextrs::gettext;
use gtk::{gio, glib, prelude::*};
use matrix_sdk::{media::MediaThumbnailSettings, Client};
use ruma::{
events::{
room::message::{
AudioMessageEventContent, FileMessageEventContent, FormattedBody,
ImageMessageEventContent, MessageType, VideoMessageEventContent,
},
sticker::StickerEventContent,
},
UInt,
};
use tracing::{debug, error};
use crate::{prelude::*, toast};
/// Get the filename of a media message.
macro_rules! filename {
($message:ident, $mime_fallback:expr) => {{
let mut filename = match &$message.filename {
Some(filename) if *filename != $message.body => filename.clone(),
_ => $message.body.clone(),
};
if filename.is_empty() {
let mimetype = $message
.info
.as_ref()
.and_then(|info| info.mimetype.as_deref());
filename = $crate::utils::media::filename_for_mime(mimetype, $mime_fallback);
}
filename
}};
}
/// Get the caption of a media message.
macro_rules! caption {
($message:ident) => {{
$message
.filename
.as_deref()
.filter(|filename| *filename != $message.body)
.map(|_| ($message.body.clone(), $message.formatted.clone()))
}};
}
/// A media message.
#[derive(Debug, Clone)]
@ -15,6 +58,8 @@ pub enum MediaMessage {
Image(ImageMessageEventContent),
/// A video.
Video(VideoMessageEventContent),
/// A sticker.
Sticker(StickerEventContent),
}
impl MediaMessage {
@ -30,32 +75,15 @@ impl MediaMessage {
}
/// The filename of the media.
///
/// For a sticker, this returns the description of the sticker.
pub fn filename(&self) -> String {
macro_rules! filename {
($message:ident, $mime_fallback:expr) => {{
let mut filename = match &$message.filename {
Some(filename) if *filename != $message.body => filename.clone(),
_ => $message.body.clone(),
};
if filename.is_empty() {
let mimetype = $message
.info
.as_ref()
.and_then(|info| info.mimetype.as_deref());
filename = $crate::utils::media::filename_for_mime(mimetype, $mime_fallback);
}
filename
}};
}
match self {
Self::Audio(c) => filename!(c, Some(mime::AUDIO)),
Self::File(c) => filename!(c, None),
Self::Image(c) => filename!(c, Some(mime::IMAGE)),
Self::Video(c) => filename!(c, Some(mime::VIDEO)),
Self::Sticker(c) => c.body.clone(),
}
}
@ -63,46 +91,281 @@ impl MediaMessage {
///
/// Returns `Some((body, formatted_body))` if the media includes a caption.
pub fn caption(&self) -> Option<(String, Option<FormattedBody>)> {
macro_rules! caption {
($message:ident) => {{
$message
.filename
.as_deref()
.filter(|filename| *filename != $message.body)
.map(|_| ($message.body.clone(), $message.formatted.clone()))
}};
}
match self {
Self::Audio(c) => caption!(c),
Self::File(c) => caption!(c),
Self::Image(c) => caption!(c),
Self::Video(c) => caption!(c),
Self::Sticker(_) => None,
}
}
/// Fetch the content of the media with the given client.
///
/// Returns an error if something occurred while fetching the content.
pub async fn content(self, client: Client) -> Result<Vec<u8>, matrix_sdk::Error> {
pub async fn into_content(self, client: &Client) -> Result<Vec<u8>, matrix_sdk::Error> {
let media = client.media();
macro_rules! data {
($content:ident) => {{
macro_rules! content {
($event_content:ident) => {{
Ok(
$crate::spawn_tokio!(async move { media.get_file(&$content, true).await })
.await
.unwrap()?
.expect("All media message types have a file"),
$crate::spawn_tokio!(
async move { media.get_file(&$event_content, true).await }
)
.await
.unwrap()?
.expect("All media message types have a file"),
)
}};
}
match self {
Self::Audio(c) => data!(c),
Self::File(c) => data!(c),
Self::Image(c) => data!(c),
Self::Video(c) => data!(c),
Self::Audio(c) => content!(c),
Self::File(c) => content!(c),
Self::Image(c) => content!(c),
Self::Video(c) => content!(c),
Self::Sticker(c) => content!(c),
}
}
/// Fetch the content of the media with the given client and write it to a
/// temporary file.
///
/// Returns an error if something occurred while fetching the content.
pub async fn into_tmp_file(self, client: &Client) -> Result<gio::File, MediaFileError> {
let data = self.into_content(client).await?;
let (file, _) = gio::File::new_tmp(None::<String>)?;
file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
)?;
Ok(file)
}
/// Save the content of the media to a file selected by the user.
///
/// Shows a dialog to the user to select a file on the system.
pub async fn save_to_file(self, client: &Client, parent: &impl IsA<gtk::Widget>) {
let filename = self.filename();
let data = match self.into_content(client).await {
Ok(data) => data,
Err(error) => {
error!("Could not retrieve media file: {error}");
toast!(parent, error.to_user_facing());
return;
}
};
let dialog = gtk::FileDialog::builder()
.title(gettext("Save File"))
.modal(true)
.accept_label(gettext("Save"))
.initial_name(filename)
.build();
match dialog
.save_future(parent.root().and_downcast_ref::<gtk::Window>())
.await
{
Ok(file) => {
if let Err(error) = file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
) {
error!("Could not save file: {error}");
toast!(parent, gettext("Could not save file"));
}
}
Err(error) => {
if error.matches(gtk::DialogError::Dismissed) {
debug!("File dialog dismissed by user");
} else {
error!("Could not access file: {error}");
toast!(parent, gettext("Could not access file"));
}
}
};
}
}
impl From<AudioMessageEventContent> for MediaMessage {
fn from(value: AudioMessageEventContent) -> Self {
Self::Audio(value)
}
}
impl From<FileMessageEventContent> for MediaMessage {
fn from(value: FileMessageEventContent) -> Self {
Self::File(value)
}
}
impl From<StickerEventContent> for MediaMessage {
fn from(value: StickerEventContent) -> Self {
Self::Sticker(value)
}
}
/// A visual media message.
#[derive(Debug, Clone)]
pub enum VisualMediaMessage {
/// An image.
Image(ImageMessageEventContent),
/// A video.
Video(VideoMessageEventContent),
/// A sticker.
Sticker(StickerEventContent),
}
impl VisualMediaMessage {
/// Construct a `VisualMediaMessage` from the given message.
pub fn from_message(msgtype: &MessageType) -> Option<Self> {
match msgtype {
MessageType::Image(c) => Some(Self::Image(c.clone())),
MessageType::Video(c) => Some(Self::Video(c.clone())),
_ => None,
}
}
/// The filename of the media.
///
/// For a sticker, this returns the description of the sticker.
pub fn filename(&self) -> String {
match self {
Self::Image(c) => filename!(c, Some(mime::IMAGE)),
Self::Video(c) => filename!(c, Some(mime::VIDEO)),
Self::Sticker(c) => c.body.clone(),
}
}
/// The caption of the media, if any.
///
/// Returns `Some((body, formatted_body))` if the media includes a caption.
pub fn caption(&self) -> Option<(String, Option<FormattedBody>)> {
match self {
Self::Image(c) => caption!(c),
Self::Video(c) => caption!(c),
Self::Sticker(_) => None,
}
}
/// The dimensions of the media, if any.
///
/// Returns a `(width, height)` tuple.
pub fn dimensions(&self) -> Option<(UInt, UInt)> {
match self {
Self::Image(c) => c.info.as_ref().and_then(|i| i.width.zip(i.height)),
Self::Video(c) => c.info.as_ref().and_then(|i| i.width.zip(i.height)),
Self::Sticker(c) => c.info.width.zip(c.info.height),
}
}
/// Fetch the content of the media with the given client and thumbnail
/// settings.
///
/// This might not return a thumbnail at the requested size, depending on
/// the homeserver.
///
/// Returns `Ok(None)` if no thumbnail could be retrieved. Returns an error
/// if something occurred while fetching the content.
pub async fn thumbnail(
&self,
client: &Client,
settings: MediaThumbnailSettings,
) -> Result<Option<Vec<u8>>, matrix_sdk::Error> {
let media = client.media();
macro_rules! thumbnail {
($event_content:ident) => {{
let event_content = $event_content.clone();
$crate::spawn_tokio!(async move {
media.get_thumbnail(&event_content, settings, true).await
})
.await
.unwrap()
}};
}
match self {
Self::Image(c) => {
thumbnail!(c)
}
Self::Video(c) => {
thumbnail!(c)
}
Self::Sticker(c) => {
thumbnail!(c)
}
}
}
/// Fetch the content of the media with the given client.
///
/// Returns an error if something occurred while fetching the content.
pub async fn into_content(self, client: &Client) -> Result<Vec<u8>, matrix_sdk::Error> {
MediaMessage::from(self).into_content(client).await
}
/// Fetch the content of the media with the given client and write it to a
/// temporary file.
///
/// Returns an error if something occurred while fetching the content.
pub async fn into_tmp_file(self, client: &Client) -> Result<gio::File, MediaFileError> {
MediaMessage::from(self).into_tmp_file(client).await
}
/// Save the content of the media to a file selected by the user.
///
/// Shows a dialog to the user to select a file on the system.
pub async fn save_to_file(self, client: &Client, parent: &impl IsA<gtk::Widget>) {
MediaMessage::from(self).save_to_file(client, parent).await
}
}
impl From<ImageMessageEventContent> for VisualMediaMessage {
fn from(value: ImageMessageEventContent) -> Self {
Self::Image(value)
}
}
impl From<VideoMessageEventContent> for VisualMediaMessage {
fn from(value: VideoMessageEventContent) -> Self {
Self::Video(value)
}
}
impl From<StickerEventContent> for VisualMediaMessage {
fn from(value: StickerEventContent) -> Self {
Self::Sticker(value)
}
}
impl From<VisualMediaMessage> for MediaMessage {
fn from(value: VisualMediaMessage) -> Self {
match value {
VisualMediaMessage::Image(c) => Self::Image(c),
VisualMediaMessage::Video(c) => Self::Video(c),
VisualMediaMessage::Sticker(c) => Self::Sticker(c),
}
}
}
/// All errors that can occur when downloading a media to a file.
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub enum MediaFileError {
/// An error occurred when downloading the media.
Sdk(#[from] matrix_sdk::Error),
/// An error occurred when writing the media to a file.
File(#[from] glib::Error),
}

2
src/utils/matrix/mod.rs

@ -29,7 +29,7 @@ use thiserror::Error;
mod media_message;
pub use self::media_message::MediaMessage;
pub use self::media_message::{MediaMessage, VisualMediaMessage};
use crate::{
components::Pill,
gettext_f,

39
src/utils/media.rs

@ -6,9 +6,6 @@ use gettextrs::gettext;
use gtk::{gio, glib, prelude::*};
use matrix_sdk::attachment::{BaseAudioInfo, BaseImageInfo, BaseVideoInfo};
use mime::Mime;
use tracing::{debug, error};
use crate::toast;
/// Get a default filename for a mime type.
///
@ -184,39 +181,3 @@ pub async fn get_audio_info(file: &gio::File) -> BaseAudioInfo {
info.duration = media_info.duration().map(Into::into);
info
}
/// Save the given data to a file with the given filename.
pub async fn save_to_file(obj: &impl IsA<gtk::Widget>, data: Vec<u8>, filename: String) {
let dialog = gtk::FileDialog::builder()
.title(gettext("Save File"))
.modal(true)
.accept_label(gettext("Save"))
.initial_name(filename)
.build();
match dialog
.save_future(obj.root().and_downcast_ref::<gtk::Window>())
.await
{
Ok(file) => {
if let Err(error) = file.replace_contents(
&data,
None,
false,
gio::FileCreateFlags::REPLACE_DESTINATION,
gio::Cancellable::NONE,
) {
error!("Could not save file: {error}");
toast!(obj, gettext("Could not save file"));
}
}
Err(error) => {
if error.matches(gtk::DialogError::Dismissed) {
debug!("File dialog dismissed by user");
} else {
error!("Could not access file: {error}");
toast!(obj, gettext("Could not access file"));
}
}
};
}

16
src/utils/mod.rs

@ -23,7 +23,6 @@ use futures_util::{
pin_mut,
};
use gtk::{gdk, glib, prelude::*, subclass::prelude::*};
use matrix_sdk::ruma::UInt;
use once_cell::sync::Lazy;
use regex::Regex;
@ -35,21 +34,6 @@ pub use self::{
};
use crate::RUNTIME;
/// Converts a `UInt` to `i32`.
///
/// Returns `-1` if the conversion didn't work.
pub fn uint_to_i32(u: Option<UInt>) -> i32 {
u.and_then(|ui| {
let u: Option<u16> = ui.try_into().ok();
u
})
.map(|u| {
let i: i32 = u.into();
i
})
.unwrap_or(-1)
}
pub enum TimeoutFuture {
Timeout,
}

Loading…
Cancel
Save