conversation-viewer.vala 90.5 KB
Newer Older
1
/* Copyright 2011-2013 Yorba Foundation
Jim Nelson's avatar
Jim Nelson committed
2 3
 *
 * This software is licensed under the GNU Lesser General Public License
4
 * (version 2.1 or later).  See the COPYING file in this distribution.
Jim Nelson's avatar
Jim Nelson committed
5 6
 */

7
public class ConversationViewer : Gtk.Box {
8 9 10 11 12 13
    public const Geary.Email.Field REQUIRED_FIELDS =
        Geary.Email.Field.HEADER
        | Geary.Email.Field.BODY
        | Geary.Email.Field.ORIGINATORS
        | Geary.Email.Field.RECEIVERS
        | Geary.Email.Field.SUBJECT
14
        | Geary.Email.Field.DATE
15
        | Geary.Email.Field.FLAGS
16
        | Geary.Email.Field.PREVIEW;
17
    
18 19 20 21 22 23 24 25 26 27
    private const string[] INLINE_MIME_TYPES = {
        "image/png",
        "image/gif",
        "image/jpeg",
        "image/pjpeg",
        "image/bmp",
        "image/x-icon",
        "image/x-xbitmap",
        "image/x-xbm"
    };
28
    
29
    private const int ATTACHMENT_PREVIEW_SIZE = 50;
Eric Gregory's avatar
Eric Gregory committed
30
    private const int SELECT_CONVERSATION_TIMEOUT_MSEC = 100;
31 32
    private const string MESSAGE_CONTAINER_ID = "message_container";
    private const string SELECTION_COUNTER_ID = "multiple_messages";
Eric Gregory's avatar
Eric Gregory committed
33
    private const string SPINNER_ID = "spinner";
34
    private const string DATA_IMAGE_CLASS = "data_inline_image";
35
    private const int MAX_INLINE_IMAGE_MAJOR_DIM = 1024;
36
    
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
    private enum SearchState {
        // Search/find states.
        NONE,         // Not in search
        FIND,         // Find toolbar
        SEARCH_FOLDER, // Search folder
        
        COUNT;
    }
    
    private enum SearchEvent {
        // User-initated events.
        RESET,
        OPEN_FIND_BAR,
        CLOSE_FIND_BAR,
        ENTER_SEARCH_FOLDER,
        
        COUNT;
    }
    
Eric Gregory's avatar
Eric Gregory committed
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
    // Main display mode.
    private enum DisplayMode {
        NONE = 0,     // Nothing is shown (ni
        CONVERSATION, // Email conversation
        MULTISELECT,  // Message indicating that <> 1 conversations are selected
        LOADING,      // Loading spinner
        
        COUNT;
        
        // Returns the CSS id associated with this mode's DIV container.
        public string get_id() {
            switch (this) {
                case CONVERSATION:
                    return MESSAGE_CONTAINER_ID;
                
                case MULTISELECT:
                    return SELECTION_COUNTER_ID;
                
                case LOADING:
                    return SPINNER_ID;
                
                default:
                    assert_not_reached();
            }
        }
    }
    
83 84
    // Fired when the user clicks a link.
    public signal void link_selected(string link);
85
    
86
    // Fired when the user clicks "reply" in the message menu.
87
    public signal void reply_to_message(Geary.Email message);
88 89

    // Fired when the user clicks "reply all" in the message menu.
90
    public signal void reply_all_message(Geary.Email message);
91 92

    // Fired when the user clicks "forward" in the message menu.
93
    public signal void forward_message(Geary.Email message);
94

95 96 97
    // Fired when the user mark messages.
    public signal void mark_messages(Gee.Collection<Geary.EmailIdentifier> emails,
        Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove);
98

99 100 101 102 103
    // Fired when the user opens an attachment.
    public signal void open_attachment(Geary.Attachment attachment);

