Browse Source

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.
fractal-12
Kévin Commaille 10 months ago
parent
commit
b84a584995
No known key found for this signature in database
GPG Key ID: F26F4BE20A08255B
  1. 36
      src/components/avatar/data.rs
  2. 33
      src/components/avatar/mod.rs
  3. 20
      src/components/pill/mod.rs
  4. 154
      src/session/model/notifications/mod.rs
  5. 191
      src/session/model/room/mod.rs
  6. 27
      src/session/model/session_settings.rs
  7. 18
      src/session/view/account_settings/safety_page/mod.rs
  8. 11
      src/session/view/account_settings/safety_page/mod.ui
  9. 92
      src/session/view/content/invite.rs
  10. 2
      src/session/view/content/invite.ui
  11. 52
      src/session/view/content/room_details/general_page.rs
  12. 2
      src/session/view/content/room_details/general_page.ui
  13. 85
      src/session/view/sidebar/room_row.rs
  14. 119
      src/utils/matrix/mod.rs

36
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<gdk::Texture> {
pub(crate) async fn as_notification_icon(&self, inhibit_image: bool) -> Option<gdk::Texture> {
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}");
}
}
}
}

33
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<i32>,
/// 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<bool>,
paintable_ref: RefCell<Option<CountedRef>>,
paintable_animation_ref: RefCell<Option<CountedRef>>,
}
@ -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<AvatarData>) {
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;
}

20
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<bool>,
/// 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<bool>,
gesture_click: RefCell<Option<gtk::GestureClick>>,
}
@ -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.

154
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<String> {
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<String> {
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
}
}

191
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<Member>,
/// Whether this room is a current invite or an invite that was declined
/// or retracted.
#[property(get)]
is_invite: Cell<bool>,
/// 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::<RoomMemberEventContent, _>(
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::<RoomMemberMembershipEvent>()
}
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::<RoomMemberMembershipEvent>()
{
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<RoomMemberMembershipUnsigned>,
}
/// Helper type to extract the membership of the `unsigned` object of an
/// `m.room.member` event.
#[derive(Deserialize)]
struct RoomMemberMembershipUnsigned {
replaces_state: Option<OwnedEventId>,
prev_content: Option<RoomMemberMembershipContent>,
}
/// Helper type to extract the membership of the `content` object of an
/// `m.room.member` event.
#[derive(Deserialize)]
struct RoomMemberMembershipContent {
membership: MembershipState,
}

27
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<bool>,
/// 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<bool>,
}
#[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();
}
}
}

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

@ -27,6 +27,8 @@ mod imp {
typing_row: TemplateChild<adw::SwitchRow>,
#[template_child]
ignored_users_row: TemplateChild<ButtonCountRow>,
#[template_child]
invite_avatars_row: TemplateChild<adw::SwitchRow>,
/// The current session.
#[property(get, set = Self::set_session, nullable)]
session: glib::WeakRef<Session>,
@ -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);

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

@ -62,5 +62,16 @@
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwSwitchRow" id="invite_avatars_row">
<property name="selectable">False</property>
<property name="title" translatable="yes">Show Avatars for Invites</property>
<property name="subtitle" translatable="yes">Display the avatars of the room and the inviter</property>
</object>
</child>
</object>
</child>
</template>
</interface>

92
src/session/view/content/invite.rs

@ -28,10 +28,14 @@ mod imp {
pub room_members: RefCell<Option<MemberList>>,
pub accept_requests: RefCell<HashSet<Room>>,
pub decline_requests: RefCell<HashSet<Room>>,
pub category_handler: RefCell<Option<glib::SignalHandlerId>>,
category_handler: RefCell<Option<glib::SignalHandlerId>>,
invite_avatars_handler: RefCell<Option<glib::SignalHandlerId>>,
inviter_pill: RefCell<Option<Pill>>,
#[template_child]
pub header_bar: TemplateChild<adw::HeaderBar>,
#[template_child]
avatar: TemplateChild<Avatar>,
#[template_child]
pub room_alias: TemplateChild<gtk::Label>,
#[template_child]
pub room_topic: TemplateChild<gtk::Label>,
@ -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);
}
}
}
}
}
}

2
src/session/view/content/invite.ui

@ -39,7 +39,7 @@
<property name="label" translatable="yes">Invite</property>
</accessibility>
<child>
<object class="Avatar">
<object class="Avatar" id="avatar">
<property name="size">150</property>
<binding name="data">
<lookup name="avatar-data">

52
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<Avatar>,
#[template_child]
room_topic: TemplateChild<gtk::Label>,
#[template_child]
@ -116,6 +118,7 @@ mod imp {
#[property(get)]
is_published: Cell<bool>,
expr_watch: RefCell<Option<gtk::ExpressionWatch>>,
invite_avatars_handler: RefCell<Option<glib::SignalHandlerId>>,
notifications_settings_handlers: RefCell<Vec<glib::SignalHandlerId>>,
membership_handler: RefCell<Option<glib::SignalHandlerId>>,
permissions_handler: RefCell<Option<glib::SignalHandlerId>>,
@ -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);
}
}

2
src/session/view/content/room_details/general_page.ui

@ -9,7 +9,7 @@
<property name="spacing">12</property>
<property name="orientation">vertical</property>
<child>
<object class="Avatar">
<object class="Avatar" id="avatar">
<property name="size">128</property>
<property name="accessible-role">presentation</property>
<binding name="data">

85
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<Room>,
#[template_child]
avatar: TemplateChild<Avatar>,
#[template_child]
display_name_box: TemplateChild<gtk::Box>,
#[template_child]
display_name: TemplateChild<gtk::Label>,
#[template_child]
notification_count: TemplateChild<gtk::Label>,
direct_icon: RefCell<Option<gtk::Image>>,
invite_avatars_handler: RefCell<Option<glib::SignalHandlerId>>,
}
#[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();
}
}
}

119
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<String> {
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<String> {
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<String> {
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 {

Loading…
Cancel
Save