add_column.rs (16254B)
1 use core::f32; 2 use serde::{Deserialize, Serialize}; 3 use std::collections::HashMap; 4 5 use egui::{ 6 pos2, vec2, Align, Button, Color32, FontId, Id, ImageSource, Margin, Pos2, Rect, RichText, 7 Separator, Ui, Vec2, 8 }; 9 use nostrdb::Ndb; 10 use tracing::error; 11 12 use crate::{ 13 app_style::{get_font_size, NotedeckTextStyle}, 14 login_manager::AcquireKeyState, 15 timeline::{PubkeySource, Timeline, TimelineKind}, 16 ui::anim::ICON_EXPANSION_MULTIPLE, 17 user_account::UserAccount, 18 Damus, 19 }; 20 21 use super::{anim::AnimationHelper, padding}; 22 23 pub enum AddColumnResponse { 24 Timeline(Timeline), 25 UndecidedNotification, 26 ExternalNotification, 27 Hashtag, 28 } 29 30 pub enum NotificationColumnType { 31 Home, 32 External, 33 } 34 35 #[derive(Clone, Debug)] 36 enum AddColumnOption { 37 Universe, 38 UndecidedNotification, 39 ExternalNotification, 40 Notification(PubkeySource), 41 Home(PubkeySource), 42 UndecidedHashtag, 43 Hashtag(String), 44 } 45 46 #[derive(Clone, Copy, Eq, PartialEq, Debug, Serialize, Deserialize)] 47 pub enum AddColumnRoute { 48 Base, 49 UndecidedNotification, 50 ExternalNotification, 51 Hashtag, 52 } 53 54 impl AddColumnOption { 55 pub fn take_as_response( 56 self, 57 ndb: &Ndb, 58 cur_account: Option<&UserAccount>, 59 ) -> Option<AddColumnResponse> { 60 match self { 61 AddColumnOption::Universe => TimelineKind::Universe 62 .into_timeline(ndb, None) 63 .map(AddColumnResponse::Timeline), 64 AddColumnOption::Notification(pubkey) => TimelineKind::Notifications(pubkey) 65 .into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) 66 .map(AddColumnResponse::Timeline), 67 AddColumnOption::UndecidedNotification => { 68 Some(AddColumnResponse::UndecidedNotification) 69 } 70 AddColumnOption::Home(pubkey) => { 71 let tlk = TimelineKind::contact_list(pubkey); 72 tlk.into_timeline(ndb, cur_account.map(|a| a.pubkey.bytes())) 73 .map(AddColumnResponse::Timeline) 74 } 75 AddColumnOption::ExternalNotification => Some(AddColumnResponse::ExternalNotification), 76 AddColumnOption::UndecidedHashtag => Some(AddColumnResponse::Hashtag), 77 AddColumnOption::Hashtag(hashtag) => TimelineKind::Hashtag(hashtag) 78 .into_timeline(ndb, None) 79 .map(AddColumnResponse::Timeline), 80 } 81 } 82 } 83 84 pub struct AddColumnView<'a> { 85 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 86 ndb: &'a Ndb, 87 cur_account: Option<&'a UserAccount>, 88 } 89 90 impl<'a> AddColumnView<'a> { 91 pub fn new( 92 key_state_map: &'a mut HashMap<Id, AcquireKeyState>, 93 ndb: &'a Ndb, 94 cur_account: Option<&'a UserAccount>, 95 ) -> Self { 96 Self { 97 key_state_map, 98 ndb, 99 cur_account, 100 } 101 } 102 103 pub fn ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 104 let mut selected_option: Option<AddColumnResponse> = None; 105 for column_option_data in self.get_base_options() { 106 let option = column_option_data.option.clone(); 107 if self.column_option_ui(ui, column_option_data).clicked() { 108 selected_option = option.take_as_response(self.ndb, self.cur_account); 109 } 110 111 ui.add(Separator::default().spacing(0.0)); 112 } 113 114 selected_option 115 } 116 117 fn notifications_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 118 let mut selected_option: Option<AddColumnResponse> = None; 119 for column_option_data in self.get_notifications_options() { 120 let option = column_option_data.option.clone(); 121 if self.column_option_ui(ui, column_option_data).clicked() { 122 selected_option = option.take_as_response(self.ndb, self.cur_account); 123 } 124 125 ui.add(Separator::default().spacing(0.0)); 126 } 127 128 selected_option 129 } 130 131 fn external_notification_ui(&mut self, ui: &mut Ui) -> Option<AddColumnResponse> { 132 padding(16.0, ui, |ui| { 133 let id = ui.id().with("external_notif"); 134 let key_state = self.key_state_map.entry(id).or_default(); 135 136 let text_edit = key_state.get_acquire_textedit(|text| { 137 egui::TextEdit::singleline(text) 138 .hint_text( 139 RichText::new("Enter the user's key (npub, hex, nip05) here...") 140 .text_style(NotedeckTextStyle::Body.text_style()), 141 ) 142 .vertical_align(Align::Center) 143 .desired_width(f32::INFINITY) 144 .min_size(Vec2::new(0.0, 40.0)) 145 .margin(Margin::same(12.0)) 146 }); 147 148 ui.add(text_edit); 149 150 if ui.button("Add").clicked() { 151 key_state.apply_acquire(); 152 } 153 154 if key_state.is_awaiting_network() { 155 ui.spinner(); 156 } 157 158 if let Some(error) = key_state.check_for_error() { 159 error!("acquire key error: {}", error); 160 ui.colored_label( 161 Color32::RED, 162 "Please enter a valid npub, public hex key or nip05", 163 ); 164 } 165 166 if let Some(keypair) = key_state.check_for_successful_login() { 167 key_state.should_create_new(); 168 AddColumnOption::Notification(PubkeySource::Explicit(keypair.pubkey)) 169 .take_as_response(self.ndb, self.cur_account) 170 } else { 171 None 172 } 173 }) 174 .inner 175 } 176 177 fn column_option_ui(&mut self, ui: &mut Ui, data: ColumnOptionData) -> egui::Response { 178 let icon_padding = 8.0; 179 let min_icon_width = 32.0; 180 let height_padding = 12.0; 181 let max_width = ui.available_width(); 182 let title_style = NotedeckTextStyle::Body; 183 let desc_style = NotedeckTextStyle::Button; 184 let title_min_font_size = get_font_size(ui.ctx(), &title_style); 185 let desc_min_font_size = get_font_size(ui.ctx(), &desc_style); 186 187 let max_height = { 188 let max_wrap_width = 189 max_width - ((icon_padding * 2.0) + (min_icon_width * ICON_EXPANSION_MULTIPLE)); 190 let title_max_font = FontId::new( 191 title_min_font_size * ICON_EXPANSION_MULTIPLE, 192 title_style.font_family(), 193 ); 194 let desc_max_font = FontId::new( 195 desc_min_font_size * ICON_EXPANSION_MULTIPLE, 196 desc_style.font_family(), 197 ); 198 let max_desc_galley = ui.fonts(|f| { 199 f.layout( 200 data.description.to_string(), 201 desc_max_font, 202 Color32::WHITE, 203 max_wrap_width, 204 ) 205 }); 206 207 let max_title_galley = ui.fonts(|f| { 208 f.layout( 209 data.title.to_string(), 210 title_max_font, 211 Color32::WHITE, 212 max_wrap_width, 213 ) 214 }); 215 216 let desc_font_max_size = max_desc_galley.rect.height(); 217 let title_font_max_size = max_title_galley.rect.height(); 218 title_font_max_size + desc_font_max_size + (2.0 * height_padding) 219 }; 220 221 let helper = AnimationHelper::new(ui, data.title, vec2(max_width, max_height)); 222 let animation_rect = helper.get_animation_rect(); 223 224 let cur_icon_width = helper.scale_1d_pos(min_icon_width); 225 let painter = ui.painter_at(animation_rect); 226 227 let cur_icon_size = vec2(cur_icon_width, cur_icon_width); 228 let cur_icon_x_pos = animation_rect.left() + (icon_padding) + (cur_icon_width / 2.0); 229 230 let title_cur_font = FontId::new( 231 helper.scale_1d_pos(title_min_font_size), 232 title_style.font_family(), 233 ); 234 235 let desc_cur_font = FontId::new( 236 helper.scale_1d_pos(desc_min_font_size), 237 desc_style.font_family(), 238 ); 239 240 let wrap_width = max_width - (cur_icon_width + (icon_padding * 2.0)); 241 let text_color = ui.ctx().style().visuals.text_color(); 242 let fallback_color = ui.ctx().style().visuals.weak_text_color(); 243 244 let title_galley = painter.layout( 245 data.title.to_string(), 246 title_cur_font, 247 text_color, 248 wrap_width, 249 ); 250 let desc_galley = painter.layout( 251 data.description.to_string(), 252 desc_cur_font, 253 text_color, 254 wrap_width, 255 ); 256 257 let galley_heights = title_galley.rect.height() + desc_galley.rect.height(); 258 259 let cur_height_padding = (animation_rect.height() - galley_heights) / 2.0; 260 let corner_x_pos = cur_icon_x_pos + (cur_icon_width / 2.0) + icon_padding; 261 let title_corner_pos = Pos2::new(corner_x_pos, animation_rect.top() + cur_height_padding); 262 let desc_corner_pos = Pos2::new( 263 corner_x_pos, 264 title_corner_pos.y + title_galley.rect.height(), 265 ); 266 267 let icon_cur_y = animation_rect.top() + cur_height_padding + (galley_heights / 2.0); 268 let icon_img = egui::Image::new(data.icon).fit_to_exact_size(cur_icon_size); 269 let icon_rect = Rect::from_center_size(pos2(cur_icon_x_pos, icon_cur_y), cur_icon_size); 270 271 icon_img.paint_at(ui, icon_rect); 272 painter.galley(title_corner_pos, title_galley, fallback_color); 273 painter.galley(desc_corner_pos, desc_galley, fallback_color); 274 275 helper.take_animation_response() 276 } 277 278 fn get_base_options(&self) -> Vec<ColumnOptionData> { 279 let mut vec = Vec::new(); 280 vec.push(ColumnOptionData { 281 title: "Universe", 282 description: "See the whole nostr universe", 283 icon: egui::include_image!("../../assets/icons/universe_icon_dark_4x.png"), 284 option: AddColumnOption::Universe, 285 }); 286 287 if let Some(acc) = self.cur_account { 288 let source = PubkeySource::Explicit(acc.pubkey); 289 290 vec.push(ColumnOptionData { 291 title: "Home timeline", 292 description: "See recommended notes first", 293 icon: egui::include_image!("../../assets/icons/home_icon_dark_4x.png"), 294 option: AddColumnOption::Home(source.clone()), 295 }); 296 } 297 vec.push(ColumnOptionData { 298 title: "Notifications", 299 description: "Stay up to date with notifications and mentions", 300 icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), 301 option: AddColumnOption::UndecidedNotification, 302 }); 303 vec.push(ColumnOptionData { 304 title: "Hashtag", 305 description: "Stay up to date with a certain hashtag", 306 icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), 307 option: AddColumnOption::UndecidedHashtag, 308 }); 309 310 vec 311 } 312 313 fn get_notifications_options(&self) -> Vec<ColumnOptionData> { 314 let mut vec = Vec::new(); 315 316 if let Some(acc) = self.cur_account { 317 let source = if acc.secret_key.is_some() { 318 PubkeySource::DeckAuthor 319 } else { 320 PubkeySource::Explicit(acc.pubkey) 321 }; 322 323 vec.push(ColumnOptionData { 324 title: "Your Notifications", 325 description: "Stay up to date with your notifications and mentions", 326 icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), 327 option: AddColumnOption::Notification(source), 328 }); 329 } 330 331 vec.push(ColumnOptionData { 332 title: "Someone else's Notifications", 333 description: "Stay up to date with someone else's notifications and mentions", 334 icon: egui::include_image!("../../assets/icons/notifications_icon_dark_4x.png"), 335 option: AddColumnOption::ExternalNotification, 336 }); 337 338 vec 339 } 340 } 341 342 struct ColumnOptionData { 343 title: &'static str, 344 description: &'static str, 345 icon: ImageSource<'static>, 346 option: AddColumnOption, 347 } 348 349 pub fn render_add_column_routes( 350 ui: &mut egui::Ui, 351 app: &mut Damus, 352 col: usize, 353 route: &AddColumnRoute, 354 ) { 355 let mut add_column_view = AddColumnView::new( 356 &mut app.view_state.id_state_map, 357 &app.ndb, 358 app.accounts.get_selected_account(), 359 ); 360 let resp = match route { 361 AddColumnRoute::Base => add_column_view.ui(ui), 362 AddColumnRoute::UndecidedNotification => add_column_view.notifications_ui(ui), 363 AddColumnRoute::ExternalNotification => add_column_view.external_notification_ui(ui), 364 AddColumnRoute::Hashtag => hashtag_ui(ui, &app.ndb, &mut app.view_state.id_string_map), 365 }; 366 367 if let Some(resp) = resp { 368 match resp { 369 AddColumnResponse::Timeline(mut timeline) => { 370 crate::timeline::setup_new_timeline( 371 &mut timeline, 372 &app.ndb, 373 &mut app.subscriptions, 374 &mut app.pool, 375 &mut app.note_cache, 376 app.since_optimize, 377 &app.accounts.mutefun(), 378 ); 379 app.columns_mut().add_timeline_to_column(col, timeline); 380 } 381 AddColumnResponse::UndecidedNotification => { 382 app.columns_mut().column_mut(col).router_mut().route_to( 383 crate::route::Route::AddColumn(AddColumnRoute::UndecidedNotification), 384 ); 385 } 386 AddColumnResponse::ExternalNotification => { 387 app.columns_mut().column_mut(col).router_mut().route_to( 388 crate::route::Route::AddColumn(AddColumnRoute::ExternalNotification), 389 ); 390 } 391 AddColumnResponse::Hashtag => { 392 app.columns_mut() 393 .column_mut(col) 394 .router_mut() 395 .route_to(crate::route::Route::AddColumn(AddColumnRoute::Hashtag)); 396 } 397 }; 398 } 399 } 400 401 pub fn hashtag_ui( 402 ui: &mut Ui, 403 ndb: &Ndb, 404 id_string_map: &mut HashMap<Id, String>, 405 ) -> Option<AddColumnResponse> { 406 padding(16.0, ui, |ui| { 407 let id = ui.id().with("hashtag"); 408 let text_buffer = id_string_map.entry(id).or_default(); 409 410 let text_edit = egui::TextEdit::singleline(text_buffer) 411 .hint_text( 412 RichText::new("Enter the desired hashtag here") 413 .text_style(NotedeckTextStyle::Body.text_style()), 414 ) 415 .vertical_align(Align::Center) 416 .desired_width(f32::INFINITY) 417 .min_size(Vec2::new(0.0, 40.0)) 418 .margin(Margin::same(12.0)); 419 ui.add(text_edit); 420 421 ui.add_space(8.0); 422 if ui 423 .add_sized( 424 egui::vec2(50.0, 40.0), 425 Button::new("Add").rounding(8.0).fill(crate::colors::PINK), 426 ) 427 .clicked() 428 { 429 let resp = AddColumnOption::Hashtag(text_buffer.to_owned()).take_as_response(ndb, None); 430 id_string_map.remove(&id); 431 resp 432 } else { 433 None 434 } 435 }) 436 .inner 437 } 438 439 mod preview { 440 use crate::{ 441 test_data, 442 ui::{Preview, PreviewConfig, View}, 443 Damus, 444 }; 445 446 use super::AddColumnView; 447 448 pub struct AddColumnPreview { 449 app: Damus, 450 } 451 452 impl AddColumnPreview { 453 fn new() -> Self { 454 let app = test_data::test_app(); 455 456 AddColumnPreview { app } 457 } 458 } 459 460 impl View for AddColumnPreview { 461 fn ui(&mut self, ui: &mut egui::Ui) { 462 AddColumnView::new( 463 &mut self.app.view_state.id_state_map, 464 &self.app.ndb, 465 self.app.accounts.get_selected_account(), 466 ) 467 .ui(ui); 468 } 469 } 470 471 impl Preview for AddColumnView<'_> { 472 type Prev = AddColumnPreview; 473 474 fn preview(_cfg: PreviewConfig) -> Self::Prev { 475 AddColumnPreview::new() 476 } 477 } 478 }