    // Fired when the user wants to save one or more attachments.
    public signal void save_attachments(Gee.List<Geary.Attachment> attachment);
Eric Gregory's avatar
Eric Gregory committed
104
    
105
    // Fired when the user wants to save an image buffer to disk
106
    public signal void save_buffer_to_file(string? filename, Geary.Memory.Buffer buffer);
107
    
Eric Gregory's avatar
Eric Gregory committed
108 109 110
    // Fired when the user clicks the edit draft button.
    public signal void edit_draft(Geary.Email message);
    
111
    // List of emails in this view.
112
    public Gee.TreeSet<Geary.Email> messages { get; private set; default = 
113
        new Gee.TreeSet<Geary.Email>(Geary.Email.compare_date_ascending); }
114
    
115
    // The HTML viewer to view the emails.
116
    public ConversationWebView web_view { get; private set; }
117
    
118
    // Current conversation, or null if none.
119
    public Geary.App.Conversation? current_conversation = null;
120
    
121 122 123
    // Overlay consisting of a label in front of a webpage
    private Gtk.Overlay message_overlay;
    
124 125 126
    // Label for displaying overlay messages.
    private Gtk.Label message_overlay_label;
    
127 128
    // Maps emails to their corresponding elements.
    private Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement> email_to_element = new
Eric Gregory's avatar
Eric Gregory committed
129
        Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement>();
130
    
131 132 133 134
    // State machine setup for search/find modes.
    private Geary.State.MachineDescriptor search_machine_desc = new Geary.State.MachineDescriptor(
        "ConversationViewer search", SearchState.NONE, SearchState.COUNT, SearchEvent.COUNT, null, null);
    
135 136
    private string? hover_url = null;
    private Gtk.Menu? context_menu = null;
137
    private Gtk.Menu? message_menu = null;
138
    private Gtk.Menu? attachment_menu = null;
139
    private Gtk.Menu? image_menu = null;
140
    private weak Geary.Folder? current_folder = null;
141
    private weak Geary.SearchFolder? search_folder = null;
142
    private Geary.App.EmailStore? email_store = null;
143
    private Geary.AccountInformation? current_account_information = null;
144
    private ConversationFindBar conversation_find_bar;
145
    private Cancellable cancellable_fetch = new Cancellable();
146
    private Geary.State.Machine fsm;
Eric Gregory's avatar
Eric Gregory committed
147 148
    private DisplayMode display_mode = DisplayMode.NONE;
    private uint select_conversation_timeout_id = 0;
149
    
150
    public ConversationViewer() {
151
        Object(orientation: Gtk.Orientation.VERTICAL, spacing: 0);
152
        
153
        web_view = new ConversationWebView();
154
        
155 156 157 158
        // Setup state machine for search/find states.
        Geary.State.Mapping[] mappings = {
            new Geary.State.Mapping(SearchState.NONE, SearchEvent.RESET, on_reset),
            new Geary.State.Mapping(SearchState.NONE, SearchEvent.OPEN_FIND_BAR, on_open_find_bar),
Eric Gregory's avatar
Eric Gregory committed
159
            new Geary.State.Mapping(SearchState.NONE, SearchEvent.CLOSE_FIND_BAR, on_close_find_bar),
160 161 162 163 164 165 166 167 168
            new Geary.State.Mapping(SearchState.NONE, SearchEvent.ENTER_SEARCH_FOLDER, on_enter_search_folder),
            
            new Geary.State.Mapping(SearchState.FIND, SearchEvent.RESET, on_reset),
            new Geary.State.Mapping(SearchState.FIND, SearchEvent.OPEN_FIND_BAR, Geary.State.nop),
            new Geary.State.Mapping(SearchState.FIND, SearchEvent.CLOSE_FIND_BAR, on_close_find_bar),
            new Geary.State.Mapping(SearchState.FIND, SearchEvent.ENTER_SEARCH_FOLDER, Geary.State.nop),
            
            new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.RESET, on_reset),
            new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.OPEN_FIND_BAR, on_open_find_bar),
Eric Gregory's avatar
Eric Gregory committed
169
            new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.CLOSE_FIND_BAR, on_close_find_bar),
170 171 172 173 174 175
            new Geary.State.Mapping(SearchState.SEARCH_FOLDER, SearchEvent.ENTER_SEARCH_FOLDER, Geary.State.nop),
        };
        
        fsm = new Geary.State.Machine(search_machine_desc, mappings, null);
        fsm.set_logging(false);
        
176 177
        GearyApplication.instance.controller.conversations_selected.connect(on_conversations_selected);
        GearyApplication.instance.controller.folder_selected.connect(on_folder_selected);
178
        GearyApplication.instance.controller.conversation_count_changed.connect(on_conversation_count_changed);
179
        
180
        web_view.hovering_over_link.connect(on_hovering_over_link);
181
        web_view.context_menu.connect(() => { return true; }); // Suppress default context menu.
182 183 184
        web_view.realize.connect( () => { web_view.get_vadjustment().value_changed.connect(mark_read); });
        web_view.size_allocate.connect(mark_read);

185
        web_view.link_selected.connect((link) => { link_selected(link); });
186
        
187 188 189
        Gtk.ScrolledWindow conversation_viewer_scrolled = new Gtk.ScrolledWindow(null, null);
        conversation_viewer_scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC);
        conversation_viewer_scrolled.add(web_view);
190
        
191
        message_overlay = new Gtk.Overlay();
192
        message_overlay.add(conversation_viewer_scrolled);
193
        pack_start(message_overlay);
194 195 196
        
        conversation_find_bar = new ConversationFindBar(web_view);
        conversation_find_bar.no_show_all = true;
197
        conversation_find_bar.close.connect(() => { fsm.issue(SearchEvent.CLOSE_FIND_BAR); });
198 199
        
        pack_start(conversation_find_bar, false);
200 201
    }
    
202 203 204 205
    public Geary.Email? get_last_message() {
        return messages.is_empty ? null : messages.last();
    }
    
206
    // Removes all displayed e-mails from the view.
207
    private void clear(Geary.Folder? new_folder, Geary.AccountInformation? account_information) {
208 209 210 211 212 213 214 215 216 217
        // Remove all messages from DOM.
        try {
            foreach (WebKit.DOM.HTMLElement element in email_to_element.values) {
                if (element.get_parent_element() != null)
                    element.get_parent_element().remove_child(element);
            }
        } catch (Error e) {
            debug("Error clearing message viewer: %s", e.message);
        }
        email_to_element.clear();
218
        messages.clear();
219
        
220
        current_account_information = account_information;
221 222
    }
    
223 224 225 226 227
    // Converts an email ID into HTML ID used by the <div> for the email.
    private string get_div_id(Geary.EmailIdentifier id) {
        return "message_%s".printf(id.to_string());
    }
    
