From b84a5849959faef39d62660023da16a8e2246958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 17 May 2025 16:16:04 +0200 Subject: [PATCH] account-settings: Add safety setting to hide avatars in invites For consistency, we also hide avatars for invites that were rejected or retracted, if we manage to find that out for left rooms. --- src/components/avatar/data.rs | 36 ++-- src/components/avatar/mod.rs | 33 ++- src/components/pill/mod.rs | 20 +- src/session/model/notifications/mod.rs | 154 +++++++++++++- src/session/model/room/mod.rs | 191 ++++++++++++++++-- src/session/model/session_settings.rs | 27 +++ .../view/account_settings/safety_page/mod.rs | 18 +- .../view/account_settings/safety_page/mod.ui | 11 + src/session/view/content/invite.rs | 92 ++++++--- src/session/view/content/invite.ui | 2 +- .../view/content/room_details/general_page.rs | 52 ++++- .../view/content/room_details/general_page.ui | 2 +- src/session/view/sidebar/room_row.rs | 85 +++++++- src/utils/matrix/mod.rs | 119 +---------- 14 files changed, 641 insertions(+), 201 deletions(-) diff --git a/src/components/avatar/data.rs b/src/components/avatar/data.rs index bbcd9fe6..2025b655 100644 --- a/src/components/avatar/data.rs +++ b/src/components/avatar/data.rs @@ -68,8 +68,10 @@ impl AvatarData { /// Get this avatar as a notification icon. /// + /// If `inhibit_image` is set, the image of the avatar will not be used. + /// /// Returns `None` if an error occurred while generating the icon. - pub(crate) async fn as_notification_icon(&self) -> Option { + pub(crate) async fn as_notification_icon(&self, inhibit_image: bool) -> Option { let Some(window) = Application::default().active_window() else { warn!("Could not generate icon for notification: no active window"); return None; @@ -80,21 +82,23 @@ impl AvatarData { }; let scale_factor = window.scale_factor(); - if let Some(image) = self.image() { - match image.load_small_paintable().await { - Ok(Some(paintable)) => { - let texture = paintable_as_notification_icon( - paintable.upcast_ref(), - scale_factor, - &renderer, - ); - return Some(texture); - } - // No paintable, we will try to generate the fallback. - Ok(None) => {} - // Could not get the paintable, we will try to generate the fallback. - Err(error) => { - warn!("Could not generate icon for notification: {error}"); + if !inhibit_image { + if let Some(image) = self.image() { + match image.load_small_paintable().await { + Ok(Some(paintable)) => { + let texture = paintable_as_notification_icon( + paintable.upcast_ref(), + scale_factor, + &renderer, + ); + return Some(texture); + } + // No paintable, we will try to generate the fallback. + Ok(None) => {} + // Could not get the paintable, we will try to generate the fallback. + Err(error) => { + warn!("Could not generate icon for notification: {error}"); + } } } } diff --git a/src/components/avatar/mod.rs b/src/components/avatar/mod.rs index 8c1ec780..bba30fc2 100644 --- a/src/components/avatar/mod.rs +++ b/src/components/avatar/mod.rs @@ -1,5 +1,5 @@ use adw::subclass::prelude::*; -use gtk::{glib, glib::clone, prelude::*, CompositeTemplate}; +use gtk::{gdk, glib, glib::clone, prelude::*, CompositeTemplate}; mod crop_circle; mod data; @@ -20,7 +20,10 @@ use crate::{ }; mod imp { - use std::{cell::RefCell, marker::PhantomData}; + use std::{ + cell::{Cell, RefCell}, + marker::PhantomData, + }; use glib::subclass::InitializingObject; @@ -41,6 +44,11 @@ mod imp { /// The size of the Avatar. #[property(get = Self::size, set = Self::set_size, explicit_notify, builder().default_value(-1).minimum(-1))] size: PhantomData, + /// Whether to inhibit the image of the avatar. + /// + /// If the image is inhibited, it will not be loaded. + #[property(get, set = Self::set_inhibit_image, explicit_notify)] + inhibit_image: Cell, paintable_ref: RefCell>, paintable_animation_ref: RefCell>, } @@ -108,6 +116,18 @@ mod imp { self.obj().notify_size(); } + /// Set whether to inhibit the image of the avatar. + fn set_inhibit_image(&self, inhibit: bool) { + if self.inhibit_image.get() == inhibit { + return; + } + + self.inhibit_image.set(inhibit); + + self.update_paintable(); + self.obj().notify_inhibit_image(); + } + /// Set the [`AvatarData`] displayed by this widget. fn set_data(&self, data: Option) { if self.data.obj() == data { @@ -188,6 +208,13 @@ mod imp { fn update_paintable(&self) { let _old_paintable_ref = self.paintable_ref.take(); + if self.inhibit_image.get() { + // We need to unset the paintable. + self.avatar.set_custom_image(None::<&gdk::Paintable>); + self.update_animated_paintable_state(); + return; + } + if !self.obj().is_mapped() { // We do not need a paintable. self.update_animated_paintable_state(); @@ -218,7 +245,7 @@ mod imp { fn update_animated_paintable_state(&self) { let _old_paintable_animation_ref = self.paintable_animation_ref.take(); - if !self.obj().is_mapped() { + if self.inhibit_image.get() || !self.obj().is_mapped() { // We do not need to animate the paintable. return; } diff --git a/src/components/pill/mod.rs b/src/components/pill/mod.rs index 1737522b..5f9d045e 100644 --- a/src/components/pill/mod.rs +++ b/src/components/pill/mod.rs @@ -23,7 +23,10 @@ use crate::{ }; mod imp { - use std::cell::{Cell, RefCell}; + use std::{ + cell::{Cell, RefCell}, + marker::PhantomData, + }; use glib::subclass::InitializingObject; @@ -45,6 +48,11 @@ mod imp { /// Whether the pill can be activated. #[property(get, set = Self::set_activatable, explicit_notify)] activatable: Cell, + /// Whether to inhibit the image of the avatar. + /// + /// If the image is inhibited, it will not be loaded. + #[property(get = Self::inhibit_image, set = Self::set_inhibit_image)] + inhibit_image: PhantomData, gesture_click: RefCell>, } @@ -165,6 +173,16 @@ mod imp { } } + /// Whether to inhibit the image of the avatar. + fn inhibit_image(&self) -> bool { + self.avatar.inhibit_image() + } + + /// Set whether to inhibit the image of the avatar. + fn set_inhibit_image(&self, inhibit: bool) { + self.avatar.set_inhibit_image(inhibit); + } + /// Set the display name of this pill. fn set_display_name(&self, label: &str) { // We ellipsize the string manually because GtkTextView uses the minimum width. diff --git a/src/session/model/notifications/mod.rs b/src/session/model/notifications/mod.rs index 8c7b8f8b..b8f23a67 100644 --- a/src/session/model/notifications/mod.rs +++ b/src/session/model/notifications/mod.rs @@ -3,7 +3,16 @@ use std::borrow::Cow; use gettextrs::gettext; use gtk::{gdk, gio, glib, prelude::*, subclass::prelude::*}; use matrix_sdk::{sync::Notification, Room as MatrixRoom}; -use ruma::{api::client::device::get_device, OwnedRoomId, RoomId}; +use ruma::{ + api::client::device::get_device, + events::{ + room::{member::MembershipState, message::MessageType}, + AnyMessageLikeEventContent, AnyStrippedStateEvent, AnySyncStateEvent, AnySyncTimelineEvent, + SyncStateEvent, + }, + html::{HtmlSanitizerMode, RemoveReplyFallback}, + OwnedRoomId, RoomId, UserId, +}; use tracing::{debug, warn}; mod notifications_settings; @@ -18,8 +27,7 @@ use crate::{ prelude::*, spawn_tokio, utils::matrix::{ - get_event_body, AnySyncOrStrippedTimelineEvent, MatrixEventIdUri, MatrixIdUri, - MatrixRoomIdUri, + AnySyncOrStrippedTimelineEvent, MatrixEventIdUri, MatrixIdUri, MatrixRoomIdUri, }, Application, Window, }; @@ -142,6 +150,7 @@ impl Notifications { /// /// The notification will not be shown if the application is active and the /// room of the event is displayed. + #[allow(clippy::too_many_lines)] pub(crate) async fn show_push( &self, matrix_notification: Notification, @@ -215,10 +224,17 @@ impl Notifications { }, ); - let Some(body) = get_event_body(&event, &sender_name, session.user_id(), !is_direct) else { - debug!("Received notification for event of unexpected type {event:?}",); - return; - }; + let (body, is_invite) = + if let Some(body) = message_notification_body(&event, &sender_name, !is_direct) { + (body, false) + } else if let Some(body) = + own_invite_notification_body(&event, &sender_name, session.user_id()) + { + (body, true) + } else { + debug!("Received notification for event of unexpected type {event:?}",); + return; + }; let room_id = room.room_id().to_owned(); let event_id = event.event_id(); @@ -242,7 +258,9 @@ impl Notifications { let random_id = glib::uuid_string_random(); format!("{session_id}//{matrix_uri}//{random_id}") }; - let icon = room.avatar_data().as_notification_icon().await; + + let inhibit_image = is_invite && !session.settings().invite_avatars_enabled(); + let icon = room.avatar_data().as_notification_icon(inhibit_image).await; Self::send_notification( &id, @@ -294,7 +312,7 @@ impl Notifications { &[("user", &user.display_name())], ); - let icon = user.avatar_data().as_notification_icon().await; + let icon = user.avatar_data().as_notification_icon(false).await; let id = format!("{session_id}//{room_id}//{user_id}//{flow_id}"); Self::send_notification( @@ -418,3 +436,121 @@ impl Default for Notifications { Self::new() } } + +/// Generate the notification body for the given event, if it is a message-like +/// event. +/// +/// If it's a media message, this will return a localized body. +/// +/// Returns `None` if it is not a message-like event or if the message type is +/// not supported. +pub(crate) fn message_notification_body( + event: &AnySyncOrStrippedTimelineEvent, + sender_name: &str, + show_sender: bool, +) -> Option { + let AnySyncOrStrippedTimelineEvent::Sync(sync_event) = event else { + return None; + }; + let AnySyncTimelineEvent::MessageLike(message_event) = &**sync_event else { + return None; + }; + + match message_event.original_content()? { + AnyMessageLikeEventContent::RoomMessage(mut message) => { + message.sanitize(HtmlSanitizerMode::Compat, RemoveReplyFallback::Yes); + + let body = match message.msgtype { + MessageType::Audio(_) => { + gettext_f("{user} sent an audio file.", &[("user", sender_name)]) + } + MessageType::Emote(content) => format!("{sender_name} {}", content.body), + MessageType::File(_) => gettext_f("{user} sent a file.", &[("user", sender_name)]), + MessageType::Image(_) => { + gettext_f("{user} sent an image.", &[("user", sender_name)]) + } + MessageType::Location(_) => { + gettext_f("{user} sent their location.", &[("user", sender_name)]) + } + MessageType::Notice(content) => { + text_event_body(content.body, sender_name, show_sender) + } + MessageType::ServerNotice(content) => { + text_event_body(content.body, sender_name, show_sender) + } + MessageType::Text(content) => { + text_event_body(content.body, sender_name, show_sender) + } + MessageType::Video(_) => { + gettext_f("{user} sent a video.", &[("user", sender_name)]) + } + _ => return None, + }; + Some(body) + } + AnyMessageLikeEventContent::Sticker(_) => Some(gettext_f( + "{user} sent a sticker.", + &[("user", sender_name)], + )), + _ => None, + } +} + +fn text_event_body(message: String, sender_name: &str, show_sender: bool) -> String { + if show_sender { + gettext_f( + "{user}: {message}", + &[("user", sender_name), ("message", &message)], + ) + } else { + message + } +} + +/// Generate the notification body for the given event, if it is an invite for +/// our own user. +/// +/// This will return a localized body. +/// +/// Returns `None` if it is not an invite for our own user. +pub(crate) fn own_invite_notification_body( + event: &AnySyncOrStrippedTimelineEvent, + sender_name: &str, + own_user_id: &UserId, +) -> Option { + let (membership, state_key) = match event { + AnySyncOrStrippedTimelineEvent::Sync(sync_event) => { + if let AnySyncTimelineEvent::State(AnySyncStateEvent::RoomMember(member_event)) = + &**sync_event + { + match member_event { + SyncStateEvent::Original(original_event) => ( + &original_event.content.membership, + &original_event.state_key, + ), + SyncStateEvent::Redacted(redacted_event) => ( + &redacted_event.content.membership, + &redacted_event.state_key, + ), + } + } else { + return None; + } + } + AnySyncOrStrippedTimelineEvent::Stripped(stripped_event) => { + if let AnyStrippedStateEvent::RoomMember(member_event) = &**stripped_event { + (&member_event.content.membership, &member_event.state_key) + } else { + return None; + } + } + }; + + if *membership == MembershipState::Invite && state_key == own_user_id { + // Translators: Do NOT translate the content between '{' and '}', this is a + // variable name. + Some(gettext_f("{user} invited you", &[("user", sender_name)])) + } else { + None + } +} diff --git a/src/session/model/room/mod.rs b/src/session/model/room/mod.rs index a5ce3811..26eae8cf 100644 --- a/src/session/model/room/mod.rs +++ b/src/session/model/room/mod.rs @@ -9,9 +9,11 @@ use gtk::{ subclass::prelude::*, }; use matrix_sdk::{ - deserialized_responses::AmbiguityChange, event_handler::EventHandlerDropGuard, - room::Room as MatrixRoom, send_queue::RoomSendQueueUpdate, Result as MatrixResult, - RoomDisplayName, RoomInfo, RoomMemberships, RoomState, + deserialized_responses::{AmbiguityChange, RawSyncOrStrippedState}, + event_handler::EventHandlerDropGuard, + room::Room as MatrixRoom, + send_queue::RoomSendQueueUpdate, + Result as MatrixResult, RoomDisplayName, RoomInfo, RoomMemberships, RoomState, }; use ruma::{ api::client::{ @@ -21,12 +23,14 @@ use ruma::{ events::{ receipt::ReceiptThread, room::{ - guest_access::GuestAccess, history_visibility::HistoryVisibility, - member::SyncRoomMemberEvent, + guest_access::GuestAccess, + history_visibility::HistoryVisibility, + member::{MembershipState, RoomMemberEventContent, SyncRoomMemberEvent}, }, }, EventId, MatrixToUri, OwnedEventId, OwnedRoomId, OwnedUserId, RoomId, UserId, }; +use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tracing::{debug, error, warn}; @@ -150,6 +154,10 @@ mod imp { /// The member corresponding to our own user. #[property(get)] own_member: OnceCell, + /// Whether this room is a current invite or an invite that was declined + /// or retracted. + #[property(get)] + is_invite: Cell, /// The user who sent the invite to this room. /// /// This is only set when this room is an invitation. @@ -549,8 +557,13 @@ mod imp { return; } + self.update_is_invite().await; + self.update_inviter().await; + let matrix_room = self.matrix_room(); - let category = match matrix_room.state() { + let state = matrix_room.state(); + + let category = match state { RoomState::Joined => { if matrix_room.is_space() { RoomCategory::Space @@ -563,8 +576,6 @@ mod imp { } } RoomState::Invited => { - self.load_inviter().await; - if self .inviter .borrow() @@ -805,17 +816,145 @@ mod imp { } } - /// Load the member that invited us to this room, when applicable. - async fn load_inviter(&self) { + /// Update whether this room is a current invite or an invite that was + /// declined or retracted. + async fn update_is_invite(&self) { let matrix_room = self.matrix_room(); - if matrix_room.state() != RoomState::Invited { - // We are only interested in the inviter for current invites. + let is_invite = match matrix_room.state() { + RoomState::Invited => true, + RoomState::Left => self.was_invite().await, + _ => false, + }; + + if self.is_invite.get() == is_invite { return; } + self.is_invite.set(is_invite); + self.obj().notify_is_invite(); + } + + /// Check if this room was an invite that was declined or retracted. + async fn was_invite(&self) -> bool { + let matrix_room = self.matrix_room(); + + if matrix_room.state() != RoomState::Left { + return false; + } + + // To know if this was an invite we need to check in the member event of our own + // user if the current membership is `invite`, or if the current membership is + // `leave` or `ban`, and the previous membership was `invite`. let matrix_room_clone = matrix_room.clone(); - let handle = spawn_tokio!(async move { matrix_room_clone.invite_details().await }); + let handle = spawn_tokio!(async move { + matrix_room_clone + .get_state_event_static_for_key::( + matrix_room_clone.own_user_id(), + ) + .await + }); + + let raw_member_event = match handle.await.expect("task was not aborted") { + Ok(Some(raw_member_event)) => raw_member_event, + Ok(None) => { + return false; + } + Err(error) => { + error!("Could not get own member event: {error}"); + return false; + } + }; + + let member_event = match raw_member_event { + RawSyncOrStrippedState::Sync(raw) => { + raw.deserialize_as::() + } + RawSyncOrStrippedState::Stripped(raw) => raw.deserialize_as(), + }; + + let member_event = match member_event { + Ok(member_event) => member_event, + Err(error) => { + warn!("Could not deserialize room member event: {error}"); + return false; + } + }; + + // Check if the last membership was `invite`. This can happen if we do not get a + // timeline update when leaving the room. + let membership = member_event.content.membership; + if membership == MembershipState::Invite { + return true; + } + + // Check if the last membership mas `leave` or `ban`, and the previous + // membership was `invite`. This can happen if we do get a timeline update when + // leaving the room. + if !matches!(membership, MembershipState::Leave | MembershipState::Ban) { + return false; + } + + if let Some(prev_content) = member_event + .unsigned + .as_ref() + .and_then(|unsigned| unsigned.prev_content.as_ref()) + { + return prev_content.membership == MembershipState::Invite; + } + + // If we do not have the `prev_content`, we need to fetch the previous state + // event. + let Some(replaces_state) = member_event + .unsigned + .and_then(|unsigned| unsigned.replaces_state) + else { + return false; + }; + + let matrix_room = matrix_room.clone(); + let handle = spawn_tokio!(async move { + matrix_room.load_or_fetch_event(&replaces_state, None).await + }); + + let raw_prev_member_event = match handle.await.expect("task was not aborted") { + Ok(event) => event, + Err(error) => { + warn!("Could not fetch previous member event: {error}"); + return false; + } + }; + + match raw_prev_member_event + .kind + .raw() + .deserialize_as::() + { + Ok(prev_member_event) => { + prev_member_event.content.membership == MembershipState::Invite + } + Err(error) => { + warn!("Could not deserialize previous member event: {error}"); + false + } + } + } + + /// Update the member that invited us to this room. + async fn update_inviter(&self) { + let matrix_room = self.matrix_room(); + + // We are only interested in the inviter for current invites. + if matrix_room.state() != RoomState::Invited { + if self.inviter.take().is_some() { + self.obj().notify_inviter(); + } + + return; + } + + let matrix_room = matrix_room.clone(); + let handle = spawn_tokio!(async move { matrix_room.invite_details().await }); let invite = match handle.await.expect("task was not aborted") { Ok(invite) => invite, @@ -826,6 +965,9 @@ mod imp { }; let Some(inviter_member) = invite.inviter else { + if self.inviter.take().is_some() { + self.obj().notify_inviter(); + } return; }; @@ -2091,3 +2233,26 @@ pub(crate) enum ReceiptPosition { /// We are at the event with the given ID. Event(OwnedEventId), } + +/// Helper type to extract the current and previous memberships from a raw +/// `m.room.member` event. +#[derive(Deserialize)] +struct RoomMemberMembershipEvent { + content: RoomMemberMembershipContent, + unsigned: Option, +} + +/// Helper type to extract the membership of the `unsigned` object of an +/// `m.room.member` event. +#[derive(Deserialize)] +struct RoomMemberMembershipUnsigned { + replaces_state: Option, + prev_content: Option, +} + +/// Helper type to extract the membership of the `content` object of an +/// `m.room.member` event. +#[derive(Deserialize)] +struct RoomMemberMembershipContent { + membership: MembershipState, +} diff --git a/src/session/model/session_settings.rs b/src/session/model/session_settings.rs index c6b733a8..d6e6e678 100644 --- a/src/session/model/session_settings.rs +++ b/src/session/model/session_settings.rs @@ -43,6 +43,13 @@ pub(crate) struct StoredSessionSettings { /// Which rooms display media previews for this session. #[serde(default, skip_serializing_if = "ruma::serde::is_default")] media_previews_enabled: MediaPreviewsSetting, + + /// Whether to display avatars in invites. + #[serde( + default = "ruma::serde::default_true", + skip_serializing_if = "ruma::serde::is_true" + )] + invite_avatars_enabled: bool, } impl Default for StoredSessionSettings { @@ -54,6 +61,7 @@ impl Default for StoredSessionSettings { typing_enabled: true, sections_expanded: Default::default(), media_previews_enabled: Default::default(), + invite_avatars_enabled: true, } } } @@ -86,6 +94,9 @@ mod imp { /// Whether typing notifications are enabled for this session. #[property(get = Self::typing_enabled, set = Self::set_typing_enabled, explicit_notify, default = true)] typing_enabled: PhantomData, + /// Whether to display avatars in invites. + #[property(get = Self::invite_avatars_enabled, set = Self::set_invite_avatars_enabled, explicit_notify, default = true)] + invite_avatars_enabled: PhantomData, } #[glib::object_subclass] @@ -153,6 +164,22 @@ mod imp { session_list_settings().save(); self.obj().notify_typing_enabled(); } + + /// Whether to display avatars in invites. + fn invite_avatars_enabled(&self) -> bool { + self.stored_settings.borrow().invite_avatars_enabled + } + + /// Set whether to display avatars in invites. + fn set_invite_avatars_enabled(&self, enabled: bool) { + if self.invite_avatars_enabled() == enabled { + return; + } + + self.stored_settings.borrow_mut().invite_avatars_enabled = enabled; + session_list_settings().save(); + self.obj().notify_invite_avatars_enabled(); + } } } diff --git a/src/session/view/account_settings/safety_page/mod.rs b/src/session/view/account_settings/safety_page/mod.rs index 6670177d..bd461199 100644 --- a/src/session/view/account_settings/safety_page/mod.rs +++ b/src/session/view/account_settings/safety_page/mod.rs @@ -27,6 +27,8 @@ mod imp { typing_row: TemplateChild, #[template_child] ignored_users_row: TemplateChild, + #[template_child] + invite_avatars_row: TemplateChild, /// The current session. #[property(get, set = Self::set_session, nullable)] session: glib::WeakRef, @@ -122,9 +124,21 @@ mod imp { .bidirectional() .sync_create() .build(); + let invite_avatars_binding = session_settings + .bind_property( + "invite-avatars-enabled", + &*self.invite_avatars_row, + "active", + ) + .bidirectional() + .sync_create() + .build(); - self.bindings - .replace(vec![public_read_receipts_binding, typing_binding]); + self.bindings.replace(vec![ + public_read_receipts_binding, + typing_binding, + invite_avatars_binding, + ]); } self.session.set(session); diff --git a/src/session/view/account_settings/safety_page/mod.ui b/src/session/view/account_settings/safety_page/mod.ui index 4288a161..0d8907e4 100644 --- a/src/session/view/account_settings/safety_page/mod.ui +++ b/src/session/view/account_settings/safety_page/mod.ui @@ -62,5 +62,16 @@ + + + + + False + Show Avatars for Invites + Display the avatars of the room and the inviter + + + + diff --git a/src/session/view/content/invite.rs b/src/session/view/content/invite.rs index 0627be4d..d536e671 100644 --- a/src/session/view/content/invite.rs +++ b/src/session/view/content/invite.rs @@ -28,10 +28,14 @@ mod imp { pub room_members: RefCell>, pub accept_requests: RefCell>, pub decline_requests: RefCell>, - pub category_handler: RefCell>, + category_handler: RefCell>, + invite_avatars_handler: RefCell>, + inviter_pill: RefCell>, #[template_child] pub header_bar: TemplateChild, #[template_child] + avatar: TemplateChild, + #[template_child] pub room_alias: TemplateChild, #[template_child] pub room_topic: TemplateChild, @@ -50,9 +54,6 @@ mod imp { type ParentType = adw::Bin; fn class_init(klass: &mut Self::Class) { - Pill::ensure_type(); - Avatar::ensure_type(); - Self::bind_template(klass); klass.set_accessible_role(gtk::AccessibleRole::Group); @@ -105,11 +106,7 @@ mod imp { } fn dispose(&self) { - if let Some(room) = self.room.take() { - if let Some(handler) = self.category_handler.take() { - room.disconnect(handler); - } - } + self.disconnect_signals(); } } @@ -127,6 +124,10 @@ mod imp { if *self.room.borrow() == room { return; } + + self.disconnect_signals(); + self.inviter_pill.take(); + let obj = self.obj(); match &room { @@ -143,12 +144,6 @@ mod imp { _ => obj.reset(), } - if let Some(room) = self.room.take() { - if let Some(handler) = self.category_handler.take() { - room.disconnect(handler); - } - } - if let Some(room) = &room { let category_handler = room.connect_category_notify(clone!( #[weak] @@ -185,18 +180,46 @@ mod imp { )); self.category_handler.replace(Some(category_handler)); - if let Some(inviter) = room.inviter() { - let pill = inviter.to_pill(); - let label = gettext_f( - // Translators: Do NOT translate the content between '{' and '}', these are - // variable names. - "{user_name} ({user_id}) invited you", - &[ - ("user_name", LabelWithWidgets::PLACEHOLDER), - ("user_id", inviter.user_id().as_str()), - ], - ); - self.inviter.set_label_and_widgets(label, vec![pill]); + if let Some(session) = room.session() { + let settings = session.settings(); + + let invite_avatars_handler = + settings.connect_invite_avatars_enabled_notify(clone!( + #[weak(rename_to = imp)] + self, + move |settings| { + let inhibit_images = !settings.invite_avatars_enabled(); + imp.avatar.set_inhibit_image(inhibit_images); + + if let Some(pill) = &*imp.inviter_pill.borrow() { + pill.set_inhibit_image(inhibit_images); + }; + } + )); + self.invite_avatars_handler + .replace(Some(invite_avatars_handler)); + + let inhibit_images = !settings.invite_avatars_enabled(); + self.avatar.set_inhibit_image(inhibit_images); + + if let Some(inviter) = room.inviter() { + let pill = inviter.to_pill(); + pill.set_inhibit_image(inhibit_images); + + let label = gettext_f( + // Translators: Do NOT translate the content between '{' and '}', these + // are variable names. + "{user_name} ({user_id}) invited you", + &[ + ("user_name", LabelWithWidgets::PLACEHOLDER), + ("user_id", inviter.user_id().as_str()), + ], + ); + + self.inviter + .set_label_and_widgets(label, vec![pill.clone()]); + self.inviter_pill.replace(Some(pill)); + } } } @@ -207,6 +230,21 @@ mod imp { obj.notify_room(); } + + /// Disconnect the signal handlers of this view. + fn disconnect_signals(&self) { + if let Some(room) = self.room.take() { + if let Some(handler) = self.category_handler.take() { + room.disconnect(handler); + } + + if let Some(session) = room.session() { + if let Some(handler) = self.invite_avatars_handler.take() { + session.settings().disconnect(handler); + } + } + } + } } } diff --git a/src/session/view/content/invite.ui b/src/session/view/content/invite.ui index 3f8ee4d5..889c877e 100644 --- a/src/session/view/content/invite.ui +++ b/src/session/view/content/invite.ui @@ -39,7 +39,7 @@ Invite - + 150 diff --git a/src/session/view/content/room_details/general_page.rs b/src/session/view/content/room_details/general_page.rs index 02ce3b4a..cecbbf2b 100644 --- a/src/session/view/content/room_details/general_page.rs +++ b/src/session/view/content/room_details/general_page.rs @@ -25,7 +25,7 @@ use tracing::error; use super::{room_upgrade_dialog::confirm_room_upgrade, MemberRow, MembershipLists, RoomDetails}; use crate::{ components::{ - ButtonCountRow, CheckLoadingRow, ComboLoadingRow, CopyableRow, LoadingButton, + Avatar, ButtonCountRow, CheckLoadingRow, ComboLoadingRow, CopyableRow, LoadingButton, SwitchLoadingRow, }, gettext_f, @@ -54,6 +54,8 @@ mod imp { )] #[properties(wrapper_type = super::GeneralPage)] pub struct GeneralPage { + #[template_child] + avatar: TemplateChild, #[template_child] room_topic: TemplateChild, #[template_child] @@ -116,6 +118,7 @@ mod imp { #[property(get)] is_published: Cell, expr_watch: RefCell>, + invite_avatars_handler: RefCell>, notifications_settings_handlers: RefCell>, membership_handler: RefCell>, permissions_handler: RefCell>, @@ -310,22 +313,41 @@ mod imp { imp.update_encryption(); } )), + room.connect_is_invite_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_image(); + } + )), ]; self.room.set(room, room_handler_ids); obj.notify_room(); if let Some(session) = room.session() { - let settings = session.notifications().settings(); + let invite_avatars_handler = session + .settings() + .connect_invite_avatars_enabled_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_image(); + } + )); + self.invite_avatars_handler + .replace(Some(invite_avatars_handler)); + + let notifications_settings = session.notifications().settings(); let notifications_settings_handlers = vec![ - settings.connect_account_enabled_notify(clone!( + notifications_settings.connect_account_enabled_notify(clone!( #[weak(rename_to = imp)] self, move |_| { imp.update_notifications(); } )), - settings.connect_session_enabled_notify(clone!( + notifications_settings.connect_session_enabled_notify(clone!( #[weak(rename_to = imp)] self, move |_| { @@ -338,6 +360,7 @@ mod imp { .replace(notifications_settings_handlers); } + self.update_image(); self.init_edit_details(); self.update_members(); self.update_notifications(); @@ -413,6 +436,20 @@ mod imp { ); } + /// Update the image of the avatar of the room according to the current + /// state. + fn update_image(&self) { + let Some(room) = self.room.obj() else { + return; + }; + let Some(session) = room.session() else { + return; + }; + + let inhibit_image = room.is_invite() && !session.settings().invite_avatars_enabled(); + self.avatar.set_inhibit_image(inhibit_image); + } + /// Initialize the button to edit details. fn init_edit_details(&self) { let Some(room) = self.room.obj() else { @@ -523,9 +560,12 @@ mod imp { fn disconnect_all(&self) { if let Some(room) = self.room.obj() { if let Some(session) = room.session() { - let settings = session.notifications().settings(); + if let Some(handler) = self.invite_avatars_handler.take() { + session.settings().disconnect(handler); + } + for handler in self.notifications_settings_handlers.take() { - settings.disconnect(handler); + session.notifications().settings().disconnect(handler); } } diff --git a/src/session/view/content/room_details/general_page.ui b/src/session/view/content/room_details/general_page.ui index 785a3454..5b6af305 100644 --- a/src/session/view/content/room_details/general_page.ui +++ b/src/session/view/content/room_details/general_page.ui @@ -9,7 +9,7 @@ 12 vertical - + 128 presentation diff --git a/src/session/view/sidebar/room_row.rs b/src/session/view/sidebar/room_row.rs index f5a2b2e9..2a106d36 100644 --- a/src/session/view/sidebar/room_row.rs +++ b/src/session/view/sidebar/room_row.rs @@ -3,6 +3,7 @@ use gtk::{gdk, glib, glib::clone, CompositeTemplate}; use super::SidebarRow; use crate::{ + components::Avatar, i18n::{gettext_f, ngettext_f}, prelude::*, session::model::{HighlightFlags, Room, RoomCategory}, @@ -24,12 +25,15 @@ mod imp { #[property(get, set = Self::set_room, explicit_notify, nullable)] room: BoundObject, #[template_child] + avatar: TemplateChild, + #[template_child] display_name_box: TemplateChild, #[template_child] display_name: TemplateChild, #[template_child] notification_count: TemplateChild, direct_icon: RefCell>, + invite_avatars_handler: RefCell>, } #[glib::object_subclass] @@ -82,6 +86,10 @@ mod imp { )); self.obj().add_controller(drag); } + + fn dispose(&self) { + self.disconnect_signals(); + } } impl WidgetImpl for SidebarRoomRow {} @@ -94,10 +102,23 @@ mod imp { return; } - self.room.disconnect_signals(); - self.display_name.remove_css_class("dimmed"); + self.disconnect_signals(); if let Some(room) = room { + if let Some(session) = room.session() { + let invite_avatars_handler = session + .settings() + .connect_invite_avatars_enabled_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_image(); + } + )); + self.invite_avatars_handler + .replace(Some(invite_avatars_handler)); + } + let highlight_handler = room.connect_highlight_notify(clone!( #[weak(rename_to = imp)] self, @@ -126,10 +147,20 @@ mod imp { imp.update_accessibility_label(); } )); - - if room.category() == RoomCategory::Left { - self.display_name.add_css_class("dimmed"); - } + let category_handler = room.connect_category_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_display_name(); + } + )); + let is_invite_handler = room.connect_is_invite_notify(clone!( + #[weak(rename_to = imp)] + self, + move |_| { + imp.update_image(); + } + )); self.room.set( room, @@ -138,17 +169,48 @@ mod imp { direct_handler, name_handler, notifications_count_handler, + category_handler, + is_invite_handler, ], ); self.update_accessibility_label(); } + self.update_image(); + self.update_display_name(); self.update_highlight(); self.update_direct_icon(); self.obj().notify_room(); } + /// Update the image of the avatar of the room according to the current + /// state. + fn update_image(&self) { + let Some(room) = self.room.obj() else { + return; + }; + let Some(session) = room.session() else { + return; + }; + + let inhibit_image = room.is_invite() && !session.settings().invite_avatars_enabled(); + self.avatar.set_inhibit_image(inhibit_image); + } + + /// Update the display name of the room according to the current state. + fn update_display_name(&self) { + let Some(room) = self.room.obj() else { + return; + }; + + if matches!(room.category(), RoomCategory::Left) { + self.display_name.add_css_class("dimmed"); + } else { + self.display_name.remove_css_class("dimmed"); + } + } + /// Update how this row is highlighted according to the current state. fn update_highlight(&self) { if let Some(room) = self.room.obj() { @@ -284,6 +346,17 @@ mod imp { name } } + + /// Disconnect the signal handlers of this row. + fn disconnect_signals(&self) { + if let Some(session) = self.room.obj().and_then(|room| room.session()) { + if let Some(handler) = self.invite_avatars_handler.take() { + session.settings().disconnect(handler); + } + } + + self.room.disconnect_signals(); + } } } diff --git a/src/utils/matrix/mod.rs b/src/utils/matrix/mod.rs index 894b620a..b43f824b 100644 --- a/src/utils/matrix/mod.rs +++ b/src/utils/matrix/mod.rs @@ -15,14 +15,10 @@ use matrix_sdk::{ AuthSession, Client, ClientBuildError, SessionMeta, SessionTokens, }; use ruma::{ - events::{ - room::{member::MembershipState, message::MessageType}, - AnyMessageLikeEventContent, AnyStrippedStateEvent, AnySyncMessageLikeEvent, - AnySyncTimelineEvent, - }, + events::{AnyStrippedStateEvent, AnySyncTimelineEvent}, html::{ matrix::{AnchorUri, MatrixElement}, - Children, Html, HtmlSanitizerMode, NodeRef, RemoveReplyFallback, StrTendril, + Children, Html, NodeRef, StrTendril, }, matrix_uri::MatrixId, serde::Raw, @@ -37,7 +33,7 @@ pub(crate) mod ext_traits; mod media_message; pub(crate) use self::media_message::*; -use crate::{components::Pill, gettext_f, prelude::*, secret::StoredSession, session::model::Room}; +use crate::{components::Pill, prelude::*, secret::StoredSession, session::model::Room}; /// The result of a password validation. #[derive(Debug, Default, Clone, Copy)] @@ -150,115 +146,6 @@ impl AnySyncOrStrippedTimelineEvent { } } -/// Extract the body from the given event. -/// -/// If the event does not have a body but is supported, this will return a -/// localized string. -/// -/// Returns `None` if the event type is not supported. -pub(crate) fn get_event_body( - event: &AnySyncOrStrippedTimelineEvent, - sender_name: &str, - own_user: &UserId, - show_sender: bool, -) -> Option { - match event { - AnySyncOrStrippedTimelineEvent::Sync(sync_event) => match &**sync_event { - AnySyncTimelineEvent::MessageLike(message) => { - get_message_event_body(message, sender_name, show_sender) - } - AnySyncTimelineEvent::State(_) => None, - }, - AnySyncOrStrippedTimelineEvent::Stripped(state) => { - get_stripped_state_event_body(state, sender_name, own_user) - } - } -} - -/// Extract the body from the given message event. -/// -/// If it's a media message, this will return a localized body. -/// -/// Returns `None` if the message type is not supported. -pub(crate) fn get_message_event_body( - event: &AnySyncMessageLikeEvent, - sender_name: &str, - show_sender: bool, -) -> Option { - match event.original_content()? { - AnyMessageLikeEventContent::RoomMessage(mut message) => { - message.sanitize(HtmlSanitizerMode::Compat, RemoveReplyFallback::Yes); - - let body = match message.msgtype { - MessageType::Audio(_) => { - gettext_f("{user} sent an audio file.", &[("user", sender_name)]) - } - MessageType::Emote(content) => format!("{sender_name} {}", content.body), - MessageType::File(_) => gettext_f("{user} sent a file.", &[("user", sender_name)]), - MessageType::Image(_) => { - gettext_f("{user} sent an image.", &[("user", sender_name)]) - } - MessageType::Location(_) => { - gettext_f("{user} sent their location.", &[("user", sender_name)]) - } - MessageType::Notice(content) => { - text_event_body(content.body, sender_name, show_sender) - } - MessageType::ServerNotice(content) => { - text_event_body(content.body, sender_name, show_sender) - } - MessageType::Text(content) => { - text_event_body(content.body, sender_name, show_sender) - } - MessageType::Video(_) => { - gettext_f("{user} sent a video.", &[("user", sender_name)]) - } - _ => return None, - }; - Some(body) - } - AnyMessageLikeEventContent::Sticker(_) => Some(gettext_f( - "{user} sent a sticker.", - &[("user", sender_name)], - )), - _ => None, - } -} - -fn text_event_body(message: String, sender_name: &str, show_sender: bool) -> String { - if show_sender { - gettext_f( - "{user}: {message}", - &[("user", sender_name), ("message", &message)], - ) - } else { - message - } -} - -/// Extract the body from the given state event. -/// -/// This will return a localized body. -/// -/// Returns `None` if the state event type is not supported. -pub(crate) fn get_stripped_state_event_body( - event: &AnyStrippedStateEvent, - sender_name: &str, - own_user: &UserId, -) -> Option { - if let AnyStrippedStateEvent::RoomMember(member_event) = event { - if member_event.content.membership == MembershipState::Invite - && member_event.state_key == own_user - { - // Translators: Do NOT translate the content between '{' and '}', this is a - // variable name. - return Some(gettext_f("{user} invited you", &[("user", sender_name)])); - } - } - - None -} - /// All errors that can occur when setting up the Matrix client. #[derive(Error, Debug)] pub(crate) enum ClientSetupError {