Browse Source

account-settings: Add safety setting to choose which rooms should show media previews

It is a global choice between all rooms, only private rooms, or no
rooms.
merge-requests/1958/merge
Kévin Commaille 11 months ago committed by Kévin Commaille
parent
commit
213b5bd5dd
  1. 2
      data/resources/icons/scalable/actions/hide-symbolic.svg
  2. 1
      data/resources/resources.gresource.xml
  3. 1
      po/POTFILES.in
  4. 2
      src/session/model/mod.rs
  5. 4
      src/session/model/notifications/notifications_settings.rs
  6. 201
      src/session/model/session_settings.rs
  7. 104
      src/session/view/account_settings/safety_page/mod.rs
  8. 27
      src/session/view/account_settings/safety_page/mod.ui
  9. 23
      src/session/view/content/room_history/message_row/content.rs
  10. 325
      src/session/view/content/room_history/message_row/visual_media.rs
  11. 49
      src/session/view/content/room_history/message_row/visual_media.ui
  12. 7
      src/session/view/media_viewer.rs
  13. 2
      src/session_list/session_list_settings.rs
  14. 29
      src/utils/matrix/media_message.rs
  15. 2
      src/utils/matrix/mod.rs

2
data/resources/icons/scalable/actions/hide-symbolic.svg

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" height="16px" viewBox="0 0 16 16" width="16px"><path d="m 13.980469 1.988281 c -0.261719 0.007813 -0.507813 0.117188 -0.6875 0.304688 l -0.984375 0.984375 c -1.285156 -0.828125 -2.78125 -1.273438 -4.308594 -1.277344 c -3.648438 0.003906 -6.832031 2.476562 -7.738281 6.011719 c 0.460937 1.746093 1.496093 3.285156 2.941406 4.371093 l -0.910156 0.910157 c -0.261719 0.25 -0.367188 0.625 -0.273438 0.972656 c 0.089844 0.351563 0.363281 0.625 0.714844 0.714844 c 0.347656 0.09375 0.722656 -0.011719 0.972656 -0.273438 l 11 -11 c 0.296875 -0.289062 0.382813 -0.726562 0.222657 -1.105469 c -0.160157 -0.382812 -0.539063 -0.625 -0.949219 -0.613281 z m -5.980469 2.011719 c 0.957031 0 1.886719 0.347656 2.609375 0.976562 l -1.417969 1.417969 c -0.34375 -0.257812 -0.761718 -0.394531 -1.191406 -0.394531 c -1.105469 0 -2 0.894531 -2 2 c 0 0.429688 0.140625 0.847656 0.394531 1.1875 l -1.417969 1.421875 c -0.628906 -0.726563 -0.972656 -1.652344 -0.976562 -2.609375 c 0 -2.210938 1.789062 -4 4 -4 z m 7.027344 2.207031 l -3.34375 3.34375 c -0.402344 0.960938 -1.167969 1.722657 -2.125 2.128907 l -2.28125 2.277343 c 0.242187 0.027344 0.480468 0.039063 0.722656 0.042969 c 3.648438 -0.003906 6.832031 -2.476562 7.738281 -6.011719 c -0.164062 -0.617187 -0.402343 -1.214843 -0.710937 -1.78125 z m -7.527344 0.792969 c 0.277344 0 0.5 0.222656 0.5 0.5 s -0.222656 0.5 -0.5 0.5 s -0.5 -0.222656 -0.5 -0.5 s 0.222656 -0.5 0.5 -0.5 z m 0 0" fill="#222222"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
data/resources/resources.gresource.xml

@ -18,6 +18,7 @@
<file preprocess="xml-stripblanks">icons/scalable/actions/go-bottom-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/go-next-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/go-previous-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/hide-symbolic.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-apple-dark.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-apple.svg</file>
<file preprocess="xml-stripblanks">icons/scalable/actions/idp-facebook.svg</file>

1
po/POTFILES.in

@ -159,6 +159,7 @@ src/session/view/content/room_history/message_row/reaction_list.ui
src/session/view/content/room_history/message_row/reply.ui
src/session/view/content/room_history/message_row/text/widgets.rs
src/session/view/content/room_history/message_row/visual_media.rs
src/session/view/content/room_history/message_row/visual_media.ui
src/session/view/content/room_history/message_toolbar/attachment_dialog.ui
src/session/view/content/room_history/message_toolbar/completion/completion_popover.rs
src/session/view/content/room_history/message_toolbar/mod.rs

2
src/session/model/mod.rs

