events: introduce custom StateKey type for call member state events
This commit is contained in:
		
							parent
							
								
									1a138ed6c9
								
							
						
					
					
						commit
						d92404d114
					
				| @ -6,9 +6,11 @@ | |||||||
| 
 | 
 | ||||||
| mod focus; | mod focus; | ||||||
| mod member_data; | mod member_data; | ||||||
|  | mod member_state_key; | ||||||
| 
 | 
 | ||||||
| pub use focus::*; | pub use focus::*; | ||||||
| pub use member_data::*; | pub use member_data::*; | ||||||
|  | pub use member_state_key::*; | ||||||
| use ruma_common::MilliSecondsSinceUnixEpoch; | use ruma_common::MilliSecondsSinceUnixEpoch; | ||||||
| use ruma_macros::{EventContent, StringEnum}; | use ruma_macros::{EventContent, StringEnum}; | ||||||
| use serde::{Deserialize, Serialize}; | use serde::{Deserialize, Serialize}; | ||||||
| @ -29,7 +31,7 @@ use crate::{ | |||||||
| ///
 | ///
 | ||||||
| /// This struct also exposes allows to call the methods from [`CallMemberEventContent`].
 | /// This struct also exposes allows to call the methods from [`CallMemberEventContent`].
 | ||||||
| #[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)] | #[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)] | ||||||
| #[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = String, custom_redacted, custom_possibly_redacted)] | #[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = CallMemberStateKey, custom_redacted, custom_possibly_redacted)] | ||||||
| #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] | ||||||
| #[serde(untagged)] | #[serde(untagged)] | ||||||
| pub enum CallMemberEventContent { | pub enum CallMemberEventContent { | ||||||
| @ -177,7 +179,7 @@ impl RedactContent for CallMemberEventContent { | |||||||
| pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent; | pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent; | ||||||
| 
 | 
 | ||||||
| impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent { | impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent { | ||||||
|     type StateKey = String; |     type StateKey = CallMemberStateKey; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// The Redacted version of [`CallMemberEventContent`].
 | /// The Redacted version of [`CallMemberEventContent`].
 | ||||||
| @ -193,7 +195,7 @@ impl ruma_events::content::EventContent for RedactedCallMemberEventContent { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| impl RedactedStateEventContent for RedactedCallMemberEventContent { | impl RedactedStateEventContent for RedactedCallMemberEventContent { | ||||||
|     type StateKey = String; |     type StateKey = CallMemberStateKey; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| /// Legacy content with an array of memberships. See also: [`CallMemberEventContent`]
 | /// Legacy content with an array of memberships. See also: [`CallMemberEventContent`]
 | ||||||
| @ -231,8 +233,11 @@ mod tests { | |||||||
|     use std::time::Duration; |     use std::time::Duration; | ||||||
| 
 | 
 | ||||||
|     use assert_matches2::assert_matches; |     use assert_matches2::assert_matches; | ||||||
|     use ruma_common::{MilliSecondsSinceUnixEpoch as TS, OwnedEventId, OwnedRoomId, OwnedUserId}; |     use ruma_common::{ | ||||||
|     use serde_json::{from_value as from_json_value, json}; |         device_id, user_id, MilliSecondsSinceUnixEpoch as TS, OwnedEventId, OwnedRoomId, | ||||||
|  |         OwnedUserId, | ||||||
|  |     }; | ||||||
|  |     use serde_json::{from_value as from_json_value, json, Value as JsonValue}; | ||||||
| 
 | 
 | ||||||
|     use super::{ |     use super::{ | ||||||
|         focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus}, |         focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus}, | ||||||
| @ -467,8 +472,8 @@ mod tests { | |||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fn deserialize_member_event_helper(state_key: &str) { |     fn member_event_json(state_key: &str) -> JsonValue { | ||||||
|         let ev = json!({ |         json!({ | ||||||
|             "content":{ |             "content":{ | ||||||
|                 "application": "m.call", |                 "application": "m.call", | ||||||
|                 "call_id": "", |                 "call_id": "", | ||||||
| @ -497,7 +502,11 @@ mod tests { | |||||||
|                 "prev_content": {}, |                 "prev_content": {}, | ||||||
|                 "prev_sender":"@user:example.org", |                 "prev_sender":"@user:example.org", | ||||||
|             } |             } | ||||||
|         }); |         }) | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     fn deserialize_member_event_helper(state_key: &str) { | ||||||
|  |         let ev = member_event_json(state_key); | ||||||
| 
 | 
 | ||||||
|         assert_matches!( |         assert_matches!( | ||||||
|             from_json_value(ev), |             from_json_value(ev), | ||||||
| @ -507,7 +516,7 @@ mod tests { | |||||||
|         let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap(); |         let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap(); | ||||||
|         let sender = OwnedUserId::try_from("@user:example.org").unwrap(); |         let sender = OwnedUserId::try_from("@user:example.org").unwrap(); | ||||||
|         let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap(); |         let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap(); | ||||||
|         assert_eq!(member_event.state_key, state_key); |         assert_eq!(member_event.state_key.as_ref(), state_key); | ||||||
|         assert_eq!(member_event.event_id, event_id); |         assert_eq!(member_event.event_id, event_id); | ||||||
|         assert_eq!(member_event.sender, sender); |         assert_eq!(member_event.sender, sender); | ||||||
|         assert_eq!(member_event.room_id, room_id); |         assert_eq!(member_event.room_id, room_id); | ||||||
| @ -555,12 +564,12 @@ mod tests { | |||||||
| 
 | 
 | ||||||
|     #[test] |     #[test] | ||||||
|     fn deserialize_member_event_with_scoped_state_key_prefixed() { |     fn deserialize_member_event_with_scoped_state_key_prefixed() { | ||||||
|         deserialize_member_event_helper("_@user:example.org:THIS_DEVICE"); |         deserialize_member_event_helper("_@user:example.org_THIS_DEVICE"); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     #[test] |     #[test] | ||||||
|     fn deserialize_member_event_with_scoped_state_key_unprefixed() { |     fn deserialize_member_event_with_scoped_state_key_unprefixed() { | ||||||
|         deserialize_member_event_helper("@user:example.org:THIS_DEVICE"); |         deserialize_member_event_helper("@user:example.org_THIS_DEVICE"); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     fn timestamps() -> (TS, TS, TS) { |     fn timestamps() -> (TS, TS, TS) { | ||||||
| @ -632,4 +641,52 @@ mod tests { | |||||||
|             vec![] as Vec<MembershipData<'_>> |             vec![] as Vec<MembershipData<'_>> | ||||||
|         ); |         ); | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|  |     #[test] | ||||||
|  |     fn test_parse_rtc_member_event_key() { | ||||||
|  |         assert!(from_json_value::<AnyStateEvent>(member_event_json("abc")).is_err()); | ||||||
|  |         assert!(from_json_value::<AnyStateEvent>(member_event_json("@nocolon")).is_err()); | ||||||
|  |         assert!(from_json_value::<AnyStateEvent>(member_event_json("@noserverpart:")).is_err()); | ||||||
|  |         assert!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json("@noserverpart:_suffix")).is_err() | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         let user_id = user_id!("@username:example.org").as_str(); | ||||||
|  |         let device_id = device_id!("VALID_DEVICE_ID").as_str(); | ||||||
|  | 
 | ||||||
|  |         let parse_result = from_json_value::<AnyStateEvent>(member_event_json(user_id)); | ||||||
|  |         assert_matches!(parse_result, Ok(_)); | ||||||
|  |         assert_matches!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json(&format!("{user_id}_{device_id}"))), | ||||||
|  |             Ok(_) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         assert_matches!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json(&format!( | ||||||
|  |                 "{user_id}:invalid_suffix" | ||||||
|  |             ))), | ||||||
|  |             Err(_) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         assert_matches!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json(&format!("_{user_id}"))), | ||||||
|  |             Err(_) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         assert_matches!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json(&format!("_{user_id}_{device_id}"))), | ||||||
|  |             Ok(_) | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         assert_matches!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json(&format!( | ||||||
|  |                 "_{user_id}:invalid_suffix" | ||||||
|  |             ))), | ||||||
|  |             Err(_) | ||||||
|  |         ); | ||||||
|  |         assert_matches!( | ||||||
|  |             from_json_value::<AnyStateEvent>(member_event_json(&format!("{user_id}_"))), | ||||||
|  |             Err(_) | ||||||
|  |         ); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										225
									
								
								crates/ruma-events/src/call/member/member_state_key.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										225
									
								
								crates/ruma-events/src/call/member/member_state_key.rs
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,225 @@ | |||||||
|  | use std::str::FromStr; | ||||||
|  | 
 | ||||||
|  | use ruma_common::{DeviceId, OwnedDeviceId, OwnedUserId, UserId}; | ||||||
|  | use serde::{ | ||||||
|  |     de::{self, Deserialize, Deserializer, Unexpected}, | ||||||
|  |     Serialize, Serializer, | ||||||
|  | }; | ||||||
|  | /// A type that can be used as the `state_key` for call member state events.
 | ||||||
|  | /// Those state keys can be a combination of UserId and DeviceId.
 | ||||||
|  | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] | ||||||
|  | #[allow(clippy::exhaustive_structs)] | ||||||
|  | pub struct CallMemberStateKey { | ||||||
|  |     key: CallMemberStateKeyEnum, | ||||||
|  |     raw: Box<str>, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl CallMemberStateKey { | ||||||
|  |     /// Constructs a new CallMemberStateKey there are three possible formats:
 | ||||||
|  |     /// - "_{UserId}_{DeviceId}" example: "_@test:user.org_DEVICE". `device_id`: Some`, `underscore:
 | ||||||
|  |     ///   true`
 | ||||||
|  |     /// - "{UserId}_{DeviceId}" example: "@test:user.org_DEVICE". `device_id`: Some`, `underscore:
 | ||||||
|  |     ///   false`
 | ||||||
|  |     /// - "{UserId}" example example: "@test:user.org". `device_id`: None`, underscore is ignored:
 | ||||||
|  |     ///   `underscore: false|true`
 | ||||||
|  |     ///
 | ||||||
|  |     /// Dependent on the parameters the correct CallMemberStateKey will be constructed.
 | ||||||
|  |     pub fn new(user_id: OwnedUserId, device_id: Option<OwnedDeviceId>, underscore: bool) -> Self { | ||||||
|  |         CallMemberStateKeyEnum::new(user_id, device_id, underscore).into() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Returns the user id in this state key.
 | ||||||
|  |     /// (This is a cheap operations. The id is already type checked on initialization. And does
 | ||||||
|  |     /// only returns a reference to an existing OwnedUserId.)
 | ||||||
|  |     pub fn user_id(&self) -> &UserId { | ||||||
|  |         match &self.key { | ||||||
|  |             CallMemberStateKeyEnum::UnderscoreUserDevice(u, _) => u, | ||||||
|  |             CallMemberStateKeyEnum::UserDevice(u, _) => u, | ||||||
|  |             CallMemberStateKeyEnum::User(u) => u, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// Returns the device id in this state key (if available)
 | ||||||
|  |     /// (This is a cheap operations. The id is already type checked on initialization. And does
 | ||||||
|  |     /// only returns a reference to an existing OwnedDeviceId.)
 | ||||||
|  |     pub fn device_id(&self) -> Option<&DeviceId> { | ||||||
|  |         match &self.key { | ||||||
|  |             CallMemberStateKeyEnum::UnderscoreUserDevice(_, d) => Some(d), | ||||||
|  |             CallMemberStateKeyEnum::UserDevice(_, d) => Some(d), | ||||||
|  |             CallMemberStateKeyEnum::User(_) => None, | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl AsRef<str> for CallMemberStateKey { | ||||||
|  |     fn as_ref(&self) -> &str { | ||||||
|  |         &self.raw | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl From<CallMemberStateKeyEnum> for CallMemberStateKey { | ||||||
|  |     fn from(value: CallMemberStateKeyEnum) -> Self { | ||||||
|  |         let raw = value.to_string().into(); | ||||||
|  |         Self { key: value, raw } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl FromStr for CallMemberStateKey { | ||||||
|  |     type Err = KeyParseError; | ||||||
|  | 
 | ||||||
|  |     fn from_str(state_key: &str) -> Result<Self, Self::Err> { | ||||||
|  |         // Intentionally do not use CallMemberStateKeyEnum.into since this would reconstruct the
 | ||||||
|  |         // state key string.
 | ||||||
|  |         Ok(Self { key: CallMemberStateKeyEnum::from_str(state_key)?, raw: state_key.into() }) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl<'de> Deserialize<'de> for CallMemberStateKey { | ||||||
|  |     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> | ||||||
|  |     where | ||||||
|  |         D: Deserializer<'de>, | ||||||
|  |     { | ||||||
|  |         let s = ruma_common::serde::deserialize_cow_str(deserializer)?; | ||||||
|  |         Self::from_str(&s).map_err(|err| de::Error::invalid_value(Unexpected::Str(&s), &err)) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl Serialize for CallMemberStateKey { | ||||||
|  |     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> | ||||||
|  |     where | ||||||
|  |         S: Serializer, | ||||||
|  |     { | ||||||
|  |         serializer.serialize_str(self.as_ref()) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// This enum represents all possible formats for a call member event state key.
 | ||||||
|  | #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] | ||||||
|  | enum CallMemberStateKeyEnum { | ||||||
|  |     UnderscoreUserDevice(OwnedUserId, OwnedDeviceId), | ||||||
|  |     UserDevice(OwnedUserId, OwnedDeviceId), | ||||||
|  |     User(OwnedUserId), | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl CallMemberStateKeyEnum { | ||||||
|  |     fn new(user_id: OwnedUserId, device_id: Option<OwnedDeviceId>, underscore: bool) -> Self { | ||||||
|  |         match (device_id, underscore) { | ||||||
|  |             (Some(device_id), true) => { | ||||||
|  |                 CallMemberStateKeyEnum::UnderscoreUserDevice(user_id, device_id) | ||||||
|  |             } | ||||||
|  |             (Some(device_id), false) => CallMemberStateKeyEnum::UserDevice(user_id, device_id), | ||||||
|  |             (None, _) => CallMemberStateKeyEnum::User(user_id), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl std::fmt::Display for CallMemberStateKeyEnum { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         match &self { | ||||||
|  |             CallMemberStateKeyEnum::UnderscoreUserDevice(u, d) => write!(f, "_{u}_{d}"), | ||||||
|  |             CallMemberStateKeyEnum::UserDevice(u, d) => write!(f, "{u}_{d}"), | ||||||
|  |             CallMemberStateKeyEnum::User(u) => f.write_str(u.as_str()), | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl FromStr for CallMemberStateKeyEnum { | ||||||
|  |     type Err = KeyParseError; | ||||||
|  | 
 | ||||||
|  |     fn from_str(state_key: &str) -> Result<Self, Self::Err> { | ||||||
|  |         // Ignore leading underscore if present
 | ||||||
|  |         // (used for avoiding auth rules on @-prefixed state keys)
 | ||||||
|  |         let (state_key, underscore) = match state_key.strip_prefix('_') { | ||||||
|  |             Some(s) => (s, true), | ||||||
|  |             None => (state_key, false), | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         // Fail early if we cannot find the index of the ":"
 | ||||||
|  |         let Some(colon_idx) = state_key.find(':') else { | ||||||
|  |             return Err(KeyParseError::InvalidUser { | ||||||
|  |                 user_id: state_key.to_owned(), | ||||||
|  |                 error: ruma_common::IdParseError::MissingColon, | ||||||
|  |             }); | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         let (user_id, device_id) = match state_key[colon_idx + 1..].find('_') { | ||||||
|  |             None => { | ||||||
|  |                 return match UserId::parse(state_key) { | ||||||
|  |                     Ok(user_id) => { | ||||||
|  |                         if underscore { | ||||||
|  |                             Err(KeyParseError::LeadingUnderscoreNoDevice) | ||||||
|  |                         } else { | ||||||
|  |                             Ok(CallMemberStateKeyEnum::new(user_id, None, underscore)) | ||||||
|  |                         } | ||||||
|  |                     } | ||||||
|  |                     Err(err) => Err(KeyParseError::InvalidUser { | ||||||
|  |                         error: err, | ||||||
|  |                         user_id: state_key.to_owned(), | ||||||
|  |                     }), | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |             Some(suffix_idx) => { | ||||||
|  |                 (&state_key[..colon_idx + 1 + suffix_idx], &state_key[colon_idx + 2 + suffix_idx..]) | ||||||
|  |             } | ||||||
|  |         }; | ||||||
|  | 
 | ||||||
|  |         match (UserId::parse(user_id), OwnedDeviceId::from(device_id)) { | ||||||
|  |             (Ok(user_id), device_id) => { | ||||||
|  |                 if device_id.as_str().is_empty() { | ||||||
|  |                     return Err(KeyParseError::EmptyDevice); | ||||||
|  |                 }; | ||||||
|  |                 Ok(CallMemberStateKeyEnum::new(user_id, Some(device_id), underscore)) | ||||||
|  |             } | ||||||
|  |             (Err(err), _) => { | ||||||
|  |                 Err(KeyParseError::InvalidUser { user_id: user_id.to_owned(), error: err }) | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /// Error when trying to parse a call member state key.
 | ||||||
|  | #[derive(Debug, thiserror::Error)] | ||||||
|  | #[non_exhaustive] | ||||||
|  | pub enum KeyParseError { | ||||||
|  |     /// The user part of the state key is invalid.
 | ||||||
|  |     #[error("uses a malformatted UserId in the UserId defined section.")] | ||||||
|  |     InvalidUser { | ||||||
|  |         /// The user Id that the parser thinks it should have parsed.
 | ||||||
|  |         user_id: String, | ||||||
|  |         /// The user Id parse error why if failed to parse it.
 | ||||||
|  |         error: ruma_common::IdParseError, | ||||||
|  |     }, | ||||||
|  |     /// Uses a leading underscore but no trailing device id. The part after the underscore is a
 | ||||||
|  |     /// valid user id.
 | ||||||
|  |     #[error("uses a leading underscore but no trailing device id. The part after the underscore is a valid user id.")] | ||||||
|  |     LeadingUnderscoreNoDevice, | ||||||
|  |     /// Uses an empty device id. (UserId with trailing underscore)
 | ||||||
|  |     #[error("uses an empty device id. (UserId with trailing underscore)")] | ||||||
|  |     EmptyDevice, | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | impl de::Expected for KeyParseError { | ||||||
|  |     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | ||||||
|  |         write!(f, "correct call member event key format. The provided string, {})", self) | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | #[cfg(test)] | ||||||
|  | mod tests { | ||||||
|  |     use std::str::FromStr; | ||||||
|  | 
 | ||||||
|  |     use crate::call::member::{member_state_key::CallMemberStateKeyEnum, CallMemberStateKey}; | ||||||
|  | 
 | ||||||
|  |     #[test] | ||||||
|  |     fn convert_state_key_enum_to_state_key() { | ||||||
|  |         let key = "_@user:domain.org_DEVICE"; | ||||||
|  |         let state_key_enum = CallMemberStateKeyEnum::from_str(key).unwrap(); | ||||||
|  |         // This generates state_key.raw from the enum
 | ||||||
|  |         let state_key: CallMemberStateKey = state_key_enum.into(); | ||||||
|  |         // This compares state_key.raw (generated) with key (original)
 | ||||||
|  |         assert_eq!(state_key.as_ref(), key); | ||||||
|  |         // Compare to the from string without `CallMemberStateKeyEnum` step.
 | ||||||
|  |         let state_key_direct = CallMemberStateKey::from_str(state_key.as_ref()).unwrap(); | ||||||
|  |         assert_eq!(state_key, state_key_direct); | ||||||
|  |     } | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user