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:
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),
+ );
+ }
+ });
+ }
+}