diff --git a/src/chat/config.rs b/src/chat/config.rs index 079720f..5c74470 100644 --- a/src/chat/config.rs +++ b/src/chat/config.rs @@ -3,6 +3,8 @@ use serde_default::DefaultFromSerde; use serde_yml; use std::{error::Error, fs, path::PathBuf}; +use super::SERVER_LIST; + const MESSAGE_FORMAT: &str = "\u{B9AC}\u{3E70}<{name}> {text}"; fn default_true() -> bool { @@ -30,6 +32,10 @@ pub fn default_message_format() -> String { MESSAGE_FORMAT.to_string() } +pub fn default_servers() -> Vec { + SERVER_LIST.to_vec() +} + #[derive(serde::Serialize, serde::Deserialize, DefaultFromSerde, Clone)] pub struct Config { #[serde(default = "default_host")] @@ -70,6 +76,8 @@ pub struct Config { pub debug_logs: bool, #[serde(default)] pub avatar: Option, + #[serde(default = "default_servers")] + pub servers: Vec, } #[cfg(target_os = "windows")] @@ -183,6 +191,7 @@ pub struct Args { pub avatar: Option, #[arg(long)] pub debug_logs: bool, + // TODO: add servers } impl Args { diff --git a/src/chat/gui/images/servers.png b/src/chat/gui/images/servers.png new file mode 100644 index 0000000..544c248 Binary files /dev/null and b/src/chat/gui/images/servers.png differ diff --git a/src/chat/gui/mod.rs b/src/chat/gui/mod.rs index f9ec74d..eab4e27 100644 --- a/src/chat/gui/mod.rs +++ b/src/chat/gui/mod.rs @@ -12,40 +12,32 @@ use std::time::{Duration, SystemTime}; use clap::crate_version; -use libadwaita::gdk::Texture; -use libadwaita::gtk::gdk_pixbuf::InterpType; -use libadwaita::gtk::{Button, Label}; -use libadwaita::{ - self as adw, Avatar, Breakpoint, BreakpointCondition, OverlaySplitView -}; use adw::gdk::Display; use adw::gio::{ActionEntry, ApplicationFlags, Menu}; use adw::glib::clone; -use adw::glib::{ - self, source::timeout_add_local_once, timeout_add_once, -}; +use adw::glib::{self, source::timeout_add_local_once, timeout_add_once}; use adw::prelude::*; use adw::{Application, ApplicationWindow}; +use libadwaita::gdk::Texture; +use libadwaita::gtk::gdk_pixbuf::InterpType; +use libadwaita::gtk::{Button, Entry, Label, Picture}; +use libadwaita::{self as adw, Avatar, Breakpoint, BreakpointCondition, Dialog, OverlaySplitView}; use adw::gtk; use gtk::gdk_pixbuf::{Pixbuf, PixbufLoader}; -use gtk::{ - Box as GtkBox, - CssProvider, - Orientation, ScrolledWindow, Settings, -}; +use gtk::{Box as GtkBox, CssProvider, Orientation, ScrolledWindow, Settings}; use crate::chat::grab_avatar; +use super::config::get_config_path; use super::{ - config::{ - save_config, Config, - }, - ctx::Context, print_message, recv_tick, sanitize_message, + config::{save_config, Config}, + ctx::Context, + print_message, recv_tick, sanitize_message, }; -mod preferences; mod page; +mod preferences; mod widgets; use page::*; @@ -53,7 +45,7 @@ use preferences::*; pub fn try_save_config(path: PathBuf, config: &Config) { match save_config(path, config) { - Ok(_) => {}, + Ok(_) => {} Err(e) => { println!("save config error: {e}") } @@ -71,7 +63,7 @@ struct UiModel { #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] notifications: Arc>>, avatars: Arc>>>, - latest_sign: Arc + latest_sign: Arc, } thread_local!( @@ -163,9 +155,175 @@ fn build_menu(ctx: Arc, app: &Application) -> Menu { menu } -fn build_sidebar(_ctx: Arc, _app: &Application) -> GtkBox { - let sidebar = GtkBox::new(Orientation::Vertical, 0); - sidebar.append(&Label::new(Some("hello worlding"))); +fn build_sidebar_button( + ctx: Arc, + split_view: &OverlaySplitView, + server: String, + servers_list: &GtkBox, +) -> GtkBox { + let hbox = GtkBox::new(Orientation::Horizontal, 5); + + let button = Button::builder().label(&server).hexpand(true).build(); + + button.connect_clicked(clone!( + #[weak] + split_view, + #[weak] + ctx, + #[strong] + server, + move |_| { + let mut config = ctx.config.read().unwrap().clone(); + config.host = server.clone(); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + update_window_title(ctx.clone()); + if split_view.is_collapsed() { + split_view.set_show_sidebar(false); + } + } + )); + + hbox.append(&button); + + let delete_button = Button::from_icon_name("user-trash-symbolic"); + + delete_button.connect_clicked(clone!( + #[weak] + ctx, + #[weak] + hbox, + #[weak] + servers_list, + #[strong] + server, + move |_| { + servers_list.remove(&hbox); + let mut config = ctx.config.read().unwrap().clone(); + let index = config.servers.iter().position(|x| *x == server).unwrap(); + config.servers.remove(index); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + } + )); + + hbox.append(&delete_button); + + hbox +} + +fn build_sidebar(ctx: Arc, app: &Application, split_view: &OverlaySplitView) -> GtkBox { + let sidebar = GtkBox::new(Orientation::Vertical, 5); + + sidebar.append( + &Picture::builder() + .paintable(&Texture::for_pixbuf( + &load_pixbuf(include_bytes!("images/servers.png")).unwrap(), + )) + .build(), + ); + + let servers_list = GtkBox::new(Orientation::Vertical, 5); + + for server in ctx.config(|o| o.servers.clone()) { + servers_list.append(&build_sidebar_button( + ctx.clone(), + &split_view, + server, + &servers_list, + )); + } + + sidebar.append(&servers_list); + + let add_server = Button::builder() + .label("Add Server") + // .start_icon_name("list-add-symbolic") + .margin_top(10) + .build(); + + add_server.connect_clicked(clone!( + #[weak] + app, + #[weak] + servers_list, + #[weak] + ctx, + #[weak] + split_view, + move |_| { + let dialog = Dialog::new(); + + let vbox = GtkBox::new(Orientation::Vertical, 5); + + vbox.set_margin_bottom(20); + vbox.set_margin_top(20); + vbox.set_margin_end(20); + vbox.set_margin_start(20); + + vbox.append(&Label::builder().label("Add server").build()); + + let entry = Entry::builder().placeholder_text("Server host").build(); + + vbox.append(&entry); + + let hbox = GtkBox::new(Orientation::Horizontal, 5); + + let confirm = Button::builder().label("Confirm").build(); + + confirm.connect_clicked(clone!( + #[weak] + dialog, + #[weak] + servers_list, + #[weak] + ctx, + #[weak] + split_view, + #[weak] + entry, + move |_| { + let server: String = entry.text().into(); + + let mut config = ctx.config.read().unwrap().clone(); + config.servers.push(server.clone()); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + + servers_list.append(&build_sidebar_button( + ctx.clone(), + &split_view, + server, + &servers_list, + )); + dialog.close(); + } + )); + + hbox.append(&confirm); + + let cancel = Button::builder().label("Cancel").build(); + + cancel.connect_clicked(clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + + hbox.append(&cancel); + + vbox.append(&hbox); + + dialog.set_child(Some(&vbox)); + + dialog.present(app.active_window().as_ref()); + } + )); + + sidebar.append(&add_server); + sidebar } @@ -182,43 +340,43 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { #[cfg(target_os = "windows")] let is_dark_theme = true; - - let main_box = GtkBox::new(Orientation::Vertical, 0); - let title = format!( - "bRAC - Connected to {} as {}", - ctx.config(|o| o.host.clone()), - &ctx.name() - ); + let main_box = GtkBox::new(Orientation::Vertical, 0); let (header, page, chat_box, chat_scrolled) = build_page(ctx.clone(), app); - let sidebar = build_sidebar(ctx.clone(), &app); - let split_view = OverlaySplitView::builder() - .sidebar(&sidebar) .content(&page) .enable_hide_gesture(true) .enable_show_gesture(true) .collapsed(true) .build(); - + + let sidebar = build_sidebar(ctx.clone(), &app, &split_view); + + split_view.set_sidebar(Some(&sidebar)); + main_box.append(&split_view); let toggle_button = Button::from_icon_name("go-previous-symbolic"); toggle_button.connect_clicked(clone!( - #[weak] split_view, + #[weak] + split_view, move |_| { split_view.set_show_sidebar(!split_view.shows_sidebar()); } )); - + header.pack_start(&toggle_button); let window = ApplicationWindow::builder() .application(app) - .title(&title) + .title(&format!( + "bRAC - Connected to {} as {}", + ctx.config(|o| o.host.clone()), + &ctx.name() + )) .default_width(500) .default_height(500) .resizable(true) @@ -226,17 +384,15 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { .content(&main_box) .build(); - let breakpoint = Breakpoint::new( - BreakpointCondition::new_length( - libadwaita::BreakpointConditionLengthType::MinWidth, - 700.0, - libadwaita::LengthUnit::Px - ) - ); + let breakpoint = Breakpoint::new(BreakpointCondition::new_length( + libadwaita::BreakpointConditionLengthType::MinWidth, + 700.0, + libadwaita::LengthUnit::Px, + )); breakpoint.add_setter(&split_view, "collapsed", Some(&false.into())); breakpoint.add_setter(&toggle_button, "visible", Some(&false.into())); - + window.add_breakpoint(breakpoint); window.present(); @@ -252,7 +408,7 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] notifications: Arc::new(RwLock::new(Vec::::new())), avatars: Arc::new(Mutex::new(HashMap::new())), - latest_sign: Arc::new(AtomicU64::new(0)) + latest_sign: Arc::new(AtomicU64::new(0)), } } @@ -329,22 +485,41 @@ fn setup(_: &Application, ctx: Arc, ui: UiModel) { if ctx.config(|o| !o.new_ui_enabled) { return; } - + thread::spawn(move || { for message in messages.iter() { - let Some(avatar_url) = grab_avatar(message) else { continue }; + let Some(avatar_url) = grab_avatar(message) else { + continue; + }; let avatar_id = get_avatar_id(&avatar_url); - let Some(avatar) = load_avatar(&avatar_url, ctx.config(|o| o.proxy.clone()), ctx.config(|o| o.max_avatar_size as usize)) else { println!("cant load avatar: {avatar_url} request error"); continue }; - let Ok(pixbuf) = load_pixbuf(&avatar) else { println!("cant load avatar: {avatar_url} pixbuf error"); continue; }; - let Some(pixbuf) = pixbuf.scale_simple(32, 32, InterpType::Bilinear) else { println!("cant load avatar: {avatar_url} scale image error"); continue }; + let Some(avatar) = load_avatar( + &avatar_url, + ctx.config(|o| o.proxy.clone()), + ctx.config(|o| o.max_avatar_size as usize), + ) else { + println!("cant load avatar: {avatar_url} request error"); + continue; + }; + let Ok(pixbuf) = load_pixbuf(&avatar) else { + println!("cant load avatar: {avatar_url} pixbuf error"); + continue; + }; + let Some(pixbuf) = + pixbuf.scale_simple(32, 32, InterpType::Bilinear) + else { + println!("cant load avatar: {avatar_url} scale image error"); + continue; + }; let texture = Texture::for_pixbuf(&pixbuf); timeout_add_once(Duration::ZERO, { move || { GLOBAL.with(|global| { if let Some(ui) = &*global.borrow() { - if let Some(pics) = ui.avatars.lock().unwrap().remove(&avatar_id) { + if let Some(pics) = + ui.avatars.lock().unwrap().remove(&avatar_id) + { for pic in pics { pic.set_custom_image(Some(&texture)); } @@ -454,42 +629,42 @@ fn load_avatar(url: &str, proxy: Option, response_limit: usize) -> Optio } else { format!("socks5://{proxy}") }; - + reqwest::blocking::Client::builder() .proxy(reqwest::Proxy::all(&proxy).ok()?) - .build().ok()? + .build() + .ok()? } else { reqwest::blocking::Client::new() }; - - client.get(url).send().ok() - .and_then(|mut resp| { - let mut data = Vec::new(); - let mut length = 0; - - loop { - if length >= response_limit { - break; - } - let mut buf = vec![0; (response_limit - length).min(1024)]; - let now_len = resp.read(&mut buf).ok()?; - if now_len == 0 { - break; - } - buf.truncate(now_len); - length += now_len; - data.append(&mut buf); - } - Some(data) - }) + client.get(url).send().ok().and_then(|mut resp| { + let mut data = Vec::new(); + let mut length = 0; + + loop { + if length >= response_limit { + break; + } + let mut buf = vec![0; (response_limit - length).min(1024)]; + let now_len = resp.read(&mut buf).ok()?; + if now_len == 0 { + break; + } + buf.truncate(now_len); + length += now_len; + data.append(&mut buf); + } + + Some(data) + }) } // creates sign that expires in 0-20 minutes fn get_message_sign(name: &str, date: &str) -> u64 { let mut hasher = DefaultHasher::new(); hasher.write(name.as_bytes()); - hasher.write(date[..date.len()-2].as_bytes()); + hasher.write(date[..date.len() - 2].as_bytes()); hasher.finish() } @@ -512,9 +687,21 @@ fn on_add_message(ctx: Arc, ui: &UiModel, message: String, notify: bool } if ctx.config(|o| o.new_ui_enabled) { - ui.chat_box.append(&get_new_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); + ui.chat_box.append(&get_new_message_box( + ctx.clone(), + ui, + message, + notify, + formatting_enabled, + )); } else { - ui.chat_box.append(&get_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); + ui.chat_box.append(&get_message_box( + ctx.clone(), + ui, + message, + notify, + formatting_enabled, + )); }; timeout_add_local_once(Duration::from_millis(1000), move || { diff --git a/src/chat/gui/preferences.rs b/src/chat/gui/preferences.rs index 949a362..3135577 100644 --- a/src/chat/gui/preferences.rs +++ b/src/chat/gui/preferences.rs @@ -1,35 +1,29 @@ use std::sync::Arc; -use libadwaita::gtk::Adjustment; -use libadwaita::{ - self as adw, ActionRow, ButtonRow, EntryRow, PreferencesDialog, PreferencesGroup, PreferencesPage, SpinRow, SwitchRow -}; use adw::gdk::Display; use adw::glib::clone; -use adw::glib::{ - self, -}; +use adw::glib::{self}; use adw::prelude::*; use adw::Application; +use libadwaita::gtk::Adjustment; +use libadwaita::{ + self as adw, ActionRow, ButtonRow, EntryRow, PreferencesDialog, PreferencesGroup, + PreferencesPage, SpinRow, SwitchRow, +}; use adw::gtk; use gtk::Button; - use crate::chat::{ - config::{ - get_config_path, Config, - }, + config::{get_config_path, Config}, ctx::Context, }; use super::{try_save_config, update_window_title}; - pub fn open_settings(ctx: Arc, app: &Application) { let dialog = PreferencesDialog::builder().build(); - let page = PreferencesPage::builder() .title("General") .icon_name("avatar-default-symbolic") @@ -40,7 +34,6 @@ pub fn open_settings(ctx: Arc, app: &Application) { .description("Profile preferences") .build(); - // Name preference let name = EntryRow::builder() @@ -50,27 +43,22 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&name); - // Avatar preference - + let avatar = EntryRow::builder() .title("Avatar Link") .text(ctx.config(|o| o.avatar.clone()).unwrap_or_default()) .build(); group.add(&avatar); - page.add(&group); - - let group = PreferencesGroup::builder() .title("Server") .description("Connection preferences") .build(); - // Host preference let host = EntryRow::builder() @@ -80,60 +68,61 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&host); - // Messages limit preference - + let messages_limit = SpinRow::builder() .title("Messages limit") - .adjustment(&Adjustment::builder() - .lower(1.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.max_messages) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(1.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.max_messages) as f64) + .build(), + ) .build(); - + group.add(&messages_limit); - // Update interval preference - + let update_interval = SpinRow::builder() .title("Update interval") .subtitle("In milliseconds") - .adjustment(&Adjustment::builder() - .lower(10.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.update_time) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(10.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.update_time) as f64) + .build(), + ) .build(); - + group.add(&update_interval); - // Update interval OOF preference - + let update_interval_oof = SpinRow::builder() .title("Update interval when unfocused") .subtitle("In milliseconds") - .adjustment(&Adjustment::builder() - .lower(10.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.oof_update_time) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(10.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.oof_update_time) as f64) + .build(), + ) .build(); group.add(&update_interval_oof); page.add(&group); - - let group = PreferencesGroup::builder() .title("Config") .description("Configuration tools") @@ -154,7 +143,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { config_path_copy.set_margin_top(10); config_path_copy.set_margin_bottom(10); config_path_copy.connect_clicked(clone!( - #[weak] clipboard, + #[weak] + clipboard, move |_| { if let Some(text) = get_config_path().to_str() { clipboard.set_text(text); @@ -164,19 +154,20 @@ pub fn open_settings(ctx: Arc, app: &Application) { config_path.add_suffix(&config_path_copy); config_path.set_activatable(false); - + group.add(&config_path); // Reset button - let reset_button = ButtonRow::builder() - .title("Reset all") - .build(); + let reset_button = ButtonRow::builder().title("Reset all").build(); reset_button.connect_activated(clone!( - #[weak] ctx, - #[weak] app, - #[weak] dialog, + #[weak] + ctx, + #[weak] + app, + #[weak] + dialog, move |_| { dialog.close(); let config = Config::default(); @@ -185,15 +176,13 @@ pub fn open_settings(ctx: Arc, app: &Application) { open_settings(ctx, &app); } )); - + group.add(&reset_button); - + page.add(&group); dialog.add(&page); - - let page = PreferencesPage::builder() .title("Protocol") .icon_name("network-wired-symbolic") @@ -204,7 +193,6 @@ pub fn open_settings(ctx: Arc, app: &Application) { .description("Network preferences") .build(); - // Proxy preference let proxy = EntryRow::builder() @@ -214,35 +202,33 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&proxy); - // Max avatar size preference - + let max_avatar_size = SpinRow::builder() .title("Max avatar size") .subtitle("Maximum avatar size in bytes") - .adjustment(&Adjustment::builder() - .lower(0.0) - .upper(1074790400.0) - .page_increment(1024.0) - .step_increment(1024.0) - .value(ctx.config(|o| o.max_avatar_size) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(0.0) + .upper(1074790400.0) + .page_increment(1024.0) + .step_increment(1024.0) + .value(ctx.config(|o| o.max_avatar_size) as f64) + .build(), + ) .build(); group.add(&max_avatar_size); - page.add(&group); - let group = PreferencesGroup::builder() .title("Protocol") .description("Rac protocol preferences") .build(); - // Message format preference - + let message_format = EntryRow::builder() .title("Message format") .text(ctx.config(|o| o.message_format.clone())) @@ -252,9 +238,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { page.add(&group); - // Hide IP preference - + let hide_my_ip = SwitchRow::builder() .title("Hide IP") .subtitle("Hides only for clRAC and other dummy clients") @@ -263,9 +248,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&hide_my_ip); - // Chunked reading preference - + let chunked_reading = SwitchRow::builder() .title("Chunked reading") .subtitle("Read messages in chunks (less traffic usage, less compatibility)") @@ -274,9 +258,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&chunked_reading); - // Enable commands preference - + let enable_commands = SwitchRow::builder() .title("Enable commands") .subtitle("Enable slash commands (eg. /login) on client-side") @@ -285,11 +268,9 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&enable_commands); - page.add(&group); - - dialog.add(&page); + dialog.add(&page); let page = PreferencesPage::builder() .title("Interface") @@ -301,102 +282,96 @@ pub fn open_settings(ctx: Arc, app: &Application) { .description("Messages render preferences") .build(); - // Debug logs preference - + let debug_logs = SwitchRow::builder() .title("Debug logs") .subtitle("Print debug logs to the chat") .active(ctx.config(|o| o.debug_logs)) .build(); - + group.add(&debug_logs); - // Show IPs preference - + let show_ips = SwitchRow::builder() .title("Show IPs") .subtitle("Show authors IP addresses if possible") .active(ctx.config(|o| o.show_other_ip)) .build(); - + group.add(&show_ips); - // Format messages preference - + let format_messages = SwitchRow::builder() .title("Format messages") .subtitle("Disable to see raw messages") .active(ctx.config(|o| o.formatting_enabled)) .build(); - + group.add(&format_messages); - // Show avatars preference - + let show_avatars = SwitchRow::builder() .title("Show avatars") .subtitle("Enables new messages UI") .active(ctx.config(|o| o.new_ui_enabled)) .build(); - + group.add(&show_avatars); page.add(&group); - let group = PreferencesGroup::builder() .title("Interface") .description("General interface preferences (restart after changing)") .build(); - // Remove GUI shit preference - + let remove_gui_shit = SwitchRow::builder() .title("Remove GUI shit") .subtitle("Removes calendar, konata and clock") .active(ctx.config(|o| o.remove_gui_shit)) .build(); - + group.add(&remove_gui_shit); - // Konata size preference - + let konata_size = SpinRow::builder() .title("Konata size") .subtitle("Set konata size percent") - .adjustment(&Adjustment::builder() - .lower(0.0) - .upper(200.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.konata_size) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(0.0) + .upper(200.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.konata_size) as f64) + .build(), + ) .build(); group.add(&konata_size); - - + // Enable notifications preference - + let enable_notifications = SwitchRow::builder() .title("Enable notifications") .subtitle("Send notifications on chat and system messages") .active(ctx.config(|o| o.notifications_enabled)) .build(); - + group.add(&enable_notifications); page.add(&group); - - + dialog.add(&page); - dialog.connect_closed(move |_| { + let old_config = ctx.config.read().unwrap().clone(); + let config = Config { host: host.text().to_string(), name: { @@ -441,6 +416,7 @@ pub fn open_settings(ctx: Arc, app: &Application) { Some(proxy) } }, + servers: old_config.servers, }; ctx.set_config(&config); try_save_config(get_config_path(), &config); @@ -449,5 +425,3 @@ pub fn open_settings(ctx: Arc, app: &Application) { dialog.present(app.active_window().as_ref()); } - -