diff --git a/Cargo.lock b/Cargo.lock index d713f0c..76caf57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2607,6 +2607,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "tracing-wasm", + "urlencoding", "uuid", "wasm-bindgen-futures", "winit", @@ -4679,6 +4680,12 @@ dependencies = [ "serde", ] +[[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" diff --git a/Cargo.toml b/Cargo.toml index 213b754..152f7ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ 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" diff --git a/assets/icons/help_icon_dark_4x.png b/assets/icons/help_icon_dark_4x.png new file mode 100644 index 0000000..cc77a15 Binary files /dev/null and b/assets/icons/help_icon_dark_4x.png differ diff --git a/src/app.rs b/src/app.rs index ba3ec6c..5459bb7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,6 +17,7 @@ use crate::{ profile::Profile, storage::{Directory, FileKeyStorage, KeyStorageType}, subscriptions::{SubKind, Subscriptions}, + support::Support, thread::Thread, timeline::{Timeline, TimelineId, TimelineKind, ViewFilter}, ui::{self, DesktopSidePanel}, @@ -62,6 +63,7 @@ pub struct Damus { pub accounts: AccountManager, pub subscriptions: Subscriptions, pub app_rect_handler: AppSizeHandler, + pub support: Support, frame_history: crate::frame_history::FrameHistory, @@ -751,6 +753,7 @@ impl Damus { frame_history: FrameHistory::default(), view_state: ViewState::default(), app_rect_handler: AppSizeHandler::default(), + support: Support::default(), } } @@ -835,6 +838,7 @@ impl Damus { frame_history: FrameHistory::default(), view_state: ViewState::default(), app_rect_handler: AppSizeHandler::default(), + support: Support::default(), } } @@ -1024,7 +1028,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/lib.rs b/src/lib.rs index a887d1e..4ed39bf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,7 @@ pub mod relay_pool_manager; mod result; mod route; mod subscriptions; +mod support; mod test_data; mod thread; mod time; diff --git a/src/nav.rs b/src/nav.rs index 4f6d395..efe1283 100644 --- 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 index 9fb0f7f..27923fb 100644 --- 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/support.rs b/src/support.rs new file mode 100644 index 0000000..3d8222e --- /dev/null +++ b/src/support.rs @@ -0,0 +1,158 @@ +use tracing::error; + +use crate::{storage::Directory, DataPaths}; + +pub struct Support { + directory: Option, + mailto_url: String, + most_recent_log: Option, +} + +fn new_log_dir() -> Option { + 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 { + 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, + address: String, + subject: Option, +} + +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 new file mode 100644 index 0000000..c8d1873 --- /dev/null +++ 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 index 08f239b..326b260 100644 --- 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 index bb779ca..7cefedb 100644 --- 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 new file mode 100644 index 0000000..0cd10bf --- /dev/null +++ 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), + ); + } + }); + } +}