1/* 2 * Copyright 2016 Software Freedom Conservancy Inc. 3 * Copyright 2016,2019 Michael Gratton <mike@vee.net> 4 * 5 * This software is licensed under the GNU Lesser General Public License 6 * (version 2.1 or later). See the COPYING file in this distribution. 7 */ 8 9/** 10 * A widget for displaying an email in a conversation. 11 * 12 * This view corresponds to {@link Geary.Email}, displaying the 13 * email's primary message (a {@link Geary.RFC822.Message}), any 14 * sub-messages (also instances of {@link Geary.RFC822.Message}) and 15 * attachments. The RFC822 messages are themselves displayed by {@link 16 * ConversationMessage}. 17 */ 18[GtkTemplate (ui = "/org/gnome/Geary/conversation-email.ui")] 19public class ConversationEmail : Gtk.Box, Geary.BaseInterface { 20 // This isn't a Gtk.Grid since when added to a Gtk.ListBoxRow the 21 // hover style isn't applied to it. 22 23 private const string MANUAL_READ_CLASS = "geary-manual-read"; 24 private const string SENT_CLASS = "geary-sent"; 25 private const string STARRED_CLASS = "geary-starred"; 26 private const string UNREAD_CLASS = "geary-unread"; 27 28 /** Fields that must be available for constructing the view. */ 29 internal const Geary.Email.Field REQUIRED_FOR_CONSTRUCT = ( 30 Geary.Email.Field.ENVELOPE | 31 Geary.Email.Field.PREVIEW | 32 Geary.Email.Field.FLAGS 33 ); 34 35 /** Fields that must be available for loading the body. */ 36 internal const Geary.Email.Field REQUIRED_FOR_LOAD = ( 37 // Include those needed by the constructor since we'll replace 38 // the ctor's email arg value once the body has been fully 39 // loaded 40 REQUIRED_FOR_CONSTRUCT | 41 Geary.Email.REQUIRED_FOR_MESSAGE 42 ); 43 44 // Time to wait loading the body before showing the progress meter 45 private const int BODY_LOAD_TIMEOUT_MSEC = 250; 46 47 48 /** Specifies the loading state for a message part. */ 49 public enum LoadState { 50 51 /** Loading has not started. */ 52 NOT_STARTED, 53 54 /** Loading has started, but not completed. */ 55 STARTED, 56 57 /** Loading has started and completed. */ 58 COMPLETED, 59 60 /** Loading has started but encountered an error. */ 61 FAILED; 62 63 } 64 65 /** 66 * Iterator that returns all message views in an email view. 67 */ 68 private class MessageViewIterator : 69 Gee.Traversable<ConversationMessage>, 70 Gee.Iterator<ConversationMessage>, 71 Geary.BaseObject { 72 73 74 public bool read_only { 75 get { return true; } 76 } 77 public bool valid { 78 get { return this.pos == 0 || this.attached_views.valid; } 79 } 80 81 private ConversationEmail parent_view; 82 private int pos = -1; 83 private Gee.Iterator<ConversationMessage>? attached_views = null; 84 85 internal MessageViewIterator(ConversationEmail parent_view) { 86 this.parent_view = parent_view; 87 this.attached_views = parent_view._attached_messages.iterator(); 88 } 89 90 public bool next() { 91 bool has_next = false; 92 this.pos += 1; 93 if (this.pos == 0) { 94 has_next = true; 95 } else { 96 has_next = this.attached_views.next(); 97 } 98 return has_next; 99 } 100 101 public bool has_next() { 102 return this.pos == -1 || this.attached_views.next(); 103 } 104 105 public new ConversationMessage get() { 106 switch (this.pos) { 107 case -1: 108 assert_not_reached(); 109 110 case 0: 111 return this.parent_view.primary_message; 112 113 default: 114 return this.attached_views.get(); 115 } 116 } 117 118 public void remove() { 119 assert_not_reached(); 120 } 121 122 public new bool foreach(Gee.ForallFunc<ConversationMessage> f) { 123 bool cont = true; 124 while (cont && has_next()) { 125 next(); 126 cont = f(get()); 127 } 128 return cont; 129 } 130 131 } 132 133 134 private static GLib.MenuModel email_menu_template; 135 private static GLib.MenuModel email_menu_trash_section; 136 private static GLib.MenuModel email_menu_delete_section; 137 138 139 static construct { 140 Gtk.Builder builder = new Gtk.Builder.from_resource( 141 "/org/gnome/Geary/conversation-email-menus.ui" 142 ); 143 email_menu_template = (GLib.MenuModel) builder.get_object("email_menu"); 144 email_menu_trash_section = (GLib.MenuModel) builder.get_object("email_menu_trash"); 145 email_menu_delete_section = (GLib.MenuModel) builder.get_object("email_menu_delete"); 146 } 147 148 149 /** 150 * The specific email that is displayed by this view. 151 * 152 * This object is updated as additional fields are loaded, so it 153 * should not be relied on to a) contain required fields without 154 * testing or b) assumed to be the same over the life of this view 155 * object. 156 */ 157 public Geary.Email email { get; private set; } 158 159 /** Determines if this email currently flagged as unread. */ 160 public bool is_unread { 161 get { 162 Geary.EmailFlags? flags = this.email.email_flags; 163 return (flags != null && flags.is_unread()); 164 } 165 } 166 167 /** Determines if this email currently flagged as starred. */ 168 public bool is_starred { 169 get { 170 Geary.EmailFlags? flags = this.email.email_flags; 171 return (flags != null && flags.is_flagged()); 172 } 173 } 174 175 /** Determines if the email is showing a preview or the full message. */ 176 public bool is_collapsed = true; 177 178 /** Determines if the email has been manually marked as being read. */ 179 public bool is_manually_read { 180 get { return get_style_context().has_class(MANUAL_READ_CLASS); } 181 set { 182 if (value) { 183 get_style_context().add_class(MANUAL_READ_CLASS); 184 } else { 185 get_style_context().remove_class(MANUAL_READ_CLASS); 186 } 187 } 188 } 189 190 /** Determines if the email is a draft message. */ 191 public bool is_draft { get; private set; } 192 193 /** The view displaying the email's primary message headers and body. */ 194 public ConversationMessage primary_message { get; private set; } 195 196 public Components.AttachmentPane? attachments_pane { 197 get; private set; default = null; 198 } 199 200 /** Views for attached messages. */ 201 public Gee.List<ConversationMessage> attached_messages { 202 owned get { return this._attached_messages.read_only_view; } 203 } 204 private Gee.List<ConversationMessage> _attached_messages = 205 new Gee.LinkedList<ConversationMessage>(); 206 207 /** Determines the message body loading state. */ 208 public LoadState message_body_state { get; private set; default = NOT_STARTED; } 209 210 public Geary.App.Conversation conversation; 211 212 // Store from which to load message content, if needed 213 private Geary.App.EmailStore email_store; 214 215 // Store from which to lookup contacts 216 private Application.ContactStore contacts; 217 218 // Cancellable to use when loading message content 219 private GLib.Cancellable load_cancellable; 220 221 private Application.Configuration config; 222 223 private Geary.TimeoutManager body_loading_timeout; 224 225 /** Determines if all message's web views have finished loading. */ 226 private Geary.Nonblocking.Spinlock message_bodies_loaded_lock; 227 228 // Message view with selected text, if any 229 private ConversationMessage? body_selection_message = null; 230 231 // A subset of the message's attachments that are displayed in the 232 // attachments view 233 private Gee.List<Geary.Attachment> displayed_attachments = 234 new Gee.LinkedList<Geary.Attachment>(); 235 236 // Tracks if Shift key handler has been installed on the main 237 // window, for updating email menu trash/delete actions. 238 private bool shift_handler_installed = false; 239 240 [GtkChild] private unowned Gtk.Grid actions; 241 242 [GtkChild] private unowned Gtk.Button attachments_button; 243 244 [GtkChild] private unowned Gtk.Button star_button; 245 246 [GtkChild] private unowned Gtk.Button unstar_button; 247 248 [GtkChild] private unowned Gtk.MenuButton email_menubutton; 249 250 [GtkChild] private unowned Gtk.Grid sub_messages; 251 252 253 /** Fired when a internal link is activated */ 254 internal signal void internal_link_activated(int y); 255 256 /** Fired when the user selects text in a message. */ 257 internal signal void body_selection_changed(bool has_selection); 258 259 260 /** 261 * Constructs a new view to display an email. 262 * 263 * This method sets up most of the user interface for displaying 264 * the complete email, but does not attempt any possibly 265 * long-running loading processes. 266 */ 267 public ConversationEmail(Geary.App.Conversation conversation, 268 Geary.Email email, 269 Geary.App.EmailStore email_store, 270 Application.ContactStore contacts, 271 Application.Configuration config, 272 bool is_sent, 273 bool is_draft, 274 GLib.Cancellable load_cancellable) { 275 base_ref(); 276 this.conversation = conversation; 277 this.email = email; 278 this.is_draft = is_draft; 279 this.email_store = email_store; 280 this.contacts = contacts; 281 this.config = config; 282 this.load_cancellable = load_cancellable; 283 this.message_bodies_loaded_lock = 284 new Geary.Nonblocking.Spinlock(load_cancellable); 285 286 if (is_sent) { 287 get_style_context().add_class(SENT_CLASS); 288 } 289 290 // Construct the view for the primary message, hook into it 291 292 this.primary_message = new ConversationMessage.from_email( 293 email, 294 email.load_remote_images().is_certain(), 295 this.contacts, 296 this.config 297 ); 298 this.primary_message.summary.add(this.actions); 299 connect_message_view_signals(this.primary_message); 300 301 // Wire up the rest of the UI 302 303 email_store.account.incoming.notify["current-status"].connect( 304 on_service_status_change 305 ); 306 307 this.load_cancellable.cancelled.connect(on_load_cancelled); 308 309 this.body_loading_timeout = new Geary.TimeoutManager.milliseconds( 310 BODY_LOAD_TIMEOUT_MSEC, this.on_body_loading_timeout 311 ); 312 313 pack_start(this.primary_message, true, true, 0); 314 update_email_state(); 315 } 316 317 ~ConversationEmail() { 318 base_unref(); 319 } 320 321 /** 322 * Loads the contacts for the primary message. 323 */ 324 public async void load_contacts() 325 throws GLib.Error { 326 try { 327 yield this.primary_message.load_contacts(this.load_cancellable); 328 } catch (IOError.CANCELLED err) { 329 // okay 330 } catch (Error err) { 331 Geary.RFC822.MailboxAddress? from = 332 this.primary_message.primary_originator; 333 debug("Contact load failed for \"%s\": %s", 334 from != null ? from.to_string() : "<unknown>", err.message); 335 } 336 if (this.load_cancellable.is_cancelled()) { 337 throw new GLib.IOError.CANCELLED("Contact load was cancelled"); 338 } 339 } 340 341 /** 342 * Loads the message body and attachments. 343 * 344 * This potentially hits the database if the email that the view 345 * was constructed from doesn't satisfy requirements, loads 346 * attachments, including views and avatars for any attached 347 * messages, and waits for the primary message body content to 348 * have been loaded by its web view before returning. 349 */ 350 public async void load_body() 351 throws GLib.Error { 352 this.message_body_state = STARTED; 353 354 // Ensure we have required data to load the message 355 356 bool loaded = this.email.fields.fulfills(REQUIRED_FOR_LOAD); 357 if (!loaded) { 358 this.body_loading_timeout.start(); 359 try { 360 this.email = yield this.email_store.fetch_email_async( 361 this.email.id, 362 REQUIRED_FOR_LOAD, 363 LOCAL_ONLY, // Throws an error if not downloaded 364 this.load_cancellable 365 ); 366 loaded = true; 367 this.body_loading_timeout.reset(); 368 } catch (Geary.EngineError.INCOMPLETE_MESSAGE err) { 369 // Don't have the complete message at the moment, so 370 // download it in the background. Don't reset the body 371 // load timeout here since this will attempt to fetch 372 // from the remote 373 this.fetch_remote_body.begin(); 374 } catch (GLib.IOError.CANCELLED err) { 375 this.body_loading_timeout.reset(); 376 throw err; 377 } catch (GLib.Error err) { 378 this.body_loading_timeout.reset(); 379 handle_load_failure(err); 380 throw err; 381 } 382 } 383 384 if (loaded) { 385 try { 386 yield update_body(); 387 } catch (GLib.IOError.CANCELLED err) { 388 this.body_loading_timeout.reset(); 389 throw err; 390 } catch (GLib.Error err) { 391 this.body_loading_timeout.reset(); 392 handle_load_failure(err); 393 throw err; 394 } 395 yield this.message_bodies_loaded_lock.wait_async( 396 this.load_cancellable 397 ); 398 } 399 } 400 401 /** 402 * Shows the complete message: headers, body and attachments. 403 */ 404 public void expand_email(bool include_transitions=true) { 405 this.is_collapsed = false; 406 update_email_state(); 407 this.attachments_button.set_sensitive(true); 408 // Needs at least some menu set otherwise it won't be enabled, 409 // also has the side effect of making it sensitive 410 this.email_menubutton.set_menu_model(new GLib.Menu()); 411 412 // Set targets to enable the actions 413 GLib.Variant email_target = email.id.to_variant(); 414 this.attachments_button.set_action_target_value(email_target); 415 this.star_button.set_action_target_value(email_target); 416 this.unstar_button.set_action_target_value(email_target); 417 418 foreach (ConversationMessage message in this) { 419 message.show_message_body(include_transitions); 420 } 421 } 422 423 /** 424 * Hides the complete message, just showing the header preview. 425 */ 426 public void collapse_email() { 427 is_collapsed = true; 428 update_email_state(); 429 attachments_button.set_sensitive(false); 430 email_menubutton.set_sensitive(false); 431 432 // Clear targets to disable the actions 433 this.attachments_button.set_action_target_value(null); 434 this.star_button.set_action_target_value(null); 435 this.unstar_button.set_action_target_value(null); 436 437 primary_message.hide_message_body(); 438 foreach (ConversationMessage attached in this._attached_messages) { 439 attached.hide_message_body(); 440 } 441 } 442 443 /** 444 * Updates the current email's flags and dependent UI state. 445 */ 446 public void update_flags(Geary.Email email) { 447 this.email.set_flags(email.email_flags); 448 update_email_state(); 449 } 450 451 /** 452 * Returns user-selected body HTML from a message, if any. 453 */ 454 public async string? get_selection_for_quoting() { 455 string? selection = null; 456 if (this.body_selection_message != null) { 457 try { 458 selection = 459 yield this.body_selection_message.get_selection_for_quoting(); 460 } catch (Error err) { 461 debug("Failed to get selection for quoting: %s", err.message); 462 } 463 } 464 return selection; 465 } 466 467 /** 468 * Returns user-selected body text from a message, if any. 469 */ 470 public async string? get_selection_for_find() { 471 string? selection = null; 472 if (this.body_selection_message != null) { 473 try { 474 selection = 475 yield this.body_selection_message.get_selection_for_find(); 476 } catch (Error err) { 477 debug("Failed to get selection for find: %s", err.message); 478 } 479 } 480 return selection; 481 } 482 483 /** Displays the raw RFC 822 source for this email. */ 484 public async void view_source() { 485 var main = get_toplevel() as Application.MainWindow; 486 if (main != null) { 487 Geary.Email email = this.email; 488 try { 489 yield Geary.Nonblocking.Concurrent.global.schedule_async( 490 () => { 491 string source = ( 492 email.header.buffer.to_string() + 493 email.body.buffer.to_string() 494 ); 495 string temporary_filename; 496 int temporary_handle = GLib.FileUtils.open_tmp( 497 "geary-message-XXXXXX.txt", 498 out temporary_filename 499 ); 500 GLib.FileUtils.set_contents(temporary_filename, source); 501 GLib.FileUtils.close(temporary_handle); 502 503 // ensure this file is only readable by the 504 // user ... this needs to be done after the 505 // file is closed 506 GLib.FileUtils.chmod( 507 temporary_filename, 508 (int) (Posix.S_IRUSR | Posix.S_IWUSR) 509 ); 510 511 string temporary_uri = GLib.Filename.to_uri( 512 temporary_filename, null 513 ); 514 main.application.show_uri.begin(temporary_uri); 515 }, 516 null 517 ); 518 } catch (GLib.Error error) { 519 main.application.controller.report_problem( 520 new Geary.ProblemReport(error) 521 ); 522 } 523 } 524 } 525 526 /** Print this view's email. */ 527 public async void print() throws Error { 528 Json.Builder builder = new Json.Builder(); 529 builder.begin_object(); 530 if (this.email.from != null) { 531 builder.set_member_name(_("From:")); 532 builder.add_string_value(this.email.from.to_string()); 533 } 534 if (this.email.to != null) { 535 // Translators: Human-readable version of the RFC 822 To header 536 builder.set_member_name(_("To:")); 537 builder.add_string_value(this.email.to.to_string()); 538 } 539 if (this.email.cc != null) { 540 // Translators: Human-readable version of the RFC 822 CC header 541 builder.set_member_name(_("Cc:")); 542 builder.add_string_value(this.email.cc.to_string()); 543 } 544 if (this.email.bcc != null) { 545 // Translators: Human-readable version of the RFC 822 BCC header 546 builder.set_member_name(_("Bcc:")); 547 builder.add_string_value(this.email.bcc.to_string()); 548 } 549 if (this.email.date != null) { 550 // Translators: Human-readable version of the RFC 822 Date header 551 builder.set_member_name(_("Date:")); 552 builder.add_string_value( 553 Util.Date.pretty_print_verbose( 554 this.email.date.value.to_local(), 555 this.config.clock_format 556 ) 557 ); 558 } 559 if (this.email.subject != null) { 560 // Translators: Human-readable version of the RFC 822 Subject header 561 builder.set_member_name(_("Subject:")); 562 builder.add_string_value(this.email.subject.to_string()); 563 } 564 builder.end_object(); 565 Json.Generator generator = new Json.Generator(); 566 generator.set_root(builder.get_root()); 567 string js = "geary.addPrintHeaders(" + generator.to_data(null) + ");"; 568 yield this.primary_message.run_javascript(js, null); 569 570 Gtk.Window? window = get_toplevel() as Gtk.Window; 571 WebKit.PrintOperation op = this.primary_message.new_print_operation(); 572 Gtk.PrintSettings settings = new Gtk.PrintSettings(); 573 574 // Use XDG_DOWNLOADS as default while WebKitGTK printing is 575 // entirely b0rked on Flatpak, since we know at least have the 576 // RW filesystem override in place to allow printing to PDF to 577 // work, when using that directory. 578 var download_dir = GLib.Environment.get_user_special_dir(DOWNLOAD); 579 if (!Geary.String.is_empty_or_whitespace(download_dir)) { 580 settings.set(Gtk.PRINT_SETTINGS_OUTPUT_DIR, download_dir); 581 } 582 583 if (this.email.subject != null) { 584 string file_name = Geary.String.reduce_whitespace(this.email.subject.value); 585 file_name = file_name.replace("/", "_"); 586 if (file_name.char_count() > 128) { 587 file_name = Geary.String.safe_byte_substring(file_name, 128); 588 } 589 590 if (!Geary.String.is_empty(file_name)) { 591 settings.set(Gtk.PRINT_SETTINGS_OUTPUT_BASENAME, file_name); 592 } 593 } 594 595 op.set_print_settings(settings); 596 op.run_dialog(window); 597 } 598 599 /** 600 * Returns a new Iterable over all message views in this email view 601 */ 602 internal Gee.Iterator<ConversationMessage> iterator() { 603 return new MessageViewIterator(this); 604 } 605 606 private void connect_message_view_signals(ConversationMessage view) { 607 view.content_loaded.connect(on_content_loaded); 608 view.flag_remote_images.connect(on_flag_remote_images); 609 view.internal_link_activated.connect((y) => { 610 internal_link_activated(y); 611 }); 612 view.internal_resource_loaded.connect(on_resource_loaded); 613 view.save_image.connect(on_save_image); 614 view.selection_changed.connect((has_selection) => { 615 this.body_selection_message = has_selection ? view : null; 616 body_selection_changed(has_selection); 617 }); 618 } 619 620 private async void fetch_remote_body() { 621 if (is_online()) { 622 // XXX Need proper progress reporting here, rather than just 623 // doing a pulse 624 if (!this.body_loading_timeout.is_running) { 625 this.body_loading_timeout.start(); 626 } 627 628 Geary.Email? loaded = null; 629 try { 630 debug("Downloading remote message: %s", this.email.to_string()); 631 loaded = yield this.email_store.fetch_email_async( 632 this.email.id, 633 REQUIRED_FOR_LOAD, 634 FORCE_UPDATE, 635 this.load_cancellable 636 ); 637 } catch (GLib.IOError.CANCELLED err) { 638 // All good 639 } catch (GLib.Error err) { 640 debug("Remote message download failed: %s", err.message); 641 handle_load_failure(err); 642 } 643 644 this.body_loading_timeout.reset(); 645 646 if (loaded != null && !this.load_cancellable.is_cancelled()) { 647 try { 648 this.email = loaded; 649 yield update_body(); 650 } catch (GLib.IOError.CANCELLED err) { 651 // All good 652 } catch (GLib.Error err) { 653 debug("Remote message update failed: %s", err.message); 654 handle_load_failure(err); 655 } 656 } 657 } else { 658 this.body_loading_timeout.reset(); 659 handle_load_offline(); 660 } 661 } 662 663 private async void update_body() 664 throws GLib.Error { 665 Geary.RFC822.Message message = this.email.get_message(); 666 667 // Load all mime parts and construct CID resources from them 668 669 Gee.Map<string,Geary.Memory.Buffer> cid_resources = 670 new Gee.HashMap<string,Geary.Memory.Buffer>(); 671 foreach (Geary.Attachment attachment in email.attachments) { 672 // Assume all parts are attachments. As the primary and 673 // secondary message bodies are loaded, any displayed 674 // inline will be removed from the list. 675 this.displayed_attachments.add(attachment); 676 677 if (attachment.content_id != null) { 678 try { 679 cid_resources[attachment.content_id] = 680 new Geary.Memory.FileBuffer(attachment.file, true); 681 } catch (Error err) { 682 debug("Could not open attachment: %s", err.message); 683 } 684 } 685 } 686 this.attachments_button.set_visible(!this.displayed_attachments.is_empty); 687 688 // Load all messages 689 690 this.primary_message.add_internal_resources(cid_resources); 691 yield this.primary_message.load_message_body( 692 message, this.load_cancellable 693 ); 694 695 Gee.List<Geary.RFC822.Message> sub_messages = message.get_sub_messages(); 696 if (sub_messages.size > 0) { 697 this.primary_message.body_container.add(this.sub_messages); 698 } 699 foreach (Geary.RFC822.Message sub_message in sub_messages) { 700 ConversationMessage attached_message = 701 new ConversationMessage.from_message( 702 sub_message, 703 this.email.load_remote_images().is_certain(), 704 this.contacts, 705 this.config 706 ); 707 connect_message_view_signals(attached_message); 708 attached_message.add_internal_resources(cid_resources); 709 this.sub_messages.add(attached_message); 710 this._attached_messages.add(attached_message); 711 attached_message.load_contacts.begin(this.load_cancellable); 712 yield attached_message.load_message_body( 713 sub_message, this.load_cancellable 714 ); 715 if (!this.is_collapsed) { 716 attached_message.show_message_body(false); 717 } 718 } 719 } 720 721 private void update_email_state() { 722 Gtk.StyleContext style = get_style_context(); 723 724 if (this.is_unread) { 725 style.add_class(UNREAD_CLASS); 726 } else { 727 style.remove_class(UNREAD_CLASS); 728 } 729 730 if (this.is_starred) { 731 style.add_class(STARRED_CLASS); 732 this.star_button.hide(); 733 this.unstar_button.show(); 734 } else { 735 style.remove_class(STARRED_CLASS); 736 this.star_button.show(); 737 this.unstar_button.hide(); 738 } 739 740 update_email_menu(); 741 } 742 743 private void update_email_menu() { 744 if (this.email_menubutton.active) { 745 bool in_base_folder = this.conversation.is_in_base_folder( 746 this.email.id 747 ); 748 bool supports_trash = ( 749 in_base_folder && 750 Application.Controller.does_folder_support_trash( 751 this.conversation.base_folder 752 ) 753 ); 754 bool supports_delete = ( 755 in_base_folder && 756 this.conversation.base_folder is Geary.FolderSupport.Remove 757 ); 758 bool is_shift_down = false; 759 var main = get_toplevel() as Application.MainWindow; 760 if (main != null) { 761 is_shift_down = main.is_shift_down; 762 763 if (!this.shift_handler_installed) { 764 this.shift_handler_installed = true; 765 main.notify["is-shift-down"].connect(on_shift_changed); 766 } 767 } 768 769 string[] blacklist = {}; 770 if (this.is_unread) { 771 blacklist += ( 772 ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." + 773 ConversationListBox.ACTION_MARK_UNREAD 774 ); 775 blacklist += ( 776 ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." + 777 ConversationListBox.ACTION_MARK_UNREAD_DOWN 778 ); 779 } else { 780 blacklist += ( 781 ConversationListBox.EMAIL_ACTION_GROUP_NAME + "." + 782 ConversationListBox.ACTION_MARK_READ 783 ); 784 } 785 786 bool show_trash = !is_shift_down && supports_trash; 787 bool show_delete = !show_trash && supports_delete; 788 GLib.Variant email_target = email.id.to_variant(); 789 GLib.Menu new_model = Util.Gtk.construct_menu( 790 email_menu_template, 791 (menu, submenu, action, item) => { 792 bool accept = true; 793 if (submenu == email_menu_trash_section && !show_trash) { 794 accept = false; 795 } 796 if (submenu == email_menu_delete_section && !show_delete) { 797 accept = false; 798 } 799 if (action != null && !(action in blacklist)) { 800 item.set_action_and_target_value( 801 action, email_target 802 ); 803 } 804 return accept; 805 } 806 ); 807 808 this.email_menubutton.popover.bind_model(new_model, null); 809 this.email_menubutton.popover.grab_focus(); 810 } 811 } 812 813 814 private void update_displayed_attachments() { 815 bool has_attachments = !this.displayed_attachments.is_empty; 816 this.attachments_button.set_visible(has_attachments); 817 var main = get_toplevel() as Application.MainWindow; 818 819 if (has_attachments && main != null) { 820 this.attachments_pane = new Components.AttachmentPane( 821 false, main.attachments 822 ); 823 this.primary_message.body_container.add(this.attachments_pane); 824 825 foreach (var attachment in this.displayed_attachments) { 826 this.attachments_pane.add_attachment( 827 attachment, this.load_cancellable 828 ); 829 } 830 } 831 } 832 833 private void handle_load_failure(GLib.Error error) { 834 this.message_body_state = FAILED; 835 this.primary_message.show_load_error_pane(); 836 837 var main = get_toplevel() as Application.MainWindow; 838 if (main != null) { 839 Geary.AccountInformation account = this.email_store.account.information; 840 main.application.controller.report_problem( 841 new Geary.ServiceProblemReport(account, account.incoming, error) 842 ); 843 } 844 } 845 846 private void handle_load_offline() { 847 this.message_body_state = FAILED; 848 this.primary_message.show_offline_pane(); 849 } 850 851 private inline bool is_online() { 852 return (this.email_store.account.incoming.current_status == CONNECTED); 853 } 854 855 private void activate_email_action(string name) { 856 GLib.ActionGroup? email_actions = get_action_group( 857 ConversationListBox.EMAIL_ACTION_GROUP_NAME 858 ); 859 if (email_actions != null) { 860 email_actions.activate_action(name, this.email.id.to_variant()); 861 } 862 } 863 864 [GtkCallback] 865 private void on_email_menu() { 866 update_email_menu(); 867 } 868 869 private void on_shift_changed() { 870 update_email_menu(); 871 } 872 873 private void on_body_loading_timeout() { 874 this.primary_message.show_loading_pane(); 875 } 876 877 private void on_load_cancelled() { 878 this.body_loading_timeout.reset(); 879 } 880 881 private void on_flag_remote_images() { 882 activate_email_action(ConversationListBox.ACTION_MARK_LOAD_REMOTE); 883 } 884 885 private void on_save_image(string uri, 886 string? alt_text, 887 Geary.Memory.Buffer? content) { 888 var main = get_toplevel() as Application.MainWindow; 889 if (main != null) { 890 if (uri.has_prefix(Components.WebView.CID_URL_PREFIX)) { 891 string cid = uri.substring(Components.WebView.CID_URL_PREFIX.length); 892 try { 893 Geary.Attachment attachment = this.email.get_attachment_by_content_id( 894 cid 895 ); 896 main.attachments.save_attachment.begin( 897 attachment, 898 alt_text, 899 null // XXX no cancellable yet, need UI for it 900 ); 901 } catch (GLib.Error err) { 902 debug("Could not get attachment \"%s\": %s", cid, err.message); 903 } 904 } else if (content != null) { 905 GLib.File source = GLib.File.new_for_uri(uri); 906 // Querying the URL-based file for the display name 907 // results in it being looked up, so just get the basename 908 // from it directly. GIO seems to decode any %-encoded 909 // chars anyway. 910 string? display_name = source.get_basename(); 911 if (Geary.String.is_empty_or_whitespace(display_name)) { 912 display_name = Application.AttachmentManager.untitled_file_name; 913 } 914 main.attachments.save_buffer.begin( 915 display_name, 916 content, 917 null // XXX no cancellable yet, need UI for it 918 ); 919 } 920 } 921 } 922 923 private void on_resource_loaded(string id) { 924 Gee.Iterator<Geary.Attachment> displayed = 925 this.displayed_attachments.iterator(); 926 while (displayed.has_next()) { 927 displayed.next(); 928 Geary.Attachment? attachment = displayed.get(); 929 if (attachment.content_id == id) { 930 displayed.remove(); 931 } 932 } 933 } 934 935 private void on_content_loaded() { 936 bool all_loaded = true; 937 foreach (ConversationMessage message in this) { 938 if (!message.is_content_loaded) { 939 all_loaded = false; 940 break; 941 } 942 } 943 if (all_loaded && this.message_body_state != COMPLETED) { 944 this.message_body_state = COMPLETED; 945 this.message_bodies_loaded_lock.blind_notify(); 946 947 // Update attachments once the web views have finished 948 // loading, since we want to know if any attachments 949 // marked as being inline were actually not displayed 950 // inline, and hence need to be displayed as if they were 951 // attachments. 952 this.update_displayed_attachments(); 953 } 954 } 955 956 private void on_service_status_change() { 957 if (this.message_body_state == FAILED && 958 !this.load_cancellable.is_cancelled() && 959 is_online()) { 960 this.fetch_remote_body.begin(); 961 } 962 } 963 964} 965