From d29486a17c7fcd19381c42b77d87cacd75e26766 Mon Sep 17 00:00:00 2001 From: Marc Bauer Date: Tue, 19 Dec 2023 16:35:00 +0100 Subject: [PATCH] Improve room handling (#37) - Introduce strongly-typed JIDs (UserId, RoomId, etc.) - Add the ability to reconfigure rooms (needs server support to finalize) - Handle room events (room was destroyed, user was kicked, etc.) - Parse server events into unambigous Rust structs --- bindings/prose-sdk-ffi/src/client.rs | 6 +- bindings/prose-sdk-ffi/src/prose_sdk_ffi.udl | 4 +- bindings/prose-sdk-ffi/src/types/contact.rs | 8 +- bindings/prose-sdk-ffi/src/types/message.rs | 6 +- bindings/prose-sdk-ffi/src/uniffi_api.rs | 2 +- bindings/prose-sdk-js/src/client.rs | 23 +- bindings/prose-sdk-js/src/delegate.rs | 48 +- bindings/prose-sdk-js/src/types/contact.rs | 16 +- bindings/prose-sdk-js/src/types/jid.rs | 19 +- bindings/prose-sdk-js/src/types/js_array.rs | 9 +- bindings/prose-sdk-js/src/types/message.rs | 6 +- bindings/prose-sdk-js/src/types/mod.rs | 5 +- bindings/prose-sdk-js/src/types/room.rs | 33 +- bindings/prose-sdk-js/src/types/user_info.rs | 79 +- crates/prose-core-client/Cargo.toml | 4 +- .../src/app/deps/app_context.rs | 5 +- .../src/app/deps/app_dependencies.rs | 5 +- .../prose-core-client/src/app/dtos/contact.rs | 10 +- crates/prose-core-client/src/app/dtos/mod.rs | 9 +- .../event_handlers/bookmarks_event_handler.rs | 120 +-- .../event_handlers/client_event_dispatcher.rs | 10 +- .../connection_event_handler.rs | 51 +- .../app/event_handlers/event_handler_queue.rs | 33 +- .../event_handlers/messages_event_handler.rs | 68 +- .../src/app/event_handlers/mod.rs | 16 +- .../event_handlers/requests_event_handler.rs | 125 +-- .../app/event_handlers/rooms_event_handler.rs | 382 +++++--- .../src/app/event_handlers/server_event.rs | 175 ++++ .../user_state_event_handler.rs | 151 +-- .../src/app/services/account_service.rs | 14 +- .../src/app/services/connection_service.rs | 18 +- .../src/app/services/contacts_service.rs | 12 +- .../src/app/services/debug_service.rs | 17 +- .../src/app/services/room.rs | 222 +++-- .../src/app/services/rooms_service.rs | 27 +- .../src/app/services/sidebar_service.rs | 6 +- .../src/app/services/user_data_service.rs | 15 +- crates/prose-core-client/src/client.rs | 6 +- .../prose-core-client/src/client_builder.rs | 4 +- crates/prose-core-client/src/client_event.rs | 14 +- .../account/services/user_account_service.rs | 4 +- .../models/connection_properties.rs | 4 +- .../connection/services/connection_service.rs | 4 +- .../src/domain/contacts/models/contact.rs | 5 +- .../contacts/repos/contacts_repository.rs | 4 +- .../services/request_handling_service.rs | 22 +- .../src/domain/messaging/models/message.rs | 54 +- .../src/domain/messaging/models/mod.rs | 2 +- .../messaging/repos/messages_repository.rs | 8 +- .../impls/message_migration_domain_service.rs | 66 ++ .../domain/messaging/services/impls/mod.rs | 10 + .../message_migration_domain_service.rs | 23 + .../messaging/services/messaging_service.rs | 10 +- .../src/domain/messaging/services/mod.rs | 4 + .../src/domain/rooms/models/compose_state.rs | 12 + .../src/domain/rooms/models/mod.rs | 12 +- .../domain/rooms/models/participant_list.rs | 635 ++++++++++++ .../domain/rooms/models/public_room_info.rs | 4 +- .../domain/rooms/models/room_affiliation.rs | 49 + .../src/domain/rooms/models/room_error.rs | 41 +- .../src/domain/rooms/models/room_internals.rs | 188 ++-- .../domain/rooms/models/room_session_info.rs | 24 +- .../src/domain/rooms/models/room_spec.rs | 31 +- .../src/domain/rooms/models/room_state.rs | 244 ----- .../rooms/repos/connected_rooms_repository.rs | 14 +- .../services/impls/rooms_domain_service.rs | 442 ++++++--- .../src/domain/rooms/services/room_factory.rs | 4 +- .../rooms/services/room_management_service.rs | 30 +- .../services/room_participation_service.rs | 13 +- .../rooms/services/rooms_domain_service.rs | 35 +- .../repos/account_settings_repository.rs | 7 +- .../domain/shared/models/anon_occupant_id.rs | 32 + .../src/domain/shared/models/availability.rs | 16 + .../domain/shared/models/capabilities_id.rs | 26 + .../src/domain/shared/models/mod.rs | 26 +- .../src/domain/shared/models/occupant_id.rs | 65 ++ .../domain/shared/models/participant_id.rs | 43 + .../src/domain/shared/models/request_id.rs | 31 + .../src/domain/shared/models/room_id.rs | 124 +++ .../src/domain/shared/models/room_jid.rs | 55 -- .../src/domain/shared/models/room_type.rs | 15 + .../src/domain/shared/models/sender_id.rs | 36 + .../domain/shared/models/user_endpoint_id.rs | 66 ++ .../src/domain/shared/models/user_id.rs | 118 +++ .../src/domain/shared/models/user_info.rs | 30 +- .../shared/models/user_or_resource_id.rs | 40 + .../domain/shared/models/user_resource_id.rs | 74 ++ .../shared/utils/contact_name_builder.rs | 8 +- .../src/domain/sidebar/models/bookmark.rs | 4 +- .../domain/sidebar/models/bookmark_type.rs | 17 + .../src/domain/sidebar/models/sidebar_item.rs | 4 +- .../sidebar/repos/sidebar_repository.rs | 10 +- .../sidebar/services/bookmarks_service.rs | 4 +- .../services/impls/sidebar_domain_service.rs | 464 +++++++-- .../services/sidebar_domain_service.rs | 59 +- .../src/domain/user_info/models/mod.rs | 4 +- .../src/domain/user_info/models/user_info.rs | 4 +- .../{user_activity.rs => user_status.rs} | 2 +- .../user_info/repos/avatar_repository.rs | 15 +- .../user_info/repos/user_info_repository.rs | 17 +- .../user_info/services/user_info_service.rs | 6 +- .../repos/user_profile_repository.rs | 11 +- .../services/user_profile_service.rs | 8 +- .../src/infra/account/user_account_service.rs | 5 +- .../src/infra/avatars/avatar_cache.rs | 8 +- .../src/infra/avatars/fs_avatar_cache.rs | 10 +- .../src/infra/avatars/store_avatar_cache.rs | 8 +- .../infra/connection/connection_service.rs | 6 +- .../contacts/caching_contacts_repository.rs | 6 +- .../src/infra/contacts/contacts_service.rs | 2 +- .../infra/general/request_handling_service.rs | 53 +- .../messaging/caching_message_repository.rs | 8 +- .../src/infra/messaging/messaging_service.rs | 42 +- .../src/infra/platform_dependencies.rs | 26 +- .../in_memory_connected_rooms_repository.rs | 14 +- .../infra/rooms/room_management_service.rs | 152 ++- .../infra/rooms/room_participation_service.rs | 55 +- .../settings/account_settings_repository.rs | 14 +- .../src/infra/sidebar/bookmarks_service.rs | 4 +- .../sidebar/in_memory_sidebar_repository.rs | 8 +- .../user_info/caching_avatar_repository.rs | 8 +- .../user_info/caching_user_info_repository.rs | 25 +- .../src/infra/user_info/presence_map.rs | 100 +- .../src/infra/user_info/user_info_service.rs | 14 +- .../caching_user_profile_repository.rs | 12 +- .../user_profile/user_profile_service.rs | 17 +- .../src/infra/xmpp/event_parser/message.rs | 114 +++ .../src/infra/xmpp/event_parser/mod.rs | 346 +++++++ .../src/infra/xmpp/event_parser/presence.rs | 164 ++++ .../src/infra/xmpp/event_parser/pubsub.rs | 95 ++ .../prose-core-client/src/infra/xmpp/mod.rs | 1 + .../infra/xmpp/type_conversions/bookmark.rs | 4 +- .../xmpp/type_conversions/compose_state.rs | 18 + .../src/infra/xmpp/type_conversions/mod.rs | 2 + .../xmpp/type_conversions/room_affiliation.rs | 20 + .../xmpp/type_conversions/user_activity.rs | 10 +- .../src/infra/xmpp/xmpp_client.rs | 16 +- crates/prose-core-client/src/lib.rs | 7 +- crates/prose-core-client/src/test/bookmark.rs | 11 +- .../src/test/mock_app_dependencies.rs | 30 +- crates/prose-core-client/src/test/mod.rs | 59 +- .../src/test/room_internals.rs | 114 ++- .../src/test/room_metadata.rs | 48 +- .../src/test/sidebar_item.rs | 11 +- crates/prose-core-client/src/util/jid_ext.rs | 73 -- crates/prose-core-client/src/util/mod.rs | 11 +- .../prose-core-client/src/util/string_ext.rs | 43 + .../tests/account_service.rs | 2 +- .../tests/connection_service.rs | 42 +- .../tests/contacts_service.rs | 34 +- .../event_parsing/connection_event_parser.rs | 53 + .../tests/event_parsing/main.rs | 11 + .../event_parsing/message_event_parser.rs | 37 + .../event_parsing/request_event_parser.rs | 166 ++++ .../tests/event_parsing/room_event_parser.rs | 443 +++++++++ .../sidebar_bookmark_event_parser.rs | 148 +++ .../tests/event_parsing/user_event_parser.rs | 312 ++++++ .../tests/in_memory_sidebar_repository.rs | 18 +- .../tests/messages_event_handler.rs | 70 +- .../tests/requests_event_handler.rs | 86 +- crates/prose-core-client/tests/room.rs | 60 +- .../tests/rooms_domain_service.rs | 924 +++++++++++++++++- .../tests/rooms_event_handler.rs | 677 +++++++++++-- .../tests/sidebar_domain_service.rs | 669 +++++++++++-- .../tests/user_data_service.rs | 13 +- .../tests/user_state_event_handler.rs | 87 -- crates/prose-proc-macros/Cargo.toml | 2 +- crates/prose-proc-macros/src/lib.rs | 16 +- crates/prose-wasm-utils/Cargo.toml | 3 + crates/prose-wasm-utils/src/future_ext.rs | 27 + crates/prose-wasm-utils/src/lib.rs | 9 + crates/prose-xmpp/Cargo.toml | 1 + crates/prose-xmpp/src/client/builder.rs | 31 +- crates/prose-xmpp/src/client/client.rs | 4 +- crates/prose-xmpp/src/client/mod.rs | 12 +- crates/prose-xmpp/src/connector/xmpp_rs.rs | 10 +- crates/prose-xmpp/src/mods/chat.rs | 5 + crates/prose-xmpp/src/mods/muc.rs | 12 +- crates/prose-xmpp/src/mods/pubsub.rs | 42 +- crates/prose-xmpp/src/mods/status.rs | 67 +- .../prose-xmpp/src/stanza/message/builder.rs | 6 + .../prose-xmpp/src/stanza/message/message.rs | 5 + crates/prose-xmpp/src/stanza/message/mod.rs | 1 + .../src/stanza/message/muc_invite.rs | 125 +++ .../src/stanza/muc/mediated_invite.rs | 19 +- crates/prose-xmpp/src/stanza/muc/mod.rs | 2 + crates/prose-xmpp/src/stanza/muc/muc_user.rs | 206 ++++ crates/prose-xmpp/src/stanza/muc/query.rs | 4 +- crates/prose-xmpp/src/stanza/ns.rs | 3 + .../prose-xmpp/src/test/connected_client.rs | 10 +- examples/.gitignore | 3 +- examples/common/Cargo.toml | 3 +- examples/common/src/lib.rs | 42 +- examples/prose-core-client-cli/Cargo.toml | 2 +- examples/prose-core-client-cli/src/main.rs | 245 +++-- examples/xmpp-client/src/main.rs | 5 +- .../src/tests/account_settings_repository.rs | 12 +- .../src/tests/contacts_repository.rs | 17 +- .../src/tests/messages_repository.rs | 16 +- .../src/tests/user_info_repository.rs | 40 +- 200 files changed, 9361 insertions(+), 2514 deletions(-) create mode 100644 crates/prose-core-client/src/app/event_handlers/server_event.rs create mode 100644 crates/prose-core-client/src/domain/messaging/services/impls/message_migration_domain_service.rs create mode 100644 crates/prose-core-client/src/domain/messaging/services/impls/mod.rs create mode 100644 crates/prose-core-client/src/domain/messaging/services/message_migration_domain_service.rs create mode 100644 crates/prose-core-client/src/domain/rooms/models/compose_state.rs create mode 100644 crates/prose-core-client/src/domain/rooms/models/participant_list.rs create mode 100644 crates/prose-core-client/src/domain/rooms/models/room_affiliation.rs delete mode 100644 crates/prose-core-client/src/domain/rooms/models/room_state.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/anon_occupant_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/capabilities_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/occupant_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/participant_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/request_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/room_id.rs delete mode 100644 crates/prose-core-client/src/domain/shared/models/room_jid.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/sender_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/user_endpoint_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/user_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/user_or_resource_id.rs create mode 100644 crates/prose-core-client/src/domain/shared/models/user_resource_id.rs rename crates/prose-core-client/src/domain/user_info/models/{user_activity.rs => user_status.rs} (91%) create mode 100644 crates/prose-core-client/src/infra/xmpp/event_parser/message.rs create mode 100644 crates/prose-core-client/src/infra/xmpp/event_parser/mod.rs create mode 100644 crates/prose-core-client/src/infra/xmpp/event_parser/presence.rs create mode 100644 crates/prose-core-client/src/infra/xmpp/event_parser/pubsub.rs create mode 100644 crates/prose-core-client/src/infra/xmpp/type_conversions/compose_state.rs create mode 100644 crates/prose-core-client/src/infra/xmpp/type_conversions/room_affiliation.rs delete mode 100644 crates/prose-core-client/src/util/jid_ext.rs create mode 100644 crates/prose-core-client/tests/event_parsing/connection_event_parser.rs create mode 100644 crates/prose-core-client/tests/event_parsing/main.rs create mode 100644 crates/prose-core-client/tests/event_parsing/message_event_parser.rs create mode 100644 crates/prose-core-client/tests/event_parsing/request_event_parser.rs create mode 100644 crates/prose-core-client/tests/event_parsing/room_event_parser.rs create mode 100644 crates/prose-core-client/tests/event_parsing/sidebar_bookmark_event_parser.rs create mode 100644 crates/prose-core-client/tests/event_parsing/user_event_parser.rs delete mode 100644 crates/prose-core-client/tests/user_state_event_handler.rs create mode 100644 crates/prose-wasm-utils/src/future_ext.rs create mode 100644 crates/prose-xmpp/src/stanza/message/muc_invite.rs create mode 100644 crates/prose-xmpp/src/stanza/muc/muc_user.rs diff --git a/bindings/prose-sdk-ffi/src/client.rs b/bindings/prose-sdk-ffi/src/client.rs index c79d8b10..bb5bc72a 100644 --- a/bindings/prose-sdk-ffi/src/client.rs +++ b/bindings/prose-sdk-ffi/src/client.rs @@ -62,7 +62,7 @@ impl Client { pub async fn connect(&self, password: String) -> Result<(), ConnectionError> { self.client - .connect(&self.jid.to_bare().unwrap(), password) + .connect(&self.jid.to_bare().unwrap().into(), password) .await?; Ok(()) } @@ -81,7 +81,7 @@ impl Client { let profile = self .client .user_data - .load_user_profile(&from.to_bare().unwrap()) + .load_user_profile(&from.to_bare().unwrap().into()) .await?; Ok(profile) } @@ -100,7 +100,7 @@ impl Client { let path = self .client .user_data - .load_avatar(&from.to_bare().unwrap()) + .load_avatar(&from.to_bare().unwrap().into()) .await?; Ok(path) } diff --git a/bindings/prose-sdk-ffi/src/prose_sdk_ffi.udl b/bindings/prose-sdk-ffi/src/prose_sdk_ffi.udl index 953df026..4cff3088 100644 --- a/bindings/prose-sdk-ffi/src/prose_sdk_ffi.udl +++ b/bindings/prose-sdk-ffi/src/prose_sdk_ffi.udl @@ -133,7 +133,7 @@ enum Group { "Other", }; -dictionary UserActivity { +dictionary UserStatus { string emoji; string? status; }; @@ -142,7 +142,7 @@ dictionary Contact { JID jid; string name; Availability availability; - UserActivity? activity; + UserStatus? status; Group group; }; diff --git a/bindings/prose-sdk-ffi/src/types/contact.rs b/bindings/prose-sdk-ffi/src/types/contact.rs index f79c6030..a0b9a262 100644 --- a/bindings/prose-sdk-ffi/src/types/contact.rs +++ b/bindings/prose-sdk-ffi/src/types/contact.rs @@ -5,7 +5,7 @@ use crate::types::JID; use prose_core_client::dtos::{ - Availability, Contact as CoreContact, Group as CoreGroup, UserActivity, + Availability, Contact as CoreContact, Group as CoreGroup, UserStatus, }; #[derive(Debug, PartialEq, Clone)] @@ -20,17 +20,17 @@ pub struct Contact { pub jid: JID, pub name: String, pub availability: Availability, - pub activity: Option, + pub status: Option, pub group: Group, } impl From for Contact { fn from(value: CoreContact) -> Self { Contact { - jid: value.jid.into(), + jid: value.id.into_inner().into(), name: value.name, availability: value.availability, - activity: value.activity, + status: value.status, group: value.group.into(), } } diff --git a/bindings/prose-sdk-ffi/src/types/message.rs b/bindings/prose-sdk-ffi/src/types/message.rs index 400159b3..a7e53726 100644 --- a/bindings/prose-sdk-ffi/src/types/message.rs +++ b/bindings/prose-sdk-ffi/src/types/message.rs @@ -50,7 +50,11 @@ impl From for Reaction { fn from(value: ProseReaction) -> Self { Reaction { emoji: value.emoji, - from: value.from.into_iter().map(Into::into).collect(), + from: value + .from + .into_iter() + .map(|id| id.into_inner().into()) + .collect(), } } } diff --git a/bindings/prose-sdk-ffi/src/uniffi_api.rs b/bindings/prose-sdk-ffi/src/uniffi_api.rs index a2c57210..9656321d 100644 --- a/bindings/prose-sdk-ffi/src/uniffi_api.rs +++ b/bindings/prose-sdk-ffi/src/uniffi_api.rs @@ -10,7 +10,7 @@ pub use std::path::PathBuf; pub use jid::{BareJid, Error as JidParseError, FullJid}; pub use prose_core_client::dtos::{ - Address, Availability, Emoji, MessageId, StanzaId, Url, UserActivity, UserProfile, + Address, Availability, Emoji, MessageId, StanzaId, Url, UserProfile, UserStatus, }; pub use prose_core_client::ConnectionEvent; pub use prose_xmpp::ConnectionError; diff --git a/bindings/prose-sdk-js/src/client.rs b/bindings/prose-sdk-js/src/client.rs index d876eb2d..4feda622 100644 --- a/bindings/prose-sdk-js/src/client.rs +++ b/bindings/prose-sdk-js/src/client.rs @@ -7,13 +7,13 @@ use base64::{engine::general_purpose, Engine as _}; use js_sys::Array; use wasm_bindgen::prelude::*; -use prose_core_client::dtos::{RoomJid, SoftwareVersion, UserActivity}; +use prose_core_client::dtos::{RoomId, SoftwareVersion, UserStatus}; use prose_core_client::{open_store, Client as ProseClient, IndexedDBDriver, StoreAvatarCache}; use crate::connector::{Connector, ProseConnectionProvider}; use crate::delegate::{Delegate, JSDelegate}; use crate::types::{ - try_jid_vec_from_string_array, Availability, BareJid, Channel, ChannelsArray, Contact, + try_user_id_vec_from_string_array, Availability, BareJid, Channel, ChannelsArray, Contact, ContactsArray, IntoJSArray, SidebarItem, SidebarItemsArray, UserMetadata, UserProfile, }; @@ -187,7 +187,7 @@ impl Client { /// Pass a String[] as participants where each string is a valid BareJid. #[wasm_bindgen(js_name = "startConversation")] pub async fn start_conversation(&self, participants: Array) -> Result { - let participants = try_jid_vec_from_string_array(participants)?; + let participants = try_user_id_vec_from_string_array(participants)?; Ok(self .client @@ -204,7 +204,7 @@ impl Client { /// Pass a String[] as participants where each string is a valid BareJid. #[wasm_bindgen(js_name = "createGroup")] pub async fn create_group(&self, participants: Array) -> Result { - let participants = try_jid_vec_from_string_array(participants)?; + let participants = try_user_id_vec_from_string_array(participants)?; Ok(self .client @@ -249,13 +249,24 @@ impl Client { Ok(self .client .rooms - .join_room(&RoomJid::from(room_jid.clone()), password.as_deref()) + .join_room(&RoomId::from(room_jid.clone()), password.as_deref()) .await .map_err(|err| WasmError::from(anyhow::Error::from(err)))? .into_inner() .into()) } + /// Destroys the room identified by `room_jid`. + #[wasm_bindgen(js_name = "destroyRoom")] + pub async fn destroy_room(&self, room_jid: &BareJid) -> Result<()> { + self.client + .rooms + .destroy_room(&RoomId::from(room_jid.clone())) + .await + .map_err(|err| WasmError::from(anyhow::Error::from(err)))?; + Ok(()) + } + /// XEP-0108: User Activity /// https://xmpp.org/extensions/xep-0108.html #[wasm_bindgen(js_name = "sendActivity")] @@ -265,7 +276,7 @@ impl Client { text: Option, ) -> Result<()> { let user_activity = if let Some(icon) = &icon { - Some(UserActivity { + Some(UserStatus { emoji: icon.clone(), status: text.clone(), }) diff --git a/bindings/prose-sdk-js/src/delegate.rs b/bindings/prose-sdk-js/src/delegate.rs index dbf3215c..da6c107e 100644 --- a/bindings/prose-sdk-js/src/delegate.rs +++ b/bindings/prose-sdk-js/src/delegate.rs @@ -7,7 +7,7 @@ use tracing::warn; use wasm_bindgen::prelude::*; use prose_core_client::dtos::MessageId; -use prose_core_client::{ClientDelegate, ClientEvent, ConnectionEvent, RoomEventType}; +use prose_core_client::{ClientDelegate, ClientEvent, ClientRoomEventType, ConnectionEvent}; use prose_xmpp::ConnectionError; use crate::client::Client; @@ -119,6 +119,13 @@ extern "C" { client: Client, room: JsValue, ) -> Result<(), JsValue>; + + #[wasm_bindgen(method, catch, js_name = "roomParticipantsChanged")] + fn room_participants_changed( + this: &JSDelegate, + client: Client, + room: JsValue, + ) -> Result<(), JsValue>; } #[wasm_bindgen(getter_with_clone)] @@ -198,32 +205,31 @@ impl Delegate { .inner .client_disconnected(client, error.map(Into::into))?, ClientEvent::SidebarChanged => self.inner.sidebar_changed(client)?, - ClientEvent::ContactChanged { jid } => { - self.inner.contact_changed(client, jid.into())? + ClientEvent::ContactChanged { id: jid } => self + .inner + .contact_changed(client, jid.into_inner().into())?, + ClientEvent::AvatarChanged { id: jid } => { + self.inner.avatar_changed(client, jid.into_inner().into())? } - ClientEvent::AvatarChanged { jid } => self.inner.avatar_changed(client, jid.into())?, ClientEvent::RoomChanged { room, r#type } => match r#type { - RoomEventType::MessagesAppended { message_ids } => self.inner.messages_appended( - client, - room.into_js_value(), - message_ids.into_js_array(), - )?, - RoomEventType::MessagesUpdated { message_ids } => self.inner.messages_updated( - client, - room.into_js_value(), - message_ids.into_js_array(), - )?, - RoomEventType::MessagesDeleted { message_ids } => self.inner.messages_deleted( - client, - room.into_js_value(), - message_ids.into_js_array(), - )?, - RoomEventType::ComposingUsersChanged => self + ClientRoomEventType::MessagesAppended { message_ids } => self + .inner + .messages_appended(client, room.into_js_value(), message_ids.into_js_array())?, + ClientRoomEventType::MessagesUpdated { message_ids } => self + .inner + .messages_updated(client, room.into_js_value(), message_ids.into_js_array())?, + ClientRoomEventType::MessagesDeleted { message_ids } => self + .inner + .messages_deleted(client, room.into_js_value(), message_ids.into_js_array())?, + ClientRoomEventType::ComposingUsersChanged => self .inner .composing_users_changed(client, room.into_js_value())?, - RoomEventType::AttributesChanged => self + ClientRoomEventType::AttributesChanged => self .inner .room_attributes_changed(client, room.into_js_value())?, + ClientRoomEventType::ParticipantsChanged => self + .inner + .room_participants_changed(client, room.into_js_value())?, }, } Ok(()) diff --git a/bindings/prose-sdk-js/src/types/contact.rs b/bindings/prose-sdk-js/src/types/contact.rs index 2b2a8716..2abc454f 100644 --- a/bindings/prose-sdk-js/src/types/contact.rs +++ b/bindings/prose-sdk-js/src/types/contact.rs @@ -5,7 +5,7 @@ use prose_core_client::dtos::{ Availability as CoreAvailability, Contact as CoreContact, Group as CoreGroup, - UserActivity as CoreUserActivity, + UserStatus as CoreUserStatus, }; use wasm_bindgen::prelude::*; @@ -48,13 +48,13 @@ impl From for CoreAvailability { } #[wasm_bindgen] -pub struct UserActivity(CoreUserActivity); +pub struct UserStatus(CoreUserStatus); #[wasm_bindgen] impl Contact { #[wasm_bindgen(getter)] pub fn jid(&self) -> BareJid { - self.0.jid.clone().into() + self.0.id.clone().into_inner().into() } #[wasm_bindgen(getter)] @@ -68,11 +68,11 @@ impl Contact { } #[wasm_bindgen(getter)] - pub fn activity(&self) -> Option { + pub fn status(&self) -> Option { self.0 - .activity + .status .as_ref() - .map(|activity| UserActivity(activity.clone())) + .map(|activity| UserStatus(activity.clone())) } #[wasm_bindgen(getter)] @@ -84,10 +84,10 @@ impl Contact { } #[wasm_bindgen] -impl UserActivity { +impl UserStatus { #[wasm_bindgen(constructor)] pub fn new(icon: &str, status: Option) -> Self { - UserActivity(CoreUserActivity { + UserStatus(CoreUserStatus { emoji: icon.to_string(), status: status.clone(), }) diff --git a/bindings/prose-sdk-js/src/types/jid.rs b/bindings/prose-sdk-js/src/types/jid.rs index cbf5d60c..08625440 100644 --- a/bindings/prose-sdk-js/src/types/jid.rs +++ b/bindings/prose-sdk-js/src/types/jid.rs @@ -6,9 +6,10 @@ use core::fmt::{Debug, Display, Formatter}; use core::str::FromStr; -use prose_core_client::dtos::RoomJid; use wasm_bindgen::prelude::*; +use prose_core_client::dtos::{RoomId, UserId}; + #[derive(Debug, PartialEq, Clone)] #[wasm_bindgen(js_name = "JID")] pub struct BareJid(jid::BareJid); @@ -90,8 +91,20 @@ impl Display for BareJid { } } -impl From for RoomJid { +impl From for RoomId { + fn from(value: BareJid) -> Self { + RoomId::from(value.0) + } +} + +impl From for UserId { fn from(value: BareJid) -> Self { - RoomJid::from(value.0) + UserId::from(value.0) + } +} + +impl From<&BareJid> for UserId { + fn from(value: &BareJid) -> Self { + UserId::from(value.0.clone()) } } diff --git a/bindings/prose-sdk-js/src/types/js_array.rs b/bindings/prose-sdk-js/src/types/js_array.rs index 392b8650..9c1a08ff 100644 --- a/bindings/prose-sdk-js/src/types/js_array.rs +++ b/bindings/prose-sdk-js/src/types/js_array.rs @@ -3,12 +3,13 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::client::WasmError; use anyhow::anyhow; -use core::str::FromStr; use js_sys::Array; use wasm_bindgen::prelude::*; +use prose_core_client::dtos::UserId; + +use crate::client::WasmError; use crate::types::Message; #[wasm_bindgen] @@ -97,7 +98,7 @@ impl TryFrom<&StringArray> for Vec { } } -pub fn try_jid_vec_from_string_array(arr: Array) -> Result, WasmError> { +pub fn try_user_id_vec_from_string_array(arr: Array) -> Result, WasmError> { arr.into_iter() .map(|value| { value @@ -105,7 +106,7 @@ pub fn try_jid_vec_from_string_array(arr: Array) -> Result, Wa .ok_or(anyhow::format_err!( "Could not read String from supposed String Array" )) - .and_then(|str| jid::BareJid::from_str(&str).map_err(Into::into)) + .and_then(|str| str.parse::().map_err(Into::into)) }) .collect::, _>>() .map_err(WasmError::from) diff --git a/bindings/prose-sdk-js/src/types/message.rs b/bindings/prose-sdk-js/src/types/message.rs index 9e959d87..0a180098 100644 --- a/bindings/prose-sdk-js/src/types/message.rs +++ b/bindings/prose-sdk-js/src/types/message.rs @@ -133,7 +133,11 @@ impl From for Reaction { fn from(value: dtos::Reaction) -> Self { Reaction { emoji: value.emoji.into_inner(), - from: value.from.into_iter().map(Into::into).collect(), + from: value + .from + .into_iter() + .map(|id| id.into_inner().into()) + .collect(), } } } diff --git a/bindings/prose-sdk-js/src/types/mod.rs b/bindings/prose-sdk-js/src/types/mod.rs index 13f10a46..4ac41a50 100644 --- a/bindings/prose-sdk-js/src/types/mod.rs +++ b/bindings/prose-sdk-js/src/types/mod.rs @@ -10,7 +10,10 @@ pub use js_array::*; pub use message::Message; pub use room::RoomEnvelopeExt; pub use sidebar_item::{SidebarItem, SidebarItemsArray}; -pub use user_info::{UserBasicInfo, UserBasicInfoArray, UserPresenceInfo, UserPresenceInfoArray}; +pub use user_info::{ + ParticipantInfo, ParticipantInfoArray, UserBasicInfo, UserBasicInfoArray, UserPresenceInfo, + UserPresenceInfoArray, +}; pub use user_metadata::UserMetadata; pub use user_profile::UserProfile; diff --git a/bindings/prose-sdk-js/src/types/room.rs b/bindings/prose-sdk-js/src/types/room.rs index ee5302e3..a1683e09 100644 --- a/bindings/prose-sdk-js/src/types/room.rs +++ b/bindings/prose-sdk-js/src/types/room.rs @@ -15,8 +15,8 @@ use prose_core_client::services::{ use crate::client::WasmError; use crate::types::{ - try_jid_vec_from_string_array, MessagesArray, StringArray, UserBasicInfo, UserBasicInfoArray, - UserPresenceInfo, UserPresenceInfoArray, + try_user_id_vec_from_string_array, MessagesArray, ParticipantInfo, ParticipantInfoArray, + StringArray, UserBasicInfo, UserBasicInfoArray, }; use super::IntoJSArray; @@ -31,10 +31,7 @@ export interface RoomBase { readonly type: RoomType; readonly id: RoomID; readonly name: string; - /// The members of a room. Only available for DirectMessage and Group (member-only rooms) - readonly members: UserPresenceInfo[]; - /// The occupants of a room. - readonly occupants: UserBasicInfo[]; + readonly participants: ParticipantInfo[]; sendMessage(body: string): Promise; updateMessage(messageID: string, body: string): Promise; @@ -157,21 +154,12 @@ macro_rules! base_room_impl { } #[wasm_bindgen(getter)] - pub fn members(&self) -> UserPresenceInfoArray { + pub fn participants(&self) -> ParticipantInfoArray { self.room - .members() + .participants() .into_iter() - .map(UserPresenceInfo::from) - .collect_into_js_array::() - } - - #[wasm_bindgen(getter)] - pub fn occupants(&self) -> UserBasicInfoArray { - self.room - .occupants() - .into_iter() - .map(UserBasicInfo::from) - .collect_into_js_array::() + .map(ParticipantInfo::from) + .collect_into_js_array::() } #[wasm_bindgen(js_name = "sendMessage")] @@ -291,10 +279,7 @@ macro_rules! muc_room_impl { #[wasm_bindgen(js_name = "setTopic")] pub async fn set_topic(&self, topic: Option) -> Result<()> { - self.room - .set_topic(topic.as_deref()) - .await - .map_err(WasmError::from)?; + self.room.set_topic(topic).await.map_err(WasmError::from)?; Ok(()) } } @@ -323,7 +308,7 @@ macro_rules! channel_room_impl { impl $t { #[wasm_bindgen(js_name = "inviteUsers")] pub async fn invite_users(&self, users: Array) -> Result<()> { - let users = try_jid_vec_from_string_array(users)?; + let users = try_user_id_vec_from_string_array(users)?; self.room .invite_users(users.as_slice()) .await diff --git a/bindings/prose-sdk-js/src/types/user_info.rs b/bindings/prose-sdk-js/src/types/user_info.rs index 0cbacc1b..829f4daa 100644 --- a/bindings/prose-sdk-js/src/types/user_info.rs +++ b/bindings/prose-sdk-js/src/types/user_info.rs @@ -6,6 +6,7 @@ use wasm_bindgen::prelude::wasm_bindgen; use prose_core_client::dtos::{ + ParticipantInfo as SdkParticipantInfo, RoomAffiliation as SdkRoomAffiliation, UserBasicInfo as SdkUserBasicInfo, UserPresenceInfo as SdkUserPresenceInfo, }; @@ -33,7 +34,7 @@ impl UserBasicInfo { impl From for UserBasicInfo { fn from(value: SdkUserBasicInfo) -> Self { Self { - jid: value.jid.into(), + jid: value.id.into_inner().into(), name: value.name, } } @@ -67,13 +68,84 @@ impl UserPresenceInfo { impl From for UserPresenceInfo { fn from(value: SdkUserPresenceInfo) -> Self { Self { - jid: value.jid.into(), + jid: value.id.into_inner().into(), name: value.name, availability: value.availability.into(), } } } +#[wasm_bindgen] +#[derive(Clone)] +pub enum RoomAffiliation { + Outcast = 0, + None = 1, + Member = 2, + Admin = 3, + Owner = 4, +} + +impl From for RoomAffiliation { + fn from(value: SdkRoomAffiliation) -> Self { + match value { + SdkRoomAffiliation::Outcast => RoomAffiliation::Outcast, + SdkRoomAffiliation::None => RoomAffiliation::None, + SdkRoomAffiliation::Member => RoomAffiliation::Member, + SdkRoomAffiliation::Admin => RoomAffiliation::Admin, + SdkRoomAffiliation::Owner => RoomAffiliation::Owner, + } + } +} + +#[wasm_bindgen] +pub struct ParticipantInfo { + jid: Option, + name: String, + is_self: bool, + availability: Availability, + affiliation: RoomAffiliation, +} + +#[wasm_bindgen] +impl ParticipantInfo { + #[wasm_bindgen(getter)] + pub fn name(&self) -> String { + self.name.clone() + } + + #[wasm_bindgen(getter)] + pub fn jid(&self) -> Option { + self.jid.clone() + } + + #[wasm_bindgen(getter, js_name = "isSelf")] + pub fn is_self(&self) -> bool { + self.is_self + } + + #[wasm_bindgen(getter)] + pub fn availability(&self) -> Availability { + self.availability.clone() + } + + #[wasm_bindgen(getter)] + pub fn affiliation(&self) -> RoomAffiliation { + self.affiliation.clone() + } +} + +impl From for ParticipantInfo { + fn from(value: SdkParticipantInfo) -> Self { + Self { + jid: value.id.map(|id| id.into_inner().into()), + name: value.name, + is_self: value.is_self, + availability: value.availability.into(), + affiliation: value.affiliation.into(), + } + } +} + #[wasm_bindgen] extern "C" { #[wasm_bindgen(typescript_type = "UserBasicInfo[]")] @@ -81,4 +153,7 @@ extern "C" { #[wasm_bindgen(typescript_type = "UserPresenceInfo[]")] pub type UserPresenceInfoArray; + + #[wasm_bindgen(typescript_type = "ParticipantInfo[]")] + pub type ParticipantInfoArray; } diff --git a/crates/prose-core-client/Cargo.toml b/crates/prose-core-client/Cargo.toml index d8b13cf4..6985ca38 100644 --- a/crates/prose-core-client/Cargo.toml +++ b/crates/prose-core-client/Cargo.toml @@ -15,6 +15,7 @@ authors = ["Valerian Saliou "] anyhow = { workspace = true } async-trait = { workspace = true } base64 = { workspace = true } +futures = { workspace = true } indexmap = "2.0.0" itertools = { workspace = true } jid = { workspace = true, features = ["serde"] } @@ -55,6 +56,7 @@ chrono = { workspace = true, features = ["serde"] } uniffi = { workspace = true, features = ["build"] } [features] +debug = [] default = [] test = ["prose-xmpp/test", "dep:tokio", "tokio/macros", "dep:mockall", "dep:derivative"] -debug = [] \ No newline at end of file +trace-stanzas = ["prose-xmpp/trace-stanzas"] \ No newline at end of file diff --git a/crates/prose-core-client/src/app/deps/app_context.rs b/crates/prose-core-client/src/app/deps/app_context.rs index f0f62b92..4ad75bf2 100644 --- a/crates/prose-core-client/src/app/deps/app_context.rs +++ b/crates/prose-core-client/src/app/deps/app_context.rs @@ -6,11 +6,12 @@ use std::sync::atomic::AtomicBool; use anyhow::Result; -use jid::{BareJid, FullJid}; +use jid::BareJid; use parking_lot::RwLock; use crate::domain::connection::models::ConnectionProperties; use crate::domain::general::models::{Capabilities, SoftwareVersion}; +use crate::dtos::UserResourceId; pub struct AppContext { pub connection_properties: RwLock>, @@ -31,7 +32,7 @@ impl AppContext { } impl AppContext { - pub fn connected_jid(&self) -> Result { + pub fn connected_id(&self) -> Result { self.connection_properties .read() .as_ref() diff --git a/crates/prose-core-client/src/app/deps/app_dependencies.rs b/crates/prose-core-client/src/app/deps/app_dependencies.rs index fe3267aa..3c5eb56f 100644 --- a/crates/prose-core-client/src/app/deps/app_dependencies.rs +++ b/crates/prose-core-client/src/app/deps/app_dependencies.rs @@ -15,7 +15,9 @@ use crate::domain::contacts::repos::ContactsRepository; use crate::domain::contacts::services::ContactsService; use crate::domain::general::services::RequestHandlingService; use crate::domain::messaging::repos::{DraftsRepository, MessagesRepository}; -use crate::domain::messaging::services::{MessageArchiveService, MessagingService}; +use crate::domain::messaging::services::{ + MessageArchiveService, MessageMigrationDomainService, MessagingService, +}; use crate::domain::rooms::repos::{ConnectedRoomsReadOnlyRepository, ConnectedRoomsRepository}; use crate::domain::rooms::services::{ RoomAttributesService, RoomFactory, RoomManagementService, RoomParticipationService, @@ -59,6 +61,7 @@ pub type DynUserInfoRepository = Arc; pub type DynUserInfoService = Arc; pub type DynUserProfileRepository = Arc; pub type DynUserProfileService = Arc; +pub type DynMessageMigrationDomainService = Arc; pub struct AppDependencies { pub account_settings_repo: DynAccountSettingsRepository, diff --git a/crates/prose-core-client/src/app/dtos/contact.rs b/crates/prose-core-client/src/app/dtos/contact.rs index e775f0b0..d867ff31 100644 --- a/crates/prose-core-client/src/app/dtos/contact.rs +++ b/crates/prose-core-client/src/app/dtos/contact.rs @@ -3,17 +3,15 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::BareJid; - use crate::domain::contacts::models::Group; -use crate::domain::shared::models::Availability; -use crate::domain::user_info::models::UserActivity; +use crate::domain::shared::models::{Availability, UserId}; +use crate::domain::user_info::models::UserStatus; #[derive(Debug, PartialEq, Clone)] pub struct Contact { - pub jid: BareJid, + pub id: UserId, pub name: String, pub availability: Availability, - pub activity: Option, + pub status: Option, pub group: Group, } diff --git a/crates/prose-core-client/src/app/dtos/mod.rs b/crates/prose-core-client/src/app/dtos/mod.rs index 8d2092e0..35c90713 100644 --- a/crates/prose-core-client/src/app/dtos/mod.rs +++ b/crates/prose-core-client/src/app/dtos/mod.rs @@ -13,9 +13,12 @@ pub use crate::domain::{ contacts::models::Group, general::models::SoftwareVersion, messaging::models::{Emoji, MessageId, Reaction, StanzaId}, - rooms::models::{Member, Occupant, PublicRoomInfo}, - shared::models::{Availability, RoomJid, UserBasicInfo, UserPresenceInfo}, - user_info::models::{LastActivity, UserActivity, UserInfo, UserMetadata}, + rooms::models::{Participant, PublicRoomInfo, RoomAffiliation}, + shared::models::{ + Availability, OccupantId, ParticipantInfo, RoomId, UserBasicInfo, UserId, UserPresenceInfo, + UserResourceId, + }, + user_info::models::{LastActivity, UserInfo, UserMetadata, UserStatus}, user_profiles::models::{Address, UserProfile}, }; diff --git a/crates/prose-core-client/src/app/event_handlers/bookmarks_event_handler.rs b/crates/prose-core-client/src/app/event_handlers/bookmarks_event_handler.rs index ffdbe5b7..abcee839 100644 --- a/crates/prose-core-client/src/app/event_handlers/bookmarks_event_handler.rs +++ b/crates/prose-core-client/src/app/event_handlers/bookmarks_event_handler.rs @@ -3,24 +3,13 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use std::str::FromStr; - use anyhow::Result; use async_trait::async_trait; -use itertools::partition; -use tracing::warn; -use xmpp_parsers::pubsub::event::Item; -use xmpp_parsers::pubsub::{ItemId, PubSubEvent}; use prose_proc_macros::InjectDependencies; -use prose_xmpp::mods::pubsub; -use prose_xmpp::Event; use crate::app::deps::DynSidebarDomainService; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; -use crate::domain::sidebar::models::Bookmark; -use crate::dtos::RoomJid; -use crate::infra::xmpp::type_conversions::bookmark::ns; +use crate::app::event_handlers::{ServerEvent, ServerEventHandler, SidebarBookmarkEvent}; #[derive(InjectDependencies)] pub struct BookmarksEventHandler { @@ -30,107 +19,38 @@ pub struct BookmarksEventHandler { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -impl XMPPEventHandler for BookmarksEventHandler { +impl ServerEventHandler for BookmarksEventHandler { fn name(&self) -> &'static str { "bookmarks" } - async fn handle_event(&self, event: XMPPEvent) -> Result> { + async fn handle_event(&self, event: ServerEvent) -> Result> { match event { - Event::PubSub(event) => match event { - pubsub::Event::PubSubMessage { mut message } => { - let partition_idx = partition(&mut message.events, |event| { - let node = match event { - PubSubEvent::Configuration { node, .. } => node, - PubSubEvent::Delete { node, .. } => node, - PubSubEvent::PublishedItems { node, .. } => node, - PubSubEvent::RetractedItems { node, .. } => node, - PubSubEvent::Purge { node, .. } => node, - PubSubEvent::Subscription { node, .. } => node, - }; - node.0 != ns::PROSE_BOOKMARK - }); - self.handle_pubsub_events(message.events.drain(partition_idx..)) - .await?; - - if message.events.is_empty() { - return Ok(None); - } - - Ok(Some(Event::PubSub(pubsub::Event::PubSubMessage { - message, - }))) - } - }, - _ => Ok(Some(event)), + ServerEvent::SidebarBookmark(event) => self.handle_bookmark_event(event).await?, + _ => return Ok(Some(event)), } + Ok(None) } } impl BookmarksEventHandler { - async fn handle_pubsub_events( - &self, - events: impl IntoIterator, - ) -> Result<()> { - for event in events { - match event { - PubSubEvent::PublishedItems { items, .. } => { - self.handle_added_or_updated_items(items).await? - } - PubSubEvent::RetractedItems { items, .. } => { - self.handle_retracted_items(items).await? - } - PubSubEvent::Purge { .. } | PubSubEvent::Delete { .. } => { - self.handle_purge().await? - } - PubSubEvent::Configuration { .. } => {} - PubSubEvent::Subscription { .. } => {} + async fn handle_bookmark_event(&self, event: SidebarBookmarkEvent) -> Result<()> { + match event { + SidebarBookmarkEvent::AddedOrUpdated { bookmarks } => { + self.sidebar_domain_service + .extend_items_from_bookmarks(bookmarks) + .await?; + } + SidebarBookmarkEvent::Deleted { ids } => { + self.sidebar_domain_service + .handle_removed_items(ids.as_slice()) + .await?; + } + SidebarBookmarkEvent::Purged => { + self.sidebar_domain_service.handle_remote_purge().await?; } } Ok(()) } - - async fn handle_added_or_updated_items(&self, items: Vec) -> Result<()> { - let bookmarks = items - .into_iter() - .filter_map(|item| { - let Some(payload) = item.0.payload else { - warn!("Encountered missing payload in PubSub item for bookmark"); - return None; - }; - - let Ok(bookmark) = Bookmark::try_from(payload) else { - warn!("Encountered invalid payload in PubSub item for bookmark"); - return None; - }; - - Some(bookmark) - }) - .collect::>(); - - self.sidebar_domain_service - .extend_items_from_bookmarks(bookmarks) - .await?; - Ok(()) - } - - async fn handle_retracted_items(&self, ids: Vec) -> Result<()> { - let jids = ids - .into_iter() - .map(|id| RoomJid::from_str(&id.0)) - .collect::, _>>()?; - let jids_refs = jids.iter().collect::>(); - - self.sidebar_domain_service - .handle_removed_items(jids_refs.as_slice()) - .await?; - - Ok(()) - } - - async fn handle_purge(&self) -> Result<()> { - self.sidebar_domain_service.handle_remote_purge().await?; - Ok(()) - } } diff --git a/crates/prose-core-client/src/app/event_handlers/client_event_dispatcher.rs b/crates/prose-core-client/src/app/event_handlers/client_event_dispatcher.rs index 3306e680..c060a2f5 100644 --- a/crates/prose-core-client/src/app/event_handlers/client_event_dispatcher.rs +++ b/crates/prose-core-client/src/app/event_handlers/client_event_dispatcher.rs @@ -9,7 +9,8 @@ use crate::app::deps::DynRoomFactory; use crate::app::event_handlers::ClientEventDispatcherTrait; use crate::client::ClientInner; use crate::domain::rooms::models::RoomInternals; -use crate::{ClientDelegate, ClientEvent, RoomEventType}; +use crate::domain::shared::models::RoomType; +use crate::{ClientDelegate, ClientEvent, ClientRoomEventType}; pub struct ClientEventDispatcher { client_inner: OnceLock>, @@ -59,7 +60,12 @@ impl ClientEventDispatcherTrait for ClientEventDispatcher { delegate.handle_event(client_inner.into(), event) } - fn dispatch_room_event(&self, room: Arc, event: RoomEventType) { + fn dispatch_room_event(&self, room: Arc, event: ClientRoomEventType) { + // We're not sending events for rooms that are still pending… + if room.r#type == RoomType::Pending { + return; + } + let Some(ref delegate) = self.delegate else { return; }; diff --git a/crates/prose-core-client/src/app/event_handlers/connection_event_handler.rs b/crates/prose-core-client/src/app/event_handlers/connection_event_handler.rs index 545e7ee4..cf77ac30 100644 --- a/crates/prose-core-client/src/app/event_handlers/connection_event_handler.rs +++ b/crates/prose-core-client/src/app/event_handlers/connection_event_handler.rs @@ -9,12 +9,10 @@ use anyhow::Result; use async_trait::async_trait; use prose_proc_macros::InjectDependencies; -use prose_xmpp::{client, Event}; use crate::app::deps::{DynAppContext, DynClientEventDispatcher}; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; -use crate::client_event::ConnectionEvent; -use crate::ClientEvent; +use crate::app::event_handlers::{ConnectionEvent, ServerEvent, ServerEventHandler}; +use crate::{ClientEvent, ConnectionEvent as ClientConnectionEvent}; #[derive(InjectDependencies)] pub struct ConnectionEventHandler { @@ -26,31 +24,36 @@ pub struct ConnectionEventHandler { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -impl XMPPEventHandler for ConnectionEventHandler { +impl ServerEventHandler for ConnectionEventHandler { fn name(&self) -> &'static str { "connection" } - async fn handle_event(&self, event: XMPPEvent) -> Result> { + async fn handle_event(&self, event: ServerEvent) -> Result> { match event { - Event::Client(event) => match event { - client::Event::Connected => { - // We'll send an event from our `connect` method since we need to gather - // information about the server first. Once we'll fire the event SDK consumers - // can be sure that we have everything we need. - Ok(None) - } - client::Event::Disconnected { error } => { - self.ctx.is_observing_rooms.store(false, Ordering::Relaxed); - self.client_event_dispatcher.dispatch_event( - ClientEvent::ConnectionStatusChanged { - event: ConnectionEvent::Disconnect { error }, - }, - ); - Ok(None) - } - }, - _ => Ok(Some(event)), + ServerEvent::Connection(event) => self.handle_connection_event(event).await?, + _ => return Ok(Some(event)), } + Ok(None) + } +} + +impl ConnectionEventHandler { + async fn handle_connection_event(&self, event: ConnectionEvent) -> Result<()> { + match event { + ConnectionEvent::Connected => { + // We'll send an event from our `connect` method since we need to gather + // information about the server first. Once we'll fire the event SDK consumers + // can be sure that we have everything we need. + } + ConnectionEvent::Disconnected { error } => { + self.ctx.is_observing_rooms.store(false, Ordering::Relaxed); + self.client_event_dispatcher + .dispatch_event(ClientEvent::ConnectionStatusChanged { + event: ClientConnectionEvent::Disconnect { error }, + }); + } + } + Ok(()) } } diff --git a/crates/prose-core-client/src/app/event_handlers/event_handler_queue.rs b/crates/prose-core-client/src/app/event_handlers/event_handler_queue.rs index 794ea90c..dbe143bb 100644 --- a/crates/prose-core-client/src/app/event_handlers/event_handler_queue.rs +++ b/crates/prose-core-client/src/app/event_handlers/event_handler_queue.rs @@ -7,32 +7,51 @@ use std::sync::OnceLock; use tracing::error; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; +use prose_xmpp::Event as XMPPEvent; -pub struct XMPPEventHandlerQueue { - handlers: OnceLock>>, +use crate::app::event_handlers::{ServerEvent, ServerEventHandler}; +use crate::infra::xmpp::event_parser::parse_xmpp_event; + +pub struct ServerEventHandlerQueue { + handlers: OnceLock>>, } -impl XMPPEventHandlerQueue { +impl ServerEventHandlerQueue { pub fn new() -> Self { Self { handlers: Default::default(), } } - pub fn set_handlers(&self, handlers: Vec>) { + pub fn set_handlers(&self, handlers: Vec>) { self.handlers .set(handlers) .map_err(|_| ()) - .expect("Tried to applied handlers XMPPEventHandlerQueue more than once"); + .expect("Tried to applied handlers ServerEventHandlerQueue more than once"); } pub async fn handle_event(&self, event: XMPPEvent) { + let events = match parse_xmpp_event(event) { + Ok(event) => event, + Err(err) => { + error!("Failed to parse XMPP event. Reason: {}", err.to_string()); + return; + } + }; + + for event in events { + self.handle_server_event(event).await + } + } +} + +impl ServerEventHandlerQueue { + async fn handle_server_event(&self, event: ServerEvent) { let mut event = event; let handlers = self .handlers .get() - .expect("Handlers were not set in XMPPEventHandlerQueue"); + .expect("Handlers were not set in ServerEventHandlerQueue"); for handler in handlers.iter() { match handler.handle_event(event).await { diff --git a/crates/prose-core-client/src/app/event_handlers/messages_event_handler.rs b/crates/prose-core-client/src/app/event_handlers/messages_event_handler.rs index 42f8c3d3..2b5bde33 100644 --- a/crates/prose-core-client/src/app/event_handlers/messages_event_handler.rs +++ b/crates/prose-core-client/src/app/event_handlers/messages_event_handler.rs @@ -5,25 +5,22 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use tracing::{debug, error}; use xmpp_parsers::message::MessageType; use prose_proc_macros::InjectDependencies; -use prose_xmpp::mods::chat; use prose_xmpp::mods::chat::Carbon; use prose_xmpp::stanza::Message; -use prose_xmpp::Event; use crate::app::deps::{ DynClientEventDispatcher, DynConnectedRoomsReadOnlyRepository, DynMessagesRepository, DynMessagingService, DynSidebarDomainService, DynTimeProvider, }; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; +use crate::app::event_handlers::{MessageEvent, MessageEventType, ServerEvent, ServerEventHandler}; use crate::domain::messaging::models::{MessageLike, MessageLikeError, TimestampedMessage}; use crate::domain::rooms::services::CreateOrEnterRoomRequest; -use crate::domain::shared::models::RoomJid; -use crate::RoomEventType; +use crate::domain::shared::models::{RoomId, UserId}; +use crate::ClientRoomEventType; #[derive(InjectDependencies)] pub struct MessagesEventHandler { @@ -43,32 +40,19 @@ pub struct MessagesEventHandler { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -impl XMPPEventHandler for MessagesEventHandler { +impl ServerEventHandler for MessagesEventHandler { fn name(&self) -> &'static str { "messages" } - async fn handle_event(&self, event: XMPPEvent) -> Result> { + async fn handle_event(&self, event: ServerEvent) -> Result> { match event { - Event::Chat(event) => match event { - chat::Event::Message(message) => { - self.handle_received_message(ReceivedMessage::Message(message)) - .await?; - Ok(None) - } - chat::Event::Carbon(carbon) => { - self.handle_received_message(ReceivedMessage::Carbon(carbon)) - .await?; - Ok(None) - } - chat::Event::Sent(message) => { - self.handle_sent_message(message).await?; - Ok(None) - } - _ => Ok(Some(Event::Chat(event))), - }, - _ => Ok(Some(event)), + ServerEvent::Message(event) => { + self.handle_message_event(event).await?; + } + _ => return Ok(Some(event)), } + Ok(None) } } @@ -85,7 +69,7 @@ impl ReceivedMessage { } } - pub fn sender(&self) -> Option { + pub fn sender(&self) -> Option { match &self { ReceivedMessage::Message(message) => message.from.as_ref().map(|jid| jid.to_bare()), ReceivedMessage::Carbon(Carbon::Received(message)) => message @@ -99,6 +83,7 @@ impl ReceivedMessage { .and_then(|message| message.to.as_ref()) .map(|jid| jid.to_bare()), } + .map(UserId::from) } pub fn r#type(&self) -> Option { @@ -115,13 +100,28 @@ impl ReceivedMessage { } impl MessagesEventHandler { + async fn handle_message_event(&self, event: MessageEvent) -> Result<()> { + match event.r#type { + MessageEventType::Received(message) => { + self.handle_received_message(ReceivedMessage::Message(message)) + .await? + } + MessageEventType::Sync(carbon) => { + self.handle_received_message(ReceivedMessage::Carbon(carbon)) + .await? + } + MessageEventType::Sent(message) => self.handle_sent_message(message).await?, + } + Ok(()) + } + async fn handle_received_message(&self, message: ReceivedMessage) -> Result<()> { let Some(from) = message.sender() else { error!("Received message from unknown sender."); return Ok(()); }; - let from = RoomJid::from(from); + let from = RoomId::from(from.into_inner()); let mut room = self.connected_rooms_repo.get(&from); @@ -129,7 +129,7 @@ impl MessagesEventHandler { self.sidebar_domain_service .insert_item_by_creating_or_joining_room( CreateOrEnterRoomRequest::JoinDirectMessage { - participant: from.clone().into_inner(), + participant: UserId::from(from.clone().into_inner()), }, ) .await?; @@ -143,7 +143,7 @@ impl MessagesEventHandler { if let ReceivedMessage::Message(message) = &message { if let Some(subject) = &message.subject() { - room.set_topic((!subject.is_empty()).then_some(subject)); + room.set_topic((!subject.is_empty()).then_some(subject.to_string())); return Ok(()); } } @@ -190,7 +190,7 @@ impl MessagesEventHandler { } self.client_event_dispatcher - .dispatch_room_event(room.clone(), RoomEventType::from(&message)); + .dispatch_room_event(room.clone(), ClientRoomEventType::from(&message)); // Don't send delivery receipts for carbons or anything other than a regular message. if message_is_carbon || !message.payload.is_message() { @@ -199,7 +199,7 @@ impl MessagesEventHandler { if let Some(message_id) = message.id.into_original_id() { self.messaging_service - .send_read_receipt(&room.jid, &room.r#type, &message_id) + .send_read_receipt(&room.room_id, &room.r#type, &message_id) .await?; } @@ -212,7 +212,7 @@ impl MessagesEventHandler { return Ok(()); }; - let to = RoomJid::from(to.to_bare()); + let to = RoomId::from(to.to_bare()); let Some(room) = self.connected_rooms_repo.get(&to) else { error!("Sent message to recipient for which we do not have a room."); @@ -228,7 +228,7 @@ impl MessagesEventHandler { self.messages_repo.append(&to, &[&message]).await?; self.client_event_dispatcher - .dispatch_room_event(room, RoomEventType::from(&message)); + .dispatch_room_event(room, ClientRoomEventType::from(&message)); Ok(()) } diff --git a/crates/prose-core-client/src/app/event_handlers/mod.rs b/crates/prose-core-client/src/app/event_handlers/mod.rs index 619dcff8..9c85ff0a 100644 --- a/crates/prose-core-client/src/app/event_handlers/mod.rs +++ b/crates/prose-core-client/src/app/event_handlers/mod.rs @@ -11,16 +11,17 @@ use async_trait::async_trait; pub use bookmarks_event_handler::BookmarksEventHandler; pub use client_event_dispatcher::ClientEventDispatcher; pub use connection_event_handler::ConnectionEventHandler; -pub use event_handler_queue::XMPPEventHandlerQueue; +pub use event_handler_queue::ServerEventHandlerQueue; pub use messages_event_handler::MessagesEventHandler; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; pub use prose_xmpp::Event as XMPPEvent; pub use requests_event_handler::RequestsEventHandler; pub use rooms_event_handler::RoomsEventHandler; +pub use server_event::*; pub use user_state_event_handler::UserStateEventHandler; use crate::domain::rooms::models::RoomInternals; -use crate::{ClientEvent, RoomEventType}; +use crate::{ClientEvent, ClientRoomEventType}; mod bookmarks_event_handler; mod client_event_dispatcher; @@ -29,23 +30,24 @@ mod event_handler_queue; mod messages_event_handler; mod requests_event_handler; mod rooms_event_handler; +mod server_event; mod user_state_event_handler; -/// `XMPPEventHandler` is a trait representing a handler for XMPP events. +/// `ServerEventHandler` is a trait representing a handler for XMPP events. /// /// Implementors of this trait should provide a `handle_event` method, which takes an `XMPPEvent` -/// and returns an `Option`. If the handler returns `None`, it means the event has been +/// and returns an `Option`. If the handler returns `None`, it means the event has been /// consumed and no further processing should be done. If it returns `Some(event)`, the event is /// not consumed and should be passed to the next handler. #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -pub trait XMPPEventHandler: SendUnlessWasm + SyncUnlessWasm { +pub trait ServerEventHandler: SendUnlessWasm + SyncUnlessWasm { fn name(&self) -> &'static str; - async fn handle_event(&self, event: XMPPEvent) -> Result>; + async fn handle_event(&self, event: ServerEvent) -> Result>; } #[cfg_attr(feature = "test", mockall::automock)] pub trait ClientEventDispatcherTrait: SendUnlessWasm + SyncUnlessWasm { fn dispatch_event(&self, event: ClientEvent); - fn dispatch_room_event(&self, room: Arc, event: RoomEventType); + fn dispatch_room_event(&self, room: Arc, event: ClientRoomEventType); } diff --git a/crates/prose-core-client/src/app/event_handlers/requests_event_handler.rs b/crates/prose-core-client/src/app/event_handlers/requests_event_handler.rs index 6b501d73..51a04b7a 100644 --- a/crates/prose-core-client/src/app/event_handlers/requests_event_handler.rs +++ b/crates/prose-core-client/src/app/event_handlers/requests_event_handler.rs @@ -8,11 +8,9 @@ use async_trait::async_trait; use tracing::info; use prose_proc_macros::InjectDependencies; -use prose_xmpp::mods::{caps, ping, profile, roster}; -use prose_xmpp::Event; use crate::app::deps::{DynAppContext, DynRequestHandlingService, DynTimeProvider}; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; +use crate::app::event_handlers::{RequestEvent, RequestEventType, ServerEvent, ServerEventHandler}; use crate::domain::general::services::SubscriptionResponse; /// Handles various server requests. @@ -28,68 +26,75 @@ pub struct RequestsEventHandler { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -impl XMPPEventHandler for RequestsEventHandler { +impl ServerEventHandler for RequestsEventHandler { fn name(&self) -> &'static str { "requests" } - async fn handle_event(&self, event: XMPPEvent) -> Result> { + async fn handle_event(&self, event: ServerEvent) -> Result> { match event { - Event::Caps(event) => match event { - caps::Event::DiscoInfoQuery { - from, - id, - node: _node, - } => { - self.request_handling_service - .respond_to_disco_info_query(&from, &id, &self.ctx.capabilities) - .await?; - Ok(None) - } - _ => Ok(Some(Event::Caps(event))), - }, - Event::Ping(event) => match event { - ping::Event::Ping { from, id } => { - self.request_handling_service - .respond_to_ping(&from, &id) - .await?; - Ok(None) - } - }, - Event::Profile(event) => match event { - profile::Event::EntityTimeQuery { from, id } => { - self.request_handling_service - .respond_to_entity_time_request(&from, &id, &self.time_provider.now()) - .await?; - Ok(None) - } - profile::Event::SoftwareVersionQuery { from, id } => { - self.request_handling_service - .respond_to_software_version_request(&from, &id, &self.ctx.software_version) - .await?; - Ok(None) - } - profile::Event::LastActivityQuery { from, id } => { - self.request_handling_service - .respond_to_last_activity_request(&from, &id, 0) - .await?; - Ok(None) - } - _ => Ok(Some(Event::Profile(event))), - }, - Event::Roster(event) => match event { - roster::Event::PresenceSubscriptionRequest { from } => { - info!("Approving presence subscription request from {}…", from); - self.request_handling_service - .respond_to_presence_subscription_request( - &from, - SubscriptionResponse::Approve, - ) - .await?; - Ok(None) - } - }, - _ => Ok(Some(event)), + ServerEvent::Request(event) => { + self.handle_request_event(event).await?; + } + _ => return Ok(Some(event)), } + Ok(None) + } +} + +impl RequestsEventHandler { + async fn handle_request_event(&self, event: RequestEvent) -> Result<()> { + match event.r#type { + RequestEventType::Ping => { + self.request_handling_service + .respond_to_ping(&event.sender_id, &event.request_id) + .await?; + } + RequestEventType::LocalTime => { + self.request_handling_service + .respond_to_entity_time_request( + &event.sender_id, + &event.request_id, + &self.time_provider.now(), + ) + .await?; + } + RequestEventType::LastActivity => { + self.request_handling_service + .respond_to_last_activity_request(&event.sender_id, &event.request_id, 0) + .await?; + } + RequestEventType::Capabilities { id: _id } => { + self.request_handling_service + .respond_to_disco_info_query( + &event.sender_id, + &event.request_id, + &self.ctx.capabilities, + ) + .await?; + } + RequestEventType::SoftwareVersion => { + self.request_handling_service + .respond_to_software_version_request( + &event.sender_id, + &event.request_id, + &self.ctx.software_version, + ) + .await?; + } + RequestEventType::PresenceSubscription => { + info!( + "Approving presence subscription request from {}…", + event.sender_id + ); + self.request_handling_service + .respond_to_presence_subscription_request( + &event.sender_id, + SubscriptionResponse::Approve, + ) + .await?; + } + } + Ok(()) } } diff --git a/crates/prose-core-client/src/app/event_handlers/rooms_event_handler.rs b/crates/prose-core-client/src/app/event_handlers/rooms_event_handler.rs index 93afaa2b..d84d0aff 100644 --- a/crates/prose-core-client/src/app/event_handlers/rooms_event_handler.rs +++ b/crates/prose-core-client/src/app/event_handlers/rooms_event_handler.rs @@ -3,28 +3,32 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +use std::sync::Arc; + use anyhow::Result; use async_trait::async_trait; -use jid::{BareJid, Jid}; -use tracing::{error, info, warn}; -use xmpp_parsers::chatstates::ChatState; -use xmpp_parsers::message::MessageType; -use xmpp_parsers::muc::MucUser; -use xmpp_parsers::presence::Presence; +use tracing::info; use prose_proc_macros::InjectDependencies; -use prose_xmpp::mods::{bookmark, bookmark2, chat, muc, status}; -use prose_xmpp::{ns, Event}; +use prose_xmpp::TimeProvider; use crate::app::deps::{ DynAppContext, DynClientEventDispatcher, DynConnectedRoomsReadOnlyRepository, - DynSidebarDomainService, DynTimeProvider, DynUserProfileRepository, + DynSidebarDomainService, DynTimeProvider, DynUserInfoRepository, DynUserProfileRepository, +}; +use crate::app::event_handlers::ServerEventHandler; +use crate::app::event_handlers::{ + OccupantEvent, OccupantEventType, RoomEvent, RoomEventType, ServerEvent, UserStatusEvent, + UserStatusEventType, }; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; -use crate::client_event::RoomEventType; +use crate::client_event::ClientRoomEventType; use crate::domain::messaging::models::{MessageLike, MessageLikePayload}; +use crate::domain::rooms::models::RoomInternals; use crate::domain::rooms::services::CreateOrEnterRoomRequest; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::{ParticipantId, RoomId}; +use crate::domain::user_info::models::Presence; +use crate::dtos::Availability; +use crate::ClientEvent; #[derive(InjectDependencies)] pub struct RoomsEventHandler { @@ -40,179 +44,267 @@ pub struct RoomsEventHandler { time_provider: DynTimeProvider, #[inject] user_profile_repo: DynUserProfileRepository, + #[inject] + user_info_repo: DynUserInfoRepository, } #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -impl XMPPEventHandler for RoomsEventHandler { +impl ServerEventHandler for RoomsEventHandler { fn name(&self) -> &'static str { "rooms" } - async fn handle_event(&self, event: XMPPEvent) -> Result> { + async fn handle_event(&self, event: ServerEvent) -> Result> { match event { - Event::Status(event) => match event { - status::Event::Presence(presence) => { - self.presence_did_change(presence).await?; - Ok(None) - } - _ => Ok(Some(Event::Status(event))), - }, - Event::Chat(event) => match event { - chat::Event::ChatStateChanged { - from, - chat_state, - message_type, - } => { - self.handle_changed_chat_state(from, chat_state, message_type) - .await?; - Ok(None) - } - _ => Ok(Some(Event::Chat(event))), - }, - Event::MUC(event) => match event { - muc::Event::DirectInvite { - from: _from, - invite, - } => { - self.handle_invite(invite.jid, invite.password).await?; - Ok(None) - } - muc::Event::MediatedInvite { from, invite } => { - self.handle_invite(from.to_bare(), invite.password).await?; - Ok(None) - } - }, - Event::Bookmark(event) => match event { - bookmark::Event::BookmarksChanged { - bookmarks: _bookmarks, - } => { - // TODO: Handle changed bookmarks - Ok(None) - } - }, - Event::Bookmark2(event) => match event { - bookmark2::Event::BookmarksPublished { - bookmarks: _bookmarks, - } => { - // TODO: Handle changed bookmarks - Ok(None) - } - bookmark2::Event::BookmarksRetracted { jids: _jids } => { - // TODO: Handle changed bookmarks - Ok(None) - } - }, - _ => Ok(Some(event)), + ServerEvent::Occupant(event) => { + self.handle_occupant_event(event).await?; + } + ServerEvent::Room(event) => { + self.handle_room_event(event).await?; + } + ServerEvent::UserStatus(event) => self.handle_user_status_event(event).await?, + _ => return Ok(Some(event)), } + Ok(None) } } impl RoomsEventHandler { - async fn presence_did_change(&self, presence: Presence) -> Result<()> { - let Some(from) = presence.from else { - error!( - "Received presence from unknown user. {}", - String::from(&minidom::Element::from(presence)) - ); - return Ok(()); - }; + fn get_room(&self, jid: &RoomId) -> Result> { + self.connected_rooms_repo + .get(jid) + .ok_or(anyhow::format_err!("Could not find room with jid {}", jid)) + } - let from = from; - let bare_from = RoomJid::from(from.to_bare()); + async fn handle_occupant_event(&self, event: OccupantEvent) -> Result<()> { + let room = self.get_room(&event.occupant_id.room_id())?; + let participant_id = ParticipantId::Occupant(event.occupant_id.clone()); - // Ignore presences that were sent by us. We don't have a room for the logged-in user. - if *bare_from == self.ctx.connected_jid()?.into_bare() { - return Ok(()); - } + let participants_changed = match event.r#type { + OccupantEventType::AffiliationChanged { affiliation } => 'outer: { + let mut participants_changed = false; - let Some(room) = self.connected_rooms_repo.get(&bare_from) else { - warn!( - "Received presence from user ({}) for which we do not have a room.", - from - ); - return Ok(()); - }; + { + let mut participants = room.participants_mut(); + if participants.get(&participant_id).map(|p| &p.affiliation) + != Some(&affiliation) + { + participants_changed = true; + participants.set_affiliation(&participant_id, event.is_self, &affiliation); + } + } + + // Let's see if we knew the real id of the participant already, if not let's + // look up their name… + let (Some(real_id), Some(participant)) = ( + event.real_id, + room.participants().get(&participant_id).cloned(), + ) else { + break 'outer participants_changed; + }; - let Some(muc_user) = presence - .payloads - .into_iter() - .filter_map(|payload| { - if !payload.is("x", ns::MUC_USER) { - return None; + if participant.real_id.is_some() { + // Real id was known already… + break 'outer participants_changed; } - MucUser::try_from(payload).ok() - }) - .take(1) - .next() - else { - return Ok(()); - }; - // Let's try to pull out the real jid of our user… - let Some((jid, affiliation)) = muc_user - .items - .into_iter() - .filter_map(|item| item.jid.map(|jid| (jid, item.affiliation))) - .take(1) - .next() - else { - return Ok(()); - }; + let name = self.user_profile_repo.get_display_name(&real_id).await?; + room.participants_mut().set_ids_and_name( + &participant_id, + Some(&real_id), + event.anon_occupant_id.as_ref(), + name.as_deref(), + ); - info!("Received real jid for {}: {}", from, jid); + true + } + OccupantEventType::DisconnectedByServer => { + room.participants_mut().set_availability( + &participant_id, + event.is_self, + &Availability::Unavailable, + ); - let bare_jid = jid.into_bare(); - let name = self.user_profile_repo.get_display_name(&bare_jid).await?; + if event.is_self { + self.sidebar_domain_service + .handle_removal_from_room(&event.occupant_id.room_id(), false) + .await?; + } - room.insert_occupant(&from, Some(&bare_jid), name.as_deref(), &affiliation); + true + } + OccupantEventType::PermanentlyRemoved => 'outer: { + room.participants_mut().remove(&participant_id); + + if event.is_self { + self.sidebar_domain_service + .handle_removal_from_room(&event.occupant_id.room_id(), true) + .await?; + // A SidebarChanged event will be sent instead + break 'outer false; + } + + true + } + }; + + if participants_changed { + self.client_event_dispatcher + .dispatch_room_event(room, ClientRoomEventType::ParticipantsChanged); + } Ok(()) } - async fn handle_invite(&self, room_jid: BareJid, password: Option) -> Result<()> { - info!("Joining room {} after receiving invite…", room_jid); + async fn handle_room_event(&self, event: RoomEvent) -> Result<()> { + match event.r#type { + RoomEventType::Destroyed { replacement } => { + info!( + "Room {} was destroyed. Alternative is {:?}", + event.room_id, replacement + ); + self.sidebar_domain_service + .handle_destroyed_room(&event.room_id, replacement) + .await?; + } + RoomEventType::RoomConfigChanged => { + info!("Config changed for room {}.", event.room_id); + self.sidebar_domain_service + .handle_changed_room_config(&event.room_id) + .await?; + } + RoomEventType::RoomTopicChanged { new_topic } => { + info!( + "Updating topic of room {} to '{:?}'", + event.room_id, new_topic + ); - self.sidebar_domain_service - .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::JoinRoom { - room_jid: RoomJid::from(room_jid), - password, - }) - .await?; + let room = self.get_room(&event.room_id)?; + if room.topic() != new_topic { + room.set_topic(new_topic); + self.client_event_dispatcher + .dispatch_room_event(room, ClientRoomEventType::AttributesChanged) + } + } + RoomEventType::ReceivedInvitation { sender, password } => { + info!( + "Joining room {} after receiving invitation from {sender}…", + event.room_id + ); + self.sidebar_domain_service + .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::JoinRoom { + room_jid: event.room_id, + password, + }) + .await?; + } + RoomEventType::UserAdded { + user_id, + affiliation, + reason, + } => { + info!( + "User {user_id} was added to room {} via invitation. Reason: {}", + event.room_id, + reason.as_deref().unwrap_or("") + ); + + let room = self.get_room(&event.room_id)?; + + let name = self.user_profile_repo.get_display_name(&user_id).await?; + room.participants_mut() + .add_user(&user_id, false, &affiliation, name.as_deref()); + + self.client_event_dispatcher + .dispatch_room_event(room, ClientRoomEventType::ParticipantsChanged); + } + } Ok(()) } - pub async fn handle_changed_chat_state( - &self, - from: Jid, - chat_state: ChatState, - message_type: MessageType, - ) -> Result<()> { - let bare_from = RoomJid::from(from.to_bare()); - - let Some(room) = self.connected_rooms_repo.get(&bare_from) else { - error!("Received chat state from sender for which we do not have a room."); - return Ok(()); - }; + async fn handle_user_status_event(&self, event: UserStatusEvent) -> Result<()> { + let is_self_event = + event.user_id.to_user_id() == Some(self.ctx.connected_id()?.into_user_id()); - let jid = if message_type == MessageType::Groupchat { - from - } else { - Jid::Bare(bare_from.into_inner()) - }; - let now = self.time_provider.now(); + match event.r#type { + UserStatusEventType::AvailabilityChanged { + availability, + priority, + } => { + let mut room_changed = false; + + // If we have a room, update it… + if let Ok(room) = self.get_room(&event.user_id.to_room_id()) { + let participant_id = event.user_id.to_participant_id(); + room.participants_mut().set_availability( + &participant_id, + is_self_event, + &availability, + ); + room_changed = true; + }; + + // if we do not have a room and the event is from a contact, we'll still want + // to update our repo… + let Some(id) = event.user_id.to_user_or_resource_id() else { + return Ok(()); + }; + + self.user_info_repo + .set_user_presence( + &id, + &Presence { + priority, + availability, + status: None, + }, + ) + .await?; + + // We won't send an event for our own availability… + if is_self_event { + return Ok(()); + } + + self.client_event_dispatcher + .dispatch_event(ClientEvent::ContactChanged { + id: id.to_user_id(), + }); - room.set_occupant_chat_state(&jid, &now, chat_state); + if room_changed { + self.client_event_dispatcher + .dispatch_event(ClientEvent::SidebarChanged) + } + } + UserStatusEventType::ComposeStateChanged { state } => { + let Ok(room) = self.get_room(&event.user_id.to_room_id()) else { + return Ok(()); + }; + let participant_id = event.user_id.to_participant_id(); - self.client_event_dispatcher - .dispatch_room_event(room, RoomEventType::ComposingUsersChanged); + room.participants_mut().set_compose_state( + &participant_id, + &self.time_provider.now(), + state, + ); + + // We won't send an event for our own compose state… + if is_self_event { + return Ok(()); + } + + self.client_event_dispatcher + .dispatch_room_event(room, ClientRoomEventType::ComposingUsersChanged); + } + } Ok(()) } } -impl From<&MessageLike> for RoomEventType { +impl From<&MessageLike> for ClientRoomEventType { fn from(message: &MessageLike) -> Self { if let Some(ref target) = message.target { if message.payload == MessageLikePayload::Retraction { diff --git a/crates/prose-core-client/src/app/event_handlers/server_event.rs b/crates/prose-core-client/src/app/event_handlers/server_event.rs new file mode 100644 index 00000000..64daffa9 --- /dev/null +++ b/crates/prose-core-client/src/app/event_handlers/server_event.rs @@ -0,0 +1,175 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use prose_xmpp::ConnectionError; + +use crate::domain::sidebar::models::Bookmark; +use crate::domain::{ + rooms::models::{ComposeState, RoomAffiliation}, + shared::models::{ + AnonOccupantId, Availability, CapabilitiesId, OccupantId, RequestId, RoomId, SenderId, + UserEndpointId, UserId, UserResourceId, + }, + user_info::models::{AvatarMetadata, UserStatus}, + user_profiles::models::UserProfile, +}; + +#[derive(Debug, Clone, PartialEq)] +pub enum ServerEvent { + Connection(ConnectionEvent), + /// Events that affect the status of a user within a conversation or globally. + UserStatus(UserStatusEvent), + /// Events that affect the information about the user globally. + UserInfo(UserInfoEvent), + /// Events that affect a specific resource of a user. + UserResource(UserResourceEvent), + /// Events about changes to a MUC room. + Room(RoomEvent), + /// Events about changes to an occupant of a MUC room. + Occupant(OccupantEvent), + /// Events about requests that are directed at us. + Request(RequestEvent), + /// Events about received messages. + Message(MessageEvent), + /// Events about changes to the sidebar. + SidebarBookmark(SidebarBookmarkEvent), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionEvent { + Connected, + Disconnected { error: Option }, +} + +#[derive(Debug, Clone, PartialEq)] +// Events that affect the status of a user within a conversation or globally. +pub struct UserStatusEvent { + pub user_id: UserEndpointId, + pub r#type: UserStatusEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UserStatusEventType { + AvailabilityChanged { + availability: Availability, + priority: i8, + }, + ComposeStateChanged { + state: ComposeState, + }, +} + +#[derive(Debug, Clone, PartialEq)] +// Events that affect the information about the user globally. +pub struct UserInfoEvent { + pub user_id: UserId, + pub r#type: UserInfoEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UserInfoEventType { + AvatarChanged { metadata: AvatarMetadata }, + ProfileChanged { profile: UserProfile }, + StatusChanged { status: Option }, +} + +#[derive(Debug, Clone, PartialEq)] +// Events that affect a specific resource of a user. +pub struct UserResourceEvent { + pub user_id: UserResourceId, + pub r#type: UserResourceEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum UserResourceEventType { + CapabilitiesChanged { id: CapabilitiesId }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RequestEvent { + pub sender_id: SenderId, + pub request_id: RequestId, + pub r#type: RequestEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RequestEventType { + Ping, + LocalTime, + LastActivity, + Capabilities { id: CapabilitiesId }, + SoftwareVersion, + PresenceSubscription, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RoomEvent { + pub room_id: RoomId, + pub r#type: RoomEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum RoomEventType { + /// The room was destroyed and potentially replaced by `replacement`. + Destroyed { replacement: Option }, + /// The configuration _or_ name of the room was changed. + RoomConfigChanged, + /// The topic of the room was changed. + RoomTopicChanged { new_topic: Option }, + /// `sender` sent you an invitation to this room. + ReceivedInvitation { + sender: UserResourceId, + password: Option, + }, + /// A user was added via an invitation. + UserAdded { + user_id: UserId, + affiliation: RoomAffiliation, + reason: Option, + }, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct OccupantEvent { + /// The occupant's id within the room. + pub occupant_id: OccupantId, + /// The occupant's anonymous id (https://xmpp.org/extensions/xep-0421.html) + pub anon_occupant_id: Option, + /// The global id of the occupant on their server. + pub real_id: Option, + /// Is this the current (logged-in) user? + pub is_self: bool, + /// The type of this event. + pub r#type: OccupantEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum OccupantEventType { + /// The occupant's affiliation was modified. + AffiliationChanged { affiliation: RoomAffiliation }, + /// The occupant was disconnected temporarily by the server, i.e. because of a restart. + DisconnectedByServer, + /// The occupant was permanently removed/banned from the room. + PermanentlyRemoved, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MessageEvent { + pub r#type: MessageEventType, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum MessageEventType { + Received(prose_xmpp::stanza::Message), + Sync(prose_xmpp::mods::chat::Carbon), + Sent(prose_xmpp::stanza::Message), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum SidebarBookmarkEvent { + AddedOrUpdated { bookmarks: Vec }, + Deleted { ids: Vec }, + Purged, +} diff --git a/crates/prose-core-client/src/app/event_handlers/user_state_event_handler.rs b/crates/prose-core-client/src/app/event_handlers/user_state_event_handler.rs index 4af2a9a6..07c31840 100644 --- a/crates/prose-core-client/src/app/event_handlers/user_state_event_handler.rs +++ b/crates/prose-core-client/src/app/event_handlers/user_state_event_handler.rs @@ -5,29 +5,19 @@ use anyhow::Result; use async_trait::async_trait; -use jid::Jid; -use tracing::debug; -use xmpp_parsers::presence::Presence; use prose_proc_macros::InjectDependencies; -use prose_xmpp::mods::{profile, status}; -use prose_xmpp::stanza::{avatar, UserActivity, VCard4}; -use prose_xmpp::Event; use crate::app::deps::{ - DynAppContext, DynAvatarRepository, DynClientEventDispatcher, DynUserInfoRepository, - DynUserProfileRepository, + DynAvatarRepository, DynClientEventDispatcher, DynUserInfoRepository, DynUserProfileRepository, }; -use crate::app::event_handlers::{XMPPEvent, XMPPEventHandler}; -use crate::domain::user_info::models::{ - AvatarMetadata, Presence as DomainPresence, UserActivity as DomainUserActivity, +use crate::app::event_handlers::{ + ServerEvent, ServerEventHandler, UserInfoEvent, UserInfoEventType, }; use crate::ClientEvent; #[derive(InjectDependencies)] pub struct UserStateEventHandler { - #[inject] - ctx: DynAppContext, #[inject] client_event_dispatcher: DynClientEventDispatcher, #[inject] @@ -40,122 +30,49 @@ pub struct UserStateEventHandler { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] -impl XMPPEventHandler for UserStateEventHandler { +impl ServerEventHandler for UserStateEventHandler { fn name(&self) -> &'static str { "user_state" } - async fn handle_event(&self, event: XMPPEvent) -> Result> { + async fn handle_event(&self, event: ServerEvent) -> Result> { match event { - Event::Status(event) => match event { - status::Event::Presence(presence) => { - if let Some(presence) = self.presence_did_change(presence).await? { - // Since presence can contain more information than we handle, give others - // a chance to handle this event has well… - Ok(Some(Event::Status(status::Event::Presence(presence)))) - } else { - Ok(None) - } - } - status::Event::UserActivity { - from, - user_activity, - } => { - self.user_activity_did_change(from, user_activity).await?; - Ok(None) - } - }, - Event::Profile(event) => match event { - profile::Event::Vcard { from, vcard } => { - self.vcard_did_change(from, vcard).await?; - Ok(None) - } - profile::Event::AvatarMetadata { from, metadata } => { - self.avatar_metadata_did_change(from, metadata).await?; - Ok(None) - } - _ => Ok(Some(Event::Profile(event))), - }, - _ => Ok(Some(event)), + ServerEvent::UserInfo(event) => { + self.handle_user_info_event(event).await?; + } + _ => return Ok(Some(event)), } + Ok(None) } } impl UserStateEventHandler { - async fn presence_did_change(&self, presence: Presence) -> Result> { - let Some(from) = &presence.from else { - return Ok(Some(presence)); - }; - - self.user_info_repo - .set_user_presence(from, &DomainPresence::from(presence.clone())) - .await?; - - let from = from.to_bare(); - let user_jid = self.ctx.connected_jid()?.into_bare(); - - // We'll ignore our own presence and won't forward it. - if from == user_jid { - return Ok(None); + async fn handle_user_info_event(&self, event: UserInfoEvent) -> Result<()> { + match event.r#type { + UserInfoEventType::AvatarChanged { metadata } => { + self.user_info_repo + .set_avatar_metadata(&event.user_id, &metadata) + .await?; + self.avatar_repo + .precache_avatar_image(&event.user_id, &metadata.to_info()) + .await?; + self.client_event_dispatcher + .dispatch_event(ClientEvent::AvatarChanged { id: event.user_id }); + } + UserInfoEventType::ProfileChanged { profile } => { + self.user_profile_repo.set(&event.user_id, &profile).await?; + self.client_event_dispatcher + .dispatch_event(ClientEvent::ContactChanged { id: event.user_id }); + } + UserInfoEventType::StatusChanged { status } => { + self.user_info_repo + .set_user_activity(&event.user_id, status.as_ref()) + .await?; + self.client_event_dispatcher + .dispatch_event(ClientEvent::ContactChanged { id: event.user_id }); + } } - self.client_event_dispatcher - .dispatch_event(ClientEvent::ContactChanged { jid: from.clone() }); - - Ok(Some(presence)) - } - - async fn vcard_did_change(&self, from: Jid, vcard: VCard4) -> Result<()> { - debug!("New vcard for {} {:?}", from, vcard); - - let from = from.into_bare(); - self.user_profile_repo - .set(&from, &vcard.try_into()?) - .await?; - self.client_event_dispatcher - .dispatch_event(ClientEvent::ContactChanged { jid: from }); - - Ok(()) - } - - async fn avatar_metadata_did_change( - &self, - from: Jid, - metadata: avatar::Metadata, - ) -> Result<()> { - debug!("New metadata for {} {:?}", from, metadata); - - let Some(metadata) = metadata - .infos - .first() - .map(|i| AvatarMetadata::from(i.clone())) - else { - return Ok(()); - }; - - let from = from.into_bare(); - - self.user_info_repo - .set_avatar_metadata(&from, &metadata) - .await?; - self.avatar_repo - .precache_avatar_image(&from, &metadata.to_info()) - .await?; - - self.client_event_dispatcher - .dispatch_event(ClientEvent::AvatarChanged { jid: from }); - - Ok(()) - } - - async fn user_activity_did_change(&self, from: Jid, user_activity: UserActivity) -> Result<()> { - let jid = from.into_bare(); - let user_activity = DomainUserActivity::try_from(user_activity)?; - self.user_info_repo - .set_user_activity(&jid, Some(&user_activity)) - .await?; - self.client_event_dispatcher - .dispatch_event(ClientEvent::ContactChanged { jid }); Ok(()) } } diff --git a/crates/prose-core-client/src/app/services/account_service.rs b/crates/prose-core-client/src/app/services/account_service.rs index bc5f6fbd..32a925d6 100644 --- a/crates/prose-core-client/src/app/services/account_service.rs +++ b/crates/prose-core-client/src/app/services/account_service.rs @@ -11,7 +11,7 @@ use prose_xmpp::mods::AvatarData; use crate::app::deps::*; use crate::domain::shared::models::Availability; -use crate::domain::user_info::models::{AvatarMetadata, UserActivity}; +use crate::domain::user_info::models::{AvatarMetadata, UserStatus}; use crate::domain::user_profiles::models::UserProfile; #[derive(InjectDependencies)] @@ -34,7 +34,7 @@ impl AccountService { pub async fn set_profile(&self, user_profile: &UserProfile) -> Result<()> { self.user_account_service.set_profile(&user_profile).await?; self.user_profile_repo - .set(&self.ctx.connected_jid()?.into_bare(), user_profile) + .set(&self.ctx.connected_id()?.to_user_id(), user_profile) .await?; Ok(()) } @@ -42,7 +42,7 @@ impl AccountService { pub async fn delete_profile(&self) -> Result<()> { self.user_account_service.delete_profile().await?; self.user_profile_repo - .delete(&self.ctx.connected_jid()?.into_bare()) + .delete(&self.ctx.connected_id()?.to_user_id()) .await?; Ok(()) } @@ -53,20 +53,20 @@ impl AccountService { .await?; self.account_settings_repo .update( - &self.ctx.connected_jid()?.into_bare(), + &self.ctx.connected_id()?.to_user_id(), Box::new(|settings| settings.availability = Some(availability)), ) .await?; Ok(()) } - pub async fn set_user_activity(&self, user_activity: Option) -> Result<()> { + pub async fn set_user_activity(&self, user_activity: Option) -> Result<()> { self.user_account_service .set_user_activity(user_activity.as_ref()) .await?; self.user_info_repo .set_user_activity( - &self.ctx.connected_jid()?.into_bare(), + &self.ctx.connected_id()?.to_user_id(), user_activity.as_ref(), ) .await?; @@ -80,7 +80,7 @@ impl AccountService { height: Option, mime_type: impl AsRef, ) -> Result<()> { - let jid = self.ctx.connected_jid()?.into_bare(); + let jid = self.ctx.connected_id()?.to_user_id(); let image_data_len = image_data.as_ref().len(); let image_data = AvatarData::Data(image_data.as_ref().to_vec()); diff --git a/crates/prose-core-client/src/app/services/connection_service.rs b/crates/prose-core-client/src/app/services/connection_service.rs index 51a596aa..a7056d9c 100644 --- a/crates/prose-core-client/src/app/services/connection_service.rs +++ b/crates/prose-core-client/src/app/services/connection_service.rs @@ -3,7 +3,7 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::BareJid; +use tracing::error; use prose_proc_macros::InjectDependencies; use prose_xmpp::{ConnectionError, IDProvider}; @@ -15,6 +15,7 @@ use crate::app::deps::{ use crate::client_event::ConnectionEvent; use crate::domain::connection::models::ConnectionProperties; use crate::domain::shared::models::Availability; +use crate::dtos::UserId; use crate::ClientEvent; #[derive(InjectDependencies)] @@ -36,7 +37,7 @@ pub struct ConnectionService { impl ConnectionService { pub async fn connect( &self, - jid: &BareJid, + jid: &UserId, password: impl AsRef, ) -> Result<(), ConnectionError> { let settings = @@ -52,7 +53,7 @@ impl ConnectionService { let availability = settings.availability.unwrap_or(Availability::Available); let full_jid = jid - .with_resource_str(&resource) + .with_resource(&resource) .expect("Failed to build FullJid with generated ID as resource."); self.ctx.set_connection_properties(ConnectionProperties { @@ -79,6 +80,17 @@ impl ConnectionService { msg: err.to_string(), })?; + if let Err(err) = self + .connection_service + .set_message_carbons_enabled(true) + .await + { + error!( + "Failed to enable message carbons. Reason: {}", + err.to_string() + ); + } + let server_features = self .connection_service .load_server_features() diff --git a/crates/prose-core-client/src/app/services/contacts_service.rs b/crates/prose-core-client/src/app/services/contacts_service.rs index c08140c3..816d699d 100644 --- a/crates/prose-core-client/src/app/services/contacts_service.rs +++ b/crates/prose-core-client/src/app/services/contacts_service.rs @@ -30,29 +30,29 @@ impl ContactsService { pub async fn load_contacts(&self) -> Result> { let domain_contacts = self .contacts_repo - .get_all(&self.ctx.connected_jid()?.into_bare()) + .get_all(&self.ctx.connected_id()?.to_user_id()) .await?; let mut contacts = vec![]; for domain_contact in domain_contacts { let profile = self .user_profile_repo - .get(&domain_contact.jid) + .get(&domain_contact.id) .await? .unwrap_or_default(); let user_info = self .user_info_repo - .get_user_info(&domain_contact.jid) + .get_user_info(&domain_contact.id) .await? .unwrap_or_default(); - let name = build_contact_name(&domain_contact.jid, &profile); + let name = build_contact_name(&domain_contact.id, &profile); let contact = Contact { - jid: domain_contact.jid, + id: domain_contact.id, name, availability: user_info.availability, - activity: user_info.activity, + status: user_info.activity, group: domain_contact.group, }; contacts.push(contact) diff --git a/crates/prose-core-client/src/app/services/debug_service.rs b/crates/prose-core-client/src/app/services/debug_service.rs index b1316bdf..1d5b4523 100644 --- a/crates/prose-core-client/src/app/services/debug_service.rs +++ b/crates/prose-core-client/src/app/services/debug_service.rs @@ -3,13 +3,17 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +use std::sync::Arc; + +use anyhow::Result; + +use prose_xmpp::mods; + use crate::domain::sidebar::models::Bookmark; use crate::domain::sidebar::services::BookmarksService; +use crate::dtos::RoomId; use crate::infra::xmpp::type_conversions::bookmark::ns; use crate::infra::xmpp::XMPPClient; -use anyhow::Result; -use prose_xmpp::mods; -use std::sync::Arc; pub struct DebugService { client: Arc, @@ -31,4 +35,11 @@ impl DebugService { pub async fn load_bookmarks(&self) -> Result> { self.client.load_bookmarks().await } + + pub async fn delete_bookmarks(&self, jids: impl IntoIterator) -> Result<()> { + for jid in jids.into_iter() { + self.client.delete_bookmark(&jid).await?; + } + Ok(()) + } } diff --git a/crates/prose-core-client/src/app/services/room.rs b/crates/prose-core-client/src/app/services/room.rs index 1d129b44..860d50d0 100644 --- a/crates/prose-core-client/src/app/services/room.rs +++ b/crates/prose-core-client/src/app/services/room.rs @@ -10,22 +10,20 @@ use std::sync::Arc; use anyhow::{format_err, Result}; use chrono::Duration; -use jid::{BareJid, Jid}; +use jid::Jid; use tracing::{debug, info}; use crate::app::deps::{ - DynClientEventDispatcher, DynDraftsRepository, DynMessageArchiveService, DynMessagesRepository, - DynMessagingService, DynRoomAttributesService, DynRoomParticipationService, - DynSidebarDomainService, DynTimeProvider, DynUserProfileRepository, + DynAppContext, DynClientEventDispatcher, DynDraftsRepository, DynMessageArchiveService, + DynMessagesRepository, DynMessagingService, DynRoomAttributesService, + DynRoomParticipationService, DynSidebarDomainService, DynTimeProvider, + DynUserProfileRepository, }; use crate::domain::messaging::models::{Emoji, Message, MessageId, MessageLike}; -use crate::domain::rooms::models::RoomInternals; -use crate::domain::shared::models::{RoomJid, RoomType}; -use crate::dtos::{ - Availability, Message as MessageDTO, MessageSender, UserBasicInfo, UserPresenceInfo, -}; -use crate::util::jid_ext::{BareJidExt, JidExt}; -use crate::RoomEventType; +use crate::domain::rooms::models::{RoomAffiliation, RoomInternals, RoomSpec}; +use crate::domain::shared::models::{ParticipantId, ParticipantInfo, RoomId}; +use crate::dtos::{Message as MessageDTO, MessageSender, UserBasicInfo, UserId}; +use crate::ClientRoomEventType; pub struct Room { inner: Arc, @@ -59,6 +57,7 @@ impl HasMutableName for Generic {} pub struct RoomInner { pub(crate) data: Arc, + pub(crate) ctx: DynAppContext, pub(crate) attributes_service: DynRoomAttributesService, pub(crate) client_event_dispatcher: DynClientEventDispatcher, pub(crate) drafts_repo: DynDraftsRepository, @@ -97,20 +96,19 @@ impl Deref for Room { impl Debug for Room { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.debug_struct("Room") - .field("jid", &self.data.jid) + .field("jid", &self.data.room_id) .field("name", &self.data.name()) - .field("description", &self.data.description) - .field("user_jid", &self.data.user_jid) + .field("description", &self.data.description()) .field("user_nickname", &self.data.user_nickname) .field("subject", &self.data.topic()) - .field("occupants", &self.data.occupants()) + .field("occupants", &self.data.participants()) .finish_non_exhaustive() } } impl PartialEq for Room { fn eq(&self, other: &Self) -> bool { - self.data.jid == other.data.jid + self.data.room_id == other.data.room_id } } @@ -121,16 +119,16 @@ impl Room { } impl Room { - pub fn jid(&self) -> &RoomJid { - &self.data.jid + pub fn jid(&self) -> &RoomId { + &self.data.room_id } pub fn name(&self) -> Option { self.data.name() } - pub fn description(&self) -> Option<&str> { - self.data.description.as_deref() + pub fn description(&self) -> Option { + self.data.description() } pub fn user_nickname(&self) -> &str { @@ -141,29 +139,11 @@ impl Room { self.data.topic() } - pub fn members(&self) -> Vec { + pub fn participants(&self) -> Vec { self.data - .members + .participants() .iter() - .map(|(jid, member)| UserPresenceInfo { - jid: jid.clone(), - name: member.name.clone(), - availability: Availability::Available, - }) - .collect() - } - - pub fn occupants(&self) -> Vec { - self.data - .occupants() - .into_iter() - .filter_map(|occupant| { - let Some(jid) = occupant.jid else { - return None; - }; - let name = occupant.name.unwrap_or_else(|| jid.to_display_name()); - Some(UserBasicInfo { jid, name }) - }) + .map(ParticipantInfo::from) .collect() } } @@ -171,32 +151,33 @@ impl Room { impl Room { pub async fn send_message(&self, body: impl Into) -> Result<()> { self.messaging_service - .send_message(&self.data.jid, &self.data.r#type, body.into()) + .send_message(&self.data.room_id, &self.data.r#type, body.into()) .await } pub async fn update_message(&self, id: MessageId, body: impl Into) -> Result<()> { self.messaging_service - .update_message(&self.data.jid, &self.data.r#type, &id, body.into()) + .update_message(&self.data.room_id, &self.data.r#type, &id, body.into()) .await } pub async fn toggle_reaction_to_message(&self, id: MessageId, emoji: Emoji) -> Result<()> { - let messages = self.message_repo.get(&self.data.jid, &id).await?; + let user_jid = self.ctx.connected_id()?.to_user_id(); + let messages = self.message_repo.get(&self.data.room_id, &id).await?; let mut message = Message::reducing_messages(messages) .pop() .ok_or(format_err!("No message with id {}", id))?; - message.toggle_reaction(&self.data.user_jid, emoji); + message.toggle_reaction(&user_jid, emoji); let all_emojis = message - .reactions_from(&self.data.user_jid) + .reactions_from(&user_jid) .cloned() .collect::>(); self.messaging_service .react_to_message( - &self.data.jid, + &self.data.room_id, &self.data.r#type, &id, all_emojis.as_slice(), @@ -206,18 +187,18 @@ impl Room { pub async fn retract_message(&self, id: MessageId) -> Result<()> { self.messaging_service - .retract_message(&self.data.jid, &self.data.r#type, &id) + .retract_message(&self.data.room_id, &self.data.r#type, &id) .await } pub async fn load_messages_with_ids(&self, ids: &[&MessageId]) -> Result> { - let messages = self.message_repo.get_all(&self.data.jid, ids).await?; + let messages = self.message_repo.get_all(&self.data.room_id, ids).await?; Ok(self.reduce_messages_and_add_sender(messages).await) } pub async fn set_user_is_composing(&self, is_composing: bool) -> Result<()> { self.messaging_service - .set_user_is_composing(&self.data.jid, &self.data.r#type, is_composing) + .set_user_is_composing(&self.data.room_id, &self.data.r#type, is_composing) .await } @@ -225,15 +206,15 @@ impl Room { // If the chat state is 'composing' but older than 30 seconds we do not consider // the user as currently typing. let thirty_secs_ago = self.time_provider.now() - Duration::seconds(30); - Ok(self.data.composing_users(thirty_secs_ago)) + Ok(self.data.participants().composing_users(thirty_secs_ago)) } pub async fn save_draft(&self, text: Option<&str>) -> Result<()> { - self.drafts_repo.set(&self.data.jid, text).await + self.drafts_repo.set(&self.data.room_id, text).await } pub async fn load_draft(&self) -> Result> { - self.drafts_repo.get(&self.data.jid).await + self.drafts_repo.get(&self.data.room_id).await } pub async fn load_latest_messages(&self) -> Result> { @@ -241,7 +222,7 @@ impl Room { let result = self .message_archive_service - .load_messages(&self.data.jid, &self.data.r#type, None, None) + .load_messages(&self.data.room_id, &self.data.r#type, None, None) .await?; let messages = result @@ -253,7 +234,7 @@ impl Room { debug!("Found {} messages. Saving to cache…", messages.len()); self.message_repo .append( - &self.data.jid, + &self.data.room_id, messages.iter().collect::>().as_slice(), ) .await?; @@ -268,7 +249,11 @@ impl Room { let mut message_dtos = Vec::with_capacity(messages.len()); for message in messages { - let name = self.resolve_user_name(&message.from).await; + let participant_id = match &message.from { + Jid::Bare(id) => ParticipantId::User(id.clone().into()), + Jid::Full(id) => ParticipantId::Occupant(id.clone().into()), + }; + let name = self.resolve_user_name(&participant_id).await; let from = MessageSender { jid: message.from.into_bare(), @@ -291,69 +276,112 @@ impl Room { message_dtos } - async fn resolve_user_name(&self, jid: &Jid) -> String { - let name = { - match jid { - Jid::Bare(bare) => self - .data - .members - .get(bare) - .map(|member| member.name.clone()) - .or_else(|| self.data.get_occupant(jid).and_then(|o| o.name)), - Jid::Full(_) => self.data.get_occupant(jid).and_then(|o| o.name), - } - }; + async fn resolve_user_name(&self, id: &ParticipantId) -> String { + let participant = self + .data + .participants() + .get(id) + .map(|p| (p.name.clone(), p.real_id.clone())); - if let Some(name) = name { + if let Some(name) = participant.as_ref().and_then(|p| p.0.clone()) { return name; - }; + } - if let Jid::Bare(bare) = &jid { + let real_id = participant.and_then(|p| p.1).or_else(|| id.to_user_id()); + + if let Some(real_id) = real_id { if let Some(name) = self .user_profile_repo - .get_display_name(bare) + .get_display_name(&real_id) .await .unwrap_or_default() { return name; - }; + } } - if self.data.r#type == RoomType::DirectMessage { - jid.node_to_display_name() - } else { - jid.resource_to_display_name() + match id { + ParticipantId::User(id) => id.formatted_username(), + ParticipantId::Occupant(id) => id.formatted_nickname(), } } } -#[cfg(feature = "debug")] -impl Room { - pub fn occupants_dbg(&self) -> Vec { - self.data.occupants() - } -} - impl Room { pub async fn resend_invites_to_members(&self) -> Result<()> { info!("Sending invites to group members…"); - let member_jids = self.data.members.keys().map(|jid| jid).collect::>(); + let member_jids = self + .data + .participants() + .iter() + .filter_map(|p| { + if p.affiliation >= RoomAffiliation::Member { + p.real_id.clone() + } else { + None + } + }) + .collect::>(); + + self.participation_service + .invite_users_to_room(&self.data.room_id, member_jids.as_slice()) + .await?; + Ok(()) + } + + pub async fn convert_to_private_channel(&self, name: impl AsRef) -> Result<()> { + self.sidebar_domain_service + .reconfigure_item_with_spec(&self.data.room_id, RoomSpec::PrivateChannel, name.as_ref()) + .await?; + Ok(()) + } +} + +impl Room { + pub async fn convert_to_public_channel(&self) -> Result<()> { + self.sidebar_domain_service + .reconfigure_item_with_spec( + &self.data.room_id, + RoomSpec::PublicChannel, + self.data.name().as_deref().unwrap_or_default(), + ) + .await?; + Ok(()) + } + + pub async fn invite_users(&self, users: impl IntoIterator) -> Result<()> { + let user_jids = users.into_iter().cloned().collect::>(); self.participation_service - .invite_users_to_room(&self.data.jid, member_jids.as_slice()) + .invite_users_to_room(&self.data.room_id, user_jids.as_slice()) .await?; Ok(()) } } -impl Room -where - Kind: Channel, -{ - pub async fn invite_users(&self, users: impl IntoIterator) -> Result<()> { - let user_jids = users.into_iter().collect::>(); +impl Room { + pub async fn convert_to_private_channel(&self) -> Result<()> { + self.sidebar_domain_service + .reconfigure_item_with_spec( + &self.data.room_id, + RoomSpec::PrivateChannel, + self.data.name().as_deref().unwrap_or_default(), + ) + .await?; + Ok(()) + } + + pub async fn invite_users(&self, users: impl IntoIterator) -> Result<()> { + let user_jids = users.into_iter().cloned().collect::>(); + + for user in user_jids.iter() { + self.participation_service + .grant_membership(&self.data.room_id, user) + .await?; + } + self.participation_service - .invite_users_to_room(&self.data.jid, user_jids.as_slice()) + .invite_users_to_room(&self.data.room_id, user_jids.as_slice()) .await?; Ok(()) } @@ -363,14 +391,14 @@ impl Room where Kind: HasTopic, { - pub async fn set_topic(&self, topic: Option<&str>) -> Result<()> { + pub async fn set_topic(&self, topic: Option) -> Result<()> { self.attributes_service - .set_topic(&self.data.jid, topic) + .set_topic(&self.data.room_id, topic.as_deref()) .await?; self.data.set_topic(topic); self.client_event_dispatcher - .dispatch_room_event(self.data.clone(), RoomEventType::AttributesChanged); + .dispatch_room_event(self.data.clone(), ClientRoomEventType::AttributesChanged); Ok(()) } @@ -382,7 +410,7 @@ where { pub async fn set_name(&self, name: impl AsRef) -> Result<()> { self.sidebar_domain_service - .rename_item(&self.data.jid, name.as_ref()) + .rename_item(&self.data.room_id, name.as_ref()) .await?; Ok(()) } diff --git a/crates/prose-core-client/src/app/services/rooms_service.rs b/crates/prose-core-client/src/app/services/rooms_service.rs index f3205ce7..2903b5f5 100644 --- a/crates/prose-core-client/src/app/services/rooms_service.rs +++ b/crates/prose-core-client/src/app/services/rooms_service.rs @@ -6,15 +6,14 @@ use std::sync::atomic::Ordering; use anyhow::{bail, Result}; -use jid::BareJid; use prose_proc_macros::InjectDependencies; use crate::app::deps::{DynAppContext, DynRoomManagementService, DynSidebarDomainService}; use crate::domain::rooms::models::constants::MAX_PARTICIPANTS_PER_GROUP; +use crate::domain::rooms::models::PublicRoomInfo; use crate::domain::rooms::services::{CreateOrEnterRoomRequest, CreateRoomType}; -use crate::domain::shared::models::RoomJid; -use crate::dtos::PublicRoomInfo; +use crate::domain::shared::models::{RoomId, UserId}; #[derive(InjectDependencies)] pub struct RoomsService { @@ -27,6 +26,7 @@ pub struct RoomsService { } impl RoomsService { + #[tracing::instrument(skip(self))] pub async fn start_observing_rooms(&self) -> Result<()> { if self.ctx.is_observing_rooms.swap(true, Ordering::Acquire) { return Ok(()); @@ -44,7 +44,7 @@ impl RoomsService { .await?) } - pub async fn start_conversation(&self, participants: &[BareJid]) -> Result { + pub async fn start_conversation(&self, participants: &[UserId]) -> Result { if participants.is_empty() { bail!("You need at least one participant to start a conversation") } @@ -57,7 +57,7 @@ impl RoomsService { } } - pub async fn join_room(&self, room_jid: &RoomJid, password: Option<&str>) -> Result { + pub async fn join_room(&self, room_jid: &RoomId, password: Option<&str>) -> Result { self.sidebar_domain_service .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::JoinRoom { room_jid: room_jid.clone(), @@ -66,10 +66,7 @@ impl RoomsService { .await } - pub async fn create_room_for_direct_message( - &self, - participant_jid: &BareJid, - ) -> Result { + pub async fn create_room_for_direct_message(&self, participant_jid: &UserId) -> Result { self.sidebar_domain_service .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::JoinDirectMessage { participant: participant_jid.clone(), @@ -77,7 +74,7 @@ impl RoomsService { .await } - pub async fn create_room_for_group(&self, participants: &[BareJid]) -> Result { + pub async fn create_room_for_group(&self, participants: &[UserId]) -> Result { self.sidebar_domain_service .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::Create { service: self.ctx.muc_service()?, @@ -91,7 +88,7 @@ impl RoomsService { pub async fn create_room_for_private_channel( &self, channel_name: impl AsRef, - ) -> Result { + ) -> Result { self.sidebar_domain_service .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::Create { service: self.ctx.muc_service()?, @@ -105,7 +102,7 @@ impl RoomsService { pub async fn create_room_for_public_channel( &self, channel_name: impl AsRef, - ) -> Result { + ) -> Result { self.sidebar_domain_service .insert_item_by_creating_or_joining_room(CreateOrEnterRoomRequest::Create { service: self.ctx.muc_service()?, @@ -116,8 +113,10 @@ impl RoomsService { .await } - pub async fn destroy_room(&self, room_jid: &BareJid) -> Result<()> { - self.room_management_service.destroy_room(room_jid).await?; + pub async fn destroy_room(&self, room_jid: &RoomId) -> Result<()> { + self.room_management_service + .destroy_room(room_jid, None) + .await?; Ok(()) } } diff --git a/crates/prose-core-client/src/app/services/sidebar_service.rs b/crates/prose-core-client/src/app/services/sidebar_service.rs index 7280d079..ee45cdee 100644 --- a/crates/prose-core-client/src/app/services/sidebar_service.rs +++ b/crates/prose-core-client/src/app/services/sidebar_service.rs @@ -12,7 +12,7 @@ use crate::app::deps::{ DynConnectedRoomsReadOnlyRepository, DynRoomFactory, DynSidebarDomainService, DynSidebarReadOnlyRepository, }; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use crate::dtos::SidebarItem as SidebarItemDTO; #[derive(InjectDependencies)] @@ -56,14 +56,14 @@ impl SidebarService { item_dtos } - pub async fn toggle_favorite(&self, jid: &RoomJid) -> Result<()> { + pub async fn toggle_favorite(&self, jid: &RoomId) -> Result<()> { self.sidebar_domain_service .toggle_item_is_favorite(jid) .await?; Ok(()) } - pub async fn remove_from_sidebar(&self, jid: &RoomJid) -> Result<()> { + pub async fn remove_from_sidebar(&self, jid: &RoomId) -> Result<()> { self.sidebar_domain_service.remove_items(&[jid]).await?; Ok(()) } diff --git a/crates/prose-core-client/src/app/services/user_data_service.rs b/crates/prose-core-client/src/app/services/user_data_service.rs index de373649..a8a6ad64 100644 --- a/crates/prose-core-client/src/app/services/user_data_service.rs +++ b/crates/prose-core-client/src/app/services/user_data_service.rs @@ -4,7 +4,6 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use anyhow::Result; -use jid::{BareJid, Jid}; use prose_proc_macros::InjectDependencies; @@ -12,6 +11,7 @@ use crate::app::deps::{ DynAvatarRepository, DynTimeProvider, DynUserInfoRepository, DynUserProfileRepository, DynUserProfileService, }; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::{PlatformImage, UserMetadata}; use crate::domain::user_profiles::models::UserProfile; @@ -30,7 +30,7 @@ pub struct UserDataService { } impl UserDataService { - pub async fn load_avatar(&self, from: &BareJid) -> Result> { + pub async fn load_avatar(&self, from: &UserId) -> Result> { let Some(avatar_metadata) = self .user_info_repo .get_user_info(from) @@ -43,17 +43,20 @@ impl UserDataService { Ok(image) } - pub async fn load_user_profile(&self, from: &BareJid) -> Result> { + pub async fn load_user_profile(&self, from: &UserId) -> Result> { self.user_profile_repo.get(from).await } - pub async fn load_user_metadata(&self, from: &BareJid) -> Result> { - let Jid::Full(full_jid) = self.user_info_repo.resolve_bare_jid_to_full(from) else { + pub async fn load_user_metadata(&self, from: &UserId) -> Result> { + let Some(resource_id) = self + .user_info_repo + .resolve_user_id_to_user_resource_id(from) + else { return Ok(None); }; let metadata = self .user_profile_service - .load_user_metadata(&full_jid, self.time_provider.now()) + .load_user_metadata(&resource_id, self.time_provider.now()) .await?; Ok(metadata) } diff --git a/crates/prose-core-client/src/client.rs b/crates/prose-core-client/src/client.rs index ee49966c..733b9e85 100644 --- a/crates/prose-core-client/src/client.rs +++ b/crates/prose-core-client/src/client.rs @@ -7,12 +7,12 @@ use std::ops::Deref; use std::sync::Arc; use anyhow::Result; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use prose_xmpp::ConnectionError; use crate::client_builder::{ClientBuilder, UndefinedAvatarCache, UndefinedStore}; +use crate::domain::shared::models::UserId; use crate::services::{ AccountService, CacheService, ConnectionService, ContactsService, RoomsService, SidebarService, UserDataService, @@ -63,10 +63,10 @@ impl Deref for Client { impl Client { pub async fn connect( &self, - jid: &BareJid, + id: &UserId, password: impl AsRef, ) -> Result<(), ConnectionError> { - self.connection.connect(jid, password).await + self.connection.connect(id, password).await } pub async fn disconnect(&self) { diff --git a/crates/prose-core-client/src/client_builder.rs b/crates/prose-core-client/src/client_builder.rs index 360a0c6d..411a3a08 100644 --- a/crates/prose-core-client/src/client_builder.rs +++ b/crates/prose-core-client/src/client_builder.rs @@ -12,7 +12,7 @@ use prose_xmpp::{ns, IDProvider, SystemTimeProvider, TimeProvider, UUIDProvider} use crate::app::deps::{AppContext, AppDependencies}; use crate::app::event_handlers::{ BookmarksEventHandler, ClientEventDispatcher, ConnectionEventHandler, MessagesEventHandler, - RequestsEventHandler, RoomsEventHandler, UserStateEventHandler, XMPPEventHandlerQueue, + RequestsEventHandler, RoomsEventHandler, ServerEventHandlerQueue, UserStateEventHandler, }; use crate::app::services::{ AccountService, ConnectionService, ContactsService, RoomsService, UserDataService, @@ -154,7 +154,7 @@ impl ClientBuilder, A> { ], ); - let handler_queue = Arc::new(XMPPEventHandlerQueue::new()); + let handler_queue = Arc::new(ServerEventHandlerQueue::new()); let xmpp_client = Arc::new( { diff --git a/crates/prose-core-client/src/client_event.rs b/crates/prose-core-client/src/client_event.rs index 375a693a..c6797bf5 100644 --- a/crates/prose-core-client/src/client_event.rs +++ b/crates/prose-core-client/src/client_event.rs @@ -3,12 +3,11 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::BareJid; - use prose_xmpp::ConnectionError; use crate::app::services::RoomEnvelope; use crate::domain::messaging::models::MessageId; +use crate::domain::shared::models::UserId; #[derive(Debug, Clone, PartialEq)] pub enum ClientEvent { @@ -19,19 +18,19 @@ pub enum ClientEvent { SidebarChanged, /// Infos about a contact have changed. - ContactChanged { jid: BareJid }, + ContactChanged { id: UserId }, /// The avatar of a user changed. - AvatarChanged { jid: BareJid }, + AvatarChanged { id: UserId }, RoomChanged { room: RoomEnvelope, - r#type: RoomEventType, + r#type: ClientRoomEventType, }, } #[derive(Debug, Clone, PartialEq)] -pub enum RoomEventType { +pub enum ClientRoomEventType { /// One or many messages were either received or sent. MessagesAppended { message_ids: Vec }, @@ -44,6 +43,9 @@ pub enum RoomEventType { /// Attributes changed like name or topic. AttributesChanged, + /// The list of participants has changed. + ParticipantsChanged, + /// A user in `conversation` started or stopped typing. ComposingUsersChanged, } diff --git a/crates/prose-core-client/src/domain/account/services/user_account_service.rs b/crates/prose-core-client/src/domain/account/services/user_account_service.rs index 634b5f5f..4f91942f 100644 --- a/crates/prose-core-client/src/domain/account/services/user_account_service.rs +++ b/crates/prose-core-client/src/domain/account/services/user_account_service.rs @@ -10,7 +10,7 @@ use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::general::models::Capabilities; use crate::domain::shared::models::Availability; -use crate::domain::user_info::models::{AvatarImageId, AvatarMetadata, UserActivity}; +use crate::domain::user_info::models::{AvatarImageId, AvatarMetadata, UserStatus}; use crate::domain::user_profiles::models::UserProfile; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] @@ -29,7 +29,7 @@ pub trait UserAccountService: SendUnlessWasm + SyncUnlessWasm { capabilities: &Capabilities, availability: &Availability, ) -> Result<()>; - async fn set_user_activity(&self, user_activity: Option<&UserActivity>) -> Result<()>; + async fn set_user_activity(&self, user_activity: Option<&UserStatus>) -> Result<()>; async fn set_profile(&self, profile: &UserProfile) -> Result<()>; async fn delete_profile(&self) -> Result<()>; diff --git a/crates/prose-core-client/src/domain/connection/models/connection_properties.rs b/crates/prose-core-client/src/domain/connection/models/connection_properties.rs index 557c66bd..24de5398 100644 --- a/crates/prose-core-client/src/domain/connection/models/connection_properties.rs +++ b/crates/prose-core-client/src/domain/connection/models/connection_properties.rs @@ -3,13 +3,13 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::FullJid; +use crate::domain::shared::models::UserResourceId; use super::ServerFeatures; pub struct ConnectionProperties { /// The JID of our connected user. - pub connected_jid: FullJid, + pub connected_jid: UserResourceId, /// The features of the server we're connected with. pub server_features: ServerFeatures, } diff --git a/crates/prose-core-client/src/domain/connection/services/connection_service.rs b/crates/prose-core-client/src/domain/connection/services/connection_service.rs index 05f12e83..10fa1d92 100644 --- a/crates/prose-core-client/src/domain/connection/services/connection_service.rs +++ b/crates/prose-core-client/src/domain/connection/services/connection_service.rs @@ -5,19 +5,19 @@ use anyhow::Result; use async_trait::async_trait; -use jid::FullJid; use minidom::Element; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use prose_xmpp::ConnectionError; use crate::domain::connection::models::ServerFeatures; +use crate::domain::shared::models::UserResourceId; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait ConnectionService: SendUnlessWasm + SyncUnlessWasm { - async fn connect(&self, jid: &FullJid, password: &str) -> Result<(), ConnectionError>; + async fn connect(&self, jid: &UserResourceId, password: &str) -> Result<(), ConnectionError>; async fn disconnect(&self); async fn set_message_carbons_enabled(&self, is_enabled: bool) -> Result<()>; diff --git a/crates/prose-core-client/src/domain/contacts/models/contact.rs b/crates/prose-core-client/src/domain/contacts/models/contact.rs index 434a02fe..da852902 100644 --- a/crates/prose-core-client/src/domain/contacts/models/contact.rs +++ b/crates/prose-core-client/src/domain/contacts/models/contact.rs @@ -3,12 +3,13 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::BareJid; use serde::{Deserialize, Serialize}; +use crate::domain::shared::models::UserId; + #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] pub struct Contact { - pub jid: BareJid, + pub id: UserId, pub name: Option, pub group: Group, } diff --git a/crates/prose-core-client/src/domain/contacts/repos/contacts_repository.rs b/crates/prose-core-client/src/domain/contacts/repos/contacts_repository.rs index 1f65254b..e99204c5 100644 --- a/crates/prose-core-client/src/domain/contacts/repos/contacts_repository.rs +++ b/crates/prose-core-client/src/domain/contacts/repos/contacts_repository.rs @@ -5,16 +5,16 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::contacts::models::Contact; +use crate::domain::shared::models::UserId; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait ContactsRepository: SendUnlessWasm + SyncUnlessWasm { - async fn get_all(&self, account_jid: &BareJid) -> Result>; + async fn get_all(&self, account_jid: &UserId) -> Result>; async fn clear_cache(&self) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/general/services/request_handling_service.rs b/crates/prose-core-client/src/domain/general/services/request_handling_service.rs index 629ee0ab..98678642 100644 --- a/crates/prose-core-client/src/domain/general/services/request_handling_service.rs +++ b/crates/prose-core-client/src/domain/general/services/request_handling_service.rs @@ -6,11 +6,11 @@ use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use jid::{BareJid, Jid}; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::general::models::{Capabilities, SoftwareVersion}; +use crate::domain::shared::models::{RequestId, SenderId}; #[derive(Debug, PartialEq)] pub enum SubscriptionResponse { @@ -22,39 +22,39 @@ pub enum SubscriptionResponse { #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait RequestHandlingService: SendUnlessWasm + SyncUnlessWasm { - async fn respond_to_ping(&self, to: &Jid, id: &str) -> Result<()>; + async fn respond_to_ping(&self, to: &SenderId, id: &RequestId) -> Result<()>; async fn respond_to_disco_info_query( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, capabilities: &Capabilities, ) -> Result<()>; async fn respond_to_entity_time_request( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, now: &DateTime, ) -> Result<()>; async fn respond_to_software_version_request( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, version: &SoftwareVersion, ) -> Result<()>; async fn respond_to_last_activity_request( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, last_active_seconds_ago: u64, ) -> Result<()>; async fn respond_to_presence_subscription_request( &self, - to: &BareJid, + to: &SenderId, response: SubscriptionResponse, ) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/messaging/models/message.rs b/crates/prose-core-client/src/domain/messaging/models/message.rs index 5009d83e..1254cd2d 100644 --- a/crates/prose-core-client/src/domain/messaging/models/message.rs +++ b/crates/prose-core-client/src/domain/messaging/models/message.rs @@ -5,11 +5,13 @@ use chrono::{DateTime, Utc}; use indexmap::IndexMap; -use jid::{BareJid, Jid}; +use jid::Jid; use serde::{Deserialize, Serialize}; use prose_utils::id_string; +use crate::domain::shared::models::UserId; + use super::{MessageLike, MessageLikePayload}; id_string!(MessageId); @@ -19,7 +21,7 @@ id_string!(Emoji); #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Reaction { pub emoji: Emoji, - pub from: Vec, + pub from: Vec, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -36,7 +38,7 @@ pub struct Message { } impl Message { - pub fn toggle_reaction(&mut self, user_id: &BareJid, emoji: Emoji) { + pub fn toggle_reaction(&mut self, user_id: &UserId, emoji: Emoji) { let Some(reaction) = self .reactions .iter_mut() @@ -58,7 +60,7 @@ impl Message { pub fn reactions_from<'a, 'b: 'a>( &'a self, - user_id: &'b BareJid, + user_id: &'b UserId, ) -> impl Iterator { self.reactions .iter() @@ -114,7 +116,7 @@ impl Message { MessageLikePayload::ReadReceipt => message.is_read = true, MessageLikePayload::Message { .. } => unreachable!(), MessageLikePayload::Reaction { mut emojis } => { - let modifier_from = modifier.from.to_bare(); + let modifier_from = UserId::from(modifier.from.to_bare()); // Iterate over all existing reactions 'outer: for reaction in &mut message.reactions { @@ -167,15 +169,13 @@ impl Message { #[cfg(test)] mod tests { - use std::str::FromStr; - use chrono::{TimeZone, Utc}; - use jid::BareJid; use prose_xmpp::{bare, jid}; use crate::domain::messaging::models::{MessageLike, MessageLikePayload}; use crate::test::MessageBuilder; + use crate::user_id; use super::*; @@ -184,55 +184,55 @@ mod tests { let mut message = MessageBuilder::new_with_index(1).build_message(); assert!(message.reactions.is_empty()); - message.toggle_reaction(&bare!("a@prose.org"), "🎉".into()); + message.toggle_reaction(&user_id!("a@prose.org"), "🎉".into()); assert_eq!( message.reactions, vec![Reaction { emoji: "🎉".into(), - from: vec![bare!("a@prose.org")] + from: vec![user_id!("a@prose.org")] }] ); - message.toggle_reaction(&bare!("b@prose.org"), "🎉".into()); + message.toggle_reaction(&user_id!("b@prose.org"), "🎉".into()); assert_eq!( message.reactions, vec![Reaction { emoji: "🎉".into(), - from: vec![bare!("a@prose.org"), bare!("b@prose.org")] + from: vec![user_id!("a@prose.org"), user_id!("b@prose.org")] }] ); - message.toggle_reaction(&bare!("b@prose.org"), "✅".into()); + message.toggle_reaction(&user_id!("b@prose.org"), "✅".into()); assert_eq!( message.reactions, vec![ Reaction { emoji: "🎉".into(), - from: vec![bare!("a@prose.org"), bare!("b@prose.org")] + from: vec![user_id!("a@prose.org"), user_id!("b@prose.org")] }, Reaction { emoji: "✅".into(), - from: vec![bare!("b@prose.org")] + from: vec![user_id!("b@prose.org")] } ] ); - message.toggle_reaction(&bare!("a@prose.org"), "🎉".into()); + message.toggle_reaction(&user_id!("a@prose.org"), "🎉".into()); assert_eq!( message.reactions, vec![ Reaction { emoji: "🎉".into(), - from: vec![bare!("b@prose.org")] + from: vec![user_id!("b@prose.org")] }, Reaction { emoji: "✅".into(), - from: vec![bare!("b@prose.org")] + from: vec![user_id!("b@prose.org")] } ] ); - message.toggle_reaction(&bare!("b@prose.org"), "🎉".into()); + message.toggle_reaction(&user_id!("b@prose.org"), "🎉".into()); assert_eq!( message.reactions, vec![ @@ -242,7 +242,7 @@ mod tests { }, Reaction { emoji: "✅".into(), - from: vec![bare!("b@prose.org")] + from: vec![user_id!("b@prose.org")] } ] ); @@ -254,24 +254,24 @@ mod tests { message.reactions = vec![ Reaction { emoji: "🎉".into(), - from: vec![bare!("a@prose.org"), bare!("b@prose.org")], + from: vec![user_id!("a@prose.org"), user_id!("b@prose.org")], }, Reaction { emoji: "✅".into(), - from: vec![bare!("b@prose.org")], + from: vec![user_id!("b@prose.org")], }, ]; assert_eq!( message - .reactions_from(&bare!("a@prose.org")) + .reactions_from(&user_id!("a@prose.org")) .cloned() .collect::>(), vec!["🎉".into()] ); assert_eq!( message - .reactions_from(&bare!("b@prose.org")) + .reactions_from(&user_id!("b@prose.org")) .cloned() .collect::>(), vec!["🎉".into(), "✅".into()] @@ -371,15 +371,15 @@ mod tests { reactions: vec![ Reaction { emoji: "👍".into(), - from: vec![BareJid::from_str("c@prose.org").unwrap()] + from: vec![user_id!("c@prose.org")] }, Reaction { emoji: "📼".into(), - from: vec![BareJid::from_str("b@prose.org").unwrap()] + from: vec![user_id!("b@prose.org")] }, Reaction { emoji: "🍿".into(), - from: vec![BareJid::from_str("b@prose.org").unwrap()] + from: vec![user_id!("b@prose.org")] } ], } diff --git a/crates/prose-core-client/src/domain/messaging/models/mod.rs b/crates/prose-core-client/src/domain/messaging/models/mod.rs index aa95892c..12540518 100644 --- a/crates/prose-core-client/src/domain/messaging/models/mod.rs +++ b/crates/prose-core-client/src/domain/messaging/models/mod.rs @@ -3,7 +3,7 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -pub(self) use error::StanzaParseError; +pub(crate) use error::StanzaParseError; pub use message::{Emoji, Message, MessageId, Reaction, StanzaId}; pub use message_like::{ MessageLike, MessageLikeError, MessageLikeId, Payload as MessageLikePayload, TimestampedMessage, diff --git a/crates/prose-core-client/src/domain/messaging/repos/messages_repository.rs b/crates/prose-core-client/src/domain/messaging/repos/messages_repository.rs index eb512dad..5197c64d 100644 --- a/crates/prose-core-client/src/domain/messaging/repos/messages_repository.rs +++ b/crates/prose-core-client/src/domain/messaging/repos/messages_repository.rs @@ -9,16 +9,16 @@ use async_trait::async_trait; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::messaging::models::{MessageId, MessageLike}; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait MessagesRepository: SendUnlessWasm + SyncUnlessWasm { /// Returns all parts (MessageLike) that make up message with `id`. Sorted chronologically. - async fn get(&self, room_id: &RoomJid, id: &MessageId) -> Result>; + async fn get(&self, room_id: &RoomId, id: &MessageId) -> Result>; /// Returns all parts (MessageLike) that make up all messages in `ids`. Sorted chronologically. - async fn get_all(&self, room_id: &RoomJid, ids: &[&MessageId]) -> Result>; - async fn append(&self, room_id: &RoomJid, messages: &[&MessageLike]) -> Result<()>; + async fn get_all(&self, room_id: &RoomId, ids: &[&MessageId]) -> Result>; + async fn append(&self, room_id: &RoomId, messages: &[&MessageLike]) -> Result<()>; async fn clear_cache(&self) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/messaging/services/impls/message_migration_domain_service.rs b/crates/prose-core-client/src/domain/messaging/services/impls/message_migration_domain_service.rs new file mode 100644 index 00000000..673f43db --- /dev/null +++ b/crates/prose-core-client/src/domain/messaging/services/impls/message_migration_domain_service.rs @@ -0,0 +1,66 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; +use async_trait::async_trait; +use xmpp_parsers::mam::Complete; + +use crate::domain::messaging::models::StanzaId; +use prose_proc_macros::DependenciesStruct; + +use crate::app::deps::{DynMessageArchiveService, DynMessagingService}; +use crate::domain::shared::models::{RoomId, RoomType}; + +use super::super::MessageMigrationDomainService as MessageMigrationDomainServiceTrait; + +#[derive(DependenciesStruct)] +pub struct MessageMigrationDomainService { + message_archive_service: DynMessageArchiveService, + messaging_service: DynMessagingService, +} + +#[cfg_attr(target_arch = "wasm32", async_trait(? Send))] +#[async_trait] +impl MessageMigrationDomainServiceTrait for MessageMigrationDomainService { + async fn copy_all_messages_from_room( + &self, + source_room: &RoomId, + source_room_type: &RoomType, + target_room: &RoomId, + target_room_type: &RoomType, + ) -> Result<()> { + let mut first_message_id: Option = None; + + loop { + let (messages, sentinel) = self + .message_archive_service + .load_messages( + &source_room, + source_room_type, + first_message_id.as_ref(), + None, + ) + .await?; + + first_message_id = messages + .first() + .and_then(|m| m.forwarded.stanza.as_ref()) + .and_then(|m| m.stanza_id().clone()) + .map(|id| StanzaId::from(id.id.into_inner())); + + for message in messages { + self.messaging_service + .relay_archived_message_to_room(target_room, target_room_type, message) + .await?; + } + + if sentinel.complete == Complete::True { + break; + } + } + + Ok(()) + } +} diff --git a/crates/prose-core-client/src/domain/messaging/services/impls/mod.rs b/crates/prose-core-client/src/domain/messaging/services/impls/mod.rs new file mode 100644 index 00000000..886d69b4 --- /dev/null +++ b/crates/prose-core-client/src/domain/messaging/services/impls/mod.rs @@ -0,0 +1,10 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +pub use message_migration_domain_service::{ + MessageMigrationDomainService, MessageMigrationDomainServiceDependencies, +}; + +mod message_migration_domain_service; diff --git a/crates/prose-core-client/src/domain/messaging/services/message_migration_domain_service.rs b/crates/prose-core-client/src/domain/messaging/services/message_migration_domain_service.rs new file mode 100644 index 00000000..e781bc50 --- /dev/null +++ b/crates/prose-core-client/src/domain/messaging/services/message_migration_domain_service.rs @@ -0,0 +1,23 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; +use async_trait::async_trait; +use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; + +use crate::domain::shared::models::{RoomId, RoomType}; + +#[cfg_attr(target_arch = "wasm32", async_trait(? Send))] +#[async_trait] +#[cfg_attr(feature = "test", mockall::automock)] +pub trait MessageMigrationDomainService: SendUnlessWasm + SyncUnlessWasm { + async fn copy_all_messages_from_room( + &self, + source_room: &RoomId, + source_room_type: &RoomType, + target_room: &RoomId, + target_room_type: &RoomType, + ) -> Result<()>; +} diff --git a/crates/prose-core-client/src/domain/messaging/services/messaging_service.rs b/crates/prose-core-client/src/domain/messaging/services/messaging_service.rs index e6c90f74..a134d581 100644 --- a/crates/prose-core-client/src/domain/messaging/services/messaging_service.rs +++ b/crates/prose-core-client/src/domain/messaging/services/messaging_service.rs @@ -8,9 +8,10 @@ use async_trait::async_trait; use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; +use prose_xmpp::stanza::message::mam::ArchivedMessage; use crate::domain::messaging::models::{Emoji, MessageId}; -use crate::domain::shared::models::RoomType; +use crate::domain::shared::models::{RoomId, RoomType}; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] @@ -59,4 +60,11 @@ pub trait MessagingService: SendUnlessWasm + SyncUnlessWasm { room_type: &RoomType, message_id: &MessageId, ) -> Result<()>; + + async fn relay_archived_message_to_room( + &self, + room_jid: &RoomId, + room_type: &RoomType, + message: ArchivedMessage, + ) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/messaging/services/mod.rs b/crates/prose-core-client/src/domain/messaging/services/mod.rs index f7edc85c..d4dc1311 100644 --- a/crates/prose-core-client/src/domain/messaging/services/mod.rs +++ b/crates/prose-core-client/src/domain/messaging/services/mod.rs @@ -4,13 +4,17 @@ // License: Mozilla Public License v2.0 (MPL v2.0) pub use message_archive_service::MessageArchiveService; +pub use message_migration_domain_service::MessageMigrationDomainService; pub use messaging_service::MessagingService; +pub mod impls; mod message_archive_service; +mod message_migration_domain_service; mod messaging_service; #[cfg(feature = "test")] pub mod mocks { pub use super::message_archive_service::MockMessageArchiveService; + pub use super::message_migration_domain_service::MockMessageMigrationDomainService; pub use super::messaging_service::MockMessagingService; } diff --git a/crates/prose-core-client/src/domain/rooms/models/compose_state.rs b/crates/prose-core-client/src/domain/rooms/models/compose_state.rs new file mode 100644 index 00000000..aa97bc10 --- /dev/null +++ b/crates/prose-core-client/src/domain/rooms/models/compose_state.rs @@ -0,0 +1,12 @@ +// Copyright (c) 2017 Emmanuel Gil Peyrot +// +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +#[derive(Debug, PartialEq, Clone, Default)] +pub enum ComposeState { + #[default] + Idle, + Composing, +} diff --git a/crates/prose-core-client/src/domain/rooms/models/mod.rs b/crates/prose-core-client/src/domain/rooms/models/mod.rs index f0332997..85b37f16 100644 --- a/crates/prose-core-client/src/domain/rooms/models/mod.rs +++ b/crates/prose-core-client/src/domain/rooms/models/mod.rs @@ -3,17 +3,21 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +pub use compose_state::ComposeState; +pub use participant_list::{Participant, ParticipantList, RegisteredMember}; pub use public_room_info::PublicRoomInfo; +pub use room_affiliation::RoomAffiliation; pub use room_error::RoomError; -pub use room_internals::{Member, RoomInfo, RoomInternals}; -pub use room_session_info::RoomSessionInfo; +pub use room_internals::{RoomInfo, RoomInternals}; +pub use room_session_info::{RoomConfig, RoomSessionInfo, RoomSessionMember}; pub use room_spec::RoomSpec; -pub use room_state::{Occupant, RoomState}; +mod compose_state; pub mod constants; +mod participant_list; mod public_room_info; +mod room_affiliation; mod room_error; mod room_internals; mod room_session_info; mod room_spec; -mod room_state; diff --git a/crates/prose-core-client/src/domain/rooms/models/participant_list.rs b/crates/prose-core-client/src/domain/rooms/models/participant_list.rs new file mode 100644 index 00000000..db17baf5 --- /dev/null +++ b/crates/prose-core-client/src/domain/rooms/models/participant_list.rs @@ -0,0 +1,635 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Utc}; + +use crate::domain::shared::models::{ + AnonOccupantId, Availability, ParticipantId, UserBasicInfo, UserId, +}; + +use super::{ComposeState, RoomAffiliation}; + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct ParticipantList { + anon_occupant_id_to_participant_id_map: HashMap, + participants_map: HashMap, +} + +#[derive(Debug, Default, Clone, PartialEq)] +pub struct Participant { + /// The real JID of the occupant. Only available in non-anonymous rooms. + pub real_id: Option, + pub anon_occupant_id: Option, + pub name: Option, + pub is_self: bool, + pub affiliation: RoomAffiliation, + pub availability: Availability, + pub compose_state: ComposeState, + pub compose_state_updated: DateTime, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RegisteredMember { + pub user_id: UserId, + pub affiliation: RoomAffiliation, + pub name: Option, + pub is_self: bool, +} + +impl ParticipantList { + pub fn for_direct_message( + contact_id: &UserId, + contact_name: &str, + availability: &Availability, + ) -> Self { + Self { + anon_occupant_id_to_participant_id_map: Default::default(), + participants_map: HashMap::from([( + ParticipantId::User(contact_id.clone()), + Participant { + real_id: Some(contact_id.clone()), + anon_occupant_id: None, + name: Some(contact_name.to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + availability: availability.clone(), + compose_state: Default::default(), + compose_state_updated: Default::default(), + }, + )]), + } + } + + /// Modifies the participant's availability or inserts a new participant with the availability + /// if it didn't exist. + pub fn set_availability( + &mut self, + id: &ParticipantId, + is_self: bool, + availability: &Availability, + ) { + self.participants_map + .entry(id.clone()) + .and_modify(|participant| { + participant.availability = availability.clone(); + participant.is_self = is_self; + if availability == &Availability::Unavailable { + participant.compose_state = ComposeState::Idle; + } + }) + .or_insert_with(|| Participant { + real_id: None, + anon_occupant_id: None, + name: None, + is_self, + affiliation: RoomAffiliation::None, + availability: availability.clone(), + compose_state: ComposeState::Idle, + compose_state_updated: DateTime::default(), + }); + } + + /// Sets the participant's affiliation. Does nothing if the participant doesn't exist. + pub fn set_affiliation( + &mut self, + id: &ParticipantId, + is_self: bool, + affiliation: &RoomAffiliation, + ) { + self.participants_map + .entry(id.clone()) + .and_modify(|participant| { + participant.affiliation = affiliation.clone(); + participant.is_self = is_self; + }) + .or_insert_with(|| Participant { + real_id: None, + anon_occupant_id: None, + name: None, + is_self, + affiliation: affiliation.clone(), + availability: Availability::Unavailable, + compose_state: ComposeState::Idle, + compose_state_updated: DateTime::default(), + }); + } + + /// Sets the participant's compose state. Does nothing if the participant doesn't exist. + pub fn set_compose_state( + &mut self, + id: &ParticipantId, + timestamp: &DateTime, + compose_state: ComposeState, + ) { + self.participants_map + .entry(id.clone()) + .and_modify(|participant| { + participant.compose_state = compose_state; + participant.compose_state_updated = timestamp.clone() + }); + } + + pub fn add_user( + &mut self, + real_id: &UserId, + is_self: bool, + affiliation: &RoomAffiliation, + name: Option<&str>, + ) { + if self + .participants_map + .values() + .find(|p| p.real_id.as_ref() == Some(real_id)) + .is_some() + { + return; + } + + self.participants_map + .entry(ParticipantId::User(real_id.clone())) + .and_modify(|participant| { + participant.affiliation = affiliation.clone(); + participant.name = name.map(ToString::to_string); + participant.is_self = is_self; + }) + .or_insert_with(|| Participant { + real_id: Some(real_id.clone()), + anon_occupant_id: None, + name: name.map(ToString::to_string), + is_self, + affiliation: affiliation.clone(), + availability: Availability::Unavailable, + compose_state: ComposeState::Idle, + compose_state_updated: DateTime::default(), + }); + } + + /// Sets the participant's real id, anonymous occupant id and name. Does nothing if the + /// participant doesn't exist. + pub fn set_ids_and_name( + &mut self, + id: &ParticipantId, + real_id: Option<&UserId>, + anon_occupant_id: Option<&AnonOccupantId>, + name: Option<&str>, + ) { + let Some(participant) = self.participants_map.get_mut(id) else { + return; + }; + + participant.real_id = real_id.cloned(); + participant.anon_occupant_id = anon_occupant_id.cloned(); + participant.name = name.map(ToString::to_string); + + // Remove registered user matching the real id… + if let Some(real_id) = real_id { + self.participants_map + .remove(&ParticipantId::User(real_id.clone())); + } + + self.anon_occupant_id_to_participant_id_map + .retain(|_, participant_id| participant_id != id); + + if let Some(anon_occupant_id) = anon_occupant_id { + self.anon_occupant_id_to_participant_id_map + .insert(anon_occupant_id.clone(), id.clone()); + } + } + + pub fn set_registered_members(&mut self, members: impl IntoIterator) { + let members = members.into_iter().collect::>(); + + let known_member_ids = self + .participants_map + .iter() + .filter_map(|(_, p)| p.real_id.clone()) + .collect::>(); + + for member in members { + if known_member_ids.contains(&member.user_id) { + continue; + } + + let participant_id = ParticipantId::User(member.user_id.clone()); + + if self.participants_map.contains_key(&participant_id) { + continue; + } + + let participant = Participant { + real_id: Some(member.user_id), + anon_occupant_id: None, + name: member.name, + is_self: member.is_self, + affiliation: member.affiliation, + availability: Default::default(), + compose_state: Default::default(), + compose_state_updated: Default::default(), + }; + + self.participants_map.insert(participant_id, participant); + } + } + + /// Removes the participant. Does nothing if the participant doesn't exist. + pub fn remove(&mut self, id: &ParticipantId) { + self.participants_map.remove(id); + } + + /// Returns the participant identified by `id` if it exists. + pub fn get(&self, id: &ParticipantId) -> Option<&Participant> { + self.participants_map.get(id) + } + + /// Returns an iterator over the contained participants. + pub fn iter(&self) -> impl Iterator { + self.participants_map.values() + } + + /// Returns the number of participants. + pub fn len(&self) -> usize { + self.participants_map.len() + } +} + +impl ParticipantList { + /// Returns the real JIDs of all composing users that started composing after `started_after`. + /// If we don't have a real JID for a composing user they are excluded from the list. + pub fn composing_users(&self, started_after: DateTime) -> Vec { + let mut composing_occupants = self + .participants_map + .values() + .filter_map(|occupant| { + if occupant.compose_state != ComposeState::Composing + || occupant.compose_state_updated <= started_after + || occupant.real_id.is_none() + { + return None; + } + Some(occupant.clone()) + }) + .collect::>(); + + composing_occupants.sort_by_key(|o| o.compose_state_updated); + + composing_occupants + .into_iter() + .filter_map(|occupant| { + let Some(real_id) = &occupant.real_id else { + return None; + }; + + Some(UserBasicInfo { + name: occupant + .name + .clone() + .unwrap_or_else(|| real_id.formatted_username()), + id: real_id.clone(), + }) + }) + .collect() + } +} + +#[cfg(feature = "test")] +impl ParticipantList { + pub fn extend_participants(&mut self, participants: HashMap) { + self.participants_map.extend(participants); + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeZone; + + use crate::domain::shared::models::OccupantId; + use crate::{occupant_id, user_id}; + + use super::*; + + #[test] + fn test_insert_occupant() { + let mut state = ParticipantList::default(); + assert!(state.participants_map.is_empty()); + + state.set_availability( + &occupant_id!("room@prose.org/a").into(), + false, + &Availability::Unavailable, + ); + state.set_affiliation( + &occupant_id!("room@prose.org/a").into(), + false, + &RoomAffiliation::Owner, + ); + state.set_ids_and_name( + &occupant_id!("room@prose.org/a").into(), + Some(&user_id!("a@prose.org")), + None, + None, + ); + + state.set_availability( + &user_id!("b@prose.org").into(), + false, + &Availability::Unavailable, + ); + state.set_affiliation( + &user_id!("b@prose.org").into(), + false, + &RoomAffiliation::Member, + ); + + assert_eq!(state.participants_map.len(), 2); + assert_eq!( + state + .participants_map + .get(&occupant_id!("room@prose.org/a").into()) + .unwrap(), + &Participant { + real_id: Some(user_id!("a@prose.org")), + affiliation: RoomAffiliation::Owner, + ..Default::default() + } + ); + assert_eq!( + state + .participants_map + .get(&user_id!("b@prose.org").into()) + .unwrap(), + &Participant { + affiliation: RoomAffiliation::Member, + ..Default::default() + } + ); + } + + #[test] + fn test_set_occupant_chat_state() { + let mut state = ParticipantList::default(); + + state.set_availability( + &occupant_id!("room@prose.org/a").into(), + false, + &Availability::Unavailable, + ); + + state.set_compose_state( + &occupant_id!("room@prose.org/a").into(), + &Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 0).unwrap(), + ComposeState::Composing, + ); + + assert_eq!( + state + .participants_map + .get(&occupant_id!("room@prose.org/a").into()) + .unwrap() + .compose_state, + ComposeState::Composing + ); + assert_eq!( + state + .participants_map + .get(&occupant_id!("room@prose.org/a").into()) + .unwrap() + .compose_state_updated, + Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 0).unwrap() + ); + } + + #[test] + fn test_composing_users() { + let mut state = ParticipantList::default(); + + state.participants_map.insert( + occupant_id!("room@prose.org/a").into(), + Participant { + real_id: Some(user_id!("a@prose.org")), + compose_state: ComposeState::Composing, + compose_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 30).unwrap(), + ..Default::default() + }, + ); + state.participants_map.insert( + occupant_id!("room@prose.org/b").into(), + Participant { + real_id: Some(user_id!("b@prose.org")), + compose_state: ComposeState::Idle, + compose_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 30).unwrap(), + ..Default::default() + }, + ); + state.participants_map.insert( + occupant_id!("room@prose.org/c").into(), + Participant { + real_id: Some(user_id!("c@prose.org")), + name: Some("Jonathan Doe".to_string()), + compose_state: ComposeState::Composing, + compose_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 20).unwrap(), + ..Default::default() + }, + ); + state.participants_map.insert( + occupant_id!("room@prose.org/d").into(), + Participant { + real_id: Some(user_id!("d@prose.org")), + compose_state: ComposeState::Composing, + compose_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 10).unwrap(), + ..Default::default() + }, + ); + + assert_eq!( + state.composing_users(Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 10).unwrap()), + vec![ + UserBasicInfo { + name: "Jonathan Doe".to_string(), + id: user_id!("c@prose.org") + }, + UserBasicInfo { + name: "A".to_string(), + id: user_id!("a@prose.org") + }, + ] + ); + } + + #[test] + fn test_registered_members_in_muc_room() { + // Start with a fresh state… + let mut list = ParticipantList::default(); + + // Assume that a registered member is online and we've received their presence when + // connecting to the room. + list.set_availability( + &ParticipantId::Occupant(occupant_id!("room@conference.prose.org/a")), + false, + &Availability::Available, + ); + list.set_affiliation( + &ParticipantId::Occupant(occupant_id!("room@conference.prose.org/a")), + false, + &RoomAffiliation::Member, + ); + list.set_ids_and_name( + &ParticipantId::Occupant(occupant_id!("room@conference.prose.org/a")), + Some(&user_id!("a@prose.org")), + None, + Some("User A"), + ); + + // Additionally we've loaded the other registered member. + list.set_registered_members(vec![ + RegisteredMember { + user_id: user_id!("a@prose.org"), + affiliation: RoomAffiliation::Member, + name: Some("User A".to_string()), + is_self: false, + }, + RegisteredMember { + user_id: user_id!("b@prose.org"), + affiliation: RoomAffiliation::Member, + name: Some("User B".to_string()), + is_self: false, + }, + ]); + + assert_eq!( + list.participants_map, + HashMap::from([ + ( + ParticipantId::Occupant(occupant_id!("room@conference.prose.org/a")), + Participant { + real_id: Some(user_id!("a@prose.org")), + anon_occupant_id: None, + name: Some("User A".to_string()), + is_self: false, + affiliation: RoomAffiliation::Member, + availability: Availability::Available, + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ), + ( + ParticipantId::User(user_id!("b@prose.org")), + Participant { + real_id: Some(user_id!("b@prose.org")), + anon_occupant_id: None, + name: Some("User B".to_string()), + is_self: false, + affiliation: RoomAffiliation::Member, + availability: Availability::Unavailable, + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ) + ]) + ); + + // Now the second member comes online… + list.set_availability( + &ParticipantId::Occupant(occupant_id!("room@conference.prose.org/b")), + false, + &Availability::Available, + ); + list.set_affiliation( + &ParticipantId::Occupant(occupant_id!("room@conference.prose.org/b")), + false, + &RoomAffiliation::Member, + ); + list.set_ids_and_name( + &ParticipantId::Occupant(occupant_id!("room@conference.prose.org/b")), + Some(&user_id!("b@prose.org")), + None, + Some("User B New Name"), + ); + + assert_eq!( + list.participants_map, + HashMap::from([ + ( + ParticipantId::Occupant(occupant_id!("room@conference.prose.org/a")), + Participant { + real_id: Some(user_id!("a@prose.org")), + anon_occupant_id: None, + name: Some("User A".to_string()), + is_self: false, + affiliation: RoomAffiliation::Member, + availability: Availability::Available, + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ), + ( + ParticipantId::Occupant(occupant_id!("room@conference.prose.org/b")), + Participant { + real_id: Some(user_id!("b@prose.org")), + anon_occupant_id: None, + name: Some("User B New Name".to_string()), + is_self: false, + affiliation: RoomAffiliation::Member, + availability: Availability::Available, + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ) + ]) + ); + } + + #[test] + fn test_registered_members_in_direct_message_room() { + // Start with a fresh state… + let mut list = ParticipantList::for_direct_message( + &user_id!("a@prose.org"), + "User A", + &Availability::Unavailable, + ); + + assert_eq!( + list.participants_map, + HashMap::from([( + ParticipantId::User(user_id!("a@prose.org")), + Participant { + real_id: Some(user_id!("a@prose.org")), + anon_occupant_id: None, + name: Some("User A".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + availability: Availability::Unavailable, + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ),]) + ); + + // Now the user comes online… + list.set_availability( + &ParticipantId::User(user_id!("a@prose.org")), + false, + &Availability::Available, + ); + + assert_eq!( + list.participants_map, + HashMap::from([( + ParticipantId::User(user_id!("a@prose.org")), + Participant { + real_id: Some(user_id!("a@prose.org")), + anon_occupant_id: None, + name: Some("User A".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + availability: Availability::Available, + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ),]) + ); + } +} diff --git a/crates/prose-core-client/src/domain/rooms/models/public_room_info.rs b/crates/prose-core-client/src/domain/rooms/models/public_room_info.rs index ecd4d904..4ffbbe94 100644 --- a/crates/prose-core-client/src/domain/rooms/models/public_room_info.rs +++ b/crates/prose-core-client/src/domain/rooms/models/public_room_info.rs @@ -3,10 +3,10 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; #[derive(Debug, Clone, PartialEq)] pub struct PublicRoomInfo { - pub jid: RoomJid, + pub jid: RoomId, pub name: Option, } diff --git a/crates/prose-core-client/src/domain/rooms/models/room_affiliation.rs b/crates/prose-core-client/src/domain/rooms/models/room_affiliation.rs new file mode 100644 index 00000000..ab2a3833 --- /dev/null +++ b/crates/prose-core-client/src/domain/rooms/models/room_affiliation.rs @@ -0,0 +1,49 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Display, Formatter}; + +#[derive(Debug, PartialEq, Clone, Default, PartialOrd, Eq, Ord)] +pub enum RoomAffiliation { + /// A user who has been banned from this room. + Outcast, + /// A normal participant. + #[default] + None, + /// A user who is whitelisted to speak in moderated rooms, or to join a + /// member-only room. + Member, + /// A user who has been empowered by an owner to do administrative + /// operations. + Admin, + /// The user who created the room, or who got appointed by its creator + /// to be their equal. + Owner, +} + +impl Display for RoomAffiliation { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RoomAffiliation::Owner => write!(f, "owner"), + RoomAffiliation::Admin => write!(f, "admin"), + RoomAffiliation::Member => write!(f, "member"), + RoomAffiliation::Outcast => write!(f, "outcast"), + RoomAffiliation::None => write!(f, "none"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ord() { + assert!(RoomAffiliation::Owner > RoomAffiliation::Admin); + assert!(RoomAffiliation::Admin > RoomAffiliation::Member); + assert!(RoomAffiliation::Member > RoomAffiliation::None); + assert!(RoomAffiliation::None > RoomAffiliation::Outcast); + } +} diff --git a/crates/prose-core-client/src/domain/rooms/models/room_error.rs b/crates/prose-core-client/src/domain/rooms/models/room_error.rs index 5c371cd7..5db6f08c 100644 --- a/crates/prose-core-client/src/domain/rooms/models/room_error.rs +++ b/crates/prose-core-client/src/domain/rooms/models/room_error.rs @@ -3,16 +3,20 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use xmpp_parsers::stanza_error::DefinedCondition; +use xmpp_parsers::stanza_error::{DefinedCondition, StanzaError}; use prose_xmpp::RequestError; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; #[derive(thiserror::Error, Debug)] pub enum RoomError { #[error("Room is already connected ({0}).")] - RoomIsAlreadyConnected(RoomJid), + RoomIsAlreadyConnected(RoomId), + #[error("No room exists with the specified JID.")] + RoomNotFound, + #[error("Room was modified while performing an action")] + RoomWasModified, #[error("A public channel with the chosen name exists already.")] PublicChannelNameConflict, #[error("Group must have at least two participants.")] @@ -29,6 +33,11 @@ pub enum RoomError { ParseError(#[from] prose_xmpp::ParseError), } +#[derive(Debug, Clone, PartialEq)] +pub struct GoneError { + pub new_location: Option, +} + impl RoomError { pub(crate) fn is_gone_err(&self) -> bool { let Self::RequestError(error) = &self else { @@ -36,4 +45,30 @@ impl RoomError { }; error.defined_condition() == Some(DefinedCondition::Gone) } + + pub(crate) fn gone_err(&self) -> Option { + let Self::RequestError(error) = &self else { + return None; + }; + + let RequestError::XMPP { + err: + StanzaError { + defined_condition, + new_location, + .. + }, + } = error + else { + return None; + }; + + if defined_condition != &DefinedCondition::Gone { + return None; + } + + Some(GoneError { + new_location: new_location.as_ref().and_then(|l| RoomId::from_iri(l).ok()), + }) + } } diff --git a/crates/prose-core-client/src/domain/rooms/models/room_internals.rs b/crates/prose-core-client/src/domain/rooms/models/room_internals.rs index c42e1b09..1d782281 100644 --- a/crates/prose-core-client/src/domain/rooms/models/room_internals.rs +++ b/crates/prose-core-client/src/domain/rooms/models/room_internals.rs @@ -3,18 +3,15 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use chrono::{DateTime, Utc}; -use std::collections::HashMap; use std::ops::{Deref, DerefMut}; -use jid::{BareJid, FullJid, Jid}; -use parking_lot::RwLock; -use xmpp_parsers::chatstates::ChatState; -use xmpp_parsers::muc::user::Affiliation; +use jid::FullJid; +use parking_lot::{ + MappedRwLockReadGuard, MappedRwLockWriteGuard, RwLock, RwLockReadGuard, RwLockWriteGuard, +}; -use crate::domain::rooms::models::RoomState; -use crate::domain::shared::models::{RoomJid, RoomType}; -use crate::dtos::{Occupant, UserBasicInfo}; +use crate::domain::rooms::models::{ParticipantList, RegisteredMember}; +use crate::domain::shared::models::{Availability, RoomId, RoomType, UserId}; /// Contains information about a connected room and its state. #[derive(Debug)] @@ -26,22 +23,23 @@ pub struct RoomInternals { #[derive(Debug, Clone, PartialEq)] pub struct RoomInfo { /// The JID of the room. - pub jid: RoomJid, - /// The description of the room. - pub description: Option, - /// The JID of our logged-in user. - pub user_jid: BareJid, + pub room_id: RoomId, /// The nickname with which our user is connected to the room. pub user_nickname: String, - /// The list of members. Only available for DirectMessage and Group (member-only rooms). - pub members: HashMap, /// The type of the room. pub r#type: RoomType, } -#[derive(Debug, Clone, PartialEq)] -pub struct Member { - pub name: String, +#[derive(Clone, Default, Debug, PartialEq)] +pub struct RoomState { + /// The name of the room. + pub name: Option, + /// The description of the room. + pub description: Option, + /// The room's topic. + pub topic: Option, + /// The participants in the room. + pub participants: ParticipantList, } impl Deref for RoomInternals { @@ -63,65 +61,41 @@ impl RoomInternals { self.state.read().name.clone() } - pub fn set_name(&self, name: &str) { - self.state.write().name.replace(name.to_string()); - } - - pub fn topic(&self) -> Option { - self.state.read().topic.clone() + pub fn set_name(&self, name: Option) { + self.state.write().name = name } - pub fn set_topic(&self, topic: Option<&str>) { - self.state.write().topic = topic.map(ToString::to_string) + pub fn description(&self) -> Option { + self.state.read().description.clone() } - pub fn occupants(&self) -> Vec { - self.state.read().occupants.values().cloned().collect() + pub fn set_description(&self, name: Option) { + self.state.write().description = name } - pub fn get_occupant(&self, jid: &Jid) -> Option { - self.state.read().occupants.get(&jid).cloned() + pub fn topic(&self) -> Option { + self.state.read().topic.clone() } - pub fn insert_occupant( - &self, - jid: &Jid, - real_jid: Option<&BareJid>, - name: Option<&str>, - affiliation: &Affiliation, - ) { - self.state - .write() - .insert_occupant(jid, real_jid, name, affiliation) + pub fn set_topic(&self, topic: Option) { + self.state.write().topic = topic } - pub fn set_occupant_chat_state( - &self, - occupant_jid: &Jid, - timestamp: &DateTime, - chat_state: ChatState, - ) { - self.state - .write() - .set_occupant_chat_state(occupant_jid, timestamp, chat_state) + pub fn participants(&self) -> MappedRwLockReadGuard { + RwLockReadGuard::map(self.state.read(), |s| &s.participants) } - /// Returns the real JIDs of all composing users that started composing after `started_after`. - /// If we don't have a real JID for a composing user they are excluded from the list. - pub fn composing_users(&self, started_after: DateTime) -> Vec { - self.state.read().composing_users(started_after) + pub fn participants_mut(&self) -> MappedRwLockWriteGuard { + RwLockWriteGuard::map(self.state.write(), |s| &mut s.participants) } } impl RoomInternals { - pub fn pending(room_jid: &RoomJid, user_jid: &BareJid, nickname: &str) -> Self { + pub fn pending(room_id: &RoomId, nickname: &str) -> Self { Self { info: RoomInfo { - jid: room_jid.clone(), - description: None, - user_jid: user_jid.clone(), + room_id: room_id.clone(), user_nickname: nickname.to_string(), - members: HashMap::new(), r#type: RoomType::Pending, }, state: Default::default(), @@ -133,52 +107,59 @@ impl RoomInternals { } // Resolves a pending room. - pub fn by_resolving_with_info(&self, name: Option, info: RoomInfo) -> Self { + pub fn by_resolving_with_info( + &self, + name: Option, + description: Option, + info: RoomInfo, + members: Vec, + ) -> Self { assert!(self.is_pending(), "Cannot promote a non-pending room"); let mut state = self.state.read().clone(); state.name = name; + state.description = description; + state.participants.set_registered_members(members); Self { info, state: RwLock::new(state), } } + + pub fn by_changing_type(&self, new_type: RoomType) -> Self { + Self { + info: RoomInfo { + room_id: self.room_id.clone(), + user_nickname: self.user_nickname.clone(), + r#type: new_type, + }, + state: RwLock::new(self.state.read().clone()), + } + } } impl RoomInternals { pub fn for_direct_message( - user_jid: &BareJid, - contact_jid: &BareJid, + contact_id: &UserId, contact_name: &str, + availability: &Availability, ) -> Self { Self { info: RoomInfo { - jid: contact_jid.clone().into(), - description: None, - user_jid: user_jid.clone(), + room_id: RoomId::from(contact_id.clone().into_inner()), user_nickname: "no_nickname".to_string(), - members: HashMap::from([( - contact_jid.clone(), - Member { - name: contact_name.to_string(), - }, - )]), r#type: RoomType::DirectMessage, }, state: RwLock::new(RoomState { name: Some(contact_name.to_string()), + description: None, topic: None, - occupants: HashMap::from([( - Jid::Bare(contact_jid.clone()), - Occupant { - jid: Some(contact_jid.clone()), - name: Some(contact_name.to_string()), - affiliation: Affiliation::Owner, - chat_state: ChatState::Gone, - chat_state_updated: Default::default(), - }, - )]), + participants: ParticipantList::for_direct_message( + contact_id, + contact_name, + availability, + ), }), } } @@ -186,10 +167,6 @@ impl RoomInternals { #[cfg(feature = "test")] impl RoomInternals { - pub fn set_occupants(&self, occupants: HashMap) { - self.state.write().occupants = occupants; - } - pub fn new(info: RoomInfo) -> Self { Self { info, @@ -202,7 +179,7 @@ impl RoomInfo { /// Returns the full jid of the connected user by appending their nickname to the room's /// bare jid. pub fn user_full_jid(&self) -> FullJid { - self.jid.with_resource_str(&self.user_nickname) + self.room_id.with_resource_str(&self.user_nickname) .expect("The provided JID and user_nickname were invalid and could not be used to form a FullJid.") } } @@ -216,54 +193,35 @@ impl PartialEq for RoomInternals { #[cfg(test)] mod tests { - use std::collections::HashMap; - - use xmpp_parsers::chatstates::ChatState; - use xmpp_parsers::muc::user::Affiliation; - - use prose_xmpp::{bare, jid}; - - use crate::dtos::Occupant; + use crate::{room_id, user_id}; use super::*; #[test] fn test_room_internals_for_direct_message() { let internals = RoomInternals::for_direct_message( - &bare!("logged-in-user@prose.org"), - &bare!("contact@prose.org"), + &user_id!("contact@prose.org"), "Jane Doe", + &Availability::Available, ); assert_eq!( internals, RoomInternals { info: RoomInfo { - jid: bare!("contact@prose.org").into(), - description: None, - user_jid: bare!("logged-in-user@prose.org"), + room_id: room_id!("contact@prose.org"), user_nickname: "no_nickname".to_string(), - members: HashMap::from([( - bare!("contact@prose.org"), - Member { - name: "Jane Doe".to_string() - } - )]), r#type: RoomType::DirectMessage, }, state: RwLock::new(RoomState { name: Some("Jane Doe".to_string()), + description: None, topic: None, - occupants: HashMap::from([( - jid!("contact@prose.org"), - Occupant { - jid: Some(bare!("contact@prose.org")), - name: Some("Jane Doe".to_string()), - affiliation: Affiliation::Owner, - chat_state: ChatState::Gone, - chat_state_updated: Default::default(), - } - )]) + participants: ParticipantList::for_direct_message( + &user_id!("contact@prose.org"), + "Jane Doe", + &Availability::Available + ) }) } ) diff --git a/crates/prose-core-client/src/domain/rooms/models/room_session_info.rs b/crates/prose-core-client/src/domain/rooms/models/room_session_info.rs index 298d6951..582ba06d 100644 --- a/crates/prose-core-client/src/domain/rooms/models/room_session_info.rs +++ b/crates/prose-core-client/src/domain/rooms/models/room_session_info.rs @@ -3,18 +3,28 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::BareJid; - -use crate::domain::shared::models::{RoomJid, RoomType}; +use crate::domain::rooms::models::RoomAffiliation; +use crate::domain::shared::models::{RoomId, RoomType, UserId}; /// Contains information about a room after creating or joining it. #[derive(Debug, PartialEq, Clone)] pub struct RoomSessionInfo { - pub room_jid: RoomJid, + pub room_id: RoomId, + pub config: RoomConfig, + pub user_nickname: String, + pub members: Vec, + pub room_has_been_created: bool, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct RoomConfig { pub room_name: Option, pub room_description: Option, pub room_type: RoomType, - pub user_nickname: String, - pub members: Vec, - pub room_has_been_created: bool, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct RoomSessionMember { + pub id: UserId, + pub affiliation: RoomAffiliation, } diff --git a/crates/prose-core-client/src/domain/rooms/models/room_spec.rs b/crates/prose-core-client/src/domain/rooms/models/room_spec.rs index cd0c9998..732b641e 100644 --- a/crates/prose-core-client/src/domain/rooms/models/room_spec.rs +++ b/crates/prose-core-client/src/domain/rooms/models/room_spec.rs @@ -3,12 +3,16 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::domain::shared::models::RoomType; +use std::fmt::{Display, Formatter}; + use strum_macros::EnumIter; +use crate::domain::shared::models::RoomType; +use crate::domain::sidebar::models::BookmarkType; + /// Describes how a MUC room should be configured in order to match our idea of different /// room types. -#[derive(Debug, Clone, EnumIter)] +#[derive(Debug, Clone, PartialEq, EnumIter)] pub enum RoomSpec { // The order needs to be from most restrictive to least restrictive in order to correctly // identify a room type from a set of MUC properties. @@ -26,4 +30,27 @@ impl RoomSpec { RoomSpec::PublicChannel => RoomType::PublicChannel, } } + + /// The type of bookmark that matches this spec… + pub fn bookmark_type(&self) -> BookmarkType { + match self { + RoomSpec::Group => BookmarkType::Group, + RoomSpec::PrivateChannel => BookmarkType::PrivateChannel, + RoomSpec::PublicChannel => BookmarkType::PublicChannel, + } + } +} + +impl Display for RoomSpec { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + RoomSpec::Group => "Group", + RoomSpec::PrivateChannel => "Private Channel", + RoomSpec::PublicChannel => "Public Channel", + } + ) + } } diff --git a/crates/prose-core-client/src/domain/rooms/models/room_state.rs b/crates/prose-core-client/src/domain/rooms/models/room_state.rs deleted file mode 100644 index 9a44ca3c..00000000 --- a/crates/prose-core-client/src/domain/rooms/models/room_state.rs +++ /dev/null @@ -1,244 +0,0 @@ -// prose-core-client/prose-core-client -// -// Copyright: 2023, Marc Bauer -// License: Mozilla Public License v2.0 (MPL v2.0) - -use std::collections::HashMap; - -use crate::domain::shared::models::UserBasicInfo; -use crate::util::jid_ext::BareJidExt; -use chrono::{DateTime, Utc}; -use jid::{BareJid, Jid}; -use xmpp_parsers::chatstates::ChatState; -use xmpp_parsers::muc::user::Affiliation; - -#[derive(Default, Clone, Debug, PartialEq)] -pub struct RoomState { - /// The name of the room. - pub name: Option, - /// The room's subject. - pub topic: Option, - /// The occupants of the room. The key is either the user's FullJid in a MUC room or the user's - /// BareJid in direct message room. - pub occupants: HashMap, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Occupant { - /// The real JID of the occupant. Only available in non-anonymous rooms. - pub jid: Option, - pub name: Option, - pub affiliation: Affiliation, - pub chat_state: ChatState, - pub chat_state_updated: DateTime, -} - -impl Default for Occupant { - fn default() -> Self { - Self { - jid: None, - name: None, - affiliation: Default::default(), - chat_state: ChatState::Gone, - chat_state_updated: Default::default(), - } - } -} - -impl RoomState { - pub fn insert_occupant( - &mut self, - jid: &Jid, - real_jid: Option<&BareJid>, - name: Option<&str>, - affiliation: &Affiliation, - ) { - let occupant = self.occupants.entry(jid.clone()).or_default(); - occupant.jid = real_jid.cloned(); - occupant.name = name.map(ToString::to_string); - occupant.affiliation = affiliation.clone(); - } - - pub fn set_occupant_chat_state( - &mut self, - occupant_jid: &Jid, - timestamp: &DateTime, - chat_state: ChatState, - ) { - self.occupants - .entry(occupant_jid.clone()) - .and_modify(|occupant| { - occupant.chat_state = chat_state; - occupant.chat_state_updated = timestamp.clone() - }); - } - - /// Returns the real JIDs of all composing users that started composing after `started_after`. - /// If we don't have a real JID for a composing user they are excluded from the list. - pub fn composing_users(&self, started_after: DateTime) -> Vec { - let mut composing_occupants = self - .occupants - .values() - .filter_map(|occupant| { - if occupant.chat_state != ChatState::Composing - || occupant.chat_state_updated <= started_after - || occupant.jid.is_none() - { - return None; - } - Some(occupant.clone()) - }) - .collect::>(); - - composing_occupants.sort_by_key(|o| o.chat_state_updated); - - composing_occupants - .into_iter() - .filter_map(|occupant| { - let Some(jid) = &occupant.jid else { - return None; - }; - - Some(UserBasicInfo { - name: occupant - .name - .clone() - .unwrap_or_else(|| jid.to_display_name()), - jid: jid.clone(), - }) - }) - .collect() - } -} - -#[cfg(test)] -mod tests { - use chrono::TimeZone; - - use prose_xmpp::{bare, jid}; - - use super::*; - - #[test] - fn test_insert_occupant() { - let mut state = RoomState::default(); - assert!(state.occupants.is_empty()); - - state.insert_occupant( - &jid!("room@prose.org/a"), - Some(&bare!("a@prose.org")), - None, - &Affiliation::Owner, - ); - state.insert_occupant(&jid!("b@prose.org"), None, None, &Affiliation::Member); - - assert_eq!(state.occupants.len(), 2); - assert_eq!( - state.occupants.get(&jid!("room@prose.org/a")).unwrap(), - &Occupant { - jid: Some(bare!("a@prose.org")), - affiliation: Affiliation::Owner, - ..Default::default() - } - ); - assert_eq!( - state.occupants.get(&jid!("b@prose.org")).unwrap(), - &Occupant { - affiliation: Affiliation::Member, - ..Default::default() - } - ); - } - - #[test] - fn test_set_occupant_chat_state() { - let mut state = RoomState::default(); - - state.insert_occupant( - &jid!("room@prose.org/a"), - Some(&bare!("a@prose.org")), - None, - &Affiliation::Owner, - ); - - state.set_occupant_chat_state( - &jid!("room@prose.org/a"), - &Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 0).unwrap(), - ChatState::Composing, - ); - - assert_eq!( - state - .occupants - .get(&jid!("room@prose.org/a")) - .unwrap() - .chat_state, - ChatState::Composing - ); - assert_eq!( - state - .occupants - .get(&jid!("room@prose.org/a")) - .unwrap() - .chat_state_updated, - Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 0).unwrap() - ); - } - - #[test] - fn test_composing_users() { - let mut state = RoomState::default(); - - state.occupants.insert( - jid!("room@prose.org/a"), - Occupant { - jid: Some(bare!("a@prose.org")), - chat_state: ChatState::Composing, - chat_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 30).unwrap(), - ..Default::default() - }, - ); - state.occupants.insert( - jid!("room@prose.org/b"), - Occupant { - jid: Some(bare!("b@prose.org")), - chat_state: ChatState::Active, - chat_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 30).unwrap(), - ..Default::default() - }, - ); - state.occupants.insert( - jid!("room@prose.org/c"), - Occupant { - jid: Some(bare!("c@prose.org")), - name: Some("Jonathan Doe".to_string()), - chat_state: ChatState::Composing, - chat_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 20).unwrap(), - ..Default::default() - }, - ); - state.occupants.insert( - jid!("room@prose.org/d"), - Occupant { - jid: Some(bare!("d@prose.org")), - chat_state: ChatState::Composing, - chat_state_updated: Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 10).unwrap(), - ..Default::default() - }, - ); - - assert_eq!( - state.composing_users(Utc.with_ymd_and_hms(2023, 01, 03, 0, 0, 10).unwrap()), - vec![ - UserBasicInfo { - name: "Jonathan Doe".to_string(), - jid: bare!("c@prose.org") - }, - UserBasicInfo { - name: "A".to_string(), - jid: bare!("a@prose.org") - }, - ] - ); - } -} diff --git a/crates/prose-core-client/src/domain/rooms/repos/connected_rooms_repository.rs b/crates/prose-core-client/src/domain/rooms/repos/connected_rooms_repository.rs index e49ab523..b0f5c6e4 100644 --- a/crates/prose-core-client/src/domain/rooms/repos/connected_rooms_repository.rs +++ b/crates/prose-core-client/src/domain/rooms/repos/connected_rooms_repository.rs @@ -10,7 +10,7 @@ use anyhow::Result; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::rooms::models::RoomInternals; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; type UpdateHandler = Box) -> RoomInternals + Send>; @@ -18,7 +18,7 @@ pub struct RoomAlreadyExistsError; #[cfg_attr(feature = "test", mockall::automock)] pub trait ConnectedRoomsReadOnlyRepository: SendUnlessWasm + SyncUnlessWasm { - fn get(&self, room_jid: &RoomJid) -> Option>; + fn get(&self, room_jid: &RoomId) -> Option>; fn get_all(&self) -> Vec>; } @@ -27,9 +27,9 @@ pub trait ConnectedRoomsRepository: ConnectedRoomsReadOnlyRepository { /// If a room with `room_jid` was found returns the room returned by `block` otherwise /// returns `None`. - fn update(&self, room_jid: &RoomJid, block: UpdateHandler) -> Option>; + fn update(&self, room_jid: &RoomId, block: UpdateHandler) -> Option>; - fn delete(&self, room_jid: &RoomJid); + fn delete(&self, room_jid: &RoomId); fn delete_all(&self); } @@ -38,14 +38,14 @@ mockall::mock! { pub ConnectedRoomsReadWriteRepository {} impl ConnectedRoomsReadOnlyRepository for ConnectedRoomsReadWriteRepository { - fn get(&self, room_jid: &RoomJid) -> Option>; + fn get(&self, room_jid: &RoomId) -> Option>; fn get_all(&self) -> Vec>; } impl ConnectedRoomsRepository for ConnectedRoomsReadWriteRepository { fn set(&self, room: Arc) -> Result<(), RoomAlreadyExistsError>; - fn update(&self, room_jid: &RoomJid, block: UpdateHandler) -> Option>; - fn delete(&self, room_jid: &RoomJid); + fn update(&self, room_jid: &RoomId, block: UpdateHandler) -> Option>; + fn delete(&self, room_jid: &RoomId); fn delete_all(&self); } } diff --git a/crates/prose-core-client/src/domain/rooms/services/impls/rooms_domain_service.rs b/crates/prose-core-client/src/domain/rooms/services/impls/rooms_domain_service.rs index e1130308..339ea52d 100644 --- a/crates/prose-core-client/src/domain/rooms/services/impls/rooms_domain_service.rs +++ b/crates/prose-core-client/src/domain/rooms/services/impls/rooms_domain_service.rs @@ -3,40 +3,38 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use std::collections::HashMap; use std::future::Future; use std::iter; use std::sync::Arc; use anyhow::{Context, Result}; use async_trait::async_trait; +use base64::{engine::general_purpose, Engine as _}; use jid::{BareJid, NodePart}; use sha1::{Digest, Sha1}; -use tracing::info; +use tracing::{debug, error, info, warn}; use prose_proc_macros::DependenciesStruct; use prose_xmpp::{IDProvider, RequestError}; use crate::app::deps::{ DynAppContext, DynClientEventDispatcher, DynConnectedRoomsRepository, DynIDProvider, - DynRoomAttributesService, DynRoomManagementService, DynRoomParticipationService, - DynUserProfileRepository, + DynMessageMigrationDomainService, DynRoomAttributesService, DynRoomManagementService, + DynRoomParticipationService, DynUserInfoRepository, DynUserProfileRepository, }; use crate::domain::rooms::models::{ - Member, RoomError, RoomInfo, RoomInternals, RoomSessionInfo, RoomSpec, + RegisteredMember, RoomAffiliation, RoomError, RoomInfo, RoomInternals, RoomSessionInfo, + RoomSessionMember, RoomSpec, }; use crate::domain::rooms::services::CreateOrEnterRoomRequest; -use crate::domain::shared::models::{RoomJid, RoomType}; -use crate::domain::shared::utils::build_contact_name; -use crate::util::jid_ext::BareJidExt; +use crate::domain::shared::models::{RoomId, RoomType, UserId}; use crate::util::StringExt; -use crate::RoomEventType; +use crate::ClientRoomEventType; use super::super::{CreateRoomType, RoomsDomainService as RoomsDomainServiceTrait}; const GROUP_PREFIX: &str = "org.prose.group"; -const PRIVATE_CHANNEL_PREFIX: &str = "org.prose.private-channel"; -const PUBLIC_CHANNEL_PREFIX: &str = "org.prose.public-channel"; +const CHANNEL_PREFIX: &str = "org.prose.channel"; #[derive(DependenciesStruct)] pub struct RoomsDomainService { @@ -44,9 +42,11 @@ pub struct RoomsDomainService { connected_rooms_repo: DynConnectedRoomsRepository, ctx: DynAppContext, id_provider: DynIDProvider, + message_migration_domain_service: DynMessageMigrationDomainService, room_attributes_service: DynRoomAttributesService, room_management_service: DynRoomManagementService, room_participation_service: DynRoomParticipationService, + user_info_repo: DynUserInfoRepository, user_profile_repo: DynUserProfileRepository, } @@ -77,9 +77,9 @@ impl RoomsDomainServiceTrait for RoomsDomainService { /// or `RoomType::Generic`. /// - Fails with `RoomError::PublicChannelNameConflict` if the room is of type /// `RoomType::PublicChannel` and `name` is already used by another public channel. - async fn rename_room(&self, room_jid: &RoomJid, name: &str) -> Result<(), RoomError> { + async fn rename_room(&self, room_jid: &RoomId, name: &str) -> Result<(), RoomError> { let Some(room) = self.connected_rooms_repo.get(room_jid) else { - return Ok(()); + return Err(RoomError::RoomNotFound); }; match room.r#type { @@ -98,36 +98,242 @@ impl RoomsDomainServiceTrait for RoomsDomainService { } self.room_attributes_service - .set_name(&room.jid, name.as_ref()) + .set_name(&room.room_id, name.as_ref()) .await?; - room.set_name(name); + room.set_name(Some(name.to_string())); self.client_event_dispatcher - .dispatch_room_event(room, RoomEventType::AttributesChanged); + .dispatch_room_event(room, ClientRoomEventType::AttributesChanged); Ok(()) } + + /// Reconfigures the room identified by `room_jid` according to `spec` and renames it to `new_name`. + /// + /// If the room is not connected no action is performed, otherwise: + /// - Panics if the reconfiguration is not not allowed. Allowed reconfigurations are: + /// - `RoomType::Group` -> `RoomType::PrivateChannel` + /// - `RoomType::PublicChannel` -> `RoomType::PrivateChannel` + /// - `RoomType::PrivateChannel` -> `RoomType::PublicChannel` + /// - Dispatches `ClientEvent::RoomChanged` of type `RoomEventType::AttributesChanged` + /// after processing. + async fn reconfigure_room_with_spec( + &self, + room_jid: &RoomId, + spec: RoomSpec, + new_name: &str, + ) -> Result, RoomError> { + let Some(room) = self.connected_rooms_repo.get(room_jid) else { + return Err(RoomError::RoomNotFound); + }; + + match (&room.r#type, spec.room_type()) { + (RoomType::Group, RoomType::PrivateChannel) => { + // Remove room first so that we don't run into problems with reentrancy… + self.connected_rooms_repo.delete(room_jid); + + let service = BareJid::from_parts(None, &room_jid.domain()); + + // Create new room + debug!("Creating new room {}…", new_name); + let new_room = match self + .create_room( + &service, + CreateRoomType::PrivateChannel { + name: new_name.to_string(), + }, + ) + .await + { + Ok(room) => room, + Err(err) => { + // Something went wrong, let's put the room back… + _ = self.connected_rooms_repo.set(room); + return Err(err); + } + }; + + // Migrate messages to new room + debug!("Copying messages to new room {}…", new_name); + match self + .message_migration_domain_service + .copy_all_messages_from_room( + &room.room_id, + &room.r#type, + &new_room.room_id, + &new_room.r#type, + ) + .await + { + Ok(_) => (), + Err(err) => { + // If that failed, let's put the initial room back and delete the new room?! + _ = self.connected_rooms_repo.set(room); + _ = self + .room_management_service + .destroy_room(&new_room.room_id, None) + .await; + return Err(err.into()); + } + } + + let current_user = self.ctx.connected_id()?.to_user_id(); + let member_ids = room + .participants() + .iter() + .filter_map(|p| { + if p.affiliation >= RoomAffiliation::Member { + return p.real_id.clone(); + } + None + }) + .collect::>(); + + // Now grant the members of the original group access to the new channel… + debug!("Granting membership to members of new room {}…", new_name); + for member in member_ids { + // Our user is already admin, no need to set them as a member… + if member == current_user { + continue; + } + + match self + .room_participation_service + .grant_membership(&new_room.room_id, &member) + .await + { + Ok(_) => (), + Err(err) => { + error!( + "Could not grant membership for new private channel {} to {}. Reason: {}", + new_room.room_id, member, err.to_string() + ); + } + } + } + + // And finally destroy the original room. Since we pass in the JID to the new room + // we do not need to send invites to the members of the original group. + debug!("Destroying old room {}…", room.room_id); + match self + .room_management_service + .destroy_room(room_jid, Some(new_room.room_id.clone())) + .await + { + Ok(_) => (), + Err(err) => { + // If that failed, no reason to stop here. Let's just log the error… + warn!("Failed to delete the initial group after trying to convert it to a Private Channel. Reason: {}", err.to_string()); + } + } + + Ok(new_room) + } + (RoomType::PrivateChannel, RoomType::PublicChannel) => { + // Ensure that the new name doesn't exist already. + if !self.is_public_channel_name_unique(new_name).await? { + return Err(RoomError::PublicChannelNameConflict); + } + + self.room_management_service + .reconfigure_room(room_jid, spec, new_name) + .await?; + + let Some(room) = self.connected_rooms_repo.update(room_jid, { + Box::new(|room| room.by_changing_type(RoomType::PublicChannel)) + }) else { + return Err(RequestError::Generic { + msg: "Room was modified during reconfiguration".to_string(), + } + .into()); + }; + + Ok(room) + } + (RoomType::PublicChannel, RoomType::PrivateChannel) => { + self.room_management_service + .reconfigure_room(room_jid, spec, new_name) + .await?; + + // TODO: Make public channels also members-only so that the member list translates to the private channel + + let Some(room) = self.connected_rooms_repo.update(room_jid, { + Box::new(|room| room.by_changing_type(RoomType::PrivateChannel)) + }) else { + return Err(RequestError::Generic { + msg: "Room was modified during reconfiguration".to_string(), + } + .into()); + }; + + Ok(room) + } + (RoomType::Group, _) + | (RoomType::PrivateChannel, _) + | (RoomType::PublicChannel, _) + | (RoomType::DirectMessage, _) + | (RoomType::Pending, _) + | (RoomType::Generic, _) => { + panic!( + "Cannot convert room of type {} to type {}.", + room.r#type, + spec.room_type() + ); + } + } + } + + /// Loads the configuration for `room_id` and updates the corresponding `RoomInternals` + /// accordingly. Call this method after the room configuration changed. + /// Returns `RoomError::RoomNotFound` if no room with `room_id` exists. + async fn reevaluate_room_spec( + &self, + room_id: &RoomId, + ) -> Result, RoomError> { + let Some(room) = self.connected_rooms_repo.get(room_id) else { + return Err(RoomError::RoomNotFound); + }; + + let config = self + .room_management_service + .load_room_config(room_id) + .await?; + + room.set_name(config.room_name); + room.set_description(config.room_description); + + if room.r#type == config.room_type { + info!("Room type remained for {}.", room_id); + return Ok(room); + } + + info!( + "Room type changed from {} to {} for {}.", + room.r#type, config.room_type, room_id + ); + + self.connected_rooms_repo + .update( + room_id, + Box::new(|room| room.by_changing_type(config.room_type)), + ) + .ok_or(RoomError::RoomWasModified) + } } impl RoomsDomainService { async fn join_room( &self, - room_jid: &RoomJid, + room_jid: &RoomId, password: Option<&str>, ) -> Result, RoomError> { - let user_jid = self.ctx.connected_jid()?.into_bare(); - // We generate a random suffix to prevent any nickname conflicts… - let nickname = format!( - "{}-{}", - user_jid.node_str().unwrap_or("unknown-user"), - self.id_provider.new_id() - ); + let nickname = self.build_nickname()?; // Insert pending room so that we don't miss any stanzas for this room while we're // connecting to it… - self.insert_pending_room(room_jid, &user_jid, &nickname)?; + self.insert_pending_room(room_jid, &nickname)?; - let full_room_jid = room_jid.with_resource_str(&nickname)?; + let full_room_jid = room_jid.occupant_id_with_nickname(&nickname)?; info!( "Trying to join room {} with nickname {}…", @@ -150,42 +356,43 @@ impl RoomsDomainService { } }?; - self.finalize_pending_room(&user_jid, info).await + self.finalize_pending_room(info).await } async fn join_direct_message( &self, - participant: &BareJid, + participant: &UserId, ) -> Result, RoomError> { - if let Some(room) = self.connected_rooms_repo.get(&participant.clone().into()) { + let room_id = RoomId::from(participant.clone().into_inner()); + + if let Some(room) = self.connected_rooms_repo.get(&room_id) { return Ok(room); } - let user_profile = self + let contact_name = self .user_profile_repo - .get(participant) - .await - .ok() - .map(|maybe_profile| maybe_profile.unwrap_or_default()) + .get_display_name(participant) + .await? + .unwrap_or_else(|| participant.formatted_username()); + let user_info = self + .user_info_repo + .get_user_info(participant) + .await? .unwrap_or_default(); - let user_jid = self.ctx.connected_jid()?.into_bare(); - let contact_name = build_contact_name(&participant, &user_profile); - let room = Arc::new(RoomInternals::for_direct_message( - &user_jid, &participant, &contact_name, + &user_info.availability, )); match self.connected_rooms_repo.set(room.clone()) { Ok(()) => Ok(room), Err(_err) => { - let room_jid = RoomJid::from(participant.clone()); - if let Some(room) = self.connected_rooms_repo.get(&room_jid) { + if let Some(room) = self.connected_rooms_repo.get(&room_id) { return Ok(room); } - return Err(RoomError::RoomIsAlreadyConnected(room_jid)); + return Err(RoomError::RoomIsAlreadyConnected(room_id)); } } } @@ -195,12 +402,9 @@ impl RoomsDomainService { service: &BareJid, request: CreateRoomType, ) -> Result, RoomError> { - let user_jid = self.ctx.connected_jid()?.into_bare(); - let result = match request { CreateRoomType::Group { participants } => { - self.create_or_join_group(&service, &user_jid, participants) - .await + self.create_or_join_group(&service, participants).await } CreateRoomType::PrivateChannel { name } => { // We'll use a random ID for the jid of the private channel. This way @@ -211,8 +415,7 @@ impl RoomsDomainService { self.create_or_join_room_with_spec( &service, - &user_jid, - &format!("{}.{}", PRIVATE_CHANNEL_PREFIX, channel_id), + &format!("{}.{}", CHANNEL_PREFIX, channel_id), &name, RoomSpec::PrivateChannel, |_| async { Ok(()) }, @@ -234,8 +437,7 @@ impl RoomsDomainService { self.create_or_join_room_with_spec( &service, - &user_jid, - &format!("{}.{}", PUBLIC_CHANNEL_PREFIX, channel_id), + &format!("{}.{}", CHANNEL_PREFIX, channel_id), &name, RoomSpec::PublicChannel, |_| async { Ok(()) }, @@ -255,24 +457,25 @@ impl RoomsDomainService { Err(error) => return Err(error), }; - self.finalize_pending_room(&user_jid, info).await + self.finalize_pending_room(info).await } async fn create_or_join_group( &self, service: &BareJid, - user_jid: &BareJid, - participants: Vec, + participants: Vec, ) -> Result { if participants.len() < 2 { return Err(RoomError::InvalidNumberOfParticipants); } + let user_jid = self.ctx.connected_id()?.into_user_id(); + // Load participant infos so that we can build a nice human-readable name for the group… let mut participant_names = vec![]; let participants_including_self = participants .iter() - .chain(iter::once(user_jid)) + .chain(iter::once(&user_jid)) .cloned() .collect::>(); @@ -282,8 +485,7 @@ impl RoomsDomainService { .get(jid) .await? .and_then(|profile| profile.first_name.or(profile.nickname)) - .or(jid.node_str().map(|node| node.to_uppercase_first_letter())) - .unwrap_or(jid.to_string()); + .unwrap_or_else(|| jid.username().to_uppercase_first_letter()); participant_names.push(participant_name); } participant_names.sort(); @@ -309,22 +511,28 @@ impl RoomsDomainService { let info = self .create_or_join_room_with_spec( service, - user_jid, &group_hash, &group_name, RoomSpec::Group, |info| { // Try to promote all participants to owners… info!("Update participant affiliations…"); - let room_jid = info.room_jid.clone(); + let room_jid = info.room_id.clone(); let room_has_been_created = info.room_has_been_created; let service = self.room_management_service.clone(); - async move { - let owners = participants_including_self.iter().collect::>(); + for participant in &participants { + if info.members.iter().find(|m| &m.id == participant).is_none() { + info.members.push(RoomSessionMember { + id: participant.clone(), + affiliation: RoomAffiliation::Owner, + }); + } + } + async move { if room_has_been_created { - service.set_room_owners(&room_jid, owners.as_slice()).await + service.set_room_owners(&room_jid, participants_including_self.as_slice()).await .context( "Failed to update user affiliations of created group to type 'owner'", ) @@ -339,9 +547,8 @@ impl RoomsDomainService { // Send invites… if info.room_has_been_created { info!("Sending invites for created group…"); - let participants = participants.iter().collect::>(); self.room_participation_service - .invite_users_to_room(&info.room_jid, participants.as_slice()) + .invite_users_to_room(&info.room_id, participants.as_slice()) .await?; } @@ -351,18 +558,12 @@ impl RoomsDomainService { async fn create_or_join_room_with_spec> + 'static>( &self, service: &BareJid, - user_jid: &BareJid, room_id: &str, room_name: &str, spec: RoomSpec, - perform_additional_config: impl FnOnce(&RoomSessionInfo) -> Fut, + perform_additional_config: impl FnOnce(&mut RoomSessionInfo) -> Fut, ) -> Result { - // We generate a random suffix to prevent any nickname conflicts… - let nickname = format!( - "{}-{}", - user_jid.node_str().unwrap_or("unknown-user"), - self.id_provider.new_id() - ); + let nickname = self.build_nickname()?; let mut attempt = 0; @@ -374,15 +575,15 @@ impl RoomsDomainService { }; attempt += 1; - let room_jid = RoomJid::from(BareJid::from_parts( + let room_jid = RoomId::from(BareJid::from_parts( Some(&NodePart::new(&unique_room_id)?), &service.domain(), )); - let full_room_jid = room_jid.with_resource_str(&nickname)?; + let full_room_jid = room_jid.occupant_id_with_nickname(&nickname)?; // Insert pending room so that we don't miss any stanzas for this room while we're // creating (but potentially connecting to) it… - self.insert_pending_room(&room_jid, user_jid, &nickname)?; + self.insert_pending_room(&room_jid, &nickname)?; // Try to create or enter the room and configure it… let result = self @@ -390,7 +591,7 @@ impl RoomsDomainService { .create_or_join_room(&full_room_jid, room_name, spec.clone()) .await; - let info = match result { + let mut info = match result { Ok(occupancy) => occupancy, Err(error) => { // Remove pending room again… @@ -406,7 +607,7 @@ impl RoomsDomainService { } }; - match (perform_additional_config)(&info).await { + match (perform_additional_config)(&mut info).await { Ok(_) => (), Err(error) => { // Remove pending room again… @@ -414,7 +615,10 @@ impl RoomsDomainService { // Again, if the additional configuration fails and we've created the room // we'll destroy it again. if info.room_has_been_created { - _ = self.room_management_service.destroy_room(&room_jid).await; + _ = self + .room_management_service + .destroy_room(&room_jid, None) + .await; } return Err(error.into()); } @@ -426,44 +630,47 @@ impl RoomsDomainService { async fn finalize_pending_room( &self, - user_jid: &BareJid, info: RoomSessionInfo, ) -> Result, RoomError> { // It could be the case that the room_jid was modified, i.e. if the preferred JID was // taken already. - let room_name = info.room_name; + let room_name = info.config.room_name; + let room_description = info.config.room_description; + let current_user_id = self.ctx.connected_id()?.into_user_id(); - let mut members = HashMap::with_capacity(info.members.len()); - for jid in info.members { + let mut members = Vec::with_capacity(info.members.len()); + for member in info.members { let name = self .user_profile_repo - .get_display_name(&jid) + .get_display_name(&member.id) .await - .unwrap_or_default() - .unwrap_or_else(|| jid.to_display_name()); - members.insert(jid, Member { name }); + .unwrap_or_default(); + let is_self = member.id == current_user_id; + + members.push(RegisteredMember { + user_id: member.id, + name, + affiliation: member.affiliation, + is_self, + }); } let room_info = RoomInfo { - jid: info.room_jid.clone(), - description: info.room_description, - user_jid: user_jid.clone(), + room_id: info.room_id.clone(), user_nickname: info.user_nickname, - members, - r#type: info.room_type, + r#type: info.config.room_type, }; - let Some(room) = self.connected_rooms_repo.update(&info.room_jid, { + let Some(room) = self.connected_rooms_repo.update(&info.room_id, { let room_name = room_name; Box::new(move |room| { // Convert the temporary room to its final form… - room.by_resolving_with_info(room_name, room_info) + let room = + room.by_resolving_with_info(room_name, room_description, room_info, members); + room }) }) else { - return Err(RequestError::Generic { - msg: "Room was modified during connection".to_string(), - } - .into()); + return Err(RoomError::RoomWasModified); }; Ok(room) @@ -489,16 +696,21 @@ impl RoomsDomainService { Ok(true) } - fn insert_pending_room( - &self, - room_jid: &RoomJid, - user_jid: &BareJid, - nickname: &str, - ) -> Result<(), RoomError> { + fn build_nickname(&self) -> Result { + // We append a suffix to prevent any nickname conflicts, but want to make sure that it is + // identical between multiple sessions so that these would be displayed as one user. + let user_id = self.ctx.connected_id()?.to_user_id(); + + Ok(format!( + "{}#{}", + user_id.username(), + general_purpose::URL_SAFE_NO_PAD.encode(user_id.to_string()) + )) + } + + fn insert_pending_room(&self, room_jid: &RoomId, nickname: &str) -> Result<(), RoomError> { self.connected_rooms_repo - .set(Arc::new(RoomInternals::pending( - room_jid, user_jid, nickname, - ))) + .set(Arc::new(RoomInternals::pending(room_jid, nickname))) .map_err(|_| RoomError::RoomIsAlreadyConnected(room_jid.clone())) } } @@ -507,7 +719,7 @@ trait ParticipantsVecExt { fn group_name_hash(&self) -> String; } -impl ParticipantsVecExt for Vec { +impl ParticipantsVecExt for Vec { fn group_name_hash(&self) -> String { let mut sorted_participant_jids = self.iter().map(|jid| jid.to_string()).collect::>(); @@ -521,7 +733,7 @@ impl ParticipantsVecExt for Vec { #[cfg(test)] mod tests { - use prose_xmpp::jid; + use crate::user_id; use super::*; @@ -529,9 +741,9 @@ mod tests { fn test_group_name_for_participants() { assert_eq!( vec![ - jid!("a@prose.org").into_bare(), - jid!("b@prose.org").into_bare(), - jid!("c@prose.org").into_bare() + user_id!("a@prose.org"), + user_id!("b@prose.org"), + user_id!("c@prose.org") ] .group_name_hash(), "org.prose.group.7c138d7281db96e0d42fe026a4195c85a7dc2cae".to_string() @@ -539,15 +751,15 @@ mod tests { assert_eq!( vec![ - jid!("a@prose.org").into_bare(), - jid!("b@prose.org").into_bare(), - jid!("c@prose.org").into_bare() + user_id!("a@prose.org"), + user_id!("b@prose.org"), + user_id!("c@prose.org") ] .group_name_hash(), vec![ - jid!("c@prose.org").into_bare(), - jid!("a@prose.org").into_bare(), - jid!("b@prose.org").into_bare() + user_id!("c@prose.org"), + user_id!("a@prose.org"), + user_id!("b@prose.org") ] .group_name_hash() ) diff --git a/crates/prose-core-client/src/domain/rooms/services/room_factory.rs b/crates/prose-core-client/src/domain/rooms/services/room_factory.rs index bc3c30f8..d9613de8 100644 --- a/crates/prose-core-client/src/domain/rooms/services/room_factory.rs +++ b/crates/prose-core-client/src/domain/rooms/services/room_factory.rs @@ -29,7 +29,9 @@ impl RoomFactory { let inner = Arc::new((self.builder)(room)); match room_type { - RoomType::Pending => panic!("Cannot convert pending room to RoomEnvelope"), + RoomType::Pending => { + panic!("Cannot convert pending room to RoomEnvelope") + } RoomType::DirectMessage => RoomEnvelope::DirectMessage(inner.into()), RoomType::Group => RoomEnvelope::Group(inner.into()), RoomType::PrivateChannel => RoomEnvelope::PrivateChannel(inner.into()), diff --git a/crates/prose-core-client/src/domain/rooms/services/room_management_service.rs b/crates/prose-core-client/src/domain/rooms/services/room_management_service.rs index a1c7e93c..44668f07 100644 --- a/crates/prose-core-client/src/domain/rooms/services/room_management_service.rs +++ b/crates/prose-core-client/src/domain/rooms/services/room_management_service.rs @@ -8,7 +8,10 @@ use jid::{BareJid, FullJid}; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; -use crate::domain::rooms::models::{PublicRoomInfo, RoomError, RoomSessionInfo, RoomSpec}; +use crate::domain::rooms::models::{ + PublicRoomInfo, RoomConfig, RoomError, RoomSessionInfo, RoomSpec, +}; +use crate::domain::shared::models::{OccupantId, RoomId, UserId}; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] @@ -21,24 +24,35 @@ pub trait RoomManagementService: SendUnlessWasm + SyncUnlessWasm { async fn create_or_join_room( &self, - room_jid: &FullJid, + occupant_id: &OccupantId, room_name: &str, spec: RoomSpec, ) -> Result; async fn join_room( &self, - room_jid: &FullJid, + occupant_id: &OccupantId, password: Option<&str>, ) -> Result; + async fn reconfigure_room( + &self, + room_jid: &RoomId, + spec: RoomSpec, + new_name: &str, + ) -> Result<(), RoomError>; + + async fn load_room_config(&self, room_jid: &RoomId) -> Result; + async fn exit_room(&self, room_jid: &FullJid) -> Result<(), RoomError>; - async fn set_room_owners( + async fn set_room_owners(&self, room_jid: &RoomId, users: &[UserId]) -> Result<(), RoomError>; + + /// Destroys the room identified by `room_jid`. If specified sets `alternate_room` as + /// replacement room, so that users will be redirected there. + async fn destroy_room( &self, - room_jid: &BareJid, - users: &[&BareJid], + room_jid: &RoomId, + alternate_room: Option, ) -> Result<(), RoomError>; - - async fn destroy_room(&self, room_jid: &BareJid) -> Result<(), RoomError>; } diff --git a/crates/prose-core-client/src/domain/rooms/services/room_participation_service.rs b/crates/prose-core-client/src/domain/rooms/services/room_participation_service.rs index a820e769..bad27486 100644 --- a/crates/prose-core-client/src/domain/rooms/services/room_participation_service.rs +++ b/crates/prose-core-client/src/domain/rooms/services/room_participation_service.rs @@ -4,12 +4,11 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use async_trait::async_trait; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::rooms::models::RoomError; -use crate::dtos::RoomJid; +use crate::domain::shared::models::{RoomId, UserId}; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] @@ -17,7 +16,13 @@ use crate::dtos::RoomJid; pub trait RoomParticipationService: SendUnlessWasm + SyncUnlessWasm { async fn invite_users_to_room( &self, - room_jid: &RoomJid, - participants: &[&BareJid], + room_jid: &RoomId, + participants: &[UserId], + ) -> Result<(), RoomError>; + + async fn grant_membership( + &self, + room_jid: &RoomId, + participant: &UserId, ) -> Result<(), RoomError>; } diff --git a/crates/prose-core-client/src/domain/rooms/services/rooms_domain_service.rs b/crates/prose-core-client/src/domain/rooms/services/rooms_domain_service.rs index 6ab29064..33afb633 100644 --- a/crates/prose-core-client/src/domain/rooms/services/rooms_domain_service.rs +++ b/crates/prose-core-client/src/domain/rooms/services/rooms_domain_service.rs @@ -8,14 +8,15 @@ use std::sync::Arc; use anyhow::Result; use async_trait::async_trait; use jid::BareJid; + use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; -use crate::domain::rooms::models::{RoomError, RoomInternals}; -use crate::domain::shared::models::RoomJid; +use crate::domain::rooms::models::{RoomError, RoomInternals, RoomSpec}; +use crate::domain::shared::models::{RoomId, UserId}; #[derive(Debug, Clone, PartialEq)] pub enum CreateRoomType { - Group { participants: Vec }, + Group { participants: Vec }, PrivateChannel { name: String }, PublicChannel { name: String }, } @@ -27,11 +28,11 @@ pub enum CreateOrEnterRoomRequest { room_type: CreateRoomType, }, JoinRoom { - room_jid: RoomJid, + room_jid: RoomId, password: Option, }, JoinDirectMessage { - participant: BareJid, + participant: UserId, }, } @@ -53,5 +54,27 @@ pub trait RoomsDomainService: SendUnlessWasm + SyncUnlessWasm { /// `RoomType::PublicChannel` and `name` is already used by another public channel. /// - Dispatches `ClientEvent::RoomChanged` of type `RoomEventType::AttributesChanged` /// after processing. - async fn rename_room(&self, room_jid: &RoomJid, name: &str) -> Result<(), RoomError>; + async fn rename_room(&self, room_id: &RoomId, name: &str) -> Result<(), RoomError>; + + /// Reconfigures the room identified by `room_jid` according to `spec` and renames it to `new_name`. + /// + /// If the room is not connected no action is performed, otherwise: + /// - Panics if the reconfiguration is not not allowed. Allowed reconfigurations are: + /// - `RoomType::Group` -> `RoomType::PrivateChannel` + /// - `RoomType::PublicChannel` -> `RoomType::PrivateChannel` + /// - `RoomType::PrivateChannel` -> `RoomType::PublicChannel` + /// - Dispatches `ClientEvent::RoomChanged` of type `RoomEventType::AttributesChanged` + /// after processing. + async fn reconfigure_room_with_spec( + &self, + room_id: &RoomId, + spec: RoomSpec, + new_name: &str, + ) -> Result, RoomError>; + + /// Loads the configuration for `room_id` and updates the corresponding `RoomInternals` + /// accordingly. Call this method after the room configuration changed. + /// Returns `RoomError::RoomNotFound` if no room with `room_id` exists. + async fn reevaluate_room_spec(&self, room_id: &RoomId) + -> Result, RoomError>; } diff --git a/crates/prose-core-client/src/domain/settings/repos/account_settings_repository.rs b/crates/prose-core-client/src/domain/settings/repos/account_settings_repository.rs index 1917f3d0..99e7964f 100644 --- a/crates/prose-core-client/src/domain/settings/repos/account_settings_repository.rs +++ b/crates/prose-core-client/src/domain/settings/repos/account_settings_repository.rs @@ -5,10 +5,11 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; + use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::settings::models::AccountSettings; +use crate::domain::shared::models::UserId; type UpdateHandler = Box FnOnce(&'a mut AccountSettings) + Send>; @@ -16,7 +17,7 @@ type UpdateHandler = Box FnOnce(&'a mut AccountSettings) + Send>; #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait AccountSettingsRepository: SendUnlessWasm + SyncUnlessWasm { - async fn get(&self, jid: &BareJid) -> Result; - async fn update(&self, jid: &BareJid, block: UpdateHandler) -> Result<()>; + async fn get(&self, jid: &UserId) -> Result; + async fn update(&self, jid: &UserId, block: UpdateHandler) -> Result<()>; async fn clear_cache(&self) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/shared/models/anon_occupant_id.rs b/crates/prose-core-client/src/domain/shared/models/anon_occupant_id.rs new file mode 100644 index 00000000..9f34c5a6 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/anon_occupant_id.rs @@ -0,0 +1,32 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Debug, Display, Formatter}; + +#[derive(Clone, PartialEq, Eq, Hash)] +/// Represents an anonymous identifier of a user within a Multi-User Chat (MUC) room. +/// See: https://xmpp.org/extensions/xep-0421.html +pub struct AnonOccupantId(String); + +impl From for AnonOccupantId +where + T: Into, +{ + fn from(s: T) -> Self { + AnonOccupantId(s.into()) + } +} + +impl Display for AnonOccupantId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl Debug for AnonOccupantId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "AnonOccupantId({})", self.0) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/availability.rs b/crates/prose-core-client/src/domain/shared/models/availability.rs index 116e2b24..6922f25d 100644 --- a/crates/prose-core-client/src/domain/shared/models/availability.rs +++ b/crates/prose-core-client/src/domain/shared/models/availability.rs @@ -4,6 +4,7 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use serde::{Deserialize, Serialize}; +use std::fmt::{Display, Formatter}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub enum Availability { @@ -13,3 +14,18 @@ pub enum Availability { DoNotDisturb, Away, } + +impl Display for Availability { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + Availability::Available => "available", + Availability::Unavailable => "unavailable", + Availability::DoNotDisturb => "do not disturb", + Availability::Away => "away", + } + ) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/capabilities_id.rs b/crates/prose-core-client/src/domain/shared/models/capabilities_id.rs new file mode 100644 index 00000000..8a6b238b --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/capabilities_id.rs @@ -0,0 +1,26 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Display, Formatter}; + +/// This is the combination "{node}#{ver}" of a "" element. +/// https://xmpp.org/extensions/xep-0115.html +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct CapabilitiesId(String); + +impl From for CapabilitiesId +where + T: Into, +{ + fn from(s: T) -> Self { + CapabilitiesId(s.into()) + } +} + +impl Display for CapabilitiesId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/mod.rs b/crates/prose-core-client/src/domain/shared/models/mod.rs index 15fc58a9..75c1fda4 100644 --- a/crates/prose-core-client/src/domain/shared/models/mod.rs +++ b/crates/prose-core-client/src/domain/shared/models/mod.rs @@ -3,12 +3,32 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +pub use anon_occupant_id::AnonOccupantId; pub use availability::Availability; -pub use room_jid::RoomJid; +pub use capabilities_id::CapabilitiesId; +pub use occupant_id::OccupantId; +pub use participant_id::ParticipantId; +pub use request_id::RequestId; +pub use room_id::RoomId; pub use room_type::RoomType; -pub use user_info::{UserBasicInfo, UserPresenceInfo}; +pub use sender_id::SenderId; +pub use user_endpoint_id::UserEndpointId; +pub use user_id::UserId; +pub use user_info::{ParticipantInfo, UserBasicInfo, UserPresenceInfo}; +pub use user_or_resource_id::UserOrResourceId; +pub use user_resource_id::UserResourceId; +mod anon_occupant_id; mod availability; -mod room_jid; +mod capabilities_id; +mod occupant_id; +mod participant_id; +mod request_id; +mod room_id; mod room_type; +mod sender_id; +mod user_endpoint_id; +mod user_id; mod user_info; +mod user_or_resource_id; +mod user_resource_id; diff --git a/crates/prose-core-client/src/domain/shared/models/occupant_id.rs b/crates/prose-core-client/src/domain/shared/models/occupant_id.rs new file mode 100644 index 00000000..d6df4560 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/occupant_id.rs @@ -0,0 +1,65 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Debug, Display, Formatter}; + +use jid::FullJid; +use minidom::IntoAttributeValue; + +use crate::dtos::RoomId; +use crate::util::StringExt; + +#[derive(Clone, PartialEq, Eq, Hash)] +/// Represents the identifier of a user within a Multi-User Chat (MUC) room, combining the +/// room's JID with the user's unique nickname in that room. +pub struct OccupantId(FullJid); + +impl OccupantId { + pub fn nickname(&self) -> &str { + self.0.resource_str() + } + + pub fn formatted_nickname(&self) -> String { + self.0.resource_str().capitalized_display_name() + } + + pub fn room_id(&self) -> RoomId { + self.0.to_bare().into() + } + + pub fn into_inner(self) -> FullJid { + self.0 + } +} + +impl From for OccupantId { + fn from(value: FullJid) -> Self { + OccupantId(value) + } +} + +impl Debug for OccupantId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "OccupantId({})", self.0) + } +} + +impl Display for OccupantId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl IntoAttributeValue for OccupantId { + fn into_attribute_value(self) -> Option { + self.0.into_attribute_value() + } +} + +impl AsRef for OccupantId { + fn as_ref(&self) -> &FullJid { + &self.0 + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/participant_id.rs b/crates/prose-core-client/src/domain/shared/models/participant_id.rs new file mode 100644 index 00000000..be59e4d0 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/participant_id.rs @@ -0,0 +1,43 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use super::{OccupantId, UserId}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Represents the identifier of a user within - what we define as - room. So it could be either a +/// regular UserId (BareJid) in a DirectMessage room (1:1 conversation) or a OccupantId when in a +/// multi-user room (MUC chat). +pub enum ParticipantId { + User(UserId), + Occupant(OccupantId), +} + +impl ParticipantId { + pub fn to_user_id(&self) -> Option { + let ParticipantId::User(id) = &self else { + return None; + }; + Some(id.clone()) + } + + pub fn to_occupant_id(&self) -> Option { + let ParticipantId::Occupant(id) = &self else { + return None; + }; + Some(id.clone()) + } +} + +impl From for ParticipantId { + fn from(value: UserId) -> Self { + ParticipantId::User(value) + } +} + +impl From for ParticipantId { + fn from(value: OccupantId) -> Self { + ParticipantId::Occupant(value) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/request_id.rs b/crates/prose-core-client/src/domain/shared/models/request_id.rs new file mode 100644 index 00000000..47923185 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/request_id.rs @@ -0,0 +1,31 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Represents the identifier of a request directed at us. +pub struct RequestId(String); + +impl From for RequestId +where + T: Into, +{ + fn from(s: T) -> Self { + RequestId(s.into()) + } +} + +impl Display for RequestId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for RequestId { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/room_id.rs b/crates/prose-core-client/src/domain/shared/models/room_id.rs new file mode 100644 index 00000000..051f87a6 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/room_id.rs @@ -0,0 +1,124 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Debug, Display, Formatter}; +use std::ops::Deref; +use std::str::FromStr; + +use jid::BareJid; +use minidom::IntoAttributeValue; + +use super::OccupantId; + +#[derive(Clone, PartialEq, Eq, Hash)] +/// A RoomJid while always a BareJid can either stand for a single contact or a MUC room. +pub struct RoomId(BareJid); + +impl RoomId { + pub fn into_inner(self) -> BareJid { + self.0 + } + + pub fn occupant_id_with_nickname( + &self, + nickname: impl AsRef, + ) -> Result { + Ok(OccupantId::from( + self.0.with_resource_str(nickname.as_ref())?, + )) + } +} + +impl From for RoomId { + fn from(value: BareJid) -> Self { + RoomId(value) + } +} + +impl Deref for RoomId { + type Target = BareJid; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Debug for RoomId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "RoomId({})", self.0) + } +} + +impl Display for RoomId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl IntoAttributeValue for RoomId { + fn into_attribute_value(self) -> Option { + self.0.into_attribute_value() + } +} + +impl FromStr for RoomId { + type Err = jid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(BareJid::from_str(s)?)) + } +} + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum RoomJidParseError { + #[error("Missing xmpp: prefix in IRI")] + InvalidIRI, + #[error(transparent)] + JID(#[from] jid::Error), +} + +impl RoomId { + pub fn from_iri(iri: &str) -> Result { + let Some(mut iri) = iri.strip_prefix("xmpp:") else { + return Err(RoomJidParseError::InvalidIRI); + }; + if let Some(idx) = iri.rfind("?join") { + iri = &iri[..idx]; + } + Ok(Self::from_str(iri)?) + } +} + +#[cfg(feature = "test")] +impl RoomId { + pub fn to_display_name(&self) -> String { + use crate::util::StringExt; + + let Some(node) = self.node_str() else { + return self.to_string().to_uppercase_first_letter(); + }; + node.capitalized_display_name() + } +} + +#[cfg(test)] +mod tests { + use crate::room_id; + + use super::*; + + #[test] + fn test_from_iri() { + assert!(RoomId::from_iri("").is_err()); + assert_eq!( + RoomId::from_iri("xmpp:room@muc.example.org?join"), + Ok(room_id!("room@muc.example.org")) + ); + assert_eq!( + RoomId::from_iri("xmpp:room@muc.example.org"), + Ok(room_id!("room@muc.example.org")) + ); + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/room_jid.rs b/crates/prose-core-client/src/domain/shared/models/room_jid.rs deleted file mode 100644 index 67d044fb..00000000 --- a/crates/prose-core-client/src/domain/shared/models/room_jid.rs +++ /dev/null @@ -1,55 +0,0 @@ -// prose-core-client/prose-core-client -// -// Copyright: 2023, Marc Bauer -// License: Mozilla Public License v2.0 (MPL v2.0) - -use std::fmt::{Debug, Display, Formatter}; -use std::ops::Deref; -use std::str::FromStr; - -use jid::BareJid; -use minidom::IntoAttributeValue; - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -/// A RoomJid while always a BareJid can either stand for a single contact or a MUC room. -pub struct RoomJid(BareJid); - -impl RoomJid { - pub fn into_inner(self) -> BareJid { - self.0 - } -} - -impl From for RoomJid { - fn from(value: BareJid) -> Self { - RoomJid(value) - } -} - -impl Deref for RoomJid { - type Target = BareJid; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Display for RoomJid { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -impl IntoAttributeValue for RoomJid { - fn into_attribute_value(self) -> Option { - self.0.into_attribute_value() - } -} - -impl FromStr for RoomJid { - type Err = jid::Error; - - fn from_str(s: &str) -> Result { - Ok(Self(BareJid::from_str(s)?)) - } -} diff --git a/crates/prose-core-client/src/domain/shared/models/room_type.rs b/crates/prose-core-client/src/domain/shared/models/room_type.rs index 47148e7e..8d5d6a67 100644 --- a/crates/prose-core-client/src/domain/shared/models/room_type.rs +++ b/crates/prose-core-client/src/domain/shared/models/room_type.rs @@ -3,6 +3,8 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +use std::fmt::{Display, Formatter}; + #[derive(Debug, Clone, PartialEq)] pub enum RoomType { Pending, @@ -12,3 +14,16 @@ pub enum RoomType { PublicChannel, Generic, } + +impl Display for RoomType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + RoomType::Pending => write!(f, "Pending"), + RoomType::DirectMessage => write!(f, "Direct Message"), + RoomType::Group => write!(f, "Group"), + RoomType::PrivateChannel => write!(f, "Private Channel"), + RoomType::PublicChannel => write!(f, "Public Channel"), + RoomType::Generic => write!(f, "Generic"), + } + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/sender_id.rs b/crates/prose-core-client/src/domain/shared/models/sender_id.rs new file mode 100644 index 00000000..b3308cfd --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/sender_id.rs @@ -0,0 +1,36 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Debug, Display, Formatter}; + +use jid::Jid; + +#[derive(Clone, PartialEq, Eq, Hash)] +/// Represents a unspecified XMPP identifier. Could be a user, server, user resource, etc.… +pub struct SenderId(Jid); + +impl SenderId { + pub fn into_inner(self) -> Jid { + self.0 + } +} + +impl From for SenderId { + fn from(value: Jid) -> Self { + SenderId(value) + } +} + +impl Debug for SenderId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "SenderId({})", self.0) + } +} + +impl Display for SenderId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/user_endpoint_id.rs b/crates/prose-core-client/src/domain/shared/models/user_endpoint_id.rs new file mode 100644 index 00000000..de9a9b21 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/user_endpoint_id.rs @@ -0,0 +1,66 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use super::{OccupantId, ParticipantId, RoomId, UserId, UserOrResourceId, UserResourceId}; + +// Represents any id a user can be identified by. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UserEndpointId { + User(UserId), + UserResource(UserResourceId), + Occupant(OccupantId), +} + +impl UserEndpointId { + pub fn to_room_id(&self) -> RoomId { + match self { + UserEndpointId::User(id) => RoomId::from(id.clone().into_inner()), + UserEndpointId::UserResource(id) => RoomId::from(id.to_user_id().into_inner()), + UserEndpointId::Occupant(id) => id.room_id(), + } + } + + pub fn to_participant_id(&self) -> ParticipantId { + match self { + UserEndpointId::User(id) => ParticipantId::User(id.clone()), + UserEndpointId::UserResource(id) => ParticipantId::User(id.to_user_id()), + UserEndpointId::Occupant(id) => ParticipantId::Occupant(id.clone()), + } + } + + pub fn to_user_or_resource_id(&self) -> Option { + match self { + UserEndpointId::User(id) => Some(UserOrResourceId::User(id.clone())), + UserEndpointId::UserResource(id) => Some(UserOrResourceId::UserResource(id.clone())), + UserEndpointId::Occupant(_) => None, + } + } + + pub fn to_user_id(&self) -> Option { + match self { + UserEndpointId::User(id) => Some(id.clone()), + UserEndpointId::UserResource(id) => Some(id.to_user_id()), + UserEndpointId::Occupant(_) => None, + } + } +} + +impl From for UserEndpointId { + fn from(value: UserId) -> Self { + Self::User(value) + } +} + +impl From for UserEndpointId { + fn from(value: UserResourceId) -> Self { + Self::UserResource(value) + } +} + +impl From for UserEndpointId { + fn from(value: OccupantId) -> Self { + Self::Occupant(value) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/user_id.rs b/crates/prose-core-client/src/domain/shared/models/user_id.rs new file mode 100644 index 00000000..9b2e87b4 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/user_id.rs @@ -0,0 +1,118 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::cmp::Ordering; +use std::fmt::{Debug, Display, Formatter}; +use std::str::FromStr; + +use jid::BareJid; +use minidom::IntoAttributeValue; +use serde::{Deserialize, Serialize}; + +use prose_store::{KeyType, RawKey}; + +use crate::util::StringExt; + +use super::UserResourceId; + +#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +/// Represents a unique XMPP user identifier without resource specification. +pub struct UserId(BareJid); + +impl UserId { + pub fn into_inner(self) -> BareJid { + self.0 + } + + pub fn with_resource(&self, res: impl AsRef) -> Result { + Ok(UserResourceId::from( + self.0.with_resource_str(res.as_ref())?, + )) + } + + pub fn username(&self) -> &str { + self.0.node_str().expect("Missing node in UserId") + } + + pub fn formatted_username(&self) -> String { + let Some(node) = self.0.node_str() else { + return self.to_string().to_uppercase_first_letter(); + }; + node.capitalized_display_name() + } +} + +impl From for UserId { + fn from(value: BareJid) -> Self { + assert!(value.node_str().is_some(), "Missing node in UserId"); + UserId(value) + } +} + +impl Debug for UserId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "UserId({})", self.0) + } +} + +impl Display for UserId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for UserId { + type Err = jid::Error; + + fn from_str(s: &str) -> Result { + Ok(UserId(s.parse::()?)) + } +} + +impl AsRef for UserId { + fn as_ref(&self) -> &BareJid { + &self.0 + } +} + +impl From for BareJid { + fn from(value: UserId) -> Self { + value.0 + } +} + +impl IntoAttributeValue for UserId { + fn into_attribute_value(self) -> Option { + self.0.into_attribute_value() + } +} + +impl KeyType for UserId { + fn to_raw_key(&self) -> RawKey { + RawKey::Text(self.0.to_string()) + } +} + +impl KeyType for &UserId { + fn to_raw_key(&self) -> RawKey { + RawKey::Text(self.0.to_string()) + } +} + +impl PartialOrd for UserId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for UserId { + fn cmp(&self, other: &Self) -> Ordering { + let ord = self.username().cmp(other.username()); + if ord != Ordering::Equal { + return ord; + } + self.0.domain_str().cmp(other.0.domain_str()) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/user_info.rs b/crates/prose-core-client/src/domain/shared/models/user_info.rs index 41bfead3..83918efb 100644 --- a/crates/prose-core-client/src/domain/shared/models/user_info.rs +++ b/crates/prose-core-client/src/domain/shared/models/user_info.rs @@ -3,19 +3,39 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use jid::BareJid; - -use crate::dtos::Availability; +use super::{Availability, UserId}; +use crate::domain::rooms::models::{Participant, RoomAffiliation}; #[derive(Debug, Clone, PartialEq)] pub struct UserBasicInfo { - pub jid: BareJid, + pub id: UserId, pub name: String, } #[derive(Debug, Clone, PartialEq)] pub struct UserPresenceInfo { - pub jid: BareJid, + pub id: UserId, + pub name: String, + pub availability: Availability, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ParticipantInfo { + pub id: Option, pub name: String, + pub is_self: bool, pub availability: Availability, + pub affiliation: RoomAffiliation, +} + +impl From<&Participant> for ParticipantInfo { + fn from(value: &Participant) -> Self { + ParticipantInfo { + id: value.real_id.clone(), + name: value.name.as_deref().unwrap_or("").to_string(), + is_self: value.is_self, + availability: value.availability.clone(), + affiliation: value.affiliation.clone(), + } + } } diff --git a/crates/prose-core-client/src/domain/shared/models/user_or_resource_id.rs b/crates/prose-core-client/src/domain/shared/models/user_or_resource_id.rs new file mode 100644 index 00000000..5f259bad --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/user_or_resource_id.rs @@ -0,0 +1,40 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use crate::domain::shared::models::{UserId, UserResourceId}; + +#[derive(Debug, Clone, PartialEq, Hash)] +pub enum UserOrResourceId { + User(UserId), + UserResource(UserResourceId), +} + +impl UserOrResourceId { + pub fn to_user_id(&self) -> UserId { + match self { + UserOrResourceId::User(id) => id.clone(), + UserOrResourceId::UserResource(id) => id.to_user_id(), + } + } + + pub fn resource_str(&self) -> Option<&str> { + match self { + UserOrResourceId::User(_) => None, + UserOrResourceId::UserResource(id) => Some(id.resource()), + } + } +} + +impl From for UserOrResourceId { + fn from(value: UserId) -> Self { + Self::User(value) + } +} + +impl From for UserOrResourceId { + fn from(value: UserResourceId) -> Self { + Self::UserResource(value) + } +} diff --git a/crates/prose-core-client/src/domain/shared/models/user_resource_id.rs b/crates/prose-core-client/src/domain/shared/models/user_resource_id.rs new file mode 100644 index 00000000..b721afd2 --- /dev/null +++ b/crates/prose-core-client/src/domain/shared/models/user_resource_id.rs @@ -0,0 +1,74 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::fmt::{Debug, Display, Formatter}; + +use jid::{FullJid, Jid}; +use minidom::IntoAttributeValue; + +use super::UserId; + +#[derive(Clone, PartialEq, Eq, Hash)] +/// Represents a unique XMPP user identifier including the specific resource part. +pub struct UserResourceId(FullJid); + +impl UserResourceId { + pub fn into_inner(self) -> FullJid { + self.0 + } + + pub fn to_user_id(&self) -> UserId { + UserId::from(self.0.to_bare()) + } + + pub fn into_user_id(self) -> UserId { + UserId::from(self.0.into_bare()) + } + + pub fn resource(&self) -> &str { + &self.0.resource_str() + } + + pub fn username(&self) -> &str { + self.0.node_str().expect("Missing node in UserId") + } +} + +impl From for UserResourceId { + fn from(value: FullJid) -> Self { + assert!(value.node_str().is_some(), "Missing node in UserResourceId"); + UserResourceId(value) + } +} + +impl Debug for UserResourceId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "UserResourceId({})", self.0) + } +} + +impl Display for UserResourceId { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl IntoAttributeValue for UserResourceId { + fn into_attribute_value(self) -> Option { + self.0.into_attribute_value() + } +} + +impl AsRef for UserResourceId { + fn as_ref(&self) -> &FullJid { + &self.0 + } +} + +impl From for Jid { + fn from(value: UserResourceId) -> Self { + Jid::Full(value.0) + } +} diff --git a/crates/prose-core-client/src/domain/shared/utils/contact_name_builder.rs b/crates/prose-core-client/src/domain/shared/utils/contact_name_builder.rs index 0afb58c2..31bdaf46 100644 --- a/crates/prose-core-client/src/domain/shared/utils/contact_name_builder.rs +++ b/crates/prose-core-client/src/domain/shared/utils/contact_name_builder.rs @@ -5,15 +5,13 @@ use std::ops::Deref; -use jid::BareJid; - use crate::app::dtos::UserProfile; -use crate::util::jid_ext::BareJidExt; +use crate::dtos::UserId; -pub(crate) fn build_contact_name(contact_jid: &BareJid, profile: &UserProfile) -> String { +pub(crate) fn build_contact_name(contact_jid: &UserId, profile: &UserProfile) -> String { concatenate_names(&profile.first_name, &profile.last_name) .or_else(|| profile.nickname.clone()) - .unwrap_or_else(|| contact_jid.to_display_name()) + .unwrap_or_else(|| contact_jid.formatted_username()) } pub(crate) fn concatenate_names( diff --git a/crates/prose-core-client/src/domain/sidebar/models/bookmark.rs b/crates/prose-core-client/src/domain/sidebar/models/bookmark.rs index a45c2970..df85b7fe 100644 --- a/crates/prose-core-client/src/domain/sidebar/models/bookmark.rs +++ b/crates/prose-core-client/src/domain/sidebar/models/bookmark.rs @@ -3,14 +3,14 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use super::BookmarkType; #[derive(Debug, Clone, PartialEq)] pub struct Bookmark { pub name: String, - pub jid: RoomJid, + pub jid: RoomId, pub r#type: BookmarkType, pub is_favorite: bool, pub in_sidebar: bool, diff --git a/crates/prose-core-client/src/domain/sidebar/models/bookmark_type.rs b/crates/prose-core-client/src/domain/sidebar/models/bookmark_type.rs index 658bceda..37ec57f4 100644 --- a/crates/prose-core-client/src/domain/sidebar/models/bookmark_type.rs +++ b/crates/prose-core-client/src/domain/sidebar/models/bookmark_type.rs @@ -3,6 +3,8 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +use std::fmt::{Display, Formatter}; + #[derive(Debug, Clone, PartialEq)] pub enum BookmarkType { DirectMessage, @@ -10,3 +12,18 @@ pub enum BookmarkType { PrivateChannel, PublicChannel, } + +impl Display for BookmarkType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + match self { + BookmarkType::DirectMessage => "Direct Message", + BookmarkType::Group => "Group", + BookmarkType::PrivateChannel => "Private Channel", + BookmarkType::PublicChannel => "Public Channel", + } + ) + } +} diff --git a/crates/prose-core-client/src/domain/sidebar/models/sidebar_item.rs b/crates/prose-core-client/src/domain/sidebar/models/sidebar_item.rs index d5efbc6b..9811b151 100644 --- a/crates/prose-core-client/src/domain/sidebar/models/sidebar_item.rs +++ b/crates/prose-core-client/src/domain/sidebar/models/sidebar_item.rs @@ -3,14 +3,14 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use super::BookmarkType; #[derive(Debug, Clone, PartialEq)] pub struct SidebarItem { pub name: String, - pub jid: RoomJid, + pub jid: RoomId, pub r#type: BookmarkType, pub is_favorite: bool, /// If we were unable to connect to a Room, `error` contains an error message about what diff --git a/crates/prose-core-client/src/domain/sidebar/repos/sidebar_repository.rs b/crates/prose-core-client/src/domain/sidebar/repos/sidebar_repository.rs index 498762af..4e9f012f 100644 --- a/crates/prose-core-client/src/domain/sidebar/repos/sidebar_repository.rs +++ b/crates/prose-core-client/src/domain/sidebar/repos/sidebar_repository.rs @@ -5,19 +5,19 @@ use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use crate::domain::sidebar::models::SidebarItem; #[cfg_attr(feature = "test", mockall::automock)] pub trait SidebarReadOnlyRepository: SendUnlessWasm + SyncUnlessWasm { - fn get(&self, jid: &RoomJid) -> Option; + fn get(&self, jid: &RoomId) -> Option; fn get_all(&self) -> Vec; } pub trait SidebarRepository: SidebarReadOnlyRepository { fn put(&self, item: &SidebarItem); - fn delete(&self, item: &RoomJid); + fn delete(&self, item: &RoomId); fn delete_all(&self); } @@ -26,13 +26,13 @@ mockall::mock! { pub SidebarReadWriteRepository {} impl SidebarReadOnlyRepository for SidebarReadWriteRepository { - fn get(&self, jid: &RoomJid) -> Option; + fn get(&self, jid: &RoomId) -> Option; fn get_all(&self) -> Vec; } impl SidebarRepository for SidebarReadWriteRepository { fn put(&self, item: &SidebarItem); - fn delete(&self, item: &RoomJid); + fn delete(&self, item: &RoomId); fn delete_all(&self); } } diff --git a/crates/prose-core-client/src/domain/sidebar/services/bookmarks_service.rs b/crates/prose-core-client/src/domain/sidebar/services/bookmarks_service.rs index 7729fd35..a577f05e 100644 --- a/crates/prose-core-client/src/domain/sidebar/services/bookmarks_service.rs +++ b/crates/prose-core-client/src/domain/sidebar/services/bookmarks_service.rs @@ -5,11 +5,11 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::sidebar::models::Bookmark; +use crate::dtos::RoomId; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] @@ -17,5 +17,5 @@ use crate::domain::sidebar::models::Bookmark; pub trait BookmarksService: SendUnlessWasm + SyncUnlessWasm { async fn load_bookmarks(&self) -> Result>; async fn save_bookmark(&self, bookmark: &Bookmark) -> Result<()>; - async fn delete_bookmark(&self, jid: &BareJid) -> Result<()>; + async fn delete_bookmark(&self, jid: &RoomId) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/sidebar/services/impls/sidebar_domain_service.rs b/crates/prose-core-client/src/domain/sidebar/services/impls/sidebar_domain_service.rs index a1b5d491..32b4ae31 100644 --- a/crates/prose-core-client/src/domain/sidebar/services/impls/sidebar_domain_service.rs +++ b/crates/prose-core-client/src/domain/sidebar/services/impls/sidebar_domain_service.rs @@ -3,21 +3,26 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +use std::collections::HashSet; +use std::mem; use std::sync::Arc; use anyhow::{bail, Result}; use async_trait::async_trait; -use tracing::error; +use futures::future::join_all; +use futures::FutureExt; +use tracing::{error, info, warn}; use prose_proc_macros::DependenciesStruct; +use prose_wasm_utils::ProseFutureExt; use crate::app::deps::{ DynBookmarksService, DynClientEventDispatcher, DynConnectedRoomsRepository, DynRoomManagementService, DynRoomsDomainService, DynSidebarRepository, }; -use crate::domain::rooms::models::RoomInternals; +use crate::domain::rooms::models::{RoomError, RoomInternals, RoomSpec}; use crate::domain::rooms::services::CreateOrEnterRoomRequest; -use crate::domain::shared::models::{RoomJid, RoomType}; +use crate::domain::shared::models::{RoomId, RoomType, UserId}; use crate::domain::sidebar::models::{Bookmark, BookmarkType, SidebarItem}; use crate::ClientEvent; @@ -40,6 +45,7 @@ impl SidebarDomainServiceTrait for SidebarDomainService { /// /// Loads the remote bookmarks then proceeds with the logic details /// in `extend_items_from_bookmarks`. + #[tracing::instrument(skip(self))] async fn load_and_extend_items_from_bookmarks(&self) -> Result<()> { let bookmarks = self.bookmarks_service.load_bookmarks().await?; self.extend_items_from_bookmarks(bookmarks).await?; @@ -62,7 +68,10 @@ impl SidebarDomainServiceTrait for SidebarDomainService { async fn extend_items_from_bookmarks(&self, bookmarks: Vec) -> Result<()> { let mut sidebar_changed = false; - for bookmark in bookmarks { + let mut sidebar_items_to_delete = HashSet::::new(); + let mut bookmarks_to_save = Vec::::new(); + + for mut bookmark in bookmarks { if let Some(mut sidebar_item) = self.sidebar_repo.get(&bookmark.jid) { // Update basic properties sidebar_item.name = bookmark.name; @@ -75,9 +84,9 @@ impl SidebarDomainServiceTrait for SidebarDomainService { // we'd otherwise loose track of them, while Groups are kept because these should // always be connected so that our user can receive messages from them. if !bookmark.in_sidebar { + self.sidebar_repo.delete(&sidebar_item.jid); self.disconnect_room_for_removed_sidebar_item_if_needed(&sidebar_item) .await?; - self.sidebar_repo.delete(&sidebar_item.jid); } else { self.sidebar_repo.put(&sidebar_item); } @@ -89,11 +98,44 @@ impl SidebarDomainServiceTrait for SidebarDomainService { continue; } - let join_result = self - .join_room_identified_by_bookmark_if_needed(&bookmark) - .await; + let mut bookmark_modified = false; + + let join_result = 'result: loop { + let result = self + .join_room_identified_by_bookmark_if_needed(&bookmark) + .await; + + match result { + Ok(room) => break 'result Ok(room), + Err(err) => { + // The room is gone… + info!("Bookmarked room {} is gone.", bookmark.jid); + if let Some(gone_err) = err.gone_err() { + // Does it have a new location? + if let Some(new_location) = gone_err.new_location { + // Do we have a sidebar item already with that location? + if self.sidebar_repo.get(&new_location).is_some() { + break 'result Ok(None); + } + info!("Following to new location {}…", new_location); + let gone_room_jid = mem::replace(&mut bookmark.jid, new_location); + if !bookmark_modified { + sidebar_items_to_delete.insert(gone_room_jid); + bookmark_modified = true; + } + continue; + } + } + break 'result Err(err); + } + } + }; sidebar_changed = true; + if bookmark_modified { + bookmarks_to_save.push(bookmark.clone()); + } + let sidebar_item = match join_result { Ok(None) => continue, Ok(Some(room)) => SidebarItem { @@ -115,6 +157,28 @@ impl SidebarDomainServiceTrait for SidebarDomainService { self.sidebar_repo.put(&sidebar_item); } + for jid in sidebar_items_to_delete { + match self.bookmarks_service.delete_bookmark(&jid).await { + Ok(()) => (), + Err(err) => warn!( + "Could not delete outdated bookmark. Reason {}", + err.to_string() + ), + } + } + + for bookmark in bookmarks_to_save { + match self.bookmarks_service.save_bookmark(&bookmark).await { + Ok(()) => (), + Err(err) => { + warn!( + "Could not save updated bookmark. Reason {}", + err.to_string() + ) + } + } + } + if sidebar_changed { self.client_event_dispatcher .dispatch_event(ClientEvent::SidebarChanged); @@ -132,63 +196,14 @@ impl SidebarDomainServiceTrait for SidebarDomainService { async fn insert_item_by_creating_or_joining_room( &self, request: CreateOrEnterRoomRequest, - ) -> Result { + ) -> Result { let room = self .rooms_domain_service .create_or_join_room(request) .await?; - if let Some(sidebar_item) = self.sidebar_repo.get(&room.jid) { - return Ok(sidebar_item.jid); - } - - let room_name = room.name().unwrap_or(room.jid.to_string()); - - let bookmark_type = match room.r#type { - RoomType::Pending => { - unreachable!("RoomsDomainService unexpectedly returned a pending room.") - } - RoomType::DirectMessage => BookmarkType::DirectMessage, - RoomType::Group => BookmarkType::Group, - RoomType::PrivateChannel => BookmarkType::PrivateChannel, - RoomType::PublicChannel => BookmarkType::PublicChannel, - RoomType::Generic => { - bail!("The joined/created room did not match any of our specifications.") - } - }; - - let result = self - .bookmarks_service - .save_bookmark(&Bookmark { - name: room_name.clone(), - jid: room.jid.clone(), - r#type: bookmark_type.clone(), - is_favorite: false, - in_sidebar: true, - }) - .await; - - match result { - Ok(_) => (), - Err(error) => { - error!("Failed to save bookmark for room {}. {}", room.jid, error) - } - } - - let sidebar_item = SidebarItem { - name: room_name, - jid: room.jid.clone(), - r#type: bookmark_type.clone(), - is_favorite: false, - error: None, - }; - - self.sidebar_repo.put(&sidebar_item); - - self.client_event_dispatcher - .dispatch_event(ClientEvent::SidebarChanged); - - Ok(sidebar_item.jid) + self.insert_or_update_sidebar_item_and_bookmark_for_room_if_needed(room) + .await } /// Ensures a sidebar item exists for an active direct message or group conversation. @@ -198,7 +213,7 @@ impl SidebarDomainServiceTrait for SidebarDomainService { /// corresponding bookmark. /// /// Dispatches a `ClientEvent::SidebarChanged` event after processing. - async fn insert_item_for_received_message_if_needed(&self, room_jid: &RoomJid) -> Result<()> { + async fn insert_item_for_received_message_if_needed(&self, room_jid: &RoomId) -> Result<()> { // We do not need to create or join rooms here since we couldn't have received a message // from a room we're not connected to. Also rooms for groups are always connected no matter // if they are in the sidebar or not. @@ -206,39 +221,14 @@ impl SidebarDomainServiceTrait for SidebarDomainService { return Ok(()); }; - let bookmark_type = match room.r#type { - RoomType::DirectMessage => BookmarkType::DirectMessage, - RoomType::Group => BookmarkType::Group, + match room.r#type { + RoomType::DirectMessage => (), + RoomType::Group => (), _ => return Ok(()), }; - if self.sidebar_repo.get(&room.jid).is_some() { - return Ok(()); - } - - let bookmark_name = room.name().unwrap_or("Untitled Conversation".to_string()); - - self.bookmarks_service - .save_bookmark(&Bookmark { - name: bookmark_name.clone(), - jid: room.jid.clone(), - r#type: bookmark_type.clone(), - is_favorite: false, - in_sidebar: true, - }) + self.insert_or_update_sidebar_item_and_bookmark_for_room_if_needed(room) .await?; - - self.sidebar_repo.put(&SidebarItem { - name: bookmark_name, - jid: room.jid.clone(), - r#type: bookmark_type, - is_favorite: false, - error: None, - }); - - self.client_event_dispatcher - .dispatch_event(ClientEvent::SidebarChanged); - Ok(()) } @@ -248,7 +238,7 @@ impl SidebarDomainServiceTrait for SidebarDomainService { /// - The corresponding room will be renamed. /// - The corresponding bookmark will be renamed. /// - `ClientEvent::SidebarChanged` will be dispatched after processing. - async fn rename_item(&self, room_jid: &RoomJid, name: &str) -> Result<()> { + async fn rename_item(&self, room_jid: &RoomId, name: &str) -> Result<()> { // If we don't have a sidebar item for this room there's no point in renaming it. It would // either not be connected or be a group which cannot be renamed. let Some(mut item) = self.sidebar_repo.get(room_jid) else { @@ -260,14 +250,15 @@ impl SidebarDomainServiceTrait for SidebarDomainService { return Ok(()); } - // Rename the room first. If that succeeds continue. + // Optimistically update the sidebar item and prevent consecutive renames while ours + // is in progress. + item.name = name.to_string(); + self.sidebar_repo.put(&item); + self.rooms_domain_service .rename_room(room_jid, name) .await?; - item.name = name.to_string(); - self.sidebar_repo.put(&item); - self.bookmarks_service .save_bookmark(&Bookmark::from(&item)) .await?; @@ -283,17 +274,17 @@ impl SidebarDomainServiceTrait for SidebarDomainService { /// If the item is not in the list of sidebar items no action is performed, otherwise: /// - The corresponding bookmark will be updated to reflect the new status of `is_favorite`. /// - `ClientEvent::SidebarChanged` will be dispatched after processing. - async fn toggle_item_is_favorite(&self, room_jid: &RoomJid) -> Result<()> { + async fn toggle_item_is_favorite(&self, room_jid: &RoomId) -> Result<()> { let Some(mut sidebar_item) = self.sidebar_repo.get(room_jid) else { return Ok(()); }; sidebar_item.is_favorite ^= true; + self.sidebar_repo.put(&sidebar_item); self.bookmarks_service .save_bookmark(&Bookmark::from(&sidebar_item)) .await?; - self.sidebar_repo.put(&sidebar_item); self.client_event_dispatcher .dispatch_event(ClientEvent::SidebarChanged); @@ -301,6 +292,51 @@ impl SidebarDomainServiceTrait for SidebarDomainService { Ok(()) } + /// Reconfigures the sidebar item identified by `room_jid` according to `spec`. + /// + /// If the item is not in the list of sidebar items no action is performed, otherwise: + /// - The corresponding room will be reconfigured. + /// - The corresponding bookmark's type will be updated. + /// - `ClientEvent::SidebarChanged` will be dispatched after processing. + #[tracing::instrument(skip(self))] + async fn reconfigure_item_with_spec( + &self, + room_jid: &RoomId, + spec: RoomSpec, + new_name: &str, + ) -> Result<()> { + info!("Reconfiguring room {} to type {}…", room_jid, spec); + + let room = self + .rooms_domain_service + .reconfigure_room_with_spec(room_jid, spec, new_name) + .await?; + + info!( + "Reconfiguration of room {} finished. Room Jid is now {}", + room_jid, room.room_id + ); + + // The returned room has a new JID, which implies that the old room has been deleted… + if room_jid != &room.room_id { + self.connected_rooms_repo.delete(room_jid); + self.sidebar_repo.delete(room_jid); + + if let Err(err) = self.bookmarks_service.delete_bookmark(room_jid).await { + warn!( + "Could not delete bookmark {}. Reason: {}", + room_jid, + err.to_string() + ); + } + } + + self.insert_or_update_sidebar_item_and_bookmark_for_room_if_needed(room) + .await?; + + Ok(()) + } + /// Removes multiple sidebar items associated with the provided `room_jids`. /// /// - Disconnects channels and updates the repository state for each provided JID. @@ -310,7 +346,7 @@ impl SidebarDomainServiceTrait for SidebarDomainService { /// persistent connections and can be rediscovered. /// - Triggers a `ClientEvent::SidebarChanged` event after processing to notify of the /// sidebar update. - async fn remove_items(&self, room_jids: &[&RoomJid]) -> Result<()> { + async fn remove_items(&self, room_jids: &[&RoomId]) -> Result<()> { for jid in room_jids { self.remove_item(*jid).await?; } @@ -326,14 +362,14 @@ impl SidebarDomainServiceTrait for SidebarDomainService { /// - Disconnects channels and updates the repository state for each provided JID. /// - Bookmarks remain untouched. /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. - async fn handle_removed_items(&self, room_jids: &[&RoomJid]) -> Result<()> { - for jid in room_jids { - let Some(sidebar_item) = self.sidebar_repo.get(jid) else { + async fn handle_removed_items(&self, room_ids: &[RoomId]) -> Result<()> { + for id in room_ids { + let Some(sidebar_item) = self.sidebar_repo.get(id) else { continue; }; + self.sidebar_repo.delete(&id); self.disconnect_room_for_removed_sidebar_item_if_needed(&sidebar_item) .await?; - self.sidebar_repo.delete(&jid); } self.client_event_dispatcher @@ -370,6 +406,141 @@ impl SidebarDomainServiceTrait for SidebarDomainService { Ok(()) } + /// Handles a destroyed room. + /// + /// - Removes the connected room. + /// - Deletes the corresponding sidebar item. + /// - Joins `alternate_room` if set (see `insert_item_by_creating_or_joining_room`). + /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. + async fn handle_destroyed_room( + &self, + room_jid: &RoomId, + alternate_room: Option, + ) -> Result<()> { + // Figure out if this affects the sidebar so that we'll have to send an event… + let mut dispatch_event = self.sidebar_repo.get(room_jid).is_some(); + + self.connected_rooms_repo.delete(room_jid); + self.sidebar_repo.delete(room_jid); + + let mut futures = vec![self + .bookmarks_service + .delete_bookmark(room_jid) + .prose_boxed()]; + + if let Some(alternate_room) = alternate_room { + if self.sidebar_repo.get(&alternate_room).is_none() { + // `insert_item_by_creating_or_joining_room` will dispatch the + // `ClientEvent::SidebarChanged` event, so we don't have to… + dispatch_event = false; + + // If we have an alternate room, we'll join that one… + futures.push( + Box::pin(self.insert_item_by_creating_or_joining_room( + CreateOrEnterRoomRequest::JoinRoom { + room_jid: alternate_room, + password: None, + }, + )) + .map(|res| res.map(|_| ())) + .prose_boxed(), + ); + } + } + + join_all(futures) + .await + .into_iter() + .collect::, _>>()?; + + if dispatch_event { + self.client_event_dispatcher + .dispatch_event(ClientEvent::SidebarChanged); + } + + Ok(()) + } + + /// Handles removal from a room. + /// + /// If the removal is temporary: + /// - Deletes the connected room. + /// - Sets an error on the corresponding sidebar item. + /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. + /// + /// If the removal is permanent, follows the procedure described in `handle_destroyed_room`. + async fn handle_removal_from_room(&self, room_jid: &RoomId, is_permanent: bool) -> Result<()> { + if is_permanent { + return self.handle_destroyed_room(room_jid, None).await; + } + + self.connected_rooms_repo.delete(room_jid); + + let Some(mut item) = self.sidebar_repo.get(room_jid) else { + return Ok(()); + }; + + item.error = Some("Room is disconnected.".to_string()); + self.sidebar_repo.put(&item); + + self.client_event_dispatcher + .dispatch_event(ClientEvent::SidebarChanged); + + Ok(()) + } + + /// Handles a changed room configuration. + /// + /// - Reloads the configuration and adjusts the connected room accordingly. + /// - Replaces the connected room if the type of room changed. + /// - Updates the sidebar & associated bookmark to reflect the updated configuration. + /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. + async fn handle_changed_room_config(&self, room_id: &RoomId) -> Result<()> { + let Some(room) = self.connected_rooms_repo.get(room_id) else { + return Ok(()); + }; + + // Ignore pending rooms… + if room.is_pending() { + return Ok(()); + } + + let room = self + .rooms_domain_service + .reevaluate_room_spec(room_id) + .await?; + + let Some(mut item) = self.sidebar_repo.get(room_id) else { + return Ok(()); + }; + + let item_name = room.name().unwrap_or(room.room_id.to_string()); + let item_type = BookmarkType::try_from(room.r#type.clone())?; + + if item.name == item_name && item.r#type == item_type { + info!("No changes required for SidebarItem {}.", room_id); + return Ok(()); + } + + info!("Updating SidebarItem {}…", room_id); + item.name = item_name; + item.r#type = item_type; + + self.sidebar_repo.put(&item); + + if let Err(err) = self.bookmarks_service.save_bookmark(&(&item).into()).await { + error!( + "Failed to save bookmark after configuration change. Reason: {}", + err.to_string() + ); + } + + self.client_event_dispatcher + .dispatch_event(ClientEvent::SidebarChanged); + + Ok(()) + } + /// Removes all connected rooms and sidebar items. /// /// Call this method after logging out. @@ -394,7 +565,7 @@ impl SidebarDomainService { /// - The bookmarks are fully deleted as DirectMessages do not need to be tracked and /// Public Channels can be rediscovered. /// - The `SidebarRepository` is updated by removing the item. - async fn remove_item(&self, jid: &RoomJid) -> Result<()> { + async fn remove_item(&self, jid: &RoomId) -> Result<()> { let Some(sidebar_item) = self.sidebar_repo.get(jid) else { return Ok(()); }; @@ -464,7 +635,7 @@ impl SidebarDomainService { async fn join_room_identified_by_bookmark_if_needed( &self, bookmark: &Bookmark, - ) -> Result>> { + ) -> Result>, RoomError> { let room = match bookmark.r#type { BookmarkType::DirectMessage if !bookmark.in_sidebar => None, @@ -478,7 +649,7 @@ impl SidebarDomainService { BookmarkType::DirectMessage => Some( self.rooms_domain_service .create_or_join_room(CreateOrEnterRoomRequest::JoinDirectMessage { - participant: bookmark.jid.clone().into_inner(), + participant: UserId::from(bookmark.jid.clone().into_inner()), }) .await?, ), @@ -503,6 +674,69 @@ impl SidebarDomainService { } } +impl SidebarDomainService { + async fn insert_or_update_sidebar_item_and_bookmark_for_room_if_needed( + &self, + room: Arc, + ) -> Result { + let room_name = room.name().unwrap_or(room.room_id.to_string()); + + let bookmark_type = BookmarkType::try_from(room.r#type.clone())?; + + let mut new_sidebar_item = SidebarItem { + name: room_name.clone(), + jid: room.room_id.clone(), + r#type: bookmark_type.clone(), + is_favorite: false, + error: None, + }; + + if let Some(sidebar_item) = self.sidebar_repo.get(&room.room_id) { + if sidebar_item.name == new_sidebar_item.name + && sidebar_item.r#type == new_sidebar_item.r#type + { + // Nothing to do… + return Ok(sidebar_item.jid); + } + + // Maintain `is_favorite` status… + new_sidebar_item.is_favorite = sidebar_item.is_favorite; + } + + self.sidebar_repo.put(&new_sidebar_item); + + info!( + "Saving bookmark for room {} (type: {})", + room.room_id, bookmark_type + ); + let result = self + .bookmarks_service + .save_bookmark(&Bookmark { + name: room_name, + jid: room.room_id.clone(), + r#type: bookmark_type.clone(), + is_favorite: false, + in_sidebar: true, + }) + .await; + + match result { + Ok(_) => (), + Err(error) => { + error!( + "Failed to save bookmark for room {}. {}", + room.room_id, error + ) + } + } + + self.client_event_dispatcher + .dispatch_event(ClientEvent::SidebarChanged); + + Ok(new_sidebar_item.jid) + } +} + impl From<&SidebarItem> for Bookmark { fn from(value: &SidebarItem) -> Self { Self { @@ -514,3 +748,23 @@ impl From<&SidebarItem> for Bookmark { } } } + +impl TryFrom for BookmarkType { + type Error = anyhow::Error; + + fn try_from(value: RoomType) -> Result { + let value = match value { + RoomType::Pending => { + unreachable!("RoomsDomainService unexpectedly returned a pending room.") + } + RoomType::DirectMessage => BookmarkType::DirectMessage, + RoomType::Group => BookmarkType::Group, + RoomType::PrivateChannel => BookmarkType::PrivateChannel, + RoomType::PublicChannel => BookmarkType::PublicChannel, + RoomType::Generic => { + bail!("The joined/created room did not match any of our specifications.") + } + }; + Ok(value) + } +} diff --git a/crates/prose-core-client/src/domain/sidebar/services/sidebar_domain_service.rs b/crates/prose-core-client/src/domain/sidebar/services/sidebar_domain_service.rs index b05632bc..5fecf960 100644 --- a/crates/prose-core-client/src/domain/sidebar/services/sidebar_domain_service.rs +++ b/crates/prose-core-client/src/domain/sidebar/services/sidebar_domain_service.rs @@ -8,8 +8,9 @@ use async_trait::async_trait; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; +use crate::domain::rooms::models::RoomSpec; use crate::domain::rooms::services::CreateOrEnterRoomRequest; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use crate::domain::sidebar::models::Bookmark; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] @@ -46,7 +47,7 @@ pub trait SidebarDomainService: SendUnlessWasm + SyncUnlessWasm { async fn insert_item_by_creating_or_joining_room( &self, request: CreateOrEnterRoomRequest, - ) -> Result; + ) -> Result; /// Ensures a sidebar item exists for an active direct message or group conversation. /// @@ -55,7 +56,7 @@ pub trait SidebarDomainService: SendUnlessWasm + SyncUnlessWasm { /// corresponding bookmark. /// /// Dispatches a `ClientEvent::SidebarChanged` event after processing. - async fn insert_item_for_received_message_if_needed(&self, room_jid: &RoomJid) -> Result<()>; + async fn insert_item_for_received_message_if_needed(&self, room_id: &RoomId) -> Result<()>; /// Renames the sidebar item identified by `room_jid` to `name`. /// @@ -63,14 +64,28 @@ pub trait SidebarDomainService: SendUnlessWasm + SyncUnlessWasm { /// - The corresponding room will be renamed. /// - The corresponding bookmark will be renamed. /// - `ClientEvent::SidebarChanged` will be dispatched after processing. - async fn rename_item(&self, room_jid: &RoomJid, name: &str) -> Result<()>; + async fn rename_item(&self, room_id: &RoomId, name: &str) -> Result<()>; /// Toggles the `is_favorite` flag for the sidebar item identified by `room_jid`. /// /// If the item is not in the list of sidebar items no action is performed, otherwise: /// - The corresponding bookmark will be updated to reflect the new status of `is_favorite`. /// - `ClientEvent::SidebarChanged` will be dispatched after processing. - async fn toggle_item_is_favorite(&self, room_jid: &RoomJid) -> Result<()>; + async fn toggle_item_is_favorite(&self, room_id: &RoomId) -> Result<()>; + + /// Reconfigures the sidebar item identified by `room_jid` according to `spec` and renames it + /// to `new_name`. + /// + /// If the item is not in the list of sidebar items no action is performed, otherwise: + /// - The corresponding room will be reconfigured. + /// - The corresponding bookmark's type will be updated. + /// - `ClientEvent::SidebarChanged` will be dispatched after processing. + async fn reconfigure_item_with_spec( + &self, + room_id: &RoomId, + spec: RoomSpec, + new_name: &str, + ) -> Result<()>; /// Removes multiple sidebar items associated with the provided `room_jids`. /// @@ -80,14 +95,14 @@ pub trait SidebarDomainService: SendUnlessWasm + SyncUnlessWasm { /// - DirectMessages and Public Channels are deleted from bookmarks, as they do not require /// persistent connections and can be rediscovered. /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. - async fn remove_items(&self, room_jids: &[&RoomJid]) -> Result<()>; + async fn remove_items(&self, room_ids: &[&RoomId]) -> Result<()>; /// Handles remote deletion of bookmarks. /// /// - Disconnects channels and updates the repository state for each provided JID. /// - Bookmarks remain untouched. /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. - async fn handle_removed_items(&self, room_jids: &[&RoomJid]) -> Result<()>; + async fn handle_removed_items(&self, room_ids: &[RoomId]) -> Result<()>; /// Disconnects *all* rooms and deletes all sidebar items. Dispatches /// a `ClientEvent::SidebarChanged` event after processing. @@ -96,6 +111,36 @@ pub trait SidebarDomainService: SendUnlessWasm + SyncUnlessWasm { /// purged or deleted altogether. It should usually only happen when debugging. async fn handle_remote_purge(&self) -> Result<()>; + /// Handles a destroyed room. + /// + /// - Removes the connected room. + /// - Deletes the corresponding sidebar item. + /// - Joins `alternate_room` if set (see `insert_item_by_creating_or_joining_room`). + /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. + async fn handle_destroyed_room( + &self, + room_id: &RoomId, + alternate_room: Option, + ) -> Result<()>; + + /// Handles removal from a room. + /// + /// If the removal is temporary: + /// - Deletes the connected room. + /// - Sets an error on the corresponding sidebar item. + /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. + /// + /// If the removal is permanent, follows the procedure described in `handle_destroyed_room`. + async fn handle_removal_from_room(&self, room_id: &RoomId, is_permanent: bool) -> Result<()>; + + /// Handles a changed room configuration. + /// + /// - Reloads the configuration and adjusts the connected room accordingly. + /// - Replaces the connected room if the type of room changed. + /// - Updates the sidebar & associated bookmark to reflect the updated configuration. + /// - Dispatches a `ClientEvent::SidebarChanged` event after processing. + async fn handle_changed_room_config(&self, room_id: &RoomId) -> Result<()>; + /// Removes all connected rooms and sidebar items. /// /// Call this method after logging out. diff --git a/crates/prose-core-client/src/domain/user_info/models/mod.rs b/crates/prose-core-client/src/domain/user_info/models/mod.rs index e0fd89b3..1016c579 100644 --- a/crates/prose-core-client/src/domain/user_info/models/mod.rs +++ b/crates/prose-core-client/src/domain/user_info/models/mod.rs @@ -6,13 +6,13 @@ pub use avatar_metadata::{AvatarImageId, AvatarInfo, AvatarMetadata}; pub use platform_image::PlatformImage; pub use presence::Presence; -pub use user_activity::UserActivity; pub use user_info::UserInfo; pub use user_metadata::{LastActivity, UserMetadata}; +pub use user_status::UserStatus; mod avatar_metadata; mod platform_image; mod presence; -mod user_activity; mod user_info; mod user_metadata; +mod user_status; diff --git a/crates/prose-core-client/src/domain/user_info/models/user_info.rs b/crates/prose-core-client/src/domain/user_info/models/user_info.rs index 8b0272f4..08c5ce38 100644 --- a/crates/prose-core-client/src/domain/user_info/models/user_info.rs +++ b/crates/prose-core-client/src/domain/user_info/models/user_info.rs @@ -6,12 +6,12 @@ use crate::domain::shared::models::Availability; use serde::{Deserialize, Serialize}; -use crate::domain::user_info::models::{AvatarInfo, UserActivity}; +use crate::domain::user_info::models::{AvatarInfo, UserStatus}; #[derive(Serialize, Deserialize, Clone, PartialEq, Debug, Default)] pub struct UserInfo { pub avatar: Option, - pub activity: Option, + pub activity: Option, #[serde(skip_serializing, default)] pub availability: Availability, } diff --git a/crates/prose-core-client/src/domain/user_info/models/user_activity.rs b/crates/prose-core-client/src/domain/user_info/models/user_status.rs similarity index 91% rename from crates/prose-core-client/src/domain/user_info/models/user_activity.rs rename to crates/prose-core-client/src/domain/user_info/models/user_status.rs index 83848951..13c55ecb 100644 --- a/crates/prose-core-client/src/domain/user_info/models/user_activity.rs +++ b/crates/prose-core-client/src/domain/user_info/models/user_status.rs @@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] -pub struct UserActivity { +pub struct UserStatus { pub emoji: String, pub status: Option, } diff --git a/crates/prose-core-client/src/domain/user_info/repos/avatar_repository.rs b/crates/prose-core-client/src/domain/user_info/repos/avatar_repository.rs index 20e58a64..c828a709 100644 --- a/crates/prose-core-client/src/domain/user_info/repos/avatar_repository.rs +++ b/crates/prose-core-client/src/domain/user_info/repos/avatar_repository.rs @@ -5,11 +5,11 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use prose_xmpp::mods::AvatarData; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::{AvatarInfo, PlatformImage}; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] @@ -17,19 +17,14 @@ use crate::domain::user_info::models::{AvatarInfo, PlatformImage}; #[cfg_attr(feature = "test", mockall::automock)] pub trait AvatarRepository: SendUnlessWasm + SyncUnlessWasm { /// Loads the avatar for `user_jid` and `checksum` and caches it locally. - async fn precache_avatar_image(&self, user_jid: &BareJid, metadata: &AvatarInfo) -> Result<()>; + async fn precache_avatar_image(&self, user_jid: &UserId, metadata: &AvatarInfo) -> Result<()>; /// Returns the avatar for `user_jid` and `metadata` from cache or loads it from the server. - async fn get(&self, user_jid: &BareJid, metadata: &AvatarInfo) - -> Result>; + async fn get(&self, user_jid: &UserId, metadata: &AvatarInfo) -> Result>; /// Saves the avatar to the local cache. - async fn set( - &self, - user_jid: &BareJid, - metadata: &AvatarInfo, - image: &AvatarData, - ) -> Result<()>; + async fn set(&self, user_jid: &UserId, metadata: &AvatarInfo, image: &AvatarData) + -> Result<()>; async fn clear_cache(&self) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/user_info/repos/user_info_repository.rs b/crates/prose-core-client/src/domain/user_info/repos/user_info_repository.rs index 27541703..cdbbbf5e 100644 --- a/crates/prose-core-client/src/domain/user_info/repos/user_info_repository.rs +++ b/crates/prose-core-client/src/domain/user_info/repos/user_info_repository.rs @@ -5,10 +5,11 @@ use anyhow::Result; use async_trait::async_trait; -use jid::{BareJid, Jid}; + use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; -use crate::domain::user_info::models::{AvatarMetadata, Presence, UserActivity, UserInfo}; +use crate::domain::shared::models::{UserId, UserOrResourceId, UserResourceId}; +use crate::domain::user_info::models::{AvatarMetadata, Presence, UserInfo, UserStatus}; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] @@ -16,17 +17,17 @@ use crate::domain::user_info::models::{AvatarMetadata, Presence, UserActivity, U pub trait UserInfoRepository: SendUnlessWasm + SyncUnlessWasm { /// Tries to resolve `jid` to a FullJid by appending the available resource with the highest /// priority. If no available resource is found, returns `jid` as a `Jid`. - fn resolve_bare_jid_to_full(&self, jid: &BareJid) -> Jid; + fn resolve_user_id_to_user_resource_id(&self, jid: &UserId) -> Option; - async fn get_user_info(&self, jid: &BareJid) -> Result>; + async fn get_user_info(&self, jid: &UserId) -> Result>; - async fn set_avatar_metadata(&self, jid: &BareJid, metadata: &AvatarMetadata) -> Result<()>; + async fn set_avatar_metadata(&self, jid: &UserId, metadata: &AvatarMetadata) -> Result<()>; async fn set_user_activity( &self, - jid: &BareJid, - user_activity: Option<&UserActivity>, + jid: &UserId, + user_activity: Option<&UserStatus>, ) -> Result<()>; - async fn set_user_presence(&self, jid: &Jid, presence: &Presence) -> Result<()>; + async fn set_user_presence(&self, jid: &UserOrResourceId, presence: &Presence) -> Result<()>; async fn clear_cache(&self) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/user_info/services/user_info_service.rs b/crates/prose-core-client/src/domain/user_info/services/user_info_service.rs index 4febdcb6..a51215e3 100644 --- a/crates/prose-core-client/src/domain/user_info/services/user_info_service.rs +++ b/crates/prose-core-client/src/domain/user_info/services/user_info_service.rs @@ -5,11 +5,11 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use prose_xmpp::mods::AvatarData; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::AvatarImageId; use crate::domain::user_info::models::AvatarMetadata; @@ -17,10 +17,10 @@ use crate::domain::user_info::models::AvatarMetadata; #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait UserInfoService: SendUnlessWasm + SyncUnlessWasm { - async fn load_latest_avatar_metadata(&self, from: &BareJid) -> Result>; + async fn load_latest_avatar_metadata(&self, from: &UserId) -> Result>; async fn load_avatar_image( &self, - from: &BareJid, + from: &UserId, image_id: &AvatarImageId, ) -> Result>; } diff --git a/crates/prose-core-client/src/domain/user_profiles/repos/user_profile_repository.rs b/crates/prose-core-client/src/domain/user_profiles/repos/user_profile_repository.rs index 7962f72d..2374a6c1 100644 --- a/crates/prose-core-client/src/domain/user_profiles/repos/user_profile_repository.rs +++ b/crates/prose-core-client/src/domain/user_profiles/repos/user_profile_repository.rs @@ -5,22 +5,23 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; + use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use crate::domain::user_profiles::models::UserProfile; +use crate::dtos::UserId; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait UserProfileRepository: SendUnlessWasm + SyncUnlessWasm { - async fn get(&self, jid: &BareJid) -> Result>; - async fn set(&self, jid: &BareJid, profile: &UserProfile) -> Result<()>; - async fn delete(&self, jid: &BareJid) -> Result<()>; + async fn get(&self, jid: &UserId) -> Result>; + async fn set(&self, jid: &UserId, profile: &UserProfile) -> Result<()>; + async fn delete(&self, jid: &UserId) -> Result<()>; /// Returns the display name for `jid`. Display name is a cascade of first_name, last_name /// and nickname; - async fn get_display_name(&self, jid: &BareJid) -> Result>; + async fn get_display_name(&self, jid: &UserId) -> Result>; async fn clear_cache(&self) -> Result<()>; } diff --git a/crates/prose-core-client/src/domain/user_profiles/services/user_profile_service.rs b/crates/prose-core-client/src/domain/user_profiles/services/user_profile_service.rs index b05a0753..45d7a191 100644 --- a/crates/prose-core-client/src/domain/user_profiles/services/user_profile_service.rs +++ b/crates/prose-core-client/src/domain/user_profiles/services/user_profile_service.rs @@ -6,21 +6,21 @@ use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use jid::{BareJid, FullJid}; -use crate::domain::user_info::models::UserMetadata; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; +use crate::domain::shared::models::{UserId, UserResourceId}; +use crate::domain::user_info::models::UserMetadata; use crate::domain::user_profiles::models::UserProfile; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] #[cfg_attr(feature = "test", mockall::automock)] pub trait UserProfileService: SendUnlessWasm + SyncUnlessWasm { - async fn load_profile(&self, from: &BareJid) -> Result>; + async fn load_profile(&self, from: &UserId) -> Result>; async fn load_user_metadata( &self, - from: &FullJid, + from: &UserResourceId, now: DateTime, ) -> Result>; } diff --git a/crates/prose-core-client/src/infra/account/user_account_service.rs b/crates/prose-core-client/src/infra/account/user_account_service.rs index 26031e9a..f9fb5988 100644 --- a/crates/prose-core-client/src/infra/account/user_account_service.rs +++ b/crates/prose-core-client/src/infra/account/user_account_service.rs @@ -12,7 +12,7 @@ use prose_xmpp::stanza::VCard4; use crate::domain::account::services::UserAccountService; use crate::domain::general::models::Capabilities; use crate::domain::shared::models::Availability; -use crate::domain::user_info::models::{AvatarImageId, AvatarMetadata, UserActivity}; +use crate::domain::user_info::models::{AvatarImageId, AvatarMetadata, UserStatus}; use crate::domain::user_profiles::models::UserProfile; use crate::infra::xmpp::XMPPClient; @@ -55,10 +55,11 @@ impl UserAccountService for XMPPClient { Some(availability.clone().try_into()?), None, Some(capabilities.into()), + None, ) } - async fn set_user_activity(&self, user_activity: Option<&UserActivity>) -> Result<()> { + async fn set_user_activity(&self, user_activity: Option<&UserStatus>) -> Result<()> { let status_mod = self.client.get_mod::(); status_mod .publish_activity(user_activity.cloned().map(Into::into).unwrap_or_default()) diff --git a/crates/prose-core-client/src/infra/avatars/avatar_cache.rs b/crates/prose-core-client/src/infra/avatars/avatar_cache.rs index 1898e758..b11bae8b 100644 --- a/crates/prose-core-client/src/infra/avatars/avatar_cache.rs +++ b/crates/prose-core-client/src/infra/avatars/avatar_cache.rs @@ -5,11 +5,11 @@ use anyhow::Error; use async_trait::async_trait; -use jid::BareJid; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use prose_xmpp::mods::AvatarData; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::{AvatarImageId, AvatarInfo, PlatformImage}; pub const MAX_IMAGE_DIMENSIONS: (u32, u32) = (400, 400); @@ -19,20 +19,20 @@ pub const MAX_IMAGE_DIMENSIONS: (u32, u32) = (400, 400); pub trait AvatarCache: SendUnlessWasm + SyncUnlessWasm { async fn cache_avatar_image( &self, - jid: &BareJid, + jid: &UserId, image: &AvatarData, metadata: &AvatarInfo, ) -> Result<(), Error>; async fn has_cached_avatar_image( &self, - jid: &BareJid, + jid: &UserId, image_checksum: &AvatarImageId, ) -> Result; async fn cached_avatar_image( &self, - jid: &BareJid, + jid: &UserId, image_checksum: &AvatarImageId, ) -> Result, Error>; diff --git a/crates/prose-core-client/src/infra/avatars/fs_avatar_cache.rs b/crates/prose-core-client/src/infra/avatars/fs_avatar_cache.rs index e0330268..1aae46e7 100644 --- a/crates/prose-core-client/src/infra/avatars/fs_avatar_cache.rs +++ b/crates/prose-core-client/src/infra/avatars/fs_avatar_cache.rs @@ -10,11 +10,11 @@ use anyhow::Result; use async_trait::async_trait; use base64::DecodeError; use image::{guess_format, ImageError, ImageFormat, ImageOutputFormat}; -use jid::BareJid; use thiserror::Error; use prose_xmpp::mods::AvatarData; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::{AvatarImageId, AvatarInfo, PlatformImage}; use crate::infra::avatars::{AvatarCache, MAX_IMAGE_DIMENSIONS}; @@ -51,7 +51,7 @@ pub enum FsAvatarCacheError { impl AvatarCache for FsAvatarCache { async fn cache_avatar_image( &self, - jid: &BareJid, + jid: &UserId, image_data: &AvatarData, info: &AvatarInfo, ) -> Result<()> { @@ -75,7 +75,7 @@ impl AvatarCache for FsAvatarCache { async fn has_cached_avatar_image( &self, - jid: &BareJid, + jid: &UserId, image_checksum: &AvatarImageId, ) -> Result { let path = self.filename_for(jid, image_checksum); @@ -84,7 +84,7 @@ impl AvatarCache for FsAvatarCache { async fn cached_avatar_image( &self, - jid: &BareJid, + jid: &UserId, image_checksum: &AvatarImageId, ) -> Result> { let path = self.filename_for(jid, image_checksum); @@ -112,7 +112,7 @@ impl AvatarCache for FsAvatarCache { } impl FsAvatarCache { - fn filename_for(&self, jid: &BareJid, image_checksum: &AvatarImageId) -> PathBuf { + fn filename_for(&self, jid: &UserId, image_checksum: &AvatarImageId) -> PathBuf { self.path.join(format!( "{}-{}.jpg", jid.to_string(), diff --git a/crates/prose-core-client/src/infra/avatars/store_avatar_cache.rs b/crates/prose-core-client/src/infra/avatars/store_avatar_cache.rs index dfe43d43..9a25e76c 100644 --- a/crates/prose-core-client/src/infra/avatars/store_avatar_cache.rs +++ b/crates/prose-core-client/src/infra/avatars/store_avatar_cache.rs @@ -5,13 +5,13 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_store::prelude::*; use prose_store::RawKey; use prose_xmpp::mods::AvatarData; use crate::domain::user_info::models::{AvatarImageId, AvatarInfo, PlatformImage}; +use crate::dtos::UserId; use crate::infra::avatars::AvatarCache; pub struct StoreAvatarCache { @@ -42,7 +42,7 @@ impl KeyType for AvatarImageId { impl AvatarCache for StoreAvatarCache { async fn cache_avatar_image( &self, - _jid: &BareJid, + _jid: &UserId, image: &AvatarData, metadata: &AvatarInfo, ) -> Result<()> { @@ -62,7 +62,7 @@ impl AvatarCache for StoreAvatarCache { async fn has_cached_avatar_image( &self, - _jid: &BareJid, + _jid: &UserId, image_checksum: &AvatarImageId, ) -> Result { let tx = self @@ -76,7 +76,7 @@ impl AvatarCache for StoreAvatarCache { async fn cached_avatar_image( &self, - _jid: &BareJid, + _jid: &UserId, image_checksum: &AvatarImageId, ) -> Result> { let tx = self diff --git a/crates/prose-core-client/src/infra/connection/connection_service.rs b/crates/prose-core-client/src/infra/connection/connection_service.rs index 55eeffa6..6a86682f 100644 --- a/crates/prose-core-client/src/infra/connection/connection_service.rs +++ b/crates/prose-core-client/src/infra/connection/connection_service.rs @@ -5,20 +5,20 @@ use anyhow::Result; use async_trait::async_trait; -use jid::FullJid; use minidom::Element; use prose_xmpp::{mods, ConnectionError}; use crate::domain::connection::models::ServerFeatures; use crate::domain::connection::services::ConnectionService; +use crate::dtos::UserResourceId; use crate::infra::xmpp::XMPPClient; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl ConnectionService for XMPPClient { - async fn connect(&self, jid: &FullJid, password: &str) -> Result<(), ConnectionError> { - self.client.connect(jid, password).await + async fn connect(&self, jid: &UserResourceId, password: &str) -> Result<(), ConnectionError> { + self.client.connect(jid.as_ref(), password).await } async fn disconnect(&self) { diff --git a/crates/prose-core-client/src/infra/contacts/caching_contacts_repository.rs b/crates/prose-core-client/src/infra/contacts/caching_contacts_repository.rs index e520471b..78ca8646 100644 --- a/crates/prose-core-client/src/infra/contacts/caching_contacts_repository.rs +++ b/crates/prose-core-client/src/infra/contacts/caching_contacts_repository.rs @@ -5,12 +5,12 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use parking_lot::RwLock; use crate::app::deps::DynContactsService; use crate::domain::contacts::models::Contact; use crate::domain::contacts::repos::ContactsRepository; +use crate::domain::shared::models::UserId; pub struct CachingContactsRepository { service: DynContactsService, @@ -29,12 +29,12 @@ impl CachingContactsRepository { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl ContactsRepository for CachingContactsRepository { - async fn get_all(&self, account_jid: &BareJid) -> Result> { + async fn get_all(&self, account_jid: &UserId) -> Result> { if let Some(contacts) = self.contacts.read().clone() { return Ok(contacts); } - let contacts = self.service.load_contacts(account_jid).await?; + let contacts = self.service.load_contacts(account_jid.as_ref()).await?; self.contacts .write() .replace(contacts.iter().cloned().collect()); diff --git a/crates/prose-core-client/src/infra/contacts/contacts_service.rs b/crates/prose-core-client/src/infra/contacts/contacts_service.rs index 6ef9a606..fbb72329 100644 --- a/crates/prose-core-client/src/infra/contacts/contacts_service.rs +++ b/crates/prose-core-client/src/infra/contacts/contacts_service.rs @@ -50,7 +50,7 @@ impl From<(&BareJid, Item)> for Contact { }; Contact { - jid: roster_item.jid, + id: roster_item.jid.into(), name: roster_item.name, group, } diff --git a/crates/prose-core-client/src/infra/general/request_handling_service.rs b/crates/prose-core-client/src/infra/general/request_handling_service.rs index 9fa7b2bc..619c9d6a 100644 --- a/crates/prose-core-client/src/infra/general/request_handling_service.rs +++ b/crates/prose-core-client/src/infra/general/request_handling_service.rs @@ -7,34 +7,34 @@ use std::fmt::{Display, Formatter}; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use jid::{BareJid, Jid}; use xmpp_parsers::version::VersionResult; use prose_xmpp::mods; use crate::domain::general::models::{Capabilities, Feature, Identity, SoftwareVersion}; use crate::domain::general::services::{RequestHandlingService, SubscriptionResponse}; +use crate::domain::shared::models::{RequestId, SenderId}; use crate::infra::xmpp::XMPPClient; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl RequestHandlingService for XMPPClient { - async fn respond_to_ping(&self, to: &Jid, id: &str) -> anyhow::Result<()> { + async fn respond_to_ping(&self, to: &SenderId, id: &RequestId) -> anyhow::Result<()> { let ping = self.client.get_mod::(); - ping.send_pong(to.clone(), id).await?; + ping.send_pong(to.clone().into_inner(), id.as_ref()).await?; Ok(()) } async fn respond_to_disco_info_query( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, capabilities: &Capabilities, ) -> anyhow::Result<()> { let caps = self.client.get_mod::(); caps.send_disco_info_query_response( - to.clone(), - id.to_string(), + to.clone().into_inner(), + id.as_ref().to_string(), (&capabilities.clone()).into(), ) .await?; @@ -43,55 +43,70 @@ impl RequestHandlingService for XMPPClient { async fn respond_to_entity_time_request( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, now: &DateTime, ) -> anyhow::Result<()> { let profile = self.client.get_mod::(); profile - .send_entity_time_response(now.clone().into(), to.clone(), id) + .send_entity_time_response(now.clone().into(), to.clone().into_inner(), id.as_ref()) .await?; Ok(()) } async fn respond_to_software_version_request( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, version: &SoftwareVersion, ) -> anyhow::Result<()> { let profile = self.client.get_mod::(); profile - .send_software_version_response(version.clone().into(), to.clone(), id) + .send_software_version_response( + version.clone().into(), + to.clone().into_inner(), + id.as_ref(), + ) .await?; Ok(()) } async fn respond_to_last_activity_request( &self, - to: &Jid, - id: &str, + to: &SenderId, + id: &RequestId, last_active_seconds_ago: u64, ) -> anyhow::Result<()> { let profile = self.client.get_mod::(); profile - .send_last_activity_response(last_active_seconds_ago, None, to.clone(), id) + .send_last_activity_response( + last_active_seconds_ago, + None, + to.clone().into_inner(), + id.as_ref(), + ) .await?; Ok(()) } async fn respond_to_presence_subscription_request( &self, - to: &BareJid, + to: &SenderId, response: SubscriptionResponse, ) -> anyhow::Result<()> { let roster_mod = self.client.get_mod::(); match response { SubscriptionResponse::Approve => { - roster_mod.approve_presence_subscription_request(to).await? + roster_mod + .approve_presence_subscription_request(&to.clone().into_inner().into_bare()) + .await? + } + SubscriptionResponse::Deny => { + roster_mod + .deny_presence_subscription_request(&to.clone().into_inner().into_bare()) + .await? } - SubscriptionResponse::Deny => roster_mod.deny_presence_subscription_request(to).await?, } Ok(()) diff --git a/crates/prose-core-client/src/infra/messaging/caching_message_repository.rs b/crates/prose-core-client/src/infra/messaging/caching_message_repository.rs index 35fb4cee..5b94c55c 100644 --- a/crates/prose-core-client/src/infra/messaging/caching_message_repository.rs +++ b/crates/prose-core-client/src/infra/messaging/caching_message_repository.rs @@ -11,7 +11,7 @@ use prose_store::RawKey; use crate::domain::messaging::models::{MessageId, MessageLike, MessageLikeId}; use crate::domain::messaging::repos::MessagesRepository; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; // TODO: Incorporate MessageArchiveService, cache complete pages loaded from the server @@ -58,11 +58,11 @@ impl KeyType for MessageId { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl MessagesRepository for CachingMessageRepository { - async fn get(&self, room_id: &RoomJid, id: &MessageId) -> Result> { + async fn get(&self, room_id: &RoomId, id: &MessageId) -> Result> { Ok(self.get_all(room_id, &[id]).await?) } - async fn get_all(&self, _room_id: &RoomJid, ids: &[&MessageId]) -> Result> { + async fn get_all(&self, _room_id: &RoomId, ids: &[&MessageId]) -> Result> { let tx = self .store .transaction_for_reading(&[MessagesRecord::collection()]) @@ -86,7 +86,7 @@ impl MessagesRepository for CachingMessageRepository { Ok(messages) } - async fn append(&self, _room_id: &RoomJid, messages: &[&MessageLike]) -> Result<()> { + async fn append(&self, _room_id: &RoomId, messages: &[&MessageLike]) -> Result<()> { let tx = self .store .transaction_for_reading_and_writing(&[MessagesRecord::collection()]) diff --git a/crates/prose-core-client/src/infra/messaging/messaging_service.rs b/crates/prose-core-client/src/infra/messaging/messaging_service.rs index 9984449b..99db3900 100644 --- a/crates/prose-core-client/src/infra/messaging/messaging_service.rs +++ b/crates/prose-core-client/src/infra/messaging/messaging_service.rs @@ -7,13 +7,16 @@ use anyhow::Result; use async_trait::async_trait; use jid::BareJid; use xmpp_parsers::chatstates::ChatState; +use xmpp_parsers::delay::Delay; use xmpp_parsers::message::MessageType; use prose_xmpp::mods; +use prose_xmpp::stanza::message::mam::ArchivedMessage; -use crate::domain::messaging::models::{Emoji, MessageId}; +use crate::domain::messaging::models::{Emoji, MessageId, StanzaParseError}; use crate::domain::messaging::services::MessagingService; use crate::domain::shared::models::RoomType; +use crate::dtos::RoomId; use crate::infra::xmpp::XMPPClient; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] @@ -114,6 +117,43 @@ impl MessagingService for XMPPClient { )?; Ok(()) } + + async fn relay_archived_message_to_room( + &self, + room_jid: &RoomId, + room_type: &RoomType, + message: ArchivedMessage, + ) -> Result<()> { + let timestamp = message + .forwarded + .delay + .ok_or(StanzaParseError::missing_child_node("delay"))? + .stamp; + + let mut message = *message + .forwarded + .stanza + .ok_or(StanzaParseError::missing_child_node("message"))?; + + let from = message + .from + .take() + .ok_or(StanzaParseError::missing_attribute("from"))?; + + let message = message + .set_to(room_jid.clone().into_inner()) + .set_type(room_type.message_type()) + .set_delay(Delay { + from: Some(from), + stamp: timestamp, + data: None, + }); + + let chat = self.client.get_mod::(); + chat.send_raw_message(message)?; + + Ok(()) + } } trait RoomMessageType { diff --git a/crates/prose-core-client/src/infra/platform_dependencies.rs b/crates/prose-core-client/src/infra/platform_dependencies.rs index 10002240..f9439957 100644 --- a/crates/prose-core-client/src/infra/platform_dependencies.rs +++ b/crates/prose-core-client/src/infra/platform_dependencies.rs @@ -12,6 +12,9 @@ use crate::app::deps::{ AppContext, AppDependencies, DynClientEventDispatcher, DynIDProvider, DynTimeProvider, }; use crate::app::services::RoomInner; +use crate::domain::messaging::services::impls::{ + MessageMigrationDomainService, MessageMigrationDomainServiceDependencies, +}; use crate::domain::rooms::services::impls::{RoomsDomainService, RoomsDomainServiceDependencies}; use crate::domain::rooms::services::RoomFactory; use crate::domain::sidebar::services::impls::{ @@ -97,19 +100,35 @@ impl From for AppDependencies { let messages_repo = Arc::new(CachingMessageRepository::new(d.store.clone())); let sidebar_repo = Arc::new(InMemorySidebarRepository::new()); let time_provider = d.time_provider; + let user_info_repo = Arc::new(CachingUserInfoRepository::new( + d.store.clone(), + d.xmpp.clone(), + )); let user_profile_repo = Arc::new(CachingUserProfileRepository::new( d.store.clone(), d.xmpp.clone(), )); + let message_migration_domain_service_dependencies = + MessageMigrationDomainServiceDependencies { + message_archive_service: d.xmpp.clone(), + messaging_service: d.xmpp.clone(), + }; + + let message_migration_domain_service = Arc::new(MessageMigrationDomainService::from( + message_migration_domain_service_dependencies, + )); + let rooms_domain_service_dependencies = RoomsDomainServiceDependencies { client_event_dispatcher: client_event_dispatcher.clone(), connected_rooms_repo: connected_rooms_repo.clone(), ctx: ctx.clone(), id_provider: d.short_id_provider.clone(), + message_migration_domain_service: message_migration_domain_service.clone(), room_attributes_service: d.xmpp.clone(), room_management_service: d.xmpp.clone(), room_participation_service: d.xmpp.clone(), + user_info_repo: user_info_repo.clone(), user_profile_repo: user_profile_repo.clone(), }; @@ -131,6 +150,7 @@ impl From for AppDependencies { let room_factory = { let client_event_dispatcher = client_event_dispatcher.clone(); + let ctx = ctx.clone(); let xmpp = d.xmpp.clone(); let time_provider = time_provider.clone(); let message_repo = messages_repo.clone(); @@ -141,6 +161,7 @@ impl From for AppDependencies { RoomFactory::new(Arc::new(move |data| { RoomInner { data: data.clone(), + ctx: ctx.clone(), time_provider: time_provider.clone(), messaging_service: xmpp.clone(), message_archive_service: xmpp.clone(), @@ -180,10 +201,7 @@ impl From for AppDependencies { sidebar_repo, time_provider, user_account_service: d.xmpp.clone(), - user_info_repo: Arc::new(CachingUserInfoRepository::new( - d.store.clone(), - d.xmpp.clone(), - )), + user_info_repo, user_info_service: d.xmpp.clone(), user_profile_repo, user_profile_service: d.xmpp.clone(), diff --git a/crates/prose-core-client/src/infra/rooms/in_memory_connected_rooms_repository.rs b/crates/prose-core-client/src/infra/rooms/in_memory_connected_rooms_repository.rs index 55fa4709..5b7e942a 100644 --- a/crates/prose-core-client/src/infra/rooms/in_memory_connected_rooms_repository.rs +++ b/crates/prose-core-client/src/infra/rooms/in_memory_connected_rooms_repository.rs @@ -13,10 +13,10 @@ use crate::domain::rooms::models::RoomInternals; use crate::domain::rooms::repos::{ ConnectedRoomsReadOnlyRepository, ConnectedRoomsRepository, RoomAlreadyExistsError, }; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; pub struct InMemoryConnectedRoomsRepository { - rooms: RwLock>>, + rooms: RwLock>>, } impl InMemoryConnectedRoomsRepository { @@ -28,7 +28,7 @@ impl InMemoryConnectedRoomsRepository { } impl ConnectedRoomsReadOnlyRepository for InMemoryConnectedRoomsRepository { - fn get(&self, room_jid: &RoomJid) -> Option> { + fn get(&self, room_jid: &RoomId) -> Option> { self.rooms.read().get(room_jid).cloned() } @@ -41,17 +41,17 @@ impl ConnectedRoomsRepository for InMemoryConnectedRoomsRepository { fn set(&self, room: Arc) -> Result<(), RoomAlreadyExistsError> { let mut rooms = self.rooms.write(); - if rooms.contains_key(&room.jid) { + if rooms.contains_key(&room.room_id) { return Err(RoomAlreadyExistsError); } - rooms.insert(room.jid.clone(), room); + rooms.insert(room.room_id.clone(), room); Ok(()) } fn update( &self, - room_jid: &RoomJid, + room_jid: &RoomId, block: Box) -> RoomInternals + Send>, ) -> Option> { let mut rooms = self.rooms.write(); @@ -63,7 +63,7 @@ impl ConnectedRoomsRepository for InMemoryConnectedRoomsRepository { Some(modified_room) } - fn delete(&self, room_jid: &RoomJid) { + fn delete(&self, room_jid: &RoomId) { self.rooms.write().remove(room_jid); } diff --git a/crates/prose-core-client/src/infra/rooms/room_management_service.rs b/crates/prose-core-client/src/infra/rooms/room_management_service.rs index 799bd40c..9f1201e3 100644 --- a/crates/prose-core-client/src/infra/rooms/room_management_service.rs +++ b/crates/prose-core-client/src/infra/rooms/room_management_service.rs @@ -6,17 +6,19 @@ use async_trait::async_trait; use jid::{BareJid, FullJid}; use strum::IntoEnumIterator; +use xmpp_parsers::data_forms::DataForm; use xmpp_parsers::muc::user::{Affiliation, Status}; use xmpp_parsers::stanza_error::{DefinedCondition, ErrorType, StanzaError}; -use prose_wasm_utils::PinnedFuture; use prose_xmpp::mods::muc::RoomConfigResponse; use prose_xmpp::{mods, RequestError}; -use crate::domain::rooms::models::{RoomError, RoomSessionInfo, RoomSpec}; +use crate::domain::rooms::models::{ + PublicRoomInfo, RoomAffiliation, RoomConfig, RoomError, RoomSessionInfo, RoomSessionMember, + RoomSpec, +}; use crate::domain::rooms::services::RoomManagementService; -use crate::domain::shared::models::RoomType; -use crate::dtos::{PublicRoomInfo, RoomJid}; +use crate::domain::shared::models::{OccupantId, RoomId, RoomType, UserId}; use crate::infra::xmpp::type_conversions::room_info::RoomInfo; use crate::infra::xmpp::XMPPClient; @@ -42,7 +44,7 @@ impl RoomManagementService for XMPPClient { async fn create_or_join_room( &self, - room_jid: &FullJid, + occupant_id: &OccupantId, room_name: &str, spec: RoomSpec, ) -> Result { @@ -51,7 +53,7 @@ impl RoomManagementService for XMPPClient { // Create the room… let occupancy = muc_mod .create_reserved_room( - &room_jid, + occupant_id.as_ref(), Box::new(|form| { let spec = spec.clone(); let room_name = room_name.to_string(); @@ -60,13 +62,13 @@ impl RoomManagementService for XMPPClient { Ok(RoomConfigResponse::Submit( spec.populate_form(&room_name, &form)?, )) - }) as PinnedFuture<_> + }) }), ) .await?; - let user_nickname = room_jid.resource_str().to_string(); - let room_jid = RoomJid::from(room_jid.to_bare()); + let user_nickname = occupant_id.nickname().to_string(); + let room_jid = occupant_id.room_id(); let room_has_been_created = occupancy.user.status.contains(&Status::RoomHasBeenCreated); let room_info = self.load_room_info(&room_jid).await?; @@ -76,19 +78,21 @@ impl RoomManagementService for XMPPClient { // If the room was created but doesn't match our spec, we'll try to delete it again. if room_has_been_created { // Ignore the error since it would not be indicative of what happened. - _ = muc_mod.destroy_room(&room_jid).await; + _ = muc_mod.destroy_room(&room_jid, None).await; } return Err(RoomError::RoomValidationError(error.to_string())); } - let members = self.load_room_owners(&room_jid).await?; + let members = self.load_room_members(&room_jid).await?; Ok(RoomSessionInfo { - room_jid, - room_name: room_info.name, - room_description: room_info.description, - room_type: spec.room_type(), + room_id: room_jid, + config: RoomConfig { + room_name: room_info.name, + room_description: room_info.description, + room_type: spec.room_type(), + }, user_nickname, members, room_has_been_created, @@ -97,11 +101,11 @@ impl RoomManagementService for XMPPClient { async fn join_room( &self, - room_jid: &FullJid, + occupant_id: &OccupantId, password: Option<&str>, ) -> Result { let muc_mod = self.client.get_mod::(); - let occupancy = muc_mod.enter_room(&room_jid, password).await?; + let occupancy = muc_mod.enter_room(occupant_id.as_ref(), password).await?; // If we accidentally created the room, we'll return an ItemNotFound error since our // actual intention was to join an existing room. @@ -113,13 +117,62 @@ impl RoomManagementService for XMPPClient { defined_condition: DefinedCondition::ItemNotFound, texts: Default::default(), other: None, + new_location: None, }, } .into()); } - let user_nickname = room_jid.resource_str().to_string(); - let room_jid = RoomJid::from(room_jid.to_bare()); + let user_nickname = occupant_id.nickname().to_string(); + let room_jid = occupant_id.room_id(); + let room_config = self.load_room_config(&room_jid).await?; + let members = self.load_room_members(&room_jid).await?; + + Ok(RoomSessionInfo { + room_id: room_jid, + config: room_config, + user_nickname, + members, + room_has_been_created: false, + }) + } + + async fn reconfigure_room( + &self, + room_jid: &RoomId, + spec: RoomSpec, + new_name: &str, + ) -> Result<(), RoomError> { + let muc_mod = self.client.get_mod::(); + + // Reconfigure the room… + muc_mod + .configure_room( + room_jid, + Box::new(|form: DataForm| { + let spec = spec.clone(); + let room_name = new_name.to_string(); + + Box::pin(async move { + Ok(RoomConfigResponse::Submit( + spec.populate_form(&room_name, &form)?, + )) + }) + }), + ) + .await?; + + let room_info = self.load_room_info(&room_jid).await?; + + // Then validate it against our spec… + if let Err(error) = spec.validate_against(&room_info) { + return Err(RoomError::RoomValidationError(error.to_string())); + } + + Ok(()) + } + + async fn load_room_config(&self, room_jid: &RoomId) -> Result { let room_info = self.load_room_info(&room_jid).await?; let room_type = 'room_type: { @@ -131,16 +184,10 @@ impl RoomManagementService for XMPPClient { RoomType::Generic }; - let members = self.load_room_owners(&room_jid).await?; - - Ok(RoomSessionInfo { - room_jid, + Ok(RoomConfig { room_name: room_info.name, room_description: room_info.description, room_type, - user_nickname, - members, - room_has_been_created: false, }) } @@ -152,27 +199,33 @@ impl RoomManagementService for XMPPClient { async fn set_room_owners<'a, 'b, 'c>( &'a self, - room_jid: &'b BareJid, - users: &'c [&BareJid], + room_jid: &'b RoomId, + users: &'c [UserId], ) -> Result<(), RoomError> { let muc_mod = self.client.get_mod::(); let owners = users .iter() - .map(|user_jid| ((*user_jid).clone(), Affiliation::Owner)) + .map(|user_jid| (user_jid.clone().into_inner(), Affiliation::Owner)) .collect::>(); muc_mod.update_user_affiliations(room_jid, owners).await?; Ok(()) } - async fn destroy_room(&self, room_jid: &BareJid) -> Result<(), RoomError> { + async fn destroy_room( + &self, + room_jid: &RoomId, + alternate_room: Option, + ) -> Result<(), RoomError> { let muc_mod = self.client.get_mod::(); - muc_mod.destroy_room(room_jid).await?; + muc_mod + .destroy_room(room_jid, alternate_room.map(|j| j.into_inner()).as_ref()) + .await?; Ok(()) } } impl XMPPClient { - async fn load_room_info(&self, jid: &RoomJid) -> Result { + async fn load_room_info(&self, jid: &RoomId) -> Result { let caps = self.client.get_mod::(); Ok(RoomInfo::try_from( caps.query_disco_info(jid.clone().into_inner(), None) @@ -180,17 +233,30 @@ impl XMPPClient { )?) } - async fn load_room_owners(&self, jid: &RoomJid) -> Result, RoomError> { + async fn load_room_members(&self, jid: &RoomId) -> Result, RoomError> { let muc_mod = self.client.get_mod::(); - // When creating a group we change all "members" to "owners", so at least for Prose groups - // this should work as expected. In case it fails we ignore the error, which can happen - // for channels. - Ok(muc_mod - .request_users(jid, Affiliation::Owner) - .await - .unwrap_or(vec![]) - .into_iter() - .map(|user| user.jid.to_bare()) - .collect::>()) + + let mut members = vec![]; + let affiliations = vec![ + (Affiliation::Owner, RoomAffiliation::Owner), + (Affiliation::Member, RoomAffiliation::Member), + (Affiliation::Admin, RoomAffiliation::Admin), + ]; + + for (xmpp_affiliation, domain_affiliation) in affiliations { + members.extend( + muc_mod + .request_users(jid, xmpp_affiliation) + .await + .unwrap_or(vec![]) + .into_iter() + .map(move |user| RoomSessionMember { + id: UserId::from(user.jid.to_bare()), + affiliation: domain_affiliation.clone(), + }), + ) + } + + Ok(members) } } diff --git a/crates/prose-core-client/src/infra/rooms/room_participation_service.rs b/crates/prose-core-client/src/infra/rooms/room_participation_service.rs index 76aafe5e..728d9cbf 100644 --- a/crates/prose-core-client/src/infra/rooms/room_participation_service.rs +++ b/crates/prose-core-client/src/infra/rooms/room_participation_service.rs @@ -4,14 +4,15 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use async_trait::async_trait; -use jid::{BareJid, Jid}; +use jid::Jid; +use xmpp_parsers::muc::user::Affiliation; -use crate::domain::rooms::models::RoomError; -use crate::domain::rooms::services::RoomParticipationService; -use crate::dtos::RoomJid; use prose_xmpp::mods; use prose_xmpp::stanza::muc::{mediated_invite, MediatedInvite}; +use crate::domain::rooms::models::RoomError; +use crate::domain::rooms::services::RoomParticipationService; +use crate::dtos::{RoomId, UserId}; use crate::infra::xmpp::XMPPClient; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] @@ -19,24 +20,42 @@ use crate::infra::xmpp::XMPPClient; impl RoomParticipationService for XMPPClient { async fn invite_users_to_room( &self, - room_jid: &RoomJid, - participants: &[&BareJid], + room_jid: &RoomId, + participants: &[UserId], ) -> Result<(), RoomError> { let muc_mod = self.client.get_mod::(); - muc_mod - .send_mediated_invite( - room_jid, - MediatedInvite { - invites: participants - .iter() - .map(|participant| mediated_invite::Invite { + + // It seems like the server doesn't send invites to each member if you put them into + // a single mediated invite. So we'll send one for each participant… + for participant in participants { + muc_mod + .send_mediated_invite( + room_jid, + MediatedInvite { + invites: vec![mediated_invite::Invite { from: None, - to: Some(Jid::Bare((*participant).clone())), + to: Some(Jid::Bare(participant.clone().into_inner())), reason: None, - }) - .collect(), - password: None, - }, + }], + password: None, + }, + ) + .await?; + } + + Ok(()) + } + + async fn grant_membership( + &self, + room_jid: &RoomId, + participant: &UserId, + ) -> Result<(), RoomError> { + let muc_mod = self.client.get_mod::(); + muc_mod + .update_user_affiliations( + room_jid, + vec![(participant.clone().into_inner(), Affiliation::Member)], ) .await?; Ok(()) diff --git a/crates/prose-core-client/src/infra/settings/account_settings_repository.rs b/crates/prose-core-client/src/infra/settings/account_settings_repository.rs index 1d794d5c..35bc176c 100644 --- a/crates/prose-core-client/src/infra/settings/account_settings_repository.rs +++ b/crates/prose-core-client/src/infra/settings/account_settings_repository.rs @@ -5,17 +5,17 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_store::prelude::*; use prose_store::Database; use crate::domain::settings::models::AccountSettings; use crate::domain::settings::repos::AccountSettingsRepository as DomainAccountSettingsRepository; +use crate::domain::shared::models::UserId; #[entity] pub struct AccountSettingsRecord { - id: BareJid, + id: UserId, payload: AccountSettings, } @@ -32,27 +32,27 @@ impl AccountSettingsRepository { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl DomainAccountSettingsRepository for AccountSettingsRepository { - async fn get(&self, jid: &BareJid) -> Result { + async fn get(&self, id: &UserId) -> Result { let tx = self .store .transaction_for_reading(&[AccountSettingsRecord::collection()]) .await?; let collection = tx.readable_collection(AccountSettingsRecord::collection())?; - let settings = collection.get::<_, AccountSettingsRecord>(jid).await?; + let settings = collection.get::<_, AccountSettingsRecord>(id).await?; Ok(settings.map(|s| s.payload).unwrap_or_default()) } async fn update( &self, - jid: &BareJid, + id: &UserId, block: Box FnOnce(&'a mut AccountSettings) + Send>, ) -> Result<()> { upsert!( AccountSettingsRecord, store: self.store, - id: jid, + id: id, insert_if_needed: || AccountSettingsRecord { - id: jid.clone(), + id: id.clone(), payload: Default::default() }, update: |settings: &mut AccountSettingsRecord| block(&mut settings.payload) diff --git a/crates/prose-core-client/src/infra/sidebar/bookmarks_service.rs b/crates/prose-core-client/src/infra/sidebar/bookmarks_service.rs index 6152e9de..60943b56 100644 --- a/crates/prose-core-client/src/infra/sidebar/bookmarks_service.rs +++ b/crates/prose-core-client/src/infra/sidebar/bookmarks_service.rs @@ -5,7 +5,6 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use minidom::Element; use xmpp_parsers::data_forms::{Field, FieldType}; use xmpp_parsers::pubsub::pubsub::PublishOptions; @@ -15,6 +14,7 @@ use prose_xmpp::{mods, PublishOptionsExt, RequestError}; use crate::domain::sidebar::models::Bookmark; use crate::domain::sidebar::services::BookmarksService; +use crate::dtos::RoomId; use crate::infra::xmpp::type_conversions::bookmark::ns; use crate::infra::xmpp::XMPPClient; @@ -60,7 +60,7 @@ impl BookmarksService for XMPPClient { Ok(()) } - async fn delete_bookmark(&self, jid: &BareJid) -> Result<()> { + async fn delete_bookmark(&self, jid: &RoomId) -> Result<()> { let pubsub = self.client.get_mod::(); pubsub .delete_items_with_ids(ns::PROSE_BOOKMARK, [jid.to_string()], true) diff --git a/crates/prose-core-client/src/infra/sidebar/in_memory_sidebar_repository.rs b/crates/prose-core-client/src/infra/sidebar/in_memory_sidebar_repository.rs index e1f9fde3..bc9a60ca 100644 --- a/crates/prose-core-client/src/infra/sidebar/in_memory_sidebar_repository.rs +++ b/crates/prose-core-client/src/infra/sidebar/in_memory_sidebar_repository.rs @@ -7,12 +7,12 @@ use std::collections::HashMap; use parking_lot::RwLock; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use crate::domain::sidebar::models::SidebarItem; use crate::domain::sidebar::repos::{SidebarReadOnlyRepository, SidebarRepository}; pub struct InMemorySidebarRepository { - sidebar_items: RwLock>, + sidebar_items: RwLock>, } impl InMemorySidebarRepository { @@ -24,7 +24,7 @@ impl InMemorySidebarRepository { } impl SidebarReadOnlyRepository for InMemorySidebarRepository { - fn get(&self, jid: &RoomJid) -> Option { + fn get(&self, jid: &RoomId) -> Option { self.sidebar_items.read().get(jid).cloned() } @@ -47,7 +47,7 @@ impl SidebarRepository for InMemorySidebarRepository { .insert(item.jid.clone(), item.clone()); } - fn delete(&self, item: &RoomJid) { + fn delete(&self, item: &RoomId) { self.sidebar_items.write().remove(item); } diff --git a/crates/prose-core-client/src/infra/user_info/caching_avatar_repository.rs b/crates/prose-core-client/src/infra/user_info/caching_avatar_repository.rs index 900dbd31..89aee713 100644 --- a/crates/prose-core-client/src/infra/user_info/caching_avatar_repository.rs +++ b/crates/prose-core-client/src/infra/user_info/caching_avatar_repository.rs @@ -5,11 +5,11 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_xmpp::mods::AvatarData; use crate::app::deps::DynUserInfoService; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::{AvatarInfo, PlatformImage}; use crate::domain::user_info::repos::AvatarRepository as DomainAvatarRepository; use crate::infra::avatars::AvatarCache; @@ -33,7 +33,7 @@ impl CachingAvatarRepository { impl DomainAvatarRepository for CachingAvatarRepository { async fn precache_avatar_image( &self, - user_jid: &BareJid, + user_jid: &UserId, info: &AvatarInfo, ) -> anyhow::Result<()> { if self @@ -60,7 +60,7 @@ impl DomainAvatarRepository for CachingAvatarRepository { async fn get( &self, - user_id: &BareJid, + user_id: &UserId, info: &AvatarInfo, ) -> anyhow::Result> { self.precache_avatar_image(user_id, info).await?; @@ -73,7 +73,7 @@ impl DomainAvatarRepository for CachingAvatarRepository { async fn set( &self, - user_jid: &BareJid, + user_jid: &UserId, info: &AvatarInfo, image: &AvatarData, ) -> anyhow::Result<()> { diff --git a/crates/prose-core-client/src/infra/user_info/caching_user_info_repository.rs b/crates/prose-core-client/src/infra/user_info/caching_user_info_repository.rs index 5367434e..196f3870 100644 --- a/crates/prose-core-client/src/infra/user_info/caching_user_info_repository.rs +++ b/crates/prose-core-client/src/infra/user_info/caching_user_info_repository.rs @@ -5,20 +5,20 @@ use anyhow::Result; use async_trait::async_trait; -use jid::{BareJid, Jid}; use parking_lot::RwLock; use prose_store::prelude::*; use crate::app::deps::DynUserInfoService; -use crate::domain::user_info::models::{AvatarMetadata, Presence, UserActivity, UserInfo}; +use crate::domain::shared::models::{UserId, UserOrResourceId, UserResourceId}; +use crate::domain::user_info::models::{AvatarMetadata, Presence, UserInfo, UserStatus}; use crate::domain::user_info::repos::UserInfoRepository; use super::PresenceMap; #[entity] pub struct UserInfoRecord { - id: BareJid, + id: UserId, payload: UserInfo, } @@ -41,20 +41,19 @@ impl CachingUserInfoRepository { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl UserInfoRepository for CachingUserInfoRepository { - fn resolve_bare_jid_to_full(&self, jid: &BareJid) -> Jid { + fn resolve_user_id_to_user_resource_id(&self, jid: &UserId) -> Option { let presences = self.presences.read(); let Some(resource) = presences .get_highest_presence(jid) .and_then(|entry| entry.resource.as_deref()) else { - return Jid::Bare(jid.clone()); + return None; }; - jid.with_resource_str(resource) - .map(Jid::Full) - .unwrap_or(Jid::Bare(jid.clone())) + + Some(jid.with_resource(resource).expect("Invalid resource")) } - async fn get_user_info(&self, jid: &BareJid) -> Result> { + async fn get_user_info(&self, jid: &UserId) -> Result> { let tx = self .store .transaction_for_reading(&[UserInfoRecord::collection()]) @@ -98,7 +97,7 @@ impl UserInfoRepository for CachingUserInfoRepository { Ok(Some(record.payload)) } - async fn set_avatar_metadata(&self, jid: &BareJid, metadata: &AvatarMetadata) -> Result<()> { + async fn set_avatar_metadata(&self, jid: &UserId, metadata: &AvatarMetadata) -> Result<()> { upsert!( UserInfoRecord, store: self.store, @@ -114,8 +113,8 @@ impl UserInfoRepository for CachingUserInfoRepository { async fn set_user_activity( &self, - jid: &BareJid, - user_activity: Option<&UserActivity>, + jid: &UserId, + user_activity: Option<&UserStatus>, ) -> Result<()> { upsert!( UserInfoRecord, @@ -130,7 +129,7 @@ impl UserInfoRepository for CachingUserInfoRepository { Ok(()) } - async fn set_user_presence(&self, jid: &Jid, presence: &Presence) -> Result<()> { + async fn set_user_presence(&self, jid: &UserOrResourceId, presence: &Presence) -> Result<()> { let mut map = self.presences.write(); map.update_presence(jid, presence.clone().into()); Ok(()) diff --git a/crates/prose-core-client/src/infra/user_info/presence_map.rs b/crates/prose-core-client/src/infra/user_info/presence_map.rs index dff21553..51c77475 100644 --- a/crates/prose-core-client/src/infra/user_info/presence_map.rs +++ b/crates/prose-core-client/src/infra/user_info/presence_map.rs @@ -5,14 +5,12 @@ use std::collections::HashMap; -use jid::{BareJid, Jid}; - -use crate::domain::shared::models::Availability; +use crate::domain::shared::models::{Availability, UserId, UserOrResourceId}; use crate::domain::user_info::models::Presence; #[derive(Default)] pub struct PresenceMap { - map: HashMap>, + map: HashMap>, } impl PresenceMap { @@ -21,7 +19,7 @@ impl PresenceMap { PresenceMap::default() } - pub fn update_presence(&mut self, from: &Jid, presence: Presence) { + pub fn update_presence(&mut self, from: &UserOrResourceId, presence: Presence) { if presence.availability == Availability::Unavailable { self.remove_presence(from) } else { @@ -29,7 +27,7 @@ impl PresenceMap { } } - pub fn get_highest_presence(&self, jid: &BareJid) -> Option<&PresenceEntry> { + pub fn get_highest_presence(&self, jid: &UserId) -> Option<&PresenceEntry> { self.map .get(jid) .and_then(|entries| entries.first()) @@ -42,22 +40,22 @@ impl PresenceMap { } impl PresenceMap { - fn remove_presence(&mut self, from: &Jid) { - match from { - Jid::Bare(jid) => { - self.map.remove(jid); + fn remove_presence(&mut self, id: &UserOrResourceId) { + match id { + UserOrResourceId::User(id) => { + self.map.remove(id); } - Jid::Full(jid) => { - if let Some(entries) = self.map.get_mut(&jid.to_bare()) { - entries.retain(|p| p.resource.as_deref() != Some(jid.resource_str())) + UserOrResourceId::UserResource(id) => { + if let Some(entries) = self.map.get_mut(&id.to_user_id()) { + entries.retain(|p| p.resource.as_deref() != Some(id.resource())) } } } } - fn insert_presence(&mut self, from: &Jid, presence: Presence) { - let entries = self.map.entry(from.to_bare()).or_default(); - let resource = from.resource_str(); + fn insert_presence(&mut self, id: &UserOrResourceId, presence: Presence) { + let entries = self.map.entry(id.to_user_id()).or_default(); + let resource = id.resource_str(); entries.retain(|entry| entry.resource.as_deref() != resource && entry.resource.is_some()); let idx = entries .iter() @@ -81,23 +79,24 @@ pub struct PresenceEntry { #[cfg(test)] mod tests { - use prose_xmpp::jid; + use crate::domain::shared::models::UserResourceId; + use crate::{user_id, user_resource_id}; use super::*; #[test] fn test_update_with_eq_priority() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org/r1"), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r1").into(), p(1)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r1".to_string()) ); - map.update_presence(&jid!("a@prose.org/r2"), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r2").into(), p(1)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r2".to_string()) @@ -106,17 +105,17 @@ mod tests { #[test] fn test_update_with_lower_priority() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org/r1"), p(2)); + map.update_presence(&user_resource_id!("a@prose.org/r1").into(), p(2)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r1".to_string()) ); - map.update_presence(&jid!("a@prose.org/r2"), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r2").into(), p(1)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r1".to_string()) @@ -125,17 +124,17 @@ mod tests { #[test] fn test_update_with_higher_priority() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org/r1"), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r1").into(), p(1)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r1".to_string()) ); - map.update_presence(&jid!("a@prose.org/r2"), p(2)); + map.update_presence(&user_resource_id!("a@prose.org/r2").into(), p(2)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r2".to_string()) @@ -144,42 +143,48 @@ mod tests { #[test] fn test_update_with_unavailable() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org/r1"), p(1)); - map.update_presence(&jid!("a@prose.org/r2"), p(2)); + map.update_presence(&user_resource_id!("a@prose.org/r1").into(), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r2").into(), p(2)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r2".to_string()) ); - map.update_presence(&jid!("a@prose.org/r2"), Presence::default()); + map.update_presence( + &user_resource_id!("a@prose.org/r2").into(), + Presence::default(), + ); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r1".to_string()) ); - map.update_presence(&jid!("a@prose.org/r1"), Presence::default()); + map.update_presence( + &user_resource_id!("a@prose.org/r1").into(), + Presence::default(), + ); assert_eq!(map.get_highest_presence(&user), None); } #[test] fn test_update_with_bare_jid() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org"), p(1)); + map.update_presence(&user_id!("a@prose.org").into(), p(1)); assert_eq!(map.get_highest_presence(&user).unwrap().resource, None); assert_eq!( map.get_highest_presence(&user).unwrap().presence.priority, 1 ); - map.update_presence(&jid!("a@prose.org"), p(2)); + map.update_presence(&user_id!("a@prose.org").into(), p(2)); assert_eq!( map.get_highest_presence(&user).unwrap().presence.priority, 2 @@ -188,48 +193,51 @@ mod tests { #[test] fn test_full_jid_replaces_bare_jid() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org"), p(1)); - map.update_presence(&jid!("a@prose.org/r1"), p(2)); + map.update_presence(&user_id!("a@prose.org").into(), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r1").into(), p(2)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r1".to_string()) ); - map.update_presence(&jid!("a@prose.org/r1"), Presence::default()); + map.update_presence( + &user_resource_id!("a@prose.org/r1").into(), + Presence::default(), + ); assert_eq!(map.get_highest_presence(&user), None); } #[test] fn test_update_with_unavailable_bare_jid() { - let user = jid!("a@prose.org").into_bare(); + let user = user_id!("a@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org/r1"), p(1)); - map.update_presence(&jid!("a@prose.org/r2"), p(2)); + map.update_presence(&user_resource_id!("a@prose.org/r1").into(), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/r2").into(), p(2)); assert_eq!( map.get_highest_presence(&user).unwrap().resource, Some("r2".to_string()) ); - map.update_presence(&jid!("a@prose.org"), p(2)); + map.update_presence(&user_id!("a@prose.org").into(), p(2)); assert_eq!(map.get_highest_presence(&user).unwrap().resource, None); } #[test] fn test_multiple_users() { - let user1 = jid!("a@prose.org").into_bare(); - let user2 = jid!("b@prose.org").into_bare(); + let user1 = user_id!("a@prose.org"); + let user2 = user_id!("b@prose.org"); let mut map = PresenceMap::new(); - map.update_presence(&jid!("a@prose.org/ra1"), p(1)); - map.update_presence(&jid!("b@prose.org/ra2"), p(1)); + map.update_presence(&user_resource_id!("a@prose.org/ra1").into(), p(1)); + map.update_presence(&user_resource_id!("b@prose.org/ra2").into(), p(1)); assert_eq!( map.get_highest_presence(&user1).unwrap().resource, diff --git a/crates/prose-core-client/src/infra/user_info/user_info_service.rs b/crates/prose-core-client/src/infra/user_info/user_info_service.rs index 90b17c0b..ecab2789 100644 --- a/crates/prose-core-client/src/infra/user_info/user_info_service.rs +++ b/crates/prose-core-client/src/infra/user_info/user_info_service.rs @@ -7,7 +7,7 @@ use std::str::FromStr; use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; +use jid::Jid; use tracing::warn; use xmpp_parsers::hashes::Sha1HexAttribute; @@ -15,6 +15,7 @@ use prose_xmpp::mods; use prose_xmpp::mods::AvatarData; use prose_xmpp::stanza::avatar; +use crate::domain::shared::models::UserId; use crate::domain::user_info::models::{AvatarImageId, AvatarMetadata}; use crate::domain::user_info::services::UserInfoService; use crate::infra::xmpp::XMPPClient; @@ -22,10 +23,13 @@ use crate::infra::xmpp::XMPPClient; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl UserInfoService for XMPPClient { - async fn load_latest_avatar_metadata(&self, from: &BareJid) -> Result> { + async fn load_latest_avatar_metadata(&self, from: &UserId) -> Result> { let profile = self.client.get_mod::(); - match profile.load_latest_avatar_metadata(from.clone()).await { + match profile + .load_latest_avatar_metadata(Jid::Bare(from.clone().into_inner())) + .await + { Ok(metadata) => Ok(metadata.map(Into::into)), Err(err) if err.is_forbidden_err() => { warn!( @@ -40,14 +44,14 @@ impl UserInfoService for XMPPClient { async fn load_avatar_image( &self, - from: &BareJid, + from: &UserId, image_id: &AvatarImageId, ) -> Result> { let profile = self.client.get_mod::(); match profile .load_avatar_image( - from.clone(), + Jid::Bare(from.clone().into_inner()), &Sha1HexAttribute::from_str(&image_id.as_ref())?, ) .await diff --git a/crates/prose-core-client/src/infra/user_profile/caching_user_profile_repository.rs b/crates/prose-core-client/src/infra/user_profile/caching_user_profile_repository.rs index 34e1c5ef..fccb16d8 100644 --- a/crates/prose-core-client/src/infra/user_profile/caching_user_profile_repository.rs +++ b/crates/prose-core-client/src/infra/user_profile/caching_user_profile_repository.rs @@ -5,17 +5,17 @@ use anyhow::Result; use async_trait::async_trait; -use jid::BareJid; use prose_store::prelude::*; use crate::app::deps::DynUserProfileService; +use crate::domain::shared::models::UserId; use crate::domain::user_profiles::models::UserProfile; use crate::domain::user_profiles::repos::UserProfileRepository; #[entity] pub struct UserProfileRecord { - id: BareJid, + id: UserId, payload: UserProfile, } @@ -36,7 +36,7 @@ impl CachingUserProfileRepository { #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl UserProfileRepository for CachingUserProfileRepository { - async fn get(&self, jid: &BareJid) -> Result> { + async fn get(&self, jid: &UserId) -> Result> { let tx = self .store .transaction_for_reading(&[UserProfileRecord::collection()]) @@ -56,7 +56,7 @@ impl UserProfileRepository for CachingUserProfileRepository { Ok(Some(profile)) } - async fn set(&self, jid: &BareJid, profile: &UserProfile) -> Result<()> { + async fn set(&self, jid: &UserId, profile: &UserProfile) -> Result<()> { let tx = self .store .transaction_for_reading_and_writing(&[UserProfileRecord::collection()]) @@ -70,7 +70,7 @@ impl UserProfileRepository for CachingUserProfileRepository { Ok(()) } - async fn delete(&self, jid: &BareJid) -> Result<()> { + async fn delete(&self, jid: &UserId) -> Result<()> { let tx = self .store .transaction_for_reading_and_writing(&[UserProfileRecord::collection()]) @@ -81,7 +81,7 @@ impl UserProfileRepository for CachingUserProfileRepository { Ok(()) } - async fn get_display_name(&self, jid: &BareJid) -> Result> { + async fn get_display_name(&self, jid: &UserId) -> Result> { // This is a bit heavy-handed right now to load the full contact. prose-store should // support multi-column indexes instead so that we can just pull out the required fields. Ok(self diff --git a/crates/prose-core-client/src/infra/user_profile/user_profile_service.rs b/crates/prose-core-client/src/infra/user_profile/user_profile_service.rs index 9545eca0..f9d7e6a8 100644 --- a/crates/prose-core-client/src/infra/user_profile/user_profile_service.rs +++ b/crates/prose-core-client/src/infra/user_profile/user_profile_service.rs @@ -6,14 +6,15 @@ use anyhow::Result; use async_trait::async_trait; use chrono::{DateTime, Duration, Utc}; -use jid::{BareJid, FullJid}; +use jid::Jid; use tracing::warn; use url::Url; -use crate::domain::user_info::models::{LastActivity, UserMetadata}; use prose_xmpp::mods; use prose_xmpp::stanza::{vcard, VCard4}; +use crate::domain::shared::models::{UserId, UserResourceId}; +use crate::domain::user_info::models::{LastActivity, UserMetadata}; use crate::domain::user_profiles::{ models::{Address, UserProfile}, services::UserProfileService, @@ -23,7 +24,7 @@ use crate::infra::xmpp::XMPPClient; #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl UserProfileService for XMPPClient { - async fn load_profile(&self, from: &BareJid) -> Result> { + async fn load_profile(&self, from: &UserId) -> Result> { let profile = self.client.get_mod::(); match profile.load_vcard(from.clone()).await { @@ -38,13 +39,17 @@ impl UserProfileService for XMPPClient { async fn load_user_metadata( &self, - from: &FullJid, + from: &UserResourceId, now: DateTime, ) -> Result> { let profile = self.client.get_mod::(); - let entity_time = profile.load_entity_time(from.clone()).await?; - let last_activity = profile.load_last_activity(from.clone()).await?; + let entity_time = profile + .load_entity_time(Jid::Full(from.clone().into_inner())) + .await?; + let last_activity = profile + .load_last_activity(Jid::Full(from.clone().into_inner())) + .await?; let metadata = UserMetadata { local_time: Some(entity_time), diff --git a/crates/prose-core-client/src/infra/xmpp/event_parser/message.rs b/crates/prose-core-client/src/infra/xmpp/event_parser/message.rs new file mode 100644 index 00000000..31c64ff0 --- /dev/null +++ b/crates/prose-core-client/src/infra/xmpp/event_parser/message.rs @@ -0,0 +1,114 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; +use jid::Jid; +use tracing::info; +use xmpp_parsers::message::MessageType; +use xmpp_parsers::muc::user::Status; + +use prose_xmpp::ns; +use prose_xmpp::stanza::muc::MucUser; +use prose_xmpp::stanza::Message; + +use crate::app::event_handlers::{MessageEvent, MessageEventType, RoomEvent, RoomEventType}; +use crate::dtos::RoomId; +use crate::infra::xmpp::event_parser::{ignore_stanza, missing_attribute, Context}; + +pub fn parse_message(ctx: &mut Context, message: Message) -> Result<()> { + let Some(from) = message.from.clone() else { + return missing_attribute(ctx, "from", message); + }; + + // Ignore messages that contain a chat state but no body… + // TODO: Handle this in the XMPP lib + if message.chat_state().is_some() && message.body().is_none() { + return Ok(()); + } + + match message.type_ { + MessageType::Groupchat => parse_group_chat_message(ctx, from, message)?, + MessageType::Chat => parse_chat_message(ctx, from, message)?, + MessageType::Normal => parse_normal_message(ctx, from, message)?, + MessageType::Headline | MessageType::Error => ignore_stanza(ctx, message)?, + }; + Ok(()) +} + +fn parse_group_chat_message(ctx: &mut Context, from: Jid, message: Message) -> Result<()> { + let from = RoomId::from(from.to_bare()); + + if let Some(elem) = &message.payloads.iter().find(|p| p.is("x", ns::MUC_USER)) { + let muc_user = MucUser::try_from((*elem).clone())?; + if muc_user + .status + .iter() + .find(|s| match *s { + Status::ConfigNonPrivacyRelated + | Status::ConfigShowsUnavailableMembers + | Status::ConfigHidesUnavailableMembers + | Status::ConfigMembersOnly + | Status::ConfigRoomLoggingDisabled + | Status::ConfigRoomLoggingEnabled + | Status::ConfigRoomNonAnonymous + | Status::ConfigRoomSemiAnonymous => true, + _ => false, + }) + .is_some() + { + ctx.push_event(RoomEvent { + room_id: from.clone(), + r#type: RoomEventType::RoomConfigChanged, + }); + return Ok(()); + } + } + + if let Some(subject) = message.subject() { + ctx.push_event(RoomEvent { + room_id: from, + r#type: RoomEventType::RoomTopicChanged { + new_topic: (!subject.is_empty()).then_some(subject.to_string()), + }, + }); + return Ok(()); + } + + ctx.push_event(MessageEvent { + r#type: MessageEventType::Received(message), + }); + + Ok(()) +} + +fn parse_chat_message(ctx: &mut Context, _from: Jid, message: Message) -> Result<()> { + ctx.push_event(MessageEvent { + r#type: MessageEventType::Received(message), + }); + Ok(()) +} + +fn parse_normal_message(ctx: &mut Context, from: Jid, message: Message) -> Result<()> { + // Ignore messages that contain invites… + // TODO: Handle this in the XMPP lib + if message.direct_invite().is_some() || message.mediated_invite().is_some() { + return Ok(()); + } + + let Some(muc_invite) = message.muc_invite() else { + info!("Received unknown 'normal' message."); + return Ok(()); + }; + + ctx.push_event(RoomEvent { + room_id: RoomId::from(from.into_bare()), + r#type: RoomEventType::UserAdded { + user_id: muc_invite.jid.into(), + affiliation: muc_invite.affiliation.into(), + reason: muc_invite.reason, + }, + }); + Ok(()) +} diff --git a/crates/prose-core-client/src/infra/xmpp/event_parser/mod.rs b/crates/prose-core-client/src/infra/xmpp/event_parser/mod.rs new file mode 100644 index 00000000..f19b5d8a --- /dev/null +++ b/crates/prose-core-client/src/infra/xmpp/event_parser/mod.rs @@ -0,0 +1,346 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::{bail, Result}; +use jid::Jid; +use minidom::Element; +use tracing::info; +use xmpp_parsers::message::MessageType; + +use message::parse_message; +use prose_xmpp::{ + client::Event as XMPPClientEvent, mods::caps::Event as XMPPCapsEvent, + mods::chat::Event as XMPPChatEvent, mods::muc::Event as XMPPMUCEvent, + mods::ping::Event as XMPPPingEvent, mods::profile::Event as XMPPProfileEvent, + mods::roster::Event as XMPPRosterEvent, mods::status::Event as XMPPStatusEvent, Event, +}; + +use crate::app::event_handlers::{ + ConnectionEvent, MessageEvent, MessageEventType, OccupantEvent, RequestEvent, RequestEventType, + RoomEvent, RoomEventType, ServerEvent, UserInfoEvent, UserInfoEventType, UserResourceEvent, + UserResourceEventType, UserStatusEvent, UserStatusEventType, +}; +use crate::app::event_handlers::{SidebarBookmarkEvent, XMPPEvent}; +use crate::domain::rooms::models::ComposeState; +use crate::domain::shared::models::{CapabilitiesId, RequestId, SenderId, UserEndpointId}; +use crate::dtos::{RoomId, UserId, UserResourceId}; +use crate::infra::xmpp::event_parser::presence::parse_presence; +use crate::infra::xmpp::event_parser::pubsub::parse_pubsub_event; + +mod message; +mod presence; +mod pubsub; + +pub fn parse_xmpp_event(event: XMPPEvent) -> Result> { + let mut ctx = Context::default(); + + match event { + Event::Bookmark(_) => { + // TODO: Handle changed bookmarks? + } + Event::Bookmark2(_) => { + // TODO: Handle changed bookmarks? + } + Event::Caps(event) => parse_caps_event(&mut ctx, event)?, + Event::Chat(event) => parse_chat_event(&mut ctx, event)?, + Event::Client(event) => parse_client_event(&mut ctx, event)?, + Event::MUC(event) => parse_muc_event(&mut ctx, event)?, + Event::Ping(event) => parse_ping_event(&mut ctx, event)?, + Event::Profile(event) => parse_profile_event(&mut ctx, event)?, + Event::PubSub(event) => parse_pubsub_event(&mut ctx, event)?, + Event::Roster(event) => parse_roster_event(&mut ctx, event)?, + Event::Status(event) => parse_status_event(&mut ctx, event)?, + }; + + Ok(ctx.events) +} + +#[derive(Debug, Default)] +struct Context { + events: Vec, +} + +impl Context { + pub fn push_event(&mut self, event: impl Into) { + self.events.push(event.into()) + } +} + +fn parse_chat_event(ctx: &mut Context, event: XMPPChatEvent) -> Result<()> { + match event { + XMPPChatEvent::Message(message) => parse_message(ctx, message)?, + XMPPChatEvent::Carbon(carbon) => ctx.push_event(ServerEvent::Message(MessageEvent { + r#type: MessageEventType::Sync(carbon), + })), + XMPPChatEvent::Sent(message) => ctx.push_event(ServerEvent::Message(MessageEvent { + r#type: MessageEventType::Sent(message), + })), + XMPPChatEvent::ChatStateChanged { + from, + chat_state, + message_type, + } => { + let Jid::Full(from) = from else { + bail!("Expected FullJid in ChatState") + }; + + let user_id = match message_type { + MessageType::Groupchat => UserEndpointId::Occupant(from.into()), + _ => UserEndpointId::UserResource(from.into()), + }; + + ctx.push_event(UserStatusEvent { + user_id, + r#type: UserStatusEventType::ComposeStateChanged { + state: ComposeState::from(chat_state.clone()), + }, + }) + } + }; + Ok(()) +} + +fn parse_status_event(ctx: &mut Context, event: XMPPStatusEvent) -> Result<()> { + match event { + XMPPStatusEvent::Presence(presence) => parse_presence(ctx, presence)?, + XMPPStatusEvent::UserActivity { + from, + user_activity, + } => ctx.push_event(UserInfoEvent { + user_id: UserId::from(from.into_bare()), + r#type: UserInfoEventType::StatusChanged { + status: user_activity.map(TryInto::try_into).transpose()?, + }, + }), + }; + Ok(()) +} + +fn parse_muc_event(ctx: &mut Context, event: XMPPMUCEvent) -> Result<()> { + match event { + XMPPMUCEvent::DirectInvite { from, invite } => { + let Jid::Full(from) = from else { + bail!("Expected FullJid in direct invite") + }; + + ctx.push_event(RoomEvent { + room_id: RoomId::from(invite.jid), + r#type: RoomEventType::ReceivedInvitation { + sender: UserResourceId::from(from), + password: invite.password, + }, + }) + } + XMPPMUCEvent::MediatedInvite { from, invite } => { + let Jid::Bare(from) = from else { + bail!("Expected BareJid for room in mediated invite") + }; + + let Some(embedded_invite) = invite.invites.first() else { + bail!("Expected MediatedInvite to contain at least one embedded invite.") + }; + + let Some(Jid::Full(sender_jid)) = &embedded_invite.from else { + bail!("Expected FullJid in embedded invite of MediatedInvite.") + }; + + ctx.push_event(RoomEvent { + room_id: RoomId::from(from), + r#type: RoomEventType::ReceivedInvitation { + sender: UserResourceId::from(sender_jid.clone()), + password: invite.password, + }, + }) + } + } + + Ok(()) +} + +fn parse_caps_event(ctx: &mut Context, event: XMPPCapsEvent) -> Result<()> { + match event { + XMPPCapsEvent::DiscoInfoQuery { from, id, node } => { + let Some(node) = node else { + bail!("Missing node in disco info query") + }; + + ctx.push_event(RequestEvent { + sender_id: SenderId::from(from), + request_id: RequestId::from(id), + r#type: RequestEventType::Capabilities { + id: CapabilitiesId::from(node), + }, + }) + } + XMPPCapsEvent::Caps { from, caps } => { + let Jid::Full(from) = from else { + bail!("Expected FullJid in caps element") + }; + + ctx.push_event(UserResourceEvent { + user_id: UserResourceId::from(from), + r#type: UserResourceEventType::CapabilitiesChanged { + id: CapabilitiesId::from(format!("{}#{}", caps.node, caps.hash.to_base64())), + }, + }) + } + } + + Ok(()) +} + +fn parse_profile_event(ctx: &mut Context, event: XMPPProfileEvent) -> Result<()> { + match event { + XMPPProfileEvent::Vcard { from, vcard } => ctx.push_event(UserInfoEvent { + user_id: UserId::from(from.into_bare()), + r#type: UserInfoEventType::ProfileChanged { + profile: vcard.try_into()?, + }, + }), + XMPPProfileEvent::AvatarMetadata { from, metadata } => { + let Some(info) = metadata.infos.first() else { + return missing_element(ctx, "info", metadata); + }; + + ctx.push_event(UserInfoEvent { + user_id: UserId::from(from.into_bare()), + r#type: UserInfoEventType::AvatarChanged { + metadata: info.clone().into(), + }, + }) + } + XMPPProfileEvent::EntityTimeQuery { from, id } => ctx.push_event(RequestEvent { + sender_id: SenderId::from(from), + request_id: RequestId::from(id), + r#type: RequestEventType::LocalTime, + }), + XMPPProfileEvent::SoftwareVersionQuery { from, id } => ctx.push_event(RequestEvent { + sender_id: SenderId::from(from), + request_id: RequestId::from(id), + r#type: RequestEventType::SoftwareVersion, + }), + XMPPProfileEvent::LastActivityQuery { from, id } => ctx.push_event(RequestEvent { + sender_id: SenderId::from(from), + request_id: RequestId::from(id), + r#type: RequestEventType::LastActivity, + }), + } + + Ok(()) +} + +fn parse_ping_event(ctx: &mut Context, event: XMPPPingEvent) -> Result<()> { + match event { + XMPPPingEvent::Ping { from, id } => ctx.push_event(RequestEvent { + sender_id: SenderId::from(from), + request_id: RequestId::from(id), + r#type: RequestEventType::Ping, + }), + } + + Ok(()) +} + +fn parse_client_event(ctx: &mut Context, event: XMPPClientEvent) -> Result<()> { + match event { + XMPPClientEvent::Connected => ctx.push_event(ConnectionEvent::Connected), + XMPPClientEvent::Disconnected { error } => { + ctx.push_event(ConnectionEvent::Disconnected { error }) + } + } + + Ok(()) +} + +fn parse_roster_event(ctx: &mut Context, event: XMPPRosterEvent) -> Result<()> { + match event { + XMPPRosterEvent::PresenceSubscriptionRequest { from } => ctx.push_event(RequestEvent { + sender_id: SenderId::from(Jid::Bare(from)), + request_id: RequestId::from("".to_string()), + r#type: RequestEventType::PresenceSubscription, + }), + } + + Ok(()) +} + +fn ignore_stanza(_ctx: &mut Context, stanza: impl Into) -> Result<()> { + info!("Ignoring stanza {}", String::from(&stanza.into())); + Ok(()) +} + +fn missing_attribute( + _ctx: &mut Context, + attribute: &str, + stanza: impl Into, +) -> Result<()> { + let element = stanza.into(); + Err(anyhow::format_err!( + "Missing attribute `{}` in {}. {}", + attribute, + element.name(), + String::from(&element) + )) +} + +fn missing_element( + _ctx: &mut Context, + element_name: &str, + stanza: impl Into, +) -> Result<()> { + let element = stanza.into(); + Err(anyhow::format_err!( + "Missing element `{}` in {}. {}", + element_name, + element.name(), + String::from(&element) + )) +} + +impl From for ServerEvent { + fn from(value: ConnectionEvent) -> Self { + Self::Connection(value) + } +} +impl From for ServerEvent { + fn from(value: UserStatusEvent) -> Self { + Self::UserStatus(value) + } +} +impl From for ServerEvent { + fn from(value: UserInfoEvent) -> Self { + Self::UserInfo(value) + } +} +impl From for ServerEvent { + fn from(value: UserResourceEvent) -> Self { + Self::UserResource(value) + } +} +impl From for ServerEvent { + fn from(value: RoomEvent) -> Self { + Self::Room(value) + } +} +impl From for ServerEvent { + fn from(value: OccupantEvent) -> Self { + Self::Occupant(value) + } +} +impl From for ServerEvent { + fn from(value: RequestEvent) -> Self { + Self::Request(value) + } +} +impl From for ServerEvent { + fn from(value: MessageEvent) -> Self { + Self::Message(value) + } +} +impl From for ServerEvent { + fn from(value: SidebarBookmarkEvent) -> Self { + Self::SidebarBookmark(value) + } +} diff --git a/crates/prose-core-client/src/infra/xmpp/event_parser/presence.rs b/crates/prose-core-client/src/infra/xmpp/event_parser/presence.rs new file mode 100644 index 00000000..bf59114d --- /dev/null +++ b/crates/prose-core-client/src/infra/xmpp/event_parser/presence.rs @@ -0,0 +1,164 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::{bail, Result}; +use jid::Jid; +use xmpp_parsers::muc::user::Status; +use xmpp_parsers::presence; +use xmpp_parsers::presence::Presence; + +use prose_xmpp::ns; +use prose_xmpp::stanza::muc::MucUser; + +use crate::app::event_handlers::{ + OccupantEvent, OccupantEventType, RoomEvent, RoomEventType, UserStatusEvent, + UserStatusEventType, +}; +use crate::domain::shared::models::{AnonOccupantId, OccupantId, UserEndpointId}; +use crate::dtos::{Availability, RoomId, UserId, UserResourceId}; +use crate::infra::xmpp::event_parser::{missing_attribute, missing_element, Context}; + +pub fn parse_presence(ctx: &mut Context, presence: Presence) -> Result<()> { + let Some(from) = presence.from.clone() else { + return missing_attribute(ctx, "from", presence); + }; + + let availability = Availability::from(( + (presence.type_ != presence::Type::None).then_some(presence.type_.clone()), + presence.show.clone(), + )); + + if let Some(muc_user) = presence + .payloads + .iter() + .find(|p| p.is("x", ns::MUC_USER)) + .cloned() + { + return parse_muc_presence(ctx, from, availability, presence, muc_user.try_into()?); + } + + let user_id = match from { + Jid::Bare(jid) => UserId::from(jid).into(), + Jid::Full(jid) => UserResourceId::from(jid).into(), + }; + + ctx.push_event(UserStatusEvent { + user_id, + r#type: UserStatusEventType::AvailabilityChanged { + availability, + priority: presence.priority, + }, + }); + + Ok(()) +} + +fn parse_muc_presence( + ctx: &mut Context, + from: Jid, + availability: Availability, + presence: Presence, + mut muc_user: MucUser, +) -> Result<()> { + let Jid::Full(from) = from else { + bail!("Expected FullJid in MUC presence.") + }; + + let room = RoomId::from(from.to_bare()); + + let Some(item) = muc_user.items.first() else { + return missing_element(ctx, "item", muc_user); + }; + + let is_self_presence = muc_user.status.contains(&Status::SelfPresence); + + if let Some(destroy) = muc_user.destroy.take() { + ctx.push_event(RoomEvent { + room_id: room, + r#type: RoomEventType::Destroyed { + replacement: destroy.jid.map(RoomId::from), + }, + }); + return Ok(()); + } + + let occupant_id = OccupantId::from(from); + let anon_occupant_id = presence + .payloads + .iter() + .find(|p| p.is("occupant-id", ns::OCCUPANT_ID)) + .and_then(|e| e.attr("id")) + .map(|id| AnonOccupantId::from(id.to_string())); + let real_id = item.jid.clone().map(|jid| UserId::from(jid.into_bare())); + + ctx.push_event(UserStatusEvent { + user_id: UserEndpointId::Occupant(occupant_id.clone()), + r#type: UserStatusEventType::AvailabilityChanged { + availability: availability.clone(), + priority: presence.priority, + }, + }); + + if availability == Availability::Unavailable { + if muc_user + .status + .iter() + .find(|s| match s { + Status::Banned + | Status::Kicked + | Status::RemovalFromRoom + | Status::ConfigMembersOnly => true, + _ => false, + }) + .is_some() + { + ctx.push_event(OccupantEvent { + occupant_id, + anon_occupant_id, + real_id, + is_self: is_self_presence, + r#type: OccupantEventType::PermanentlyRemoved, + }); + return Ok(()); + } + + if muc_user + .status + .iter() + .find(|s| match s { + Status::ServiceShutdown | Status::ServiceErrorKick => true, + _ => false, + }) + .is_some() + { + ctx.push_event(OccupantEvent { + occupant_id, + anon_occupant_id, + real_id, + is_self: is_self_presence, + r#type: OccupantEventType::DisconnectedByServer, + }); + return Ok(()); + } + } + + // If the user is unavailable and was not banned/room destroyed/forcefully removed then there + // is no point in sending an AffiliationChanged event, since the affiliation did not change. + if availability == Availability::Unavailable { + return Ok(()); + } + + ctx.push_event(OccupantEvent { + occupant_id, + anon_occupant_id, + real_id, + r#type: OccupantEventType::AffiliationChanged { + affiliation: item.affiliation.clone().into(), + }, + is_self: is_self_presence, + }); + + Ok(()) +} diff --git a/crates/prose-core-client/src/infra/xmpp/event_parser/pubsub.rs b/crates/prose-core-client/src/infra/xmpp/event_parser/pubsub.rs new file mode 100644 index 00000000..a388170a --- /dev/null +++ b/crates/prose-core-client/src/infra/xmpp/event_parser/pubsub.rs @@ -0,0 +1,95 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; +use itertools::partition; +use tracing::{info, warn}; +use xmpp_parsers::pubsub::event::Item; +use xmpp_parsers::pubsub::{ItemId, PubSubEvent}; + +use prose_xmpp::mods::pubsub::Event as XMPPPubSubEvent; + +use crate::app::event_handlers::SidebarBookmarkEvent; +use crate::domain::shared::models::RoomId; +use crate::domain::sidebar::models::Bookmark; +use crate::infra::xmpp::event_parser::Context; +use crate::infra::xmpp::type_conversions::bookmark::ns; + +pub fn parse_pubsub_event(ctx: &mut Context, event: XMPPPubSubEvent) -> Result<()> { + match event { + XMPPPubSubEvent::PubSubMessage { mut message } => { + // Ignore all PubSub nodes except ns::PROSE_BOOKMARK… + let partition_idx = partition(&mut message.events, |event| { + let node = match event { + PubSubEvent::Configuration { node, .. } => node, + PubSubEvent::Delete { node, .. } => node, + PubSubEvent::PublishedItems { node, .. } => node, + PubSubEvent::RetractedItems { node, .. } => node, + PubSubEvent::Purge { node, .. } => node, + PubSubEvent::Subscription { node, .. } => node, + }; + node.0 != ns::PROSE_BOOKMARK + }); + parse_pubsub_events(ctx, message.events.drain(partition_idx..))?; + + if !message.events.is_empty() { + info!("PubSub message contains unhandled events.") + } + + Ok(()) + } + } +} + +fn parse_pubsub_events( + ctx: &mut Context, + events: impl IntoIterator, +) -> Result<()> { + for event in events { + match event { + PubSubEvent::PublishedItems { items, .. } => handle_added_or_updated_items(ctx, items)?, + PubSubEvent::RetractedItems { items, .. } => handle_retracted_items(ctx, items)?, + PubSubEvent::Purge { .. } | PubSubEvent::Delete { .. } => { + ctx.push_event(SidebarBookmarkEvent::Purged) + } + PubSubEvent::Configuration { .. } => {} + PubSubEvent::Subscription { .. } => {} + } + } + + Ok(()) +} + +fn handle_added_or_updated_items(ctx: &mut Context, items: Vec) -> Result<()> { + let bookmarks = items + .into_iter() + .filter_map(|item| { + let Some(payload) = item.0.payload else { + warn!("Encountered missing payload in PubSub item for bookmark"); + return None; + }; + + let Ok(bookmark) = Bookmark::try_from(payload) else { + warn!("Encountered invalid payload in PubSub item for bookmark"); + return None; + }; + + Some(bookmark) + }) + .collect::>(); + + ctx.push_event(SidebarBookmarkEvent::AddedOrUpdated { bookmarks }); + Ok(()) +} + +fn handle_retracted_items(ctx: &mut Context, ids: Vec) -> Result<()> { + let ids = ids + .into_iter() + .map(|id| id.0.parse::()) + .collect::, _>>()?; + + ctx.push_event(SidebarBookmarkEvent::Deleted { ids }); + Ok(()) +} diff --git a/crates/prose-core-client/src/infra/xmpp/mod.rs b/crates/prose-core-client/src/infra/xmpp/mod.rs index f8b2f234..956fe60f 100644 --- a/crates/prose-core-client/src/infra/xmpp/mod.rs +++ b/crates/prose-core-client/src/infra/xmpp/mod.rs @@ -5,5 +5,6 @@ pub use xmpp_client::{XMPPClient, XMPPClientBuilder}; +pub(crate) mod event_parser; pub(crate) mod type_conversions; mod xmpp_client; diff --git a/crates/prose-core-client/src/infra/xmpp/type_conversions/bookmark.rs b/crates/prose-core-client/src/infra/xmpp/type_conversions/bookmark.rs index 2c57cafe..882548ba 100644 --- a/crates/prose-core-client/src/infra/xmpp/type_conversions/bookmark.rs +++ b/crates/prose-core-client/src/infra/xmpp/type_conversions/bookmark.rs @@ -11,7 +11,7 @@ use xmpp_parsers::pubsub::PubSubPayload; use prose_xmpp::{ElementExt, ParseError, RequestError}; -use crate::domain::shared::models::RoomJid; +use crate::domain::shared::models::RoomId; use crate::domain::sidebar::models::{Bookmark, BookmarkType}; pub mod ns { @@ -26,7 +26,7 @@ impl TryFrom for Bookmark { Ok(Self { name: value.attr_req("name")?.to_string(), - jid: RoomJid::from(BareJid::from_str(&value.attr_req("jid")?)?), + jid: RoomId::from(BareJid::from_str(&value.attr_req("jid")?)?), r#type: BookmarkType::from_str(&value.attr_req("type")?)?, is_favorite: value.attr("favorite").is_some(), in_sidebar: value.attr("sidebar").is_some(), diff --git a/crates/prose-core-client/src/infra/xmpp/type_conversions/compose_state.rs b/crates/prose-core-client/src/infra/xmpp/type_conversions/compose_state.rs new file mode 100644 index 00000000..8b47ab65 --- /dev/null +++ b/crates/prose-core-client/src/infra/xmpp/type_conversions/compose_state.rs @@ -0,0 +1,18 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use crate::domain::rooms::models::ComposeState; +use xmpp_parsers::chatstates::ChatState; + +impl From for ComposeState { + fn from(value: ChatState) -> Self { + match value { + ChatState::Composing => ComposeState::Composing, + ChatState::Active | ChatState::Gone | ChatState::Inactive | ChatState::Paused => { + ComposeState::Idle + } + } + } +} diff --git a/crates/prose-core-client/src/infra/xmpp/type_conversions/mod.rs b/crates/prose-core-client/src/infra/xmpp/type_conversions/mod.rs index b4e16bc8..974d22b2 100644 --- a/crates/prose-core-client/src/infra/xmpp/type_conversions/mod.rs +++ b/crates/prose-core-client/src/infra/xmpp/type_conversions/mod.rs @@ -6,7 +6,9 @@ pub(crate) mod availability; pub(crate) mod bookmark; pub(crate) mod caps; +pub(crate) mod compose_state; pub(crate) mod presence; +pub(crate) mod room_affiliation; pub(crate) mod room_info; pub(crate) mod room_spec; pub(crate) mod stanza_error; diff --git a/crates/prose-core-client/src/infra/xmpp/type_conversions/room_affiliation.rs b/crates/prose-core-client/src/infra/xmpp/type_conversions/room_affiliation.rs new file mode 100644 index 00000000..ffff3466 --- /dev/null +++ b/crates/prose-core-client/src/infra/xmpp/type_conversions/room_affiliation.rs @@ -0,0 +1,20 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 + +use xmpp_parsers::muc::user::Affiliation; + +use crate::domain::rooms::models::RoomAffiliation; + +impl From for RoomAffiliation { + fn from(value: Affiliation) -> Self { + match value { + Affiliation::Owner => RoomAffiliation::Owner, + Affiliation::Admin => RoomAffiliation::Admin, + Affiliation::Member => RoomAffiliation::Member, + Affiliation::Outcast => RoomAffiliation::Outcast, + Affiliation::None => RoomAffiliation::None, + } + } +} diff --git a/crates/prose-core-client/src/infra/xmpp/type_conversions/user_activity.rs b/crates/prose-core-client/src/infra/xmpp/type_conversions/user_activity.rs index 6b878d87..72846d4c 100644 --- a/crates/prose-core-client/src/infra/xmpp/type_conversions/user_activity.rs +++ b/crates/prose-core-client/src/infra/xmpp/type_conversions/user_activity.rs @@ -9,9 +9,9 @@ use anyhow::Result; use prose_xmpp::stanza::user_activity::activity; use prose_xmpp::stanza::UserActivity as XMPPUserActivity; -use crate::domain::user_info::models::UserActivity; +use crate::domain::user_info::models::UserStatus; -impl TryFrom for UserActivity { +impl TryFrom for UserStatus { type Error = anyhow::Error; fn try_from(value: XMPPUserActivity) -> Result { @@ -27,7 +27,7 @@ impl TryFrom for UserActivity { bail!("Missing emoji in UserActivity") }; - Ok(UserActivity { + Ok(UserStatus { emoji, status: value .text @@ -36,8 +36,8 @@ impl TryFrom for UserActivity { } } -impl From for XMPPUserActivity { - fn from(value: UserActivity) -> Self { +impl From for XMPPUserActivity { + fn from(value: UserStatus) -> Self { // Notice: as we are using emoji-based icons in order to specify the \ // kind of activity, we do not map to a proper RPID there, but \ // rather use the 'undefined' unspecified activity general category, \ diff --git a/crates/prose-core-client/src/infra/xmpp/xmpp_client.rs b/crates/prose-core-client/src/infra/xmpp/xmpp_client.rs index 30dae8e1..2fe70501 100644 --- a/crates/prose-core-client/src/infra/xmpp/xmpp_client.rs +++ b/crates/prose-core-client/src/infra/xmpp/xmpp_client.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use prose_wasm_utils::{SendUnlessWasm, SyncUnlessWasm}; use prose_xmpp::client::ConnectorProvider; -use prose_xmpp::{mods, Client, ClientBuilder, Event, IDProvider, TimeProvider}; +use prose_xmpp::{Client, ClientBuilder, Event, IDProvider, TimeProvider}; #[derive(Clone)] pub struct XMPPClient { @@ -64,19 +64,7 @@ impl XMPPClientBuilder { } pub fn build(self) -> XMPPClient { - let client = self - .builder - .add_mod(mods::Bookmark2::default()) - .add_mod(mods::Bookmark::default()) - .add_mod(mods::Caps::default()) - .add_mod(mods::Chat::default()) - .add_mod(mods::MAM::default()) - .add_mod(mods::MUC::default()) - .add_mod(mods::Profile::default()) - .add_mod(mods::PubSub::default()) - .add_mod(mods::Roster::default()) - .add_mod(mods::Status::default()) - .build(); + let client = self.builder.build(); XMPPClient { client: Arc::new(client), diff --git a/crates/prose-core-client/src/lib.rs b/crates/prose-core-client/src/lib.rs index 56006b31..2819c80c 100644 --- a/crates/prose-core-client/src/lib.rs +++ b/crates/prose-core-client/src/lib.rs @@ -5,8 +5,10 @@ pub use app::{dtos, services}; pub use client::{Client, ClientDelegate}; -pub use client_event::{ClientEvent, ConnectionEvent, RoomEventType}; +pub use client_event::{ClientEvent, ClientRoomEventType, ConnectionEvent}; pub use infra::platform_dependencies::open_store; +#[cfg(feature = "test")] +pub use infra::xmpp::event_parser::parse_xmpp_event; #[cfg(target_arch = "wasm32")] pub use prose_store::prelude::IndexedDBDriver; #[cfg(not(target_arch = "wasm32"))] @@ -34,4 +36,7 @@ pub(crate) mod domain; pub mod infra; +#[cfg(feature = "test")] +pub mod util; +#[cfg(not(feature = "test"))] pub(crate) mod util; diff --git a/crates/prose-core-client/src/test/bookmark.rs b/crates/prose-core-client/src/test/bookmark.rs index b6e9d69a..25de4553 100644 --- a/crates/prose-core-client/src/test/bookmark.rs +++ b/crates/prose-core-client/src/test/bookmark.rs @@ -4,11 +4,10 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use crate::domain::sidebar::models::{Bookmark, BookmarkType}; -use crate::dtos::RoomJid; -use crate::util::jid_ext::BareJidExt; +use crate::dtos::RoomId; impl Bookmark { - pub fn direct_message(jid: impl Into) -> Self { + pub fn direct_message(jid: impl Into) -> Self { let jid = jid.into(); Self { @@ -20,7 +19,7 @@ impl Bookmark { } } - pub fn group(jid: impl Into, name: impl Into) -> Self { + pub fn group(jid: impl Into, name: impl Into) -> Self { let jid = jid.into(); Self { @@ -32,7 +31,7 @@ impl Bookmark { } } - pub fn public_channel(jid: impl Into, name: impl Into) -> Self { + pub fn public_channel(jid: impl Into, name: impl Into) -> Self { let jid = jid.into(); Self { @@ -44,7 +43,7 @@ impl Bookmark { } } - pub fn private_channel(jid: impl Into, name: impl Into) -> Self { + pub fn private_channel(jid: impl Into, name: impl Into) -> Self { let jid = jid.into(); Self { diff --git a/crates/prose-core-client/src/test/mock_app_dependencies.rs b/crates/prose-core-client/src/test/mock_app_dependencies.rs index d06eb514..4a9b0981 100644 --- a/crates/prose-core-client/src/test/mock_app_dependencies.rs +++ b/crates/prose-core-client/src/test/mock_app_dependencies.rs @@ -8,14 +8,14 @@ use std::sync::Arc; use chrono::{DateTime, TimeZone, Utc}; use derivative::Derivative; -use jid::{BareJid, FullJid}; +use jid::BareJid; use parking_lot::RwLock; +use prose_xmpp::bare; use prose_xmpp::test::IncrementingIDProvider; -use prose_xmpp::{bare, full}; use crate::app::deps::{ - AppContext, AppDependencies, DynBookmarksService, DynClientEventDispatcher, + AppContext, AppDependencies, DynAppContext, DynBookmarksService, DynClientEventDispatcher, DynDraftsRepository, DynIDProvider, DynMessageArchiveService, DynMessagesRepository, DynMessagingService, DynRoomAttributesService, DynRoomParticipationService, DynSidebarDomainService, DynSidebarReadOnlyRepository, DynTimeProvider, @@ -31,7 +31,9 @@ use crate::domain::contacts::services::mocks::MockContactsService; use crate::domain::general::models::Capabilities; use crate::domain::general::services::mocks::MockRequestHandlingService; use crate::domain::messaging::repos::mocks::{MockDraftsRepository, MockMessagesRepository}; -use crate::domain::messaging::services::mocks::{MockMessageArchiveService, MockMessagingService}; +use crate::domain::messaging::services::mocks::{ + MockMessageArchiveService, MockMessageMigrationDomainService, MockMessagingService, +}; use crate::domain::rooms::repos::mocks::{ MockConnectedRoomsReadOnlyRepository, MockConnectedRoomsReadWriteRepository, }; @@ -51,7 +53,9 @@ use crate::domain::user_info::repos::mocks::{MockAvatarRepository, MockUserInfoR use crate::domain::user_info::services::mocks::MockUserInfoService; use crate::domain::user_profiles::repos::mocks::MockUserProfileRepository; use crate::domain::user_profiles::services::mocks::MockUserProfileService; +use crate::dtos::UserResourceId; use crate::test::ConstantTimeProvider; +use crate::user_resource_id; pub fn mock_reference_date() -> DateTime { Utc.with_ymd_and_hms(2021, 09, 06, 0, 0, 0).unwrap().into() @@ -61,8 +65,8 @@ pub fn mock_muc_service() -> BareJid { bare!("conference.prose.org") } -pub fn mock_account_jid() -> FullJid { - full!("jane.doe@prose.org/macOS") +pub fn mock_account_jid() -> UserResourceId { + user_resource_id!("jane.doe@prose.org/macOS") } impl Default for AppContext { @@ -140,6 +144,7 @@ impl From for AppDependencies { let user_profile_repo = Arc::new(mock.user_profile_repo); let room_factory = { + let ctx = ctx.clone(); let client_event_dispatcher = client_event_dispatcher.clone(); let drafts_repo = drafts_repo.clone(); let message_archive_service = message_archive_service.clone(); @@ -154,6 +159,7 @@ impl From for AppDependencies { RoomFactory::new(Arc::new(move |data| { RoomInner { data: data.clone(), + ctx: ctx.clone(), time_provider: time_provider.clone(), messaging_service: messaging_service.clone(), message_archive_service: message_archive_service.clone(), @@ -240,9 +246,11 @@ pub struct MockRoomsDomainServiceDependencies { pub ctx: AppContext, #[derivative(Default(value = "Arc::new(IncrementingIDProvider::new(\"short-id\"))"))] pub id_provider: DynIDProvider, + pub message_migration_domain_service: MockMessageMigrationDomainService, pub room_attributes_service: MockRoomAttributesService, pub room_management_service: MockRoomManagementService, pub room_participation_service: MockRoomParticipationService, + pub user_info_repo: MockUserInfoRepository, pub user_profile_repo: MockUserProfileRepository, } @@ -259,9 +267,11 @@ impl From for RoomsDomainServiceDependencies connected_rooms_repo: Arc::new(value.connected_rooms_repo), ctx: Arc::new(value.ctx), id_provider: Arc::new(value.id_provider), + message_migration_domain_service: Arc::new(value.message_migration_domain_service), room_attributes_service: Arc::new(value.room_attributes_service), room_management_service: Arc::new(value.room_management_service), room_participation_service: Arc::new(value.room_participation_service), + user_info_repo: Arc::new(value.user_info_repo), user_profile_repo: Arc::new(value.user_profile_repo), } } @@ -270,8 +280,10 @@ impl From for RoomsDomainServiceDependencies #[derive(Derivative)] #[derivative(Default)] pub struct MockRoomFactoryDependencies { + pub attributes_service: MockRoomAttributesService, pub bookmarks_service: MockBookmarksService, pub client_event_dispatcher: MockClientEventDispatcherTrait, + pub ctx: AppContext, pub drafts_repo: MockDraftsRepository, pub message_archive_service: MockMessageArchiveService, pub message_repo: MockMessagesRepository, @@ -281,20 +293,20 @@ pub struct MockRoomFactoryDependencies { pub sidebar_repo: MockSidebarReadOnlyRepository, #[derivative(Default(value = "Arc::new(ConstantTimeProvider::new(mock_reference_date()))"))] pub time_provider: DynTimeProvider, - pub attributes_service: MockRoomAttributesService, pub user_profile_repo: MockUserProfileRepository, } pub struct MockSealedRoomFactoryDependencies { pub bookmarks_service: DynBookmarksService, pub client_event_dispatcher: DynClientEventDispatcher, + pub ctx: DynAppContext, pub drafts_repo: DynDraftsRepository, pub message_archive_service: DynMessageArchiveService, pub message_repo: DynMessagesRepository, pub messaging_service: DynMessagingService, pub participation_service: DynRoomParticipationService, - pub sidebar_repo: DynSidebarReadOnlyRepository, pub sidebar_domain_service: DynSidebarDomainService, + pub sidebar_repo: DynSidebarReadOnlyRepository, pub time_provider: DynTimeProvider, pub topic_service: DynRoomAttributesService, pub user_profile_repo: DynUserProfileRepository, @@ -305,6 +317,7 @@ impl From for MockSealedRoomFactoryDependencies { Self { bookmarks_service: Arc::new(value.bookmarks_service), client_event_dispatcher: Arc::new(value.client_event_dispatcher), + ctx: Arc::new(value.ctx), drafts_repo: Arc::new(value.drafts_repo), message_archive_service: Arc::new(value.message_archive_service), message_repo: Arc::new(value.message_repo), @@ -324,6 +337,7 @@ impl From for RoomFactory { RoomFactory::new(Arc::new(move |data| { RoomInner { data: data.clone(), + ctx: value.ctx.clone(), client_event_dispatcher: value.client_event_dispatcher.clone(), drafts_repo: value.drafts_repo.clone(), message_archive_service: value.message_archive_service.clone(), diff --git a/crates/prose-core-client/src/test/mod.rs b/crates/prose-core-client/src/test/mod.rs index 42030162..b0ee7bbd 100644 --- a/crates/prose-core-client/src/test/mod.rs +++ b/crates/prose-core-client/src/test/mod.rs @@ -3,12 +3,19 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) +use anyhow::Result; +use minidom::Element; + pub use constant_time_provider::ConstantTimeProvider; pub use message_builder::MessageBuilder; pub use mock_app_dependencies::{ MockAppDependencies, MockRoomFactoryDependencies, MockRoomsDomainServiceDependencies, MockSidebarDomainServiceDependencies, }; +use prose_xmpp::Client; + +use crate::app::event_handlers::ServerEvent; +use crate::parse_xmpp_event; mod bookmark; mod constant_time_provider; @@ -26,8 +33,56 @@ pub mod mock_data { } #[macro_export] -macro_rules! room { +macro_rules! room_id { + ($jid:expr) => { + RoomId::from($jid.parse::().unwrap()) + }; +} + +#[macro_export] +macro_rules! user_id { + ($jid:expr) => { + UserId::from($jid.parse::().unwrap()) + }; +} + +#[macro_export] +macro_rules! sender_id { + ($jid:expr) => { + SenderId::from($jid.parse::().unwrap()) + }; +} + +#[macro_export] +macro_rules! user_resource_id { + ($jid:expr) => { + UserResourceId::from($jid.parse::().unwrap()) + }; +} + +#[macro_export] +macro_rules! occupant_id { ($jid:expr) => { - RoomJid::from($jid.parse::().unwrap()) + OccupantId::from($jid.parse::().unwrap()) }; } + +#[cfg(not(target_arch = "wasm32"))] +pub async fn parse_xml(xml: &str) -> Result> { + use prose_xmpp::test::ClientTestAdditions; + + let client = Client::connected_client().await?; + + client + .connection + .receive_stanza(xml.trim().parse::()?) + .await; + + let parsed_events = client + .sent_events() + .into_iter() + .map(|e| parse_xmpp_event(e)) + .collect::, _>>()?; + + Ok(parsed_events.into_iter().flatten().collect()) +} diff --git a/crates/prose-core-client/src/test/room_internals.rs b/crates/prose-core-client/src/test/room_internals.rs index b6ed8530..c24ee987 100644 --- a/crates/prose-core-client/src/test/room_internals.rs +++ b/crates/prose-core-client/src/test/room_internals.rs @@ -4,57 +4,48 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use chrono::{DateTime, Utc}; -use jid::{BareJid, Jid}; -use std::collections::HashMap; -use xmpp_parsers::chatstates::ChatState; -use xmpp_parsers::muc::user::Affiliation; - -use crate::domain::rooms::models::{RoomInfo, RoomInternals}; -use crate::domain::shared::models::{RoomJid, RoomType}; -use crate::dtos::{Member, Occupant}; + +use crate::domain::rooms::models::{ + ComposeState, RegisteredMember, RoomAffiliation, RoomInfo, RoomInternals, +}; +use crate::domain::shared::models::{ParticipantId, RoomId, RoomType}; +use crate::dtos::{Availability, Participant, UserId}; use crate::test::mock_data; -use crate::util::jid_ext::BareJidExt; impl RoomInternals { - pub fn direct_message(jid: impl Into) -> Self { + pub fn direct_message(jid: UserId, availability: &Availability) -> Self { let jid = jid.into(); - Self::for_direct_message( - &jid, - &mock_data::account_jid().into_bare(), - &jid.to_display_name(), + Self::for_direct_message(&jid, &jid.formatted_username(), availability) + } + + pub fn mock_pending_room(jid: impl Into, next_hash: &str) -> Self { + Self::pending( + &jid.into(), + &format!("{}-{}", mock_data::account_jid().username(), next_hash), ) } - pub fn group(jid: impl Into) -> Self { + pub fn group(jid: impl Into) -> Self { Self::new(RoomInfo { - jid: jid.into(), - description: None, - user_jid: mock_data::account_jid().into_bare(), - user_nickname: mock_data::account_jid().node_str().unwrap().to_string(), - members: HashMap::new(), + room_id: jid.into(), + user_nickname: mock_data::account_jid().username().to_string(), r#type: RoomType::Group, }) } - pub fn public_channel(jid: impl Into) -> Self { + pub fn public_channel(jid: impl Into) -> Self { Self::new(RoomInfo { - jid: jid.into(), - description: None, - user_jid: mock_data::account_jid().into_bare(), - user_nickname: mock_data::account_jid().node_str().unwrap().to_string(), - members: HashMap::new(), + room_id: jid.into(), + user_nickname: mock_data::account_jid().username().to_string(), r#type: RoomType::PublicChannel, }) } - pub fn private_channel(jid: impl Into) -> Self { + pub fn private_channel(jid: impl Into) -> Self { Self::new(RoomInfo { - jid: jid.into(), - description: None, - user_jid: mock_data::account_jid().into_bare(), - user_nickname: mock_data::account_jid().node_str().unwrap().to_string(), - members: HashMap::new(), + room_id: jid.into(), + user_nickname: mock_data::account_jid().username().to_string(), r#type: RoomType::PrivateChannel, }) } @@ -65,44 +56,59 @@ impl RoomInternals { } pub fn with_name(self, name: impl AsRef) -> Self { - self.set_name(name.as_ref()); + self.set_name(Some(name.as_ref().to_string())); + self + } + + pub fn with_topic(self, topic: Option<&str>) -> Self { + self.set_topic(topic.map(ToString::to_string)); self } - pub fn with_members(mut self, members: impl IntoIterator) -> Self { - self.members = members.into_iter().collect(); + pub fn with_members(self, members: impl IntoIterator) -> Self { + self.participants_mut().set_registered_members(members); self } - pub fn with_occupants(self, occupant: impl IntoIterator) -> Self { - self.set_occupants(occupant.into_iter().collect()); + pub fn with_participants>( + self, + occupant: impl IntoIterator, + ) -> Self { + self.participants_mut() + .extend_participants(occupant.into_iter().map(|(id, p)| (id.into(), p)).collect()); self } } -impl Occupant { +impl Participant { pub fn owner() -> Self { - Occupant { - jid: None, + Participant { + real_id: None, name: None, - affiliation: Affiliation::Owner, - chat_state: ChatState::Gone, - chat_state_updated: Default::default(), + is_self: false, + affiliation: RoomAffiliation::Owner, + compose_state: Default::default(), + compose_state_updated: Default::default(), + availability: Availability::Unavailable, + anon_occupant_id: None, } } pub fn member() -> Self { - Occupant { - jid: None, + Participant { + real_id: None, + anon_occupant_id: None, name: None, - affiliation: Affiliation::Owner, - chat_state: ChatState::Gone, - chat_state_updated: Default::default(), + is_self: false, + affiliation: RoomAffiliation::Owner, + compose_state: Default::default(), + compose_state_updated: Default::default(), + availability: Availability::Unavailable, } } - pub fn set_real_jid(mut self, jid: &BareJid) -> Self { - self.jid = Some(jid.clone()); + pub fn set_real_id(mut self, id: &UserId) -> Self { + self.real_id = Some(id.clone()); self } @@ -111,13 +117,13 @@ impl Occupant { self } - pub fn set_chat_state(mut self, chat_state: ChatState) -> Self { - self.chat_state = chat_state; + pub fn set_compose_state(mut self, compose_state: ComposeState) -> Self { + self.compose_state = compose_state; self } - pub fn set_chat_state_updated(mut self, timestamp: DateTime) -> Self { - self.chat_state_updated = timestamp; + pub fn set_compose_state_updated(mut self, timestamp: DateTime) -> Self { + self.compose_state_updated = timestamp; self } } diff --git a/crates/prose-core-client/src/test/room_metadata.rs b/crates/prose-core-client/src/test/room_metadata.rs index f7c860e0..b9db3849 100644 --- a/crates/prose-core-client/src/test/room_metadata.rs +++ b/crates/prose-core-client/src/test/room_metadata.rs @@ -3,21 +3,53 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::domain::rooms::models::RoomSessionInfo; +use crate::domain::rooms::models::{ + RoomAffiliation, RoomConfig, RoomSessionInfo, RoomSessionMember, +}; use crate::domain::shared::models::RoomType; -use crate::dtos::RoomJid; +use crate::dtos::{RoomId, UserId}; use crate::test::mock_data; +impl RoomSessionMember { + pub fn owner(id: UserId) -> Self { + Self { + id, + affiliation: RoomAffiliation::Owner, + } + } + + pub fn member(id: UserId) -> Self { + Self { + id, + affiliation: RoomAffiliation::Member, + } + } + + pub fn admin(id: UserId) -> Self { + Self { + id, + affiliation: RoomAffiliation::Admin, + } + } +} + impl RoomSessionInfo { - pub fn new_room(room_jid: impl Into, room_type: RoomType) -> Self { + pub fn new_room(room_jid: impl Into, room_type: RoomType) -> Self { Self { - room_jid: room_jid.into(), - room_name: None, - room_description: None, - room_type, - user_nickname: mock_data::account_jid().node_str().unwrap().to_string(), + room_id: room_jid.into(), + config: RoomConfig { + room_name: None, + room_description: None, + room_type, + }, + user_nickname: mock_data::account_jid().username().to_string(), members: vec![], room_has_been_created: true, } } + + pub fn with_members(mut self, members: impl IntoIterator) -> Self { + self.members = members.into_iter().collect(); + self + } } diff --git a/crates/prose-core-client/src/test/sidebar_item.rs b/crates/prose-core-client/src/test/sidebar_item.rs index 6396a2a8..cd0472b2 100644 --- a/crates/prose-core-client/src/test/sidebar_item.rs +++ b/crates/prose-core-client/src/test/sidebar_item.rs @@ -4,11 +4,10 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use crate::domain::sidebar::models::{BookmarkType, SidebarItem}; -use crate::dtos::RoomJid; -use crate::util::jid_ext::BareJidExt; +use crate::dtos::RoomId; impl SidebarItem { - pub fn direct_message(jid: impl Into) -> Self { + pub fn direct_message(jid: impl Into) -> Self { let jid = jid.into(); Self { @@ -20,7 +19,7 @@ impl SidebarItem { } } - pub fn group(jid: impl Into, name: impl Into) -> Self { + pub fn group(jid: impl Into, name: impl Into) -> Self { let jid = jid.into(); Self { @@ -32,7 +31,7 @@ impl SidebarItem { } } - pub fn public_channel(jid: impl Into, name: impl Into) -> Self { + pub fn public_channel(jid: impl Into, name: impl Into) -> Self { let jid = jid.into(); Self { @@ -44,7 +43,7 @@ impl SidebarItem { } } - pub fn private_channel(jid: impl Into, name: impl Into) -> Self { + pub fn private_channel(jid: impl Into, name: impl Into) -> Self { let jid = jid.into(); Self { diff --git a/crates/prose-core-client/src/util/jid_ext.rs b/crates/prose-core-client/src/util/jid_ext.rs deleted file mode 100644 index 68669e99..00000000 --- a/crates/prose-core-client/src/util/jid_ext.rs +++ /dev/null @@ -1,73 +0,0 @@ -// prose-core-client/prose-core-client -// -// Copyright: 2023, Marc Bauer -// License: Mozilla Public License v2.0 (MPL v2.0) - -use jid::{BareJid, FullJid, Jid}; - -use crate::util::StringExt; - -pub trait BareJidExt { - fn to_display_name(&self) -> String; -} - -pub trait FullJidExt { - fn resource_to_display_name(&self) -> String; -} - -pub trait JidExt { - fn node_to_display_name(&self) -> String; - fn resource_to_display_name(&self) -> String; -} - -impl BareJidExt for BareJid { - fn to_display_name(&self) -> String { - let Some(node) = self.node_str() else { - return self.to_string().to_uppercase_first_letter(); - }; - str_to_display_name(node) - } -} - -impl FullJidExt for FullJid { - fn resource_to_display_name(&self) -> String { - str_to_display_name(self.resource_str()) - } -} - -impl JidExt for Jid { - fn node_to_display_name(&self) -> String { - let Some(node) = self.node_str() else { - return self.to_string().to_uppercase_first_letter(); - }; - str_to_display_name(node) - } - - fn resource_to_display_name(&self) -> String { - let Some(resource) = self.resource_str() else { - return self.to_string().to_uppercase_first_letter(); - }; - str_to_display_name(resource) - } -} - -fn str_to_display_name(text: &str) -> String { - text.split_terminator(&['.', '_', '-'][..]) - .map(|s| s.to_uppercase_first_letter()) - .collect::>() - .join(" ") -} - -#[cfg(test)] -mod tests { - use super::*; - use prose_xmpp::bare; - - #[test] - fn test_display_name() { - assert_eq!(&bare!("abc@prose.org").to_display_name(), "Abc"); - assert_eq!(&bare!("jane-doe@prose.org").to_display_name(), "Jane Doe"); - assert_eq!(&bare!("jane.doe@prose.org").to_display_name(), "Jane Doe"); - assert_eq!(&bare!("jane_doe@prose.org").to_display_name(), "Jane Doe"); - } -} diff --git a/crates/prose-core-client/src/util/mod.rs b/crates/prose-core-client/src/util/mod.rs index 907a6b01..630ba006 100644 --- a/crates/prose-core-client/src/util/mod.rs +++ b/crates/prose-core-client/src/util/mod.rs @@ -3,12 +3,11 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -pub(crate) use form_config::FormConfig; -pub(crate) use string_ext::StringExt; +pub use form_config::FormConfig; +pub use string_ext::StringExt; #[cfg(not(target_arch = "wasm32"))] -pub(crate) mod account_bookmarks_client; +pub mod account_bookmarks_client; -pub(crate) mod form_config; -pub(crate) mod jid_ext; -pub(crate) mod string_ext; +pub mod form_config; +pub mod string_ext; diff --git a/crates/prose-core-client/src/util/string_ext.rs b/crates/prose-core-client/src/util/string_ext.rs index c024a401..1171915d 100644 --- a/crates/prose-core-client/src/util/string_ext.rs +++ b/crates/prose-core-client/src/util/string_ext.rs @@ -5,6 +5,28 @@ pub trait StringExt { fn to_uppercase_first_letter(&self) -> String; + + /// Converts the username part of a JID into a human-readable capitalized display name. + /// + /// This method takes the local part of a JID, splits it at characters '.', '_', and '-', + /// capitalizes the first letter of each resulting segment, and then joins them with spaces + /// to create a formatted display name. + /// + /// # Examples + /// + /// Basic usage: + /// + /// ``` + /// use prose_core_client::util::StringExt; + /// + /// let jid_username = "john_doe"; + /// assert_eq!(jid_username.capitalized_display_name(), "John Doe"); + /// + /// let jid_username = "jane.doe"; + /// assert_eq!(jid_username.capitalized_display_name(), "Jane Doe"); + /// ``` + /// + fn capitalized_display_name(&self) -> String; } impl StringExt for T @@ -19,4 +41,25 @@ where Some(f) => f.to_uppercase().collect::() + c.as_str(), } } + + fn capitalized_display_name(&self) -> String { + self.as_ref() + .split_terminator(&['.', '_', '-'][..]) + .map(|s| s.to_uppercase_first_letter()) + .collect::>() + .join(" ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_display_name() { + assert_eq!("abc".capitalized_display_name(), "Abc"); + assert_eq!("jane-doe".capitalized_display_name(), "Jane Doe"); + assert_eq!("jane.doe".capitalized_display_name(), "Jane Doe"); + assert_eq!("jane_doe".capitalized_display_name(), "Jane Doe"); + } } diff --git a/crates/prose-core-client/tests/account_service.rs b/crates/prose-core-client/tests/account_service.rs index b30a3dad..23f4a6f8 100644 --- a/crates/prose-core-client/tests/account_service.rs +++ b/crates/prose-core-client/tests/account_service.rs @@ -25,7 +25,7 @@ async fn test_set_availability_updates_settings() -> Result<()> { .expect_update() .once() .with( - predicate::eq(mock_data::account_jid().into_bare()), + predicate::eq(mock_data::account_jid().into_user_id()), predicate::always(), ) .return_once(|_, f| { diff --git a/crates/prose-core-client/tests/connection_service.rs b/crates/prose-core-client/tests/connection_service.rs index 57cf0205..9a46b913 100644 --- a/crates/prose-core-client/tests/connection_service.rs +++ b/crates/prose-core-client/tests/connection_service.rs @@ -12,11 +12,11 @@ use prose_core_client::app::deps::DynAppContext; use prose_core_client::app::services::ConnectionService; use prose_core_client::domain::connection::models::ServerFeatures; use prose_core_client::domain::settings::models::AccountSettings; -use prose_core_client::dtos::Availability; +use prose_core_client::domain::shared::models::{Availability, UserId, UserResourceId}; use prose_core_client::test::MockAppDependencies; -use prose_core_client::{ClientEvent, ConnectionEvent}; +use prose_core_client::{user_id, user_resource_id, ClientEvent, ConnectionEvent}; use prose_xmpp::test::ConstantIDProvider; -use prose_xmpp::{bare, full, ConnectionError}; +use prose_xmpp::{bare, ConnectionError}; #[tokio::test] async fn test_starts_available_and_generates_resource() -> Result<()> { @@ -32,10 +32,14 @@ async fn test_starts_available_and_generates_resource() -> Result<()> { .expect_connect() .once() .with( - predicate::eq(full!("jane.doe@prose.org/resource-id")), + predicate::eq(user_resource_id!("jane.doe@prose.org/resource-id")), predicate::eq("my-password"), ) .return_once(|_, _| Box::pin(async { Ok(Default::default()) })); + deps.connection_service + .expect_set_message_carbons_enabled() + .once() + .return_once(|_| Box::pin(async { Ok(()) })); deps.user_account_service .expect_set_availability() .once() @@ -55,7 +59,7 @@ async fn test_starts_available_and_generates_resource() -> Result<()> { .expect_update() .once() .with( - predicate::eq(bare!("jane.doe@prose.org")), + predicate::eq(user_id!("jane.doe@prose.org")), predicate::always(), ) .return_once(|_, f| { @@ -80,16 +84,16 @@ async fn test_starts_available_and_generates_resource() -> Result<()> { let service = ConnectionService::from(&deps); *deps.ctx.connection_properties.write() = None; - assert!(deps.ctx.connected_jid().is_err()); + assert!(deps.ctx.connected_id().is_err()); assert!(deps.ctx.muc_service().is_err()); service - .connect(&bare!("jane.doe@prose.org"), "my-password") + .connect(&user_id!("jane.doe@prose.org"), "my-password") .await?; assert_eq!( - deps.ctx.connected_jid()?, - full!("jane.doe@prose.org/resource-id") + deps.ctx.connected_id()?, + user_resource_id!("jane.doe@prose.org/resource-id") ); assert_eq!(deps.ctx.muc_service()?, bare!("muc@prose.org")); @@ -115,10 +119,14 @@ async fn test_restores_availability_and_resource() -> Result<()> { .expect_connect() .once() .with( - predicate::eq(full!("jane.doe@prose.org/restored-res")), + predicate::eq(user_resource_id!("jane.doe@prose.org/restored-res")), predicate::always(), ) .return_once(|_, _| Box::pin(async { Ok(Default::default()) })); + deps.connection_service + .expect_set_message_carbons_enabled() + .once() + .return_once(|_| Box::pin(async { Ok(()) })); deps.user_account_service .expect_set_availability() .once() @@ -135,7 +143,7 @@ async fn test_restores_availability_and_resource() -> Result<()> { .expect_update() .once() .with( - predicate::eq(bare!("jane.doe@prose.org")), + predicate::eq(user_id!("jane.doe@prose.org")), predicate::always(), ) .return_once(|_, f| { @@ -160,7 +168,7 @@ async fn test_restores_availability_and_resource() -> Result<()> { let service = ConnectionService::from(&deps); service - .connect(&bare!("jane.doe@prose.org"), "my-password") + .connect(&user_id!("jane.doe@prose.org"), "my-password") .await?; Ok(()) @@ -188,8 +196,8 @@ async fn test_connection_failure() -> Result<()> { .once() .return_once(move |_, _| { assert_eq!( - ctx.get().unwrap().connected_jid().ok(), - Some(full!("jane.doe@prose.org/resource-id")) + ctx.get().unwrap().connected_id().ok(), + Some(user_resource_id!("jane.doe@prose.org/resource-id")) ); Box::pin(async { Err(ConnectionError::Generic { @@ -205,15 +213,15 @@ async fn test_connection_failure() -> Result<()> { let service = ConnectionService::from(&deps); *deps.ctx.connection_properties.write() = None; - assert!(deps.ctx.connected_jid().is_err()); + assert!(deps.ctx.connected_id().is_err()); assert!(deps.ctx.muc_service().is_err()); assert!(service - .connect(&bare!("jane.doe@prose.org"), "my-password") + .connect(&user_id!("jane.doe@prose.org"), "my-password") .await .is_err()); - assert!(deps.ctx.connected_jid().is_err()); + assert!(deps.ctx.connected_id().is_err()); assert!(deps.ctx.muc_service().is_err()); Ok(()) diff --git a/crates/prose-core-client/tests/contacts_service.rs b/crates/prose-core-client/tests/contacts_service.rs index d3893d71..1b5d0d87 100644 --- a/crates/prose-core-client/tests/contacts_service.rs +++ b/crates/prose-core-client/tests/contacts_service.rs @@ -8,11 +8,11 @@ use anyhow::Result; use prose_core_client::app::dtos::Contact as ContactDTO; use prose_core_client::app::services::ContactsService; use prose_core_client::domain::contacts::models::{Contact, Group}; -use prose_core_client::domain::shared::models::Availability; +use prose_core_client::domain::shared::models::{Availability, UserId}; use prose_core_client::domain::user_info::models::UserInfo; use prose_core_client::domain::user_profiles::models::UserProfile; use prose_core_client::test::MockAppDependencies; -use prose_xmpp::bare; +use prose_core_client::user_id; #[tokio::test] async fn test_assembles_contact_dto() -> Result<()> { @@ -21,17 +21,17 @@ async fn test_assembles_contact_dto() -> Result<()> { Box::pin(async { Ok(vec![ Contact { - jid: bare!("a@prose.org"), + id: user_id!("a@prose.org"), name: None, group: Group::Favorite, }, Contact { - jid: bare!("b@prose.org"), + id: user_id!("b@prose.org"), name: Some("Contact B".to_string()), group: Group::Team, }, Contact { - jid: bare!("john.doe@prose.org"), + id: user_id!("john.doe@prose.org"), name: None, group: Group::Team, }, @@ -44,17 +44,17 @@ async fn test_assembles_contact_dto() -> Result<()> { .times(3) .returning(|jid| { let info = match &jid { - _ if jid == &bare!("a@prose.org") => Some(UserInfo { + _ if jid == &user_id!("a@prose.org") => Some(UserInfo { avatar: None, activity: None, availability: Availability::Available, }), - _ if jid == &bare!("b@prose.org") => Some(UserInfo { + _ if jid == &user_id!("b@prose.org") => Some(UserInfo { avatar: None, activity: None, availability: Availability::Available, }), - _ if jid == &bare!("john.doe@prose.org") => None, + _ if jid == &user_id!("john.doe@prose.org") => None, _ => unreachable!(), }; @@ -68,14 +68,14 @@ async fn test_assembles_contact_dto() -> Result<()> { let mut profile = UserProfile::default(); match &jid { - _ if jid == &bare!("a@prose.org") => { + _ if jid == &user_id!("a@prose.org") => { profile.first_name = Some("First".to_string()); profile.last_name = Some("Last".to_string()); } - _ if jid == &bare!("b@prose.org") => { + _ if jid == &user_id!("b@prose.org") => { profile.nickname = Some("Nickname".to_string()); } - _ if jid == &bare!("john.doe@prose.org") => (), + _ if jid == &user_id!("john.doe@prose.org") => (), _ => unreachable!(), }; @@ -89,24 +89,24 @@ async fn test_assembles_contact_dto() -> Result<()> { contacts, vec![ ContactDTO { - jid: bare!("a@prose.org"), + id: user_id!("a@prose.org"), name: "First Last".to_string(), availability: Availability::Available, - activity: None, + status: None, group: Group::Favorite, }, ContactDTO { - jid: bare!("b@prose.org"), + id: user_id!("b@prose.org"), name: "Nickname".to_string(), availability: Availability::Available, - activity: None, + status: None, group: Group::Team, }, ContactDTO { - jid: bare!("john.doe@prose.org"), + id: user_id!("john.doe@prose.org"), name: "John Doe".to_string(), availability: Availability::Unavailable, - activity: None, + status: None, group: Group::Team, } ] diff --git a/crates/prose-core-client/tests/event_parsing/connection_event_parser.rs b/crates/prose-core-client/tests/event_parsing/connection_event_parser.rs new file mode 100644 index 00000000..112a2653 --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/connection_event_parser.rs @@ -0,0 +1,53 @@ +// prose-core-client/prose-xmpp +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; + +use prose_core_client::app::event_handlers::XMPPEvent; +use prose_core_client::app::event_handlers::{ConnectionEvent, ServerEvent}; +use prose_core_client::parse_xmpp_event; +use prose_proc_macros::mt_test; +use prose_xmpp::client::Event as XMPPClientEvent; +use prose_xmpp::ConnectionError; + +#[mt_test] +async fn test_connected() -> Result<()> { + let input_event = XMPPEvent::Client(XMPPClientEvent::Connected); + let output_events = parse_xmpp_event(input_event)?; + + assert_eq!( + output_events, + vec![ServerEvent::Connection(ConnectionEvent::Connected)] + ); + + Ok(()) +} + +#[mt_test] +async fn test_disconnected() -> Result<()> { + let input_event = XMPPEvent::Client(XMPPClientEvent::Disconnected { + error: Some(ConnectionError::InvalidCredentials), + }); + let output_events = parse_xmpp_event(input_event)?; + + assert_eq!( + output_events, + vec![ServerEvent::Connection(ConnectionEvent::Disconnected { + error: Some(ConnectionError::InvalidCredentials) + })] + ); + + let input_event = XMPPEvent::Client(XMPPClientEvent::Disconnected { error: None }); + let output_events = parse_xmpp_event(input_event)?; + + assert_eq!( + output_events, + vec![ServerEvent::Connection(ConnectionEvent::Disconnected { + error: None + })] + ); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/event_parsing/main.rs b/crates/prose-core-client/tests/event_parsing/main.rs new file mode 100644 index 00000000..d8fec318 --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/main.rs @@ -0,0 +1,11 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +mod connection_event_parser; +mod message_event_parser; +mod request_event_parser; +mod room_event_parser; +mod sidebar_bookmark_event_parser; +mod user_event_parser; diff --git a/crates/prose-core-client/tests/event_parsing/message_event_parser.rs b/crates/prose-core-client/tests/event_parsing/message_event_parser.rs new file mode 100644 index 00000000..b76835bd --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/message_event_parser.rs @@ -0,0 +1,37 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; + +use prose_core_client::app::event_handlers::{RequestEvent, RequestEventType, ServerEvent}; +use prose_core_client::domain::shared::models::{RequestId, SenderId}; +use prose_core_client::sender_id; +use prose_core_client::test::parse_xml; +use prose_proc_macros::mt_test; + +#[mt_test] +async fn test_ping() -> Result<()> { + // XEP-0199: XMPP Ping + // https://xmpp.org/extensions/xep-0199.html + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from("req-id"), + sender_id: sender_id!("prose.org"), + r#type: RequestEventType::Ping, + })] + ); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/event_parsing/request_event_parser.rs b/crates/prose-core-client/tests/event_parsing/request_event_parser.rs new file mode 100644 index 00000000..0e5b2cfb --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/request_event_parser.rs @@ -0,0 +1,166 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; + +use prose_core_client::app::event_handlers::{RequestEvent, RequestEventType, ServerEvent}; +use prose_core_client::domain::shared::models::{CapabilitiesId, RequestId, SenderId}; +use prose_core_client::sender_id; +use prose_core_client::test::parse_xml; +use prose_proc_macros::mt_test; + +#[mt_test] +async fn test_ping() -> Result<()> { + // XEP-0199: XMPP Ping + // https://xmpp.org/extensions/xep-0199.html + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from("req-id"), + sender_id: sender_id!("prose.org"), + r#type: RequestEventType::Ping, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_local_time() -> Result<()> { + // XEP-0202: Entity Time + // https://xmpp.org/extensions/xep-0202.html + + let events = parse_xml( + r#" + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from("req-id"), + sender_id: sender_id!("user@prose.org/res"), + r#type: RequestEventType::LocalTime, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_last_activity() -> Result<()> { + // XEP-0012: Last Activity + // https://xmpp.org/extensions/xep-0012.html + + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from("req-id"), + sender_id: sender_id!("user@prose.org/res"), + r#type: RequestEventType::LastActivity, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_capabilities() -> Result<()> { + // XEP-0115: Entity Capabilities + // https://xmpp.org/extensions/xep-0115.html + + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from("req-id"), + sender_id: sender_id!("user@prose.org/res"), + r#type: RequestEventType::Capabilities { + id: CapabilitiesId::from("https://prose.org#ImujI7nqf7pn4YqcjefXE3o5P1k=") + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_software_version() -> Result<()> { + // XEP-0092: Software Version + // https://xmpp.org/extensions/xep-0092.html + + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from("req-id"), + sender_id: sender_id!("user@prose.org/res"), + r#type: RequestEventType::SoftwareVersion, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_presence_subscription() -> Result<()> { + // https://xmpp.org/rfcs/rfc6121.html#sub-request + + let events = parse_xml( + r#" + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Request(RequestEvent { + request_id: RequestId::from(""), + sender_id: sender_id!("user@prose.org"), + r#type: RequestEventType::PresenceSubscription, + })] + ); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/event_parsing/room_event_parser.rs b/crates/prose-core-client/tests/event_parsing/room_event_parser.rs new file mode 100644 index 00000000..3d6541b9 --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/room_event_parser.rs @@ -0,0 +1,443 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; + +use prose_core_client::app::event_handlers::{ + OccupantEvent, OccupantEventType, RoomEvent, RoomEventType, ServerEvent, UserStatusEvent, + UserStatusEventType, +}; +use prose_core_client::domain::rooms::models::RoomAffiliation; +use prose_core_client::domain::shared::models::AnonOccupantId; +use prose_core_client::dtos::*; +use prose_core_client::test::parse_xml; +use prose_core_client::{occupant_id, room_id, user_id, user_resource_id}; +use prose_proc_macros::mt_test; + +#[mt_test] +async fn test_room_topic_changed() -> Result<()> { + // Room Subject (https://xmpp.org/extensions/xep-0045.html#enter-subject) + let events = parse_xml( + r#" + + Fire Burn and Cauldron Bubble! + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::RoomTopicChanged { + new_topic: Some("Fire Burn and Cauldron Bubble!".to_string()) + }, + })] + ); + + let events = parse_xml( + r#" + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::RoomTopicChanged { new_topic: None }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_room_destroyed() -> Result<()> { + // Destroying a Room (https://xmpp.org/extensions/xep-0045.html#destroyroom) + let events = + parse_xml( + r#" + + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::Destroyed { + replacement: Some(room_id!("new-room@prose.org")) + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_room_config_changed() -> Result<()> { + // Notification of Configuration Changes (https://xmpp.org/extensions/xep-0045.html#roomconfig-notify) + let events = parse_xml( + r#" + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::RoomConfigChanged, + })] + ); + + let events = parse_xml( + r#" + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::RoomConfigChanged, + })] + ); + + let events = parse_xml( + r#" + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::RoomConfigChanged, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_user_was_permanently_removed() -> Result<()> { + // Kicking an Occupant (https://xmpp.org/extensions/xep-0045.html#kick) + let events = parse_xml( + r#" + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Unavailable, + priority: 0 + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@prose.org/nick"), + anon_occupant_id: None, + real_id: None, + is_self: false, + r#type: OccupantEventType::PermanentlyRemoved + }) + ] + ); + + Ok(()) +} + +#[mt_test] +async fn test_user_was_disconnected_by_server() -> Result<()> { + // Service removes user because of service shut down (https://xmpp.org/extensions/xep-0045.html#service-shutdown-kick) + let events = parse_xml( + r#" + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Unavailable, + priority: 0 + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@prose.org/nick"), + anon_occupant_id: None, + real_id: Some(user_id!("user@prose.org")), + is_self: true, + r#type: OccupantEventType::DisconnectedByServer, + }) + ] + ); + + Ok(()) +} + +#[mt_test] +async fn test_user_entered_room() -> Result<()> { + // Entering a room (https://xmpp.org/extensions/xep-0045.html#example-21) + let events = parse_xml( + r#" + + + cdc05cb9c48d5e817a36d462fe0470a0579e570a + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0 + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@prose.org/nick"), + anon_occupant_id: Some(AnonOccupantId::from( + "gk6wmXJJ58Thj95cbfEX1Tzr0ONoOuZyU6SyMAvREXw=" + )), + real_id: Some(user_id!("user@prose.org")), + is_self: false, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::None + }, + }), + ] + ); + + Ok(()) +} + +#[mt_test] +async fn test_affiliation_change_with_multiple_resources() -> Result<()> { + // https://xmpp.org/extensions/xep-0045.html#enter-conflict + + // If a user joins a room with the same nickname from multiple resources, the resources are + // merged into a single presence. If one resource goes offline again, we won't receive a + // "unavailable" presence but another affiliation change with the affected item removed. + // For our intents and purposes we'll assume that the affiliation, role and bare jid of all + // resources are identical and ignore all but the first one in the list. + + let events = parse_xml( + r#" + + + cdc05cb9c48d5e817a36d462fe0470a0579e570a + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0 + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@prose.org/nick"), + anon_occupant_id: Some(AnonOccupantId::from( + "gk6wmXJJ58Thj95cbfEX1Tzr0ONoOuZyU6SyMAvREXw=" + )), + real_id: Some(user_id!("user@prose.org")), + is_self: false, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::None + }, + }), + ] + ); + + Ok(()) +} + +#[mt_test] +async fn test_user_exited_room() -> Result<()> { + // Exiting a Room (https://xmpp.org/extensions/xep-0045.html#exit) + let events = parse_xml( + r#" + + Disconnected: closed + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Unavailable, + priority: 0 + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_received_invite() -> Result<()> { + // Mediated invitation (https://xmpp.org/extensions/xep-0045.html#invite-mediated) + let events = parse_xml( + r#" + + + + Hey Hecate, this is the place for all good witches! + + cauldronburn + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::ReceivedInvitation { + sender: user_resource_id!("user@prose.org/res"), + password: Some("cauldronburn".to_string()) + }, + })] + ); + + // Direct invitation (https://xmpp.org/extensions/xep-0249.html) + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@prose.org"), + r#type: RoomEventType::ReceivedInvitation { + sender: user_resource_id!("user@prose.org/res"), + password: Some("cauldronburn".to_string()) + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_user_was_invited() -> Result<()> { + let events = parse_xml( + r#" + + + + Invited by user1@prose.org/res + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::Room(RoomEvent { + room_id: room_id!("room@groups.prose.org"), + r#type: RoomEventType::UserAdded { + user_id: user_id!("user2@prose.org"), + affiliation: RoomAffiliation::Member, + reason: Some("Invited by user1@prose.org/res".to_string()), + }, + })] + ); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/event_parsing/sidebar_bookmark_event_parser.rs b/crates/prose-core-client/tests/event_parsing/sidebar_bookmark_event_parser.rs new file mode 100644 index 00000000..adefdb3c --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/sidebar_bookmark_event_parser.rs @@ -0,0 +1,148 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; + +use prose_core_client::app::event_handlers::{ServerEvent, SidebarBookmarkEvent}; +use prose_core_client::domain::shared::models::RoomId; +use prose_core_client::domain::sidebar::models::{Bookmark, BookmarkType}; +use prose_core_client::room_id; +use prose_core_client::test::parse_xml; +use prose_proc_macros::mt_test; + +#[mt_test] +async fn test_added_or_updated_items() -> Result<()> { + // Notification With Payload (https://xmpp.org/extensions/xep-0060.html#publisher-publish-success-withpayload) + let events = + parse_xml( + r#" + + + + + + + + + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::SidebarBookmark( + SidebarBookmarkEvent::AddedOrUpdated { + bookmarks: vec![ + Bookmark { + name: "Private Channel".to_string(), + jid: room_id!("pc@conference.prose.org"), + r#type: BookmarkType::PrivateChannel, + is_favorite: true, + in_sidebar: true, + }, + Bookmark { + name: "Group".to_string(), + jid: room_id!("group@conference.prose.org"), + r#type: BookmarkType::Group, + is_favorite: false, + in_sidebar: false, + }, + Bookmark { + name: "Direct Message".to_string(), + jid: room_id!("user@prose.org"), + r#type: BookmarkType::DirectMessage, + is_favorite: false, + in_sidebar: true, + } + ] + } + )] + ); + + Ok(()) +} + +#[mt_test] +async fn test_deleted_items() -> Result<()> { + // Delete And Notify (https://xmpp.org/extensions/xep-0060.html#example-119) + let events = parse_xml( + r#" + + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::SidebarBookmark( + SidebarBookmarkEvent::Deleted { + ids: vec![ + room_id!("pc@conference.prose.org"), + room_id!("user@prose.org"), + ] + } + )] + ); + + Ok(()) +} + +#[mt_test] +async fn test_pubsub_node_purged() -> Result<()> { + // Purge All Node Items (https://xmpp.org/extensions/xep-0060.html#example-166) + let events = parse_xml( + r#" + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::SidebarBookmark(SidebarBookmarkEvent::Purged)] + ); + + Ok(()) +} + +#[mt_test] +async fn test_pubsub_node_deleted() -> Result<()> { + // Delete a node (https://xmpp.org/extensions/xep-0060.html#owner-delete) + let events = parse_xml( + r#" + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::SidebarBookmark(SidebarBookmarkEvent::Purged)] + ); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/event_parsing/user_event_parser.rs b/crates/prose-core-client/tests/event_parsing/user_event_parser.rs new file mode 100644 index 00000000..4b7af436 --- /dev/null +++ b/crates/prose-core-client/tests/event_parsing/user_event_parser.rs @@ -0,0 +1,312 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use anyhow::Result; + +use prose_core_client::app::event_handlers::{ + ServerEvent, UserInfoEvent, UserInfoEventType, UserResourceEvent, UserResourceEventType, + UserStatusEvent, UserStatusEventType, +}; +use prose_core_client::domain::rooms::models::ComposeState; +use prose_core_client::domain::shared::models::CapabilitiesId; +use prose_core_client::domain::user_info::models::{AvatarImageId, AvatarMetadata}; +use prose_core_client::dtos::*; +use prose_core_client::test::parse_xml; +use prose_core_client::{occupant_id, user_id, user_resource_id}; +use prose_proc_macros::mt_test; + +#[mt_test] +async fn test_user_presence_and_capabilities_changed() -> Result<()> { + // Initial unavailable presence + let events = parse_xml( + r#" + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserStatus(UserStatusEvent { + user_id: user_id!("user@prose.org").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Unavailable, + priority: 0 + }, + })] + ); + + // User comes online + // https://xmpp.org/extensions/xep-0115.html + let events = parse_xml( + r#" + + 5 + chat + + + cdc05cb9c48d5e817a36d462fe0470a0579e570a + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: user_resource_id!("user@prose.org/res").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 5 + }, + }), + ServerEvent::UserResource(UserResourceEvent { + user_id: user_resource_id!("user@prose.org/res"), + r#type: UserResourceEventType::CapabilitiesChanged { + id: CapabilitiesId::from("https://prose.org#ImujI7nqf7pn4YqcjefXE3o5P1k=") + }, + }) + ] + ); + + // Exiting a Room (https://xmpp.org/extensions/xep-0045.html#exit) + let events = parse_xml( + r#" + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Unavailable, + priority: 0 + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_compose_state_changed() -> Result<()> { + // XEP-0085: Chat State Notifications (https://xmpp.org/extensions/xep-0085.html#top) + let events = parse_xml( + r#" + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@prose.org/user").into(), + r#type: UserStatusEventType::ComposeStateChanged { + state: ComposeState::Composing + }, + })] + ); + + let events = parse_xml( + r#" + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserStatus(UserStatusEvent { + user_id: user_resource_id!("user@prose.org/res").into(), + r#type: UserStatusEventType::ComposeStateChanged { + state: ComposeState::Idle + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_avatar_changed() -> Result<()> { + // XEP-0084: User Avatar + // https://xmpp.org/extensions/xep-0084.html + + let events = parse_xml( + r#" + + + + + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserInfo(UserInfoEvent { + user_id: user_id!("user@prose.org").into(), + r#type: UserInfoEventType::AvatarChanged { + metadata: AvatarMetadata { + bytes: 61501, + mime_type: "image/jpeg".to_string(), + checksum: AvatarImageId::from("fa3c5706e27f6a0093981bb315015c2bd93e094e"), + width: None, + height: None, + url: None, + }, + }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_profile_changed() -> Result<()> { + // XEP-0292: vCard4 Over XMPP + // https://xmpp.org/extensions/xep-0292.html + + let events = parse_xml( + r#" + + + + + + + DE + Berlin + + + user@prose.org + + + Jane + Doe + + + Prose Foundation + + + <text>Developer</text> + + + + + + + "#, + ) + .await?; + + let mut profile = UserProfile::default(); + profile.address = Some(Address { + locality: Some("Berlin".to_string()), + country: Some("DE".to_string()), + }); + profile.email = Some("user@prose.org".to_string()); + profile.first_name = Some("Jane".to_string()); + profile.last_name = Some("Doe".to_string()); + profile.org = Some("Prose Foundation".to_string()); + profile.title = Some("Developer".to_string()); + + assert_eq!( + events, + vec![ServerEvent::UserInfo(UserInfoEvent { + user_id: user_id!("user@prose.org").into(), + r#type: UserInfoEventType::ProfileChanged { profile }, + })] + ); + + Ok(()) +} + +#[mt_test] +async fn test_status_changed() -> Result<()> { + // XEP-0108: User Activity + // https://xmpp.org/extensions/xep-0108.html + + let events = parse_xml( + r#" + + + + + + + 🍕 + + Eating pizza + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserInfo(UserInfoEvent { + user_id: user_id!("user@prose.org").into(), + r#type: UserInfoEventType::StatusChanged { + status: Some(UserStatus { + emoji: "🍕".to_string(), + status: Some("Eating pizza".to_string()) + }) + }, + })] + ); + + let events = parse_xml( + r#" + + + + + + + + + + "#, + ) + .await?; + + assert_eq!( + events, + vec![ServerEvent::UserInfo(UserInfoEvent { + user_id: user_id!("user@prose.org").into(), + r#type: UserInfoEventType::StatusChanged { status: None }, + })] + ); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/in_memory_sidebar_repository.rs b/crates/prose-core-client/tests/in_memory_sidebar_repository.rs index 6cc57cb5..6f23f50f 100644 --- a/crates/prose-core-client/tests/in_memory_sidebar_repository.rs +++ b/crates/prose-core-client/tests/in_memory_sidebar_repository.rs @@ -5,25 +5,25 @@ use anyhow::Result; -use prose_core_client::domain::shared::models::RoomJid; +use prose_core_client::domain::shared::models::RoomId; use prose_core_client::domain::sidebar::models::{BookmarkType, SidebarItem}; use prose_core_client::domain::sidebar::repos::{SidebarReadOnlyRepository, SidebarRepository}; use prose_core_client::infra::sidebar::InMemorySidebarRepository; -use prose_core_client::room; +use prose_core_client::room_id; #[tokio::test] async fn test_put_sidebar_item() -> Result<()> { let repo = InMemorySidebarRepository::new(); repo.put(&SidebarItem { name: "A".to_string(), - jid: room!("a@prose.org"), + jid: room_id!("a@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, }); repo.put(&SidebarItem { name: "B".to_string(), - jid: room!("b@prose.org"), + jid: room_id!("b@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, @@ -34,14 +34,14 @@ async fn test_put_sidebar_item() -> Result<()> { vec![ SidebarItem { name: "A".to_string(), - jid: room!("a@prose.org"), + jid: room_id!("a@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, }, SidebarItem { name: "B".to_string(), - jid: room!("b@prose.org"), + jid: room_id!("b@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, @@ -51,7 +51,7 @@ async fn test_put_sidebar_item() -> Result<()> { repo.put(&SidebarItem { name: "C".to_string(), - jid: room!("b@prose.org"), + jid: room_id!("b@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, @@ -62,14 +62,14 @@ async fn test_put_sidebar_item() -> Result<()> { vec![ SidebarItem { name: "A".to_string(), - jid: room!("a@prose.org"), + jid: room_id!("a@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, }, SidebarItem { name: "C".to_string(), - jid: room!("b@prose.org"), + jid: room_id!("b@prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, diff --git a/crates/prose-core-client/tests/messages_event_handler.rs b/crates/prose-core-client/tests/messages_event_handler.rs index 761106aa..f66f1a31 100644 --- a/crates/prose-core-client/tests/messages_event_handler.rs +++ b/crates/prose-core-client/tests/messages_event_handler.rs @@ -9,36 +9,39 @@ use anyhow::Result; use mockall::predicate; use xmpp_parsers::message::MessageType; -use prose_core_client::app::event_handlers::{MessagesEventHandler, XMPPEvent, XMPPEventHandler}; +use prose_core_client::app::event_handlers::{ + MessageEvent, MessageEventType, MessagesEventHandler, ServerEvent, ServerEventHandler, +}; use prose_core_client::domain::rooms::models::RoomInternals; use prose_core_client::domain::rooms::services::CreateOrEnterRoomRequest; -use prose_core_client::domain::shared::models::RoomJid; +use prose_core_client::domain::shared::models::{RoomId, UserId}; +use prose_core_client::dtos::Availability; use prose_core_client::test::MockAppDependencies; -use prose_core_client::{room, RoomEventType}; -use prose_xmpp::mods::chat; +use prose_core_client::{room_id, user_id, ClientRoomEventType}; +use prose_xmpp::jid; use prose_xmpp::stanza::Message; -use prose_xmpp::{bare, jid}; #[tokio::test] async fn test_receiving_message_adds_item_to_sidebar_if_needed() -> Result<()> { let mut deps = MockAppDependencies::default(); - let room = - Arc::new(RoomInternals::group(room!("group@conference.prose.org")).with_name("Group Name")); + let room = Arc::new( + RoomInternals::group(room_id!("group@conference.prose.org")).with_name("Group Name"), + ); { let room = room.clone(); deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("group@conference.prose.org"))) + .with(predicate::eq(room_id!("group@conference.prose.org"))) .return_once(|_| Some(room)); } deps.sidebar_domain_service .expect_insert_item_for_received_message_if_needed() .once() - .with(predicate::eq(room!("group@conference.prose.org"))) + .with(predicate::eq(room_id!("group@conference.prose.org"))) .return_once(|_| Box::pin(async { Ok(()) })); deps.messages_repo @@ -56,7 +59,7 @@ async fn test_receiving_message_adds_item_to_sidebar_if_needed() -> Result<()> { .once() .with( predicate::eq(room), - predicate::eq(RoomEventType::MessagesAppended { + predicate::eq(ClientRoomEventType::MessagesAppended { message_ids: vec!["message-id".into()], }), ) @@ -64,12 +67,14 @@ async fn test_receiving_message_adds_item_to_sidebar_if_needed() -> Result<()> { let event_handler = MessagesEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Chat(chat::Event::Message( - Message::default() - .set_id("message-id".into()) - .set_from(jid!("group@conference.prose.org/jane.doe")) - .set_body("Hello World"), - ))) + .handle_event(ServerEvent::Message(MessageEvent { + r#type: MessageEventType::Received( + Message::default() + .set_id("message-id".into()) + .set_from(jid!("group@conference.prose.org/jane.doe")) + .set_body("Hello World"), + ), + })) .await?; Ok(()) @@ -79,35 +84,38 @@ async fn test_receiving_message_adds_item_to_sidebar_if_needed() -> Result<()> { async fn test_receiving_message_from_new_contact_creates_room() -> Result<()> { let mut deps = MockAppDependencies::default(); - let room = Arc::new(RoomInternals::direct_message(bare!("jane.doe@prose.org"))); + let room = Arc::new(RoomInternals::direct_message( + user_id!("jane.doe@prose.org"), + &Availability::Unavailable, + )); deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("jane.doe@prose.org"))) + .with(predicate::eq(room_id!("jane.doe@prose.org"))) .return_once(|_| None); deps.sidebar_domain_service .expect_insert_item_by_creating_or_joining_room() .once() .with(predicate::eq(CreateOrEnterRoomRequest::JoinDirectMessage { - participant: bare!("jane.doe@prose.org"), + participant: user_id!("jane.doe@prose.org"), })) - .return_once(|_| Box::pin(async { Ok(room!("jane.doe@prose.org")) })); + .return_once(|_| Box::pin(async { Ok(room_id!("jane.doe@prose.org")) })); { let room = room.clone(); deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("jane.doe@prose.org"))) + .with(predicate::eq(room_id!("jane.doe@prose.org"))) .return_once(|_| Some(room)); } deps.sidebar_domain_service .expect_insert_item_for_received_message_if_needed() .once() - .with(predicate::eq(room!("jane.doe@prose.org"))) + .with(predicate::eq(room_id!("jane.doe@prose.org"))) .return_once(|_| Box::pin(async { Ok(()) })); deps.messages_repo @@ -125,7 +133,7 @@ async fn test_receiving_message_from_new_contact_creates_room() -> Result<()> { .once() .with( predicate::eq(room), - predicate::eq(RoomEventType::MessagesAppended { + predicate::eq(ClientRoomEventType::MessagesAppended { message_ids: vec!["message-id".into()], }), ) @@ -133,13 +141,15 @@ async fn test_receiving_message_from_new_contact_creates_room() -> Result<()> { let event_handler = MessagesEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Chat(chat::Event::Message( - Message::default() - .set_type(MessageType::Chat) - .set_id("message-id".into()) - .set_from(jid!("jane.doe@prose.org")) - .set_body("Hello World"), - ))) + .handle_event(ServerEvent::Message(MessageEvent { + r#type: MessageEventType::Received( + Message::default() + .set_type(MessageType::Chat) + .set_id("message-id".into()) + .set_from(jid!("jane.doe@prose.org")) + .set_body("Hello World"), + ), + })) .await?; Ok(()) diff --git a/crates/prose-core-client/tests/requests_event_handler.rs b/crates/prose-core-client/tests/requests_event_handler.rs index fa14b2f5..5fed592d 100644 --- a/crates/prose-core-client/tests/requests_event_handler.rs +++ b/crates/prose-core-client/tests/requests_event_handler.rs @@ -9,12 +9,16 @@ use anyhow::Result; use chrono::{TimeZone, Utc}; use mockall::predicate; -use prose_core_client::app::event_handlers::{RequestsEventHandler, XMPPEvent, XMPPEventHandler}; +use prose_core_client::app::event_handlers::{ + RequestEvent, RequestEventType, RequestsEventHandler, ServerEvent, ServerEventHandler, +}; use prose_core_client::domain::general::models::{Capabilities, Feature}; use prose_core_client::domain::general::services::SubscriptionResponse; +use prose_core_client::domain::shared::models::{CapabilitiesId, RequestId, SenderId}; use prose_core_client::dtos::SoftwareVersion; +use prose_core_client::sender_id; use prose_core_client::test::{ConstantTimeProvider, MockAppDependencies}; -use prose_xmpp::{bare, jid, mods, ns}; +use prose_xmpp::ns; #[tokio::test] async fn test_handles_ping() -> Result<()> { @@ -24,16 +28,17 @@ async fn test_handles_ping() -> Result<()> { .expect_respond_to_ping() .once() .with( - predicate::eq(jid!("sender@prose.org")), - predicate::eq("request-id"), + predicate::eq(sender_id!("sender@prose.org")), + predicate::eq(RequestId::from("request-id")), ) .return_once(|_, _| Box::pin(async { Ok(()) })); let event_handler = RequestsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Ping(mods::ping::Event::Ping { - from: jid!("sender@prose.org"), - id: "request-id".to_string(), + .handle_event(ServerEvent::Request(RequestEvent { + sender_id: sender_id!("sender@prose.org"), + request_id: RequestId::from("request-id"), + r#type: RequestEventType::Ping, })) .await?; @@ -49,17 +54,18 @@ async fn test_handles_entity_time_query() -> Result<()> { .expect_respond_to_entity_time_request() .once() .with( - predicate::eq(jid!("sender@prose.org")), - predicate::eq("my-request"), + predicate::eq(sender_id!("sender@prose.org")), + predicate::eq(RequestId::from("my-request")), predicate::eq(Utc.with_ymd_and_hms(2023, 09, 10, 0, 0, 0).unwrap()), ) .return_once(|_, _, _| Box::pin(async { Ok(()) })); let event_handler = RequestsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Profile(mods::profile::Event::EntityTimeQuery { - from: jid!("sender@prose.org"), - id: "my-request".to_string(), + .handle_event(ServerEvent::Request(RequestEvent { + sender_id: sender_id!("sender@prose.org"), + request_id: RequestId::from("my-request"), + r#type: RequestEventType::LocalTime, })) .await?; @@ -79,8 +85,8 @@ async fn test_handles_software_version_query() -> Result<()> { .expect_respond_to_software_version_request() .once() .with( - predicate::eq(jid!("sender@prose.org")), - predicate::eq("my-request"), + predicate::eq(sender_id!("sender@prose.org")), + predicate::eq(RequestId::from("my-request")), predicate::eq(SoftwareVersion { name: "my-client".to_string(), version: "3000".to_string(), @@ -91,12 +97,11 @@ async fn test_handles_software_version_query() -> Result<()> { let event_handler = RequestsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Profile( - mods::profile::Event::SoftwareVersionQuery { - from: jid!("sender@prose.org"), - id: "my-request".to_string(), - }, - )) + .handle_event(ServerEvent::Request(RequestEvent { + sender_id: sender_id!("sender@prose.org"), + request_id: RequestId::from("my-request"), + r#type: RequestEventType::SoftwareVersion, + })) .await?; Ok(()) @@ -115,20 +120,19 @@ async fn test_handles_last_activity_request() -> Result<()> { .expect_respond_to_last_activity_request() .once() .with( - predicate::eq(jid!("sender@prose.org")), - predicate::eq("my-request"), + predicate::eq(sender_id!("sender@prose.org")), + predicate::eq(RequestId::from("my-request")), predicate::eq(0), ) .return_once(|_, _, _| Box::pin(async { Ok(()) })); let event_handler = RequestsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Profile( - mods::profile::Event::LastActivityQuery { - from: jid!("sender@prose.org"), - id: "my-request".to_string(), - }, - )) + .handle_event(ServerEvent::Request(RequestEvent { + sender_id: sender_id!("sender@prose.org"), + request_id: RequestId::from("my-request"), + r#type: RequestEventType::LastActivity, + })) .await?; Ok(()) @@ -147,8 +151,8 @@ async fn test_handles_disco_request() -> Result<()> { .expect_respond_to_disco_info_query() .once() .with( - predicate::eq(jid!("sender@prose.org")), - predicate::eq("my-request"), + predicate::eq(sender_id!("sender@prose.org")), + predicate::eq(RequestId::from("my-request")), predicate::eq(Capabilities::new( "My Client", "https://example.com", @@ -159,10 +163,12 @@ async fn test_handles_disco_request() -> Result<()> { let event_handler = RequestsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Caps(mods::caps::Event::DiscoInfoQuery { - from: jid!("sender@prose.org"), - id: "my-request".to_string(), - node: None, + .handle_event(ServerEvent::Request(RequestEvent { + sender_id: sender_id!("sender@prose.org"), + request_id: RequestId::from("my-request"), + r#type: RequestEventType::Capabilities { + id: CapabilitiesId::from("caps-id"), + }, })) .await?; @@ -182,18 +188,18 @@ async fn test_handles_presence_subscription_request() -> Result<()> { .expect_respond_to_presence_subscription_request() .once() .with( - predicate::eq(bare!("sender@prose.org")), + predicate::eq(sender_id!("sender@prose.org")), predicate::eq(SubscriptionResponse::Approve), ) .return_once(|_, _| Box::pin(async { Ok(()) })); let event_handler = RequestsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Roster( - mods::roster::Event::PresenceSubscriptionRequest { - from: bare!("sender@prose.org"), - }, - )) + .handle_event(ServerEvent::Request(RequestEvent { + sender_id: sender_id!("sender@prose.org"), + request_id: RequestId::from(""), + r#type: RequestEventType::PresenceSubscription, + })) .await?; Ok(()) diff --git a/crates/prose-core-client/tests/room.rs b/crates/prose-core-client/tests/room.rs index daa3a291..3cc19345 100644 --- a/crates/prose-core-client/tests/room.rs +++ b/crates/prose-core-client/tests/room.rs @@ -11,13 +11,12 @@ use xmpp_parsers::mam::Fin; use xmpp_parsers::rsm::SetResult; use prose_core_client::domain::messaging::models::MessageLikePayload; -use prose_core_client::domain::rooms::models::RoomInternals; +use prose_core_client::domain::rooms::models::{RegisteredMember, RoomAffiliation, RoomInternals}; use prose_core_client::domain::rooms::services::RoomFactory; -use prose_core_client::domain::shared::models::RoomJid; -use prose_core_client::domain::shared::models::RoomType; -use prose_core_client::dtos::{Member, Occupant}; -use prose_core_client::room; +use prose_core_client::domain::shared::models::{OccupantId, RoomId, RoomType, UserId}; +use prose_core_client::dtos::Participant; use prose_core_client::test::{mock_data, MessageBuilder, MockRoomFactoryDependencies}; +use prose_core_client::{occupant_id, room_id, user_id}; use prose_xmpp::stanza::message::MucUser; use prose_xmpp::{bare, jid}; @@ -25,22 +24,22 @@ use prose_xmpp::{bare, jid}; async fn test_load_messages_with_ids_resolves_real_jids() -> Result<()> { let mut deps = MockRoomFactoryDependencies::default(); - let internals = RoomInternals::group(room!("room@conference.prose.org")) - .with_members([( - bare!("a@prose.org"), - Member { - name: "Aron Doe".to_string(), - }, - )]) - .with_occupants([( - jid!("room@conference.prose.org/b"), - Occupant::owner().set_name("Bernhard Doe"), + let internals = RoomInternals::group(room_id!("room@conference.prose.org")) + .with_members([RegisteredMember { + user_id: user_id!("a@prose.org"), + name: Some("Aron Doe".to_string()), + affiliation: RoomAffiliation::Owner, + is_self: false, + }]) + .with_participants([( + occupant_id!("room@conference.prose.org/b"), + Participant::owner().set_name("Bernhard Doe"), )]); deps.user_profile_repo .expect_get_display_name() .once() - .with(predicate::eq(bare!("c@prose.org"))) + .with(predicate::eq(user_id!("c@prose.org"))) .return_once(|_| Box::pin(async { Ok(Some("Carl Doe".to_string())) })); deps.message_repo @@ -103,22 +102,22 @@ async fn test_load_messages_with_ids_resolves_real_jids() -> Result<()> { async fn test_load_latest_messages_resolves_real_jids() -> Result<()> { let mut deps = MockRoomFactoryDependencies::default(); - let internals = RoomInternals::group(room!("room@conference.prose.org")) - .with_members([( - bare!("a@prose.org"), - Member { - name: "Aron Doe".to_string(), - }, - )]) - .with_occupants([( - jid!("room@conference.prose.org/b"), - Occupant::owner().set_name("Bernhard Doe"), + let internals = RoomInternals::group(room_id!("room@conference.prose.org")) + .with_members([RegisteredMember { + user_id: user_id!("a@prose.org"), + name: Some("Aron Doe".to_string()), + affiliation: RoomAffiliation::Owner, + is_self: false, + }]) + .with_participants([( + occupant_id!("room@conference.prose.org/b"), + Participant::owner().set_name("Bernhard Doe"), )]); deps.user_profile_repo .expect_get_display_name() .once() - .with(predicate::eq(bare!("c@prose.org"))) + .with(predicate::eq(user_id!("c@prose.org"))) .return_once(|_| Box::pin(async { Ok(Some("Carl Doe".to_string())) })); deps.message_archive_service @@ -241,7 +240,7 @@ async fn test_toggle_reaction() -> Result<()> { ) .return_once(|_, _, _, _| Box::pin(async { Ok(()) })); - let internals = RoomInternals::group(room!("room@conference.prose.org")); + let internals = RoomInternals::group(room_id!("room@conference.prose.org")); let room = RoomFactory::from(deps) .build(Arc::new(internals)) @@ -261,14 +260,15 @@ async fn test_renames_channel_in_sidebar() -> Result<()> { .expect_rename_item() .once() .with( - predicate::eq(room!("room@conference.prose.org")), + predicate::eq(room_id!("room@conference.prose.org")), predicate::eq("New Name"), ) .return_once(|_, _| Box::pin(async { Ok(()) })); let room = RoomFactory::from(deps) .build(Arc::new( - RoomInternals::public_channel(room!("room@conference.prose.org")).with_name("Old Name"), + RoomInternals::public_channel(room_id!("room@conference.prose.org")) + .with_name("Old Name"), )) .to_generic_room(); diff --git a/crates/prose-core-client/tests/rooms_domain_service.rs b/crates/prose-core-client/tests/rooms_domain_service.rs index dedbec70..965fdcd1 100644 --- a/crates/prose-core-client/tests/rooms_domain_service.rs +++ b/crates/prose-core-client/tests/rooms_domain_service.rs @@ -3,22 +3,320 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use std::sync::Arc; +use std::sync::{Arc, OnceLock}; use anyhow::Result; -use mockall::predicate; +use mockall::{predicate, Sequence}; +use parking_lot::Mutex; -use prose_core_client::domain::rooms::models::{RoomError, RoomInternals, RoomSessionInfo}; +use prose_core_client::app::event_handlers::{ + OccupantEvent, OccupantEventType, RoomsEventHandler, ServerEvent, ServerEventHandler, + UserStatusEvent, UserStatusEventType, +}; +use prose_core_client::domain::connection::models::{ConnectionProperties, ServerFeatures}; +use prose_core_client::domain::rooms::models::{ + RegisteredMember, RoomAffiliation, RoomConfig, RoomError, RoomInfo, RoomInternals, + RoomSessionInfo, RoomSessionMember, RoomSpec, +}; use prose_core_client::domain::rooms::services::impls::RoomsDomainService; use prose_core_client::domain::rooms::services::{ CreateOrEnterRoomRequest, CreateRoomType, RoomsDomainService as RoomsDomainServiceTrait, }; -use prose_core_client::domain::shared::models::{RoomJid, RoomType}; -use prose_core_client::dtos::PublicRoomInfo; -use prose_core_client::room; -use prose_core_client::test::{mock_data, MockRoomsDomainServiceDependencies}; +use prose_core_client::domain::shared::models::{OccupantId, RoomId, RoomType, UserResourceId}; +use prose_core_client::dtos::{ + Availability, Participant, ParticipantInfo, PublicRoomInfo, UserId, UserInfo, UserProfile, +}; +use prose_core_client::test::{mock_data, MockAppDependencies, MockRoomsDomainServiceDependencies}; +use prose_core_client::{occupant_id, room_id, user_id, user_resource_id}; +use prose_xmpp::bare; use prose_xmpp::test::IncrementingIDProvider; +#[tokio::test] +async fn test_joins_room() -> Result<()> { + // This test simulates the process of joining a room. It also simulates the received presence + // events from online occupants and sends these to the RoomsEventHandler to make sure that + // we don't have duplicate participants afterwards (registered members & occupants). + + let mut deps = MockRoomsDomainServiceDependencies::default(); + let mut seq = Sequence::new(); + let event_handler = Arc::new(OnceLock::::new()); + + let room = Arc::new(Mutex::new(Arc::new(RoomInternals::pending( + &room_id!("room@conf.prose.org"), + "user1#dXNlcjFAcHJvc2Uub3Jn", + )))); + + deps.ctx.set_connection_properties(ConnectionProperties { + connected_jid: user_resource_id!("user1@prose.org/res"), + server_features: Default::default(), + }); + + deps.connected_rooms_repo + .expect_set() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room.lock().clone())) + .return_once(|_| Ok(())); + + let events = vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@conf.prose.org/user1#dXNlcjFAcHJvc2Uub3Jn").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0, + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conf.prose.org/user1#dXNlcjFAcHJvc2Uub3Jn"), + anon_occupant_id: None, + real_id: Some(user_id!("user1@prose.org")), + is_self: true, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::Owner, + }, + }), + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@conf.prose.org/user2#dXNlcjJAcHJvc2Uub3Jn").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0, + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conf.prose.org/user2#dXNlcjJAcHJvc2Uub3Jn"), + anon_occupant_id: None, + real_id: Some(user_id!("user2@prose.org")), + is_self: false, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::Member, + }, + }), + ]; + + { + let event_handler = event_handler.clone(); + deps.room_management_service + .expect_join_room() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(occupant_id!( + "room@conf.prose.org/user1#dXNlcjFAcHJvc2Uub3Jn" + )), + predicate::always(), + ) + .return_once(|_, _| { + Box::pin(async move { + let event_handler = event_handler.get().unwrap(); + + for event in events { + event_handler + .handle_event(event) + .await + .expect("Unexpected error"); + } + + Ok(RoomSessionInfo { + room_id: room_id!("room@conf.prose.org"), + config: RoomConfig { + room_name: Some("Room Name".to_string()), + room_description: None, + room_type: RoomType::PrivateChannel, + }, + user_nickname: "user#dXNlcjFAcHJvc2Uub3Jn".to_string(), + members: vec![ + RoomSessionMember { + id: user_id!("user1@prose.org"), + affiliation: RoomAffiliation::Owner, + }, + RoomSessionMember { + id: user_id!("user2@prose.org"), + affiliation: RoomAffiliation::Member, + }, + RoomSessionMember { + id: user_id!("user3@prose.org"), + affiliation: RoomAffiliation::Member, + }, + ], + room_has_been_created: false, + }) + }) + }); + } + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .times(6) + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .returning(move |_| Some(room.lock().clone())); + } + + deps.client_event_dispatcher + .expect_dispatch_room_event() + .times(3) + .returning(|_, _| ()); + + deps.user_profile_repo + .expect_get_display_name() + .times(6) + .with(predicate::in_iter([ + user_id!("user1@prose.org"), + user_id!("user2@prose.org"), + user_id!("user3@prose.org"), + ])) + .returning(|user_id| { + let username = user_id.formatted_username(); + Box::pin(async move { Ok(Some(username)) }) + }); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_update() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(room_id!("room@conf.prose.org")), + predicate::always(), + ) + .return_once(move |_, handler| { + let updated_room = Arc::new(handler(room.lock().clone())); + *room.lock() = updated_room.clone(); + Some(updated_room) + }); + } + + let rooms_deps = deps.into_deps(); + let service = Arc::new(RoomsDomainService::from(rooms_deps.clone())); + + let mut deps = MockAppDependencies::default().into_deps(); + deps.rooms_domain_service = service.clone(); + deps.client_event_dispatcher = rooms_deps.client_event_dispatcher.clone(); + deps.connected_rooms_repo = rooms_deps.connected_rooms_repo.clone(); + deps.ctx = rooms_deps.ctx.clone(); + deps.id_provider = rooms_deps.id_provider.clone(); + deps.room_attributes_service = rooms_deps.room_attributes_service.clone(); + deps.room_management_service = rooms_deps.room_management_service.clone(); + deps.room_participation_service = rooms_deps.room_participation_service.clone(); + deps.user_profile_repo = rooms_deps.user_profile_repo.clone(); + + event_handler + .set(RoomsEventHandler::from(&deps)) + .map_err(|_| ()) + .unwrap(); + + service + .create_or_join_room(CreateOrEnterRoomRequest::JoinRoom { + room_jid: room_id!("room@conf.prose.org"), + password: None, + }) + .await?; + + let mut participants = room + .lock() + .participants() + .iter() + .map(ParticipantInfo::from) + .collect::>(); + participants.sort_by_key(|p| p.name.clone()); + + assert_eq!( + participants, + vec![ + ParticipantInfo { + id: Some(user_id!("user1@prose.org")), + name: "User1".to_string(), + is_self: true, + availability: Availability::Available, + affiliation: RoomAffiliation::Owner + }, + ParticipantInfo { + id: Some(user_id!("user2@prose.org")), + name: "User2".to_string(), + is_self: false, + availability: Availability::Available, + affiliation: RoomAffiliation::Member + }, + ParticipantInfo { + id: Some(user_id!("user3@prose.org")), + name: "User3".to_string(), + is_self: false, + availability: Availability::Unavailable, + affiliation: RoomAffiliation::Member + } + ] + ); + + // Now simulate user3 coming online… + + let events = vec![ + ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@conf.prose.org/user3#dXNlcjNAcHJvc2Uub3Jn").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0, + }, + }), + ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conf.prose.org/user3#dXNlcjNAcHJvc2Uub3Jn"), + anon_occupant_id: None, + real_id: Some(user_id!("user3@prose.org")), + is_self: false, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::Member, + }, + }), + ]; + + let event_handler = event_handler.get().unwrap(); + + for event in events { + event_handler + .handle_event(event) + .await + .expect("Unexpected error"); + } + + let mut participants = room + .lock() + .participants() + .iter() + .map(ParticipantInfo::from) + .collect::>(); + participants.sort_by_key(|p| p.name.clone()); + + assert_eq!( + participants, + vec![ + ParticipantInfo { + id: Some(user_id!("user1@prose.org")), + name: "User1".to_string(), + is_self: true, + availability: Availability::Available, + affiliation: RoomAffiliation::Owner + }, + ParticipantInfo { + id: Some(user_id!("user2@prose.org")), + name: "User2".to_string(), + is_self: false, + availability: Availability::Available, + affiliation: RoomAffiliation::Member + }, + ParticipantInfo { + id: Some(user_id!("user3@prose.org")), + name: "User3".to_string(), + is_self: false, + availability: Availability::Available, + affiliation: RoomAffiliation::Member + } + ] + ); + + Ok(()) +} + #[tokio::test] async fn test_throws_conflict_error_if_room_exists() -> Result<()> { let mut deps = MockRoomsDomainServiceDependencies::default(); @@ -29,7 +327,7 @@ async fn test_throws_conflict_error_if_room_exists() -> Result<()> { .return_once(|_| { Box::pin(async { Ok(vec![PublicRoomInfo { - jid: room!("room@conference.prose.org"), + jid: room_id!("room@conference.prose.org"), name: Some("new channel".to_string()), }]) }) @@ -52,11 +350,308 @@ async fn test_throws_conflict_error_if_room_exists() -> Result<()> { Ok(()) } +#[tokio::test] +async fn test_creates_group() -> Result<()> { + let mut deps = MockRoomsDomainServiceDependencies::default(); + deps.id_provider = Arc::new(IncrementingIDProvider::new("hash")); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + deps.ctx.set_connection_properties(ConnectionProperties { + connected_jid: user_resource_id!("jane.doe@prose.org/macOS"), + server_features: Default::default(), + }); + + // jane.doe@prose.org + a@prose.org + b@prose.org + c@prose.org + let group_id = + room_id!("org.prose.group.b41be06eda5bac6e7fc5ad069d6cd863c4f329eb@conference.prose.org"); + let occupant_id = group_id + .occupant_id_with_nickname("jane.doe#amFuZS5kb2VAcHJvc2Uub3Jn") + .unwrap(); + + let account_node = mock_data::account_jid().to_user_id().username().to_string(); + + { + let account_node = account_node.clone(); + deps.user_profile_repo + .expect_get() + .times(4) + .in_sequence(&mut seq) + .returning(move |jid| { + let jid = jid.clone(); + let account_node = account_node.clone(); + + Box::pin(async move { + let first_name = match jid.username() { + _ if jid.username() == &account_node => "Jane", + "a" => "Tick", + "b" => "Trick", + "c" => "Track", + _ => panic!("Unexpected JID"), + }; + + let mut user_profile = UserProfile::default(); + user_profile.first_name = Some(first_name.to_string()); + + Ok(Some(user_profile)) + }) + }); + } + + deps.connected_rooms_repo + .expect_set() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(Arc::new(RoomInternals::pending( + &group_id, + "jane.doe#amFuZS5kb2VAcHJvc2Uub3Jn", + )))) + .return_once(|_| Ok(())); + { + let group_jid = group_id.clone(); + deps.room_management_service + .expect_create_or_join_room() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(occupant_id), + predicate::eq("Jane, Tick, Track, Trick"), + predicate::eq(RoomSpec::Group), + ) + .return_once(|_, _, _| { + Box::pin(async { + Ok( + RoomSessionInfo::new_room(group_jid, RoomType::Group).with_members(vec![ + RoomSessionMember { + id: mock_data::account_jid().into_user_id(), + affiliation: RoomAffiliation::Owner, + }, + ]), + ) + }) + }); + } + + deps.room_management_service + .expect_set_room_owners() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(group_id.clone()), + predicate::eq(vec![ + user_id!("a@prose.org"), + user_id!("b@prose.org"), + user_id!("c@prose.org"), + mock_data::account_jid().into_user_id(), + ]), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + deps.user_profile_repo + .expect_get_display_name() + .times(4) + .in_sequence(&mut seq) + .returning(move |jid| { + let jid = jid.clone(); + let account_node = account_node.clone(); + + Box::pin(async move { + let first_name = match jid.username() { + _ if jid.username() == &account_node => "Jane", + "a" => "Tick", + "b" => "Trick", + "c" => "Track", + _ => panic!("Unexpected JID"), + }; + + Ok(Some(first_name.to_string())) + }) + }); + + { + let group_jid = group_id.clone(); + deps.connected_rooms_repo + .expect_update() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(group_jid.clone()), predicate::always()) + .return_once(move |_, handler| { + let room = Arc::new(RoomInternals::mock_pending_room( + group_jid.clone(), + "hash-1", + )); + + let room = handler(room.clone()); + let mut members = room.participants().iter().cloned().collect::>(); + members.sort_by_key(|p| p.real_id.as_ref().unwrap().clone()); + + assert_eq!( + members, + vec![ + Participant { + real_id: Some(user_id!("a@prose.org")), + anon_occupant_id: None, + name: Some("Tick".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + availability: Default::default(), + compose_state: Default::default(), + compose_state_updated: Default::default(), + }, + Participant { + real_id: Some(user_id!("b@prose.org")), + anon_occupant_id: None, + name: Some("Trick".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + availability: Default::default(), + compose_state: Default::default(), + compose_state_updated: Default::default(), + }, + Participant { + real_id: Some(user_id!("c@prose.org")), + anon_occupant_id: None, + name: Some("Track".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + availability: Default::default(), + compose_state: Default::default(), + compose_state_updated: Default::default(), + }, + Participant { + real_id: Some(user_id!("jane.doe@prose.org")), + anon_occupant_id: None, + name: Some("Jane".to_string()), + is_self: true, + affiliation: RoomAffiliation::Owner, + availability: Default::default(), + compose_state: Default::default(), + compose_state_updated: Default::default(), + } + ] + ); + + Some(Arc::new(room)) + }); + } + + deps.room_participation_service + .expect_invite_users_to_room() + .once() + .with( + predicate::eq(group_id.clone()), + predicate::eq(vec![ + user_id!("a@prose.org"), + user_id!("b@prose.org"), + user_id!("c@prose.org"), + ]), + ) + .returning(|_, _| Box::pin(async { Ok(()) })); + + let service = RoomsDomainService::from(deps.into_deps()); + let result = service + .create_or_join_room(CreateOrEnterRoomRequest::Create { + service: mock_data::muc_service(), + room_type: CreateRoomType::Group { + participants: vec![ + user_id!("a@prose.org"), + user_id!("b@prose.org"), + user_id!("c@prose.org"), + ], + }, + }) + .await; + + assert!(result.is_ok()); + + Ok(()) +} + +#[tokio::test] +async fn test_joins_direct_message() -> Result<()> { + let mut deps = MockRoomsDomainServiceDependencies::default(); + let mut seq = Sequence::new(); + + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("user2@prose.org"))) + .return_once(|_| None); + + deps.user_profile_repo + .expect_get_display_name() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(user_id!("user2@prose.org"))) + .return_once(|_| Box::pin(async { Ok(Some("Jennifer Doe".to_string())) })); + + deps.user_info_repo + .expect_get_user_info() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(user_id!("user2@prose.org"))) + .return_once(|_| { + Box::pin(async { + Ok(Some(UserInfo { + avatar: None, + activity: None, + availability: Availability::Available, + })) + }) + }); + + deps.connected_rooms_repo + .expect_set() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(Arc::new(RoomInternals::for_direct_message( + &user_id!("user2@prose.org"), + "Jennifer Doe", + &Availability::Available, + )))) + .return_once(|_| Ok(())); + + let service = RoomsDomainService::from(deps.into_deps()); + let room = service + .create_or_join_room(CreateOrEnterRoomRequest::JoinDirectMessage { + participant: user_id!("user2@prose.org"), + }) + .await?; + + let mut participants = room + .participants() + .iter() + .map(ParticipantInfo::from) + .collect::>(); + participants.sort_by_key(|p| p.name.clone()); + + assert_eq!( + participants, + vec![ParticipantInfo { + id: Some(user_id!("user2@prose.org")), + name: "Jennifer Doe".to_string(), + is_self: false, + availability: Availability::Available, + affiliation: RoomAffiliation::Owner + },] + ); + + Ok(()) +} + #[tokio::test] async fn test_creates_public_room_if_it_does_not_exist() -> Result<()> { let mut deps = MockRoomsDomainServiceDependencies::default(); deps.id_provider = Arc::new(IncrementingIDProvider::new("hash")); + deps.ctx.set_connection_properties(ConnectionProperties { + connected_jid: user_resource_id!("jane.doe@prose.org/macOS"), + server_features: ServerFeatures { + muc_service: Some(bare!("conference.prose.org")), + }, + }); deps.room_management_service .expect_load_public_rooms() @@ -64,7 +659,7 @@ async fn test_creates_public_room_if_it_does_not_exist() -> Result<()> { .return_once(|_| { Box::pin(async { Ok(vec![PublicRoomInfo { - jid: room!("room@conference.prose.org"), + jid: room_id!("room@conference.prose.org"), name: Some("Old Channel".to_string()), }]) }) @@ -74,9 +669,8 @@ async fn test_creates_public_room_if_it_does_not_exist() -> Result<()> { .expect_set() .once() .with(predicate::eq(Arc::new(RoomInternals::pending( - &room!("org.prose.public-channel.hash-1@conference.prose.org"), - &mock_data::account_jid().into_bare(), - &format!("{}-hash-2", mock_data::account_jid().node_str().unwrap()), + &room_id!("org.prose.channel.hash-1@conference.prose.org"), + "jane.doe#amFuZS5kb2VAcHJvc2Uub3Jn", )))) .return_once(|_| Ok(())); @@ -86,7 +680,7 @@ async fn test_creates_public_room_if_it_does_not_exist() -> Result<()> { .return_once(|_, _, _| { Box::pin(async { Ok(RoomSessionInfo::new_room( - room!("org.prose.public-channel.hash-1@conference.prose.org"), + room_id!("org.prose.channel.hash-1@conference.prose.org"), RoomType::PublicChannel, )) }) @@ -96,15 +690,15 @@ async fn test_creates_public_room_if_it_does_not_exist() -> Result<()> { .expect_update() .once() .with( - predicate::eq(room!( - "org.prose.public-channel.hash-1@conference.prose.org" - )), + predicate::eq(room_id!("org.prose.channel.hash-1@conference.prose.org")), predicate::always(), ) .return_once(|_, _| { - Some(Arc::new(RoomInternals::public_channel(room!( - "org.prose.public-channel.hash-1@conference.prose.org" - )))) + Some(Arc::new(RoomInternals::new(RoomInfo { + room_id: room_id!("org.prose.channel.hash-1@conference.prose.org"), + user_nickname: "jane.doe#amFuZS5kb2VAcHJvc2Uub3Jn".to_string(), + r#type: RoomType::PublicChannel, + }))) }); let service = RoomsDomainService::from(deps.into_deps()); @@ -120,3 +714,295 @@ async fn test_creates_public_room_if_it_does_not_exist() -> Result<()> { assert!(result.is_ok()); Ok(()) } + +#[tokio::test] +async fn test_converts_group_to_private_channel() -> Result<()> { + let mut deps = MockRoomsDomainServiceDependencies::default(); + deps.id_provider = Arc::new(IncrementingIDProvider::new("hash")); + + deps.ctx.set_connection_properties(ConnectionProperties { + connected_jid: user_resource_id!("jane.doe@prose.org/macOS"), + server_features: Default::default(), + }); + + let channel_id = room_id!("org.prose.channel.hash-1@conf.prose.org"); + let occupant_id = channel_id + .occupant_id_with_nickname("jane.doe#amFuZS5kb2VAcHJvc2Uub3Jn") + .unwrap(); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("group@conf.prose.org"))) + .return_once(|_| { + Some(Arc::new( + RoomInternals::group(room_id!("group@conf.prose.org")).with_members(vec![ + RegisteredMember { + user_id: user_id!("jane.doe@prose.org"), + name: Some("Jane Doe".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + }, + RegisteredMember { + user_id: user_id!("a@prose.org"), + name: Some("Member A".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + }, + RegisteredMember { + user_id: user_id!("b@prose.org"), + name: Some("Member B".to_string()), + is_self: false, + affiliation: RoomAffiliation::Owner, + }, + ]), + )) + }); + + deps.connected_rooms_repo + .expect_delete() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("group@conf.prose.org"))) + .return_once(|_| ()); + + deps.connected_rooms_repo + .expect_set() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(Arc::new(RoomInternals::pending( + &channel_id, + "jane.doe#amFuZS5kb2VAcHJvc2Uub3Jn", + )))) + .return_once(|_| Ok(())); + + { + let channel_jid = channel_id.clone(); + deps.room_management_service + .expect_create_or_join_room() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(occupant_id), + predicate::eq("Private Channel"), + predicate::eq(RoomSpec::PrivateChannel), + ) + .return_once(|_, _, _| { + Box::pin(async move { + Ok(RoomSessionInfo::new_room( + channel_jid.clone(), + RoomType::PrivateChannel, + )) + }) + }); + } + + { + let channel_jid = channel_id.clone(); + deps.connected_rooms_repo + .expect_update() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(channel_jid.clone()), predicate::always()) + .return_once(move |_, _| { + Some(Arc::new(RoomInternals::private_channel( + channel_jid.clone(), + ))) + }); + } + + deps.message_migration_domain_service + .expect_copy_all_messages_from_room() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(room_id!("group@conf.prose.org")), + predicate::eq(RoomType::Group), + predicate::eq(channel_id.clone()), + predicate::eq(RoomType::PrivateChannel), + ) + .return_once(|_, _, _, _| Box::pin(async { Ok(()) })); + + deps.room_participation_service + .expect_grant_membership() + .times(2) + .in_sequence(&mut seq) + .with( + predicate::eq(channel_id.clone()), + predicate::in_iter(vec![user_id!("a@prose.org"), user_id!("b@prose.org")]), + ) + .returning(|_, _| Box::pin(async { Ok(()) })); + + deps.room_management_service + .expect_destroy_room() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(room_id!("group@conf.prose.org")), + predicate::eq(Some(channel_id.clone())), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + let service = RoomsDomainService::from(deps.into_deps()); + + let room = service + .reconfigure_room_with_spec( + &room_id!("group@conf.prose.org"), + RoomSpec::PrivateChannel, + "Private Channel", + ) + .await?; + + assert_eq!(room.r#type, RoomType::PrivateChannel); + + Ok(()) +} + +#[tokio::test] +async fn test_converts_private_to_public_channel_if_it_does_not_exist() -> Result<()> { + let mut deps = MockRoomsDomainServiceDependencies::default(); + + let room = Arc::new( + RoomInternals::private_channel(room_id!("channel@conf.prose.org")).with_members(vec![ + RegisteredMember { + user_id: mock_data::account_jid().into_user_id(), + name: Some("Jane Doe".to_string()), + affiliation: RoomAffiliation::Owner, + is_self: false, + }, + RegisteredMember { + user_id: user_id!("a@prose.org"), + name: Some("Member A".to_string()), + affiliation: RoomAffiliation::Owner, + is_self: false, + }, + ]), + ); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("channel@conf.prose.org"))) + .return_once(|_| Some(room)); + } + + deps.room_management_service + .expect_load_public_rooms() + .once() + .in_sequence(&mut seq) + .return_once(|_| Box::pin(async { Ok(vec![]) })); + + deps.room_management_service + .expect_reconfigure_room() + .with( + predicate::eq(room_id!("channel@conf.prose.org")), + predicate::eq(RoomSpec::PublicChannel), + predicate::eq("Public Channel"), + ) + .once() + .in_sequence(&mut seq) + .return_once(|_, _, _| Box::pin(async { Ok(()) })); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_update() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(room_id!("channel@conf.prose.org")), + predicate::always(), + ) + .return_once(|_, handler| Some(Arc::new(handler(room)))); + } + + let service = RoomsDomainService::from(deps.into_deps()); + + let room = service + .reconfigure_room_with_spec( + &room_id!("channel@conf.prose.org"), + RoomSpec::PublicChannel, + "Public Channel", + ) + .await?; + + assert_eq!(room.r#type, RoomType::PublicChannel); + + Ok(()) +} + +#[tokio::test] +async fn test_converts_private_to_public_channel_name_conflict() -> Result<()> { + let mut deps = MockRoomsDomainServiceDependencies::default(); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("channel@conf.prose.org"))) + .return_once(|_| { + Some(Arc::new( + RoomInternals::private_channel(room_id!("channel@conf.prose.org")).with_members( + vec![ + RegisteredMember { + user_id: mock_data::account_jid().into_user_id(), + name: Some("Jane Doe".to_string()), + affiliation: RoomAffiliation::Owner, + is_self: false, + }, + RegisteredMember { + user_id: user_id!("a@prose.org"), + name: Some("Member A".to_string()), + affiliation: RoomAffiliation::Owner, + is_self: false, + }, + ], + ), + )) + }); + + deps.room_management_service + .expect_load_public_rooms() + .once() + .in_sequence(&mut seq) + .return_once(|_| { + Box::pin(async { + Ok(vec![PublicRoomInfo { + jid: room_id!("room@conference.prose.org"), + name: Some("new channel".to_string()), + }]) + }) + }); + + let service = RoomsDomainService::from(deps.into_deps()); + + let result = service + .reconfigure_room_with_spec( + &room_id!("channel@conf.prose.org"), + RoomSpec::PublicChannel, + "New Channel", + ) + .await; + + let Err(RoomError::PublicChannelNameConflict) = result else { + panic!( + "Expected RoomError::PublicChannelNameConflict. Got {:?}", + result + ) + }; + + Ok(()) +} diff --git a/crates/prose-core-client/tests/rooms_event_handler.rs b/crates/prose-core-client/tests/rooms_event_handler.rs index 13b25891..75b4590f 100644 --- a/crates/prose-core-client/tests/rooms_event_handler.rs +++ b/crates/prose-core-client/tests/rooms_event_handler.rs @@ -7,77 +7,99 @@ use std::sync::Arc; use anyhow::Result; use chrono::{TimeZone, Utc}; -use mockall::predicate; -use xmpp_parsers::chatstates::ChatState; -use xmpp_parsers::message::MessageType; -use xmpp_parsers::muc::user::{Affiliation, Item, Role}; -use xmpp_parsers::muc::MucUser; -use xmpp_parsers::presence::Presence; - -use prose_core_client::app::event_handlers::{RoomsEventHandler, XMPPEvent, XMPPEventHandler}; -use prose_core_client::domain::rooms::models::RoomInternals; +use mockall::{predicate, Sequence}; + +use prose_core_client::app::event_handlers::{ + OccupantEvent, OccupantEventType, RoomEvent, RoomEventType, RoomsEventHandler, ServerEvent, + ServerEventHandler, UserStatusEvent, UserStatusEventType, +}; +use prose_core_client::domain::connection::models::ConnectionProperties; +use prose_core_client::domain::rooms::models::{ComposeState, RoomAffiliation, RoomInternals}; use prose_core_client::domain::rooms::services::{CreateOrEnterRoomRequest, RoomFactory}; -use prose_core_client::domain::shared::models::RoomJid; -use prose_core_client::dtos::{Occupant, UserBasicInfo}; +use prose_core_client::domain::shared::models::{ + OccupantId, RoomId, UserId, UserOrResourceId, UserResourceId, +}; +use prose_core_client::domain::user_info::models::Presence; +use prose_core_client::dtos::{Availability, Participant, ParticipantInfo, UserBasicInfo}; use prose_core_client::test::{ - mock_data, ConstantTimeProvider, MockAppDependencies, MockRoomFactoryDependencies, + ConstantTimeProvider, MockAppDependencies, MockRoomFactoryDependencies, +}; +use prose_core_client::{ + occupant_id, room_id, user_id, user_resource_id, ClientEvent, ClientRoomEventType, }; -use prose_core_client::{room, RoomEventType}; -use prose_xmpp::mods::muc; -use prose_xmpp::stanza::muc::MediatedInvite; -use prose_xmpp::{bare, full, jid, mods}; #[tokio::test] -async fn test_handles_presence_for_muc_room() -> Result<()> { +async fn test_adds_participant() -> Result<()> { let mut deps = MockAppDependencies::default(); - let room = Arc::new(RoomInternals::group(room!("room@conference.prose.org"))); + let room = Arc::new(RoomInternals::group(room_id!("room@conference.prose.org"))); { let room = room.clone(); deps.connected_rooms_repo .expect_get() - .once() - .with(predicate::eq(room!("room@conference.prose.org"))) - .return_once(move |_| Some(room.clone())); + .times(2) + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .returning(move |_| Some(room.clone())); } deps.user_profile_repo .expect_get_display_name() .once() - .with(predicate::eq(bare!("real-jid@prose.org"))) + .with(predicate::eq(user_id!("real-jid@prose.org"))) .return_once(|_| Box::pin(async { Ok(Some("George Washington".to_string())) })); + deps.client_event_dispatcher + .expect_dispatch_room_event() + .once() + .with( + predicate::eq(room.clone()), + predicate::eq(ClientRoomEventType::ParticipantsChanged), + ) + .return_once(|_, _| ()); + let event_handler = RoomsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Status(mods::status::Event::Presence( - Presence::available() - .with_from(full!("room@conference.prose.org/nick")) - .with_to(mock_data::account_jid()) - .with_payload(MucUser::new().with_items(vec![Item::new( - Affiliation::Member, - Role::Participant, - ) - .with_jid(full!("real-jid@prose.org/resource"))])), - ))) + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@conference.prose.org/nick").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0, + }, + })) + .await?; + event_handler + .handle_event(ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conference.prose.org/nick"), + anon_occupant_id: None, + real_id: Some(user_id!("real-jid@prose.org")), + is_self: false, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::Member, + }, + })) .await?; - assert_eq!(room.occupants().len(), 1); + assert_eq!(room.participants().len(), 1); let occupant = room - .get_occupant(&jid!("room@conference.prose.org/nick")) + .participants() + .get(&occupant_id!("room@conference.prose.org/nick").into()) .unwrap() .clone(); assert_eq!( occupant, - Occupant { - jid: Some(bare!("real-jid@prose.org")), + Participant { + real_id: Some(user_id!("real-jid@prose.org")), name: Some("George Washington".to_string()), - affiliation: Affiliation::Member, - chat_state: ChatState::Gone, - chat_state_updated: Default::default(), + is_self: false, + affiliation: RoomAffiliation::Member, + availability: Availability::Available, + compose_state: ComposeState::Idle, + compose_state_updated: Default::default(), + anon_occupant_id: None, } ); @@ -85,14 +107,270 @@ async fn test_handles_presence_for_muc_room() -> Result<()> { } #[tokio::test] -async fn test_handles_chat_state_for_muc_room() -> Result<()> { +async fn test_adds_invited_participant() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + let room = Arc::new(RoomInternals::private_channel(room_id!( + "room@conference.prose.org" + ))); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .return_once(move |_| Some(room.clone())); + } + + deps.user_profile_repo + .expect_get_display_name() + .once() + .with(predicate::eq(user_id!("user@prose.org"))) + .return_once(|_| Box::pin(async { Ok(Some("John Doe".to_string())) })); + + deps.client_event_dispatcher + .expect_dispatch_room_event() + .once() + .with( + predicate::eq(room.clone()), + predicate::eq(ClientRoomEventType::ParticipantsChanged), + ) + .return_once(|_, _| ()); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::Room(RoomEvent { + room_id: room_id!("room@conference.prose.org"), + r#type: RoomEventType::UserAdded { + user_id: user_id!("user@prose.org"), + affiliation: RoomAffiliation::Member, + reason: None, + }, + })) + .await?; + + assert_eq!( + room.participants() + .iter() + .map(ParticipantInfo::from) + .collect::>(), + vec![ParticipantInfo { + id: Some(user_id!("user@prose.org")), + name: "John Doe".to_string(), + is_self: false, + availability: Availability::Unavailable, + affiliation: RoomAffiliation::Member, + }] + ); + + Ok(()) +} + +#[tokio::test] +async fn test_handles_disconnected_participant() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + let room = Arc::new( + RoomInternals::private_channel(room_id!("room@conference.prose.org")).with_participants( + vec![( + occupant_id!("room@conference.prose.org/a"), + Participant { + real_id: None, + anon_occupant_id: None, + name: None, + is_self: false, + affiliation: RoomAffiliation::Admin, + availability: Availability::Available, + compose_state: ComposeState::Composing, + compose_state_updated: Default::default(), + }, + )], + ), + ); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .times(2) + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .returning(move |_| Some(room.clone())); + } + + deps.client_event_dispatcher + .expect_dispatch_room_event() + .once() + .with( + predicate::eq(room.clone()), + predicate::eq(ClientRoomEventType::ParticipantsChanged), + ) + .return_once(|_, _| ()); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@conference.prose.org/a").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Unavailable, + priority: 0, + }, + })) + .await?; + event_handler + .handle_event(ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conference.prose.org/a"), + anon_occupant_id: None, + real_id: None, + is_self: false, + r#type: OccupantEventType::AffiliationChanged { + affiliation: RoomAffiliation::Member, + }, + })) + .await?; + + assert_eq!( + room.participants().iter().cloned().collect::>(), + vec![Participant { + real_id: None, + anon_occupant_id: None, + name: None, + is_self: false, + affiliation: RoomAffiliation::Member, + availability: Availability::Unavailable, + compose_state: ComposeState::Idle, + compose_state_updated: Default::default(), + }] + ); + + Ok(()) +} + +#[tokio::test] +async fn test_handles_kicked_user() -> Result<()> { let mut deps = MockAppDependencies::default(); let room = Arc::new( - RoomInternals::group(room!("room@conference.prose.org")).with_occupants([( - jid!("room@conference.prose.org/nickname"), - Occupant::owner() - .set_real_jid(&bare!("nickname@prose.org")) + RoomInternals::group(room_id!("room@conference.prose.org")).with_participants([( + occupant_id!("room@conference.prose.org/nickname"), + Participant::owner().set_real_id(&user_id!("nickname@prose.org")), + )]), + ); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .returning(move |_| Some(room.clone())); + } + + deps.client_event_dispatcher + .expect_dispatch_room_event() + .once() + .with( + predicate::eq(room.clone()), + predicate::eq(ClientRoomEventType::ParticipantsChanged), + ) + .return_once(|_, _| ()); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + assert_eq!(room.participants().len(), 1); + + event_handler + .handle_event(ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conference.prose.org/nickname"), + anon_occupant_id: None, + real_id: None, + is_self: false, + r#type: OccupantEventType::PermanentlyRemoved, + })) + .await?; + + assert_eq!(room.participants().len(), 0); + + Ok(()) +} + +#[tokio::test] +async fn test_handles_kicked_self() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + let room = Arc::new(RoomInternals::group(room_id!("room@conference.prose.org"))); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .returning(move |_| Some(room.clone())); + } + + deps.sidebar_domain_service + .expect_handle_removal_from_room() + .once() + .with( + predicate::eq(room_id!("room@conference.prose.org")), + predicate::eq(true), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::Occupant(OccupantEvent { + occupant_id: occupant_id!("room@conference.prose.org/nickname"), + anon_occupant_id: None, + real_id: None, + is_self: true, + r#type: OccupantEventType::PermanentlyRemoved, + })) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handles_destroyed_room() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + deps.sidebar_domain_service + .expect_handle_destroyed_room() + .once() + .with( + predicate::eq(room_id!("group@prose.org")), + predicate::eq(Some(room_id!("private-channel@prose.org"))), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::Room(RoomEvent { + room_id: room_id!("group@prose.org"), + r#type: RoomEventType::Destroyed { + replacement: Some(room_id!("private-channel@prose.org")), + }, + })) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handles_compose_state_for_muc_room() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + let room = Arc::new( + RoomInternals::group(room_id!("room@conference.prose.org")).with_participants([( + occupant_id!("room@conference.prose.org/nickname"), + Participant::owner() + .set_real_id(&user_id!("nickname@prose.org")) .set_name("Janice Doe"), )]), ); @@ -102,7 +380,7 @@ async fn test_handles_chat_state_for_muc_room() -> Result<()> { deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("room@conference.prose.org"))) + .with(predicate::eq(room_id!("room@conference.prose.org"))) .return_once(move |_| Some(room.clone())); } deps.time_provider = Arc::new(ConstantTimeProvider::ymd(2023, 01, 04)); @@ -111,28 +389,30 @@ async fn test_handles_chat_state_for_muc_room() -> Result<()> { .once() .with( predicate::eq(room.clone()), - predicate::eq(RoomEventType::ComposingUsersChanged), + predicate::eq(ClientRoomEventType::ComposingUsersChanged), ) .return_once(|_, _| ()); let event_handler = RoomsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Chat(mods::chat::Event::ChatStateChanged { - from: jid!("room@conference.prose.org/nickname"), - chat_state: ChatState::Composing, - message_type: MessageType::Groupchat, + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: occupant_id!("room@conference.prose.org/nickname").into(), + r#type: UserStatusEventType::ComposeStateChanged { + state: ComposeState::Composing, + }, })) .await?; let occupant = room - .get_occupant(&jid!("room@conference.prose.org/nickname")) + .participants() + .get(&occupant_id!("room@conference.prose.org/nickname").into()) .unwrap() .clone(); - assert_eq!(occupant.chat_state, ChatState::Composing); + assert_eq!(occupant.compose_state, ComposeState::Composing); assert_eq!( - occupant.chat_state_updated, + occupant.compose_state_updated, Utc.with_ymd_and_hms(2023, 01, 04, 0, 0, 0).unwrap() ); @@ -147,7 +427,7 @@ async fn test_handles_chat_state_for_muc_room() -> Result<()> { room.load_composing_users().await?, vec![UserBasicInfo { name: "Janice Doe".to_string(), - jid: bare!("nickname@prose.org") + id: user_id!("nickname@prose.org") }] ); @@ -158,13 +438,13 @@ async fn test_handles_chat_state_for_muc_room() -> Result<()> { } #[tokio::test] -async fn test_handles_chat_state_for_direct_message_room() -> Result<()> { +async fn test_handles_compose_state_for_direct_message_room() -> Result<()> { let mut deps = MockAppDependencies::default(); let room = Arc::new(RoomInternals::for_direct_message( - &mock_data::account_jid().into_bare(), - &bare!("contact@prose.org"), + &user_id!("contact@prose.org"), "Janice Doe", + &Availability::Unavailable, )); { @@ -172,7 +452,7 @@ async fn test_handles_chat_state_for_direct_message_room() -> Result<()> { deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("contact@prose.org"))) + .with(predicate::eq(room_id!("contact@prose.org"))) .return_once(move |_| Some(room.clone())); } deps.time_provider = Arc::new(ConstantTimeProvider::ymd(2023, 01, 04)); @@ -181,28 +461,30 @@ async fn test_handles_chat_state_for_direct_message_room() -> Result<()> { .once() .with( predicate::eq(room.clone()), - predicate::eq(RoomEventType::ComposingUsersChanged), + predicate::eq(ClientRoomEventType::ComposingUsersChanged), ) .return_once(|_, _| ()); let event_handler = RoomsEventHandler::from(&deps.into_deps()); event_handler - .handle_event(XMPPEvent::Chat(mods::chat::Event::ChatStateChanged { - from: jid!("contact@prose.org/resource"), - chat_state: ChatState::Composing, - message_type: MessageType::Chat, + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: user_resource_id!("contact@prose.org/resource").into(), + r#type: UserStatusEventType::ComposeStateChanged { + state: ComposeState::Composing, + }, })) .await?; let occupant = room - .get_occupant(&jid!("contact@prose.org")) + .participants() + .get(&user_id!("contact@prose.org").into()) .unwrap() .clone(); - assert_eq!(occupant.chat_state, ChatState::Composing); + assert_eq!(occupant.compose_state, ComposeState::Composing); assert_eq!( - occupant.chat_state_updated, + occupant.compose_state_updated, Utc.with_ymd_and_hms(2023, 01, 04, 0, 0, 0).unwrap() ); @@ -217,7 +499,7 @@ async fn test_handles_chat_state_for_direct_message_room() -> Result<()> { room.load_composing_users().await?, vec![UserBasicInfo { name: "Janice Doe".to_string(), - jid: bare!("contact@prose.org") + id: user_id!("contact@prose.org") }] ); @@ -235,17 +517,18 @@ async fn test_handles_invite() -> Result<()> { .expect_insert_item_by_creating_or_joining_room() .once() .with(predicate::eq(CreateOrEnterRoomRequest::JoinRoom { - room_jid: room!("group@conference.prose.org"), + room_jid: room_id!("group@conference.prose.org"), password: None, })) - .return_once(|_| Box::pin(async move { Ok(room!("group@conference.prose.org")) })); + .return_once(|_| Box::pin(async move { Ok(room_id!("group@conference.prose.org")) })); let event_handler = RoomsEventHandler::from(&deps.into_deps()); + event_handler - .handle_event(XMPPEvent::MUC(muc::Event::MediatedInvite { - from: jid!("group@conference.prose.org"), - invite: MediatedInvite { - invites: vec![], + .handle_event(ServerEvent::Room(RoomEvent { + room_id: room_id!("group@conference.prose.org"), + r#type: RoomEventType::ReceivedInvitation { + sender: user_resource_id!("user@prose.org/res"), password: None, }, })) @@ -253,3 +536,251 @@ async fn test_handles_invite() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_handles_presence() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + let room = Arc::new(RoomInternals::for_direct_message( + &user_id!("sender@prose.org"), + "Janice Doe", + &Availability::Unavailable, + )); + + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("sender@prose.org"))) + .return_once(move |_| Some(room.clone())); + + deps.user_info_repo + .expect_set_user_presence() + .once() + .with( + predicate::eq(UserOrResourceId::from(user_resource_id!( + "sender@prose.org/resource" + ))), + predicate::eq(Presence { + priority: 1, + availability: Availability::Available, + status: None, + }), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .with(predicate::eq(ClientEvent::ContactChanged { + id: user_id!("sender@prose.org"), + })) + .return_once(|_| ()); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: user_resource_id!("sender@prose.org/resource").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 1, + }, + })) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handles_contact_presence_with_no_room() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("sender@prose.org"))) + .return_once(move |_| None); + + deps.user_info_repo + .expect_set_user_presence() + .once() + .with( + predicate::eq(UserOrResourceId::from(user_resource_id!( + "sender@prose.org/resource" + ))), + predicate::eq(Presence { + priority: 1, + availability: Availability::Available, + status: None, + }), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .with(predicate::eq(ClientEvent::ContactChanged { + id: user_id!("sender@prose.org"), + })) + .return_once(|_| ()); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: user_resource_id!("sender@prose.org/resource").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 1, + }, + })) + .await?; + + Ok(()) +} + +#[tokio::test] +/// Test that UserStateEventHandler does not send an event when a self-presence is received and +/// that the event is consumed, i.e. cannot be forwarded to other handlers. +async fn test_swallows_self_presence() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + deps.ctx.set_connection_properties(ConnectionProperties { + connected_jid: user_resource_id!("hello@prose.org/res"), + server_features: Default::default(), + }); + + let room = Arc::new(RoomInternals::for_direct_message( + &user_id!("hello@prose.org"), + "Janice Doe", + &Availability::Unavailable, + )); + + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("hello@prose.org"))) + .return_once(move |_| Some(room.clone())); + + deps.user_info_repo + .expect_set_user_presence() + .once() + .with( + predicate::eq(UserOrResourceId::from(user_id!("hello@prose.org"))), + predicate::eq(Presence { + availability: Availability::Available, + ..Default::default() + }), + ) + .return_once(|_, _| Box::pin(async { Ok(()) })); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + assert!(event_handler + .handle_event(ServerEvent::UserStatus(UserStatusEvent { + user_id: user_id!("hello@prose.org").into(), + r#type: UserStatusEventType::AvailabilityChanged { + availability: Availability::Available, + priority: 0 + } + })) + .await? + .is_none()); + + Ok(()) +} + +#[tokio::test] +async fn test_room_config_changed() -> Result<()> { + let mut deps = MockAppDependencies::default(); + + deps.sidebar_domain_service + .expect_handle_changed_room_config() + .once() + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .return_once(|_| Box::pin(async { Ok(()) })); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + event_handler + .handle_event(ServerEvent::Room(RoomEvent { + room_id: room_id!("room@conference.prose.org"), + r#type: RoomEventType::RoomConfigChanged, + })) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_room_topic_changed() -> Result<()> { + let mut deps = MockAppDependencies::default(); + let mut seq = Sequence::new(); + + let room = Arc::new( + RoomInternals::group(room_id!("room@conference.prose.org")).with_topic(Some("Old Topic")), + ); + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .returning(move |_| Some(room.clone())); + } + + { + let room = room.clone(); + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("room@conference.prose.org"))) + .returning(move |_| Some(room.clone())); + } + + deps.client_event_dispatcher + .expect_dispatch_room_event() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(room.clone()), + predicate::eq(ClientRoomEventType::AttributesChanged), + ) + .return_once(|_, _| ()); + + let event_handler = RoomsEventHandler::from(&deps.into_deps()); + + // Should not generate an event since the topic didn't actually change + event_handler + .handle_event(ServerEvent::Room(RoomEvent { + room_id: room_id!("room@conference.prose.org"), + r#type: RoomEventType::RoomTopicChanged { + new_topic: Some("Old Topic".to_string()), + }, + })) + .await?; + + // Should fire an event + event_handler + .handle_event(ServerEvent::Room(RoomEvent { + room_id: room_id!("room@conference.prose.org"), + r#type: RoomEventType::RoomTopicChanged { + new_topic: Some("New Topic".to_string()), + }, + })) + .await?; + + assert_eq!(room.topic(), Some("New Topic".to_string())); + + Ok(()) +} diff --git a/crates/prose-core-client/tests/sidebar_domain_service.rs b/crates/prose-core-client/tests/sidebar_domain_service.rs index 6aa92f0f..210eabe3 100644 --- a/crates/prose-core-client/tests/sidebar_domain_service.rs +++ b/crates/prose-core-client/tests/sidebar_domain_service.rs @@ -6,17 +6,18 @@ use std::sync::Arc; use anyhow::Result; -use mockall::predicate; +use mockall::{predicate, Sequence}; +use xmpp_parsers::stanza_error::{DefinedCondition, ErrorType, StanzaError}; -use prose_core_client::domain::rooms::models::RoomInternals; +use prose_core_client::domain::rooms::models::{RoomError, RoomInternals, RoomSpec}; use prose_core_client::domain::rooms::services::CreateOrEnterRoomRequest; -use prose_core_client::domain::shared::models::RoomJid; +use prose_core_client::domain::shared::models::RoomId; use prose_core_client::domain::sidebar::models::{Bookmark, BookmarkType, SidebarItem}; use prose_core_client::domain::sidebar::services::impls::SidebarDomainService; use prose_core_client::domain::sidebar::services::SidebarDomainService as SidebarDomainServiceTrait; use prose_core_client::test::MockSidebarDomainServiceDependencies; -use prose_core_client::{room, ClientEvent}; -use prose_xmpp::{bare, full}; +use prose_core_client::{room_id, ClientEvent}; +use prose_xmpp::{full, RequestError}; #[tokio::test] async fn test_extends_sidebar() -> Result<()> { @@ -25,25 +26,25 @@ async fn test_extends_sidebar() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("group@prose.org"))) + .with(predicate::eq(room_id!("group@prose.org"))) .return_once(|_| None); deps.rooms_domain_service .expect_create_or_join_room() .once() .with(predicate::eq(CreateOrEnterRoomRequest::JoinRoom { - room_jid: room!("group@prose.org"), + room_jid: room_id!("group@prose.org"), password: None, })) .return_once(|_| { - Box::pin(async { Ok(Arc::new(RoomInternals::group(room!("group@prose.org")))) }) + Box::pin(async { Ok(Arc::new(RoomInternals::group(room_id!("group@prose.org")))) }) }); deps.sidebar_repo .expect_put() .once() .with(predicate::eq(SidebarItem::group( - room!("group@prose.org"), + room_id!("group@prose.org"), "Group", ))) .return_once(|_| ()); @@ -57,13 +58,147 @@ async fn test_extends_sidebar() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service .extend_items_from_bookmarks(vec![ - Bookmark::group(room!("group@prose.org"), "Group").set_in_sidebar(true) + Bookmark::group(room_id!("group@prose.org"), "Group").set_in_sidebar(true) ]) .await?; Ok(()) } +#[tokio::test] +async fn test_extends_sidebar_and_follows_new_locations() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + deps.sidebar_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("a0@prose.org"))) + .return_once(|_| None); + + deps.rooms_domain_service + .expect_create_or_join_room() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(CreateOrEnterRoomRequest::JoinRoom { + room_jid: room_id!("a0@prose.org"), + password: None, + })) + .return_once(|_| { + Box::pin(async { + Err(RoomError::RequestError(RequestError::XMPP { + err: StanzaError { + type_: ErrorType::Cancel, + by: None, + defined_condition: DefinedCondition::Gone, + texts: Default::default(), + other: None, + new_location: Some("xmpp:a1@prose.org?join".to_string()), + }, + })) + }) + }); + + deps.sidebar_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("a1@prose.org"))) + .return_once(|_| None); + + deps.rooms_domain_service + .expect_create_or_join_room() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(CreateOrEnterRoomRequest::JoinRoom { + room_jid: room_id!("a1@prose.org"), + password: None, + })) + .return_once(|_| { + Box::pin(async { + Err(RoomError::RequestError(RequestError::XMPP { + err: StanzaError { + type_: ErrorType::Cancel, + by: None, + defined_condition: DefinedCondition::Gone, + texts: Default::default(), + other: None, + new_location: Some("xmpp:a2@prose.org?join".to_string()), + }, + })) + }) + }); + + deps.sidebar_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("a2@prose.org"))) + .return_once(|_| None); + + deps.rooms_domain_service + .expect_create_or_join_room() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(CreateOrEnterRoomRequest::JoinRoom { + room_jid: room_id!("a2@prose.org"), + password: None, + })) + .return_once(|_| { + Box::pin(async { + Ok(Arc::new( + RoomInternals::group(room_id!("a2@prose.org")).with_name("Group"), + )) + }) + }); + + deps.sidebar_repo + .expect_put() + .once() + .in_sequence(&mut seq) + .with(predicate::eq( + SidebarItem::group(room_id!("a2@prose.org"), "Group").set_is_favorite(true), + )) + .return_once(|_| ()); + + deps.bookmarks_service + .expect_delete_bookmark() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("a0@prose.org"))) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.bookmarks_service + .expect_save_bookmark() + .once() + .in_sequence(&mut seq) + .with(predicate::eq( + Bookmark::group(room_id!("a2@prose.org"), "Group") + .set_in_sidebar(true) + .set_is_favorite(true), + )) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .extend_items_from_bookmarks(vec![Bookmark::group(room_id!("a0@prose.org"), "Group") + .set_in_sidebar(true) + .set_is_favorite(true)]) + .await?; + + Ok(()) +} + #[tokio::test] async fn test_handles_removed_item() -> Result<()> { let mut deps = MockSidebarDomainServiceDependencies::default(); @@ -71,13 +206,13 @@ async fn test_handles_removed_item() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("group@prose.org"))) - .return_once(|_| Some(SidebarItem::group(room!("group@prose.org"), "Group"))); + .with(predicate::eq(room_id!("group@prose.org"))) + .return_once(|_| Some(SidebarItem::group(room_id!("group@prose.org"), "Group"))); deps.sidebar_repo .expect_delete() .once() - .with(predicate::eq(room!("group@prose.org"))) + .with(predicate::eq(room_id!("group@prose.org"))) .return_once(|_| ()); deps.client_event_dispatcher @@ -89,7 +224,7 @@ async fn test_handles_removed_item() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service .extend_items_from_bookmarks(vec![ - Bookmark::group(room!("group@prose.org"), "Group").set_in_sidebar(false) + Bookmark::group(room_id!("group@prose.org"), "Group").set_in_sidebar(false) ]) .await?; @@ -103,13 +238,13 @@ async fn test_does_not_add_removed_item() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("group@prose.org"))) + .with(predicate::eq(room_id!("group@prose.org"))) .return_once(|_| None); let service = SidebarDomainService::from(deps.into_deps()); service .extend_items_from_bookmarks(vec![ - Bookmark::group(room!("group@prose.org"), "Group").set_in_sidebar(false) + Bookmark::group(room_id!("group@prose.org"), "Group").set_in_sidebar(false) ]) .await?; @@ -123,16 +258,16 @@ async fn test_handles_updated_bookmark() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("group@prose.org"))) + .with(predicate::eq(room_id!("group@prose.org"))) .return_once(|_| { - Some(SidebarItem::group(room!("group@prose.org"), "Group").set_is_favorite(false)) + Some(SidebarItem::group(room_id!("group@prose.org"), "Group").set_is_favorite(false)) }); deps.sidebar_repo .expect_put() .once() .with(predicate::eq( - SidebarItem::group(room!("group@prose.org"), "Group").set_is_favorite(true), + SidebarItem::group(room_id!("group@prose.org"), "Group").set_is_favorite(true), )) .return_once(|_| ()); @@ -144,7 +279,7 @@ async fn test_handles_updated_bookmark() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .extend_items_from_bookmarks(vec![Bookmark::group(room!("group@prose.org"), "Group") + .extend_items_from_bookmarks(vec![Bookmark::group(room_id!("group@prose.org"), "Group") .set_in_sidebar(true) .set_is_favorite(true)]) .await?; @@ -159,10 +294,10 @@ async fn test_removes_public_channel_from_sidebar() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(|_| { Some(SidebarItem::public_channel( - room!("channel@conference.prose.org"), + room_id!("channel@conference.prose.org"), "", )) }); @@ -170,22 +305,22 @@ async fn test_removes_public_channel_from_sidebar() -> Result<()> { deps.bookmarks_service .expect_delete_bookmark() .once() - .with(predicate::eq(bare!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(|_| Box::pin(async { Ok(()) })); deps.sidebar_repo .expect_delete() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(|_| ()); deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(move |_| { Some(Arc::new( - RoomInternals::public_channel(room!("channel@conference.prose.org")) + RoomInternals::public_channel(room_id!("channel@conference.prose.org")) .with_user_nickname("jane.doe"), )) }); @@ -206,7 +341,7 @@ async fn test_removes_public_channel_from_sidebar() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .remove_items(&[&room!("channel@conference.prose.org")]) + .remove_items(&[&room_id!("channel@conference.prose.org")]) .await?; Ok(()) @@ -219,19 +354,19 @@ async fn test_removes_direct_message_from_sidebar() -> Result<()> { deps.bookmarks_service .expect_delete_bookmark() .once() - .with(predicate::eq(bare!("contact@prose.org"))) + .with(predicate::eq(room_id!("contact@prose.org"))) .return_once(|_| Box::pin(async { Ok(()) })); deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("contact@prose.org"))) - .return_once(|_| Some(SidebarItem::direct_message(room!("contact@prose.org")))); + .with(predicate::eq(room_id!("contact@prose.org"))) + .return_once(|_| Some(SidebarItem::direct_message(room_id!("contact@prose.org")))); deps.sidebar_repo .expect_delete() .once() - .with(predicate::eq(room!("contact@prose.org"))) + .with(predicate::eq(room_id!("contact@prose.org"))) .return_once(|_| ()); deps.client_event_dispatcher @@ -241,7 +376,9 @@ async fn test_removes_direct_message_from_sidebar() -> Result<()> { .return_once(|_| ()); let service = SidebarDomainService::from(deps.into_deps()); - service.remove_items(&[&room!("contact@prose.org")]).await?; + service + .remove_items(&[&room_id!("contact@prose.org")]) + .await?; Ok(()) } @@ -253,13 +390,13 @@ async fn test_handles_removed_direct_message() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("contact@prose.org"))) - .return_once(|_| Some(SidebarItem::direct_message(room!("contact@prose.org")))); + .with(predicate::eq(room_id!("contact@prose.org"))) + .return_once(|_| Some(SidebarItem::direct_message(room_id!("contact@prose.org")))); deps.sidebar_repo .expect_delete() .once() - .with(predicate::eq(room!("contact@prose.org"))) + .with(predicate::eq(room_id!("contact@prose.org"))) .return_once(|_| ()); deps.client_event_dispatcher @@ -270,7 +407,7 @@ async fn test_handles_removed_direct_message() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .handle_removed_items(&[&room!("contact@prose.org")]) + .handle_removed_items(&[room_id!("contact@prose.org")]) .await?; Ok(()) @@ -285,7 +422,7 @@ async fn test_removes_group_from_sidebar() -> Result<()> { .once() .with(predicate::eq(Bookmark { name: "Group Name".to_string(), - jid: room!("group@conference.prose.org"), + jid: room_id!("group@conference.prose.org"), r#type: BookmarkType::Group, // The group should be removed from favorites is_favorite: false, @@ -296,10 +433,10 @@ async fn test_removes_group_from_sidebar() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("group@conference.prose.org"))) + .with(predicate::eq(room_id!("group@conference.prose.org"))) .return_once(|_| { Some( - SidebarItem::group(room!("group@conference.prose.org"), "Group Name") + SidebarItem::group(room_id!("group@conference.prose.org"), "Group Name") .set_is_favorite(true), ) }); @@ -307,7 +444,7 @@ async fn test_removes_group_from_sidebar() -> Result<()> { deps.sidebar_repo .expect_delete() .once() - .with(predicate::eq(room!("group@conference.prose.org"))) + .with(predicate::eq(room_id!("group@conference.prose.org"))) .return_once(|_| ()); // Unlike channels, groups should never be exited. This is because a Group should basically @@ -321,7 +458,7 @@ async fn test_removes_group_from_sidebar() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .remove_items(&[&room!("group@conference.prose.org")]) + .remove_items(&[&room_id!("group@conference.prose.org")]) .await?; Ok(()) @@ -336,7 +473,7 @@ async fn test_removes_private_channel_from_sidebar() -> Result<()> { .once() .with(predicate::eq(Bookmark { name: "Channel Name".to_string(), - jid: room!("channel@conference.prose.org"), + jid: room_id!("channel@conference.prose.org"), r#type: BookmarkType::PrivateChannel, // The channel should be removed from favorites is_favorite: false, @@ -347,27 +484,30 @@ async fn test_removes_private_channel_from_sidebar() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(|_| { Some( - SidebarItem::private_channel(room!("channel@conference.prose.org"), "Channel Name") - .set_is_favorite(true), + SidebarItem::private_channel( + room_id!("channel@conference.prose.org"), + "Channel Name", + ) + .set_is_favorite(true), ) }); deps.sidebar_repo .expect_delete() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(|_| ()); deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(move |_| { Some(Arc::new( - RoomInternals::private_channel(room!("channel@conference.prose.org")) + RoomInternals::private_channel(room_id!("channel@conference.prose.org")) .with_user_nickname("jane.doe"), )) }); @@ -391,7 +531,7 @@ async fn test_removes_private_channel_from_sidebar() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .remove_items(&[&room!("channel@conference.prose.org")]) + .remove_items(&[&room_id!("channel@conference.prose.org")]) .await?; Ok(()) @@ -401,22 +541,23 @@ async fn test_removes_private_channel_from_sidebar() -> Result<()> { async fn test_insert_item_for_received_message_if_needed() -> Result<()> { let mut deps = MockSidebarDomainServiceDependencies::default(); - let room = - Arc::new(RoomInternals::group(room!("group@conference.prose.org")).with_name("Group Name")); + let room = Arc::new( + RoomInternals::group(room_id!("group@conference.prose.org")).with_name("Group Name"), + ); { let room = room.clone(); deps.connected_rooms_repo .expect_get() .once() - .with(predicate::eq(room!("group@conference.prose.org"))) + .with(predicate::eq(room_id!("group@conference.prose.org"))) .return_once(|_| Some(room)); } deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("group@conference.prose.org"))) + .with(predicate::eq(room_id!("group@conference.prose.org"))) .return_once(|_| None); deps.bookmarks_service @@ -424,7 +565,7 @@ async fn test_insert_item_for_received_message_if_needed() -> Result<()> { .once() .with(predicate::eq(Bookmark { name: "Group Name".to_string(), - jid: room!("group@conference.prose.org"), + jid: room_id!("group@conference.prose.org"), r#type: BookmarkType::Group, is_favorite: false, in_sidebar: true, @@ -436,7 +577,7 @@ async fn test_insert_item_for_received_message_if_needed() -> Result<()> { .once() .with(predicate::eq(SidebarItem { name: "Group Name".to_string(), - jid: room!("group@conference.prose.org"), + jid: room_id!("group@conference.prose.org"), r#type: BookmarkType::Group, is_favorite: false, error: None, @@ -451,7 +592,7 @@ async fn test_insert_item_for_received_message_if_needed() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .insert_item_for_received_message_if_needed(&room!("group@conference.prose.org")) + .insert_item_for_received_message_if_needed(&room_id!("group@conference.prose.org")) .await?; Ok(()) @@ -464,11 +605,11 @@ async fn test_renames_channel_in_sidebar() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("room@conference.prose.org"))) + .with(predicate::eq(room_id!("room@conference.prose.org"))) .return_once(|_| { Some(SidebarItem { name: "Old Name".to_string(), - jid: room!("room@conference.prose.org"), + jid: room_id!("room@conference.prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, @@ -479,7 +620,7 @@ async fn test_renames_channel_in_sidebar() -> Result<()> { .expect_rename_room() .once() .with( - predicate::eq(room!("room@conference.prose.org")), + predicate::eq(room_id!("room@conference.prose.org")), predicate::eq("New Name"), ) .return_once(|_, _| Box::pin(async move { Ok(()) })); @@ -489,7 +630,7 @@ async fn test_renames_channel_in_sidebar() -> Result<()> { .once() .with(predicate::eq(SidebarItem { name: "New Name".to_string(), - jid: room!("room@conference.prose.org"), + jid: room_id!("room@conference.prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, @@ -501,7 +642,7 @@ async fn test_renames_channel_in_sidebar() -> Result<()> { .once() .with(predicate::eq(Bookmark { name: "New Name".to_string(), - jid: room!("room@conference.prose.org"), + jid: room_id!("room@conference.prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, in_sidebar: true, @@ -516,7 +657,7 @@ async fn test_renames_channel_in_sidebar() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .rename_item(&room!("room@conference.prose.org"), "New Name") + .rename_item(&room_id!("room@conference.prose.org"), "New Name") .await?; Ok(()) @@ -531,7 +672,7 @@ async fn test_toggle_favorite() -> Result<()> { .once() .with(predicate::eq(Bookmark { name: "Channel Name".to_string(), - jid: room!("channel@conference.prose.org"), + jid: room_id!("channel@conference.prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: true, in_sidebar: true, @@ -541,11 +682,11 @@ async fn test_toggle_favorite() -> Result<()> { deps.sidebar_repo .expect_get() .once() - .with(predicate::eq(room!("channel@conference.prose.org"))) + .with(predicate::eq(room_id!("channel@conference.prose.org"))) .return_once(|_| { Some(SidebarItem { name: "Channel Name".to_string(), - jid: room!("channel@conference.prose.org"), + jid: room_id!("channel@conference.prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: false, error: None, @@ -557,7 +698,7 @@ async fn test_toggle_favorite() -> Result<()> { .once() .with(predicate::eq(SidebarItem { name: "Channel Name".to_string(), - jid: room!("channel@conference.prose.org"), + jid: room_id!("channel@conference.prose.org"), r#type: BookmarkType::PublicChannel, is_favorite: true, error: None, @@ -572,7 +713,401 @@ async fn test_toggle_favorite() -> Result<()> { let service = SidebarDomainService::from(deps.into_deps()); service - .toggle_item_is_favorite(&room!("channel@conference.prose.org")) + .toggle_item_is_favorite(&room_id!("channel@conference.prose.org")) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_convert_group_to_private_channel() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + // Sequence starts in SidebarDomainService where reconfigure_item_with_spec is called. + // The SidebarDomainService first calls into RoomsDomainService… + deps.rooms_domain_service + .expect_reconfigure_room_with_spec() + .once() + .in_sequence(&mut seq) + .with( + predicate::eq(room_id!("group@conference.prose.org")), + predicate::eq(RoomSpec::PrivateChannel), + predicate::eq("My Private Channel"), + ) + .return_once(|_, _, _| { + Box::pin(async move { + // RoomsDomainService then creates a new room, migrates the messages and when + // it finally destroys the original room, the server will send us a presence + // to notify us that the room was destroyed. This will be handled by + // the RoomsEventHandler but the room will be removed from the + // ConnectedRoomsRepository already, so this will not be forwarded to + // the SidebarDomainService. + Ok(Arc::new( + RoomInternals::private_channel(room_id!( + "private-channel@conference.prose.org" + )) + .with_name("My Private Channel"), + )) + }) + }); + + deps.connected_rooms_repo + .expect_delete() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("group@conference.prose.org"))) + .return_once(|_| ()); + deps.sidebar_repo + .expect_delete() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("group@conference.prose.org"))) + .return_once(|_| ()); + deps.bookmarks_service + .expect_delete_bookmark() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("group@conference.prose.org"))) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.sidebar_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!( + "private-channel@conference.prose.org" + ))) + .return_once(|_| None); + deps.sidebar_repo + .expect_put() + .once() + .with(predicate::eq(SidebarItem::private_channel( + room_id!("private-channel@conference.prose.org"), + "My Private Channel", + ))) + .return_once(|_| ()); + deps.bookmarks_service + .expect_save_bookmark() + .once() + .in_sequence(&mut seq) + .with(predicate::eq( + Bookmark::private_channel( + room_id!("private-channel@conference.prose.org"), + "My Private Channel", + ) + .set_in_sidebar(true), + )) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .reconfigure_item_with_spec( + &room_id!("group@conference.prose.org"), + RoomSpec::PrivateChannel, + "My Private Channel", + ) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handle_destroyed_room() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + deps.sidebar_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| Some(SidebarItem::group(room_id!("group@muc.prose.org"), "Group"))); + deps.connected_rooms_repo + .expect_delete() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| ()); + deps.sidebar_repo + .expect_delete() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| ()); + deps.bookmarks_service + .expect_delete_bookmark() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.sidebar_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("channel@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| None); + + deps.rooms_domain_service + .expect_create_or_join_room() + .once() + .with(predicate::eq(CreateOrEnterRoomRequest::JoinRoom { + room_jid: room_id!("channel@muc.prose.org"), + password: None, + })) + .in_sequence(&mut seq) + .return_once(|_| { + Box::pin(async { + Ok(Arc::new( + RoomInternals::private_channel(room_id!("channel@muc.prose.org")) + .with_name("The Channel"), + )) + }) + }); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + deps.sidebar_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("channel@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| None); + deps.sidebar_repo + .expect_put() + .once() + .with(predicate::eq(SidebarItem::private_channel( + room_id!("channel@muc.prose.org"), + "The Channel", + ))) + .in_sequence(&mut seq) + .return_once(|_| ()); + deps.bookmarks_service + .expect_save_bookmark() + .once() + .with(predicate::eq( + Bookmark::private_channel(room_id!("channel@muc.prose.org"), "The Channel") + .set_in_sidebar(true), + )) + .in_sequence(&mut seq) + .return_once(|_| Box::pin(async { Ok(()) })); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .handle_destroyed_room( + &room_id!("group@muc.prose.org"), + Some(room_id!("channel@muc.prose.org")), + ) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handles_temporary_removal_from_room() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + let mut seq = Sequence::new(); + + deps.connected_rooms_repo + .expect_delete() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .return_once(|_| ()); + + deps.sidebar_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .return_once(|_| { + Some(SidebarItem::private_channel( + room_id!("room@conf.prose.org"), + "Channel", + )) + }); + + deps.sidebar_repo + .expect_put() + .once() + .in_sequence(&mut seq) + .with(predicate::function(|item: &SidebarItem| { + item.error.is_some() + })) + .return_once(|_| ()); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .handle_removal_from_room(&room_id!("room@conf.prose.org"), false) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handles_permanent_removal_from_room() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + let mut seq = Sequence::new(); + + deps.sidebar_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| Some(SidebarItem::group(room_id!("group@muc.prose.org"), "Group"))); + deps.connected_rooms_repo + .expect_delete() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| ()); + deps.sidebar_repo + .expect_delete() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| ()); + deps.bookmarks_service + .expect_delete_bookmark() + .once() + .with(predicate::eq(room_id!("group@muc.prose.org"))) + .in_sequence(&mut seq) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .handle_removal_from_room(&room_id!("group@muc.prose.org"), true) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_handles_changed_room_config() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + + // Make sure that the method calls are in the exact order… + let mut seq = Sequence::new(); + + deps.connected_rooms_repo + .expect_get() + .once() + .in_sequence(&mut seq) + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .return_once(move |_| { + Some(Arc::new( + RoomInternals::private_channel(room_id!("room@conf.prose.org")) + .with_name("Old Room Name"), + )) + }); + + deps.rooms_domain_service + .expect_reevaluate_room_spec() + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .once() + .in_sequence(&mut seq) + .return_once(|_| { + Box::pin(async { + Ok(Arc::new( + RoomInternals::private_channel(room_id!("room@conf.prose.org")) + .with_name("New Room Name"), + )) + }) + }); + + deps.sidebar_repo + .expect_get() + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .once() + .in_sequence(&mut seq) + .return_once(|_| { + Some(SidebarItem::public_channel( + room_id!("room@conf.prose.org"), + "Old Room Name", + )) + }); + + deps.sidebar_repo + .expect_put() + .with(predicate::eq(SidebarItem::private_channel( + room_id!("room@conf.prose.org"), + "New Room Name", + ))) + .once() + .in_sequence(&mut seq) + .return_once(|_| ()); + + deps.bookmarks_service + .expect_save_bookmark() + .with(predicate::eq( + Bookmark::private_channel(room_id!("room@conf.prose.org"), "New Room Name") + .set_in_sidebar(true), + )) + .once() + .in_sequence(&mut seq) + .return_once(|_| Box::pin(async { Ok(()) })); + + deps.client_event_dispatcher + .expect_dispatch_event() + .once() + .with(predicate::eq(ClientEvent::SidebarChanged)) + .return_once(|_| ()); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .handle_changed_room_config(&room_id!("room@conf.prose.org")) + .await?; + + Ok(()) +} + +#[tokio::test] +async fn test_ignores_changed_config_for_pending_room() -> Result<()> { + let mut deps = MockSidebarDomainServiceDependencies::default(); + + deps.connected_rooms_repo + .expect_get() + .once() + .with(predicate::eq(room_id!("room@conf.prose.org"))) + .return_once(move |_| { + Some(Arc::new(RoomInternals::pending( + &room_id!("room@conf.prose.org"), + "nick", + ))) + }); + + let service = SidebarDomainService::from(deps.into_deps()); + service + .handle_changed_room_config(&room_id!("room@conf.prose.org")) .await?; Ok(()) diff --git a/crates/prose-core-client/tests/user_data_service.rs b/crates/prose-core-client/tests/user_data_service.rs index 7a7e8708..5fc14d7d 100644 --- a/crates/prose-core-client/tests/user_data_service.rs +++ b/crates/prose-core-client/tests/user_data_service.rs @@ -9,9 +9,10 @@ use anyhow::Result; use chrono::{TimeZone, Utc}; use mockall::predicate; +use prose_core_client::domain::shared::models::{UserId, UserResourceId}; use prose_core_client::services::UserDataService; use prose_core_client::test::{ConstantTimeProvider, MockAppDependencies}; -use prose_xmpp::{bare, full, jid}; +use prose_core_client::{user_id, user_resource_id}; #[tokio::test] async fn test_load_user_metadata_resolves_full_jid() -> Result<()> { @@ -20,23 +21,23 @@ async fn test_load_user_metadata_resolves_full_jid() -> Result<()> { deps.time_provider = Arc::new(ConstantTimeProvider::ymd(2023, 09, 11)); deps.user_info_repo - .expect_resolve_bare_jid_to_full() + .expect_resolve_user_id_to_user_resource_id() .once() - .with(predicate::eq(bare!("request@prose.org"))) - .return_once(|_| jid!("request@prose.org/resource")); + .with(predicate::eq(user_id!("request@prose.org"))) + .return_once(|_| Some(user_resource_id!("request@prose.org/resource"))); deps.user_profile_service .expect_load_user_metadata() .once() .with( - predicate::eq(full!("request@prose.org/resource")), + predicate::eq(user_resource_id!("request@prose.org/resource")), predicate::eq(Utc.with_ymd_and_hms(2023, 09, 11, 0, 0, 0).unwrap()), ) .return_once(|_, _| Box::pin(async { Ok(None) })); let service = UserDataService::from(&deps.into_deps()); service - .load_user_metadata(&bare!("request@prose.org")) + .load_user_metadata(&user_id!("request@prose.org")) .await?; Ok(()) diff --git a/crates/prose-core-client/tests/user_state_event_handler.rs b/crates/prose-core-client/tests/user_state_event_handler.rs deleted file mode 100644 index e722ceed..00000000 --- a/crates/prose-core-client/tests/user_state_event_handler.rs +++ /dev/null @@ -1,87 +0,0 @@ -// prose-core-client/prose-core-client -// -// Copyright: 2023, Marc Bauer -// License: Mozilla Public License v2.0 (MPL v2.0) - -use anyhow::Result; -use mockall::predicate; -use xmpp_parsers::presence::Presence; - -use prose_core_client::app::event_handlers::{UserStateEventHandler, XMPPEvent, XMPPEventHandler}; -use prose_core_client::domain::connection::models::ConnectionProperties; -use prose_core_client::domain::user_info::models::Presence as DomainPresence; -use prose_core_client::dtos::Availability; -use prose_core_client::test::MockAppDependencies; -use prose_core_client::ClientEvent; -use prose_xmpp::{bare, full, jid, mods}; - -#[tokio::test] -async fn test_handles_presence() -> Result<()> { - let mut deps = MockAppDependencies::default(); - - deps.user_info_repo - .expect_set_user_presence() - .once() - .with( - predicate::eq(jid!("sender@prose.org/resource")), - predicate::eq(DomainPresence { - priority: 1, - availability: Availability::Available, - status: None, - }), - ) - .return_once(|_, _| Box::pin(async { Ok(()) })); - - deps.client_event_dispatcher - .expect_dispatch_event() - .once() - .with(predicate::eq(ClientEvent::ContactChanged { - jid: bare!("sender@prose.org"), - })) - .return_once(|_| ()); - - let event_handler = UserStateEventHandler::from(&deps.into_deps()); - event_handler - .handle_event(XMPPEvent::Status(mods::status::Event::Presence( - Presence::available() - .with_from(jid!("sender@prose.org/resource")) - .with_priority(1), - ))) - .await?; - - Ok(()) -} - -#[tokio::test] -/// Test that UserStateEventHandler does not send an event when a self-presence is received and -/// that the event is consumed, i.e. cannot be forwarded to other handlers. -async fn test_swallows_self_presence() -> Result<()> { - let mut deps = MockAppDependencies::default(); - - deps.ctx.set_connection_properties(ConnectionProperties { - connected_jid: full!("hello@prose.org/res"), - server_features: Default::default(), - }); - - deps.user_info_repo - .expect_set_user_presence() - .once() - .with( - predicate::eq(jid!("hello@prose.org")), - predicate::eq(DomainPresence { - availability: Availability::Available, - ..Default::default() - }), - ) - .return_once(|_, _| Box::pin(async { Ok(()) })); - - let event_handler = UserStateEventHandler::from(&deps.into_deps()); - assert!(event_handler - .handle_event(XMPPEvent::Status(mods::status::Event::Presence( - Presence::available().with_from(jid!("hello@prose.org")), - ))) - .await? - .is_none()); - - Ok(()) -} diff --git a/crates/prose-proc-macros/Cargo.toml b/crates/prose-proc-macros/Cargo.toml index ad3ba336..420abb1e 100644 --- a/crates/prose-proc-macros/Cargo.toml +++ b/crates/prose-proc-macros/Cargo.toml @@ -16,4 +16,4 @@ proc_macro = true [dependencies] convert_case = "0.6" quote = "1.0" -syn = "2.0" \ No newline at end of file +syn = { version = "2.0", features = ["full"] } \ No newline at end of file diff --git a/crates/prose-proc-macros/src/lib.rs b/crates/prose-proc-macros/src/lib.rs index 3e7d5baf..adfb6b94 100644 --- a/crates/prose-proc-macros/src/lib.rs +++ b/crates/prose-proc-macros/src/lib.rs @@ -7,7 +7,7 @@ use proc_macro::TokenStream; use convert_case::{Case, Casing}; use quote::{format_ident, quote}; -use syn::{parse_macro_input, parse_quote, Attribute, Data, DeriveInput, Fields}; +use syn::{parse_macro_input, parse_quote, Attribute, Data, DeriveInput, Fields, ItemFn}; #[proc_macro_attribute] pub fn entity(_attrs: TokenStream, stream: TokenStream) -> TokenStream { @@ -147,6 +147,7 @@ pub fn dependencies_struct(stream: TokenStream) -> TokenStream { .collect::>(); let expanded = quote! { + #[derive(Clone)] pub struct #dependencies_struct_name { #(#struct_fields,)* } @@ -162,3 +163,16 @@ pub fn dependencies_struct(stream: TokenStream) -> TokenStream { TokenStream::from(expanded) } + +/// Multi-threaded test +#[proc_macro_attribute] +pub fn mt_test(_attr: TokenStream, item: TokenStream) -> TokenStream { + let input = parse_macro_input!(item as ItemFn); + + let expanded = quote! { + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + #input + }; + + TokenStream::from(expanded) +} diff --git a/crates/prose-wasm-utils/Cargo.toml b/crates/prose-wasm-utils/Cargo.toml index e7d87cca..e5c99b92 100644 --- a/crates/prose-wasm-utils/Cargo.toml +++ b/crates/prose-wasm-utils/Cargo.toml @@ -10,6 +10,9 @@ keywords = ["xmpp", "xmpp-client", "library"] categories = ["network-programming"] authors = ["Marc Bauer "] +[dependencies] +futures = { workspace = true } + [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { workspace = true } wasm-bindgen-futures = { workspace = true } diff --git a/crates/prose-wasm-utils/src/future_ext.rs b/crates/prose-wasm-utils/src/future_ext.rs new file mode 100644 index 00000000..df48da45 --- /dev/null +++ b/crates/prose-wasm-utils/src/future_ext.rs @@ -0,0 +1,27 @@ +// prose-wasm-utils/prose-wasm-utils +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use futures::FutureExt; +use std::future::Future; + +impl ProseFutureExt for T where T: Future {} + +pub trait ProseFutureExt: Future { + #[cfg(target_arch = "wasm32")] + fn prose_boxed<'a>(self) -> futures::future::LocalBoxFuture<'a, Self::Output> + where + Self: Sized + 'a, + { + self.boxed_local() + } + + #[cfg(not(target_arch = "wasm32"))] + fn prose_boxed<'a>(self) -> futures::future::BoxFuture<'a, Self::Output> + where + Self: Sized + Send + 'a, + { + self.boxed() + } +} diff --git a/crates/prose-wasm-utils/src/lib.rs b/crates/prose-wasm-utils/src/lib.rs index 80dff792..ed8ac6cf 100644 --- a/crates/prose-wasm-utils/src/lib.rs +++ b/crates/prose-wasm-utils/src/lib.rs @@ -1,6 +1,15 @@ +// prose-wasm-utils/prose-wasm-utils +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + use std::future::Future; use std::pin::Pin; +pub use future_ext::ProseFutureExt; + +mod future_ext; + #[cfg(not(target_arch = "wasm32"))] pub trait SendUnlessWasm: Send {} diff --git a/crates/prose-xmpp/Cargo.toml b/crates/prose-xmpp/Cargo.toml index 968c41e5..9080ad14 100644 --- a/crates/prose-xmpp/Cargo.toml +++ b/crates/prose-xmpp/Cargo.toml @@ -44,3 +44,4 @@ uuid = { workspace = true, features = ["v4", "fast-rng", "macro-diagnostics"] } [features] default = [] test = ["dep:insta"] +trace-stanzas = [] \ No newline at end of file diff --git a/crates/prose-xmpp/src/client/builder.rs b/crates/prose-xmpp/src/client/builder.rs index bac37505..078422dc 100644 --- a/crates/prose-xmpp/src/client/builder.rs +++ b/crates/prose-xmpp/src/client/builder.rs @@ -42,6 +42,17 @@ impl ClientBuilder { time_provider: Box::new(SystemTimeProvider::default()), event_handler: Box::new(|_, _| Box::pin(async {}) as PinnedFuture<_>), } + // Order matters… + .add_mod(mods::Bookmark2::default()) + .add_mod(mods::Bookmark::default()) + .add_mod(mods::Status::default()) + .add_mod(mods::Caps::default()) + .add_mod(mods::MUC::default()) + .add_mod(mods::Chat::default()) + .add_mod(mods::MAM::default()) + .add_mod(mods::Profile::default()) + .add_mod(mods::PubSub::default()) + .add_mod(mods::Roster::default()) } pub fn set_connector_provider(self, connector_provider: ConnectorProvider) -> Self { @@ -73,12 +84,6 @@ impl ClientBuilder { } } - pub fn add_mod(mut self, m: M) -> Self { - self.mods - .insert(TypeId::of::(), RwLock::new(Box::new(m))); - self - } - pub fn set_id_provider(mut self, id_provider: P) -> Self { self.id_provider = Box::new(id_provider); self @@ -91,10 +96,10 @@ impl ClientBuilder { pub fn build(self) -> Client { let mut mods = self.mods; - mods.insert( + mods.push(( TypeId::of::(), RwLock::new(Box::new(mods::Ping::default())), - ); + )); let mods = Arc::new(mods); @@ -109,7 +114,7 @@ impl ClientBuilder { event_handler: self.event_handler, }); - for m in mods.values() { + for (_, m) in mods.iter() { m.write().register_with(ModuleContext { inner: context_inner.clone(), }); @@ -124,6 +129,14 @@ impl ClientBuilder { } } +impl ClientBuilder { + fn add_mod(mut self, m: M) -> Self { + self.mods + .push((TypeId::of::(), RwLock::new(Box::new(m)))); + self + } +} + #[cfg_attr(target_arch = "wasm32", async_trait(? Send))] #[async_trait] impl Connector for UndefinedConnector { diff --git a/crates/prose-xmpp/src/client/client.rs b/crates/prose-xmpp/src/client/client.rs index b8a0ca54..4e1f8fac 100644 --- a/crates/prose-xmpp/src/client/client.rs +++ b/crates/prose-xmpp/src/client/client.rs @@ -116,10 +116,10 @@ impl ClientInner { } fn get_mod(&self) -> M { - let Some(guard) = self.mods.get(&TypeId::of::()) else { + let Some(entry) = self.mods.iter().find(|(k, _)| k == &TypeId::of::()) else { panic!("Could not find requested module.") }; - guard.read().as_any().downcast_ref::().unwrap().clone() + entry.1.read().as_any().downcast_ref::().unwrap().clone() } async fn handle_event(self: Arc, event: ConnectionEvent) { diff --git a/crates/prose-xmpp/src/client/mod.rs b/crates/prose-xmpp/src/client/mod.rs index df3cf00f..989b5edf 100644 --- a/crates/prose-xmpp/src/client/mod.rs +++ b/crates/prose-xmpp/src/client/mod.rs @@ -4,19 +4,19 @@ // License: Mozilla Public License v2.0 (MPL v2.0) use std::any::TypeId; -use std::collections::BTreeMap; use parking_lot::RwLock; -use crate::connector::ConnectionError; -use crate::connector::Connector; -use crate::mods::AnyModule; -use crate::Event as ClientEvent; pub use builder::ClientBuilder; pub use client::Client; pub(crate) use module_context::ModuleContext; use prose_wasm_utils::PinnedFuture; +use crate::connector::ConnectionError; +use crate::connector::Connector; +use crate::mods::AnyModule; +use crate::Event as ClientEvent; + mod builder; mod client; mod module_context; @@ -26,7 +26,7 @@ pub type EventHandler = Box PinnedFuture<()>>; #[cfg(not(target_arch = "wasm32"))] pub type EventHandler = Box PinnedFuture<()> + Send + Sync>; -pub(super) type ModuleLookup = BTreeMap>>; +pub(super) type ModuleLookup = Vec<(TypeId, RwLock>)>; #[cfg(target_arch = "wasm32")] pub type ConnectorProvider = Box Box>; diff --git a/crates/prose-xmpp/src/connector/xmpp_rs.rs b/crates/prose-xmpp/src/connector/xmpp_rs.rs index 2904fbfe..2877f23e 100644 --- a/crates/prose-xmpp/src/connector/xmpp_rs.rs +++ b/crates/prose-xmpp/src/connector/xmpp_rs.rs @@ -6,7 +6,6 @@ use std::sync::Arc; use std::time::Duration; -use crate::client::ConnectorProvider; use anyhow::Result; use async_trait::async_trait; use futures::stream::StreamExt; @@ -20,6 +19,7 @@ use tokio::{task, time}; use tokio_xmpp::{AsyncClient, Error, Event, Packet}; use tracing::error; +use crate::client::ConnectorProvider; use crate::connector::{ Connection as ConnectionTrait, ConnectionError, ConnectionEvent, ConnectionEventHandler, Connector as ConnectorTrait, @@ -106,11 +106,15 @@ impl Connection { msg: err.to_string(), }), }, - ); + ) + .await; break; } Event::Online { .. } => (), Event::Stanza(stanza) => { + #[cfg(feature = "trace-stanzas")] + tracing::info!(direction = "IN", "{}", String::from(&stanza)); + let fut = (event_handler)(&conn, ConnectionEvent::Stanza(stanza)); task::spawn(async move { fut.await }); } @@ -180,6 +184,8 @@ impl Connection { impl ConnectionTrait for Connection { fn send_stanza(&self, stanza: Element) -> Result<()> { + #[cfg(feature = "trace-stanzas")] + tracing::info!(direction = "OUT", "{}", String::from(&stanza)); self.sender.send(Packet::Stanza(stanza))?; Ok(()) } diff --git a/crates/prose-xmpp/src/mods/chat.rs b/crates/prose-xmpp/src/mods/chat.rs index bbc69b68..e254e713 100644 --- a/crates/prose-xmpp/src/mods/chat.rs +++ b/crates/prose-xmpp/src/mods/chat.rs @@ -120,6 +120,11 @@ impl Chat { self.send_message_stanza(stanza) } + pub fn send_raw_message(&self, message: Message) -> Result<()> { + self.ctx.send_stanza(message)?; + Ok(()) + } + pub fn update_message( &self, id: message::Id, diff --git a/crates/prose-xmpp/src/mods/muc.rs b/crates/prose-xmpp/src/mods/muc.rs index d2b3ced1..c83cf30c 100644 --- a/crates/prose-xmpp/src/mods/muc.rs +++ b/crates/prose-xmpp/src/mods/muc.rs @@ -73,6 +73,10 @@ impl Module for MUC { return Ok(()); }; + if stanza.type_ != MessageType::Normal { + return Ok(()); + } + if let Some(direct_invite) = stanza.direct_invite() { self.ctx .schedule_event(ClientEvent::MUC(Event::DirectInvite { @@ -258,11 +262,15 @@ impl MUC { /// Destroys a room. /// https://xmpp.org/extensions/xep-0045.html#destroyroom - pub async fn destroy_room(&self, jid: &BareJid) -> Result<()> { + pub async fn destroy_room( + &self, + jid: &BareJid, + alternate_room: Option<&BareJid>, + ) -> Result<()> { let iq = Iq::from_set( self.ctx.generate_id(), Query::new(Role::Owner).with_payload(Destroy { - jid: None, + jid: alternate_room.cloned(), reason: None, }), ) diff --git a/crates/prose-xmpp/src/mods/pubsub.rs b/crates/prose-xmpp/src/mods/pubsub.rs index a7f23b96..861301f3 100644 --- a/crates/prose-xmpp/src/mods/pubsub.rs +++ b/crates/prose-xmpp/src/mods/pubsub.rs @@ -6,13 +6,15 @@ use anyhow::Result; use jid::Jid; use minidom::Element; +use std::collections::HashSet; +use std::sync::OnceLock; use xmpp_parsers::data_forms::DataForm; use xmpp_parsers::disco::Item as DiscoItem; use xmpp_parsers::disco::{DiscoItemsQuery, DiscoItemsResult}; use xmpp_parsers::iq::{Iq, IqType}; use xmpp_parsers::pubsub::owner::Configure; use xmpp_parsers::pubsub::pubsub::{Item, Items, Notify, PublishOptions, Retract}; -use xmpp_parsers::pubsub::{pubsub, Item as PubSubItem, ItemId, NodeName}; +use xmpp_parsers::pubsub::{pubsub, Item as PubSubItem, ItemId, NodeName, PubSubEvent}; use crate::client::ModuleContext; use crate::event::Event as ClientEvent; @@ -37,10 +39,31 @@ impl Module for PubSub { } fn handle_pubsub_message(&self, pubsub: &PubSubMessage) -> Result<()> { + let Some(node) = pubsub.events.first().map(|event| event.node()) else { + return Ok(()); + }; + + static IGNORED_PUBSUB_NODES: OnceLock> = OnceLock::new(); + let ignored_pubsub_nodes = IGNORED_PUBSUB_NODES.get_or_init(|| { + let mut m = HashSet::new(); + m.insert(ns::AVATAR_METADATA); + m.insert(ns::BOOKMARKS); + m.insert(ns::BOOKMARKS2); + m.insert(ns::USER_ACTIVITY); + m.insert(ns::VCARD4); + m + }); + + // Ignore nodes that are handled in other modules… + if ignored_pubsub_nodes.contains(node.0.as_str()) { + return Ok(()); + } + self.ctx .schedule_event(ClientEvent::PubSub(Event::PubSubMessage { message: pubsub.clone(), })); + Ok(()) } } @@ -277,3 +300,20 @@ impl PubSub { Ok(sub) } } + +trait PubSubEventExt { + fn node(&self) -> &NodeName; +} + +impl PubSubEventExt for PubSubEvent { + fn node(&self) -> &NodeName { + match self { + PubSubEvent::Configuration { node, .. } => node, + PubSubEvent::Delete { node, .. } => node, + PubSubEvent::PublishedItems { node, .. } => node, + PubSubEvent::RetractedItems { node, .. } => node, + PubSubEvent::Purge { node, .. } => node, + PubSubEvent::Subscription { node, .. } => node, + } + } +} diff --git a/crates/prose-xmpp/src/mods/status.rs b/crates/prose-xmpp/src/mods/status.rs index 51c4ab26..7b036ce3 100644 --- a/crates/prose-xmpp/src/mods/status.rs +++ b/crates/prose-xmpp/src/mods/status.rs @@ -3,17 +3,18 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::client::ModuleContext; -use crate::mods::Module; -use crate::stanza::UserActivity; -use crate::{ns, Event as ClientEvent}; use anyhow::Result; use jid::Jid; use xmpp_parsers::iq::Iq; -use xmpp_parsers::presence::Presence; +use xmpp_parsers::presence::{Presence, Type}; use xmpp_parsers::pubsub::{NodeName, PubSub, PubSubEvent}; use xmpp_parsers::{presence, pubsub}; +use crate::client::ModuleContext; +use crate::mods::Module; +use crate::stanza::UserActivity; +use crate::{ns, ElementExt, Event as ClientEvent}; + #[derive(Default, Clone)] pub struct Status { ctx: ModuleContext, @@ -24,7 +25,7 @@ pub enum Event { Presence(Presence), UserActivity { from: Jid, - user_activity: UserActivity, + user_activity: Option, }, } @@ -34,6 +35,16 @@ impl Module for Status { } fn handle_presence_stanza(&self, stanza: &Presence) -> Result<()> { + match stanza.type_ { + Type::Error + | Type::Probe + | Type::Subscribe + | Type::Subscribed + | Type::Unsubscribe + | Type::Unsubscribed => return Ok(()), + Type::None | Type::Unavailable => {} + } + self.ctx .schedule_event(ClientEvent::Status(Event::Presence(stanza.clone()))); Ok(()) @@ -44,23 +55,31 @@ impl Module for Status { return Ok(()); }; - match node.0.as_ref() { - ns::USER_ACTIVITY => { - let Some(item) = items.first() else { - return Ok(()); - }; - let Some(payload) = &item.payload else { - return Ok(()); - }; - let user_activity = UserActivity::try_from(payload.clone())?; - self.ctx - .schedule_event(ClientEvent::Status(Event::UserActivity { - from: from.clone(), - user_activity, - })); - } - _ => (), + if node.0 != ns::USER_ACTIVITY { + return Ok(()); } + + let Some(item) = items.first() else { + return Ok(()); + }; + let Some(payload) = &item.payload else { + return Ok(()); + }; + + payload.expect_is("activity", ns::USER_ACTIVITY)?; + + let user_activity = if payload.children().next().is_none() { + None + } else { + Some(UserActivity::try_from(payload.clone())?) + }; + + self.ctx + .schedule_event(ClientEvent::Status(Event::UserActivity { + from: from.clone(), + user_activity, + })); + Ok(()) } } @@ -73,6 +92,7 @@ impl Status { show: Option, status: Option<&str>, caps: Option, + priority: Option, ) -> Result<()> { let mut presence = Presence::new(presence::Type::None); presence.show = show; @@ -82,6 +102,9 @@ impl Status { if let Some(caps) = caps { presence.add_payload(caps) } + if let Some(priority) = priority { + presence.priority = priority + } self.ctx.send_stanza(presence)?; Ok(()) } diff --git a/crates/prose-xmpp/src/stanza/message/builder.rs b/crates/prose-xmpp/src/stanza/message/builder.rs index 52ea91bf..ea1f760e 100644 --- a/crates/prose-xmpp/src/stanza/message/builder.rs +++ b/crates/prose-xmpp/src/stanza/message/builder.rs @@ -7,6 +7,7 @@ use jid::Jid; use minidom::Element; use xmpp_parsers::chatstates::ChatState; +use xmpp_parsers::delay::Delay; use xmpp_parsers::message::{Body, MessagePayload, MessageType, Subject}; use xmpp_parsers::message_correct::Replace; @@ -49,6 +50,11 @@ impl Message { self } + pub fn set_delay(mut self, delay: Delay) -> Self { + self.payloads.push(delay.into()); + self + } + pub fn add_payload(mut self, payload: P) -> Self { self.payloads.push(payload.into()); self diff --git a/crates/prose-xmpp/src/stanza/message/message.rs b/crates/prose-xmpp/src/stanza/message/message.rs index 769012fa..e49afc8d 100644 --- a/crates/prose-xmpp/src/stanza/message/message.rs +++ b/crates/prose-xmpp/src/stanza/message/message.rs @@ -18,6 +18,7 @@ use prose_utils::id_string; use crate::ns; use crate::stanza::message::fasten::ApplyTo; +use crate::stanza::message::muc_invite::MucInvite; use crate::stanza::message::muc_user::MucUser; use crate::stanza::message::stanza_id::StanzaId; use crate::stanza::message::{carbons, Reactions}; @@ -144,6 +145,10 @@ impl Message { pub fn muc_user(&self) -> Option { self.typed_payload("x", ns::MUC_USER) } + + pub fn muc_invite(&self) -> Option { + self.typed_payload("x", ns::MUC_USER) + } } impl Message { diff --git a/crates/prose-xmpp/src/stanza/message/mod.rs b/crates/prose-xmpp/src/stanza/message/mod.rs index 87bc37b0..bf9cd3ca 100644 --- a/crates/prose-xmpp/src/stanza/message/mod.rs +++ b/crates/prose-xmpp/src/stanza/message/mod.rs @@ -19,6 +19,7 @@ pub mod fasten; mod forwarding; pub mod mam; mod message; +mod muc_invite; mod muc_user; mod reactions; pub mod retract; diff --git a/crates/prose-xmpp/src/stanza/message/muc_invite.rs b/crates/prose-xmpp/src/stanza/message/muc_invite.rs new file mode 100644 index 00000000..5a1e7694 --- /dev/null +++ b/crates/prose-xmpp/src/stanza/message/muc_invite.rs @@ -0,0 +1,125 @@ +// prose-core-client/prose-xmpp +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use std::str::FromStr; + +use anyhow::Result; +use jid::BareJid; +use minidom::Element; +use xmpp_parsers::message::MessagePayload; +use xmpp_parsers::muc::user::Affiliation; + +use crate::{ns, ElementExt, ParseError}; + +#[derive(Debug, Clone, PartialEq)] +pub struct MucInvite { + pub jid: BareJid, + pub affiliation: Affiliation, + pub reason: Option, +} + +impl TryFrom for MucInvite { + type Error = anyhow::Error; + + fn try_from(value: Element) -> Result { + value.expect_is("x", ns::MUC_USER)?; + + let Some(item) = value.get_child("item", ns::MUC_USER) else { + return Err(ParseError::Generic { + msg: "Missing item in MucUser".to_string(), + } + .into()); + }; + + let jid = BareJid::from_str(item.attr_req("jid")?)?; + let affiliation = Affiliation::from_str(item.attr_req("affiliation")?)?; + let reason = item.children().find_map(|child| { + if !child.is("reason", ns::MUC_USER) { + return None; + } + let text = child.text(); + (!text.is_empty()).then_some(text) + }); + + Ok(Self { + jid, + affiliation, + reason, + }) + } +} + +impl From for Element { + fn from(value: MucInvite) -> Self { + let affiliation = match &value.affiliation { + Affiliation::Owner => "owner", + Affiliation::Admin => "admin", + Affiliation::Member => "member", + Affiliation::Outcast => "outcast", + Affiliation::None => "none", + }; + + Element::builder("x", ns::MUC_USER) + .append( + Element::builder("item", ns::MUC_USER) + .attr("jid", value.jid) + .attr("affiliation", affiliation) + .append_all(value.reason.map(|reason| { + Element::builder("reason", ns::MUC_USER) + .append(reason) + .build() + })), + ) + .build() + } +} + +impl MessagePayload for MucInvite {} + +#[cfg(test)] +mod tests { + use crate::bare; + + use super::*; + + #[test] + fn test_deserialize() -> Result<()> { + let xml = r#" + + Invited by world@prose.org/res + + "#; + + let elem = Element::from_str(xml)?; + let user = MucInvite::try_from(elem)?; + + assert_eq!( + user, + MucInvite { + jid: bare!("hello@prose.org"), + affiliation: Affiliation::Member, + reason: Some("Invited by world@prose.org/res".to_string()), + } + ); + + Ok(()) + } + + #[test] + fn test_serialize() -> Result<()> { + let user = MucInvite { + jid: bare!("user@prose.org"), + affiliation: Affiliation::Owner, + reason: Some("User was invited by other_user@prose.org".to_string()), + }; + + let elem = Element::from(user.clone()); + let parsed_user = MucInvite::try_from(elem)?; + + assert_eq!(parsed_user, user); + + Ok(()) + } +} diff --git a/crates/prose-xmpp/src/stanza/muc/mediated_invite.rs b/crates/prose-xmpp/src/stanza/muc/mediated_invite.rs index b1e86186..4e54f52b 100644 --- a/crates/prose-xmpp/src/stanza/muc/mediated_invite.rs +++ b/crates/prose-xmpp/src/stanza/muc/mediated_invite.rs @@ -3,13 +3,16 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use crate::ns; -use crate::util::ElementExt; +use std::str::FromStr; + +use anyhow::bail; use jid::Jid; use minidom::Element; -use std::str::FromStr; use xmpp_parsers::message::MessagePayload; +use crate::ns; +use crate::util::ElementExt; + #[derive(Debug, PartialEq, Clone)] pub struct MediatedInvite { pub invites: Vec, @@ -60,6 +63,10 @@ impl TryFrom for MediatedInvite { } } + if invites.is_empty() { + bail!("Missing invite element in mediated invite") + } + Ok(MediatedInvite { invites, password }) } } @@ -117,10 +124,12 @@ impl TryFrom for Continue { } #[cfg(test)] mod tests { - use super::*; - use crate::jid; use anyhow::Result; + use crate::jid; + + use super::*; + #[test] fn test_deserialize_mediated_invite() -> Result<()> { let xml = r#" diff --git a/crates/prose-xmpp/src/stanza/muc/mod.rs b/crates/prose-xmpp/src/stanza/muc/mod.rs index d40e24b6..e2c14c48 100644 --- a/crates/prose-xmpp/src/stanza/muc/mod.rs +++ b/crates/prose-xmpp/src/stanza/muc/mod.rs @@ -5,9 +5,11 @@ pub use direct_invite::DirectInvite; pub use mediated_invite::{Continue, Invite, MediatedInvite}; +pub use muc_user::MucUser; pub use query::Query; pub mod direct_invite; pub mod mediated_invite; +pub mod muc_user; pub mod ns; pub mod query; diff --git a/crates/prose-xmpp/src/stanza/muc/muc_user.rs b/crates/prose-xmpp/src/stanza/muc/muc_user.rs new file mode 100644 index 00000000..d1590378 --- /dev/null +++ b/crates/prose-xmpp/src/stanza/muc/muc_user.rs @@ -0,0 +1,206 @@ +// prose-core-client/prose-core-client +// +// Copyright: 2023, Marc Bauer +// License: Mozilla Public License v2.0 (MPL v2.0) + +use crate::{ns, ElementExt, RequestError}; +use jid::BareJid; +use minidom::Element; +use std::str::FromStr; +use xmpp_parsers::message::MessagePayload; +use xmpp_parsers::muc::user::{Item, Status}; +use xmpp_parsers::presence::PresencePayload; + +#[derive(Debug, PartialEq, Clone, Default)] +pub struct MucUser { + /// List of statuses applying to this item. + pub status: Vec, + + /// List of items. + pub items: Vec, + + /// Has the room been destroyed? + pub destroy: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Destroy { + pub jid: Option, + pub reason: Option, +} + +impl MucUser { + pub fn new() -> Self { + Self::default() + } + + pub fn with_status(mut self, status: impl IntoIterator) -> Self { + self.status = status.into_iter().collect(); + self + } + + pub fn with_items(mut self, items: impl IntoIterator) -> Self { + self.items = items.into_iter().collect(); + self + } + + pub fn with_destroy(mut self, destroy: Destroy) -> Self { + self.destroy = Some(destroy); + self + } +} + +impl MessagePayload for MucUser {} +impl PresencePayload for MucUser {} + +impl TryFrom for MucUser { + type Error = RequestError; + + fn try_from(root: Element) -> Result { + root.expect_is("x", ns::MUC_USER)?; + + let mut user = MucUser::default(); + + for child in root.children() { + match child { + _ if child.is("item", ns::MUC_USER) => { + user.items.push(Item::try_from(child.clone())?); + } + _ if child.is("status", ns::MUC_USER) => { + user.status.push(Status::try_from(child.clone())?); + } + _ if child.is("destroy", ns::MUC_USER) => { + user.destroy = Some(Destroy::try_from(child.clone())?); + } + _ => { + return Err(RequestError::Generic { + msg: format!( + "Encountered unexpected payload {} in muc query.", + child.name() + ), + }) + } + } + } + + Ok(user) + } +} + +impl From for Element { + fn from(value: MucUser) -> Self { + Element::builder("x", ns::MUC_USER) + .append_all(value.status) + .append_all(value.items) + .append_all(value.destroy) + .build() + } +} + +impl TryFrom for Destroy { + type Error = RequestError; + + fn try_from(root: Element) -> Result { + root.expect_is("destroy", ns::MUC_USER)?; + + Ok(Destroy { + jid: root.attr("jid").map(BareJid::from_str).transpose()?, + reason: root + .get_child("reason", ns::MUC_USER) + .map(|node| node.text()), + }) + } +} + +impl From for Element { + fn from(value: Destroy) -> Self { + Element::builder("destroy", ns::MUC_USER) + .attr("jid", value.jid) + .append_all(value.reason.map(|reason| { + Element::builder("reason", ns::MUC_USER) + .append(reason) + .build() + })) + .build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bare; + use anyhow::Result; + use std::str::FromStr; + use xmpp_parsers::muc::user::{Affiliation, Role}; + + #[test] + fn test_deserialize_muc_user() -> Result<()> { + let xml = r#" + + + + + Macbeth doth come. + + + "#; + + let elem = Element::from_str(xml)?; + let user = MucUser::try_from(elem)?; + + assert_eq!( + user, + MucUser { + status: vec![ + Status::AffiliationChange, + Status::ConfigShowsUnavailableMembers + ], + items: vec![Item { + affiliation: Affiliation::Member, + jid: None, + nick: None, + role: Role::Moderator, + actor: None, + continue_: None, + reason: None, + }], + destroy: Some(Destroy { + jid: Some(bare!("coven@chat.shakespeare.lit")), + reason: Some("Macbeth doth come.".to_string()) + }), + } + ); + + Ok(()) + } + + #[test] + fn test_serialize_muc_user() -> Result<()> { + let user = MucUser { + status: vec![ + Status::AffiliationChange, + Status::ConfigShowsUnavailableMembers, + ], + items: vec![Item { + affiliation: Affiliation::Member, + jid: None, + nick: None, + role: Role::Moderator, + actor: None, + continue_: None, + reason: None, + }], + destroy: Some(Destroy { + jid: Some(bare!("coven@chat.shakespeare.lit")), + reason: Some("Macbeth doth come.".to_string()), + }), + }; + + let elem = Element::try_from(user.clone())?; + let parsed_user = MucUser::try_from(elem)?; + + assert_eq!(user, parsed_user); + + Ok(()) + } +} diff --git a/crates/prose-xmpp/src/stanza/muc/query.rs b/crates/prose-xmpp/src/stanza/muc/query.rs index 16a97aca..7335da53 100644 --- a/crates/prose-xmpp/src/stanza/muc/query.rs +++ b/crates/prose-xmpp/src/stanza/muc/query.rs @@ -34,6 +34,7 @@ pub struct Destroy { pub struct User { pub jid: Jid, pub affiliation: muc::user::Affiliation, + pub nick: Option, } impl Query { @@ -135,7 +136,7 @@ impl TryFrom for Destroy { Ok(Destroy { jid: root.attr("jid").map(BareJid::from_str).transpose()?, reason: root - .get_child("destroy", ns::MUC_OWNER) + .get_child("reason", ns::MUC_OWNER) .map(|node| node.text()), }) } @@ -167,6 +168,7 @@ impl TryFrom for User { msg: err.to_string(), }, )?, + nick: root.attr("nick").map(ToString::to_string), }) } } diff --git a/crates/prose-xmpp/src/stanza/ns.rs b/crates/prose-xmpp/src/stanza/ns.rs index a2716918..aff9ff6b 100644 --- a/crates/prose-xmpp/src/stanza/ns.rs +++ b/crates/prose-xmpp/src/stanza/ns.rs @@ -51,3 +51,6 @@ pub const MUC_ROOMINFO: &str = "http://jabber.org/protocol/muc#roominfo"; /// XEP-0249: Direct MUC Invitations pub const DIRECT_MUC_INVITATIONS: &str = "jabber:x:conference"; + +/// XEP-0421: Anonymous unique occupant identifiers for MUCs +pub const OCCUPANT_ID: &str = "urn:xmpp:occupant-id:0"; diff --git a/crates/prose-xmpp/src/test/connected_client.rs b/crates/prose-xmpp/src/test/connected_client.rs index 39cbaee8..72eff64b 100644 --- a/crates/prose-xmpp/src/test/connected_client.rs +++ b/crates/prose-xmpp/src/test/connected_client.rs @@ -12,7 +12,7 @@ use jid::{BareJid, FullJid}; use parking_lot::RwLock; use crate::test::{BareJidTestAdditions, Connection, Connector, IncrementingIDProvider}; -use crate::{mods, Client, Event, IDProvider}; +use crate::{Client, Event, IDProvider}; #[async_trait(?Send)] pub trait ClientTestAdditions { @@ -41,14 +41,6 @@ impl ClientTestAdditions for Client { handler_events.write().push(event); async {} })) - .add_mod(mods::Bookmark::default()) - .add_mod(mods::Bookmark2::default()) - .add_mod(mods::Caps::default()) - .add_mod(mods::Chat::default()) - .add_mod(mods::MAM::default()) - .add_mod(mods::Profile::default()) - .add_mod(mods::Roster::default()) - .add_mod(mods::Status::default()) .build(); client diff --git a/examples/.gitignore b/examples/.gitignore index 5e465967..906cad71 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1 +1,2 @@ -cache \ No newline at end of file +cache +logs \ No newline at end of file diff --git a/examples/common/Cargo.toml b/examples/common/Cargo.toml index 32e7fa31..3c5fde9d 100644 --- a/examples/common/Cargo.toml +++ b/examples/common/Cargo.toml @@ -9,5 +9,6 @@ publish = false dotenvy = "0.15" jid = { workspace = true } tracing = { workspace = true } +tracing-appender = "0.2" tracing-oslog = "0.1.2" -tracing-subscriber = { workspace = true } \ No newline at end of file +tracing-subscriber = { workspace = true, features = ["json"] } \ No newline at end of file diff --git a/examples/common/src/lib.rs b/examples/common/src/lib.rs index e68d613b..be865dec 100644 --- a/examples/common/src/lib.rs +++ b/examples/common/src/lib.rs @@ -3,9 +3,10 @@ // Copyright: 2023, Marc Bauer // License: Mozilla Public License v2.0 (MPL v2.0) -use std::env; +use std::ffi::OsStr; use std::str::FromStr; use std::time::Instant; +use std::{env, fs}; use dotenvy; use jid::{BareJid, FullJid, ResourcePart}; @@ -14,11 +15,44 @@ pub use tracing::Level; use tracing_oslog::OsLogger; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::Layer; +use tracing_subscriber::{Layer, Registry}; pub fn enable_debug_logging(max_level: Level) { - tracing_subscriber::registry() - .with(OsLogger::new("org.prose", "default").with_filter(LevelFilter::from_level(max_level))) + // Get the current executable path + let exe_path = env::current_exe().expect("Failed to get the current executable path"); + + // Extract the file stem (name without extension) + let exe_stem = exe_path + .file_stem() + .and_then(OsStr::to_str) + .expect("Failed to extract file stem from executable name"); + + let oslog_layer = + OsLogger::new("org.prose", "default").with_filter(LevelFilter::from_level(max_level)); + + let log_dir = env::current_dir() + .expect("Cannot determine current directory") + .join("examples") + .join(exe_stem) + .join("logs"); + let log_filename = format!("{}.log", exe_stem); + + let log_file_path = log_dir.join(&log_filename); + if log_file_path.exists() { + _ = fs::remove_file(log_file_path); + } + + let appender = tracing_appender::rolling::never(log_dir, log_filename); + + let json_layer = tracing_subscriber::fmt::Layer::new() + .json() // Use the JSON formatter + .with_writer(appender) + .with_ansi(false) + .with_filter(LevelFilter::from_level(max_level)); + + Registry::default() + .with(oslog_layer) + .with(json_layer) .init(); } diff --git a/examples/prose-core-client-cli/Cargo.toml b/examples/prose-core-client-cli/Cargo.toml index 25dbaa9b..047d483e 100644 --- a/examples/prose-core-client-cli/Cargo.toml +++ b/examples/prose-core-client-cli/Cargo.toml @@ -11,7 +11,7 @@ common = { path = "../common" } dialoguer = "0.10.3" jid = { workspace = true } minidom = { workspace = true } -prose-core-client = { path = "../../crates/prose-core-client", features = ["debug"] } +prose-core-client = { path = "../../crates/prose-core-client", features = ["debug", "trace-stanzas"] } prose-xmpp = { path = "../../crates/prose-xmpp" } strum = { workspace = true } strum_macros = { workspace = true } diff --git a/examples/prose-core-client-cli/src/main.rs b/examples/prose-core-client-cli/src/main.rs index baedb1d6..840aa131 100644 --- a/examples/prose-core-client-cli/src/main.rs +++ b/examples/prose-core-client-cli/src/main.rs @@ -14,7 +14,6 @@ use std::{env, fs}; use anyhow::Result; use dialoguer::{theme::ColorfulTheme, Input, MultiSelect, Select}; use jid::{BareJid, FullJid, Jid}; -use minidom::convert::IntoAttributeValue; use minidom::Element; use strum::IntoEnumIterator; use strum_macros::{Display, EnumIter}; @@ -22,12 +21,13 @@ use url::Url; use common::{enable_debug_logging, load_credentials, Level}; use prose_core_client::dtos::{ - Address, Bookmark, Contact, Message, Occupant, PublicRoomInfo, SidebarItem, + Address, Bookmark, Contact, Message, ParticipantInfo, PublicRoomInfo, RoomId, SidebarItem, + UserId, }; use prose_core_client::infra::avatars::FsAvatarCache; use prose_core_client::services::RoomEnvelope; use prose_core_client::{ - open_store, Client, ClientDelegate, ClientEvent, RoomEventType, SqliteDriver, + open_store, Client, ClientDelegate, ClientEvent, ClientRoomEventType, SqliteDriver, }; use prose_xmpp::connector; use prose_xmpp::mods::muc; @@ -53,7 +53,9 @@ async fn configure_client() -> Result<(BareJid, Client)> { let (jid, password) = load_credentials(); println!("Connecting to server as {}…", jid); - client.connect(&jid.to_bare(), password).await?; + client + .connect(&UserId::from(jid.to_bare()), password) + .await?; println!("Connected."); println!("Starting room observation…"); @@ -237,7 +239,7 @@ impl From for JidWithName { impl From for JidWithName { fn from(value: Contact) -> Self { Self { - jid: value.jid, + jid: value.id.into_inner(), name: value.name, } } @@ -284,73 +286,84 @@ impl Display for ConnectedRoomEnvelope { } } -struct OccupantEnvelope(Occupant); +struct ParticipantEnvelope(ParticipantInfo); -impl Display for OccupantEnvelope { +impl Display for ParticipantEnvelope { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!( f, - "{:<20} {:<10}", + "{:<20} {:<20} {:<10} {}", self.0 - .jid + .id .as_ref() .map(|jid| jid.to_string()) .unwrap_or("".to_string()) .truncate_to(20), - self.0 - .affiliation - .clone() - .into_attribute_value() - .unwrap_or("".to_string()), + self.0.name, + self.0.affiliation, + self.0.availability ) } } #[allow(dead_code)] -async fn select_contact(client: &Client) -> Result { +async fn select_contact(client: &Client) -> Result { let contacts = client.contacts.load_contacts().await?.into_iter(); Ok( select_item_from_list(contacts, |c| JidWithName::from(c.clone())) - .jid + .id .clone(), ) } -async fn select_multiple_contacts(client: &Client) -> Result> { +async fn select_multiple_contacts(client: &Client) -> Result> { let contacts = client .contacts .load_contacts() .await? .into_iter() .map(JidWithName::from); - Ok(select_multiple_jids_from_list(contacts)) -} - -async fn select_room(client: &Client) -> Result { - let mut rooms = client - .sidebar - .sidebar_items() + Ok(select_multiple_jids_from_list(contacts) .into_iter() - .map(|item| item.room) - .collect::>(); - rooms.sort_by(compare_room_envelopes); - Ok(select_item_from_list(rooms, |room| JidWithName::from(room.clone())).clone()) + .map(UserId::from) + .collect()) } -async fn select_muc_room(client: &Client) -> Result { +async fn select_room( + client: &Client, + filter: impl Fn(&SidebarItem) -> bool, +) -> Result> { let mut rooms = client .sidebar .sidebar_items() .into_iter() .filter_map(|room| { - if let RoomEnvelope::DirectMessage(_) = room.room { + if !filter(&room) { return None; } Some(room.room) }) .collect::>(); rooms.sort_by(compare_room_envelopes); - Ok(select_item_from_list(rooms, |room| JidWithName::from(room.clone())).clone()) + + if rooms.is_empty() { + println!("Could not find any matching rooms."); + return Ok(None); + } + + Ok(Some( + select_item_from_list(rooms, |room| JidWithName::from(room.clone())).clone(), + )) +} + +async fn select_muc_room(client: &Client) -> Result> { + select_room(client, |room| { + if let RoomEnvelope::DirectMessage(_) = room.room { + return false; + } + true + }) + .await } async fn select_public_channel(client: &Client) -> Result { @@ -398,7 +411,7 @@ fn select_multiple_jids_from_list(jids: impl IntoIterator) - .collect() } -async fn load_avatar(client: &Client, jid: &BareJid) -> Result<()> { +async fn load_avatar(client: &Client, jid: &UserId) -> Result<()> { println!("Loading avatar for {}…", jid); match client.user_data.load_avatar(jid).await? { Some(path) => println!("Saved avatar image to {:?}.", path), @@ -430,7 +443,7 @@ async fn save_avatar(client: &Client) -> Result<()> { Ok(()) } -async fn load_user_profile(client: &Client, jid: &BareJid) -> Result<()> { +async fn load_user_profile(client: &Client, jid: &UserId) -> Result<()> { println!("Loading profile for {}…", jid); let profile = client.user_data.load_user_profile(jid).await?; @@ -468,11 +481,11 @@ async fn load_user_profile(client: &Client, jid: &BareJid) -> Result<()> { Ok(()) } -async fn update_user_profile(client: &Client, jid: BareJid) -> Result<()> { +async fn update_user_profile(client: &Client, id: UserId) -> Result<()> { println!("Loading current profile…"); let mut profile = client .user_data - .load_user_profile(&jid) + .load_user_profile(&id) .await? .unwrap_or_default(); @@ -514,7 +527,7 @@ async fn load_contacts(client: &Client) -> Result<()> { Availability: {:?} Group: {:?} "#, - contact.jid, contact.name, contact.availability, contact.group, + contact.id, contact.name, contact.availability, contact.group, ); } @@ -522,7 +535,9 @@ async fn load_contacts(client: &Client) -> Result<()> { } async fn load_messages(client: &Client) -> Result<()> { - let room = select_room(client).await?; + let Some(room) = select_room(client, |_| true).await? else { + return Ok(()); + }; let messages = match room { RoomEnvelope::DirectMessage(room) => room.load_latest_messages().await?, @@ -559,7 +574,9 @@ impl Display for MessageEnvelope { } async fn send_message(client: &Client) -> Result<()> { - let room = select_room(client).await?.to_generic_room(); + let Some(room) = select_room(client, |_| true).await? else { + return Ok(()); + }; let body = Input::with_theme(&ColorfulTheme::default()) .with_prompt("Enter message") @@ -568,7 +585,7 @@ async fn send_message(client: &Client) -> Result<()> { .interact_text() .unwrap(); - room.send_message(body).await + room.to_generic_room().send_message(body).await } fn format_opt(value: Option) -> String { @@ -624,7 +641,7 @@ impl Delegate { }; match r#type { - RoomEventType::MessagesAppended { message_ids } => { + ClientRoomEventType::MessagesAppended { message_ids } => { let message_id_refs = message_ids.iter().collect::>(); let messages = room .to_generic_room() @@ -723,8 +740,12 @@ enum Selection { LoadPublicRooms, #[strum(serialize = "Join public room")] JoinPublicRoom, + #[strum(serialize = "Join room by JID")] + JoinRoomByJid, #[strum(serialize = "Destroy public room")] DestroyPublicRoom, + #[strum(serialize = "Destroy connected room")] + DestroyConnectedRoom, #[strum(serialize = "List connected rooms")] ListConnectedRooms, #[strum(serialize = "Rename connected room")] @@ -737,13 +758,19 @@ enum Selection { RemoveSidebarItem, #[strum(serialize = "Set room subject")] SetRoomTopic, - #[strum(serialize = "List occupants in room")] - ListRoomOccupants, - #[strum(serialize = "List members in room")] - ListRoomMembers, + #[strum(serialize = "List participants in room")] + ListRoomParticipants, + #[strum(serialize = "Resend group invites")] + ResendGroupInvites, + #[strum(serialize = "Invite user to private channel")] + InviteUserToPrivateChannel, + #[strum(serialize = "Convert group to private channel")] + ConvertGroupToPrivateChannel, #[strum(serialize = "[Debug] Load bookmarks")] LoadBookmarks, - #[strum(serialize = "[Debug] Delete bookmarks PubSub node")] + #[strum(serialize = "[Debug] Delete individual bookmarks")] + DeleteIndividualBookmarks, + #[strum(serialize = "[Debug] Delete whole bookmarks PubSub node")] DeleteBookmarksPubSubNode, #[strum(serialize = "[Debug] Send raw XML")] SendRawXML, @@ -755,7 +782,7 @@ enum Selection { #[tokio::main] async fn main() -> Result<()> { env::set_var("RUST_BACKTRACE", "1"); - enable_debug_logging(Level::TRACE); + enable_debug_logging(Level::INFO); let (jid, client) = configure_client().await?; @@ -765,17 +792,17 @@ async fn main() -> Result<()> { match select_command() { Selection::LoadUserProfile => { let jid = prompt_bare_jid(&jid); - load_user_profile(&client, &jid).await?; + load_user_profile(&client, &jid.into()).await?; } Selection::UpdateUserProfile => { - update_user_profile(&client, jid.clone()).await?; + update_user_profile(&client, jid.clone().into()).await?; } Selection::DeleteUserProfile => { client.account.delete_profile().await?; } Selection::LoadUserAvatar => { let jid = prompt_bare_jid(&jid); - load_avatar(&client, &jid).await?; + load_avatar(&client, &jid.into()).await?; } Selection::SaveUserAvatar => { save_avatar(&client).await?; @@ -834,6 +861,10 @@ async fn main() -> Result<()> { let room = select_public_channel(&client).await?; client.rooms.join_room(&room.jid, None).await?; } + Selection::JoinRoomByJid => { + let jid = prompt_bare_jid(None); + client.rooms.join_room(&jid.into(), None).await?; + } Selection::DestroyPublicRoom => { let rooms = client .rooms @@ -855,13 +886,27 @@ async fn main() -> Result<()> { .interact() .unwrap(); println!(); - client.rooms.destroy_room(&rooms[selection].jid).await?; + client + .rooms + .destroy_room(&rooms[selection].jid.clone().into()) + .await?; + } + Selection::DestroyConnectedRoom => { + let Some(room) = select_room(&client, |_| true).await? else { + continue; + }; + client + .rooms + .destroy_room(room.to_generic_room().jid()) + .await?; } Selection::ListConnectedRooms => { list_connected_rooms(&client).await?; } Selection::RenameConnectedRoom => { - let room = select_room(&client).await?; + let Some(room) = select_room(&client, |_| true).await? else { + continue; + }; let name = prompt_string("Enter a new name:"); match room { @@ -897,13 +942,19 @@ async fn main() -> Result<()> { let values = items.get(key).unwrap(); for value in values { println!( - " - {:<36} | has draft: {} | unread count: {}", + " - {:<36} | {:<50} | has draft: {} | unread count: {}", value .room .to_generic_room() .name() .unwrap_or("".to_string()) .truncate_to(36), + value + .room + .to_generic_room() + .jid() + .to_string() + .truncate_to(50), value.has_draft, value.unread_count ); @@ -929,34 +980,74 @@ async fn main() -> Result<()> { .await?; } Selection::SetRoomTopic => { - let room = select_muc_room(&client).await?; + let Some(room) = select_muc_room(&client).await? else { + continue; + }; let subject = prompt_string("Enter a subject:"); match room { RoomEnvelope::DirectMessage(_) => unreachable!(), - RoomEnvelope::Group(room) => room.set_topic(Some(&subject)).await, - RoomEnvelope::PrivateChannel(room) => room.set_topic(Some(&subject)).await, - RoomEnvelope::PublicChannel(room) => room.set_topic(Some(&subject)).await, - RoomEnvelope::Generic(room) => room.set_topic(Some(&subject)).await, + RoomEnvelope::Group(room) => room.set_topic(Some(subject)).await, + RoomEnvelope::PrivateChannel(room) => room.set_topic(Some(subject)).await, + RoomEnvelope::PublicChannel(room) => room.set_topic(Some(subject)).await, + RoomEnvelope::Generic(room) => room.set_topic(Some(subject)).await, }?; } - Selection::ListRoomOccupants => { - let room = select_muc_room(&client).await?.to_generic_room(); + Selection::ListRoomParticipants => { + let Some(room) = select_room(&client, |_| true).await? else { + continue; + }; let occupants = room - .occupants_dbg() - .into_iter() - .map(|o| OccupantEnvelope(o).to_string()) + .to_generic_room() + .participants() + .iter() + .map(|o| ParticipantEnvelope(o.clone()).to_string()) .collect::>(); println!("{}", occupants.join("\n")) } - Selection::ListRoomMembers => { - let room = select_muc_room(&client).await?.to_generic_room(); - let members = room - .members() - .iter() - .map(|info| info.jid.to_string()) - .collect::>(); - println!("{}", members.join("\n")) + Selection::ResendGroupInvites => { + let Some(RoomEnvelope::Group(room)) = select_room(&client, |item| { + if let RoomEnvelope::Group(_) = item.room { + return true; + } + false + }) + .await? + else { + continue; + }; + + room.resend_invites_to_members().await?; + } + Selection::InviteUserToPrivateChannel => { + let Some(RoomEnvelope::PrivateChannel(room)) = select_room(&client, |item| { + if let RoomEnvelope::PrivateChannel(_) = item.room { + return true; + } + false + }) + .await? + else { + continue; + }; + + let contact = select_contact(&client).await?; + room.invite_users(vec![&contact]).await?; + } + Selection::ConvertGroupToPrivateChannel => { + let Some(RoomEnvelope::Group(room)) = select_room(&client, |item| { + if let RoomEnvelope::Group(_) = item.room { + return true; + } + false + }) + .await? + else { + continue; + }; + + let channel_name = prompt_string("Enter a name for the private channel"); + room.convert_to_private_channel(&channel_name).await?; } Selection::LoadBookmarks => { let bookmarks = client @@ -968,6 +1059,20 @@ async fn main() -> Result<()> { .collect::>(); println!("{}", bookmarks.join("\n")); } + Selection::DeleteIndividualBookmarks => { + let bookmarks = client + .debug + .load_bookmarks() + .await? + .into_iter() + .map(JidWithName::from) + .collect::>(); + let selected_bookmarks = select_multiple_jids_from_list(bookmarks); + client + .debug + .delete_bookmarks(selected_bookmarks.into_iter().map(RoomId::from)) + .await?; + } Selection::DeleteBookmarksPubSubNode => { println!("Deleting PubSub node…"); client.debug.delete_bookmarks_pubsub_node().await?; diff --git a/examples/xmpp-client/src/main.rs b/examples/xmpp-client/src/main.rs index ecf6939e..2aaa26bf 100644 --- a/examples/xmpp-client/src/main.rs +++ b/examples/xmpp-client/src/main.rs @@ -21,9 +21,6 @@ async fn main() -> Result<()> { let client = Client::builder() .set_connector_provider(Connector::provider()) - .add_mod(Chat::default()) - .add_mod(Profile::default()) - .add_mod(Status::default()) .set_event_handler(|client, event| handle_event(client, event).map(|f| f.unwrap())) .build(); @@ -35,7 +32,7 @@ async fn main() -> Result<()> { client .get_mod::() - .send_presence(Some(Show::Chat), None, None)?; + .send_presence(Some(Show::Chat), None, None, None)?; tokio::select! { _ = tokio::signal::ctrl_c() => { diff --git a/tests/prose-core-integration-tests/src/tests/account_settings_repository.rs b/tests/prose-core-integration-tests/src/tests/account_settings_repository.rs index 97fed74a..8eb7dc84 100644 --- a/tests/prose-core-integration-tests/src/tests/account_settings_repository.rs +++ b/tests/prose-core-integration-tests/src/tests/account_settings_repository.rs @@ -7,9 +7,9 @@ use anyhow::Result; use prose_core_client::domain::settings::models::AccountSettings; use prose_core_client::domain::settings::repos::AccountSettingsRepository as DomainAccountSettingsRepository; -use prose_core_client::domain::shared::models::Availability; +use prose_core_client::domain::shared::models::{Availability, UserId}; use prose_core_client::infra::settings::AccountSettingsRepository; -use prose_xmpp::bare; +use prose_core_client::user_id; use crate::tests::{async_test, store}; @@ -18,12 +18,12 @@ async fn test_save_and_load_account_settings() -> Result<()> { let repo = AccountSettingsRepository::new(store().await?); assert_eq!( - repo.get(&bare!("a@prose.org")).await?, + repo.get(&user_id!("a@prose.org")).await?, AccountSettings::default() ); repo.update( - &bare!("a@prose.org"), + &user_id!("a@prose.org"), Box::new(|settings: &mut AccountSettings| { settings.availability = Some(Availability::Away); }), @@ -36,9 +36,9 @@ async fn test_save_and_load_account_settings() -> Result<()> { }; assert_ne!(expected_settings, AccountSettings::default()); - assert_eq!(repo.get(&bare!("a@prose.org")).await?, expected_settings); + assert_eq!(repo.get(&user_id!("a@prose.org")).await?, expected_settings); assert_eq!( - repo.get(&bare!("b@prose.org")).await?, + repo.get(&user_id!("b@prose.org")).await?, AccountSettings::default() ); diff --git a/tests/prose-core-integration-tests/src/tests/contacts_repository.rs b/tests/prose-core-integration-tests/src/tests/contacts_repository.rs index a86a210d..6b3dd066 100644 --- a/tests/prose-core-integration-tests/src/tests/contacts_repository.rs +++ b/tests/prose-core-integration-tests/src/tests/contacts_repository.rs @@ -10,8 +10,9 @@ use anyhow::Result; use prose_core_client::domain::contacts::models::{Contact, Group}; use prose_core_client::domain::contacts::repos::ContactsRepository; use prose_core_client::domain::contacts::services::mocks::MockContactsService; +use prose_core_client::domain::shared::models::UserId; use prose_core_client::infra::contacts::CachingContactsRepository; -use prose_xmpp::bare; +use prose_core_client::user_id; use crate::tests::async_test; @@ -19,12 +20,12 @@ use crate::tests::async_test; async fn test_loads_and_caches_contacts() -> Result<()> { let contacts = vec![ Contact { - jid: bare!("a@prose.org"), + id: user_id!("a@prose.org"), name: None, group: Group::Favorite, }, Contact { - jid: bare!("b@prose.org"), + id: user_id!("b@prose.org"), name: None, group: Group::Team, }, @@ -41,8 +42,14 @@ async fn test_loads_and_caches_contacts() -> Result<()> { }; let repo = CachingContactsRepository::new(Arc::new(service)); - assert_eq!(repo.get_all(&bare!("account@prose.org")).await?, contacts); - assert_eq!(repo.get_all(&bare!("account@prose.org")).await?, contacts); + assert_eq!( + repo.get_all(&user_id!("account@prose.org")).await?, + contacts + ); + assert_eq!( + repo.get_all(&user_id!("account@prose.org")).await?, + contacts + ); Ok(()) } diff --git a/tests/prose-core-integration-tests/src/tests/messages_repository.rs b/tests/prose-core-integration-tests/src/tests/messages_repository.rs index 261cd011..47a56544 100644 --- a/tests/prose-core-integration-tests/src/tests/messages_repository.rs +++ b/tests/prose-core-integration-tests/src/tests/messages_repository.rs @@ -7,11 +7,11 @@ use anyhow::Result; use prose_core_client::domain::messaging::models::MessageLikePayload; use prose_core_client::domain::messaging::repos::MessagesRepository; -use prose_core_client::domain::shared::models::RoomJid; +use prose_core_client::domain::shared::models::{RoomId, UserId}; use prose_core_client::infra::messaging::CachingMessageRepository; -use prose_core_client::room; use prose_core_client::test::MessageBuilder; -use prose_xmpp::{bare, jid}; +use prose_core_client::{room_id, user_id}; +use prose_xmpp::jid; use crate::tests::{async_test, store}; @@ -19,7 +19,7 @@ use crate::tests::{async_test, store}; async fn test_can_insert_same_message_twice() -> Result<()> { let repo = CachingMessageRepository::new(store().await?); - let room_id = room!("a@prose.org"); + let room_id = room_id!("a@prose.org"); let message = MessageBuilder::new_with_index(123).build_message_like(); repo.append(&room_id, &[&message]).await?; @@ -38,7 +38,7 @@ async fn test_can_insert_same_message_twice() -> Result<()> { async fn test_loads_message_with_reactions() -> Result<()> { let repo = CachingMessageRepository::new(store().await?); - let room_id = room!("a@prose.org"); + let room_id = room_id!("a@prose.org"); let message1 = MessageBuilder::new_with_index(1).build_message_like(); let message2 = MessageBuilder::new_with_index(3) @@ -48,8 +48,8 @@ async fn test_loads_message_with_reactions() -> Result<()> { repo.append(&room_id, &[&message1, &message2]).await?; let mut message = MessageBuilder::new_with_index(1).build_message(); - message.toggle_reaction(&bare!("b@prose.org"), "🍿".into()); - message.toggle_reaction(&bare!("b@prose.org"), "📼".into()); + message.toggle_reaction(&user_id!("b@prose.org"), "🍿".into()); + message.toggle_reaction(&user_id!("b@prose.org"), "📼".into()); assert_eq!( repo.get_all(&room_id, &[&MessageBuilder::id_for_index(1)]) @@ -64,7 +64,7 @@ async fn test_loads_message_with_reactions() -> Result<()> { async fn test_load_messages_targeting() -> Result<()> { let repo = CachingMessageRepository::new(store().await?); - let room_id = room!("a@prose.org"); + let room_id = room_id!("a@prose.org"); let message1 = MessageBuilder::new_with_index(1).build_message_like(); let message2 = MessageBuilder::new_with_index(2).build_message_like(); diff --git a/tests/prose-core-integration-tests/src/tests/user_info_repository.rs b/tests/prose-core-integration-tests/src/tests/user_info_repository.rs index 33b1e777..2da0a78c 100644 --- a/tests/prose-core-integration-tests/src/tests/user_info_repository.rs +++ b/tests/prose-core-integration-tests/src/tests/user_info_repository.rs @@ -6,16 +6,16 @@ use std::sync::Arc; use anyhow::Result; -use jid::Jid; use prose_core_client::app::dtos::Availability; +use prose_core_client::domain::shared::models::{UserId, UserResourceId}; use prose_core_client::domain::user_info::models::{ - AvatarInfo, AvatarMetadata, Presence, UserActivity, UserInfo, + AvatarInfo, AvatarMetadata, Presence, UserInfo, UserStatus, }; use prose_core_client::domain::user_info::repos::UserInfoRepository; use prose_core_client::domain::user_info::services::mocks::MockUserInfoService; use prose_core_client::infra::user_info::CachingUserInfoRepository; -use prose_xmpp::{bare, full}; +use prose_core_client::{user_id, user_resource_id}; use crate::tests::{async_test, store}; @@ -52,11 +52,11 @@ async fn test_caches_loaded_avatar_metadata() -> Result<()> { }; assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); @@ -75,7 +75,7 @@ async fn test_caches_received_avatar_metadata() -> Result<()> { }; let repo = CachingUserInfoRepository::new(store().await?, Arc::new(MockUserInfoService::new())); - repo.set_avatar_metadata(&bare!("a@prose.org"), &metadata) + repo.set_avatar_metadata(&user_id!("a@prose.org"), &metadata) .await?; let expected_user_info = UserInfo { @@ -88,7 +88,7 @@ async fn test_caches_received_avatar_metadata() -> Result<()> { }; assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); @@ -106,7 +106,7 @@ async fn test_persists_metadata_and_user_activity() -> Result<()> { url: None, }; - let activity = UserActivity { + let activity = UserStatus { emoji: "🍕".to_string(), status: Some("Eating pizza".to_string()), }; @@ -114,9 +114,9 @@ async fn test_persists_metadata_and_user_activity() -> Result<()> { let store = store().await?; let repo = CachingUserInfoRepository::new(store.clone(), Arc::new(MockUserInfoService::new())); - repo.set_avatar_metadata(&bare!("a@prose.org"), &metadata) + repo.set_avatar_metadata(&user_id!("a@prose.org"), &metadata) .await?; - repo.set_user_activity(&bare!("a@prose.org"), Some(&activity)) + repo.set_user_activity(&user_id!("a@prose.org"), Some(&activity)) .await?; let expected_user_info = UserInfo { @@ -129,13 +129,13 @@ async fn test_persists_metadata_and_user_activity() -> Result<()> { }; assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); let repo = CachingUserInfoRepository::new(store, Arc::new(MockUserInfoService::new())); assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); @@ -155,7 +155,7 @@ async fn test_does_not_persist_availability() -> Result<()> { let repo = CachingUserInfoRepository::new(store.clone(), service.clone()); repo.set_user_presence( - &full!("a@prose.org/a").into(), + &user_resource_id!("a@prose.org/a").into(), &Presence { priority: 1, availability: Availability::Available, @@ -171,7 +171,7 @@ async fn test_does_not_persist_availability() -> Result<()> { }; assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); @@ -179,7 +179,7 @@ async fn test_does_not_persist_availability() -> Result<()> { expected_user_info.availability = Availability::Unavailable; assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&expected_user_info) ); @@ -196,7 +196,7 @@ async fn test_uses_highest_presence() -> Result<()> { let repo = CachingUserInfoRepository::new(store().await?, Arc::new(service)); repo.set_user_presence( - &full!("a@prose.org/b").into(), + &user_resource_id!("a@prose.org/b").into(), &Presence { priority: 2, availability: Availability::Away, @@ -206,7 +206,7 @@ async fn test_uses_highest_presence() -> Result<()> { .await?; repo.set_user_presence( - &full!("a@prose.org/a").into(), + &user_resource_id!("a@prose.org/a").into(), &Presence { priority: 1, availability: Availability::Available, @@ -216,11 +216,11 @@ async fn test_uses_highest_presence() -> Result<()> { .await?; assert_eq!( - repo.resolve_bare_jid_to_full(&bare!("a@prose.org")), - Jid::Full(full!("a@prose.org/b")) + repo.resolve_user_id_to_user_resource_id(&user_id!("a@prose.org")), + user_resource_id!("a@prose.org/b").into() ); assert_eq!( - repo.get_user_info(&bare!("a@prose.org")).await?.as_ref(), + repo.get_user_info(&user_id!("a@prose.org")).await?.as_ref(), Some(&UserInfo { avatar: None, activity: None,