228 229
    private void show_special_message(string msg) {
        // Remove any messages and hide the message container, then show the special message.
230
        clear(current_folder, current_account_information);
Eric Gregory's avatar
Eric Gregory committed
231 232
        set_mode(DisplayMode.MULTISELECT);
        
233 234 235
        try {
            // Update the counter's count.
            WebKit.DOM.HTMLElement counter =
236
                web_view.get_dom_document().get_element_by_id("selection_counter") as WebKit.DOM.HTMLElement;
237
            counter.set_inner_html(msg);
238
        } catch (Error e) {
239
            debug("Error updating counter: %s", e.message);
240 241 242
        }
    }
    
243 244 245 246 247 248 249 250 251 252 253 254 255 256 257
    private void hide_special_message() {
        if (display_mode != DisplayMode.MULTISELECT)
            return;
        
        clear(current_folder, current_account_information);
        set_mode(DisplayMode.NONE);
    }
    
    private void show_multiple_selected(uint selected_count) {
        if (selected_count == 0)
            show_special_message(_("No conversations selected."));
        else
            show_special_message(_("%u conversations selected.").printf(selected_count));
    }
    
258
    private void on_folder_selected(Geary.Folder? folder) {
259 260
        hide_special_message();
        
261
        current_folder = folder;
262
        email_store = (current_folder == null ? null : new Geary.App.EmailStore(current_folder.account));
263 264
        fsm.issue(SearchEvent.RESET);
        
265 266 267 268
        if (folder == null) {
            clear(null, null);
            current_conversation = null;
        }
269 270 271 272 273 274 275
        
        if (current_folder is Geary.SearchFolder) {
            fsm.issue(SearchEvent.ENTER_SEARCH_FOLDER);
            web_view.allow_collapsing(false);
        } else {
            web_view.allow_collapsing(true);
        }
276 277
    }
    
278 279 280 281 282 283 284 285 286
    private void on_conversation_count_changed(int count) {
        if (count != 0)
            hide_special_message();
        else if (current_folder is Geary.SearchFolder)
            show_special_message(_("No search results found."));
        else
            show_special_message(_("No conversations in folder."));
    }
    
287
    private void on_conversations_selected(Gee.Set<Geary.App.Conversation>? conversations,
288 289 290 291 292 293
        Geary.Folder? current_folder) {
        cancel_load();
        if (current_conversation != null) {
            current_conversation.appended.disconnect(on_conversation_appended);
            current_conversation.trimmed.disconnect(on_conversation_trimmed);
            current_conversation.email_flags_changed.disconnect(update_flags);
294
            current_conversation = null;
295 296
        }
        
297 298 299 300 301 302 303 304 305
        // Disable message buttons until conversation loads.
        GearyApplication.instance.controller.enable_message_buttons(false);
        
        if (conversations == null || conversations.size == 0 || current_folder == null) {
            show_multiple_selected(0);
            return;
        }
        
        if (conversations.size == 1) {
306 307 308
            clear(current_folder, current_folder.account.information);
            web_view.scroll_reset();
            
Eric Gregory's avatar
Eric Gregory committed
309 310 311 312 313 314 315 316 317 318 319
            if (select_conversation_timeout_id != 0)
                Source.remove(select_conversation_timeout_id);
            
            // If the load is taking too long, display a spinner.
            select_conversation_timeout_id = Timeout.add(SELECT_CONVERSATION_TIMEOUT_MSEC, () => {
                if (select_conversation_timeout_id != 0)
                    set_mode(DisplayMode.LOADING);
                
                return false;
            });
            
320 321 322 323 324 325 326 327
            current_conversation = Geary.Collection.get_first(conversations);
            
            select_conversation_async.begin(current_conversation, current_folder,
                on_select_conversation_completed);
            
            current_conversation.appended.connect(on_conversation_appended);
            current_conversation.trimmed.connect(on_conversation_trimmed);
            current_conversation.email_flags_changed.connect(update_flags);
328 329 330 331 332 333
            
            GearyApplication.instance.controller.enable_message_buttons(true);
        } else if (conversations.size > 1) {
            show_multiple_selected(conversations.size);
            
            GearyApplication.instance.controller.enable_multiple_message_buttons();
334 335 336
        }
    }
    
337
    private async void select_conversation_async(Geary.App.Conversation conversation,
338
        Geary.Folder current_folder) throws Error {
339 340 341
        // Load this once, so if it's cancelled, we cancel the WHOLE load.
        Cancellable cancellable = cancellable_fetch;
        
342
        // Fetch full messages.
343 344 345
        Gee.Collection<Geary.Email>? messages_to_add
            = yield list_full_messages_async(conversation.get_emails(
            Geary.App.Conversation.Ordering.DATE_ASCENDING), cancellable);
346 347
        
        // Add messages.
348 349 350 351
        if (messages_to_add != null) {
            foreach (Geary.Email email in messages_to_add)
                add_message(email, conversation.is_in_current_folder(email.id));
        }
352
        
353 354 355 356 357 358
        if (current_folder is Geary.SearchFolder) {
            yield highlight_search_terms();
        } else {
            unhide_last_email();
            compress_emails();
        }
359 360 361 362 363 364 365 366 367 368 369 370
    }
    
    private void on_select_conversation_completed(Object? source, AsyncResult result) {
        try {
            select_conversation_async.end(result);
            
            mark_read();
        } catch (Error err) {
            debug("Unable to select conversation: %s", err.message);
        }
    }
    
