| 1 | use ruma::{ |
| 2 | OwnedEventId, UserId, |
| 3 | events::{ |
| 4 | poll::{start::PollStartEventContent, unstable_start::UnstablePollStartEventContent}, |
| 5 | room::{ |
| 6 | member::{MembershipChange as RumaMembershipChange, OriginalSyncRoomMemberEvent}, |
| 7 | message::{ |
| 8 | MessageType, Relation, RoomMessageEventContent, |
| 9 | RoomMessageEventContentWithoutRelation, |
| 10 | }, |
| 11 | }, |
| 12 | sticker::StickerEventContent, |
| 13 | }, |
| 14 | }; |
| 15 | use serde_json::Value; |
| 16 | |
| 17 | use super::{ |
| 18 | link_preview::sender_previews, |
| 19 | media::{ |
| 20 | MediaRegistration, media_descriptor_for_sticker, media_registration, |
| 21 | media_sticker_registration, |
| 22 | }, |
| 23 | timeline::formatted_body, |
| 24 | }; |
| 25 | use crate::{ |
| 26 | Change, MediaId, MembershipChange, Message, MessageMentions, ReplyDetails, ReplyReference, |
| 27 | SendState, Sender, TimelineContent, TimelineEvent, TimelineItem, TimelineItemId, UtdCause, |
| 28 | }; |
| 29 | |
| 30 | pub(crate) fn normalise_observed( |
| 31 | value: &Value, |
| 32 | current_user_id: Option<&UserId>, |
| 33 | identity: &str, |
| 34 | ) -> Option<(TimelineItem, Vec<MediaRegistration>)> { |
| 35 | let event_id: OwnedEventId = serde_json::from_value(value.get("event_id")?.clone()).ok()?; |
| 36 | let sender_id: ruma::OwnedUserId = serde_json::from_value(value.get("sender")?.clone()).ok()?; |
| 37 | let timestamp_ms = value.get("origin_server_ts")?.as_u64()?; |
| 38 | let event_type = value.get("type")?.as_str()?.to_owned(); |
| 39 | let own = current_user_id.is_some_and(|user_id| user_id.as_str() == sender_id.as_str()); |
| 40 | let redacted = value |
| 41 | .get("unsigned") |
| 42 | .and_then(|unsigned| unsigned.get("redacted_because")) |
| 43 | .is_some(); |
| 44 | let mut media = Vec::new(); |
| 45 | let (content, reply_to, thread_root) = if redacted { |
| 46 | (TimelineContent::Redacted, None, None) |
| 47 | } else if event_type == "m.room.message" { |
| 48 | match value |
| 49 | .get("content") |
| 50 | .cloned() |
| 51 | .and_then(|content| serde_json::from_value::<RoomMessageEventContent>(content).ok()) |
| 52 | { |
| 53 | Some(mut message) => { |
| 54 | let (reply_to, thread_root) = relation_refs(message.relates_to.as_ref()); |
| 55 | let relation_replacement = match &message.relates_to { |
| 56 | Some(Relation::Replacement(replacement)) => { |
| 57 | Some(replacement.new_content.clone()) |
| 58 | } |
| 59 | _ => None, |
| 60 | }; |
| 61 | let bundled_replacement = value |
| 62 | .get("unsigned") |
| 63 | .and_then(|unsigned| unsigned.get("m.relations")) |
| 64 | .and_then(|relations| relations.get("m.replace")) |
| 65 | .and_then(|edit| { |
| 66 | let edit_sender = edit.get("sender")?.as_str()?; |
| 67 | if edit_sender != sender_id.as_str() { |
| 68 | return None; |
| 69 | } |
| 70 | edit.get("content")?.get("m.new_content").cloned() |
| 71 | }) |
| 72 | .and_then(|content| { |
| 73 | serde_json::from_value::<RoomMessageEventContentWithoutRelation>(content) |
| 74 | .ok() |
| 75 | }); |
| 76 | let edited = relation_replacement.is_some() |
| 77 | || bundled_replacement.is_some() |
| 78 | || value |
| 79 | .get("unsigned") |
| 80 | .and_then(|unsigned| unsigned.get("macltok_edited")) |
| 81 | .and_then(Value::as_bool) |
| 82 | .unwrap_or(false); |
| 83 | if let Some(replacement) = relation_replacement.or(bundled_replacement) { |
| 84 | message.apply_replacement(replacement); |
| 85 | } |
| 86 | let message_type = &message.msgtype; |
| 87 | media.extend(media_registration(identity, message_type)); |
| 88 | let previews = sender_previews(Some(identity), message_type); |
| 89 | media.extend( |
| 90 | previews |
| 91 | .iter() |
| 92 | .filter_map(|(_, registration)| registration.clone()), |
| 93 | ); |
| 94 | ( |
| 95 | TimelineContent::Message(Message { |
| 96 | kind: super::message_kind(message_type), |
| 97 | body: message.body().to_owned(), |
| 98 | geo_uri: match message_type { |
| 99 | MessageType::Location(location) => Some(location.geo_uri.clone()), |
| 100 | _ => None, |
| 101 | }, |
| 102 | formatted_body: formatted_body(message_type), |
| 103 | mentions: message.mentions.as_ref().map(|mentions| MessageMentions { |
| 104 | user_ids: mentions.user_ids.iter().cloned().collect(), |
| 105 | room: mentions.room, |
| 106 | }), |
| 107 | edited, |
| 108 | media: super::media::media_from_message(identity, message_type), |
| 109 | link_previews: previews.into_iter().map(|(preview, _)| preview).collect(), |
| 110 | }), |
| 111 | reply_to, |
| 112 | thread_root, |
| 113 | ) |
| 114 | } |
| 115 | None => (TimelineContent::Other { event_type }, None, None), |
| 116 | } |
| 117 | } else if event_type == "m.room.encrypted" { |
| 118 | ( |
| 119 | TimelineContent::UnableToDecrypt(UtdCause::Unknown), |
| 120 | None, |
| 121 | None, |
| 122 | ) |
| 123 | } else if event_type == "m.sticker" { |
| 124 | match value |
| 125 | .get("content") |
| 126 | .cloned() |
| 127 | .and_then(|content| serde_json::from_value::<StickerEventContent>(content).ok()) |
| 128 | { |
| 129 | Some(sticker) => { |
| 130 | let (reply_to, thread_root) = relation_refs(sticker.relates_to.as_ref()); |
| 131 | media.extend(media_sticker_registration(identity, &sticker)); |
| 132 | ( |
| 133 | TimelineContent::Sticker { |
| 134 | body: sticker.body.clone(), |
| 135 | media: media_descriptor_for_sticker(identity, &sticker), |
| 136 | }, |
| 137 | reply_to, |
| 138 | thread_root, |
| 139 | ) |
| 140 | } |
| 141 | None => (TimelineContent::Other { event_type }, None, None), |
| 142 | } |
| 143 | } else if event_type == "m.poll.start" || event_type == "org.matrix.msc3381.poll.start" { |
| 144 | let poll_content = value.get("content"); |
| 145 | let question = poll_content.and_then(|content| poll_question(content, &event_type)); |
| 146 | match question { |
| 147 | Some(question) => { |
| 148 | let (reply_to, thread_root) = |
| 149 | poll_content.map(raw_relation_refs).unwrap_or_default(); |
| 150 | (TimelineContent::Poll { question }, reply_to, thread_root) |
| 151 | } |
| 152 | None => (TimelineContent::Other { event_type }, None, None), |
| 153 | } |
| 154 | } else if let Some(state_key) = value.get("state_key").and_then(Value::as_str) { |
| 155 | let content = state_content(value, &event_type).unwrap_or(TimelineContent::OtherState { |
| 156 | event_type, |
| 157 | state_key: state_key.to_owned(), |
| 158 | }); |
| 159 | (content, None, None) |
| 160 | } else { |
| 161 | (TimelineContent::Other { event_type }, None, None) |
| 162 | }; |
| 163 | let event = TimelineEvent { |
| 164 | item_id: TimelineItemId::new(identity.to_owned()), |
| 165 | event_id: Some(event_id), |
| 166 | transaction_id: None, |
| 167 | sender: Sender { |
| 168 | user_id: sender_id, |
| 169 | display_name: None, |
| 170 | avatar: None, |
| 171 | name_ambiguous: false, |
| 172 | }, |
| 173 | timestamp_ms, |
| 174 | own, |
| 175 | send_state: SendState::Sent, |
| 176 | content, |
| 177 | reply_to: reply_to.map(|event_id| ReplyReference { |
| 178 | event_id, |
| 179 | details: ReplyDetails::Pending, |
| 180 | }), |
| 181 | thread_root, |
| 182 | thread_summary: None, |
| 183 | reactions: Vec::new(), |
| 184 | read_by: Vec::new(), |
| 185 | editable: false, |
| 186 | highlight: false, |
| 187 | }; |
| 188 | Some((TimelineItem::Event(Box::new(event)), media)) |
| 189 | } |
| 190 | |
| 191 | fn relation_refs<C>( |
| 192 | relation: Option<&Relation<C>>, |
| 193 | ) -> (Option<OwnedEventId>, Option<OwnedEventId>) { |
| 194 | match relation { |
| 195 | Some(Relation::Reply(reply)) => (Some(reply.in_reply_to.event_id.clone()), None), |
| 196 | Some(Relation::Thread(thread)) => ( |
| 197 | thread |
| 198 | .in_reply_to |
| 199 | .as_ref() |
| 200 | .map(|reply| reply.event_id.clone()), |
| 201 | Some(thread.event_id.clone()), |
| 202 | ), |
| 203 | _ => (None, None), |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | fn raw_relation_refs(content: &Value) -> (Option<OwnedEventId>, Option<OwnedEventId>) { |
| 208 | let Some(relation) = content.get("m.relates_to") else { |
| 209 | return (None, None); |
| 210 | }; |
| 211 | let reply_to = relation |
| 212 | .get("m.in_reply_to") |
| 213 | .and_then(|reply| reply.get("event_id")) |
| 214 | .and_then(Value::as_str) |
| 215 | .and_then(|event_id| event_id.parse().ok()); |
| 216 | let thread_root = (relation.get("rel_type").and_then(Value::as_str) == Some("m.thread")) |
| 217 | .then(|| relation.get("event_id").and_then(Value::as_str)) |
| 218 | .flatten() |
| 219 | .and_then(|event_id| event_id.parse().ok()); |
| 220 | (reply_to, thread_root) |
| 221 | } |
| 222 | |
| 223 | pub(crate) fn poll_question(content: &Value, event_type: &str) -> Option<String> { |
| 224 | let typed_question = match event_type { |
| 225 | "m.poll.start" => serde_json::from_value::<PollStartEventContent>(content.clone()) |
| 226 | .ok() |
| 227 | .and_then(|poll| poll.poll.question.text.find_plain().map(str::to_owned)), |
| 228 | "org.matrix.msc3381.poll.start" => { |
| 229 | serde_json::from_value::<UnstablePollStartEventContent>(content.clone()) |
| 230 | .ok() |
| 231 | .map(|poll| poll.poll_start().question.text.clone()) |
| 232 | } |
| 233 | _ => None, |
| 234 | }; |
| 235 | typed_question |
| 236 | .or_else(|| { |
| 237 | content |
| 238 | .get("m.poll") |
| 239 | .and_then(|poll| poll.get("question")) |
| 240 | .and_then(|question| question.get("m.text")) |
| 241 | .and_then(generic_text) |
| 242 | }) |
| 243 | .or_else(|| { |
| 244 | content |
| 245 | .get("org.matrix.msc3381.poll.start") |
| 246 | .and_then(|poll| poll.get("question")) |
| 247 | .and_then(|question| question.get("org.matrix.msc1767.text")) |
| 248 | .and_then(generic_text) |
| 249 | }) |
| 250 | .or_else(|| { |
| 251 | ["org.matrix.msc1767.text", "m.text", "question", "body"] |
| 252 | .iter() |
| 253 | .find_map(|key| content.get(*key).and_then(generic_text)) |
| 254 | }) |
| 255 | } |
| 256 | |
| 257 | fn generic_text(value: &Value) -> Option<String> { |
| 258 | match value { |
| 259 | Value::String(text) => Some(text.clone()), |
| 260 | Value::Array(representations) => representations |
| 261 | .iter() |
| 262 | .find(|representation| { |
| 263 | representation.get("mimetype").and_then(Value::as_str) == Some("text/plain") |
| 264 | }) |
| 265 | .or_else(|| representations.first()) |
| 266 | .and_then(|representation| representation.get("body")) |
| 267 | .and_then(Value::as_str) |
| 268 | .map(str::to_owned), |
| 269 | Value::Object(_) => value.get("body").and_then(Value::as_str).map(str::to_owned), |
| 270 | _ => None, |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | /// the typed state rows the live timeline gives these events |
| 275 | fn state_content(value: &Value, event_type: &str) -> Option<TimelineContent> { |
| 276 | let content = value.get("content")?; |
| 277 | let text = |key: &str| { |
| 278 | content |
| 279 | .get(key) |
| 280 | .and_then(Value::as_str) |
| 281 | .filter(|text| !text.is_empty()) |
| 282 | .map(str::to_owned) |
| 283 | }; |
| 284 | Some(match event_type { |
| 285 | "m.room.member" => { |
| 286 | let event: OriginalSyncRoomMemberEvent = serde_json::from_value(value.clone()).ok()?; |
| 287 | let user_id = event.state_key.clone(); |
| 288 | match event.membership_change() { |
| 289 | RumaMembershipChange::ProfileChanged { |
| 290 | displayname_change, |
| 291 | avatar_url_change, |
| 292 | } => TimelineContent::Profile { |
| 293 | user_id, |
| 294 | display_name: displayname_change.map(|change| Change { |
| 295 | old: change.old.map(str::to_owned), |
| 296 | new: change.new.map(str::to_owned), |
| 297 | }), |
| 298 | avatar: avatar_url_change.map(|change| Change { |
| 299 | old: change.old.map(MediaId::plain), |
| 300 | new: change.new.map(MediaId::plain), |
| 301 | }), |
| 302 | }, |
| 303 | change => TimelineContent::Membership { |
| 304 | user_id, |
| 305 | display_name: event.content.displayname.clone(), |
| 306 | change: membership_change(&change), |
| 307 | }, |
| 308 | } |
| 309 | } |
| 310 | "m.room.name" => TimelineContent::RoomName(text("name")), |
| 311 | "m.room.topic" => TimelineContent::RoomTopic(text("topic")), |
| 312 | "m.room.avatar" => TimelineContent::RoomAvatar(text("url").map(|url| MediaId::plain(&url))), |
| 313 | "m.room.encryption" => TimelineContent::EncryptionEnabled, |
| 314 | "m.room.create" => TimelineContent::RoomCreated, |
| 315 | "m.room.tombstone" => TimelineContent::RoomUpgraded { |
| 316 | successor: serde_json::from_value(content.get("replacement_room")?.clone()).ok()?, |
| 317 | }, |
| 318 | _ => return None, |
| 319 | }) |
| 320 | } |
| 321 | |
| 322 | const fn membership_change(change: &RumaMembershipChange<'_>) -> MembershipChange { |
| 323 | match change { |
| 324 | RumaMembershipChange::Joined => MembershipChange::Joined, |
| 325 | RumaMembershipChange::Left => MembershipChange::Left, |
| 326 | RumaMembershipChange::Banned => MembershipChange::Banned, |
| 327 | RumaMembershipChange::Unbanned => MembershipChange::Unbanned, |
| 328 | RumaMembershipChange::Kicked => MembershipChange::Kicked, |
| 329 | RumaMembershipChange::KickedAndBanned => MembershipChange::KickedAndBanned, |
| 330 | RumaMembershipChange::Invited => MembershipChange::Invited, |
| 331 | RumaMembershipChange::InvitationAccepted => MembershipChange::InvitationAccepted, |
| 332 | RumaMembershipChange::InvitationRejected => MembershipChange::InvitationRejected, |
| 333 | RumaMembershipChange::InvitationRevoked => MembershipChange::InvitationRevoked, |
| 334 | RumaMembershipChange::Knocked => MembershipChange::Knocked, |
| 335 | RumaMembershipChange::KnockAccepted => MembershipChange::KnockAccepted, |
| 336 | RumaMembershipChange::KnockRetracted => MembershipChange::KnockRetracted, |
| 337 | RumaMembershipChange::KnockDenied => MembershipChange::KnockDenied, |
| 338 | _ => MembershipChange::Other, |
| 339 | } |
| 340 | } |