diff --git a/examples/login.rs b/examples/login.rs index a623016d..561795e6 100644 --- a/examples/login.rs +++ b/examples/login.rs @@ -25,10 +25,14 @@ async fn async_cb(room: Arc>, event: Arc>) { .. }) = event { - let user = room.members.get(&sender.to_string()).unwrap(); + let member = room.members.get(&sender.to_string()).unwrap(); println!( "{}: {}", - user.display_name.as_ref().unwrap_or(&sender.to_string()), + member + .user + .display_name + .as_ref() + .unwrap_or(&sender.to_string()), msg_body ); } diff --git a/src/async_client.rs b/src/async_client.rs index 89e09390..43e2c4f3 100644 --- a/src/async_client.rs +++ b/src/async_client.rs @@ -30,6 +30,7 @@ use url::Url; use ruma_api::{Endpoint, Outgoing}; use ruma_events::collections::all::RoomEvent; +use ruma_events::presence::PresenceEvent; use ruma_events::room::message::MessageEventContent; use ruma_events::EventResult; pub use ruma_events::EventType; @@ -37,7 +38,7 @@ use ruma_identifiers::RoomId; use crate::api; use crate::base_client::Client as BaseClient; -use crate::base_client::Room; +use crate::models::Room; use crate::session::Session; use crate::VERSION; use crate::{Error, Result}; @@ -46,6 +47,11 @@ type RoomEventCallback = Box< dyn FnMut(Arc>, Arc>) -> BoxFuture<'static, ()> + Send, >; +type PresenceEventCallback = Box< + dyn FnMut(Arc>, Arc>) -> BoxFuture<'static, ()> + + Send, +>; + const DEFAULT_SYNC_TIMEOUT: Duration = Duration::from_secs(30); #[derive(Clone)] @@ -56,11 +62,12 @@ pub struct AsyncClient { /// The underlying HTTP client. http_client: reqwest::Client, /// User session data. - base_client: Arc>, + pub(crate) base_client: Arc>, /// The transaction id. transaction_id: Arc, /// Event callbacks event_callbacks: Arc>>, + presence_callbacks: Arc>>, } impl std::fmt::Debug for AsyncClient { @@ -245,6 +252,7 @@ impl AsyncClient { base_client: Arc::new(RwLock::new(BaseClient::new(session)?)), transaction_id: Arc::new(AtomicU64::new(0)), event_callbacks: Arc::new(Mutex::new(Vec::new())), + presence_callbacks: Arc::new(Mutex::new(Vec::new())), }) } @@ -260,6 +268,21 @@ impl AsyncClient { &self.homeserver } + /// Calculate the room name from a `RoomId`, returning a string. + pub async fn get_room_name(&self, room_id: &str) -> Option { + self.base_client.read().await.calculate_room_name(room_id) + } + + /// Calculate the room names this client knows about. + pub async fn get_room_names(&self) -> Vec { + self.base_client.read().await.calculate_room_names() + } + + /// Calculate the room names this client knows about. + pub async fn current_room_id(&self) -> Option { + self.base_client.read().await.current_room_id() + } + /// Add a callback that will be called every time the client receives a room /// event /// @@ -293,10 +316,10 @@ impl AsyncClient { /// .. /// }) = event /// { - /// let user = room.members.get(&sender.to_string()).unwrap(); + /// let member = room.members.get(&sender.to_string()).unwrap(); /// println!( /// "{}: {}", - /// user.display_name.as_ref().unwrap_or(&sender.to_string()), + /// member.user.display_name.as_ref().unwrap_or(&sender.to_string()), /// msg_body /// ); /// } @@ -323,6 +346,78 @@ impl AsyncClient { futures.push(Box::new(future)); } + /// Add a callback that will be called every time the client receives a presence + /// event + /// + /// # Arguments + /// + /// * `callback` - The callback that should be called once a RoomEvent is + /// received. + /// + /// # Examples + /// ``` + /// # use matrix_sdk::events::{ + /// # collections::all::RoomEvent, + /// # room::message::{MessageEvent, MessageEventContent, TextMessageEventContent}, + /// # presence::{PresenceEvent, PresenceEventContent}, + /// # EventResult, + /// # }; + /// # use matrix_sdk::Room; + /// # use std::sync::{Arc, RwLock}; + /// # use matrix_sdk::AsyncClient; + /// # use url::Url; + /// + /// async fn async_cb(room: Arc>, event: Arc>) { + /// let room = room.read().unwrap(); + /// let event = if let EventResult::Ok(event) = &*event { + /// event + /// } else { + /// return; + /// }; + /// let PresenceEvent { + /// content: PresenceEventContent { + /// avatar_url, + /// currently_active, + /// displayname, + /// last_active_ago, + /// presence, + /// status_msg, + /// }, + /// sender, + /// } = event; + /// { + /// let member = room.members.get(&sender.to_string()).unwrap(); + /// println!( + /// "{} is {}", + /// displayname.as_deref().unwrap_or(&sender.to_string()), + /// status_msg.as_deref().unwrap_or("not here") + /// ); + /// } + /// } + /// # fn main() -> Result<(), matrix_sdk::Error> { + /// let homeserver = Url::parse("http://localhost:8080")?; + /// + /// let mut client = AsyncClient::new(homeserver, None)?; + /// + /// client.add_presence_callback(async_cb); + /// # Ok(()) + /// # } + /// ``` + pub fn add_presence_callback( + &mut self, + mut callback: impl FnMut(Arc>, Arc>) -> C + + 'static + + Send, + ) where + C: Future + Send, + { + let mut futures = self.presence_callbacks.lock().unwrap(); + + let future = move |room, event| callback(room, event).boxed(); + + futures.push(Box::new(future)); + } + /// Login to the server. /// /// # Arguments @@ -332,9 +427,9 @@ impl AsyncClient { /// * `password` - The password of the user. /// /// * `device_id` - A unique id that will be associated with this session. If - /// not given the homeserver will create one. Can be an exising + /// not given the homeserver will create one. Can be an existing /// device_id from a previous login call. Note that this should be done - /// only if the client also holds the encryption keys for this devcie. + /// only if the client also holds the encryption keys for this device. #[instrument(skip(password))] pub async fn login + std::fmt::Debug>( &mut self, @@ -361,7 +456,7 @@ impl AsyncClient { Ok(response) } - /// Synchronise the client's state with the latest state on the server. + /// Synchronize the client's state with the latest state on the server. /// /// # Arguments /// @@ -420,7 +515,7 @@ impl AsyncClient { let mut callbacks = Vec::new(); for cb in &mut cb_futures.iter_mut() { - callbacks.push(cb(matrix_room.clone(), event.clone())); + callbacks.push(cb(matrix_room.clone(), Arc::clone(&event))); } callbacks @@ -430,6 +525,45 @@ impl AsyncClient { cb.await; } } + + // look at AccountData to further cut down users by collecting ignored users + // TODO actually use the ignored users + for account_data in &room.account_data.events { + let mut client = self.base_client.write().await; + if let EventResult::Ok(e) = account_data { + client.receive_account_data(&room_id_string, e); + } + } + + // TODO `IncomingEphemeral` events for typing events + + // After the room has been created and state/timeline events accounted for we use the room_id of the newly created + // room to add any presence events that relate to a user in the current room. This is not super + // efficient but we need a room_id so we would loop through now or later. + for presence in &response.presence.events { + let mut client = self.base_client.write().await; + if let EventResult::Ok(e) = presence { + client.receive_presence_event(&room_id_string, e); + } + + let callbacks = { + let mut cb_futures = self.presence_callbacks.lock().unwrap(); + let event = if !cb_futures.is_empty() { + Arc::new(presence.clone()) + } else { + continue; + }; + let mut callbacks = Vec::new(); + for cb in &mut cb_futures.iter_mut() { + callbacks.push(cb(matrix_room.clone(), Arc::clone(&event))); + } + + callbacks + }; + for cb in callbacks { + cb.await; + } + } } let mut client = self.base_client.write().await; diff --git a/src/base_client.rs b/src/base_client.rs index 8d3a0fae..b910d53d 100644 --- a/src/base_client.rs +++ b/src/base_client.rs @@ -14,6 +14,7 @@ // limitations under the License. use std::collections::HashMap; +use std::convert::TryFrom; #[cfg(feature = "encryption")] use std::result::Result as StdResult; @@ -21,11 +22,25 @@ use std::result::Result as StdResult; use crate::api::r0 as api; use crate::error::Result; use crate::events::collections::all::{RoomEvent, StateEvent}; -use crate::events::room::member::{MemberEvent, MembershipState}; +use crate::events::presence::PresenceEvent; +// `NonRoomEvent` is what it is aliased as +use crate::events::collections::only::Event as NonRoomEvent; +use crate::events::ignored_user_list::IgnoredUserListEvent; +use crate::events::push_rules::{PushRulesEvent, Ruleset}; +use crate::events::room::{ + aliases::AliasesEvent, + canonical_alias::CanonicalAliasEvent, + member::{MemberEvent, MembershipState}, + name::NameEvent, +}; use crate::events::EventResult; +use crate::identifiers::{RoomAliasId, UserId as Uid}; +use crate::models::Room; use crate::session::Session; use std::sync::{Arc, RwLock}; +use js_int::UInt; + #[cfg(feature = "encryption")] use tokio::sync::Mutex; @@ -38,144 +53,41 @@ use ruma_identifiers::RoomId; pub type Token = String; pub type UserId = String; -#[derive(Debug)] -/// A Matrix room member. -pub struct RoomMember { - /// The unique mxid of the user. - pub user_id: UserId, - /// The human readable name of the user. - pub display_name: Option, - /// The matrix url of the users avatar. - pub avatar_url: Option, - /// The users power level. - pub power_level: u8, +#[derive(Debug, Default)] +/// `RoomName` allows the calculation of a text room name. +pub struct RoomName { + /// The displayed name of the room. + name: Option, + /// The canonical alias of the room ex. `#room-name:example.com` and port number. + canonical_alias: Option, + /// List of `RoomAliasId`s the room has been given. + aliases: Vec, } -#[derive(Debug)] -/// A Matrix rooom. -pub struct Room { - /// The unique id of the room. - pub room_id: String, - /// The mxid of our own user. - pub own_user_id: UserId, - /// The mxid of the room creator. - pub creator: Option, - /// The map of room members. - pub members: HashMap, - /// A list of users that are currently typing. - pub typing_users: Vec, - /// A flag indicating if the room is encrypted. - pub encrypted: bool, +#[derive(Clone, Debug, Default)] +pub struct CurrentRoom { + last_active: Option, + current_room_id: Option, } -impl Room { - /// Create a new room. - /// - /// # Arguments - /// - /// * `room_id` - The unique id of the room. - /// - /// * `own_user_id` - The mxid of our own user. - pub fn new(room_id: &str, own_user_id: &str) -> Self { - Room { - room_id: room_id.to_string(), - own_user_id: own_user_id.to_owned(), - creator: None, - members: HashMap::new(), - typing_users: Vec::new(), - encrypted: false, +impl CurrentRoom { + // TODO when UserId is isomorphic to &str clean this up. + pub(crate) fn comes_after(&self, user: &Uid, event: &PresenceEvent) -> bool { + if user == &event.sender { + if self.last_active.is_none() { + true + } else { + event.content.last_active_ago < self.last_active + } + } else { + false } } - fn add_member(&mut self, event: &MemberEvent) -> bool { - if self.members.contains_key(&event.state_key) { - return false; - } - - let member = RoomMember { - user_id: event.state_key.clone(), - display_name: event.content.displayname.clone(), - avatar_url: event.content.avatar_url.clone(), - power_level: 0, - }; - - self.members.insert(event.state_key.clone(), member); - - true - } - - fn remove_member(&mut self, event: &MemberEvent) -> bool { - if !self.members.contains_key(&event.state_key) { - return false; - } - - true - } - - fn update_joined_member(&mut self, event: &MemberEvent) -> bool { - if let Some(member) = self.members.get_mut(&event.state_key) { - member.display_name = event.content.displayname.clone(); - member.avatar_url = event.content.avatar_url.clone(); - } - - false - } - - fn handle_join(&mut self, event: &MemberEvent) -> bool { - match &event.prev_content { - Some(c) => match c.membership { - MembershipState::Join => self.update_joined_member(event), - MembershipState::Invite => self.add_member(event), - MembershipState::Leave => self.remove_member(event), - _ => false, - }, - None => self.add_member(event), - } - } - - fn handle_leave(&mut self, _event: &MemberEvent) -> bool { - false - } - - /// Handle a room.member updating the room state if necessary. - /// Returns true if the joined member list changed, false otherwise. - pub fn handle_membership(&mut self, event: &MemberEvent) -> bool { - match event.content.membership { - MembershipState::Join => self.handle_join(event), - MembershipState::Leave => self.handle_leave(event), - MembershipState::Ban => self.handle_leave(event), - MembershipState::Invite => false, - MembershipState::Knock => false, - _ => false, - } - } - - /// Receive a timeline event for this room and update the room state. - /// - /// Returns true if the joined member list changed, false otherwise. - /// - /// # Arguments - /// - /// * `event` - The event of the room. - pub fn receive_timeline_event(&mut self, event: &RoomEvent) -> bool { - match event { - RoomEvent::RoomMember(m) => self.handle_membership(m), - _ => false, - } - } - - /// Receive a state event for this room and update the room state. - /// - /// Returns true if the joined member list changed, false otherwise. - /// - /// # Arguments - /// - /// * `event` - The event of the room. - pub fn receive_state_event(&mut self, event: &StateEvent) -> bool { - match event { - StateEvent::RoomMember(m) => self.handle_membership(m), - _ => false, - } + pub(crate) fn update(&mut self, room_id: &str, event: &PresenceEvent) { + self.last_active = event.content.last_active_ago; + self.current_room_id = + Some(RoomId::try_from(room_id).expect("room id failed CurrentRoom::update")); } } @@ -192,6 +104,13 @@ pub struct Client { pub sync_token: Option, /// A map of the rooms our user is joined in. pub joined_rooms: HashMap>>, + /// The most recent room the logged in user used by `RoomId`. + pub current_room_id: CurrentRoom, + /// A list of ignored users. + pub ignored_users: Vec, + /// The push ruleset for the logged in user. + pub push_ruleset: Option, + #[cfg(feature = "encryption")] olm: Arc>>, } @@ -214,6 +133,9 @@ impl Client { session, sync_token: None, joined_rooms: HashMap::new(), + current_room_id: CurrentRoom::default(), + ignored_users: Vec::new(), + push_ruleset: None, #[cfg(feature = "encryption")] olm: Arc::new(Mutex::new(olm)), }) @@ -250,6 +172,29 @@ impl Client { Ok(()) } + pub(crate) fn calculate_room_name(&self, room_id: &str) -> Option { + self.joined_rooms.get(room_id).and_then(|r| { + r.read() + .map(|r| r.room_name.calculate_name(room_id, &r.members)) + .ok() + }) + } + + pub(crate) fn calculate_room_names(&self) -> Vec { + self.joined_rooms + .iter() + .flat_map(|(id, room)| { + room.read() + .map(|r| r.room_name.calculate_name(id, &r.members)) + .ok() + }) + .collect() + } + + pub(crate) fn current_room_id(&self) -> Option { + self.current_room_id.current_room_id.clone() + } + pub(crate) fn get_or_create_room(&mut self, room_id: &str) -> &mut Arc> { #[allow(clippy::or_fun_call)] self.joined_rooms @@ -265,6 +210,48 @@ impl Client { )))) } + pub(crate) fn get_room(&mut self, room_id: &str) -> Option<&mut Arc>> { + self.joined_rooms.get_mut(room_id) + } + + /// Handle a m.ignored_user_list event, updating the room state if necessary. + /// + /// Returns true if the room name changed, false otherwise. + pub(crate) fn handle_ignored_users(&mut self, event: &IgnoredUserListEvent) -> bool { + // FIXME when UserId becomes more like a &str wrapper in ruma-identifiers + if self.ignored_users + == event + .content + .ignored_users + .iter() + .map(|u| u.to_string()) + .collect::>() + { + false + } else { + self.ignored_users = event + .content + .ignored_users + .iter() + .map(|u| u.to_string()) + .collect(); + true + } + } + + /// Handle a m.ignored_user_list event, updating the room state if necessary. + /// + /// Returns true if the room name changed, false otherwise. + pub(crate) fn handle_push_rules(&mut self, event: &PushRulesEvent) -> bool { + // TODO this is basically a stub + if self.push_ruleset.as_ref() == Some(&event.content.global) { + false + } else { + self.push_ruleset = Some(event.content.global.clone()); + true + } + } + /// Receive a timeline event for a joined room and update the client state. /// /// If the event was a encrypted room event and decryption was successful @@ -328,6 +315,52 @@ impl Client { room.receive_state_event(event) } + /// Receive a presence event from a sync response and updates the client state. + /// + /// Returns true if the membership list of the room changed, false + /// otherwise. + /// + /// # Arguments + /// + /// * `room_id` - The unique id of the room the event belongs to. + /// + /// * `event` - The event that should be handled by the client. + pub fn receive_presence_event(&mut self, room_id: &str, event: &PresenceEvent) -> bool { + let user_id = &self + .session + .as_ref() + .expect("to receive events you must be logged in") + .user_id; + + if self.current_room_id.comes_after(user_id, event) { + self.current_room_id.update(room_id, event); + } + // this should be the room that was just created in the `Client::sync` loop. + if let Some(room) = self.get_room(room_id) { + let mut room = room.write().unwrap(); + room.receive_presence_event(event) + } else { + false + } + } + + /// Receive a presence event from a sync response and updates the client state. + /// + /// This will only update the user if found in the current room looped through by `AsyncClient::sync`. + /// Returns true if the specific users presence has changed, false otherwise. + /// + /// # Arguments + /// + /// * `event` - The presence event for a specified room member. + pub fn receive_account_data(&mut self, room_id: &str, event: &NonRoomEvent) -> bool { + match event { + NonRoomEvent::IgnoredUserList(iu) => self.handle_ignored_users(iu), + NonRoomEvent::Presence(p) => self.receive_presence_event(room_id, p), + NonRoomEvent::PushRules(pr) => self.handle_push_rules(pr), + _ => false, + } + } + /// Receive a response from a sync call. /// /// # Arguments @@ -396,3 +429,48 @@ impl Client { Ok(()) } } + +#[cfg(test)] +mod test { + use super::*; + + use crate::events::room::member::MembershipState; + use crate::identifiers::UserId; + use crate::{AsyncClient, Session, SyncSettings}; + + use mockito::{mock, Matcher}; + use tokio::runtime::Runtime; + use url::Url; + + use std::convert::TryFrom; + use std::str::FromStr; + use std::time::Duration; + + #[tokio::test] + async fn account_data() { + let homeserver = Url::from_str(&mockito::server_url()).unwrap(); + + let session = Session { + access_token: "1234".to_owned(), + user_id: UserId::try_from("@example:example.com").unwrap(), + device_id: "DEVICEID".to_owned(), + }; + + let _m = mock( + "GET", + Matcher::Regex(r"^/_matrix/client/r0/sync\?.*$".to_string()), + ) + .with_status(200) + .with_body_from_file("tests/data/sync.json") + .create(); + + let mut client = AsyncClient::new(homeserver, Some(session)).unwrap(); + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + let _response = client.sync(sync_settings).await.unwrap(); + + let bc = &client.base_client.read().await; + assert_eq!(1, bc.ignored_users.len()) + } +} diff --git a/src/lib.rs b/src/lib.rs index 84b40335..c8813589 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,12 +35,14 @@ pub use ruma_identifiers as identifiers; mod async_client; mod base_client; mod error; +mod models; mod session; #[cfg(feature = "encryption")] mod crypto; pub use async_client::{AsyncClient, AsyncClientConfig, SyncSettings}; -pub use base_client::{Client, Room}; +pub use base_client::Client; +pub use models::Room; pub(crate) const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 00000000..8fbc08d4 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,10 @@ +mod room; +mod room_member; +mod user; + +pub use room::{Room, RoomName}; +pub use room_member::RoomMember; +pub use user::User; + +pub type Token = String; +pub type UserId = String; diff --git a/src/models/room.rs b/src/models/room.rs new file mode 100644 index 00000000..5e1ba558 --- /dev/null +++ b/src/models/room.rs @@ -0,0 +1,352 @@ +// Copyright 2020 Damir Jelić +// Copyright 2020 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::HashMap; +use std::sync::{Arc, RwLock}; + +use super::{RoomMember, User, UserId}; + +use crate::events::collections::all::{RoomEvent, StateEvent}; +use crate::events::room::{ + aliases::AliasesEvent, + canonical_alias::CanonicalAliasEvent, + member::{MemberEvent, MembershipChange}, + name::NameEvent, + power_levels::PowerLevelsEvent, +}; +use crate::events::{ + presence::{PresenceEvent, PresenceEventContent}, + EventResult, +}; +use crate::identifiers::RoomAliasId; +use crate::session::Session; + +use js_int::UInt; + +#[cfg(feature = "encryption")] +use tokio::sync::Mutex; + +#[cfg(feature = "encryption")] +use crate::crypto::{OlmMachine, OneTimeKeys}; +#[cfg(feature = "encryption")] +use ruma_client_api::r0::keys::{upload_keys::Response as KeysUploadResponse, DeviceKeys}; + +#[derive(Debug, Default)] +/// `RoomName` allows the calculation of a text room name. +pub struct RoomName { + /// The displayed name of the room. + name: Option, + /// The canonical alias of the room ex. `#room-name:example.com` and port number. + canonical_alias: Option, + /// List of `RoomAliasId`s the room has been given. + aliases: Vec, +} + +#[derive(Debug)] +/// A Matrix rooom. +pub struct Room { + /// The unique id of the room. + pub room_id: String, + /// The name of the room, clients use this to represent a room. + pub room_name: RoomName, + /// The mxid of our own user. + pub own_user_id: UserId, + /// The mxid of the room creator. + pub creator: Option, + /// The map of room members. + pub members: HashMap, + /// A list of users that are currently typing. + pub typing_users: Vec, + // TODO when encryption events are handled we store algorithm used and rotation time. + /// A flag indicating if the room is encrypted. + pub encrypted: bool, + /// Number of unread notifications with highlight flag set. + pub unread_highlight: Option, + /// Number of unread notifications. + pub unread_notifications: Option, +} + +impl RoomName { + pub fn push_alias(&mut self, alias: RoomAliasId) -> bool { + self.aliases.push(alias); + true + } + + pub fn set_canonical(&mut self, alias: RoomAliasId) -> bool { + self.canonical_alias = Some(alias); + true + } + + pub fn set_name(&mut self, name: &str) -> bool { + self.name = Some(name.to_string()); + true + } + + pub fn calculate_name(&self, room_id: &str, members: &HashMap) -> String { + // https://matrix.org/docs/spec/client_server/latest#calculating-the-display-name-for-a-room. + // the order in which we check for a name ^^ + if let Some(name) = &self.name { + name.clone() + } else if let Some(alias) = &self.canonical_alias { + alias.alias().to_string() + } else if !self.aliases.is_empty() { + self.aliases[0].alias().to_string() + } else { + let mut names = members + .values() + .flat_map(|m| m.user.display_name.clone()) + .take(3) + .collect::>(); + + if names.is_empty() { + // TODO implement the rest of display name for room spec + format!("Room {}", room_id) + } else { + // stabilize order + names.sort(); + names.join(", ") + } + } + } +} + +impl Room { + /// Create a new room. + /// + /// # Arguments + /// + /// * `room_id` - The unique id of the room. + /// + /// * `own_user_id` - The mxid of our own user. + pub fn new(room_id: &str, own_user_id: &str) -> Self { + Room { + room_id: room_id.to_string(), + room_name: RoomName::default(), + own_user_id: own_user_id.to_owned(), + creator: None, + members: HashMap::new(), + typing_users: Vec::new(), + encrypted: false, + unread_highlight: None, + unread_notifications: None, + } + } + + fn add_member(&mut self, event: &MemberEvent) -> bool { + if self.members.contains_key(&event.state_key) { + return false; + } + + let member = RoomMember::new(event); + + self.members.insert(event.state_key.clone(), member); + + true + } + + /// Add to the list of `RoomAliasId`s. + fn room_aliases(&mut self, alias: &RoomAliasId) -> bool { + self.room_name.push_alias(alias.clone()); + true + } + + /// RoomAliasId is `#alias:hostname` and `port` + fn canonical_alias(&mut self, alias: &RoomAliasId) -> bool { + self.room_name.set_canonical(alias.clone()); + true + } + + fn name_room(&mut self, name: &str) -> bool { + self.room_name.set_name(name); + true + } + + /// Handle a room.member updating the room state if necessary. + /// + /// Returns true if the joined member list changed, false otherwise. + pub fn handle_membership(&mut self, event: &MemberEvent) -> bool { + match event.membership_change() { + MembershipChange::Invited | MembershipChange::Joined => self.add_member(event), + _ => { + if let Some(member) = self.members.get_mut(&event.state_key) { + member.update_member(event) + } else { + false + } + } + } + } + + /// Handle a room.aliases event, updating the room state if necessary. + /// + /// Returns true if the room name changed, false otherwise. + pub fn handle_room_aliases(&mut self, event: &AliasesEvent) -> bool { + match event.content.aliases.as_slice() { + [alias] => self.room_aliases(alias), + [alias, ..] => self.room_aliases(alias), + _ => false, + } + } + + /// Handle a room.canonical_alias event, updating the room state if necessary. + /// + /// Returns true if the room name changed, false otherwise. + pub fn handle_canonical(&mut self, event: &CanonicalAliasEvent) -> bool { + match &event.content.alias { + Some(name) => self.canonical_alias(&name), + _ => false, + } + } + + /// Handle a room.name event, updating the room state if necessary. + /// + /// Returns true if the room name changed, false otherwise. + pub fn handle_room_name(&mut self, event: &NameEvent) -> bool { + match event.content.name() { + Some(name) => self.name_room(name), + _ => false, + } + } + + /// Handle a room.power_levels event, updating the room state if necessary. + /// + /// Returns true if the room name changed, false otherwise. + pub fn handle_power_level(&mut self, event: &PowerLevelsEvent) -> bool { + if let Some(member) = self.members.get_mut(&event.state_key) { + member.update_power(event) + } else { + false + } + } + + /// Receive a timeline event for this room and update the room state. + /// + /// Returns true if the joined member list changed, false otherwise. + /// + /// # Arguments + /// + /// * `event` - The event of the room. + pub fn receive_timeline_event(&mut self, event: &RoomEvent) -> bool { + match event { + // update to the current members of the room + RoomEvent::RoomMember(m) => self.handle_membership(m), + // finds all events related to the name of the room for later calculation + RoomEvent::RoomName(n) => self.handle_room_name(n), + RoomEvent::RoomCanonicalAlias(ca) => self.handle_canonical(ca), + RoomEvent::RoomAliases(a) => self.handle_room_aliases(a), + // power levels of the room members + RoomEvent::RoomPowerLevels(p) => self.handle_power_level(p), + _ => false, + } + } + + /// Receive a state event for this room and update the room state. + /// + /// Returns true if the joined member list changed, false otherwise. + /// + /// # Arguments + /// + /// * `event` - The event of the room. + pub fn receive_state_event(&mut self, event: &StateEvent) -> bool { + match event { + StateEvent::RoomMember(m) => self.handle_membership(m), + StateEvent::RoomName(n) => self.handle_room_name(n), + StateEvent::RoomCanonicalAlias(ca) => self.handle_canonical(ca), + StateEvent::RoomAliases(a) => self.handle_room_aliases(a), + _ => false, + } + } + + /// Receive a presence event from an `IncomingResponse` and updates the client state. + /// + /// This will only update the user if found in the current room looped through by `AsyncClient::sync`. + /// Returns true if the specific users presence has changed, false otherwise. + /// + /// # Arguments + /// + /// * `event` - The presence event for a specified room member. + pub fn receive_presence_event(&mut self, event: &PresenceEvent) -> bool { + if let Some(user) = self + .members + .get_mut(&event.sender.to_string()) + .map(|m| &mut m.user) + { + if user.did_update_presence(event) { + false + } else { + user.update_presence(event); + true + } + } else { + // this is probably an error as we have a `PresenceEvent` for a user + // we don't know about + false + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + use crate::events::room::member::MembershipState; + use crate::identifiers::UserId; + use crate::{AsyncClient, Session, SyncSettings}; + + use mockito::{mock, Matcher}; + use tokio::runtime::Runtime; + use url::Url; + + use std::convert::TryFrom; + use std::str::FromStr; + use std::time::Duration; + + #[tokio::test] + async fn user_presence() { + let homeserver = Url::from_str(&mockito::server_url()).unwrap(); + + let session = Session { + access_token: "1234".to_owned(), + user_id: UserId::try_from("@example:localhost").unwrap(), + device_id: "DEVICEID".to_owned(), + }; + + let _m = mock( + "GET", + Matcher::Regex(r"^/_matrix/client/r0/sync\?.*$".to_string()), + ) + .with_status(200) + .with_body_from_file("tests/data/sync.json") + .create(); + + let mut client = AsyncClient::new(homeserver, Some(session)).unwrap(); + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + let _response = client.sync(sync_settings).await.unwrap(); + + let rooms = &client.base_client.read().await.joined_rooms; + let room = &rooms + .get("!SVkFJHzfwvuaIEawgC:localhost") + .unwrap() + .read() + .unwrap(); + + assert_eq!(2, room.members.len()); + for (id, member) in &room.members { + assert_eq!(MembershipState::Join, member.membership); + } + } +} diff --git a/src/models/room_member.rs b/src/models/room_member.rs new file mode 100644 index 00000000..91c2b71d --- /dev/null +++ b/src/models/room_member.rs @@ -0,0 +1,212 @@ +// Copyright 2020 Damir Jelić +// Copyright 2020 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::convert::TryFrom; + +use super::User; +use crate::api::r0 as api; +use crate::events::collections::all::{Event, RoomEvent, StateEvent}; +use crate::events::room::{ + aliases::AliasesEvent, + canonical_alias::CanonicalAliasEvent, + member::{MemberEvent, MemberEventContent, MembershipChange, MembershipState}, + name::NameEvent, + power_levels::PowerLevelsEvent, +}; +use crate::events::EventResult; +use crate::identifiers::{RoomAliasId, UserId}; +use crate::session::Session; + +use js_int::{Int, UInt}; +#[cfg(feature = "encryption")] +use tokio::sync::Mutex; + +#[cfg(feature = "encryption")] +use crate::crypto::{OlmMachine, OneTimeKeys}; +#[cfg(feature = "encryption")] +use ruma_client_api::r0::keys::{upload_keys::Response as KeysUploadResponse, DeviceKeys}; + +// Notes: if Alice invites Bob into a room we will get an event with the sender as Alice and the state key as Bob. + +#[derive(Debug)] +/// A Matrix room member. +/// +pub struct RoomMember { + /// The unique mxid of the user. + pub user_id: UserId, + /// The unique id of the room. + pub room_id: Option, + /// If the member is typing. + pub typing: Option, + /// The user data for this room member. + pub user: User, + /// The users power level. + pub power_level: Option, + /// The normalized power level of this `RoomMember` (0-100). + pub power_level_norm: Option, + /// The `MembershipState` of this `RoomMember`. + pub membership: MembershipState, + /// The human readable name of this room member. + pub name: String, + /// The events that created the state of this room member. + pub events: Vec, +} + +impl RoomMember { + pub fn new(event: &MemberEvent) -> Self { + let user = User::new(event); + Self { + room_id: event.room_id.as_ref().map(|id| id.to_string()), + user_id: UserId::try_from(event.state_key.as_str()).unwrap(), + typing: None, + user, + power_level: None, + power_level_norm: None, + membership: event.content.membership, + name: event.state_key.clone(), + events: vec![Event::RoomMember(event.clone())], + } + } + + pub fn update_member(&mut self, event: &MemberEvent) -> bool { + use MembershipChange::*; + + match event.membership_change() { + ProfileChanged => { + self.user.display_name = event.content.displayname.clone(); + self.user.avatar_url = event.content.avatar_url.clone(); + true + } + Banned | Kicked | KickedAndBanned | InvitationRejected | InvitationRevoked | Left + | Unbanned | Joined | Invited => { + self.membership = event.content.membership; + true + } + NotImplemented => false, + None => false, + // we ignore the error here as only a buggy or malicious server would send this + Error => false, + _ => false, + } + } + + pub fn update_power(&mut self, event: &PowerLevelsEvent) -> bool { + let mut max_power = event.content.users_default; + for power in event.content.users.values() { + max_power = *power.max(&max_power); + } + + let mut changed = false; + if let Some(user_power) = event.content.users.get(&self.user_id) { + changed = self.power_level != Some(*user_power); + self.power_level = Some(*user_power); + } else { + changed = self.power_level != Some(event.content.users_default); + self.power_level = Some(event.content.users_default); + } + + if max_power > Int::from(0) { + self.power_level_norm = Some((self.power_level.unwrap() * Int::from(100)) / max_power); + } + + changed + } +} + +#[cfg(test)] +mod test { + use super::*; + + use crate::identifiers::{EventId, RoomId, UserId}; + use crate::{AsyncClient, Session, SyncSettings}; + + use js_int::{Int, UInt}; + use mockito::{mock, Matcher}; + use tokio::runtime::Runtime; + use url::Url; + + use std::collections::HashMap; + use std::convert::TryFrom; + use std::str::FromStr; + use std::time::Duration; + + use crate::events::room::power_levels::{ + NotificationPowerLevels, PowerLevelsEvent, PowerLevelsEventContent, + }; + + #[tokio::test] + async fn member_power() { + let homeserver = Url::from_str(&mockito::server_url()).unwrap(); + + let session = Session { + access_token: "1234".to_owned(), + user_id: UserId::try_from("@example:localhost").unwrap(), + device_id: "DEVICEID".to_owned(), + }; + + let _m = mock( + "GET", + Matcher::Regex(r"^/_matrix/client/r0/sync\?.*$".to_string()), + ) + .with_status(200) + .with_body_from_file("tests/data/sync.json") + .create(); + + let mut client = AsyncClient::new(homeserver, Some(session)).unwrap(); + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + let _response = client.sync(sync_settings).await.unwrap(); + + let mut rooms = client.base_client.write().await.joined_rooms.clone(); + let mut room = rooms + .get_mut("!SVkFJHzfwvuaIEawgC:localhost") + .unwrap() + .write() + .unwrap(); + + for (id, member) in &mut room.members { + let power = power_levels(); + assert!(member.update_power(&power)); + assert_eq!(MembershipState::Join, member.membership); + } + } + + fn power_levels() -> PowerLevelsEvent { + PowerLevelsEvent { + content: PowerLevelsEventContent { + ban: Int::new(40).unwrap(), + events: HashMap::default(), + events_default: Int::new(40).unwrap(), + invite: Int::new(40).unwrap(), + kick: Int::new(40).unwrap(), + redact: Int::new(40).unwrap(), + state_default: Int::new(40).unwrap(), + users: HashMap::default(), + users_default: Int::new(40).unwrap(), + notifications: NotificationPowerLevels { + room: Int::new(35).unwrap(), + }, + }, + event_id: EventId::try_from("$h29iv0s8:example.com").unwrap(), + origin_server_ts: UInt::new(1520372800469).unwrap(), + prev_content: None, + room_id: RoomId::try_from("!roomid:room.com").ok(), + unsigned: None, + sender: UserId::try_from("@example:example.com").unwrap(), + state_key: "@example:example.com".into(), + } + } +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 00000000..37c123a1 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,140 @@ +// Copyright 2020 Damir Jelić +// Copyright 2020 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::HashMap; +use std::sync::{Arc, RwLock}; + +use super::UserId; +use crate::api::r0 as api; +use crate::events::collections::all::{Event, RoomEvent, StateEvent}; +use crate::events::presence::{PresenceEvent, PresenceEventContent, PresenceState}; +use crate::events::room::{ + aliases::AliasesEvent, + canonical_alias::CanonicalAliasEvent, + member::{MemberEvent, MembershipState}, + name::NameEvent, +}; +use crate::events::EventResult; +use crate::identifiers::RoomAliasId; +use crate::session::Session; + +use js_int::UInt; +#[cfg(feature = "encryption")] +use tokio::sync::Mutex; + +#[cfg(feature = "encryption")] +use crate::crypto::{OlmMachine, OneTimeKeys}; +#[cfg(feature = "encryption")] +use ruma_client_api::r0::keys::{upload_keys::Response as KeysUploadResponse, DeviceKeys}; + +#[derive(Debug)] +/// A Matrix room member. +pub struct User { + /// The human readable name of the user. + pub display_name: Option, + /// The matrix url of the users avatar. + pub avatar_url: Option, + /// The presence of the user, if found. + pub presence: Option, + /// The presence status message, if found. + pub status_msg: Option, + /// The time, in ms, since the user interacted with the server. + pub last_active_ago: Option, + /// If the user should be considered active. + pub currently_active: Option, + /// The events that created the state of the current user. + // TODO do we want to hold the whole state or just update our structures. + pub events: Vec, + /// The `PresenceEvent`s connected to this user. + pub presence_events: Vec, +} + +impl User { + pub fn new(event: &MemberEvent) -> Self { + Self { + display_name: event.content.displayname.clone(), + avatar_url: event.content.avatar_url.clone(), + presence: None, + status_msg: None, + last_active_ago: None, + currently_active: None, + events: Vec::default(), + presence_events: Vec::default(), + } + } + + /// If the current `PresenceEvent` updated the state of this `User`. + /// + /// Returns true if the specific users presence has changed, false otherwise. + /// + /// # Arguments + /// + /// * `presence` - The presence event for a this room member. + pub fn did_update_presence(&self, presence: &PresenceEvent) -> bool { + let PresenceEvent { + content: + PresenceEventContent { + avatar_url, + currently_active, + displayname, + last_active_ago, + presence, + status_msg, + }, + .. + } = presence; + self.display_name == *displayname + && self.avatar_url == *avatar_url + && self.presence.as_ref() == Some(presence) + && self.status_msg == *status_msg + && self.last_active_ago == *last_active_ago + && self.currently_active == *currently_active + } + + /// Updates the `User`s presence. + /// + /// This should only be used if `did_update_presence` was true. + /// + /// # Arguments + /// + /// * `presence` - The presence event for a this room member. + pub fn update_presence(&mut self, presence_ev: &PresenceEvent) { + let PresenceEvent { + content: + PresenceEventContent { + avatar_url, + currently_active, + displayname, + last_active_ago, + presence, + status_msg, + }, + .. + } = presence_ev; + + self.presence_events.push(presence_ev.clone()); + *self = User { + display_name: displayname.clone(), + avatar_url: avatar_url.clone(), + presence: Some(*presence), + status_msg: status_msg.clone(), + last_active_ago: *last_active_ago, + currently_active: *currently_active, + // TODO better way of moving vec over + events: self.events.clone(), + presence_events: self.presence_events.clone(), + } + } +} diff --git a/tests/async_client_tests.rs b/tests/async_client_tests.rs index f5a972c8..5403eea7 100644 --- a/tests/async_client_tests.rs +++ b/tests/async_client_tests.rs @@ -9,10 +9,8 @@ use std::convert::TryFrom; use std::str::FromStr; use std::time::Duration; -#[test] -fn login() { - let mut rt = Runtime::new().unwrap(); - +#[tokio::test] +async fn login() { let homeserver = Url::from_str(&mockito::server_url()).unwrap(); let _m = mock("POST", "/_matrix/client/r0/login") @@ -22,22 +20,22 @@ fn login() { let mut client = AsyncClient::new(homeserver, None).unwrap(); - rt.block_on(client.login("example", "wordpass", None, None)) + client + .login("example", "wordpass", None, None) + .await .unwrap(); - let logged_in = rt.block_on(client.logged_in()); + let logged_in = client.logged_in().await; assert!(logged_in, "Clint should be logged in"); } -#[test] -fn sync() { - let mut rt = Runtime::new().unwrap(); - +#[tokio::test] +async fn sync() { let homeserver = Url::from_str(&mockito::server_url()).unwrap(); let session = Session { access_token: "1234".to_owned(), - user_id: UserId::try_from("@example:example.com").unwrap(), + user_id: UserId::try_from("@example:localhost").unwrap(), device_id: "DEVICEID".to_owned(), }; @@ -53,9 +51,70 @@ fn sync() { let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); - let response = rt.block_on(client.sync(sync_settings)).unwrap(); + let response = client.sync(sync_settings).await.unwrap(); assert_ne!(response.next_batch, ""); - assert!(rt.block_on(client.sync_token()).is_some()); + assert!(client.sync_token().await.is_some()); +} + +#[tokio::test] +async fn room_names() { + let homeserver = Url::from_str(&mockito::server_url()).unwrap(); + + let session = Session { + access_token: "1234".to_owned(), + user_id: UserId::try_from("@example:localhost").unwrap(), + device_id: "DEVICEID".to_owned(), + }; + + let _m = mock( + "GET", + Matcher::Regex(r"^/_matrix/client/r0/sync\?.*$".to_string()), + ) + .with_status(200) + .with_body_from_file("tests/data/sync.json") + .create(); + + let mut client = AsyncClient::new(homeserver, Some(session)).unwrap(); + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + let _response = client.sync(sync_settings).await.unwrap(); + + assert_eq!(vec!["tutorial"], client.get_room_names().await); + assert_eq!( + Some("tutorial".into()), + client.get_room_name("!SVkFJHzfwvuaIEawgC:localhost").await + ); +} + +#[tokio::test] +async fn current_room() { + let homeserver = Url::from_str(&mockito::server_url()).unwrap(); + + let session = Session { + access_token: "1234".to_owned(), + user_id: UserId::try_from("@example:localhost").unwrap(), + device_id: "DEVICEID".to_owned(), + }; + + let _m = mock( + "GET", + Matcher::Regex(r"^/_matrix/client/r0/sync\?.*$".to_string()), + ) + .with_status(200) + .with_body_from_file("tests/data/sync.json") + .create(); + + let mut client = AsyncClient::new(homeserver, Some(session)).unwrap(); + + let sync_settings = SyncSettings::new().timeout(Duration::from_millis(3000)); + + let _response = client.sync(sync_settings).await.unwrap(); + + assert_eq!( + Some("!SVkFJHzfwvuaIEawgC:localhost".into()), + client.current_room_id().await.map(|id| id.to_string()) + ); } diff --git a/tests/data/sync.json b/tests/data/sync.json index 3b898e20..32cd4629 100644 --- a/tests/data/sync.json +++ b/tests/data/sync.json @@ -2,18 +2,33 @@ "device_one_time_keys_count": {}, "next_batch": "s526_47314_0_7_1_1_1_11444_1", "device_lists": { - "changed": [ - "@example:example.org" - ], - "left": [] + "changed": [ + "@example:example.org" + ], + "left": [] }, - "rooms": { "invite": {}, "join": { "!SVkFJHzfwvuaIEawgC:localhost": { "account_data": { - "events": [] + "events": [ + { + "content": { + "event_id": "$someplace:example.org" + }, + "room_id": "!roomid:room.com", + "type": "m.fully_read" + }, + { + "content": { + "ignored_users": { + "@someone:example.org": {} + } + }, + "type": "m.ignored_user_list" + } + ] }, "ephemeral": { "events": [ @@ -184,7 +199,31 @@ "prev_sender": "@example:localhost", "replaces_state": "$152034819067QWJxM:localhost" } - } + }, + { + "content": { + "membership": "leave", + "reason": "offline", + "avatar_url": "avatar.com", + "displayname": "example" + }, + "event_id": "$1585345508297748AIUBh:matrix.org", + "origin_server_ts": 1585345508223, + "sender": "@example:localhost", + "state_key": "@example:localhost", + "type": "m.room.member", + "unsigned": { + "replaces_state": "$1585345354296486IGZfp:localhost", + "prev_content": { + "avatar_url": "avatar.com", + "displayname": "example", + "membership": "join" + }, + "prev_sender": "@example2:localhost", + "age": 6992 + }, + "room_id": "!roomid:room.com" + } ] }, "timeline": { @@ -219,8 +258,19 @@ "to_device": { "events": [] }, - "presence": { - "events": [] + "events": [ + { + "content": { + "avatar_url": "mxc://localhost:wefuiwegh8742w", + "currently_active": false, + "last_active_ago": 1, + "presence": "online", + "status_msg": "Making cupcakes" + }, + "sender": "@example:localhost", + "type": "m.presence" + } + ] } }