371 372 373
    private void on_search_text_changed(string? query) {
        if (query != null)
            highlight_search_terms.begin();
374 375 376 377 378 379
    }
    
    private async void highlight_search_terms() {
        if (search_folder == null)
            return;
        
380 381 382
        // Remove existing highlights.
        web_view.unmark_text_matches();
        
383 384 385 386 387 388
        // List all IDs of emails we're viewing.
        Gee.Collection<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
        foreach (Geary.Email email in messages)
            ids.add(email.id);
        
        try {
389
            Gee.Collection<string>? search_matches = yield search_folder.get_search_matches_async(
390 391
                ids, cancellable_fetch);
            
392 393 394 395 396 397 398 399 400 401 402 403
            // Webkit's highlighting is ... weird.  In order to actually see
            // all the highlighting you're applying, it seems necessary to
            // start with the shortest string and work up.  If you don't, it
            // seems that shorter strings will overwrite longer ones, and
            // you're left with incomplete highlighting.
            Gee.ArrayList<string> ordered_matches = new Gee.ArrayList<string>();
            if (search_matches != null)
                ordered_matches.add_all(search_matches);
            ordered_matches.sort((a, b) => a.length - b.length);
            
            foreach(string match in ordered_matches)
                web_view.mark_text_matches(match, false, 0);
404 405 406 407 408 409 410
        } catch (Error e) {
            debug("Error highlighting search results: %s", e.message);
        }
        
        web_view.set_highlight_text_matches(true);
    }
    
411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434
    // Given some emails, fetch the full versions with all required fields.
    private async Gee.Collection<Geary.Email>? list_full_messages_async(
        Gee.Collection<Geary.Email> emails, Cancellable? cancellable) throws Error {
        Geary.Email.Field required_fields = ConversationViewer.REQUIRED_FIELDS |
            Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
        
        Gee.ArrayList<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
        foreach (Geary.Email email in emails)
            ids.add(email.id);
        
        return yield email_store.list_email_by_sparse_id_async(ids, required_fields,
            Geary.Folder.ListFlags.NONE, cancellable);
    }
    
    // Given an email, fetch the full version with all required fields.
    private async Geary.Email fetch_full_message_async(Geary.Email email,
        Cancellable? cancellable) throws Error {
        Geary.Email.Field required_fields = ConversationViewer.REQUIRED_FIELDS |
            Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
        
        return yield email_store.fetch_email_async(email.id, required_fields,
            Geary.Folder.ListFlags.NONE, cancellable);
    }
    
435 436 437 438 439 440 441 442
    // Cancels the current message load, if in progress.
    private void cancel_load() {
        Cancellable old_cancellable = cancellable_fetch;
        cancellable_fetch = new Cancellable();
        
        old_cancellable.cancel();
    }
    
443 444
    private void on_conversation_appended(Geary.App.Conversation conversation, Geary.Email email) {
        on_conversation_appended_async.begin(conversation, email, on_conversation_appended_complete);
445 446
    }
    
447 448 449 450
    private async void on_conversation_appended_async(Geary.App.Conversation conversation,
        Geary.Email email) throws Error {
        add_message(yield fetch_full_message_async(email, cancellable_fetch),
            conversation.is_in_current_folder(email.id));
451 452 453 454 455 456 457 458 459 460 461 462 463 464
    }
    
    private void on_conversation_appended_complete(Object? source, AsyncResult result) {
        try {
            on_conversation_appended_async.end(result);
        } catch (Error err) {
            debug("Unable to append email to conversation: %s", err.message);
        }
    }
    
    private void on_conversation_trimmed(Geary.Email email) {
        remove_message(email);
    }
    