@ -23,7 +23,7 @@ pub(crate) use self::{
room_list::RoomList,
security::*,
session::*,
session_settings::{SessionSettings, StoredSessionSettings},
session_settings::*,
sidebar_data::{
Selection, SidebarIconItem, SidebarIconItemType, SidebarItemList, SidebarListModel,
SidebarSection, SidebarSectionName,

4
src/session/model/notifications/notifications_settings.rs

@ -38,9 +38,7 @@ pub enum NotificationsGlobalSetting {
}
/// The possible values for a room notifications setting.
#[derive(
Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum, strum::Display, strum::EnumString,
)]
#[derive(Debug, Default, Hash, Eq, PartialEq, Clone, Copy, glib::Enum, strum::EnumString)]
#[enum_type(name = "NotificationsRoomSetting")]
#[strum(serialize_all = "kebab-case")]
pub enum NotificationsRoomSetting {

201
src/session/model/session_settings.rs

@ -1,16 +1,16 @@
use std::collections::BTreeSet;
use gtk::{glib, prelude::*, subclass::prelude::*};
use gtk::{glib, glib::closure_local, prelude::*, subclass::prelude::*};
use indexmap::IndexSet;
use ruma::OwnedServerName;
use ruma::{serde::SerializeAsRefStr, OwnedServerName};
use serde::{Deserialize, Serialize};
use super::SidebarSectionName;
use crate::Application;
use super::{Room, SidebarSectionName};
use crate::{session_list::SessionListSettings, Application};
#[derive(Debug, Clone, Serialize, Deserialize, glib::Boxed)]
#[boxed_type(name = "StoredSessionSettings")]
pub struct StoredSessionSettings {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct StoredSessionSettings {
/// Custom servers to explore.
#[serde(default, skip_serializing_if = "IndexSet::is_empty")]
explore_custom_servers: IndexSet<OwnedServerName>,
@ -39,6 +39,10 @@ pub struct StoredSessionSettings {
/// The sections that are expanded.
#[serde(default)]
sections_expanded: SectionsExpanded,
/// Which rooms display media previews for this session.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
media_previews_enabled: MediaPreviewsSetting,
}
impl Default for StoredSessionSettings {
@ -49,6 +53,7 @@ impl Default for StoredSessionSettings {
public_read_receipts_enabled: true,
typing_enabled: true,
sections_expanded: Default::default(),
media_previews_enabled: Default::default(),
}
}
}
@ -57,8 +62,11 @@ mod imp {
use std::{
cell::{OnceCell, RefCell},
marker::PhantomData,
sync::LazyLock,
};
use glib::subclass::Signal;
use super::*;
#[derive(Debug, Default, glib::Properties)]
@ -66,19 +74,18 @@ mod imp {
pub struct SessionSettings {
/// The ID of the session these settings are for.
#[property(get, construct_only)]
pub session_id: OnceCell<String>,
session_id: OnceCell<String>,
/// The stored settings.
#[property(get, construct_only)]
pub stored_settings: RefCell<StoredSessionSettings>,
pub(super) stored_settings: RefCell<StoredSessionSettings>,
/// Whether notifications are enabled for this session.
#[property(get = Self::notifications_enabled, set = Self::set_notifications_enabled, explicit_notify, default = true)]
pub notifications_enabled: PhantomData<bool>,
notifications_enabled: PhantomData<bool>,
/// Whether public read receipts are enabled for this session.
#[property(get = Self::public_read_receipts_enabled, set = Self::set_public_read_receipts_enabled, explicit_notify, default = true)]
pub public_read_receipts_enabled: PhantomData<bool>,
public_read_receipts_enabled: PhantomData<bool>,
/// Whether typing notifications are enabled for this session.
#[property(get = Self::typing_enabled, set = Self::set_typing_enabled, explicit_notify, default = true)]
pub typing_enabled: PhantomData<bool>,
typing_enabled: PhantomData<bool>,
}
#[glib::object_subclass]
@ -88,7 +95,13 @@ mod imp {
}
#[glib::derived_properties]
impl ObjectImpl for SessionSettings {}
impl ObjectImpl for SessionSettings {
fn signals() -> &'static [Signal] {
static SIGNALS: LazyLock<Vec<Signal>> =
LazyLock::new(|| vec![Signal::builder("media-previews-enabled-changed").build()]);
SIGNALS.as_ref()
}
}
impl SessionSettings {
/// Whether notifications are enabled for this session.
@ -103,7 +116,7 @@ mod imp {
}
self.stored_settings.borrow_mut().notifications_enabled = enabled;
super::SessionSettings::save();
session_list_settings().save();
self.obj().notify_notifications_enabled();
}
@ -121,7 +134,7 @@ mod imp {
self.stored_settings
.borrow_mut()
.public_read_receipts_enabled = enabled;
super::SessionSettings::save();
session_list_settings().save();
self.obj().notify_public_read_receipts_enabled();
}
@ -137,7 +150,7 @@ mod imp {
}
self.stored_settings.borrow_mut().typing_enabled = enabled;
super::SessionSettings::save();
session_list_settings().save();
self.obj().notify_typing_enabled();
}
}
@ -150,33 +163,30 @@ glib::wrapper! {
impl SessionSettings {
/// Create a new `SessionSettings` for the given session ID.
pub fn new(session_id: &str) -> Self {
pub(crate) fn new(session_id: &str) -> Self {
glib::Object::builder()
.property("session-id", session_id)
.property("stored-settings", StoredSessionSettings::default())
.build()
}
/// Restore existing `SessionSettings` with the given session ID and stored
/// settings.
pub fn restore(session_id: &str, stored_settings: &StoredSessionSettings) -> Self {
glib::Object::builder()
pub(crate) fn restore(session_id: &str, stored_settings: StoredSessionSettings) -> Self {
let obj = glib::Object::builder::<Self>()
.property("session-id", session_id)
.property("stored-settings", stored_settings)
.build()
.build();
*obj.imp().stored_settings.borrow_mut() = stored_settings;
obj
}
/// Save these settings in the application settings.
fn save() {
Application::default().session_list().settings().save();
/// The stored settings.
pub(crate) fn stored_settings(&self) -> StoredSessionSettings {
self.imp().stored_settings.borrow().clone()
}
/// Delete the settings from the application settings.
pub fn delete(&self) {
Application::default()
.session_list()
.settings()
.remove(&self.session_id());
pub(crate) fn delete(&self) {
session_list_settings().remove(&self.session_id());
}
/// Custom servers to explore.
@ -189,7 +199,7 @@ impl SessionSettings {
}
/// Set the custom servers to explore.
pub fn set_explore_custom_servers(&self, servers: IndexSet<OwnedServerName>) {
pub(crate) fn set_explore_custom_servers(&self, servers: IndexSet<OwnedServerName>) {
if self.explore_custom_servers() == servers {
return;
}
@ -198,11 +208,11 @@ impl SessionSettings {
.stored_settings
.borrow_mut()
.explore_custom_servers = servers;
Self::save();
session_list_settings().save();
}
/// Whether the section with the given name is expanded.
pub fn is_section_expanded(&self, section_name: SidebarSectionName) -> bool {
pub(crate) fn is_section_expanded(&self, section_name: SidebarSectionName) -> bool {
self.imp()
.stored_settings
.borrow()
@ -211,28 +221,75 @@ impl SessionSettings {
}
/// Set whether the section with the given name is expanded.
pub fn set_section_expanded(&self, section_name: SidebarSectionName, expanded: bool) {
pub(crate) fn set_section_expanded(&self, section_name: SidebarSectionName, expanded: bool) {
self.imp()
.stored_settings
.borrow_mut()
.sections_expanded
.set_section_expanded(section_name, expanded);
Self::save();
session_list_settings().save();
}
/// Whether the given room should display media previews.
pub(crate) fn should_room_show_media_previews(&self, room: &Room) -> bool {
self.imp()
.stored_settings
.borrow()
.media_previews_enabled
.should_room_show_media_previews(room)
}
/// Which rooms display media previews.
pub(crate) fn media_previews_global_enabled(&self) -> MediaPreviewsGlobalSetting {
self.imp()
.stored_settings
.borrow()
.media_previews_enabled
.global
}
/// Set which rooms display media previews.
pub(crate) fn set_media_previews_global_enabled(&self, setting: MediaPreviewsGlobalSetting) {
self.imp()
.stored_settings
.borrow_mut()
.media_previews_enabled
.global = setting;
session_list_settings().save();
self.emit_by_name::<()>("media-previews-enabled-changed", &[]);
}
/// Connect to the signal emitted when the media previews setting changed.
pub fn connect_media_previews_enabled_changed<F: Fn(&Self) + 'static>(
&self,
f: F,
) -> glib::SignalHandlerId {
self.connect_closure(
"media-previews-enabled-changed",
true,
closure_local!(move |obj: Self| {
f(&obj);
}),
)
}
}
/// The sections that are expanded.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub struct SectionsExpanded(BTreeSet<SidebarSectionName>);
pub(crate) struct SectionsExpanded(BTreeSet<SidebarSectionName>);
impl SectionsExpanded {
/// Whether the section with the given name is expanded.
pub fn is_section_expanded(&self, section_name: SidebarSectionName) -> bool {
pub(crate) fn is_section_expanded(&self, section_name: SidebarSectionName) -> bool {
self.0.contains(&section_name)
}
/// Set whether the section with the given name is expanded.
pub fn set_section_expanded(&mut self, section_name: SidebarSectionName, expanded: bool) {
pub(crate) fn set_section_expanded(
&mut self,
section_name: SidebarSectionName,
expanded: bool,
) {
if expanded {
self.0.insert(section_name);
} else {
@ -252,3 +309,69 @@ impl Default for SectionsExpanded {
]))
}
}
/// Setting about which rooms display media previews.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct MediaPreviewsSetting {
/// The default setting for all rooms.
#[serde(default, skip_serializing_if = "ruma::serde::is_default")]
global: MediaPreviewsGlobalSetting,
}
impl MediaPreviewsSetting {
// Whether the given room should show room previews according to this setting.
pub(crate) fn should_room_show_media_previews(&self, room: &Room) -> bool {
self.global.should_room_show_media_previews(room)
}
}
/// Possible values of the global setting about which rooms display media
/// previews.
#[derive(
Debug,
Clone,
Copy,
Default,
PartialEq,
Eq,
strum::AsRefStr,
strum::EnumString,
SerializeAsRefStr,
)]
#[strum(serialize_all = "kebab-case")]
pub(crate) enum MediaPreviewsGlobalSetting {
/// All rooms show media previews.
All,
/// Only private rooms show media previews.
#[default]
Private,
/// No rooms show media previews.
None,
}
impl MediaPreviewsGlobalSetting {
/// Whether the given room should show room previews according to this
/// setting.
pub(crate) fn should_room_show_media_previews(self, room: &Room) -> bool {
match self {
Self::All => true,
Self::Private => !room.join_rule().anyone_can_join(),
Self::None => false,
}
}
}
impl<'de> Deserialize<'de> for MediaPreviewsGlobalSetting {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let cow = ruma::serde::deserialize_cow_str(deserializer)?;
cow.parse().map_err(serde::de::Error::custom)
}
}
/// The session list settings of the application.
fn session_list_settings() -> SessionListSettings {
Application::default().session_list().settings()
}

104
src/session/view/account_settings/safety_page/mod.rs

@ -1,13 +1,17 @@
use adw::{prelude::*, subclass::prelude::*};
use gtk::{glib, glib::clone, CompositeTemplate};
use tracing::error;
mod ignored_users_subpage;
pub(super) use self::ignored_users_subpage::IgnoredUsersSubpage;
use crate::{components::ButtonCountRow, session::model::Session};
use crate::{
components::ButtonCountRow,
session::model::{MediaPreviewsGlobalSetting, Session},
};
mod imp {
use std::cell::RefCell;
use std::{cell::RefCell, marker::PhantomData};
use glib::subclass::InitializingObject;
@ -26,7 +30,11 @@ mod imp {
/// The current session.
#[property(get, set = Self::set_session, nullable)]
session: glib::WeakRef<Session>,
/// The media previews setting, as a string.
#[property(get = Self::media_previews_enabled, set = Self::set_media_previews_enabled)]
media_previews_enabled: PhantomData<String>,
ignored_users_count_handler: RefCell<Option<glib::SignalHandlerId>>,
session_settings_handler: RefCell<Option<glib::SignalHandlerId>>,
bindings: RefCell<Vec<glib::Binding>>,
}
@ -38,6 +46,11 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
klass.install_property_action(
"safety.set-media-previews-enabled",
"media-previews-enabled",
);
}
fn instance_init(obj: &InitializingObject<Self>) {
@ -48,15 +61,7 @@ mod imp {
#[glib::derived_properties]
impl ObjectImpl for SafetyPage {
fn dispose(&self) {
if let Some(session) = self.session.upgrade() {
if let Some(handler) = self.ignored_users_count_handler.take() {
session.ignored_users().disconnect(handler);
}
}
for binding in self.bindings.take() {
binding.unbind();
}
self.clear();
}
}
@ -66,20 +71,12 @@ mod imp {
impl SafetyPage {
/// Set the current session.
fn set_session(&self, session: Option<&Session>) {
let prev_session = self.session.upgrade();
if prev_session.as_ref() == session {
if self.session.upgrade().as_ref() == session {
return;
}
if let Some(session) = prev_session {
if let Some(handler) = self.ignored_users_count_handler.take() {
session.ignored_users().disconnect(handler);
}
}
for binding in self.bindings.take() {
binding.unbind();
}
self.clear();
let obj = self.obj();
if let Some(session) = session {
let ignored_users = session.ignored_users();
@ -99,6 +96,18 @@ mod imp {
let session_settings = session.settings();
let media_previews_handler = session_settings
.connect_media_previews_enabled_changed(clone!(
#[weak]
obj,
move |_| {
// Update the active media previews radio button.
obj.notify_media_previews_enabled();
}
));
self.session_settings_handler
.replace(Some(media_previews_handler));
let public_read_receipts_binding = session_settings
.bind_property(
"public-read-receipts-enabled",
@ -119,7 +128,56 @@ mod imp {
}
self.session.set(session);
self.obj().notify_session();
// Update the active media previews radio button.
obj.notify_media_previews_enabled();
obj.notify_session();
}
/// The media previews setting, as a string.
fn media_previews_enabled(&self) -> String {
let Some(session) = self.session.upgrade() else {
return String::new();
};
session
.settings()
.media_previews_global_enabled()
.as_ref()
.to_owned()
}
/// Set the media previews setting, as a string.
fn set_media_previews_enabled(&self, setting: &str) {
let Some(session) = self.session.upgrade() else {
return;
};
let Ok(setting) = setting.parse::<MediaPreviewsGlobalSetting>() else {
error!("Invalid value to set global media previews setting: {setting}");
return;
};
session
.settings()
.set_media_previews_global_enabled(setting);
}
/// Reset the signal handlers and bindings.
fn clear(&self) {
if let Some(session) = self.session.upgrade() {
if let Some(handler) = self.ignored_users_count_handler.take() {
session.ignored_users().disconnect(handler);
}
if let Some(handler) = self.session_settings_handler.take() {
session.settings().disconnect(handler);
}
}
for binding in self.bindings.take() {
binding.unbind();
}
}
}
}

27
src/session/view/account_settings/safety_page/mod.ui

@ -35,5 +35,32 @@
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Media Previews</property>
<property name="description" translatable="yes">Which rooms automatically show previews for images and videos. Hidden previews can always be shown by clicking on the media.</property>
<child>
<object class="CheckLoadingRow" id="media_previews_all_row">
<property name="title" translatable="yes">Show in all rooms</property>
<property name="action-name">safety.set-media-previews-enabled</property>
<property name="action-target">'all'</property>
</object>
</child>
<child>
<object class="CheckLoadingRow" id="media_previews_private_row">
<property name="title" translatable="yes">Show only in private rooms</property>
<property name="action-name">safety.set-media-previews-enabled</property>
<property name="action-target">'private'</property>
</object>
</child>
<child>
<object class="CheckLoadingRow" id="media_previews_public_row">
<property name="title" translatable="yes">Hide in all rooms</property>
<property name="action-name">safety.set-media-previews-enabled</property>
<property name="action-target">'none'</property>
</object>
</child>
</object>
</child>
</template>
</interface>

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

@ -12,7 +12,7 @@ use super::{
use crate::{
prelude::*,
session::{
model::{Event, Member, Room, Session},
model::{Event, Member, Room},
view::content::room_history::message_toolbar::MessageEventSource,
},
spawn,
@ -380,10 +380,6 @@ trait MessageContentContainer: IsA<gtk::Widget> {
detect_at_room: bool,
cache_key: MessageCacheKey,
) {
let Some(session) = room.session() else {
return;
};
if let Some((caption, formatted_caption)) = media_message.caption() {
let caption_widget = self.reuse_child_or_default::<MessageCaption>();
@ -395,9 +391,9 @@ trait MessageContentContainer: IsA<gtk::Widget> {
detect_at_room,
);
caption_widget.build_media_content(media_message, format, &session, cache_key);
caption_widget.build_media_content(media_message, format, room, cache_key);
} else {
self.build_media_content(media_message, format, &session, cache_key);
self.build_media_content(media_message, format, room, cache_key);
}
}
@ -409,13 +405,16 @@ trait MessageContentContainer: IsA<gtk::Widget> {
&self,
media_message: MediaMessage,
format: ContentFormat,
session: &Session,
room: &Room,
cache_key: MessageCacheKey,
) {
match media_message {
MediaMessage::Audio(audio) => {
let Some(session) = room.session() else {
return;
};
let widget = self.reuse_child_or_default::<MessageAudio>();
widget.audio(audio.into(), session, format, cache_key);
widget.audio(audio.into(), &session, format, cache_key);
}
MediaMessage::File(file) => {
let widget = self.reuse_child_or_default::<MessageFile>();
@ -426,15 +425,15 @@ trait MessageContentContainer: IsA<gtk::Widget> {
}
MediaMessage::Image(image) => {
let widget = self.reuse_child_or_default::<MessageVisualMedia>();
widget.set_media_message(image.into(), session, format, cache_key);
widget.set_media_message(image.into(), room, format, cache_key);
}
MediaMessage::Video(video) => {
let widget = self.reuse_child_or_default::<MessageVisualMedia>();
widget.set_media_message(video.into(), session, format, cache_key);
widget.set_media_message(video.into(), room, format, cache_key);
}
MediaMessage::Sticker(sticker) => {
let widget = self.reuse_child_or_default::<MessageVisualMedia>();
widget.set_media_message(sticker.into(), session, format, cache_key);
widget.set_media_message(sticker.into(), room, format, cache_key);
}
}
}

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

@ -1,11 +1,6 @@
use adw::{prelude::*, subclass::prelude::*};
use gettextrs::gettext;
use gtk::{
gdk,
glib::{self, clone},
CompositeTemplate,
};
use matrix_sdk::Client;
use gtk::{gdk, glib, glib::clone, CompositeTemplate};
use ruma::api::client::media::get_content_thumbnail::v3::Method;
use tracing::{error, warn};
@ -13,15 +8,15 @@ use super::{content::MessageCacheKey, ContentFormat};
use crate::{
components::{AnimatedImagePaintable, VideoPlayer},
gettext_f,
session::model::Session,
session::model::Room,
spawn,
utils::{
matrix::VisualMediaMessage,
matrix::{VisualMediaMessage, VisualMediaType},
media::{
image::{ImageRequestPriority, ThumbnailSettings, THUMBNAIL_MAX_DIMENSIONS},
FrameDimensions,
},
CountedRef, File, LoadingState,
CountedRef, File, LoadingState, TemplateCallbacks,
},
};
@ -35,6 +30,8 @@ const MAX_COMPACT_DIMENSIONS: FrameDimensions = FrameDimensions {
width: 75,
height: 50,
};
/// The name of the placeholder stack page.
const PLACEHOLDER_PAGE: &str = "placeholder";
/// The name of the media stack page.
const MEDIA_PAGE: &str = "media";
@ -56,11 +53,26 @@ mod imp {
#[template_child]
stack: TemplateChild<gtk::Stack>,
#[template_child]
preview_instructions: TemplateChild<gtk::Box>,
#[template_child]
preview_instructions_icon: TemplateChild<gtk::Image>,
#[template_child]
spinner: TemplateChild<adw::Spinner>,
#[template_child]
hide_preview_button: TemplateChild<gtk::Button>,
#[template_child]
error: TemplateChild<gtk::Image>,
/// The supposed dimensions of the media.
dimensions: Cell<Option<FrameDimensions>>,
/// The room where the message was sent.
room: glib::WeakRef<Room>,
join_rule_handler: RefCell<Option<glib::SignalHandlerId>>,
session_settings_handler: RefCell<Option<glib::SignalHandlerId>>,
/// The visual media message to display.
media_message: RefCell<Option<VisualMediaMessage>>,
/// The cache key for the current media message.
///
/// We only try to reload the media if the key changes. This is to avoid
/// reloading the media when a local echo changes to a remote echo.
cache_key: RefCell<MessageCacheKey>,
/// The loading state of the media.
#[property(get, builder(LoadingState::default()))]
state: Cell<LoadingState>,
@ -74,11 +86,6 @@ mod imp {
#[property(get)]
activatable: Cell<bool>,
gesture_click: glib::WeakRef<gtk::GestureClick>,
/// The cache key for the current media message.
///
/// We only try to reload the media if the key changes. This is to avoid
/// reloading the media when a local echo changes to a remote echo.
cache_key: RefCell<MessageCacheKey>,
/// The current video file, if any.
file: RefCell<Option<File>>,
paintable_animation_ref: RefCell<Option<CountedRef>>,
@ -92,6 +99,8 @@ mod imp {
fn class_init(klass: &mut Self::Class) {
Self::bind_template(klass);
Self::bind_template_callbacks(klass);
TemplateCallbacks::bind_template_callbacks(klass);
klass.set_css_name("message-visual-media");
klass.set_accessible_role(gtk::AccessibleRole::Group);
@ -105,6 +114,7 @@ mod imp {
#[glib::derived_properties]
impl ObjectImpl for MessageVisualMedia {
fn dispose(&self) {
self.clear();
self.overlay.unparent();
}
}
@ -171,7 +181,12 @@ mod imp {
};
// Use the size from the info or the fallback size.
let media_size = self.dimensions.get().unwrap_or(FALLBACK_DIMENSIONS);
let media_size = self
.media_message
.borrow()
.as_ref()
.and_then(VisualMediaMessage::dimensions)
.unwrap_or(FALLBACK_DIMENSIONS);
let nat = media_size
.scale_to_fit(wanted_size, gtk::ContentFit::ScaleDown)
.dimension_for_orientation(orientation)
@ -200,6 +215,7 @@ mod imp {
}
}
#[gtk::template_callbacks]
impl MessageVisualMedia {
/// The media child of the given type, if any.
pub(super) fn media_child<T: IsA<gtk::Widget>>(&self) -> Option<T> {
@ -209,12 +225,14 @@ mod imp {
/// Set the media child.
///
/// Removes the previous media child if one was set.
fn set_media_child(&self, child: &impl IsA<gtk::Widget>) {
fn set_media_child(&self, child: Option<&impl IsA<gtk::Widget>>) {
if let Some(prev_child) = self.stack.child_by_name(MEDIA_PAGE) {
self.stack.remove(&prev_child);
}
self.stack.add_named(child, Some(MEDIA_PAGE));
if let Some(child) = child {
self.stack.add_named(child, Some(MEDIA_PAGE));
}
}
/// Set the state of the media.
@ -223,27 +241,42 @@ mod imp {
return;
}
match state {
LoadingState::Loading | LoadingState::Initial => {
self.stack.set_visible_child_name("placeholder");
self.spinner.set_visible(true);
self.error.set_visible(false);
}
LoadingState::Ready => {
self.stack.set_visible_child_name(MEDIA_PAGE);
self.spinner.set_visible(false);
self.error.set_visible(false);
}
LoadingState::Error => {
self.spinner.set_visible(false);
self.error.set_visible(true);
}
}
self.state.set(state);
self.update_visible_page();
self.obj().notify_state();
}
/// Update the visible page for the current state.
fn update_visible_page(&self) {
let Some(room) = self.room.upgrade() else {
return;
};
let Some(session) = room.session() else {
return;
};
let state = self.state.get();
self.preview_instructions
.set_visible(state == LoadingState::Initial);
self.spinner.set_visible(state == LoadingState::Loading);
self.hide_preview_button.set_visible(
state == LoadingState::Ready
&& !session.settings().should_room_show_media_previews(&room),
);
self.error.set_visible(state == LoadingState::Error);
let visible_page = match state {
LoadingState::Initial | LoadingState::Loading => Some(PLACEHOLDER_PAGE),
LoadingState::Ready => Some(MEDIA_PAGE),
LoadingState::Error => None,
};
if let Some(visible_page) = visible_page {
self.stack.set_visible_child_name(visible_page);
}
}
/// Update the state of the animated paintable, if any.
fn update_animated_paintable_state(&self) {
self.paintable_animation_ref.take();
@ -276,6 +309,13 @@ mod imp {
self.overlay.remove_css_class("compact");
}
let icon_size = if compact {
gtk::IconSize::Normal
} else {
gtk::IconSize::Large
};
self.preview_instructions_icon.set_icon_size(icon_size);
self.update_gesture_click();
self.obj().notify_compact();
}
@ -304,7 +344,9 @@ mod imp {
#[weak(rename_to = imp)]
self,
move |_, _, _, _| {
if imp
if imp.state.get() == LoadingState::Initial {
imp.show_media();
} else if imp
.obj()
.activate_action("message-row.show-media", None)
.is_err()
@ -333,11 +375,11 @@ mod imp {
should_reload
}
/// Build the content for the given media message.
pub(super) fn build(
/// Set the visual media message to display.
pub(super) fn set_media_message(
&self,
media_message: VisualMediaMessage,
session: &Session,
room: &Room,
format: ContentFormat,
cache_key: MessageCacheKey,
) {
@ -346,44 +388,136 @@ mod imp {
return;
}
self.file.take();
self.dimensions.set(media_message.dimensions());
// Reset the widget.
self.clear();
self.set_state(LoadingState::Initial);
let compact = matches!(format, ContentFormat::Compact | ContentFormat::Ellipsized);
self.set_compact(compact);
let activatable = matches!(
media_message,
VisualMediaMessage::Image(_) | VisualMediaMessage::Video(_)
);
self.set_activatable(activatable);
let Some(session) = room.session() else {
return;
};
let join_rule_handler = room.join_rule().connect_anyone_can_join_notify(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_media();
}
));
self.join_rule_handler.replace(Some(join_rule_handler));
let session_settings_handler = session
.settings()
.connect_media_previews_enabled_changed(clone!(
#[weak(rename_to = imp)]
self,
move |_| {
imp.update_media();
}
));
self.session_settings_handler
.replace(Some(session_settings_handler));
self.room.set(Some(room));
self.media_message.replace(Some(media_message));
self.update_accessible_label();
self.update_preview_instructions_icon();
self.update_media();
}
/// Update the accessible label for the current state.
fn update_accessible_label(&self) {
let Some((filename, visual_media_type)) =
self.media_message.borrow().as_ref().map(|media_message| {
(media_message.filename(), media_message.visual_media_type())
})
else {
return;
};
let filename = media_message.filename();
let accessible_label = if filename.is_empty() {
match &media_message {
VisualMediaMessage::Image(_) => gettext("Image"),
VisualMediaMessage::Sticker(_) => gettext("Sticker"),
VisualMediaMessage::Video(_) => gettext("Video"),
match visual_media_type {
VisualMediaType::Image => gettext("Image"),
VisualMediaType::Sticker => gettext("Sticker"),
VisualMediaType::Video => gettext("Video"),
}
} else {
match &media_message {
VisualMediaMessage::Image(_) => {
match visual_media_type {
VisualMediaType::Image => {
gettext_f("Image: {filename}", &[("filename", &filename)])
}
VisualMediaMessage::Sticker(_) => {
VisualMediaType::Sticker => {
gettext_f("Sticker: {filename}", &[("filename", &filename)])
}
VisualMediaMessage::Video(_) => {
VisualMediaType::Video => {
gettext_f("Video: {filename}", &[("filename", &filename)])
}
}
};
self.obj()
.update_property(&[gtk::accessible::Property::Label(&accessible_label)]);
}
/// Update the preview instructions icon for the current state.
fn update_preview_instructions_icon(&self) {
let Some(content_type) = self
.media_message
.borrow()
.as_ref()
.map(VisualMediaMessage::content_type)
else {
return;
};
self.preview_instructions_icon
.set_icon_name(Some(content_type.icon_name()));
}
/// Update the media for the current state.
fn update_media(&self) {
let Some(room) = self.room.upgrade() else {
return;
};
let Some(session) = room.session() else {
return;
};
if session.settings().should_room_show_media_previews(&room) {
// Only load the media if it was not loaded before.
if self.state.get() == LoadingState::Initial {
self.show_media();
}
} else {
self.hide_media();
}
}
/// Hide the media.
#[template_callback]
fn hide_media(&self) {
self.set_state(LoadingState::Initial);
self.set_media_child(None::<&gtk::Widget>);
self.file.take();
self.set_activatable(true);
}
/// Show the media.
fn show_media(&self) {
let Some(media_message) = self.media_message.borrow().clone() else {
return;
};
self.set_state(LoadingState::Loading);
let client = session.client();
let activatable = matches!(
media_message,
VisualMediaMessage::Image(_) | VisualMediaMessage::Video(_)
);
self.set_activatable(activatable);
spawn!(
glib::Priority::LOW,
clone!(
@ -392,10 +526,10 @@ mod imp {
async move {
match &media_message {
VisualMediaMessage::Image(_) | VisualMediaMessage::Sticker(_) => {
imp.build_image(&media_message, client).await;
imp.build_image(&media_message).await;
}
VisualMediaMessage::Video(_) => {
imp.build_video(media_message, &client).await;
imp.build_video(media_message).await;
}
}
@ -406,14 +540,27 @@ mod imp {
}
/// Build the content for the image in the given media message.
async fn build_image(&self, media_message: &VisualMediaMessage, client: Client) {
async fn build_image(&self, media_message: &VisualMediaMessage) {
let Some(client) = self
.room
.upgrade()
.and_then(|room| room.session())
.map(|session| session.client())
else {
return;
};
if self.state.get() != LoadingState::Loading {
// Something occurred after the task was spawned, cancel the task.
return;
}
// Disable the copy-image action while the image is loading.
if matches!(media_message, VisualMediaMessage::Image(_)) {
self.enable_copy_image_action(false);
}
let scale_factor = self.obj().scale_factor();
let settings = ThumbnailSettings {
dimensions: FrameDimensions::thumbnail_max_dimensions(scale_factor),
method: Method::Scale,
@ -433,13 +580,18 @@ mod imp {
}
};
if self.state.get() != LoadingState::Loading {
// Something occurred while the image was loading, cancel the task.
return;
}
let child = if let Some(child) = self.media_child::<gtk::Picture>() {
child
} else {
let child = gtk::Picture::builder()
.content_fit(gtk::ContentFit::ScaleDown)
.build();
self.set_media_child(&child);
self.set_media_child(Some(&child));
child
};
child.set_paintable(Some(&gdk::Paintable::from(image)));
@ -479,8 +631,22 @@ mod imp {
}
/// Build the content for the video in the given media message.
async fn build_video(&self, media_message: VisualMediaMessage, client: &Client) {
let file = match media_message.into_tmp_file(client).await {
async fn build_video(&self, media_message: VisualMediaMessage) {
let Some(client) = self
.room
.upgrade()
.and_then(|room| room.session())
.map(|session| session.client())
else {
return;
};
if self.state.get() != LoadingState::Loading {
// Something occurred after the task was spawned, cancel the task.
return;
}
let file = match media_message.into_tmp_file(&client).await {
Ok(file) => file,
Err(error) => {
warn!("Could not retrieve video: {error}");
@ -489,6 +655,11 @@ mod imp {
}
};
if self.state.get() != LoadingState::Loading {
// Something occurred while the video was loading, cancel the task.
return;
}
let child = if let Some(child) = self.media_child::<VideoPlayer>() {
child
} else {
@ -500,7 +671,7 @@ mod imp {
imp.video_state_changed(player);
}
));
self.set_media_child(&child);
self.set_media_child(Some(&child));
child
};
@ -533,6 +704,23 @@ mod imp {
}
}
}
/// Reset the state of this widget.
fn clear(&self) {
self.file.take();
if let Some(room) = self.room.upgrade() {
if let Some(handler) = self.join_rule_handler.take() {
room.join_rule().disconnect(handler);
}
if let Some(handler) = self.session_settings_handler.take() {
if let Some(session) = room.session() {
session.settings().disconnect(handler);
}
}
}
}
}
}
@ -548,15 +736,16 @@ impl MessageVisualMedia {
glib::Object::new()
}
/// Display the given visual media message.
/// Set the visual media message to display.
pub(crate) fn set_media_message(
&self,
media_message: VisualMediaMessage,
session: &Session,
room: &Room,
format: ContentFormat,
cache_key: MessageCacheKey,
) {
self.imp().build(media_message, session, format, cache_key);
self.imp()
.set_media_message(media_message, room, format, cache_key);
}
/// Get the texture displayed by this widget, if any.

49
src/session/view/content/room_history/message_row/visual_media.ui

@ -26,8 +26,41 @@
</child>
</object>
</child>
<child type="overlay">
<object class="GtkBox" id="preview_instructions">
<property name="orientation">vertical</property>
<property name="spacing">6</property>
<property name="halign">center</property>
<property name="valign">center</property>
<layout>
<property name="measure">true</property>
</layout>
<child>
<object class="GtkImage" id="preview_instructions_icon">
<style>
<class name="dimmed"/>
</style>
<property name="icon-size">large</property>
<property name="accessible-role">presentation</property>
</object>
</child>
<child>
<object class="GtkLabel">
<binding name="visible">
<closure type="gboolean" function="invert_boolean">
<lookup name="compact">ContentMessageVisualMedia</lookup>
</closure>
</binding>
<property name="label" translatable="yes">Click to show preview</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
</object>
</child>
</object>
</child>
<child type="overlay">
<object class="AdwSpinner" id="spinner">
<property name="visible">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<layout>
@ -35,12 +68,28 @@
</layout>
</object>
</child>
<child type="overlay">
<object class="GtkButton" id="hide_preview_button">
<style>
<class name="osd"/>
</style>
<property name="visible">False</property>
<property name="halign">end</property>
<property name="valign">start</property>
<property name="margin-end">3</property>
<property name="margin-top">3</property>
<property name="icon-name">hide-symbolic</property>
<property name="tooltip-text" translatable="yes">Hide media preview</property>
<signal name="clicked" handler="hide_media" swapped="yes"/>
</object>
</child>
<child type="overlay">
<object class="GtkImage" id="error">
<style>
<class name="osd"/>
<class name="circular"/>
</style>
<property name="visible">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<property name="icon-name">error-symbolic</property>

7
src/session/view/media_viewer.rs

@ -5,7 +5,7 @@ use ruma::OwnedEventId;
use tracing::warn;
use crate::{
components::{ContentType, MediaContentViewer, ScaleRevealer},
components::{MediaContentViewer, ScaleRevealer},
session::model::Room,
spawn, toast,
utils::matrix::VisualMediaMessage,
@ -369,10 +369,7 @@ mod imp {
return;
};
let content_type = match &message {
VisualMediaMessage::Image(_) | VisualMediaMessage::Sticker(_) => ContentType::Image,
VisualMediaMessage::Video(_) => ContentType::Video,
};
let content_type = message.content_type();
let client = session.client();
match message.into_tmp_file(&client).await {

2
src/session_list/session_list_settings.rs

@ -66,7 +66,7 @@ impl SessionListSettings {
needs_update = true;
}
let session = SessionSettings::restore(&session_id, &stored_session);
let session = SessionSettings::restore(&session_id, stored_session);
(session_id, session)
})
.collect();

29
src/utils/matrix/media_message.rs

@ -11,6 +11,7 @@ use ruma::events::{
use tracing::{debug, error};
use crate::{
components::ContentType,
prelude::*,
toast,
utils::{
@ -244,6 +245,23 @@ impl VisualMediaMessage {
FrameDimensions::from_options(width, height)
}
/// The type of the media.
pub(crate) fn visual_media_type(&self) -> VisualMediaType {
match self {
Self::Image(_) => VisualMediaType::Image,
Self::Sticker(_) => VisualMediaType::Sticker,
Self::Video(_) => VisualMediaType::Video,
}
}
/// The content type of the media.
pub(crate) fn content_type(&self) -> ContentType {
match self {
Self::Image(_) | Self::Sticker(_) => ContentType::Image,
Self::Video(_) => ContentType::Video,
}
}
/// Fetch a thumbnail of the media with the given client and thumbnail
/// settings.
///
@ -358,3 +376,14 @@ impl From<VisualMediaMessage> for MediaMessage {
}
}
}
/// The type of a visual media message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum VisualMediaType {
/// An image.
Image,
/// A video.
Video,
/// A sticker.
Sticker,
}

2
src/utils/matrix/mod.rs

@ -36,7 +36,7 @@ use tracing::error;
pub(crate) mod ext_traits;
mod media_message;
pub(crate) use self::media_message::{MediaMessage, VisualMediaMessage};
pub(crate) use self::media_message::*;
use crate::{
components::Pill,
gettext_f,

Loading…
Cancel
Save