mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-02-10 08:53:08 +00:00
chore: refactor context to be ffi specific, add chat.rs for manage chat experience.
This commit is contained in:
parent
a5943ae9ff
commit
67073a13de
171
conversations/src/chat.rs
Normal file
171
conversations/src/chat.rs
Normal file
@ -0,0 +1,171 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::{
|
||||
common::{Chat, ChatStore, HasChatId},
|
||||
errors::ChatError,
|
||||
identity::Identity,
|
||||
inbox::{Inbox, Introduction},
|
||||
types::{AddressedEnvelope, ContentData},
|
||||
};
|
||||
|
||||
/// ChatManager is the main entry point for the conversations API.
|
||||
/// It manages identity, inbox, and active chats.
|
||||
///
|
||||
/// This is a pure Rust API - for FFI bindings, use `Context` which wraps this
|
||||
/// with handle-based access.
|
||||
pub struct ChatManager {
|
||||
identity: Rc<Identity>,
|
||||
store: ChatStore,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl ChatManager {
|
||||
/// Create a new ChatManager with a fresh identity.
|
||||
pub fn new() -> Self {
|
||||
let identity = Rc::new(Identity::new());
|
||||
let inbox = Inbox::new(Rc::clone(&identity));
|
||||
Self {
|
||||
identity,
|
||||
store: ChatStore::new(),
|
||||
inbox,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new ChatManager with an existing identity.
|
||||
pub fn with_identity(identity: Identity) -> Self {
|
||||
let identity = Rc::new(identity);
|
||||
let inbox = Inbox::new(Rc::clone(&identity));
|
||||
Self {
|
||||
identity,
|
||||
store: ChatStore::new(),
|
||||
inbox,
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the local identity's public address.
|
||||
pub fn local_address(&self) -> String {
|
||||
self.identity.address()
|
||||
}
|
||||
|
||||
/// Create an introduction bundle that can be shared with others.
|
||||
/// They can use this to initiate a chat with you.
|
||||
pub fn create_intro_bundle(&mut self) -> Result<Introduction, ChatError> {
|
||||
let pkb = self.inbox.create_bundle();
|
||||
Ok(Introduction::from(pkb))
|
||||
}
|
||||
|
||||
/// Start a new private conversation with someone using their introduction bundle.
|
||||
///
|
||||
/// Returns the chat ID and envelopes that must be delivered to the remote party.
|
||||
pub fn start_private_chat(
|
||||
&mut self,
|
||||
remote_bundle: &Introduction,
|
||||
initial_message: &str,
|
||||
) -> Result<(String, Vec<AddressedEnvelope>), ChatError> {
|
||||
let (convo, payloads) = self
|
||||
.inbox
|
||||
.invite_to_private_convo(remote_bundle, initial_message.to_string())?;
|
||||
|
||||
let chat_id = convo.id().to_string();
|
||||
|
||||
let envelopes: Vec<AddressedEnvelope> = payloads
|
||||
.into_iter()
|
||||
.map(|p| p.to_envelope(chat_id.clone()))
|
||||
.collect();
|
||||
|
||||
self.store.insert_chat(convo);
|
||||
|
||||
Ok((chat_id, envelopes))
|
||||
}
|
||||
|
||||
/// Send a message to an existing chat.
|
||||
///
|
||||
/// Returns envelopes that must be delivered to chat participants.
|
||||
pub fn send_message(
|
||||
&mut self,
|
||||
chat_id: &str,
|
||||
content: &[u8],
|
||||
) -> Result<Vec<AddressedEnvelope>, ChatError> {
|
||||
let chat = self
|
||||
.store
|
||||
.get_mut_chat(chat_id)
|
||||
.ok_or_else(|| ChatError::NoChatId(chat_id.to_string()))?;
|
||||
|
||||
let payloads = chat.send_message(content)?;
|
||||
|
||||
Ok(payloads
|
||||
.into_iter()
|
||||
.map(|p| p.to_envelope(chat.remote_id()))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Handle an incoming payload from the network.
|
||||
///
|
||||
/// Returns the decrypted content if successful.
|
||||
pub fn handle_incoming(&mut self, _payload: &[u8]) -> Result<ContentData, ChatError> {
|
||||
// TODO: Implement proper payload handling
|
||||
// 1. Determine if this is an inbox message or a chat message
|
||||
// 2. Route to appropriate handler
|
||||
// 3. Return decrypted content
|
||||
Ok(ContentData {
|
||||
conversation_id: "convo_id".into(),
|
||||
data: vec![1, 2, 3, 4, 5, 6],
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a reference to an active chat.
|
||||
pub fn get_chat(&self, chat_id: &str) -> Option<&dyn Chat> {
|
||||
self.store.get_chat(chat_id)
|
||||
}
|
||||
|
||||
/// Get a mutable reference to an active chat.
|
||||
pub fn get_chat_mut(&mut self, chat_id: &str) -> Option<&mut dyn Chat> {
|
||||
self.store.get_mut_chat(chat_id)
|
||||
}
|
||||
|
||||
/// List all active chat IDs.
|
||||
pub fn list_chats(&self) -> Vec<String> {
|
||||
self.store.chat_ids().map(|id| id.to_string()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChatManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_chat_manager() {
|
||||
let manager = ChatManager::new();
|
||||
assert!(!manager.local_address().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_intro_bundle() {
|
||||
let mut manager = ChatManager::new();
|
||||
let bundle = manager.create_intro_bundle();
|
||||
assert!(bundle.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_private_chat() {
|
||||
let mut alice = ChatManager::new();
|
||||
let mut bob = ChatManager::new();
|
||||
|
||||
// Bob creates an intro bundle
|
||||
let bob_intro = bob.create_intro_bundle().unwrap();
|
||||
|
||||
// Alice starts a chat with Bob
|
||||
let result = alice.start_private_chat(&bob_intro, "Hello Bob!");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let (chat_id, envelopes) = result.unwrap();
|
||||
assert!(!chat_id.is_empty());
|
||||
assert!(!envelopes.is_empty());
|
||||
}
|
||||
}
|
||||
@ -1,113 +1,146 @@
|
||||
use std::{collections::HashMap, rc::Rc, sync::Arc};
|
||||
//! FFI-oriented context that wraps ChatManager with handle-based access.
|
||||
//!
|
||||
//! For pure Rust usage, prefer using `ChatManager` directly from `chat.rs`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
common::{Chat, ChatStore, HasChatId},
|
||||
chat::ChatManager,
|
||||
errors::ChatError,
|
||||
identity::Identity,
|
||||
inbox::Inbox,
|
||||
types::{AddressedEnvelope, ContentData},
|
||||
};
|
||||
|
||||
pub use crate::inbox::Introduction;
|
||||
|
||||
//Offset handles to make debuging easier
|
||||
// Offset handles to make debugging easier
|
||||
const INITIAL_CONVO_HANDLE: u32 = 0xF5000001;
|
||||
|
||||
/// Used to identify a conversation on the othersize of the FFI.
|
||||
/// Used to identify a conversation across the FFI boundary.
|
||||
/// This is an opaque integer handle that maps to an internal ChatId string.
|
||||
pub type ConvoHandle = u32;
|
||||
|
||||
// This is the main entry point to the conversations api.
|
||||
// Ctx manages lifetimes of objects to process and generate payloads.
|
||||
/// Context is the FFI-oriented wrapper around ChatManager.
|
||||
///
|
||||
/// It provides handle-based access to chats, suitable for FFI consumers
|
||||
/// that can't work with Rust strings directly.
|
||||
///
|
||||
/// For pure Rust usage, prefer using `ChatManager` directly.
|
||||
pub struct Context {
|
||||
_identity: Rc<Identity>,
|
||||
store: ChatStore,
|
||||
inbox: Inbox,
|
||||
convo_handle_map: HashMap<u32, Arc<str>>,
|
||||
next_convo_handle: ConvoHandle,
|
||||
manager: ChatManager,
|
||||
/// Maps FFI handles to internal chat IDs
|
||||
handle_to_chat_id: HashMap<ConvoHandle, String>,
|
||||
/// Maps chat IDs back to FFI handles for lookup
|
||||
chat_id_to_handle: HashMap<String, ConvoHandle>,
|
||||
next_handle: ConvoHandle,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new() -> Self {
|
||||
let identity = Rc::new(Identity::new());
|
||||
let inbox = Inbox::new(Rc::clone(&identity)); //
|
||||
Self {
|
||||
_identity: identity,
|
||||
store: ChatStore::new(),
|
||||
inbox,
|
||||
convo_handle_map: HashMap::new(),
|
||||
next_convo_handle: INITIAL_CONVO_HANDLE,
|
||||
manager: ChatManager::new(),
|
||||
handle_to_chat_id: HashMap::new(),
|
||||
chat_id_to_handle: HashMap::new(),
|
||||
next_handle: INITIAL_CONVO_HANDLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Context wrapping an existing ChatManager.
|
||||
pub fn with_manager(manager: ChatManager) -> Self {
|
||||
Self {
|
||||
manager,
|
||||
handle_to_chat_id: HashMap::new(),
|
||||
chat_id_to_handle: HashMap::new(),
|
||||
next_handle: INITIAL_CONVO_HANDLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the underlying ChatManager for direct Rust API usage.
|
||||
pub fn manager(&self) -> &ChatManager {
|
||||
&self.manager
|
||||
}
|
||||
|
||||
/// Access the underlying ChatManager mutably.
|
||||
pub fn manager_mut(&mut self) -> &mut ChatManager {
|
||||
&mut self.manager
|
||||
}
|
||||
|
||||
/// Create an introduction bundle for sharing with other users.
|
||||
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
||||
let intro = self.manager.create_intro_bundle()?;
|
||||
Ok(intro.into())
|
||||
}
|
||||
|
||||
/// Create a new private conversation using a remote party's introduction bundle.
|
||||
///
|
||||
/// Returns an FFI handle and addressed envelopes to be delivered.
|
||||
pub fn create_private_convo(
|
||||
&mut self,
|
||||
remote_bundle: &Introduction,
|
||||
content: String,
|
||||
) -> (ConvoHandle, Vec<AddressedEnvelope>) {
|
||||
let (convo, payloads) = self
|
||||
.inbox
|
||||
.invite_to_private_convo(remote_bundle, content)
|
||||
let (chat_id, envelopes) = self
|
||||
.manager
|
||||
.start_private_chat(remote_bundle, &content)
|
||||
.unwrap_or_else(|_| todo!("Log/Surface Error"));
|
||||
|
||||
let payload_bytes = payloads
|
||||
.into_iter()
|
||||
.map(|p| p.to_envelope(convo.id().to_string()))
|
||||
.collect();
|
||||
|
||||
let convo_handle = self.add_convo(convo);
|
||||
(convo_handle, payload_bytes)
|
||||
let handle = self.register_chat_id(chat_id);
|
||||
(handle, envelopes)
|
||||
}
|
||||
|
||||
/// Send content to an existing conversation identified by handle.
|
||||
pub fn send_content(
|
||||
&mut self,
|
||||
convo_handle: ConvoHandle,
|
||||
content: &[u8],
|
||||
) -> Result<Vec<AddressedEnvelope>, ChatError> {
|
||||
// Lookup convo from handle
|
||||
let convo = self.get_convo_mut(convo_handle)?;
|
||||
|
||||
// Generate encrypted payloads
|
||||
let payloads = convo.send_message(content)?;
|
||||
|
||||
// Attach conversation_ids to Envelopes
|
||||
Ok(payloads
|
||||
.into_iter()
|
||||
.map(|p| p.to_envelope(convo.remote_id()))
|
||||
.collect())
|
||||
let chat_id = self.resolve_handle(convo_handle)?;
|
||||
self.manager.send_message(&chat_id, content)
|
||||
}
|
||||
|
||||
pub fn handle_payload(&mut self, _payload: &[u8]) -> Option<ContentData> {
|
||||
// !TODO Replace Mock
|
||||
Some(ContentData {
|
||||
conversation_id: "convo_id".into(),
|
||||
data: vec![1, 2, 3, 4, 5, 6],
|
||||
})
|
||||
/// Handle an incoming payload.
|
||||
pub fn handle_payload(&mut self, payload: &[u8]) -> Option<ContentData> {
|
||||
self.manager.handle_incoming(payload).ok()
|
||||
}
|
||||
|
||||
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
||||
let pkb = self.inbox.create_bundle();
|
||||
Ok(Introduction::from(pkb).into())
|
||||
/// Get the chat ID for a given handle.
|
||||
pub fn get_chat_id(&self, handle: ConvoHandle) -> Option<&str> {
|
||||
self.handle_to_chat_id.get(&handle).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
fn add_convo(&mut self, convo: impl Chat + HasChatId + 'static) -> ConvoHandle {
|
||||
let handle = self.next_convo_handle;
|
||||
self.next_convo_handle += 1;
|
||||
let convo_id = self.store.insert_chat(convo);
|
||||
self.convo_handle_map.insert(handle, convo_id);
|
||||
/// Get the handle for a given chat ID.
|
||||
pub fn get_handle(&self, chat_id: &str) -> Option<ConvoHandle> {
|
||||
self.chat_id_to_handle.get(chat_id).copied()
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/// Register a chat ID and return its FFI handle.
|
||||
fn register_chat_id(&mut self, chat_id: String) -> ConvoHandle {
|
||||
// Check if already registered
|
||||
if let Some(&handle) = self.chat_id_to_handle.get(&chat_id) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
let handle = self.next_handle;
|
||||
self.next_handle += 1;
|
||||
|
||||
self.handle_to_chat_id.insert(handle, chat_id.clone());
|
||||
self.chat_id_to_handle.insert(chat_id, handle);
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
// Returns a mutable reference to a Convo for a given ConvoHandle
|
||||
fn get_convo_mut(&mut self, handle: ConvoHandle) -> Result<&mut dyn Chat, ChatError> {
|
||||
let convo_id = self
|
||||
.convo_handle_map
|
||||
/// Resolve a handle to its chat ID.
|
||||
fn resolve_handle(&self, handle: ConvoHandle) -> Result<String, ChatError> {
|
||||
self.handle_to_chat_id
|
||||
.get(&handle)
|
||||
.ok_or_else(|| ChatError::NoConvo(handle))?
|
||||
.clone();
|
||||
|
||||
self.store
|
||||
.get_mut_chat(&convo_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| ChatError::NoConvo(handle))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Context {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ pub enum ChatError {
|
||||
BadParsing(&'static str),
|
||||
#[error("convo with handle: {0} was not found")]
|
||||
NoConvo(u32),
|
||||
#[error("chat with id '{0}' was not found")]
|
||||
NoChatId(String),
|
||||
}
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
|
||||
@ -12,7 +12,7 @@ pub enum ErrorCode {
|
||||
UnknownError = -6,
|
||||
}
|
||||
|
||||
use crate::context::{Context, ConvoHandle, Introduction};
|
||||
use super::context::{Context, ConvoHandle, Introduction};
|
||||
|
||||
/// Opaque wrapper for Context
|
||||
#[derive_ReprC]
|
||||
|
||||
146
conversations/src/ffi/context.rs
Normal file
146
conversations/src/ffi/context.rs
Normal file
@ -0,0 +1,146 @@
|
||||
//! FFI-oriented context that wraps ChatManager with handle-based access.
|
||||
//!
|
||||
//! For pure Rust usage, prefer using `ChatManager` directly from `chat.rs`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::{
|
||||
chat::ChatManager,
|
||||
errors::ChatError,
|
||||
types::{AddressedEnvelope, ContentData},
|
||||
};
|
||||
|
||||
pub use crate::inbox::Introduction;
|
||||
|
||||
// Offset handles to make debugging easier
|
||||
const INITIAL_CONVO_HANDLE: u32 = 0xF5000001;
|
||||
|
||||
/// Used to identify a conversation across the FFI boundary.
|
||||
/// This is an opaque integer handle that maps to an internal ChatId string.
|
||||
pub type ConvoHandle = u32;
|
||||
|
||||
/// Context is the FFI-oriented wrapper around ChatManager.
|
||||
///
|
||||
/// It provides handle-based access to chats, suitable for FFI consumers
|
||||
/// that can't work with Rust strings directly.
|
||||
///
|
||||
/// For pure Rust usage, prefer using `ChatManager` directly.
|
||||
pub struct Context {
|
||||
manager: ChatManager,
|
||||
/// Maps FFI handles to internal chat IDs
|
||||
handle_to_chat_id: HashMap<ConvoHandle, String>,
|
||||
/// Maps chat IDs back to FFI handles for lookup
|
||||
chat_id_to_handle: HashMap<String, ConvoHandle>,
|
||||
next_handle: ConvoHandle,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manager: ChatManager::new(),
|
||||
handle_to_chat_id: HashMap::new(),
|
||||
chat_id_to_handle: HashMap::new(),
|
||||
next_handle: INITIAL_CONVO_HANDLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a Context wrapping an existing ChatManager.
|
||||
pub fn with_manager(manager: ChatManager) -> Self {
|
||||
Self {
|
||||
manager,
|
||||
handle_to_chat_id: HashMap::new(),
|
||||
chat_id_to_handle: HashMap::new(),
|
||||
next_handle: INITIAL_CONVO_HANDLE,
|
||||
}
|
||||
}
|
||||
|
||||
/// Access the underlying ChatManager for direct Rust API usage.
|
||||
pub fn manager(&self) -> &ChatManager {
|
||||
&self.manager
|
||||
}
|
||||
|
||||
/// Access the underlying ChatManager mutably.
|
||||
pub fn manager_mut(&mut self) -> &mut ChatManager {
|
||||
&mut self.manager
|
||||
}
|
||||
|
||||
/// Create an introduction bundle for sharing with other users.
|
||||
pub fn create_intro_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
|
||||
let intro = self.manager.create_intro_bundle()?;
|
||||
Ok(intro.into())
|
||||
}
|
||||
|
||||
/// Create a new private conversation using a remote party's introduction bundle.
|
||||
///
|
||||
/// Returns an FFI handle and addressed envelopes to be delivered.
|
||||
pub fn create_private_convo(
|
||||
&mut self,
|
||||
remote_bundle: &Introduction,
|
||||
content: String,
|
||||
) -> (ConvoHandle, Vec<AddressedEnvelope>) {
|
||||
let (chat_id, envelopes) = self
|
||||
.manager
|
||||
.start_private_chat(remote_bundle, &content)
|
||||
.unwrap_or_else(|_| todo!("Log/Surface Error"));
|
||||
|
||||
let handle = self.register_chat_id(chat_id);
|
||||
(handle, envelopes)
|
||||
}
|
||||
|
||||
/// Send content to an existing conversation identified by handle.
|
||||
pub fn send_content(
|
||||
&mut self,
|
||||
convo_handle: ConvoHandle,
|
||||
content: &[u8],
|
||||
) -> Result<Vec<AddressedEnvelope>, ChatError> {
|
||||
let chat_id = self.resolve_handle(convo_handle)?;
|
||||
self.manager.send_message(&chat_id, content)
|
||||
}
|
||||
|
||||
/// Handle an incoming payload.
|
||||
pub fn handle_payload(&mut self, payload: &[u8]) -> Option<ContentData> {
|
||||
self.manager.handle_incoming(payload).ok()
|
||||
}
|
||||
|
||||
/// Get the chat ID for a given handle.
|
||||
pub fn get_chat_id(&self, handle: ConvoHandle) -> Option<&str> {
|
||||
self.handle_to_chat_id.get(&handle).map(|s| s.as_str())
|
||||
}
|
||||
|
||||
/// Get the handle for a given chat ID.
|
||||
pub fn get_handle(&self, chat_id: &str) -> Option<ConvoHandle> {
|
||||
self.chat_id_to_handle.get(chat_id).copied()
|
||||
}
|
||||
|
||||
// --- Internal helpers ---
|
||||
|
||||
/// Register a chat ID and return its FFI handle.
|
||||
fn register_chat_id(&mut self, chat_id: String) -> ConvoHandle {
|
||||
// Check if already registered
|
||||
if let Some(&handle) = self.chat_id_to_handle.get(&chat_id) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
let handle = self.next_handle;
|
||||
self.next_handle += 1;
|
||||
|
||||
self.handle_to_chat_id.insert(handle, chat_id.clone());
|
||||
self.chat_id_to_handle.insert(chat_id, handle);
|
||||
|
||||
handle
|
||||
}
|
||||
|
||||
/// Resolve a handle to its chat ID.
|
||||
fn resolve_handle(&self, handle: ConvoHandle) -> Result<String, ChatError> {
|
||||
self.handle_to_chat_id
|
||||
.get(&handle)
|
||||
.cloned()
|
||||
.ok_or_else(|| ChatError::NoConvo(handle))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Context {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@ -1 +1,4 @@
|
||||
pub mod api;
|
||||
mod context;
|
||||
|
||||
pub use context::{Context, ConvoHandle, Introduction};
|
||||
|
||||
@ -8,7 +8,6 @@ use std::rc::Rc;
|
||||
use crypto::{PrekeyBundle, SecretKey};
|
||||
|
||||
use crate::common::{Chat, ChatId, HasChatId, InboundMessageHandler};
|
||||
use crate::context::Introduction;
|
||||
use crate::dm::privatev1::PrivateV1Convo;
|
||||
use crate::errors::ChatError;
|
||||
use crate::identity::Identity;
|
||||
@ -17,6 +16,8 @@ use crate::inbox::handshake::InboxHandshake;
|
||||
use crate::proto::{self, CopyBytes};
|
||||
use crate::types::{AddressedEncryptedPayload, ContentData};
|
||||
|
||||
use super::Introduction;
|
||||
|
||||
/// Compute the deterministic Delivery_address for an installation
|
||||
fn delivery_address_for_installation(_: PublicKey) -> String {
|
||||
// TODO: Implement Delivery Address
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
pub mod chat;
|
||||
pub mod common;
|
||||
pub mod dm;
|
||||
pub mod ffi;
|
||||
pub mod group;
|
||||
pub mod inbox;
|
||||
|
||||
mod context;
|
||||
mod errors;
|
||||
mod identity;
|
||||
mod proto;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user