mod actions; mod message; mod persistence; mod ui; use cursive::reexports::log; use cursive::views::{ Button, Dialog, DummyView, EditView, LinearLayout, NamedView, Panel, ScrollView, SelectView, TextArea, TextView, }; use cursive::{event, Cursive}; use cursive::{traits::*, CursiveExt as _}; use message::Message; use std::collections::HashMap; use std::io::{Read as _, Write as _}; use std::thread; use utils::serialize::Serialize; use ui::{ DIALOGUE_MIN_SIZE, INPUT_BUTTON_ID, INPUT_CLEAR_BUTTON_ID, INPUT_FIELD_ID, INPUT_PANEL_ID, LANGUAGE_BUTTON_ID, MESSAGE_PANEL_ID, REFRESH_BUTTON_ID, USERNAME_BUTTON_ID, USERNAME_FIELD_ID, USERS_PANEL_ID, USER_PANEL_SIZE, }; const MAX_MESSAGE_LENGTH: usize = 512; const MAX_USERNAME_LENGTH: usize = 16; const DEFAULT_USERNAME_PREFIX: &str = "Myst"; const DEFAULT_CHANNEL: &str = "Root"; const SAVE_FILE: &str = "savedata.bin"; const SAVE_FILE_FUZZY: u64 = 0b0110110001101001011001110110110101100001001000000101100001000100; #[derive(Clone)] pub struct Appdata { pub username: String, pub language: ui::Language, pub blocked_phrases: Vec, pub blocked_phrases_censor_char: char, pub api_endpoint: String, pub api_refresh_rate: usize, pub api_password: String, pub current_channel: String, pub messages: HashMap, pub quick_close_window_count: usize, pub local_channels: Vec, } impl Default for Appdata { fn default() -> Self { Self::new() } } impl Appdata { pub fn new() -> Self { Self { messages: HashMap::new(), username: format!( "{}#{}", DEFAULT_USERNAME_PREFIX, utils::rng::random_numeric_string(4) ), language: ui::Language::English, quick_close_window_count: 0, api_endpoint: String::new(), api_refresh_rate: 10, blocked_phrases: Vec::new(), blocked_phrases_censor_char: '*', current_channel: DEFAULT_CHANNEL.to_string(), local_channels: vec![DEFAULT_CHANNEL.to_string()], api_password: String::new(), } } } pub fn get_appdata(siv: &mut Cursive) -> Appdata { siv.with_user_data(|appdata: &mut Appdata| appdata.clone()) .expect("Failed to retrieve appdata.") } pub fn save_appdata(siv: &mut Cursive) { let appdata = get_appdata(siv); let savedata = persistence::Savedata::from(appdata); let bytes = savedata.serialize_checked(); let fuzzy_bytes = utils::binary::fuzzy_bytes(bytes, SAVE_FILE_FUZZY); let mut file = std::fs::File::create(SAVE_FILE).expect("Failed to create savedata file."); file.write_all(&fuzzy_bytes) .expect("Failed to write savedata file."); log::info!("Wrote savadata to file."); } pub fn load_appdata(siv: &mut Cursive) -> std::io::Result<()> { let mut df = logging::warn_deferred("Savedata file not found; using defaults"); let mut file = std::fs::File::open(SAVE_FILE)?; let mut bytes = Vec::new(); file.read_to_end(&mut bytes)?; let useful_bytes = utils::binary::fuzzy_bytes(bytes, SAVE_FILE_FUZZY); df.cancel(); let Ok(savedata) = persistence::Savedata::deserialize_checked(useful_bytes) else { // If the file is corrupted, create a new one logging::warn("Savedata file corrupted; using defaults."); return Ok(()); }; let appdata = Appdata::from(savedata); siv.set_user_data(appdata); logging::info("Savedata file loaded."); Ok(()) } fn main() { if cfg!(debug_assertions) { logging::set_global_log_level(logging::LogLevel::Trace); } else { logging::set_global_log_level(logging::LogLevel::Info); } utils::rng::shuffle_rng(); let mut siv = Cursive::default(); siv.set_user_data(Appdata::new()); // TODO (low): Add a notice when the file is corrupted. let _ = load_appdata(&mut siv); // Global hotkeys siv.add_global_callback(event::Key::Backspace, |siv| { let _ = siv.focus_name(INPUT_FIELD_ID); }); // Background thread { let cb_sink = siv.cb_sink().clone(); let mut timer = 0; thread::spawn(move || loop { cb_sink .send(Box::new(move |siv| { let appdata = get_appdata(siv); ui::visual_update(siv); if timer % appdata.api_refresh_rate == 0 { // TODO (low): Add a notice when automatic refresh fails. let _ = actions::load_messages(siv); } save_appdata(siv); })) .expect("Failed to send callback from background thread."); timer += 1; thread::sleep(std::time::Duration::from_secs(1)); }); } // Channels let channels_panel = { let mut view: SelectView = SelectView::new(); view.set_on_submit(move |siv, channel_id: &str| { siv.with_user_data(|appdata: &mut Appdata| { appdata.current_channel = channel_id.to_string(); }); ui::visual_update(siv); }); Panel::new(ScrollView::new(view)).with_name(ui::CHANNEL_VIEW_ID) }; let channel_current_panel = { let view = TextView::new(""); Panel::new(view).with_name(ui::CHANNEL_CURRENT_PANEL_ID) }; let channels_new_button = Button::new("", move |siv| { ui::keybind_setup_close_once(siv); let language = get_appdata(siv).language; siv.add_layer( cursive::views::Dialog::new() .title(ui::Labels::NewChannel.localize(language)) .content( LinearLayout::vertical().child( EditView::new() .with_name(ui::CHANNEL_NEW_FIELD_ID) .fixed_width(DIALOGUE_MIN_SIZE.0.into()), ), ) .button(ui::Labels::Ok.localize(language), move |siv| { let input = siv .call_on_name(ui::CHANNEL_NEW_FIELD_ID, |view: &mut EditView| { view.get_content() }) .expect("Failed to retrieve channel creation input."); if message::is_valid_username(&input) { // Valid siv.with_user_data(|appdata: &mut Appdata| { appdata.local_channels.push(input.to_string()); }); save_appdata(siv); ui::visual_update(siv); ui::keybind_close_manual_end(siv, false); } else { // Invalid ui::keybind_close_manual_end(siv, false); // NOTE: Error dialogue overwrites this one, so end it here beforehand. ui::error( siv, ui::Labels::InvalidChannelNameExplination.localize(language), ); } }) .button(ui::Labels::Cancel.localize(language), |siv| { ui::keybind_close_manual_end(siv, false); }) .min_size(DIALOGUE_MIN_SIZE), ); }) .with_name(ui::CHANNEL_NEW_BUTTON_ID); // Users let users_panel = { let mut users_view: SelectView = SelectView::new(); users_view.set_on_submit(move |siv, user: &String| actions::on_user_click(siv, user)); Panel::new(ScrollView::new(users_view)).with_name(USERS_PANEL_ID) }; // Messages let messages_panel = { let mut view: SelectView = SelectView::new(); view.set_on_submit(move |siv, message_id: &str| actions::on_message_click(siv, message_id)); Panel::new(ScrollView::new(view)).with_name(MESSAGE_PANEL_ID) }; // Input let input_panel = { let input_field = TextArea::new() .with_name(INPUT_FIELD_ID) .full_width() .max_height(10); // NOTE: 10 seems to be the size of 2 vertical buttons let input_button = Button::new("", move |siv| actions::on_input_submit(siv, INPUT_FIELD_ID)) .with_name(INPUT_BUTTON_ID); let input_clear_button = Button::new("", actions::clear_input_field).with_name(INPUT_CLEAR_BUTTON_ID); Panel::new( LinearLayout::horizontal().child(input_field).child( LinearLayout::vertical() .child(input_button) .child(input_clear_button), ), ) .with_name(INPUT_PANEL_ID) }; // Language selector let language_button = Button::new("", move |siv| { // NOTE: This must match the order in the SelectView let current_language_index = match get_appdata(siv).language { ui::Language::English => 0, ui::Language::Dutch => 1, ui::Language::Japanese => 2, }; let language = get_appdata(siv).language; ui::keybind_setup_close_once(siv); siv.add_layer( cursive::views::Dialog::new() .content( LinearLayout::vertical().child( SelectView::::new() .item("English", ui::Language::English) .item("Nederlands", ui::Language::Dutch) .item("日本語", ui::Language::Japanese) .selected(current_language_index) .on_submit(move |siv, language| { ui::change_language(siv, *language); ui::keybind_close_manual_end(siv, false); }), ), ) .title(ui::Labels::SetLanguage.localize(language)) .button(ui::Labels::Cancel.localize(language), |siv| { ui::keybind_close_manual_end(siv, false); }) .min_size(DIALOGUE_MIN_SIZE), ); }) .with_name(LANGUAGE_BUTTON_ID); // Current username panel let current_username_panel = { let current_username = get_appdata(&mut siv).username.clone(); let view = TextView::new(current_username); Panel::new(view).with_name(ui::CURRENT_USERNAME_PANEL_ID) }; // Set username let username_button = Button::new("", move |siv| { let current_name = get_appdata(siv).username.clone(); let language = get_appdata(siv).language; ui::keybind_setup_close_once(siv); siv.add_layer( cursive::views::Dialog::new() .title(ui::Labels::Username.localize(language)) .content( LinearLayout::vertical().child( EditView::new() .content(current_name) .with_name(USERNAME_FIELD_ID) .fixed_width(DIALOGUE_MIN_SIZE.0.into()), ), ) .button(ui::Labels::Ok.localize(language), |siv| { let input = siv .call_on_name(USERNAME_FIELD_ID, |view: &mut EditView| view.get_content()) .expect("Failed to retrieve username input."); if message::is_valid_username(&input) { // Valid siv.with_user_data(|appdata: &mut Appdata| { appdata.username = input.to_string(); }); save_appdata(siv); ui::visual_update(siv); ui::keybind_close_manual_end(siv, false); } else { // Invalid let language = get_appdata(siv).language; ui::keybind_close_manual_end(siv, false); // NOTE: Error dialogue overwrites this one, so end it here beforehand. ui::error( siv, ui::Labels::InvalidUsernameExplination.localize(language), ); } }) .button(ui::Labels::Cancel.localize(language), |siv| { ui::keybind_close_manual_end(siv, false); }) .min_size(DIALOGUE_MIN_SIZE), ); }) .with_name(USERNAME_BUTTON_ID); // Refresh button let refresh_button = Button::new("", move |siv| { if let Err(e) = actions::load_messages(siv) { let language = get_appdata(siv).language; ui::error(siv, ui::Labels::FailedToLoadMessages(e).localize(language)); } ui::visual_update(siv); }) .with_name(REFRESH_BUTTON_ID); // Blocked words list let blocked_words_list_button = { Button::new("", move |siv| { let appdata = get_appdata(siv); let language = appdata.language; let wordslist_id = utils::new_id(); let wordslist_id_clone = wordslist_id.clone(); let wordslist_id_typingarea_clone = wordslist_id.clone(); let mut wordslist_view = SelectView::::new().on_submit(move |siv, word: &str| { siv.with_user_data(|appdata: &mut Appdata| { appdata.blocked_phrases.retain(|x| x != word); }); siv.call_on_name(&wordslist_id_clone, |view: &mut NamedView| { let idx = view .get_mut() .selected_id() .expect("Selection should exist when fired from submit."); view.get_mut().remove_item(idx); }); save_appdata(siv); ui::visual_update(siv); }); wordslist_view.add_all_str(&appdata.blocked_phrases); let typingarea_id = utils::new_id(); let typing_area = EditView::new().with_name(&typingarea_id); let typing_button = Button::new(ui::Labels::Submit.localize(language), move |siv| { let text = siv .call_on_name(&typingarea_id, |view: &mut EditView| { let s = utils::strings::insensitive_string(&view.get_content()); view.set_content(""); s }) .map(|x| x.to_string()) .unwrap_or("".to_string()); if get_appdata(siv).blocked_phrases.contains(&text) || text.is_empty() { return; } siv.with_user_data(|appdata: &mut Appdata| { appdata.blocked_phrases.push(text.clone()); }); siv.call_on_name( &wordslist_id_typingarea_clone, |view: &mut NamedView| { view.get_mut().add_item_str(text); }, ); save_appdata(siv); ui::visual_update(siv); }); let d = Dialog::new() .title(ui::Labels::BlockedWords.localize(language)) .button(ui::Labels::Close.localize(language), |siv| { ui::keybind_close_manual_end(siv, false); }) .content( LinearLayout::vertical() .child(ScrollView::new(wordslist_view.with_name(wordslist_id))) .child(DummyView.fixed_height(1)) .child( LinearLayout::horizontal() .child(typing_area.min_width(DIALOGUE_MIN_SIZE.0 as usize)) .child(typing_button), ), ); ui::keybind_setup_close_once(siv); siv.add_layer(d); }) .with_name(ui::BLOCKED_WORDS_BUTTON_ID) }; // Server settings let server_settings_button = Button::new("", |siv| { let appdata = get_appdata(siv); let language = appdata.language; ui::keybind_setup_close_once(siv); siv.add_layer( cursive::views::Dialog::new() .title(ui::Labels::ServerSettings.localize(language)) .content( LinearLayout::vertical() .child( LinearLayout::horizontal() .child( TextView::new(ui::Labels::ServerAddress.localize(language)) .fixed_width(ui::SERVER_SETTINGS_FIELD_SIZE.0 / 2), ) .child(DummyView.fixed_width(1)) .child( EditView::new() .content(appdata.api_endpoint) .with_name(ui::SERVER_SETTINGS_ADDRESS_FIELD_ID) .min_width(ui::SERVER_SETTINGS_FIELD_SIZE.0) .max_height(ui::SERVER_SETTINGS_FIELD_SIZE.1), ), ) .child( LinearLayout::horizontal() .child( TextView::new(ui::Labels::ServerRefreshRate.localize(language)) .fixed_width(ui::SERVER_SETTINGS_FIELD_SIZE.0 / 2), ) .child(DummyView.fixed_width(1)) .child( EditView::new() .content(appdata.api_refresh_rate.to_string()) .with_name(ui::SERVER_SETTINGS_REFRESH_FIELD_ID) .min_width(ui::SERVER_SETTINGS_FIELD_SIZE.0) .max_height(ui::SERVER_SETTINGS_FIELD_SIZE.1), ), ) .child( LinearLayout::horizontal() .child( TextView::new(ui::Labels::Password.localize(language)) .fixed_width(ui::SERVER_SETTINGS_FIELD_SIZE.0 / 2), ) .child(DummyView.fixed_width(1)) .child( EditView::new() .content(appdata.api_password.to_string()) .with_name(ui::SERVER_SETTINGS_PASSWORD_FIELD_ID) .min_width(ui::SERVER_SETTINGS_FIELD_SIZE.0) .max_height(ui::SERVER_SETTINGS_FIELD_SIZE.1), ), ), ) .button(ui::Labels::Ok.localize(language), |siv| { let input_addr = siv .call_on_name( ui::SERVER_SETTINGS_ADDRESS_FIELD_ID, |view: &mut EditView| view.get_content(), ) .expect("Failed to retrieve server settings address input."); let input_refresh = siv .call_on_name( ui::SERVER_SETTINGS_REFRESH_FIELD_ID, |view: &mut EditView| view.get_content(), ) .expect("Failed to retrieve server settings refresh rate input."); let input_password = siv .call_on_name( ui::SERVER_SETTINGS_PASSWORD_FIELD_ID, |view: &mut EditView| view.get_content(), ) .expect("Failed to retrieve server settings password input."); siv.with_user_data(|appdata: &mut Appdata| { appdata.api_endpoint = input_addr.to_string(); appdata.api_password = input_password.to_string(); if let Ok(rate) = input_refresh.parse() { appdata.api_refresh_rate = rate; } }); save_appdata(siv); ui::visual_update(siv); ui::keybind_close_manual_end(siv, false); }) .button(ui::Labels::Cancel.localize(language), |siv| { ui::keybind_close_manual_end(siv, false); }) .min_size(DIALOGUE_MIN_SIZE), ); }) .with_name(ui::SERVER_SETTINGS_BUTTON_ID); // Main layout let main_layout = LinearLayout::vertical() .child( LinearLayout::horizontal() .child( LinearLayout::vertical() .child(channels_panel.fixed_width(USER_PANEL_SIZE).full_height()) .child(channel_current_panel), ) .child( LinearLayout::vertical() .child(users_panel.resized( cursive::view::SizeConstraint::Fixed(USER_PANEL_SIZE), cursive::view::SizeConstraint::Full, )) .child(current_username_panel), ) .child(messages_panel.full_width()), ) .child(input_panel) .child( LinearLayout::horizontal() .child(language_button) .child(DummyView.full_width()) .child(username_button) .child(DummyView.full_width()) .child(channels_new_button) .child(DummyView.full_width()) .child(blocked_words_list_button) .child(DummyView.full_width()) .child(server_settings_button) .child(DummyView.full_width()) .child(refresh_button) .full_width(), ); siv.add_fullscreen_layer(main_layout); siv.run(); }