mirror of
https://github.com/mii443/vrclipboard-ime-gui.git
synced 2025-08-22 16:15:32 +00:00
refactoring
This commit is contained in:
@ -1,241 +1,423 @@
|
||||
use anyhow::Result;
|
||||
use tracing::{debug, error, info, trace};
|
||||
use anyhow::{anyhow, Result};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use super::client::AzookeyConversionClient;
|
||||
|
||||
/// Maximum number of history entries to retain
|
||||
const MAX_HISTORY_SIZE: usize = 3;
|
||||
|
||||
/// Maximum number of conversion candidates
|
||||
const MAX_CANDIDATES: usize = 10;
|
||||
|
||||
/// AzookeyConversion - Provides romanized text to kanji conversion and candidate switching
|
||||
///
|
||||
/// This struct implements the logic for character conversion and candidate switching
|
||||
/// in a Japanese input method system.
|
||||
pub struct AzookeyConversion {
|
||||
pub conversion_history: Vec<String>,
|
||||
pub clipboard_history: Vec<String>,
|
||||
pub now_reconvertion: bool,
|
||||
pub target_text: String,
|
||||
pub reconversion_candidates: Option<Vec<String>>,
|
||||
pub reconversion_index: Option<i32>,
|
||||
pub reconversion_prefix: Option<String>,
|
||||
pub client: AzookeyConversionClient,
|
||||
/// Conversion history (max 3 entries)
|
||||
conversion_history: Vec<String>,
|
||||
|
||||
/// Pre-conversion text history (max 3 entries)
|
||||
input_history: Vec<String>,
|
||||
|
||||
/// Whether currently in reconversion mode
|
||||
is_reconversion_mode: bool,
|
||||
|
||||
/// Current text being converted
|
||||
current_text: String,
|
||||
|
||||
/// List of reconversion candidates
|
||||
reconversion_candidates: Option<Vec<String>>,
|
||||
|
||||
/// Index of currently selected reconversion candidate
|
||||
candidate_index: Option<usize>,
|
||||
|
||||
/// Common prefix for reconversion
|
||||
common_prefix: Option<String>,
|
||||
|
||||
/// Client that performs conversion operations
|
||||
client: AzookeyConversionClient,
|
||||
}
|
||||
|
||||
impl AzookeyConversion {
|
||||
/// Creates a new AzookeyConversion instance
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `client` - Client instance that performs conversion operations
|
||||
///
|
||||
/// # Returns
|
||||
/// * Initialized AzookeyConversion instance
|
||||
pub fn new(client: AzookeyConversionClient) -> Self {
|
||||
info!("Creating new AzookeyConversion instance");
|
||||
|
||||
let instance = Self {
|
||||
Self {
|
||||
conversion_history: Vec::new(),
|
||||
clipboard_history: Vec::new(),
|
||||
now_reconvertion: false,
|
||||
target_text: String::new(),
|
||||
input_history: Vec::new(),
|
||||
is_reconversion_mode: false,
|
||||
current_text: String::new(),
|
||||
reconversion_candidates: None,
|
||||
reconversion_index: None,
|
||||
reconversion_prefix: None,
|
||||
candidate_index: None,
|
||||
common_prefix: None,
|
||||
client,
|
||||
};
|
||||
instance
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_conversion_state(&mut self) {
|
||||
debug!("Resetting conversion state");
|
||||
trace!("Before reset - now_reconvertion: {}, reconversion_prefix: {:?}, reconversion_index: {:?}, reconversion_candidates: {:?}",
|
||||
self.now_reconvertion, self.reconversion_prefix, self.reconversion_index, self.reconversion_candidates);
|
||||
self.now_reconvertion = false;
|
||||
self.reconversion_prefix = None;
|
||||
self.reconversion_index = None;
|
||||
/// Converts text - Main entry point for conversion processing
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `text` - Text to be converted
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String>` - Conversion result or error
|
||||
pub fn convert(&mut self, text: &str) -> Result<String> {
|
||||
debug!("Starting conversion: {}", text);
|
||||
trace!(
|
||||
"Current state: conversion_history={:?}, input_history={:?}, is_reconversion_mode={}",
|
||||
self.conversion_history,
|
||||
self.input_history,
|
||||
self.is_reconversion_mode
|
||||
);
|
||||
|
||||
self.current_text = text.to_string();
|
||||
|
||||
// Check if same as previous conversion result
|
||||
let same_as_last_conversion = self.is_same_as_last_conversion(text);
|
||||
trace!("Same as last conversion: {}", same_as_last_conversion);
|
||||
|
||||
// Reset if input changed while in reconversion mode
|
||||
if !same_as_last_conversion && self.is_reconversion_mode {
|
||||
debug!("Resetting conversion state due to new input");
|
||||
self.reset_reconversion_state();
|
||||
}
|
||||
|
||||
// Branch conversion processing
|
||||
if self.is_reconversion_mode || same_as_last_conversion {
|
||||
// Same text re-entered or in reconversion mode
|
||||
info!("Executing AzooKey conversion");
|
||||
self.convert_with_candidates(text)
|
||||
} else {
|
||||
// Normal conversion processing
|
||||
info!("Executing regular romaji->kanji conversion");
|
||||
self.convert_roman_to_kanji(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// Determines if input is the same as the last conversion result
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `text` - Text to check
|
||||
///
|
||||
/// # Returns
|
||||
/// * `bool` - True if same as last conversion
|
||||
fn is_same_as_last_conversion(&self, text: &str) -> bool {
|
||||
if let Some(last_conversion) = self.conversion_history.last() {
|
||||
text == last_conversion
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets reconversion-related state
|
||||
fn reset_reconversion_state(&mut self) {
|
||||
debug!("Resetting reconversion state");
|
||||
|
||||
trace!(
|
||||
"Before reset - is_reconversion_mode: {}, common_prefix: {:?}, candidate_index: {:?}",
|
||||
self.is_reconversion_mode,
|
||||
self.common_prefix,
|
||||
self.candidate_index
|
||||
);
|
||||
|
||||
self.is_reconversion_mode = false;
|
||||
self.common_prefix = None;
|
||||
self.candidate_index = None;
|
||||
self.reconversion_candidates = None;
|
||||
trace!("After reset - now_reconvertion: {}, reconversion_prefix: {:?}, reconversion_index: {:?}, reconversion_candidates: {:?}",
|
||||
self.now_reconvertion, self.reconversion_prefix, self.reconversion_index, self.reconversion_candidates);
|
||||
|
||||
trace!(
|
||||
"After reset - is_reconversion_mode: {}, common_prefix: {:?}, candidate_index: {:?}",
|
||||
self.is_reconversion_mode,
|
||||
self.common_prefix,
|
||||
self.candidate_index
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts romaji to kanji (initial conversion)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `text` - Text to convert
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String>` - Conversion result or error
|
||||
fn convert_roman_to_kanji(&mut self, text: &str) -> Result<String> {
|
||||
debug!("Converting roman to kanji: {}", text);
|
||||
let o_minus_1 = self
|
||||
.conversion_history
|
||||
.get(if self.conversion_history.len() > 0 {
|
||||
self.conversion_history.len() - 1
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.unwrap_or(&("".to_string()))
|
||||
.clone();
|
||||
trace!("Previous conversion (o_minus_1): {}", o_minus_1);
|
||||
let mut first_diff_position = o_minus_1
|
||||
.chars()
|
||||
.zip(text.chars())
|
||||
.position(|(a, b)| a != b);
|
||||
debug!("Converting romaji to kanji: {}", text);
|
||||
|
||||
if o_minus_1 != text && first_diff_position.is_none() {
|
||||
first_diff_position = Some(o_minus_1.chars().count());
|
||||
}
|
||||
// Get previous conversion result
|
||||
let previous_conversion = self.get_previous_conversion();
|
||||
trace!("Previous conversion: {}", previous_conversion);
|
||||
|
||||
// Detect difference position
|
||||
let first_diff_position = self.find_first_difference(&previous_conversion, text);
|
||||
trace!("First difference position: {:?}", first_diff_position);
|
||||
let diff = text
|
||||
.chars()
|
||||
.skip(first_diff_position.unwrap_or(0))
|
||||
.collect::<String>();
|
||||
debug!("Difference to convert: {}", diff);
|
||||
|
||||
let prefix = text
|
||||
.chars()
|
||||
.take(first_diff_position.unwrap_or(0))
|
||||
.collect::<String>();
|
||||
trace!("Prefix for conversion: {}", prefix);
|
||||
// Extract difference text
|
||||
let diff_text = text.chars().skip(first_diff_position).collect::<String>();
|
||||
debug!("Difference to convert: {}", diff_text);
|
||||
|
||||
// Extract common prefix
|
||||
let prefix = text.chars().take(first_diff_position).collect::<String>();
|
||||
trace!("Conversion prefix: {}", prefix);
|
||||
|
||||
// Use client for conversion
|
||||
self.client.reset_composing_text();
|
||||
self.client.insert_at_cursor_position(&diff);
|
||||
trace!("target: {}, prefix: {}", diff, prefix);
|
||||
let converted = self
|
||||
.client
|
||||
.request_candidates("")
|
||||
.first()
|
||||
.unwrap()
|
||||
.text
|
||||
.clone();
|
||||
trace!("Converted difference: {}", converted);
|
||||
self.conversion_history.push(
|
||||
o_minus_1
|
||||
.chars()
|
||||
.zip(text.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(a, _)| a)
|
||||
.collect::<String>()
|
||||
+ &converted,
|
||||
);
|
||||
self.clipboard_history.push(text.to_string());
|
||||
info!(
|
||||
"Roman to kanji conversion result: {}",
|
||||
self.conversion_history.last().unwrap()
|
||||
);
|
||||
trace!("Updated conversion history: {:?}", self.conversion_history);
|
||||
trace!("Updated clipboard history: {:?}", self.clipboard_history);
|
||||
Ok(self.conversion_history.last().unwrap().clone())
|
||||
self.client.insert_at_cursor_position(&diff_text);
|
||||
|
||||
// Get and select conversion candidate
|
||||
let converted = match self.client.request_candidates("").first() {
|
||||
Some(candidate) => candidate.text.clone(),
|
||||
None => return Err(anyhow!("No conversion candidates available")),
|
||||
};
|
||||
trace!("Conversion result: {}", converted);
|
||||
|
||||
// Update history
|
||||
let result = prefix + &converted;
|
||||
self.update_history(result.clone(), text.to_string());
|
||||
|
||||
info!("Romaji->kanji conversion result: {}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn convert_tsf(&mut self, text: &str) -> Result<String> {
|
||||
debug!("Converting using TSF: {}", text);
|
||||
self.now_reconvertion = true;
|
||||
let mut diff = String::new();
|
||||
if self.reconversion_prefix.is_none() {
|
||||
let o_minus_2 = self
|
||||
.conversion_history
|
||||
.get(if self.conversion_history.len() > 1 {
|
||||
self.conversion_history.len() - 2
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.unwrap_or(&("".to_string()))
|
||||
.clone();
|
||||
let i_minus_1 = self
|
||||
.clipboard_history
|
||||
.get(if self.clipboard_history.len() > 0 {
|
||||
self.clipboard_history.len() - 1
|
||||
} else {
|
||||
0
|
||||
})
|
||||
.unwrap_or(&("".to_string()))
|
||||
.clone();
|
||||
trace!("o_minus_2: {}, i_minus_1: {}", o_minus_2, i_minus_1);
|
||||
let mut first_diff_position = i_minus_1
|
||||
.chars()
|
||||
.zip(o_minus_2.chars())
|
||||
.position(|(a, b)| a != b);
|
||||
/// Switches between conversion candidates (reconversion)
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `text` - Text for reconversion
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String>` - Conversion result or error
|
||||
fn convert_with_candidates(&mut self, text: &str) -> Result<String> {
|
||||
debug!("Converting with AzooKey: {}", text);
|
||||
self.is_reconversion_mode = true;
|
||||
|
||||
// Prepare for reconversion if needed
|
||||
self.prepare_reconversion_if_needed();
|
||||
|
||||
// Select and switch candidates
|
||||
let result = self.select_next_candidate()?;
|
||||
|
||||
// Update history
|
||||
self.update_history(result.clone(), text.to_string());
|
||||
|
||||
info!("AzooKey conversion result: {}", result);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prepares difference processing for reconversion
|
||||
fn prepare_reconversion_if_needed(&mut self) {
|
||||
// Only execute on first reconversion
|
||||
if self.common_prefix.is_none() {
|
||||
debug!("Preparing for reconversion");
|
||||
|
||||
// Calculate differences from past history
|
||||
let previous_output = self.get_previous_output(2);
|
||||
let previous_input = self.get_previous_input(1);
|
||||
trace!(
|
||||
"Previous output: {}, previous input: {}",
|
||||
previous_output,
|
||||
previous_input
|
||||
);
|
||||
|
||||
// Detect difference position
|
||||
let first_diff_position = self.find_first_difference(&previous_input, &previous_output);
|
||||
trace!("First difference position: {:?}", first_diff_position);
|
||||
if o_minus_2 != i_minus_1 && first_diff_position.is_none() {
|
||||
first_diff_position = Some(o_minus_2.chars().count());
|
||||
|
||||
// Extract difference text
|
||||
let diff_text = previous_input
|
||||
.chars()
|
||||
.skip(first_diff_position)
|
||||
.collect::<String>();
|
||||
debug!("Reconversion difference: {}", diff_text);
|
||||
|
||||
// Set common prefix
|
||||
let prefix = previous_input
|
||||
.chars()
|
||||
.take(first_diff_position)
|
||||
.collect::<String>();
|
||||
self.common_prefix = Some(prefix.clone());
|
||||
trace!("Set reconversion prefix: {}", prefix);
|
||||
|
||||
// Generate candidates
|
||||
self.generate_candidates(&diff_text, &prefix);
|
||||
}
|
||||
diff = i_minus_1
|
||||
.chars()
|
||||
.skip(first_diff_position.unwrap_or(0))
|
||||
.collect::<String>();
|
||||
debug!("Difference to convert: {}", diff);
|
||||
let prefix = i_minus_1
|
||||
.chars()
|
||||
.zip(o_minus_2.chars())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.map(|(a, _)| a)
|
||||
.collect::<String>();
|
||||
self.reconversion_prefix = Some(prefix.clone());
|
||||
trace!("Set reconversion prefix: {:?}", self.reconversion_prefix);
|
||||
}
|
||||
|
||||
let candidates = self.reconversion_candidates.get_or_insert_with(|| {
|
||||
debug!("Generating new candidates");
|
||||
/// Generates conversion candidates
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `diff_text` - Difference text to convert
|
||||
/// * `prefix` - Common prefix
|
||||
fn generate_candidates(&mut self, diff_text: &str, prefix: &str) {
|
||||
debug!("Generating candidates");
|
||||
|
||||
self.client.reset_composing_text();
|
||||
self.client.insert_at_cursor_position(&diff);
|
||||
let prefix = self.reconversion_prefix.clone().unwrap_or_default();
|
||||
self.client.insert_at_cursor_position(diff_text);
|
||||
|
||||
// Get candidates from client
|
||||
let mut candidates = self
|
||||
.client
|
||||
.request_candidates(&prefix)
|
||||
.request_candidates(prefix)
|
||||
.iter()
|
||||
.map(|c| c.text.clone())
|
||||
.collect::<Vec<String>>();
|
||||
trace!("Initial candidates: {:?}", candidates);
|
||||
candidates.insert(0, diff.to_string());
|
||||
trace!("Final candidates: {:?}", candidates);
|
||||
candidates
|
||||
});
|
||||
trace!("Retrieved candidates: {:?}", candidates);
|
||||
|
||||
let index = self.reconversion_index.get_or_insert(-1);
|
||||
trace!("Current reconversion index: {}", index);
|
||||
// Include raw text in candidates
|
||||
candidates.insert(0, diff_text.to_string());
|
||||
|
||||
if *index + 1 < candidates.len() as i32 {
|
||||
*index += 1;
|
||||
} else {
|
||||
*index = 0;
|
||||
// Limit number of candidates (for safety)
|
||||
if candidates.len() > MAX_CANDIDATES {
|
||||
candidates.truncate(MAX_CANDIDATES);
|
||||
}
|
||||
debug!("Updated reconversion index: {}", index);
|
||||
|
||||
self.conversion_history.push(
|
||||
self.reconversion_prefix.clone().unwrap()
|
||||
+ &self.reconversion_candidates.as_ref().unwrap()
|
||||
[self.reconversion_index.unwrap() as usize]
|
||||
.clone(),
|
||||
trace!("Final candidate list: {:?}", candidates);
|
||||
self.reconversion_candidates = Some(candidates);
|
||||
self.candidate_index = Some(0);
|
||||
}
|
||||
|
||||
/// Selects the next candidate
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Result<String>` - Selected candidate or error
|
||||
fn select_next_candidate(&mut self) -> Result<String> {
|
||||
let candidates = match &self.reconversion_candidates {
|
||||
Some(cands) => cands,
|
||||
None => return Err(anyhow!("Candidate list does not exist")),
|
||||
};
|
||||
|
||||
let index = match self.candidate_index {
|
||||
Some(i) => {
|
||||
// Update index
|
||||
let new_index = if i + 1 < candidates.len() { i + 1 } else { 0 };
|
||||
self.candidate_index = Some(new_index);
|
||||
new_index
|
||||
}
|
||||
None => return Err(anyhow!("Candidate index not initialized")),
|
||||
};
|
||||
|
||||
debug!("Updated candidate index: {}", index);
|
||||
|
||||
// Get selected candidate
|
||||
let prefix = self.common_prefix.clone().unwrap_or_default();
|
||||
let selected_candidate = &candidates[index];
|
||||
let result = prefix + selected_candidate;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Updates history
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `conversion` - Conversion result
|
||||
/// * `input` - Input text
|
||||
fn update_history(&mut self, conversion: String, input: String) {
|
||||
self.conversion_history.push(conversion);
|
||||
self.input_history.push(input);
|
||||
|
||||
trace!(
|
||||
"Before trim: conversion_history={}, input_history={}",
|
||||
self.conversion_history.len(),
|
||||
self.input_history.len()
|
||||
);
|
||||
self.clipboard_history.push(text.to_string());
|
||||
trace!("Updated conversion history: {:?}", self.conversion_history);
|
||||
trace!("Updated clipboard history: {:?}", self.clipboard_history);
|
||||
|
||||
while self.conversion_history.len() > 3 {
|
||||
// Limit history size
|
||||
self.trim_history();
|
||||
|
||||
trace!(
|
||||
"After trim: conversion_history={}, input_history={}",
|
||||
self.conversion_history.len(),
|
||||
self.input_history.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Limits history size
|
||||
fn trim_history(&mut self) {
|
||||
while self.conversion_history.len() > MAX_HISTORY_SIZE {
|
||||
self.conversion_history.remove(0);
|
||||
}
|
||||
while self.clipboard_history.len() > 3 {
|
||||
self.clipboard_history.remove(0);
|
||||
}
|
||||
trace!("Trimmed conversion history: {:?}", self.conversion_history);
|
||||
trace!("Trimmed clipboard history: {:?}", self.clipboard_history);
|
||||
|
||||
info!(
|
||||
"TSF conversion result: {}",
|
||||
self.conversion_history.last().unwrap()
|
||||
);
|
||||
Ok(self.conversion_history.last().unwrap().clone())
|
||||
while self.input_history.len() > MAX_HISTORY_SIZE {
|
||||
self.input_history.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert(&mut self, text: &str) -> Result<String> {
|
||||
debug!("Starting conversion for: {}", text);
|
||||
trace!("Current conversion history: {:?}", self.conversion_history);
|
||||
trace!("Current clipboard history: {:?}", self.clipboard_history);
|
||||
let same_as_last_conversion = text.to_string()
|
||||
== self
|
||||
.conversion_history
|
||||
/// Gets previous conversion result
|
||||
///
|
||||
/// # Returns
|
||||
/// * `String` - Previous conversion result or empty string
|
||||
fn get_previous_conversion(&self) -> String {
|
||||
self.conversion_history
|
||||
.last()
|
||||
.unwrap_or(&("".to_string()))
|
||||
.clone();
|
||||
trace!("Same as last conversion: {}", same_as_last_conversion);
|
||||
|
||||
self.target_text = text.to_string();
|
||||
trace!("Set target text: {}", self.target_text);
|
||||
|
||||
if !same_as_last_conversion && self.now_reconvertion {
|
||||
debug!("Resetting conversion state due to new input");
|
||||
self.reset_conversion_state();
|
||||
.map(|s| s.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
if !self.now_reconvertion && !same_as_last_conversion {
|
||||
info!("Converting using roman_to_kanji");
|
||||
return self.convert_roman_to_kanji(text);
|
||||
/// Gets conversion history from specified index
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `offset` - Offset from the end
|
||||
///
|
||||
/// # Returns
|
||||
/// * `String` - Retrieved history or empty string
|
||||
fn get_previous_output(&self, offset: usize) -> String {
|
||||
if offset <= self.conversion_history.len() {
|
||||
self.conversion_history[self.conversion_history.len() - offset].clone()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
if same_as_last_conversion || self.now_reconvertion {
|
||||
info!("Converting using TSF");
|
||||
return self.convert_tsf(text);
|
||||
/// Gets input history from specified index
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `offset` - Offset from the end
|
||||
///
|
||||
/// # Returns
|
||||
/// * `String` - Retrieved history or empty string
|
||||
fn get_previous_input(&self, offset: usize) -> String {
|
||||
if offset <= self.input_history.len() {
|
||||
self.input_history[self.input_history.len() - offset].clone()
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
error!("Failed to convert: {}", text);
|
||||
Err(anyhow::anyhow!("Failed to convert"))
|
||||
/// Finds first difference position between two strings
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `s1` - First string to compare
|
||||
/// * `s2` - Second string to compare
|
||||
///
|
||||
/// # Returns
|
||||
/// * `usize` - First difference position
|
||||
fn find_first_difference(&self, s1: &str, s2: &str) -> usize {
|
||||
let result = s1
|
||||
.chars()
|
||||
.zip(s2.chars())
|
||||
.position(|(a, b)| a != b)
|
||||
.unwrap_or_else(|| {
|
||||
// If one is a prefix of the other
|
||||
let min_len = s1.chars().count().min(s2.chars().count());
|
||||
if s1.len() != s2.len() {
|
||||
min_len
|
||||
} else {
|
||||
0
|
||||
}
|
||||
});
|
||||
|
||||
trace!(
|
||||
"String comparison: \"{}\" and \"{}\" differ at position: {}",
|
||||
s1,
|
||||
s2,
|
||||
result
|
||||
);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user