465
    private void add_message(Geary.Email email, bool is_in_folder) {
466
        // Make sure the message container is showing and the multi-message counter hidden.
Eric Gregory's avatar
Eric Gregory committed
467 468
        set_mode(DisplayMode.CONVERSATION);
        
469 470
        if (messages.contains(email))
            return;
471
        
472
        string message_id = get_div_id(email.id);
473
        
474
        WebKit.DOM.Node insert_before = web_view.container.get_last_child();
475
        
476 477 478
        messages.add(email);
        Geary.Email? higher = messages.higher(email);
        if (higher != null)
479
            insert_before = web_view.get_dom_document().get_element_by_id(get_div_id(higher.id));
480 481
        
        WebKit.DOM.HTMLElement div_message;
482
        
483
        try {
484
            div_message = make_email_div();
485
            div_message.set_attribute("id", message_id);
486
            web_view.container.insert_before(div_message, insert_before);
487 488 489
            if (email.is_unread() == Geary.Trillian.FALSE) {
                div_message.get_class_list().add("hide");
            }
490 491 492
            if (email.from.contains_normalized(current_account_information.email)) {
                div_message.get_class_list().add("sent");
            }
493 494
        } catch (Error setup_error) {
            warning("Error setting up webkit: %s", setup_error.message);
495 496
            
            return;
497
        }
498 499
        email_to_element.set(email.id, div_message);
        
500 501 502 503 504 505
        bool remote_images = false;
        try {
            set_message_html(email.get_message(), div_message, out remote_images);
        } catch (Error error) {
            warning("Error getting message from email: %s", error.message);
        }
506
        
507
        if (remote_images) {
508 509 510 511 512 513
            Geary.Contact contact = current_folder.account.get_contact_store().get_by_rfc822(
                email.get_primary_originator());
            bool always_load = contact != null && contact.always_load_remote_images();
            
            if (always_load || email.load_remote_images().is_certain()) {
                show_images_email(div_message, false);
514 515 516 517 518 519
            } else {
                WebKit.DOM.HTMLElement remote_images_bar =
                    Util.DOM.select(div_message, ".remote_images");
                try {
                    ((WebKit.DOM.Element) remote_images_bar).get_class_list().add("show");
                    remote_images_bar.set_inner_html("""%s %s
520 521
                        <input type="button" value="%s" class="show_images" />
                        <input type="button" value="%s" class="show_from" />""".printf(
522
                        remote_images_bar.get_inner_html(),
523 524
                        _("This message contains remote images."), _("Show Images"),
                        _("Always Show From Sender")));
525 526 527
                } catch (Error error) {
                    warning("Error showing remote images bar: %s", error.message);
                }
528
            }
529
        }
530
        
531 532 533 534
        // Set attachment icon and add the attachments container if there are displayed attachments.
        int displayed = displayed_attachments(email);
        set_attachment_icon(div_message, displayed > 0);
        if (displayed > 0) {
535
            insert_attachments(div_message, email.attachments);
536
        }
537 538 539 540
        
        // Add classes according to the state of the email.
        update_flags(email);
        
Eric Gregory's avatar
Eric Gregory committed
541
        // Edit draft button for drafts folder.
542
        if (in_drafts_folder() && is_in_folder) {
Eric Gregory's avatar
Eric Gregory committed
543 544 545 546 547 548 549 550 551 552 553
            WebKit.DOM.HTMLElement draft_edit_container = Util.DOM.select(div_message, ".draft_edit");
            WebKit.DOM.HTMLElement draft_edit_button =
                Util.DOM.select(div_message, ".draft_edit_button");
            try {
                draft_edit_container.set_attribute("style", "display:block");
                draft_edit_button.set_inner_html(_("Edit Draft"));
            } catch (Error e) {
                warning("Error setting draft button: %s", e.message);
            }
        }
        
554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570
        // Add animation class after other classes set, to avoid initial animation.
        Idle.add(() => {
            try {
                div_message.get_class_list().add("animate");
            } catch (Error error) {
                debug("Could not enable animation class: %s", error.message);
            }
            return false;
        });
        
        // Attach to the click events for hiding/showing quotes, opening the menu, and so forth.
        bind_event(web_view, ".email", "contextmenu", (Callback) on_context_menu, this);
        bind_event(web_view, ".quote_container > .hider", "click", (Callback) on_hide_quote_clicked);
        bind_event(web_view, ".quote_container > .shower", "click", (Callback) on_show_quote_clicked);
        bind_event(web_view, ".email_container .menu", "click", (Callback) on_menu_clicked, this);
        bind_event(web_view, ".email_container .starred", "click", (Callback) on_unstar_clicked, this);
        bind_event(web_view, ".email_container .unstarred", "click", (Callback) on_star_clicked, this);
Eric Gregory's avatar
Eric Gregory committed
571
        bind_event(web_view, ".email_container .draft_edit .button", "click", (Callback) on_draft_edit_menu, this);
572 573 574 575 576
        bind_event(web_view, ".header .field .value", "click", (Callback) on_value_clicked, this);
        bind_event(web_view, ".email:not(:only-of-type) .header_container, .email .email .header_container","click", (Callback) on_body_toggle_clicked, this);
        bind_event(web_view, ".email .compressed_note", "click", (Callback) on_body_toggle_clicked, this);
        bind_event(web_view, ".attachment_container .attachment", "click", (Callback) on_attachment_clicked, this);
        bind_event(web_view, ".attachment_container .attachment", "contextmenu", (Callback) on_attachment_menu, this);
577
        bind_event(web_view, "." + DATA_IMAGE_CLASS, "contextmenu", (Callback) on_data_image_menu, this);
578
        bind_event(web_view, ".remote_images .show_images", "click", (Callback) on_show_images, this);
579
        bind_event(web_view, ".remote_images .show_from", "click", (Callback) on_show_images_from, this);
580
        bind_event(web_view, ".remote_images .close_show_images", "click", (Callback) on_close_show_images, this);
581
        bind_event(web_view, ".body a", "click", (Callback) on_link_clicked, this);
582 583 584 585
        
        // Update the search results
        if (conversation_find_bar.visible)
            conversation_find_bar.commence_search();
586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635
    }
    
    private WebKit.DOM.HTMLElement make_email_div() {
        // The HTML is like this:
        // <div id="$MESSAGE_ID" class="email">
        //     <div class="geary_spacer"></div>
        //     <div class="email_container">
        //         <div class="button_bar">
        //             <div class="starred button"><img class="icon" /></div>
        //             <div class="unstarred button"><img class="icon" /></div>
        //             <div class="menu button"><img class="icon" /></div>
        //         </div>
        //         <table>$HEADER</table>
        //         <span>
        //             $EMAIL_BODY
        //
        //             <div class="signature">$SIGNATURE</div>
        //
        //             <div class="quote_container controllable">
        //                 <div class="shower">[show]</div>
        //                 <div class="hider">[hide]</div>
        //                 <div class="quote">$QUOTE</div>
        //             </div>
        //         </span>
        //     </div>
        // </div>
        return Util.DOM.clone_select(web_view.get_dom_document(), "#email_template");
    }
    
    private void set_message_html(Geary.RFC822.Message message, WebKit.DOM.HTMLElement div_message,
        out bool remote_images) {
        string header = "";
        WebKit.DOM.HTMLElement div_email_container = Util.DOM.select(div_message, "div.email_container");
        
        insert_header_address(ref header, _("From:"), message.from, true);
        
        if (message.to != null)
             insert_header_address(ref header, _("To:"), message.to);
        
        if (message.cc != null)
            insert_header_address(ref header, _("Cc:"), message.cc);
        
        if (message.bcc != null)
            insert_header_address(ref header, _("Bcc:"), message.bcc);
        
        if (message.subject != null)
            insert_header(ref header, _("Subject:"), message.subject.value);
        
        if (message.date != null)
            insert_header_date(ref header, _("Date:"), message.date.value, true);
636 637

        // Add the avatar.
638
        Geary.RFC822.MailboxAddress? primary = message.sender;
639 640 641 642 643 644 645
        if (primary != null) {
            try {
                WebKit.DOM.HTMLImageElement icon = Util.DOM.select(div_message, ".avatar")
                    as WebKit.DOM.HTMLImageElement;
                icon.set_attribute("src",
                    Gravatar.get_image_uri(primary, Gravatar.Default.MYSTERY_MAN, 48));
            } catch (Error error) {
646
                debug("Failed to inject avatar URL: %s", error.message);
647
            }
648
        }
649
        
650 651
        // Insert the preview text.
        try {
652 653
            WebKit.DOM.HTMLElement preview =
                Util.DOM.select(div_message, ".header_container .preview");
654
            string preview_str = message.get_preview();
655 656 657 658 659 660
            if (preview_str.length == Geary.Email.MAX_PREVIEW_BYTES) {
                preview_str += "…";
            }
            preview.set_inner_text(Geary.String.reduce_whitespace(preview_str));
        } catch (Error error) {
            debug("Failed to add preview text: %s", error.message);
661 662
        }

663
        string body_text = "";
664
        remote_images = false;
665
        try {
666
            body_text = message.get_body(true, inline_image_replacer) ?? "";
667
            body_text = insert_html_markup(body_text, message, out remote_images);
668
        } catch (Error err) {
669
            debug("Could not get message text. %s", err.message);
670
        }
671

672
        // Graft header and email body into the email container.
673
        try {
674 675
            WebKit.DOM.HTMLElement table_header =
                Util.DOM.select(div_email_container, ".header_container .header");
676 677
            table_header.set_inner_html(header);
            
678
            WebKit.DOM.HTMLElement span_body = Util.DOM.select(div_email_container, ".body");
679
            span_body.set_inner_html(body_text);
680 681 682
        } catch (Error html_error) {
            warning("Error setting HTML for message: %s", html_error.message);
        }
683

684 685 686 687 688
        // Look for any attached emails
        Gee.List<Geary.RFC822.Message> sub_messages = message.get_sub_messages();
        foreach (Geary.RFC822.Message sub_message in sub_messages) {
            WebKit.DOM.HTMLElement div_sub_message = make_email_div();
            bool sub_remote_images = false;
689
            try {
690 691 692 693 694 695
                div_sub_message.set_attribute("id", "");
                div_sub_message.get_class_list().add("read");
                div_sub_message.get_class_list().add("hide");
                div_message.append_child(div_sub_message);
                set_message_html(sub_message, div_sub_message, out sub_remote_images);
                remote_images = remote_images || sub_remote_images;
696
            } catch (Error error) {
697
                debug("Error adding message: %s", error.message);
698
            }
699
        }
700
    }
