diff --git a/bindings/matrix-sdk-ffi/src/timeline/content.rs b/bindings/matrix-sdk-ffi/src/timeline/content.rs index dad31e94742..ffe2cecb5b8 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/content.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/content.rs @@ -15,7 +15,7 @@ use std::{collections::HashMap, sync::Arc}; use matrix_sdk::{crypto::types::events::UtdCause, room::power_levels::power_level_user_changes}; -use matrix_sdk_ui::timeline::{PollResult, TimelineDetails}; +use matrix_sdk_ui::timeline::{PollResult, RoomPinnedEventsChange, TimelineDetails}; use ruma::events::room::{message::RoomMessageEventContentWithoutRelation, MediaSource}; use tracing::warn; @@ -338,7 +338,7 @@ pub enum OtherState { RoomHistoryVisibility, RoomJoinRules, RoomName { name: Option }, - RoomPinnedEvents, + RoomPinnedEvents { change: RoomPinnedEventsChange }, RoomPowerLevels { users: HashMap, previous: Option> }, RoomServerAcl, RoomThirdPartyInvite { display_name: Option }, @@ -381,7 +381,7 @@ impl From<&matrix_sdk_ui::timeline::AnyOtherFullStateEventContent> for OtherStat }; Self::RoomName { name } } - Content::RoomPinnedEvents(_) => Self::RoomPinnedEvents, + Content::RoomPinnedEvents(c) => Self::RoomPinnedEvents { change: c.into() }, Content::RoomPowerLevels(c) => match c { FullContent::Original { content, prev_content } => Self::RoomPowerLevels { users: power_level_user_changes(content, prev_content) diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs index d36c33324b1..e533fc61d53 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/mod.rs @@ -58,6 +58,9 @@ use tracing::warn; use crate::timeline::{polls::PollState, TimelineItem}; mod message; +pub(crate) mod pinned_events; + +pub use pinned_events::RoomPinnedEventsChange; pub use self::message::{InReplyToDetails, Message, RepliedToEvent}; diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/content/pinned_events.rs b/crates/matrix-sdk-ui/src/timeline/event_item/content/pinned_events.rs new file mode 100644 index 00000000000..31e1c7c24fa --- /dev/null +++ b/crates/matrix-sdk-ui/src/timeline/event_item/content/pinned_events.rs @@ -0,0 +1,192 @@ +// Copyright 2024 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::collections::HashSet; + +use ruma::{ + events::{room::pinned_events::RoomPinnedEventsEventContent, FullStateEventContent}, + OwnedEventId, +}; + +#[derive(Clone, Debug)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))] +/// The type of change between the previous and current pinned events. +pub enum RoomPinnedEventsChange { + /// Only new event ids were added. + Added, + /// Only event ids were removed. + Removed, + /// Some change other than only adding or only removing ids happened. + Changed, +} + +impl From<&FullStateEventContent> for RoomPinnedEventsChange { + fn from(value: &FullStateEventContent) -> Self { + match value { + FullStateEventContent::Original { content, prev_content } => { + if let Some(prev_content) = prev_content { + let mut new_pinned: HashSet<&OwnedEventId> = + HashSet::from_iter(&content.pinned); + if let Some(old_pinned) = &prev_content.pinned { + let mut still_pinned: HashSet<&OwnedEventId> = + HashSet::from_iter(old_pinned); + + // Newly added elements will be kept in new_pinned, previous ones in + // still_pinned instead + still_pinned.retain(|item| new_pinned.remove(item)); + + let added = !new_pinned.is_empty(); + let removed = still_pinned.len() < old_pinned.len(); + if added && removed { + RoomPinnedEventsChange::Changed + } else if added { + RoomPinnedEventsChange::Added + } else if removed { + RoomPinnedEventsChange::Removed + } else { + // Any other case + RoomPinnedEventsChange::Changed + } + } else { + // We don't know the previous state, so let's assume a generic change + RoomPinnedEventsChange::Changed + } + } else { + // If there is no previous content we can assume the first pinned event id was + // just added + RoomPinnedEventsChange::Added + } + } + FullStateEventContent::Redacted(_) => RoomPinnedEventsChange::Changed, + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use ruma::{ + events::{ + room::pinned_events::{ + PossiblyRedactedRoomPinnedEventsEventContent, RedactedRoomPinnedEventsEventContent, + RoomPinnedEventsEventContent, + }, + FullStateEventContent, + }, + owned_event_id, + serde::Raw, + }; + use serde_json::json; + + use crate::timeline::event_item::content::pinned_events::RoomPinnedEventsChange; + + #[test] + fn redacted_pinned_events_content_has_generic_changes() { + let content = FullStateEventContent::Redacted(RedactedRoomPinnedEventsEventContent::new()); + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Changed); + } + + #[test] + fn pinned_events_content_with_no_prev_content_returns_added() { + let content = FullStateEventContent::Original { + content: RoomPinnedEventsEventContent::new(vec![owned_event_id!("$1")]), + prev_content: None, + }; + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Added); + } + + #[test] + fn pinned_events_content_with_added_ids_returns_added() { + // This is the only way I found to create the PossiblyRedacted content + let prev_content = possibly_redacted_content(Vec::new()); + let content = FullStateEventContent::Original { + content: RoomPinnedEventsEventContent::new(vec![owned_event_id!("$1")]), + prev_content, + }; + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Added); + } + + #[test] + fn pinned_events_content_with_removed_ids_returns_removed() { + // This is the only way I found to create the PossiblyRedacted content + let prev_content = possibly_redacted_content(vec!["$1"]); + let content = FullStateEventContent::Original { + content: RoomPinnedEventsEventContent::new(Vec::new()), + prev_content, + }; + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Removed); + } + + #[test] + fn pinned_events_content_with_added_and_removed_ids_returns_changed() { + // This is the only way I found to create the PossiblyRedacted content + let prev_content = possibly_redacted_content(vec!["$1"]); + let content = FullStateEventContent::Original { + content: RoomPinnedEventsEventContent::new(vec![owned_event_id!("$2")]), + prev_content, + }; + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Changed); + } + + #[test] + fn pinned_events_content_with_changed_order_returns_changed() { + // This is the only way I found to create the PossiblyRedacted content + let prev_content = possibly_redacted_content(vec!["$1", "$2"]); + let content = FullStateEventContent::Original { + content: RoomPinnedEventsEventContent::new(vec![ + owned_event_id!("$2"), + owned_event_id!("$1"), + ]), + prev_content, + }; + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Changed); + } + + #[test] + fn pinned_events_content_with_no_changes_returns_changed() { + // Returning Changed is counter-intuitive, but it makes no sense to display in + // the timeline 'UserFoo didn't change anything in the pinned events' + + // This is the only way I found to create the PossiblyRedacted content + let prev_content = possibly_redacted_content(vec!["$1", "$2"]); + let content = FullStateEventContent::Original { + content: RoomPinnedEventsEventContent::new(vec![ + owned_event_id!("$1"), + owned_event_id!("$2"), + ]), + prev_content, + }; + let ret: RoomPinnedEventsChange = (&content).into(); + assert_matches!(ret, RoomPinnedEventsChange::Changed); + } + + fn possibly_redacted_content( + ids: Vec<&str>, + ) -> Option { + // This is the only way I found to create the PossiblyRedacted content + Raw::new(&json!({ + "pinned": ids, + })) + .unwrap() + .cast::() + .deserialize() + .ok() + } +} diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index 7a024398bc0..acd1f8ba531 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -41,8 +41,8 @@ mod remote; pub use self::{ content::{ AnyOtherFullStateEventContent, EncryptedMessage, InReplyToDetails, MemberProfileChange, - MembershipChange, Message, OtherState, RepliedToEvent, RoomMembershipChange, Sticker, - TimelineItemContent, + MembershipChange, Message, OtherState, RepliedToEvent, RoomMembershipChange, + RoomPinnedEventsChange, Sticker, TimelineItemContent, }, local::EventSendState, }; diff --git a/crates/matrix-sdk-ui/src/timeline/mod.rs b/crates/matrix-sdk-ui/src/timeline/mod.rs index e26f8308272..568df1857a2 100644 --- a/crates/matrix-sdk-ui/src/timeline/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/mod.rs @@ -94,7 +94,8 @@ pub use self::{ AnyOtherFullStateEventContent, EncryptedMessage, EventItemOrigin, EventSendState, EventTimelineItem, InReplyToDetails, MemberProfileChange, MembershipChange, Message, OtherState, Profile, ReactionInfo, ReactionsByKeyBySender, RepliedToEvent, - RoomMembershipChange, Sticker, TimelineDetails, TimelineEventItemId, TimelineItemContent, + RoomMembershipChange, RoomPinnedEventsChange, Sticker, TimelineDetails, + TimelineEventItemId, TimelineItemContent, }, event_type_filter::TimelineEventTypeFilter, inner::default_event_filter,