notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 1476be48cc37016dc16c29716850270329bd7c53
parent d729823f334048e508e28833f49fdfd9522a653f
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 28 Oct 2024 13:21:25 -0700

Merge 'Support view, key storage'

kernelkind (5):
      file storage
      write log files to disk daily and on panic
      app window size persists on app close
      support view
      fix cmd line args bug

Diffstat:
MCargo.lock | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
MCargo.toml | 7++++++-
Aassets/icons/help_icon_dark_4x.png | 0
Msrc/account_manager.rs | 29+++++++++++++++++++++++------
Msrc/app.rs | 53+++++++++++++++++++++++++++++++++++++++++------------
Msrc/app_creation.rs | 11+++++++++--
Asrc/app_size_handler.rs | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/args.rs | 4++++
Msrc/bin/notedeck.rs | 51++++++++++++++++++++++++++++++++++++++++++++++++++-
Dsrc/key_storage.rs | 90-------------------------------------------------------------------------------
Msrc/lib.rs | 8+++++---
Dsrc/linux_key_storage.rs | 210-------------------------------------------------------------------------------
Dsrc/macos_key_storage.rs | 193-------------------------------------------------------------------------------
Msrc/nav.rs | 5+++++
Msrc/route.rs | 3+++
Asrc/storage/file_key_storage.rs | 176+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/storage/file_storage.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/storage/key_storage_impl.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/storage/mod.rs | 14++++++++++++++
Asrc/storage/security_framework_key_storage.rs | 198+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/support.rs | 158+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ui/button_hyperlink.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/ui/mod.rs | 2++
Msrc/ui/side_panel.rs | 47+++++++++++++++++++++++++++++++++++++++++++++--
Asrc/ui/support.rs | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
25 files changed, 1463 insertions(+), 523 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -19,6 +19,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" [[package]] +name = "accesskit" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74a4b14f3d99c1255dcba8f45621ab1a2e7540a0009652d33989005a4d0bfc6b" +dependencies = [ + "enumn", + "serde", +] + +[[package]] name = "addr2line" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -69,6 +79,7 @@ dependencies = [ "cfg-if", "getrandom", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -918,6 +929,15 @@ dependencies = [ ] [[package]] +name = "crossbeam-channel" +version = "0.5.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] name = "crossbeam-deque" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -992,6 +1012,27 @@ dependencies = [ ] [[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] name = "dispatch" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1028,6 +1069,7 @@ source = "git+https://github.com/emilk/egui?rev=fcb7764e48ce00f8f8e58da10f937410 dependencies = [ "bytemuck", "emath", + "serde", ] [[package]] @@ -1068,12 +1110,14 @@ name = "egui" version = "0.27.2" source = "git+https://github.com/emilk/egui?rev=fcb7764e48ce00f8f8e58da10f937410d65b0bfb#fcb7764e48ce00f8f8e58da10f937410d65b0bfb" dependencies = [ + "accesskit", "ahash", "emath", "epaint", "log", "nohash-hasher", "puffin", + "serde", ] [[package]] @@ -1211,6 +1255,7 @@ version = "0.27.2" source = "git+https://github.com/emilk/egui?rev=fcb7764e48ce00f8f8e58da10f937410d65b0bfb#fcb7764e48ce00f8f8e58da10f937410d65b0bfb" dependencies = [ "bytemuck", + "serde", ] [[package]] @@ -1250,6 +1295,17 @@ dependencies = [ ] [[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.77", +] + +[[package]] name = "env_filter" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1299,6 +1355,7 @@ dependencies = [ "nohash-hasher", "parking_lot", "puffin", + "serde", ] [[package]] @@ -1359,6 +1416,12 @@ dependencies = [ ] [[package]] +name = "fastrand" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] name = "fdeflate" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2184,6 +2247,16 @@ dependencies = [ ] [[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", +] + +[[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2503,6 +2576,7 @@ dependencies = [ "base32", "bitflags 2.6.0", "console_error_panic_hook", + "dirs", "eframe", "egui", "egui_extras", @@ -2527,10 +2601,13 @@ dependencies = [ "serde_json", "strum", "strum_macros", + "tempfile", "tokio", "tracing", + "tracing-appender", "tracing-subscriber", "tracing-wasm", + "urlencoding", "uuid", "wasm-bindgen-futures", "winit", @@ -2817,12 +2894,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" [[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] name = "orbclient" version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52f0d54bde9774d3a51dcf281a5def240c71996bc6ca05d2c847ec8b2b216166" dependencies = [ - "libredox", + "libredox 0.0.2", ] [[package]] @@ -3334,6 +3417,17 @@ dependencies = [ ] [[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox 0.1.3", + "thiserror", +] + +[[package]] name = "regex" version = "1.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3500,9 +3594,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.36" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f55e80d50763938498dd5ebb18647174e0c76dc38c5505294bb224624f30f36" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ "bitflags 2.6.0", "errno", @@ -4077,6 +4171,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] +name = "tempfile" +version = "3.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] name = "termcolor" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4359,6 +4466,18 @@ dependencies = [ ] [[package]] +name = "tracing-appender" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" +dependencies = [ + "crossbeam-channel", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] name = "tracing-attributes" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4562,6 +4681,12 @@ dependencies = [ ] [[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] name = "usvg" version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml @@ -13,7 +13,7 @@ crate-type = ["lib", "cdylib"] [dependencies] #egui-android = { git = "https://github.com/jb55/egui-android.git" } -egui = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb" } +egui = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", features = ["serde"] } eframe = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "eframe", default-features = false, features = [ "wgpu", "wayland", "x11", "android-native-activity" ] } egui_extras = { git = "https://github.com/emilk/egui", rev = "fcb7764e48ce00f8f8e58da10f937410d65b0bfb", package = "egui_extras", features = ["all_loaders"] } ehttp = "0.2.0" @@ -43,7 +43,12 @@ strum_macros = "0.26" bitflags = "2.5.0" uuid = { version = "1.10.0", features = ["v4"] } indexmap = "2.6.0" +dirs = "5.0.1" +tracing-appender = "0.2.3" +urlencoding = "2.1.3" +[dev-dependencies] +tempfile = "3.13.0" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "2.11.0" diff --git a/assets/icons/help_icon_dark_4x.png b/assets/icons/help_icon_dark_4x.png Binary files differ. diff --git a/src/account_manager.rs b/src/account_manager.rs @@ -6,15 +6,15 @@ use nostrdb::Ndb; use crate::{ column::Columns, imgcache::ImageCache, - key_storage::{KeyStorage, KeyStorageResponse, KeyStorageType}, login_manager::LoginState, route::{Route, Router}, + storage::{KeyStorageResponse, KeyStorageType}, ui::{ account_login_view::{AccountLoginResponse, AccountLoginView}, account_management::{AccountsView, AccountsViewResponse}, }, }; -use tracing::info; +use tracing::{error, info}; pub use crate::user_account::UserAccount; @@ -96,13 +96,14 @@ pub fn process_accounts_view_response( } impl AccountManager { - pub fn new(currently_selected_account: Option<usize>, key_store: KeyStorageType) -> Self { + pub fn new(key_store: KeyStorageType) -> Self { let accounts = if let KeyStorageResponse::ReceivedResult(res) = key_store.get_keys() { res.unwrap_or_default() } else { Vec::new() }; + let currently_selected_account = get_selected_index(&accounts, &key_store); AccountManager { currently_selected_account, accounts, @@ -188,16 +189,31 @@ impl AccountManager { } pub fn select_account(&mut self, index: usize) { - if self.accounts.get(index).is_some() { - self.currently_selected_account = Some(index) + if let Some(account) = self.accounts.get(index) { + self.currently_selected_account = Some(index); + self.key_store.select_key(Some(account.pubkey)); } } pub fn clear_selected_account(&mut self) { - self.currently_selected_account = None + self.currently_selected_account = None; + self.key_store.select_key(None); } } +fn get_selected_index(accounts: &[UserAccount], keystore: &KeyStorageType) -> Option<usize> { + match keystore.get_selected_key() { + KeyStorageResponse::ReceivedResult(Ok(Some(pubkey))) => { + return accounts.iter().position(|account| account.pubkey == pubkey); + } + + KeyStorageResponse::ReceivedResult(Err(e)) => error!("Error getting selected key: {}", e), + KeyStorageResponse::Waiting | KeyStorageResponse::ReceivedResult(Ok(None)) => {} + }; + + None +} + pub fn process_login_view_response(manager: &mut AccountManager, response: AccountLoginResponse) { match response { AccountLoginResponse::CreateNew => { @@ -207,4 +223,5 @@ pub fn process_login_view_response(manager: &mut AccountManager, response: Accou manager.add_account(keypair); } } + manager.select_account(manager.num_accounts() - 1); } diff --git a/src/app.rs b/src/app.rs @@ -1,6 +1,7 @@ use crate::{ account_manager::AccountManager, app_creation::setup_cc, + app_size_handler::AppSizeHandler, app_style::user_requested_visuals_change, args::Args, column::Columns, @@ -9,19 +10,20 @@ use crate::{ filter::{self, FilterState}, frame_history::FrameHistory, imgcache::ImageCache, - key_storage::KeyStorageType, nav, note::NoteRef, notecache::{CachedNote, NoteCache}, notes_holder::NotesHolderStorage, profile::Profile, + storage::{Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, + support::Support, thread::Thread, timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, ui::{self, DesktopSidePanel}, unknowns::UnknownIds, view_state::ViewState, - Result, + DataPaths, Result, }; use enostr::{ClientMessage, RelayEvent, RelayMessage, RelayPool}; @@ -60,6 +62,8 @@ pub struct Damus { pub img_cache: ImageCache, pub accounts: AccountManager, pub subscriptions: Subscriptions, + pub app_rect_handler: AppSizeHandler, + pub support: Support, frame_history: crate::frame_history::FrameHistory, @@ -507,6 +511,8 @@ fn update_damus(damus: &mut Damus, ctx: &egui::Context) { error!("error processing event: {}", err); } + damus.app_rect_handler.try_save_app_size(ctx); + damus.columns.attempt_perform_deletion_request(); } @@ -664,20 +670,35 @@ impl Damus { let mut config = Config::new(); config.set_ingester_threads(4); - let mut accounts = AccountManager::new( - // TODO: should pull this from settings - None, - // TODO: use correct KeyStorage mechanism for current OS arch - KeyStorageType::None, - ); + let keystore = if parsed_args.use_keystore { + if let Ok(keys_path) = DataPaths::Keys.get_path() { + if let Ok(selected_key_path) = DataPaths::SelectedKey.get_path() { + KeyStorageType::FileSystem(FileKeyStorage::new( + Directory::new(keys_path), + Directory::new(selected_key_path), + )) + } else { + error!("Could not find path for selected key"); + KeyStorageType::None + } + } else { + error!("Could not find data path for keys"); + KeyStorageType::None + } + } else { + KeyStorageType::None + }; + + let mut accounts = AccountManager::new(keystore); + + let num_keys = parsed_args.keys.len(); for key in parsed_args.keys { info!("adding account: {}", key.pubkey); accounts.add_account(key); } - // TODO: pull currently selected account from settings - if accounts.num_accounts() > 0 { + if num_keys != 0 { accounts.select_account(0); } @@ -737,6 +758,8 @@ impl Damus { accounts, frame_history: FrameHistory::default(), view_state: ViewState::default(), + app_rect_handler: AppSizeHandler::default(), + support: Support::default(), } } @@ -817,9 +840,11 @@ impl Damus { columns, textmode: false, ndb: Ndb::new(data_path.as_ref().to_str().expect("db path ok"), &config).expect("ndb"), - accounts: AccountManager::new(None, KeyStorageType::None), + accounts: AccountManager::new(KeyStorageType::None), frame_history: FrameHistory::default(), view_state: ViewState::default(), + app_rect_handler: AppSizeHandler::default(), + support: Support::default(), } } @@ -1009,7 +1034,11 @@ fn timelines_view(ui: &mut egui::Ui, sizes: Size, app: &mut Damus) { .show(ui); if side_panel.response.clicked() { - DesktopSidePanel::perform_action(app.columns_mut(), side_panel.action); + DesktopSidePanel::perform_action( + &mut app.columns, + &mut app.support, + side_panel.action, + ); } // vertical sidebar line diff --git a/src/app_creation.rs b/src/app_creation.rs @@ -1,3 +1,4 @@ +use crate::app_size_handler::AppSizeHandler; use crate::app_style::{ create_custom_style, dark_mode, desktop_font_size, light_mode, mobile_font_size, }; @@ -8,10 +9,16 @@ use eframe::NativeOptions; pub fn generate_native_options() -> NativeOptions { generate_native_options_with_builder_modifiers(|builder| { - builder + let builder = builder .with_fullsize_content_view(true) .with_titlebar_shown(false) - .with_title_shown(false) + .with_title_shown(false); + + if let Some(window_size) = AppSizeHandler::default().get_app_size() { + builder.with_inner_size(window_size) + } else { + builder + } }) } diff --git a/src/app_size_handler.rs b/src/app_size_handler.rs @@ -0,0 +1,100 @@ +use std::time::{Duration, Instant}; + +use egui::Context; +use tracing::{error, info}; + +use crate::{ + storage::{write_file, Directory}, + DataPaths, +}; + +pub struct AppSizeHandler { + directory: Option<Directory>, + saved_size: Option<egui::Vec2>, + last_saved: Instant, +} + +static FILE_NAME: &str = "app_size.json"; +static DELAY: Duration = Duration::from_millis(500); + +impl Default for AppSizeHandler { + fn default() -> Self { + let directory = match DataPaths::Setting.get_path() { + Ok(path) => Some(Directory::new(path)), + Err(e) => { + error!("Could not load settings path: {}", e); + None + } + }; + + Self { + directory, + saved_size: None, + last_saved: Instant::now() - DELAY, + } + } +} + +impl AppSizeHandler { + pub fn try_save_app_size(&mut self, ctx: &Context) { + if let Some(interactor) = &self.directory { + // There doesn't seem to be a way to check if user is resizing window, so if the rect is different than last saved, we'll wait DELAY before saving again to avoid spamming io + if self.last_saved.elapsed() >= DELAY { + internal_try_save_app_size(interactor, &mut self.saved_size, ctx); + self.last_saved = Instant::now(); + } + } + } + + pub fn get_app_size(&self) -> Option<egui::Vec2> { + if self.saved_size.is_some() { + return self.saved_size; + } + + if let Some(directory) = &self.directory { + if let Ok(file_contents) = directory.get_file(FILE_NAME.to_owned()) { + if let Ok(rect) = serde_json::from_str::<egui::Vec2>(&file_contents) { + return Some(rect); + } + } else { + info!("Could not find {}", FILE_NAME); + } + } + + None + } +} + +fn internal_try_save_app_size( + interactor: &Directory, + maybe_saved_size: &mut Option<egui::Vec2>, + ctx: &Context, +) { + let cur_size = ctx.input(|i| i.screen_rect.size()); + if let Some(saved_size) = maybe_saved_size { + if cur_size != *saved_size { + try_save_size(interactor, cur_size, maybe_saved_size); + } + } else { + try_save_size(interactor, cur_size, maybe_saved_size); + } +} + +fn try_save_size( + interactor: &Directory, + cur_size: egui::Vec2, + maybe_saved_size: &mut Option<egui::Vec2>, +) { + if let Ok(serialized_rect) = serde_json::to_string(&cur_size) { + if write_file( + &interactor.file_path, + FILE_NAME.to_owned(), + &serialized_rect, + ) + .is_ok() + { + info!("wrote size {}", cur_size,); + *maybe_saved_size = Some(cur_size); + } + } +} diff --git a/src/args.rs b/src/args.rs @@ -13,6 +13,7 @@ pub struct Args { pub light: bool, pub debug: bool, pub textmode: bool, + pub use_keystore: bool, pub dbpath: Option<String>, pub datapath: Option<String>, } @@ -28,6 +29,7 @@ impl Args { since_optimize: true, debug: false, textmode: false, + use_keystore: true, dbpath: None, datapath: None, }; @@ -210,6 +212,8 @@ impl Args { } else { error!("failed to parse filter in '{}'", filter_file); } + } else if arg == "--no-keystore" { + res.use_keystore = false; } i += 1; diff --git a/src/bin/notedeck.rs b/src/bin/notedeck.rs @@ -11,8 +11,57 @@ use notedeck::Damus; #[cfg(not(target_arch = "wasm32"))] #[tokio::main] async fn main() { + #[allow(unused_variables)] // need guard to live for lifetime of program + let (maybe_non_blocking, maybe_guard) = + if let Ok(log_path) = notedeck::DataPaths::Log.get_path() { + // Setup logging to file + use std::panic; + + use tracing::error; + use tracing_appender::{ + non_blocking, + rolling::{RollingFileAppender, Rotation}, + }; + + let file_appender = RollingFileAppender::new( + Rotation::DAILY, + log_path, + format!("notedeck-{}.log", env!("CARGO_PKG_VERSION")), + ); + panic::set_hook(Box::new(|panic_info| { + error!("Notedeck panicked: {:?}", panic_info); + })); + + let (non_blocking, _guard) = non_blocking(file_appender); + + (Some(non_blocking), Some(_guard)) + } else { + (None, None) + }; + // Log to stdout (if you run with `RUST_LOG=debug`). - tracing_subscriber::fmt::init(); + if let Some(non_blocking_writer) = maybe_non_blocking { + use tracing::Level; + use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt}; + + let console_layer = fmt::layer().with_target(true).with_writer(std::io::stdout); + + // Create the file layer (writes to the file) + let file_layer = fmt::layer() + .with_ansi(false) + .with_writer(non_blocking_writer); + + // Set up the subscriber to combine both layers + tracing_subscriber::registry() + .with(console_layer) + .with(file_layer) + .with(tracing_subscriber::filter::LevelFilter::from_level( + Level::INFO, + )) // Set log level + .init(); + } else { + tracing_subscriber::fmt::init(); + } let _res = eframe::run_native( "Damus NoteDeck", diff --git a/src/key_storage.rs b/src/key_storage.rs @@ -1,90 +0,0 @@ -use enostr::Keypair; - -#[cfg(target_os = "linux")] -use crate::linux_key_storage::LinuxKeyStorage; -#[cfg(target_os = "macos")] -use crate::macos_key_storage::MacOSKeyStorage; - -#[cfg(target_os = "macos")] -pub const SERVICE_NAME: &str = "Notedeck"; - -#[derive(Debug, PartialEq)] -pub enum KeyStorageType { - None, - #[cfg(target_os = "macos")] - MacOS, - #[cfg(target_os = "linux")] - Linux, - // TODO: - // Windows, - // Android, -} - -#[allow(dead_code)] -#[derive(Debug, PartialEq)] -pub enum KeyStorageResponse<R> { - Waiting, - ReceivedResult(Result<R, KeyStorageError>), -} - -pub trait KeyStorage { - fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>>; - fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()>; - fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()>; -} - -impl KeyStorage for KeyStorageType { - fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> { - match self { - Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())), - #[cfg(target_os = "macos")] - Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).get_keys(), - #[cfg(target_os = "linux")] - Self::Linux => LinuxKeyStorage::new().get_keys(), - } - } - - fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - let _ = key; - match self { - Self::None => KeyStorageResponse::ReceivedResult(Ok(())), - #[cfg(target_os = "macos")] - Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).add_key(key), - #[cfg(target_os = "linux")] - Self::Linux => LinuxKeyStorage::new().add_key(key), - } - } - - fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - let _ = key; - match self { - Self::None => KeyStorageResponse::ReceivedResult(Ok(())), - #[cfg(target_os = "macos")] - Self::MacOS => MacOSKeyStorage::new(SERVICE_NAME).remove_key(key), - #[cfg(target_os = "linux")] - Self::Linux => LinuxKeyStorage::new().remove_key(key), - } - } -} - -#[allow(dead_code)] -#[derive(Debug, PartialEq)] -pub enum KeyStorageError { - Retrieval(String), - Addition(String), - Removal(String), - OSError(String), -} - -impl std::fmt::Display for KeyStorageError { - fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - match self { - Self::Retrieval(e) => write!(f, "Failed to retrieve keys: {:?}", e), - Self::Addition(key) => write!(f, "Failed to add key: {:?}", key), - Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key), - Self::OSError(e) => write!(f, "OS had an error: {:?}", e), - } - } -} - -impl std::error::Error for KeyStorageError {} diff --git a/src/lib.rs b/src/lib.rs @@ -7,6 +7,7 @@ mod abbrev; pub mod account_manager; mod actionbar; pub mod app_creation; +mod app_size_handler; mod app_style; mod args; mod colors; @@ -18,9 +19,7 @@ mod frame_history; mod images; mod imgcache; mod key_parsing; -mod key_storage; pub mod login_manager; -mod macos_key_storage; mod multi_subscriber; mod nav; mod note; @@ -32,6 +31,7 @@ pub mod relay_pool_manager; mod result; mod route; mod subscriptions; +mod support; mod test_data; mod thread; mod time; @@ -45,11 +45,13 @@ mod view_state; #[cfg(test)] #[macro_use] mod test_utils; -mod linux_key_storage; + +mod storage; pub use app::Damus; pub use error::Error; pub use profile::DisplayName; +pub use storage::DataPaths; #[cfg(target_os = "android")] use winit::platform::android::EventLoopBuilderExtAndroid; diff --git a/src/linux_key_storage.rs b/src/linux_key_storage.rs @@ -1,210 +0,0 @@ -#![cfg(target_os = "linux")] - -use enostr::{Keypair, SerializableKeypair}; -use std::fs; -use std::io::Write; -use std::path::PathBuf; -use std::{env, fs::File}; - -use crate::key_storage::{KeyStorage, KeyStorageError, KeyStorageResponse}; -use tracing::debug; - -enum LinuxKeyStorageType { - BasicFileStorage, - // TODO(kernelkind): could use the secret service api, and maybe even allow password manager integration via a settings menu -} - -pub struct LinuxKeyStorage {} - -// TODO(kernelkind): read from settings instead of hard-coding -static USE_MECHANISM: LinuxKeyStorageType = LinuxKeyStorageType::BasicFileStorage; - -impl LinuxKeyStorage { - pub fn new() -> Self { - Self {} - } -} - -impl KeyStorage for LinuxKeyStorage { - fn get_keys(&self) -> KeyStorageResponse<Vec<enostr::Keypair>> { - match USE_MECHANISM { - LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().get_keys(), - } - } - - fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { - match USE_MECHANISM { - LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().add_key(key), - } - } - - fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { - match USE_MECHANISM { - LinuxKeyStorageType::BasicFileStorage => BasicFileStorage::new().remove_key(key), - } - } -} - -struct BasicFileStorage { - credential_dir_name: String, -} - -impl BasicFileStorage { - pub fn new() -> Self { - Self { - credential_dir_name: ".credentials".to_string(), - } - } - - fn mock() -> Self { - Self { - credential_dir_name: ".credentials_test".to_string(), - } - } - - fn get_cred_dirpath(&self) -> Result<PathBuf, KeyStorageError> { - let home_dir = env::var("HOME") - .map_err(|_| KeyStorageError::OSError("HOME env variable not set".to_string()))?; - let home_path = std::path::PathBuf::from(home_dir); - let project_path_str = "notedeck"; - - let config_path = { - if let Some(xdg_config_str) = env::var_os("XDG_CONFIG_HOME") { - let xdg_path = PathBuf::from(xdg_config_str); - let xdg_path_config = if xdg_path.is_absolute() { - xdg_path - } else { - home_path.join(".config") - }; - xdg_path_config.join(project_path_str) - } else { - home_path.join(format!(".{}", project_path_str)) - } - } - .join(self.credential_dir_name.clone()); - - std::fs::create_dir_all(&config_path).map_err(|_| { - KeyStorageError::OSError(format!( - "could not create config path: {}", - config_path.display() - )) - })?; - - Ok(config_path) - } - - fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { - let mut file_path = self.get_cred_dirpath()?; - file_path.push(format!("{}", &key.pubkey)); - - let mut file = File::create(file_path) - .map_err(|_| KeyStorageError::Addition("could not create or open file".to_string()))?; - - let json_str = serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7)) - .map_err(|e| KeyStorageError::Addition(e.to_string()))?; - file.write_all(json_str.as_bytes()).map_err(|_| { - KeyStorageError::Addition("could not write keypair to file".to_string()) - })?; - - Ok(()) - } - - fn get_keys_internal(&self) -> Result<Vec<Keypair>, KeyStorageError> { - let file_path = self.get_cred_dirpath()?; - let mut keys: Vec<Keypair> = Vec::new(); - - if !file_path.is_dir() { - return Err(KeyStorageError::Retrieval( - "path is not a directory".to_string(), - )); - } - - let dir = fs::read_dir(file_path).map_err(|_| { - KeyStorageError::Retrieval("problem accessing credentials directory".to_string()) - })?; - - for entry in dir { - let entry = entry.map_err(|_| { - KeyStorageError::Retrieval("problem accessing crediential file".to_string()) - })?; - - let path = entry.path(); - - if path.is_file() { - if let Some(path_str) = path.to_str() { - debug!("key path {}", path_str); - let json_string = fs::read_to_string(path_str).map_err(|e| { - KeyStorageError::OSError(format!("File reading problem: {}", e)) - })?; - let key: SerializableKeypair = - serde_json::from_str(&json_string).map_err(|e| { - KeyStorageError::OSError(format!( - "Deserialization problem: {}", - (e.to_string().as_str()) - )) - })?; - keys.push(key.to_keypair("")) - } - } - } - - Ok(keys) - } - - fn remove_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { - let path = self.get_cred_dirpath()?; - - let filepath = path.join(key.pubkey.to_string()); - - if filepath.exists() && filepath.is_file() { - fs::remove_file(&filepath) - .map_err(|e| KeyStorageError::OSError(format!("failed to remove file: {}", e)))?; - } - - Ok(()) - } -} - -impl KeyStorage for BasicFileStorage { - fn get_keys(&self) -> crate::key_storage::KeyStorageResponse<Vec<enostr::Keypair>> { - KeyStorageResponse::ReceivedResult(self.get_keys_internal()) - } - - fn add_key(&self, key: &enostr::Keypair) -> crate::key_storage::KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) - } - - fn remove_key(&self, key: &enostr::Keypair) -> crate::key_storage::KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.remove_key_internal(key)) - } -} - -mod tests { - use crate::key_storage::{KeyStorage, KeyStorageResponse}; - - use super::BasicFileStorage; - - #[test] - fn test_basic() { - let kp = enostr::FullKeypair::generate().to_keypair(); - let resp = BasicFileStorage::mock().add_key(&kp); - - assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); - assert_num_storage(1); - - let resp = BasicFileStorage::mock().remove_key(&kp); - assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); - assert_num_storage(0); - } - - #[allow(dead_code)] - fn assert_num_storage(n: usize) { - let resp = BasicFileStorage::mock().get_keys(); - - if let KeyStorageResponse::ReceivedResult(Ok(vec)) = resp { - assert_eq!(vec.len(), n); - return; - } - panic!(); - } -} diff --git a/src/macos_key_storage.rs b/src/macos_key_storage.rs @@ -1,193 +0,0 @@ -#![cfg(target_os = "macos")] - -use enostr::{Keypair, Pubkey, SecretKey}; - -use security_framework::item::{ItemClass, ItemSearchOptions, Limit, SearchResult}; -use security_framework::passwords::{delete_generic_password, set_generic_password}; - -use crate::key_storage::{KeyStorage, KeyStorageError, KeyStorageResponse}; - -use tracing::error; - -pub struct MacOSKeyStorage<'a> { - pub service_name: &'a str, -} - -impl<'a> MacOSKeyStorage<'a> { - pub fn new(service_name: &'a str) -> Self { - MacOSKeyStorage { service_name } - } - - fn add_key(&self, key: &Keypair) -> Result<(), KeyStorageError> { - match set_generic_password( - self.service_name, - key.pubkey.hex().as_str(), - key.secret_key - .as_ref() - .map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()), - ) { - Ok(_) => Ok(()), - Err(_) => Err(KeyStorageError::Addition(key.pubkey.hex())), - } - } - - fn get_pubkey_strings(&self) -> Vec<String> { - let search_results = ItemSearchOptions::new() - .class(ItemClass::generic_password()) - .service(self.service_name) - .load_attributes(true) - .limit(Limit::All) - .search(); - - let mut accounts = Vec::new(); - - if let Ok(search_results) = search_results { - for result in search_results { - if let Some(map) = result.simplify_dict() { - if let Some(val) = map.get("acct") { - accounts.push(val.clone()); - } - } - } - } - - accounts - } - - fn get_pubkeys(&self) -> Vec<Pubkey> { - self.get_pubkey_strings() - .iter_mut() - .filter_map(|pubkey_str| Pubkey::from_hex(pubkey_str.as_str()).ok()) - .collect() - } - - fn get_privkey_bytes_for(&self, account: &str) -> Option<Vec<u8>> { - let search_result = ItemSearchOptions::new() - .class(ItemClass::generic_password()) - .service(self.service_name) - .load_data(true) - .account(account) - .search(); - - if let Ok(results) = search_result { - if let Some(SearchResult::Data(vec)) = results.first() { - return Some(vec.clone()); - } - } - - None - } - - fn get_secret_key_for_pubkey(&self, pubkey: &Pubkey) -> Option<SecretKey> { - if let Some(bytes) = self.get_privkey_bytes_for(pubkey.hex().as_str()) { - SecretKey::from_slice(bytes.as_slice()).ok() - } else { - None - } - } - - fn get_all_keypairs(&self) -> Vec<Keypair> { - self.get_pubkeys() - .iter() - .map(|pubkey| { - let maybe_secret = self.get_secret_key_for_pubkey(pubkey); - Keypair::new(*pubkey, maybe_secret) - }) - .collect() - } - - fn delete_key(&self, pubkey: &Pubkey) -> Result<(), KeyStorageError> { - match delete_generic_password(self.service_name, pubkey.hex().as_str()) { - Ok(_) => Ok(()), - Err(e) => { - error!("delete key error {}", e); - Err(KeyStorageError::Removal(pubkey.hex())) - } - } - } -} - -impl<'a> KeyStorage for MacOSKeyStorage<'a> { - fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.add_key(key)) - } - - fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> { - KeyStorageResponse::ReceivedResult(Ok(self.get_all_keypairs())) - } - - fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { - KeyStorageResponse::ReceivedResult(self.delete_key(&key.pubkey)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use enostr::FullKeypair; - - static TEST_SERVICE_NAME: &str = "NOTEDECKTEST"; - static STORAGE: MacOSKeyStorage = MacOSKeyStorage { - service_name: TEST_SERVICE_NAME, - }; - - // individual tests are ignored so test runner doesn't run them all concurrently - // TODO: a way to run them all serially should be devised - - #[test] - #[ignore] - fn add_and_remove_test_pubkey_only() { - let num_keys_before_test = STORAGE.get_pubkeys().len(); - - let keypair = FullKeypair::generate().to_keypair(); - let add_result = STORAGE.add_key(&keypair); - assert_eq!(add_result, Ok(())); - - let get_pubkeys_result = STORAGE.get_pubkeys(); - assert_eq!(get_pubkeys_result.len() - num_keys_before_test, 1); - - let remove_result = STORAGE.delete_key(&keypair.pubkey); - assert_eq!(remove_result, Ok(())); - - let keys = STORAGE.get_pubkeys(); - assert_eq!(keys.len() - num_keys_before_test, 0); - } - - fn add_and_remove_full_n(n: usize) { - let num_keys_before_test = STORAGE.get_all_keypairs().len(); - // there must be zero keys in storage for the test to work as intended - assert_eq!(num_keys_before_test, 0); - - let expected_keypairs: Vec<Keypair> = (0..n) - .map(|_| FullKeypair::generate().to_keypair()) - .collect(); - - expected_keypairs.iter().for_each(|keypair| { - let add_result = STORAGE.add_key(keypair); - assert_eq!(add_result, Ok(())); - }); - - let asserted_keypairs = STORAGE.get_all_keypairs(); - assert_eq!(expected_keypairs, asserted_keypairs); - - expected_keypairs.iter().for_each(|keypair| { - let remove_result = STORAGE.delete_key(&keypair.pubkey); - assert_eq!(remove_result, Ok(())); - }); - - let num_keys_after_test = STORAGE.get_all_keypairs().len(); - assert_eq!(num_keys_after_test, 0); - } - - #[test] - #[ignore] - fn add_and_remove_full() { - add_and_remove_full_n(1); - } - - #[test] - #[ignore] - fn add_and_remove_full_10() { - add_and_remove_full_n(10); - } -} diff --git a/src/nav.rs b/src/nav.rs @@ -16,6 +16,7 @@ use crate::{ add_column::{AddColumnResponse, AddColumnView}, anim::{AnimationHelper, ICON_EXPANSION_MULTIPLE}, note::PostAction, + support::SupportView, RelayView, View, }, Damus, @@ -128,6 +129,10 @@ pub fn render_nav(col: usize, app: &mut Damus, ui: &mut egui::Ui) { col, ui, ), + Route::Support => { + SupportView::new(&mut app.support).show(ui); + None + } } }); diff --git a/src/route.rs b/src/route.rs @@ -18,6 +18,7 @@ pub enum Route { ComposeNote, AddColumn, Profile(Pubkey), + Support, } #[derive(Clone)] @@ -100,6 +101,7 @@ impl Route { Route::Profile(pubkey) => { format!("{}'s Profile", get_profile_displayname_string(ndb, pubkey)) } + Route::Support => "Damus Support".to_owned(), }; TitledRoute { @@ -208,6 +210,7 @@ impl fmt::Display for Route { Route::AddColumn => write!(f, "Add Column"), Route::Profile(_) => write!(f, "Profile"), + Route::Support => write!(f, "Support"), } } } diff --git a/src/storage/file_key_storage.rs b/src/storage/file_key_storage.rs @@ -0,0 +1,176 @@ +use eframe::Result; +use enostr::{Keypair, Pubkey, SerializableKeypair}; + +use crate::Error; + +use super::{ + file_storage::{delete_file, write_file, Directory}, + key_storage_impl::{KeyStorageError, KeyStorageResponse}, +}; + +static SELECTED_PUBKEY_FILE_NAME: &str = "selected_pubkey"; + +/// An OS agnostic file key storage implementation +#[derive(Debug, PartialEq)] +pub struct FileKeyStorage { + keys_directory: Directory, + selected_key_directory: Directory, +} + +impl FileKeyStorage { + pub fn new(keys_directory: Directory, selected_key_directory: Directory) -> Self { + Self { + keys_directory, + selected_key_directory, + } + } + + fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { + write_file( + &self.keys_directory.file_path, + key.pubkey.hex(), + &serde_json::to_string(&SerializableKeypair::from_keypair(key, "", 7)) + .map_err(|e| KeyStorageError::Addition(Error::Generic(e.to_string())))?, + ) + .map_err(KeyStorageError::Addition) + } + + fn get_keys_internal(&self) -> Result<Vec<Keypair>, KeyStorageError> { + let keys = self + .keys_directory + .get_files() + .map_err(KeyStorageError::Retrieval)? + .values() + .filter_map(|str_key| serde_json::from_str::<SerializableKeypair>(str_key).ok()) + .map(|serializable_keypair| serializable_keypair.to_keypair("")) + .collect(); + Ok(keys) + } + + fn remove_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { + delete_file(&self.keys_directory.file_path, key.pubkey.hex()) + .map_err(KeyStorageError::Removal) + } + + fn get_selected_pubkey(&self) -> Result<Option<Pubkey>, KeyStorageError> { + let pubkey_str = self + .selected_key_directory + .get_file(SELECTED_PUBKEY_FILE_NAME.to_owned()) + .map_err(KeyStorageError::Selection)?; + + serde_json::from_str(&pubkey_str) + .map_err(|e| KeyStorageError::Selection(Error::Generic(e.to_string()))) + } + + fn select_pubkey(&self, pubkey: Option<Pubkey>) -> Result<(), KeyStorageError> { + if let Some(pubkey) = pubkey { + write_file( + &self.selected_key_directory.file_path, + SELECTED_PUBKEY_FILE_NAME.to_owned(), + &serde_json::to_string(&pubkey.hex()) + .map_err(|e| KeyStorageError::Selection(Error::Generic(e.to_string())))?, + ) + .map_err(KeyStorageError::Selection) + } else if self + .selected_key_directory + .get_file(SELECTED_PUBKEY_FILE_NAME.to_owned()) + .is_ok() + { + // Case where user chose to have no selected pubkey, but one already exists + delete_file( + &self.selected_key_directory.file_path, + SELECTED_PUBKEY_FILE_NAME.to_owned(), + ) + .map_err(KeyStorageError::Selection) + } else { + Ok(()) + } + } +} + +impl FileKeyStorage { + pub fn get_keys(&self) -> KeyStorageResponse<Vec<enostr::Keypair>> { + KeyStorageResponse::ReceivedResult(self.get_keys_internal()) + } + + pub fn add_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) + } + + pub fn remove_key(&self, key: &enostr::Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.remove_key_internal(key)) + } + + pub fn get_selected_key(&self) -> KeyStorageResponse<Option<Pubkey>> { + KeyStorageResponse::ReceivedResult(self.get_selected_pubkey()) + } + + pub fn select_key(&self, key: Option<Pubkey>) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.select_pubkey(key)) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + use enostr::Keypair; + static CREATE_TMP_DIR: fn() -> Result<PathBuf, Error> = + || Ok(tempfile::TempDir::new()?.path().to_path_buf()); + + impl FileKeyStorage { + fn mock() -> Result<Self, Error> { + Ok(Self { + keys_directory: Directory::new(CREATE_TMP_DIR()?), + selected_key_directory: Directory::new(CREATE_TMP_DIR()?), + }) + } + } + + #[test] + fn test_basic() { + let kp = enostr::FullKeypair::generate().to_keypair(); + let storage = FileKeyStorage::mock().unwrap(); + let resp = storage.add_key(&kp); + + assert_eq!(resp, KeyStorageResponse::ReceivedResult(Ok(()))); + assert_num_storage(&storage.get_keys(), 1); + + assert_eq!( + storage.remove_key(&kp), + KeyStorageResponse::ReceivedResult(Ok(())) + ); + assert_num_storage(&storage.get_keys(), 0); + } + + fn assert_num_storage(keys_response: &KeyStorageResponse<Vec<Keypair>>, n: usize) { + match keys_response { + KeyStorageResponse::ReceivedResult(Ok(keys)) => { + assert_eq!(keys.len(), n); + } + KeyStorageResponse::ReceivedResult(Err(_e)) => { + panic!("could not get keys"); + } + KeyStorageResponse::Waiting => { + panic!("did not receive result"); + } + } + } + + #[test] + fn test_select_key() { + let kp = enostr::FullKeypair::generate().to_keypair(); + + let storage = FileKeyStorage::mock().unwrap(); + let _ = storage.add_key(&kp); + assert_num_storage(&storage.get_keys(), 1); + + let resp = storage.select_pubkey(Some(kp.pubkey)); + assert!(resp.is_ok()); + + let resp = storage.get_selected_pubkey(); + + assert!(resp.is_ok()); + } +} diff --git a/src/storage/file_storage.rs b/src/storage/file_storage.rs @@ -0,0 +1,259 @@ +use std::{ + collections::{HashMap, VecDeque}, + fs::{self, File}, + io::{self, BufRead}, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use crate::Error; + +pub enum DataPaths { + Log, + Setting, + Keys, + SelectedKey, +} + +impl DataPaths { + pub fn get_path(&self) -> Result<PathBuf, Error> { + let base_path = match self { + DataPaths::Log => dirs::data_local_dir(), + DataPaths::Setting | DataPaths::Keys | DataPaths::SelectedKey => { + dirs::config_local_dir() + } + } + .ok_or(Error::Generic( + "Could not open well known OS directory".to_owned(), + ))?; + + let specific_path = match self { + DataPaths::Log => PathBuf::from("logs"), + DataPaths::Setting => PathBuf::from("settings"), + DataPaths::Keys => PathBuf::from("storage").join("accounts"), + DataPaths::SelectedKey => PathBuf::from("storage").join("selected_account"), + }; + + Ok(base_path.join("notedeck").join(specific_path)) + } +} + +#[derive(Debug, PartialEq)] +pub struct Directory { + pub file_path: PathBuf, +} + +impl Directory { + pub fn new(file_path: PathBuf) -> Self { + Self { file_path } + } + + /// Get the files in the current directory where the key is the file name and the value is the file contents + pub fn get_files(&self) -> Result<HashMap<String, String>, Error> { + let dir = fs::read_dir(self.file_path.clone())?; + let map = dir + .filter_map(|f| f.ok()) + .filter(|f| f.path().is_file()) + .filter_map(|f| { + let file_name = f.file_name().into_string().ok()?; + let contents = fs::read_to_string(f.path()).ok()?; + Some((file_name, contents)) + }) + .collect(); + + Ok(map) + } + + pub fn get_file_names(&self) -> Result<Vec<String>, Error> { + let dir = fs::read_dir(self.file_path.clone())?; + let names = dir + .filter_map(|f| f.ok()) + .filter(|f| f.path().is_file()) + .filter_map(|f| f.file_name().into_string().ok()) + .collect(); + + Ok(names) + } + + pub fn get_file(&self, file_name: String) -> Result<String, Error> { + let filepath = self.file_path.clone().join(file_name.clone()); + + if filepath.exists() && filepath.is_file() { + let filepath_str = filepath + .to_str() + .ok_or_else(|| Error::Generic("Could not turn path to string".to_owned()))?; + Ok(fs::read_to_string(filepath_str)?) + } else { + Err(Error::Generic(format!( + "Requested file was not found: {}", + file_name + ))) + } + } + + pub fn get_file_last_n_lines(&self, file_name: String, n: usize) -> Result<FileResult, Error> { + let filepath = self.file_path.clone().join(file_name.clone()); + + if filepath.exists() && filepath.is_file() { + let file = File::open(&filepath)?; + let reader = io::BufReader::new(file); + + let mut queue: VecDeque<String> = VecDeque::with_capacity(n); + + let mut total_lines_in_file = 0; + for line in reader.lines() { + let line = line?; + + queue.push_back(line); + + if queue.len() > n { + queue.pop_front(); + } + total_lines_in_file += 1; + } + + let output_num_lines = queue.len(); + let output = queue.into_iter().collect::<Vec<String>>().join("\n"); + Ok(FileResult { + output, + output_num_lines, + total_lines_in_file, + }) + } else { + Err(Error::Generic(format!( + "Requested file was not found: {}", + file_name + ))) + } + } + + /// Get the file name which is most recently modified in the directory + pub fn get_most_recent(&self) -> Result<Option<String>, Error> { + let mut most_recent: Option<(SystemTime, String)> = None; + + for entry in fs::read_dir(&self.file_path)? { + let entry = entry?; + let metadata = entry.metadata()?; + if metadata.is_file() { + let modified = metadata.modified()?; + let file_name = entry.file_name().to_string_lossy().to_string(); + + match most_recent { + Some((last_modified, _)) if modified > last_modified => { + most_recent = Some((modified, file_name)); + } + None => { + most_recent = Some((modified, file_name)); + } + _ => {} + } + } + } + + Ok(most_recent.map(|(_, file_name)| file_name)) + } +} + +pub struct FileResult { + pub output: String, + pub output_num_lines: usize, + pub total_lines_in_file: usize, +} + +/// Write the file to the directory +pub fn write_file(directory: &Path, file_name: String, data: &str) -> Result<(), Error> { + if !directory.exists() { + fs::create_dir_all(directory)? + } + + std::fs::write(directory.join(file_name), data)?; + Ok(()) +} + +pub fn delete_file(directory: &Path, file_name: String) -> Result<(), Error> { + let file_to_delete = directory.join(file_name.clone()); + if file_to_delete.exists() && file_to_delete.is_file() { + fs::remove_file(file_to_delete).map_err(Error::Io) + } else { + Err(Error::Generic(format!( + "Requested file to delete was not found: {}", + file_name + ))) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{ + storage::file_storage::{delete_file, write_file}, + Error, + }; + + use super::Directory; + + static CREATE_TMP_DIR: fn() -> Result<PathBuf, Error> = + || Ok(tempfile::TempDir::new()?.path().to_path_buf()); + + #[test] + fn test_add_get_delete() { + if let Ok(path) = CREATE_TMP_DIR() { + let directory = Directory::new(path); + let file_name = "file_test_name.txt".to_string(); + let file_contents = "test"; + let write_res = write_file(&directory.file_path, file_name.clone(), file_contents); + assert!(write_res.is_ok()); + + if let Ok(asserted_file_contents) = directory.get_file(file_name.clone()) { + assert_eq!(asserted_file_contents, file_contents); + } else { + panic!("File not found"); + } + + let delete_res = delete_file(&directory.file_path, file_name); + assert!(delete_res.is_ok()); + } else { + panic!("could not get interactor") + } + } + + #[test] + fn test_get_multiple() { + if let Ok(path) = CREATE_TMP_DIR() { + let directory = Directory::new(path); + + for i in 0..10 { + let file_name = format!("file{}.txt", i); + let write_res = write_file(&directory.file_path, file_name, "test"); + assert!(write_res.is_ok()); + } + + if let Ok(files) = directory.get_files() { + for i in 0..10 { + let file_name = format!("file{}.txt", i); + assert!(files.contains_key(&file_name)); + assert_eq!(files.get(&file_name).unwrap(), "test"); + } + } else { + panic!("Files not found"); + } + + if let Ok(file_names) = directory.get_file_names() { + for i in 0..10 { + let file_name = format!("file{}.txt", i); + assert!(file_names.contains(&file_name)); + } + } else { + panic!("File names not found"); + } + + for i in 0..10 { + let file_name = format!("file{}.txt", i); + assert!(delete_file(&directory.file_path, file_name).is_ok()); + } + } else { + panic!("could not get interactor") + } + } +} diff --git a/src/storage/key_storage_impl.rs b/src/storage/key_storage_impl.rs @@ -0,0 +1,112 @@ +use enostr::{Keypair, Pubkey}; + +use super::file_key_storage::FileKeyStorage; +use crate::Error; + +#[cfg(target_os = "macos")] +use super::security_framework_key_storage::SecurityFrameworkKeyStorage; + +#[derive(Debug, PartialEq)] +pub enum KeyStorageType { + None, + FileSystem(FileKeyStorage), + #[cfg(target_os = "macos")] + SecurityFramework(SecurityFrameworkKeyStorage), +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum KeyStorageResponse<R> { + Waiting, + ReceivedResult(Result<R, KeyStorageError>), +} + +impl<R: PartialEq> PartialEq for KeyStorageResponse<R> { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (KeyStorageResponse::Waiting, KeyStorageResponse::Waiting) => true, + ( + KeyStorageResponse::ReceivedResult(Ok(r1)), + KeyStorageResponse::ReceivedResult(Ok(r2)), + ) => r1 == r2, + ( + KeyStorageResponse::ReceivedResult(Err(_)), + KeyStorageResponse::ReceivedResult(Err(_)), + ) => true, + _ => false, + } + } +} + +impl KeyStorageType { + pub fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> { + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(Vec::new())), + Self::FileSystem(f) => f.get_keys(), + #[cfg(target_os = "macos")] + Self::SecurityFramework(f) => f.get_keys(), + } + } + + pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + let _ = key; + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(())), + Self::FileSystem(f) => f.add_key(key), + #[cfg(target_os = "macos")] + Self::SecurityFramework(f) => f.add_key(key), + } + } + + pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + let _ = key; + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(())), + Self::FileSystem(f) => f.remove_key(key), + #[cfg(target_os = "macos")] + Self::SecurityFramework(f) => f.remove_key(key), + } + } + + pub fn get_selected_key(&self) -> KeyStorageResponse<Option<Pubkey>> { + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(None)), + Self::FileSystem(f) => f.get_selected_key(), + #[cfg(target_os = "macos")] + Self::SecurityFramework(_) => unimplemented!(), + } + } + + pub fn select_key(&self, key: Option<Pubkey>) -> KeyStorageResponse<()> { + match self { + Self::None => KeyStorageResponse::ReceivedResult(Ok(())), + Self::FileSystem(f) => f.select_key(key), + #[cfg(target_os = "macos")] + Self::SecurityFramework(_) => unimplemented!(), + } + } +} + +#[allow(dead_code)] +#[derive(Debug)] +pub enum KeyStorageError { + Retrieval(Error), + Addition(Error), + Selection(Error), + Removal(Error), + OSError(Error), +} + +impl std::fmt::Display for KeyStorageError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Self::Retrieval(e) => write!(f, "Failed to retrieve keys: {:?}", e), + Self::Addition(key) => write!(f, "Failed to add key: {:?}", key), + Self::Selection(pubkey) => write!(f, "Failed to select key: {:?}", pubkey), + Self::Removal(key) => write!(f, "Failed to remove key: {:?}", key), + Self::OSError(e) => write!(f, "OS had an error: {:?}", e), + } + } +} + +impl std::error::Error for KeyStorageError {} diff --git a/src/storage/mod.rs b/src/storage/mod.rs @@ -0,0 +1,14 @@ +#[cfg(any(target_os = "linux", target_os = "macos"))] +mod file_key_storage; +mod file_storage; + +pub use file_key_storage::FileKeyStorage; +pub use file_storage::write_file; +pub use file_storage::DataPaths; +pub use file_storage::Directory; + +#[cfg(target_os = "macos")] +mod security_framework_key_storage; + +pub mod key_storage_impl; +pub use key_storage_impl::{KeyStorageResponse, KeyStorageType}; diff --git a/src/storage/security_framework_key_storage.rs b/src/storage/security_framework_key_storage.rs @@ -0,0 +1,198 @@ +use std::borrow::Cow; + +use enostr::{Keypair, Pubkey, SecretKey}; +use security_framework::{ + item::{ItemClass, ItemSearchOptions, Limit, SearchResult}, + passwords::{delete_generic_password, set_generic_password}, +}; +use tracing::error; + +use crate::Error; + +use super::{key_storage_impl::KeyStorageError, KeyStorageResponse}; + +#[derive(Debug, PartialEq)] +pub struct SecurityFrameworkKeyStorage { + pub service_name: Cow<'static, str>, +} + +impl SecurityFrameworkKeyStorage { + pub fn new(service_name: String) -> Self { + SecurityFrameworkKeyStorage { + service_name: Cow::Owned(service_name), + } + } + + fn add_key_internal(&self, key: &Keypair) -> Result<(), KeyStorageError> { + match set_generic_password( + &self.service_name, + key.pubkey.hex().as_str(), + key.secret_key + .as_ref() + .map_or_else(|| &[] as &[u8], |sc| sc.as_secret_bytes()), + ) { + Ok(_) => Ok(()), + Err(e) => Err(KeyStorageError::Addition(Error::Generic(e.to_string()))), + } + } + + fn get_pubkey_strings(&self) -> Vec<String> { + let search_results = ItemSearchOptions::new() + .class(ItemClass::generic_password()) + .service(&self.service_name) + .load_attributes(true) + .limit(Limit::All) + .search(); + + let mut accounts = Vec::new(); + + if let Ok(search_results) = search_results { + for result in search_results { + if let Some(map) = result.simplify_dict() { + if let Some(val) = map.get("acct") { + accounts.push(val.clone()); + } + } + } + } + + accounts + } + + fn get_pubkeys(&self) -> Vec<Pubkey> { + self.get_pubkey_strings() + .iter_mut() + .filter_map(|pubkey_str| Pubkey::from_hex(pubkey_str.as_str()).ok()) + .collect() + } + + fn get_privkey_bytes_for(&self, account: &str) -> Option<Vec<u8>> { + let search_result = ItemSearchOptions::new() + .class(ItemClass::generic_password()) + .service(&self.service_name) + .load_data(true) + .account(account) + .search(); + + if let Ok(results) = search_result { + if let Some(SearchResult::Data(vec)) = results.first() { + return Some(vec.clone()); + } + } + + None + } + + fn get_secret_key_for_pubkey(&self, pubkey: &Pubkey) -> Option<SecretKey> { + if let Some(bytes) = self.get_privkey_bytes_for(pubkey.hex().as_str()) { + SecretKey::from_slice(bytes.as_slice()).ok() + } else { + None + } + } + + fn get_all_keypairs(&self) -> Vec<Keypair> { + self.get_pubkeys() + .iter() + .map(|pubkey| { + let maybe_secret = self.get_secret_key_for_pubkey(pubkey); + Keypair::new(*pubkey, maybe_secret) + }) + .collect() + } + + fn delete_key(&self, pubkey: &Pubkey) -> Result<(), KeyStorageError> { + match delete_generic_password(&self.service_name, pubkey.hex().as_str()) { + Ok(_) => Ok(()), + Err(e) => { + error!("delete key error {}", e); + Err(KeyStorageError::Removal(Error::Generic(e.to_string()))) + } + } + } +} + +impl SecurityFrameworkKeyStorage { + pub fn add_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.add_key_internal(key)) + } + + pub fn get_keys(&self) -> KeyStorageResponse<Vec<Keypair>> { + KeyStorageResponse::ReceivedResult(Ok(self.get_all_keypairs())) + } + + pub fn remove_key(&self, key: &Keypair) -> KeyStorageResponse<()> { + KeyStorageResponse::ReceivedResult(self.delete_key(&key.pubkey)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use enostr::FullKeypair; + + static TEST_SERVICE_NAME: &str = "NOTEDECKTEST"; + static STORAGE: SecurityFrameworkKeyStorage = SecurityFrameworkKeyStorage { + service_name: Cow::Borrowed(TEST_SERVICE_NAME), + }; + + // individual tests are ignored so test runner doesn't run them all concurrently + // TODO: a way to run them all serially should be devised + + #[test] + #[ignore] + fn add_and_remove_test_pubkey_only() { + let num_keys_before_test = STORAGE.get_pubkeys().len(); + + let keypair = FullKeypair::generate().to_keypair(); + let add_result = STORAGE.add_key_internal(&keypair); + assert!(add_result.is_ok()); + + let get_pubkeys_result = STORAGE.get_pubkeys(); + assert_eq!(get_pubkeys_result.len() - num_keys_before_test, 1); + + let remove_result = STORAGE.delete_key(&keypair.pubkey); + assert!(remove_result.is_ok()); + + let keys = STORAGE.get_pubkeys(); + assert_eq!(keys.len() - num_keys_before_test, 0); + } + + fn add_and_remove_full_n(n: usize) { + let num_keys_before_test = STORAGE.get_all_keypairs().len(); + // there must be zero keys in storage for the test to work as intended + assert_eq!(num_keys_before_test, 0); + + let expected_keypairs: Vec<Keypair> = (0..n) + .map(|_| FullKeypair::generate().to_keypair()) + .collect(); + + expected_keypairs.iter().for_each(|keypair| { + let add_result = STORAGE.add_key_internal(keypair); + assert!(add_result.is_ok()); + }); + + let asserted_keypairs = STORAGE.get_all_keypairs(); + assert_eq!(expected_keypairs, asserted_keypairs); + + expected_keypairs.iter().for_each(|keypair| { + let remove_result = STORAGE.delete_key(&keypair.pubkey); + assert!(remove_result.is_ok()); + }); + + let num_keys_after_test = STORAGE.get_all_keypairs().len(); + assert_eq!(num_keys_after_test, 0); + } + + #[test] + #[ignore] + fn add_and_remove_full() { + add_and_remove_full_n(1); + } + + #[test] + #[ignore] + fn add_and_remove_full_10() { + add_and_remove_full_n(10); + } +} diff --git a/src/support.rs b/src/support.rs @@ -0,0 +1,158 @@ +use tracing::error; + +use crate::{storage::Directory, DataPaths}; + +pub struct Support { + directory: Option<Directory>, + mailto_url: String, + most_recent_log: Option<String>, +} + +fn new_log_dir() -> Option<Directory> { + match DataPaths::Log.get_path() { + Ok(path) => Some(Directory::new(path)), + Err(e) => { + error!("Support could not open directory: {}", e.to_string()); + None + } + } +} + +impl Default for Support { + fn default() -> Self { + let directory = new_log_dir(); + + Self { + mailto_url: MailtoBuilder::new(SUPPORT_EMAIL.to_string()) + .with_subject("Help Needed".to_owned()) + .with_content(EMAIL_TEMPLATE.to_owned()) + .build(), + directory, + most_recent_log: None, + } + } +} + +static MAX_LOG_LINES: usize = 500; +static SUPPORT_EMAIL: &str = "support@damus.io"; +static EMAIL_TEMPLATE: &str = "Describe the bug you have encountered:\n<-- your statement here -->\n\n===== Paste your log below =====\n\n"; + +impl Support { + pub fn refresh(&mut self) { + if let Some(directory) = &self.directory { + self.most_recent_log = get_log_str(directory); + } else { + self.directory = new_log_dir(); + } + } + + pub fn get_mailto_url(&self) -> &str { + &self.mailto_url + } + + pub fn get_log_dir(&self) -> Option<&str> { + self.directory.as_ref()?.file_path.to_str() + } + + pub fn get_most_recent_log(&self) -> Option<&String> { + self.most_recent_log.as_ref() + } +} + +fn get_log_str(interactor: &Directory) -> Option<String> { + match interactor.get_most_recent() { + Ok(Some(most_recent_name)) => { + match interactor.get_file_last_n_lines(most_recent_name.clone(), MAX_LOG_LINES) { + Ok(file_output) => { + return Some( + get_prefix( + &most_recent_name, + file_output.output_num_lines, + file_output.total_lines_in_file, + ) + &file_output.output, + ) + } + Err(e) => { + error!( + "Error retrieving the last lines from file {}: {:?}", + most_recent_name, e + ); + } + } + } + Ok(None) => { + error!("No files were found."); + } + Err(e) => { + error!("Error fetching the most recent file: {:?}", e); + } + } + + None +} + +fn get_prefix(file_name: &str, lines_displayed: usize, num_total_lines: usize) -> String { + format!( + "===\nDisplaying the last {} of {} lines in file {}\n===\n\n", + lines_displayed, num_total_lines, file_name, + ) +} + +struct MailtoBuilder { + content: Option<String>, + address: String, + subject: Option<String>, +} + +impl MailtoBuilder { + fn new(address: String) -> Self { + Self { + content: None, + address, + subject: None, + } + } + + // will be truncated so the whole URL is at most 2000 characters + pub fn with_content(mut self, content: String) -> Self { + self.content = Some(content); + self + } + + pub fn with_subject(mut self, subject: String) -> Self { + self.subject = Some(subject); + self + } + + pub fn build(self) -> String { + let mut url = String::new(); + + url.push_str("mailto:"); + url.push_str(&self.address); + + let has_subject = self.subject.is_some(); + + if has_subject || self.content.is_some() { + url.push('?'); + } + + if let Some(subject) = self.subject { + url.push_str("subject="); + url.push_str(&urlencoding::encode(&subject)); + } + + if let Some(content) = self.content { + if has_subject { + url.push('&'); + } + + url.push_str("body="); + + let body = urlencoding::encode(&content); + + url.push_str(&body); + } + + url + } +} diff --git a/src/ui/button_hyperlink.rs b/src/ui/button_hyperlink.rs @@ -0,0 +1,49 @@ +use egui::{Button, Response, Ui, Widget}; + +pub struct ButtonHyperlink<'a> { + url: String, + button: Button<'a>, + new_tab: bool, +} + +impl<'a> ButtonHyperlink<'a> { + pub fn new(button: Button<'a>, url: impl ToString) -> Self { + let url = url.to_string(); + Self { + url: url.clone(), + button, + new_tab: false, + } + } + + pub fn open_in_new_tab(mut self, new_tab: bool) -> Self { + self.new_tab = new_tab; + self + } +} + +impl<'a> Widget for ButtonHyperlink<'a> { + fn ui(self, ui: &mut Ui) -> Response { + let response = ui.add(self.button); + + if response.clicked() { + let modifiers = ui.ctx().input(|i| i.modifiers); + ui.ctx().open_url(egui::OpenUrl { + url: self.url.clone(), + new_tab: self.new_tab || modifiers.any(), + }); + } + if response.middle_clicked() { + ui.ctx().open_url(egui::OpenUrl { + url: self.url.clone(), + new_tab: true, + }); + } + + if ui.style().url_in_tooltip { + response.on_hover_text(self.url) + } else { + response + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs @@ -2,12 +2,14 @@ pub mod account_login_view; pub mod account_management; pub mod add_column; pub mod anim; +pub mod button_hyperlink; pub mod mention; pub mod note; pub mod preview; pub mod profile; pub mod relay; pub mod side_panel; +pub mod support; pub mod thread; pub mod timeline; pub mod username; diff --git a/src/ui/side_panel.rs b/src/ui/side_panel.rs @@ -7,6 +7,7 @@ use crate::{ column::{Column, Columns}, imgcache::ImageCache, route::Route, + support::Support, user_account::UserAccount, Damus, }; @@ -41,6 +42,7 @@ pub enum SidePanelAction { ComposeNote, Search, ExpandSidePanel, + Support, } pub struct SidePanelResponse { @@ -114,6 +116,8 @@ impl<'a> DesktopSidePanel<'a> { let pfp_resp = self.pfp_button(ui); let settings_resp = ui.add(settings_button(dark_mode)); + let support_resp = ui.add(support_button()); + let optional_inner = if pfp_resp.clicked() { Some(egui::InnerResponse::new( SidePanelAction::Account, @@ -124,6 +128,11 @@ impl<'a> DesktopSidePanel<'a> { SidePanelAction::Settings, settings_resp, )) + } else if support_resp.clicked() { + Some(egui::InnerResponse::new( + SidePanelAction::Support, + support_resp, + )) } else { None }; @@ -162,7 +171,7 @@ impl<'a> DesktopSidePanel<'a> { helper.take_animation_response() } - pub fn perform_action(columns: &mut Columns, action: SidePanelAction) { + pub fn perform_action(columns: &mut Columns, support: &mut Support, action: SidePanelAction) { let router = columns.get_first_router(); match action { SidePanelAction::Panel => {} // TODO @@ -208,6 +217,14 @@ impl<'a> DesktopSidePanel<'a> { // TODO info!("Clicked expand side panel button"); } + SidePanelAction::Support => { + if router.routes().iter().any(|&r| r == Route::Support) { + router.go_back(); + } else { + support.refresh(); + router.route_to(Route::Support); + } + } } } } @@ -352,6 +369,28 @@ fn expand_side_panel_button() -> impl Widget { } } +fn support_button() -> impl Widget { + |ui: &mut egui::Ui| -> egui::Response { + let img_size = 16.0; + + let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget + let img_data = egui::include_image!("../../assets/icons/help_icon_dark_4x.png"); + let img = egui::Image::new(img_data).max_width(img_size); + + let helper = AnimationHelper::new(ui, "help-button", vec2(max_size, max_size)); + + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at( + ui, + helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0), + ); + + helper.take_animation_response() + } +} + mod preview { use egui_extras::{Size, StripBuilder}; @@ -390,7 +429,11 @@ mod preview { ); let response = panel.show(ui); - DesktopSidePanel::perform_action(&mut self.app.columns, response.action); + DesktopSidePanel::perform_action( + &mut self.app.columns, + &mut self.app.support, + response.action, + ); }); }); } diff --git a/src/ui/support.rs b/src/ui/support.rs @@ -0,0 +1,76 @@ +use egui::{vec2, Button, Label, Layout, RichText}; + +use crate::{ + app_style::{get_font_size, NotedeckTextStyle}, + colors::PINK, + fonts::NamedFontFamily, + support::Support, +}; + +use super::{button_hyperlink::ButtonHyperlink, padding}; + +pub struct SupportView<'a> { + support: &'a mut Support, +} + +impl<'a> SupportView<'a> { + pub fn new(support: &'a mut Support) -> Self { + Self { support } + } + + pub fn show(&mut self, ui: &mut egui::Ui) { + padding(8.0, ui, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 8.0); + let font = egui::FontId::new( + get_font_size(ui.ctx(), &NotedeckTextStyle::Body), + egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()), + ); + ui.add(Label::new(RichText::new("Running into a bug?").font(font))); + ui.label(RichText::new("Step 1").text_style(NotedeckTextStyle::Heading3.text_style())); + padding(8.0, ui, |ui| { + ui.label("Open your default email client to get help from the Damus team"); + let size = vec2(120.0, 40.0); + ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { + ui.add(ButtonHyperlink::new( + Button::new( + RichText::new("Open Email") + .size(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)), + ) + .fill(PINK) + .min_size(size), + self.support.get_mailto_url(), + )); + }) + }); + + ui.add_space(8.0); + + if let Some(logs) = self.support.get_most_recent_log() { + ui.label( + RichText::new("Step 2").text_style(NotedeckTextStyle::Heading3.text_style()), + ); + let size = vec2(80.0, 40.0); + let copy_button = Button::new( + RichText::new("Copy").size(get_font_size(ui.ctx(), &NotedeckTextStyle::Body)), + ) + .fill(PINK) + .min_size(size); + padding(8.0, ui, |ui| { + ui.add(Label::new("Press the button below to copy your most recent logs to your system's clipboard. Then paste it into your email.").wrap()); + ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { + if ui.add(copy_button).clicked() { + ui.output_mut(|w| { + w.copied_text = logs.to_string(); + }); + } + }); + }); + } else { + ui.label( + egui::RichText::new("ERROR: Could not find logs on system") + .color(egui::Color32::RED), + ); + } + }); + } +}