701
    
702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719
    private static bool is_content_type_supported_inline(Geary.Mime.ContentType content_type) {
        foreach (string mime_type in INLINE_MIME_TYPES) {
            try {
                if (content_type.is_mime_type(mime_type))
                    return true;
            } catch (Error err) {
                debug("Unable to compare MIME type %s: %s", mime_type, err.message);
            }
        }
        
        return false;
    }
    
    private static string? inline_image_replacer(string filename, Geary.Mime.ContentType? content_type,
        Geary.Mime.ContentDisposition? disposition, Geary.Memory.Buffer buffer) {
        if (content_type == null || !is_content_type_supported_inline(content_type)) {
            debug("Not displaying %s inline: unsupported Content-Type", content_type.to_string());
            
720
            return null;
721
        }
722
        
723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749
        // Even if the image doesn't need to be rotated, there's a win here: by reducing the size
        // of the image at load time, it reduces the amount of work that has to be done to insert
        // it into the HTML and then decoded and displayed for the user ... note that we currently
        // have the doucment set up to reduce the size of the image to fit in the viewport, and a
        // scaled load-and-deode is always faster than load followed by scale.
        Geary.Memory.Buffer rotated_image = buffer;
        try {
            Gdk.PixbufLoader loader = new Gdk.PixbufLoader();
            loader.size_prepared.connect(on_inline_image_size_prepared);
            
            Geary.Memory.UnownedBytesBuffer? unowned_buffer = buffer as Geary.Memory.UnownedBytesBuffer;
            if (unowned_buffer != null)
                loader.write(unowned_buffer.to_unowned_uint8_array());
            else
                loader.write(buffer.get_uint8_array());
            loader.close();
            
            Gdk.Pixbuf? pixbuf = loader.get_pixbuf();
            if (pixbuf != null) {
                pixbuf = pixbuf.apply_embedded_orientation();
                
                // trade-off here between how long it takes to compress the data and how long it
                // takes to turn it into Base-64 (coupled with how long it takes WebKit to then
                // Base-64 decode and uncompress it)
                uint8[] image_data;
                pixbuf.save_to_buffer(out image_data, "png", "compression", "5");
                
750 751 752
                // Save length before transferring ownership (which frees the array)
                int image_length = image_data.length;
                rotated_image = new Geary.Memory.ByteBuffer.take((owned) image_data, image_length);
753 754 755 756 757
            }
        } catch (Error err) {
            debug("Unable to load and rotate image %s for display: %s", filename, err.message);
        }
        
758
        return "<img alt=\"%s\" class=\"%s\" src=\"%s\" />".printf(
759
            filename, DATA_IMAGE_CLASS, assemble_data_uri(content_type.get_mime_type(), rotated_image));
760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789
    }
    
    // Called by Gdk.PixbufLoader when the image's size has been determined but not loaded yet ...
    // this allows us to load the image scaled down, for better performance when manipulating and
    // writing the data URI for WebKit
    private static void on_inline_image_size_prepared(Gdk.PixbufLoader loader, int width, int height) {
        // easier to use as local variable than have the const listed everywhere in the code
        // IN ALL SCREAMING CAPS
        int scale = MAX_INLINE_IMAGE_MAJOR_DIM;
        
        // Borrowed liberally from Shotwell's Dimensions.get_scaled() method
        
        // check for existing fit
        if (width <= scale && height <= scale)
            return;
        
        int adj_width, adj_height;
        if ((width - scale) > (height - scale)) {
            double aspect = (double) scale / (double) width;
            
            adj_width = scale;
            adj_height = (int) Math.round((double) height * aspect);
        } else {
            double aspect = (double) scale / (double) height;
            
            adj_width = (int) Math.round((double) width * aspect);
            adj_height = scale;
        }
        
        loader.set_size(adj_width, adj_height);
790 791
    }
    
