refactor: split the files that had grown past reading size
Five files ran between 1.2k and 2.4k lines, and one function past the length the quality gate allows. None of it was doing anything wrong; they had simply accumulated until you had to scroll to hold one in your head. The move throughout is into *child* modules rather than siblings, because a child still sees its parent's private items. So `ClientContext` keeps its private fields and `ServerActor` keeps its private state, while the methods that work on them move out — no encapsulation is widened for the sake of the split. Three `ServerActor` methods become `pub(super)`, which is only acknowledging the calls the parent already made. The client's operation loop and the actor's standing-message match are split by subject: the arms move out whole, so the bodies read exactly as they did inside. Where an arm said `continue` it now says `return` — the match was the last thing the loop did, and none of the arms that moved holds a loop of its own, so skipping the rest of an arm and ending its handler are the same move. The test modules split by topic the same way, which is most of the size: main_tui's alone was 2.1k of the 2.4k lines. No behaviour changes. Verified against a live server: the full e2e suite passes, including the 98 CLI and 19 daemon-protocol cases that drive the dispatch loop this touches.
This commit is contained in:
@@ -0,0 +1,560 @@
|
||||
//! The window driven the way a terminal drives it: keys and mouse events in,
|
||||
//! a rendered screen out, over a stand-in session.
|
||||
//!
|
||||
//! This file is the harness the topic modules below share — the session
|
||||
//! double, the window constructors, and the press/screen helpers.
|
||||
|
||||
mod browse;
|
||||
mod layout;
|
||||
mod navigation;
|
||||
mod rooms;
|
||||
mod searches;
|
||||
mod session;
|
||||
mod settings;
|
||||
mod transfers;
|
||||
|
||||
use super::*;
|
||||
|
||||
use crate::daemon::proto::ChatMessageDto;
|
||||
|
||||
use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus};
|
||||
|
||||
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
/// A stand-in for a daemon: it already holds a conversation, a transfer
|
||||
/// queue, and a search cache, none of which this window created.
|
||||
#[derive(Default)]
|
||||
struct TalkativeSession {
|
||||
history: Vec<ChatMessageDto>,
|
||||
downloads: Vec<soulseek_rs::types::Download>,
|
||||
searches: std::sync::Mutex<Vec<crate::api::SessionSearch>>,
|
||||
/// What `try_get_search_results` hands back to the window.
|
||||
results: std::sync::Mutex<Vec<soulseek_rs::SearchResult>>,
|
||||
/// Every query a window put on the wire, in order.
|
||||
searched: std::sync::Mutex<Vec<String>>,
|
||||
/// How many times a window asked for the search list.
|
||||
all_searches_calls: std::sync::atomic::AtomicUsize,
|
||||
/// How many times a window pulled a full result set across. Against a
|
||||
/// daemon each pull is the whole set over the socket, so the tests
|
||||
/// count them.
|
||||
result_fetches: std::sync::atomic::AtomicUsize,
|
||||
/// Whether this session is a daemon's. Local sessions do not sync.
|
||||
shared: bool,
|
||||
/// What `set_download_directory` was last asked for, if anything.
|
||||
download_dir_set: std::sync::Mutex<Option<String>>,
|
||||
/// What `set_shared_directories` was last asked for, if anything.
|
||||
shares_set: std::sync::Mutex<Option<Vec<String>>>,
|
||||
/// What `change_password` was last asked for, if anything.
|
||||
password_set: std::sync::Mutex<Option<String>>,
|
||||
/// The share index this session serves to peers.
|
||||
listing: std::sync::Mutex<Vec<soulseek_rs::SharedDirectory>>,
|
||||
download_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||
upload_cancelled: std::sync::Mutex<Vec<(String, String)>>,
|
||||
download_removed: std::sync::Mutex<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download {
|
||||
at_status(username, filename, soulseek_rs::DownloadStatus::Queued)
|
||||
}
|
||||
|
||||
fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download {
|
||||
at_status(username, filename, soulseek_rs::DownloadStatus::Completed)
|
||||
}
|
||||
|
||||
fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo {
|
||||
soulseek_rs::types::UploadInfo {
|
||||
username: username.to_string(),
|
||||
filename: filename.to_string(),
|
||||
size: 10,
|
||||
bytes_sent: 1,
|
||||
speed_bytes_per_sec: 1.0,
|
||||
status: soulseek_rs::types::UploadStatus::InProgress,
|
||||
}
|
||||
}
|
||||
|
||||
fn at_status(
|
||||
username: &str,
|
||||
filename: &str,
|
||||
status: soulseek_rs::DownloadStatus,
|
||||
) -> soulseek_rs::types::Download {
|
||||
let (sender, _) = std::sync::mpsc::channel();
|
||||
soulseek_rs::types::Download {
|
||||
username: username.to_string(),
|
||||
filename: filename.to_string(),
|
||||
token: 1,
|
||||
size: 4096,
|
||||
download_directory: "/tmp".to_string(),
|
||||
status,
|
||||
sender,
|
||||
queue_position: None,
|
||||
metadata: soulseek_rs::types::DownloadMetadata::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn key(ch: char) -> KeyEvent {
|
||||
KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
impl SessionApi for TalkativeSession {
|
||||
fn message_history(&self) -> Vec<ChatMessageDto> {
|
||||
self.history.clone()
|
||||
}
|
||||
fn daemon_endpoint(&self) -> Option<String> {
|
||||
self.shared.then(|| "test-daemon".to_string())
|
||||
}
|
||||
fn username(&self) -> String {
|
||||
"tester".to_string()
|
||||
}
|
||||
fn listen_port(&self) -> Option<u16> {
|
||||
None
|
||||
}
|
||||
fn session_loss(&self) -> Option<soulseek_rs::SessionLoss> {
|
||||
None
|
||||
}
|
||||
fn search_with_cancel(
|
||||
&self,
|
||||
query: &str,
|
||||
_timeout: Duration,
|
||||
_cancel: Option<Arc<std::sync::atomic::AtomicBool>>,
|
||||
) -> soulseek_rs::Result<Vec<soulseek_rs::SearchResult>> {
|
||||
self.searched
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.push(query.to_string());
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn get_search_results(&self, _key: &str) -> Vec<soulseek_rs::SearchResult> {
|
||||
Vec::new()
|
||||
}
|
||||
fn all_searches(&self) -> Vec<crate::api::SessionSearch> {
|
||||
self.all_searches_calls
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
self.searches.lock().expect("not poisoned").clone()
|
||||
}
|
||||
fn forget_search(&self, query: &str) -> bool {
|
||||
let mut searches = self.searches.lock().expect("not poisoned");
|
||||
let before = searches.len();
|
||||
searches.retain(|search| search.query != query);
|
||||
searches.len() != before
|
||||
}
|
||||
fn get_search_results_count(&self, _key: &str) -> usize {
|
||||
0
|
||||
}
|
||||
fn try_get_search_results(
|
||||
&self,
|
||||
_key: &str,
|
||||
) -> Option<Vec<soulseek_rs::SearchResult>> {
|
||||
self.result_fetches
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
Some(self.results.lock().expect("not poisoned").clone())
|
||||
}
|
||||
fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn wishlist_interval(&self) -> Duration {
|
||||
Duration::from_mins(12)
|
||||
}
|
||||
fn download(
|
||||
&self,
|
||||
_filename: String,
|
||||
_username: String,
|
||||
_size: u64,
|
||||
_directory: String,
|
||||
) -> soulseek_rs::Result<(
|
||||
soulseek_rs::types::Download,
|
||||
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
|
||||
)> {
|
||||
Err(soulseek_rs::SoulseekRs::NotConnected)
|
||||
}
|
||||
fn download_with_metadata(
|
||||
&self,
|
||||
_filename: String,
|
||||
_username: String,
|
||||
_size: u64,
|
||||
_directory: String,
|
||||
_metadata: soulseek_rs::types::DownloadMetadata,
|
||||
) -> soulseek_rs::Result<(
|
||||
soulseek_rs::types::Download,
|
||||
std::sync::mpsc::Receiver<soulseek_rs::DownloadStatus>,
|
||||
)> {
|
||||
Err(soulseek_rs::SoulseekRs::NotConnected)
|
||||
}
|
||||
fn get_all_downloads(&self) -> Vec<soulseek_rs::types::Download> {
|
||||
self.downloads.clone()
|
||||
}
|
||||
fn pause_download(&self, _u: &str, _f: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn resume_download(&self, _u: &str, _f: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn remove_queued_download(&self, _u: &str, _f: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn remove_download(&self, u: &str, f: &str) -> bool {
|
||||
self.download_removed
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.push((u.to_string(), f.to_string()));
|
||||
true
|
||||
}
|
||||
fn uploads(&self) -> Vec<soulseek_rs::UploadInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
fn take_upload_events(&self) -> Vec<soulseek_rs::UploadInfo> {
|
||||
Vec::new()
|
||||
}
|
||||
fn cancel_upload(&self, u: &str, f: &str) -> bool {
|
||||
self.upload_cancelled
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.push((u.to_string(), f.to_string()));
|
||||
true
|
||||
}
|
||||
fn cancel_download(&self, u: &str, f: &str) -> bool {
|
||||
self.download_cancelled
|
||||
.lock()
|
||||
.expect("not poisoned")
|
||||
.push((u.to_string(), f.to_string()));
|
||||
true
|
||||
}
|
||||
fn set_upload_slots(&self, _slots: usize) {}
|
||||
fn check_privileges(&self) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn own_privilege_seconds(&self) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
fn request_room_list(&self) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn say_in_room(
|
||||
&self,
|
||||
_room: &str,
|
||||
_message: &str,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn room_members(&self, _room: &str) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn room_member_stats(
|
||||
&self,
|
||||
_room: &str,
|
||||
) -> Vec<soulseek_rs::types::RoomUserStats> {
|
||||
Vec::new()
|
||||
}
|
||||
fn take_room_events(&self) -> Vec<soulseek_rs::RoomEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
fn send_private_message(
|
||||
&self,
|
||||
_u: &str,
|
||||
_m: &str,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn take_private_messages(&self) -> Vec<soulseek_rs::UserMessage> {
|
||||
Vec::new()
|
||||
}
|
||||
fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn take_browse_result(
|
||||
&self,
|
||||
_username: &str,
|
||||
) -> Option<Vec<soulseek_rs::SharedDirectory>> {
|
||||
None
|
||||
}
|
||||
fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn watched_users(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn user_info(&self, _username: &str) -> Option<soulseek_rs::UserInfo> {
|
||||
None
|
||||
}
|
||||
fn shared_counts(&self) -> (u32, u32) {
|
||||
(3, 12)
|
||||
}
|
||||
fn change_password(&self, password: &str) -> soulseek_rs::Result<()> {
|
||||
*self.password_set.lock().expect("not poisoned") =
|
||||
Some(password.to_string());
|
||||
Ok(())
|
||||
}
|
||||
fn shared_listing(&self) -> Vec<soulseek_rs::SharedDirectory> {
|
||||
self.listing.lock().expect("not poisoned").clone()
|
||||
}
|
||||
fn shared_directories(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
fn set_shared_directories(
|
||||
&self,
|
||||
directories: Vec<String>,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
*self.shares_set.lock().expect("not poisoned") = Some(directories);
|
||||
Ok(())
|
||||
}
|
||||
fn set_download_directory(
|
||||
&self,
|
||||
directory: String,
|
||||
) -> soulseek_rs::Result<()> {
|
||||
*self.download_dir_set.lock().expect("not poisoned") = Some(directory);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn tui(history: Vec<ChatMessageDto>) -> MainTui {
|
||||
with_session(TalkativeSession {
|
||||
history,
|
||||
shared: true,
|
||||
..TalkativeSession::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn shared(searches: Vec<crate::api::SessionSearch>) -> TalkativeSession {
|
||||
TalkativeSession {
|
||||
searches: std::sync::Mutex::new(searches),
|
||||
shared: true,
|
||||
..TalkativeSession::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch {
|
||||
crate::api::SessionSearch {
|
||||
query: query.to_string(),
|
||||
files,
|
||||
started_secs_ago: Some(age),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_session(session: TalkativeSession) -> MainTui {
|
||||
attach(Arc::new(session))
|
||||
}
|
||||
|
||||
fn attach(session: Arc<TalkativeSession>) -> MainTui {
|
||||
MainTui::new(
|
||||
session,
|
||||
"/tmp".to_string(),
|
||||
Duration::from_secs(1),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult {
|
||||
soulseek_rs::SearchResult {
|
||||
token: 0,
|
||||
files: names
|
||||
.iter()
|
||||
.map(|name| soulseek_rs::types::File {
|
||||
username: "bob".to_string(),
|
||||
name: (*name).to_string(),
|
||||
size: 42,
|
||||
attribs: std::collections::HashMap::new(),
|
||||
})
|
||||
.collect(),
|
||||
slots: 1,
|
||||
speed: 0,
|
||||
username: "bob".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// An attached window that selected a two-file search and has already
|
||||
/// ticked past its one necessary fetch.
|
||||
fn window_that_pulled_two_files() -> (Arc<TalkativeSession>, MainTui) {
|
||||
let session = Arc::new(TalkativeSession {
|
||||
results: std::sync::Mutex::new(vec![files_from_bob(&[
|
||||
"@@x\\a.mp3",
|
||||
"@@x\\b.mp3",
|
||||
])]),
|
||||
..shared(vec![search_of("aphex twin", 2, 9_999)])
|
||||
});
|
||||
let mut tui = attach(session.clone());
|
||||
tui.state.selected_search_index = Some(0);
|
||||
tui.update_search_results();
|
||||
tui.update_search_results();
|
||||
(session, tui)
|
||||
}
|
||||
|
||||
fn results(n: usize) -> Vec<crate::models::FileDisplayData> {
|
||||
(0..n)
|
||||
.map(|i| crate::models::FileDisplayData {
|
||||
filename: format!("@@x\\Music\\{i:02}.mp3"),
|
||||
username: "bob".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn results_tui(n: usize) -> MainTui {
|
||||
let mut tui = with_session(TalkativeSession::default());
|
||||
tui.state.results_items = results(n);
|
||||
tui.state.focused_pane = FocusedPane::Results;
|
||||
// 88 cells wide gives the name column 11 cells and the folder 7.
|
||||
tui.state.results_pane_area =
|
||||
Some(ratatui::layout::Rect::new(0, 0, 88, 13));
|
||||
tui
|
||||
}
|
||||
|
||||
fn press(tui: &mut MainTui, code: KeyCode) {
|
||||
tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn ctrl(tui: &mut MainTui, ch: char) {
|
||||
tui.handle_key_event(KeyEvent::new(
|
||||
KeyCode::Char(ch),
|
||||
KeyModifiers::CONTROL,
|
||||
));
|
||||
}
|
||||
|
||||
fn selected(tui: &MainTui) -> Option<usize> {
|
||||
tui.state.results_table_state.selected()
|
||||
}
|
||||
|
||||
fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String {
|
||||
let backend = ratatui::backend::TestBackend::new(width, height);
|
||||
let mut terminal = ratatui::Terminal::new(backend).expect("backend");
|
||||
terminal.draw(|frame| tui.render(frame)).expect("draw");
|
||||
terminal.backend().to_string()
|
||||
}
|
||||
|
||||
fn screen_of(tui: &mut MainTui) -> String {
|
||||
screen_sized(tui, 160, 40)
|
||||
}
|
||||
|
||||
fn mouse(
|
||||
tui: &mut MainTui,
|
||||
kind: ratatui::crossterm::event::MouseEventKind,
|
||||
column: u16,
|
||||
row: u16,
|
||||
) {
|
||||
tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent {
|
||||
kind,
|
||||
column,
|
||||
row,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
});
|
||||
}
|
||||
|
||||
fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) {
|
||||
use ratatui::crossterm::event::{MouseButton, MouseEventKind};
|
||||
mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1);
|
||||
mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1);
|
||||
mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1);
|
||||
}
|
||||
|
||||
/// The screen row carrying a pane's legend, `[2] Results` say, without
|
||||
/// the quotes the test backend wraps each row in.
|
||||
fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str {
|
||||
let line = screen
|
||||
.lines()
|
||||
.find(|line| line.contains(needle))
|
||||
.unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}"));
|
||||
let start = line.find('"').map_or(0, |i| i + 1);
|
||||
let end = line.rfind('"').unwrap_or(line.len());
|
||||
&line[start..end]
|
||||
}
|
||||
|
||||
/// A window with a search, its results, and a transfer, so every pane
|
||||
/// has something to show.
|
||||
fn furnished_tui() -> MainTui {
|
||||
let mut tui = results_tui(3);
|
||||
tui.state.searches.push(crate::models::SearchEntry {
|
||||
query: "aphex twin".to_string(),
|
||||
status: SearchStatus::Completed,
|
||||
results: results(3),
|
||||
known_files: 3,
|
||||
owned: true,
|
||||
start_time: std::time::Instant::now(),
|
||||
cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
});
|
||||
tui.state.selected_search_index = Some(0);
|
||||
tui.state.downloads.push(crate::models::DownloadEntry {
|
||||
download: queued("bob", "@@x\\Music\\00.mp3"),
|
||||
receiver: None,
|
||||
});
|
||||
tui
|
||||
}
|
||||
|
||||
fn browsed(tui: &mut MainTui, files: usize) {
|
||||
tui.state.browse.open("bob");
|
||||
let listing = vec![soulseek_rs::SharedDirectory {
|
||||
name: "Music".to_string(),
|
||||
files: (0..files)
|
||||
.map(|i| soulseek_rs::SharedFileEntry {
|
||||
name: format!("{i:02}.mp3"),
|
||||
size: 1,
|
||||
attributes: Vec::new(),
|
||||
})
|
||||
.collect(),
|
||||
}];
|
||||
tui.state
|
||||
.browse
|
||||
.active_tab_mut()
|
||||
.expect("tab")
|
||||
.load(&listing);
|
||||
tui.state.show_browse = true;
|
||||
let _ = screen_of(tui);
|
||||
}
|
||||
|
||||
fn in_a_room(tui: &mut MainTui, messages: usize) {
|
||||
tui.state.rooms.apply_event(
|
||||
soulseek_rs::RoomEvent::Joined {
|
||||
room: "jazz".to_string(),
|
||||
users: (0..30).map(|i| format!("user{i:02}")).collect(),
|
||||
},
|
||||
None,
|
||||
);
|
||||
tui.state.rooms.focus_or_open("jazz");
|
||||
for i in 0..messages {
|
||||
tui.state.rooms.apply_event(
|
||||
soulseek_rs::RoomEvent::Message {
|
||||
room: "jazz".to_string(),
|
||||
username: "alice".to_string(),
|
||||
message: format!("line {i:03}"),
|
||||
},
|
||||
Some("jazz"),
|
||||
);
|
||||
}
|
||||
tui.state.show_rooms = true;
|
||||
let _ = screen_of(tui);
|
||||
}
|
||||
|
||||
/// The queries the window has sent so far, once at least `count` of them
|
||||
/// are out: a search goes to the session from a worker thread, so a test
|
||||
/// has to give it a moment.
|
||||
fn searched(session: &TalkativeSession, count: usize) -> Vec<String> {
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let seen = session.searched.lock().expect("not poisoned").clone();
|
||||
if seen.len() >= count || std::time::Instant::now() > deadline {
|
||||
return seen;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
|
||||
/// A window over last session's search list: the queries came back from
|
||||
/// disk, with nothing under them.
|
||||
fn restored(session: Arc<TalkativeSession>, queries: &[&str]) -> MainTui {
|
||||
let mut tui = attach(session);
|
||||
let queries: Vec<String> =
|
||||
queries.iter().map(ToString::to_string).collect();
|
||||
restore_searches(&mut tui.state, &queries);
|
||||
tui.state.focused_pane = FocusedPane::Searches;
|
||||
tui
|
||||
}
|
||||
Reference in New Issue
Block a user