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 {