792
    private void unhide_last_email() {
793
        WebKit.DOM.HTMLElement last_email = (WebKit.DOM.HTMLElement) web_view.container.get_last_child().previous_sibling;
794 795 796 797 798 799 800 801 802
        if (last_email != null) {
            WebKit.DOM.DOMTokenList class_list = last_email.get_class_list();
            try {
                class_list.remove("hide");
            } catch (Error error) {
                // Expected, if not hidden
            }
        }
    }
803
    
804
    private void compress_emails() {
805 806 807
        if (messages.size == 0)
            return;
        
808
        WebKit.DOM.Document document = web_view.get_dom_document();
809 810 811
        WebKit.DOM.Element first_compressed = null, prev_message = null,
            curr_message = document.get_element_by_id("message_container").get_first_element_child(),
            next_message = curr_message.next_element_sibling;
812
        int compress_count = 0;
813 814
        bool prev_hidden = false, curr_hidden = false, next_hidden = false;
        try {
815 816
            next_hidden = curr_message.get_class_list().contains("hide");
            // The first step of the loop is to advance the hidden statuses.
817 818 819
        } catch (Error error) {
            debug("Error checking hidden status: %s", error.message);
        }
820
        
821 822
        // Note that next_message = span#placeholder when current_message is last in conversation.
        while (next_message != null) {
823
            try {
824 825
                prev_hidden = curr_hidden;
                curr_hidden = next_hidden;
826 827 828 829
                next_hidden = next_message.get_class_list().contains("hide");
                if (curr_hidden && prev_hidden && next_hidden ||
                    curr_message.get_class_list().contains("compressed")) {
                    curr_message.get_class_list().add("compressed");
830
                    compress_count += 1;
831
                    if (first_compressed == null)
832
                        first_compressed = curr_message;
833 834
                } else if (compress_count > 0) {
                    if (compress_count == 1) {
835
                        prev_message.get_class_list().remove("compressed");
836 837
                    } else {
                        WebKit.DOM.HTMLElement span =
838
                            first_compressed.first_element_child.first_element_child
839 840 841 842
                            as WebKit.DOM.HTMLElement;
                        span.set_inner_html(_("%u read messages").printf(compress_count));
                        // We need to set the display to get an accurate offset_height
                        span.set_attribute("style", "display:inline-block;");
843
                        span.set_attribute("style", "display:inline-block; top:%ipx".printf(
844
                            (int) (curr_message.offset_top - first_compressed.offset_top
845
                            - span.offset_height) / 2));
846 847
                    }
                    compress_count = 0;
848
                    first_compressed = null;
849 850 851 852
                }
            } catch (Error error) {
                debug("Error compressing emails: %s", error.message);
            }
853 854 855
            prev_message = curr_message;
            curr_message = next_message;
            next_message = curr_message.next_element_sibling;
856 857 858
        }
    }
    
859
    private void decompress_emails(WebKit.DOM.Element email_element) {
860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875
        WebKit.DOM.Element iter_element = email_element;
        try {
            while ((iter_element != null) && iter_element.get_class_list().contains("compressed")) {
                iter_element.get_class_list().remove("compressed");
                iter_element.first_element_child.first_element_child.set_attribute("style", "display:none");
                iter_element = iter_element.previous_element_sibling;
            }
        } catch (Error error) {
            debug("Error decompressing emails: %s", error.message);
        }
        iter_element = email_element.next_element_sibling;
        try {
            while ((iter_element != null) && iter_element.get_class_list().contains("compressed")) {
                iter_element.get_class_list().remove("compressed");
                iter_element.first_element_child.first_element_child.set_attribute("style", "display:none");
                iter_element = iter_element.next_element_sibling;
876
            }
877 878
        } catch (Error error) {
            debug("Error decompressing emails: %s", error.message);
879 880
        }
    }
881
    
882 883
    private Geary.Email? get_email_from_element(WebKit.DOM.Element element) {
        // First get the email container.
884
        WebKit.DOM.Element? email_element = null;
885 886 887 888 889 890 891 892 893 894
        try {
            if (element.webkit_matches_selector(".email")) {
                email_element = element;
            } else {
                email_element = closest_ancestor(element, ".email");
            }
        } catch (Error error) {
            debug("Failed to find div.email from element: %s", error.message);
            return null;
        }
895 896 897 898
        
        if (email_element == null)
            return null;
        
899 900 901 902 903 904 905 906
        // Next find the ID in the email-to-element map.
        Geary.EmailIdentifier? email_id = null;
        foreach (var entry in email_to_element.entries) {
            if (entry.value == email_element) {
                email_id = entry.key;
                break;
            }
        }
907 908 909
        
        if (email_id == null)
            return null;
910 911 912

        // Now lookup the email in our messages set.
        foreach (Geary.Email message in messages) {
913
            if (message.id == email_id)
914 915
                return message;
        }
916
        
917 918
        return null;
    }
919 920 921 922 923 924 925
    
    private Geary.Attachment? get_attachment_from_element(WebKit.DOM.Element element) {
        Geary.Email? email = get_email_from_element(element);
        if (email == null)
            return null;
         
        try {
926
            return email.get_attachment(element.get_attribute("data-attachment-id"));
927 928 929 930
        } catch (Geary.EngineError err) {
            return null;
        }
    }
931

932 933 934 935 936 937 938 939 940
    private void set_attachment_icon(WebKit.DOM.HTMLElement container, bool show) {
        try {
            WebKit.DOM.DOMTokenList class_list = container.get_class_list();
            Util.DOM.toggle_class(class_list, "attachment", show);
        } catch (Error e) {
            warning("Failed to set attachment icon: %s", e.message);
        }
    }

941
    private void update_flags(Geary.Email email) {
942
        // Nothing to do if we aren't displaying this email.
943
        if (!email_to_element.has_key(email.id)) {
944 945 946
            return;
        }

947
        Geary.EmailFlags flags = email.email_flags;
948
        
949
        // Update the flags in our message set.
950
        foreach (Geary.Email message in messages) {
Eric Gregory's avatar
Eric Gregory committed
951
            if (message.id.equal_to(email.id)) {
952 953 954 955
                message.set_flags(flags);
                break;
            }
        }
956
        
957
        // Get the email div and update its state.
958
        WebKit.DOM.HTMLElement container = email_to_element.get(email.id);
959 960
        try {
            WebKit.DOM.DOMTokenList class_list = container.get_class_list();
961 962
            Util.DOM.toggle_class(class_list, "read", !flags.is_unread());
            Util.DOM.toggle_class(class_list, "starred", flags.is_flagged());
963 964 965 966
        } catch (Error e) {
            warning("Failed to set classes on .email: %s", e.message);
        }
    }
967

968
    private static void on_context_menu(WebKit.DOM.Element clicked_element, WebKit.DOM.Event event,
969 970
        ConversationViewer conversation_viewer) {
        Geary.Email email = conversation_viewer.get_email_from_element(clicked_element);
971
        if (email != null)
972
            conversation_viewer.show_context_menu(email, clicked_element);
973 974
    }
    
975 976
    private void show_context_menu(Geary.Email email, WebKit.DOM.Element clicked_element) {
        context_menu = build_context_menu(email, clicked_element);
977
        context_menu.show_all();
978
        context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
979 980
    }
    
981
    private Gtk.Menu build_context_menu(Geary.Email email, WebKit.DOM.Element clicked_element) {
982 983
        Gtk.Menu menu = new Gtk.Menu();
        
984
        if (web_view.can_copy_clipboard()) {
985 986 987 988 989 990 991
            // Add a menu item for copying the current selection.
            Gtk.MenuItem item = new Gtk.MenuItem.with_mnemonic(_("_Copy"));
            item.activate.connect(on_copy_text);
            menu.append(item);
        }
        
        if (hover_url != null) {
992
            if (hover_url.has_prefix(Geary.ComposedEmail.MAILTO_SCHEME)) {
993
                // Add a menu item for copying the address.
994
                Gtk.MenuItem item = new Gtk.MenuItem.with_mnemonic(_("Copy _Email Address"));
995
                item.activate.connect(on_copy_email_address);
996 997 998 999 1000
                menu.append(item);
            } else {
                // Add a menu item for copying the link.
                Gtk.MenuItem item = new Gtk.MenuItem.with_mnemonic(_("Copy _Link"));
                item.activate.connect(on_copy_link);
1001
                menu.append(item);
1002 1003 1004
            }
        }
        
1005 1006 1007 1008 1009 1010 1011
        // Select message.
        if (!is_hidden_email(clicked_element)) {
            Gtk.MenuItem select_message_item = new Gtk.MenuItem.with_mnemonic(_("Select _Message"));
            select_message_item.activate.connect(() => {on_select_message(clicked_element);});
            menu.append(select_message_item);
        }
        
1012 1013 1014 1015 1016
        // Select all.
        Gtk.MenuItem select_all_item = new Gtk.MenuItem.with_mnemonic(_("Select _All"));
        select_all_item.activate.connect(on_select_all);
        menu.append(select_all_item);
        
1017 1018 1019 1020 1021 1022 1023
        // Inspect.
        if (Args.inspector) {
            Gtk.MenuItem inspect_item = new Gtk.MenuItem.with_mnemonic(_("_Inspect"));
            inspect_item.activate.connect(() => {web_view.web_inspector.inspect_node(clicked_element);});
            menu.append(inspect_item);
        }
        
1024
        return menu;
1025 1026
<