<commit_before>/* Copyright 2011-2013 Yorba Foundation
 *
 * This software is licensed under the GNU Lesser General Public License
 * (version 2.1 or later).  See the COPYING file in this distribution.
 */

public class ConversationViewer : Gtk.Box {
    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
        | Geary.Email.Field.DATE
        | Geary.Email.Field.FLAGS
        | Geary.Email.Field.PREVIEW;
    
    private const int ATTACHMENT_PREVIEW_SIZE = 50;
    private const string MESSAGE_CONTAINER_ID = "message_container";
    private const string SELECTION_COUNTER_ID = "multiple_messages";
    
    // Fired when the user clicks a link.
    public signal void link_selected(string link);
    
    // Fired when the user clicks "reply" in the message menu.
    public signal void reply_to_message(Geary.Email message);

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

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

    // Fired when the user marks a message.
    public signal void mark_message(Geary.Email message, Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove);

    // 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);

    // List of emails in this view.
    public Gee.TreeSet<Geary.Email> messages { get; private set; default = 
        new Geary.Collection.FixedTreeSet<Geary.Email>(Geary.Email.compare_date_ascending); }
    
    // The HTML viewer to view the emails.
    public ConversationWebView web_view { get; private set; }
    
    // Label for displaying overlay messages.
    private Gtk.Label message_overlay_label;
    
    // Maps emails to their corresponding elements.
    private Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement> email_to_element = new
        Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement>();
    
    private string? hover_url = null;
    private Gtk.Menu? context_menu = null;
    private Gtk.Menu? message_menu = null;
    private Gtk.Menu? attachment_menu = null;
    private weak Geary.Folder? current_folder = null;
    private Geary.AccountInformation? current_account_information = null;
    private ConversationFindBar conversation_find_bar;
    
    public ConversationViewer() {
        Object(orientation: Gtk.Orientation.VERTICAL, spacing: 0);
        
        web_view = new ConversationWebView();
        
        web_view.hovering_over_link.connect(on_hovering_over_link);
        web_view.context_menu.connect(() => { return true; }); // Suppress default context menu.
        web_view.realize.connect( () => { web_view.get_vadjustment().value_changed.connect(mark_read); });
        web_view.size_allocate.connect(mark_read);

        web_view.link_selected.connect((link) => { link_selected(link); });
        
        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);
        
        Gtk.Overlay message_overlay = new Gtk.Overlay();
        message_overlay.add(conversation_viewer_scrolled);
        
        message_overlay_label = new Gtk.Label(null);
        message_overlay_label.ellipsize = Pango.EllipsizeMode.MIDDLE;
        message_overlay_label.halign = Gtk.Align.START;
        message_overlay_label.valign = Gtk.Align.END;
        message_overlay.add_overlay(message_overlay_label);
        
        pack_start(message_overlay);
        
        conversation_find_bar = new ConversationFindBar(web_view);
        conversation_find_bar.no_show_all = true;
        
        pack_start(conversation_find_bar, false);
    }
    
    public Geary.Email? get_last_message() {
        return messages.is_empty ? null : messages.last();
    }
    
    // Removes all displayed e-mails from the view.
    public void clear(Geary.Folder? new_folder, Geary.AccountInformation? account_information) {
        // 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();
        messages.clear();
        
        current_folder = new_folder;
        current_account_information = account_information;
        
        if (conversation_find_bar.visible)
            conversation_find_bar.hide();
    }
    
    // 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());
    }
    
    public void show_multiple_selected(uint selected_count) {
        // Remove any messages and hide the message container, then show the counter.
        clear(current_folder, current_account_information);
        try {
            web_view.hide_element_by_id(MESSAGE_CONTAINER_ID);
            web_view.show_element_by_id(SELECTION_COUNTER_ID);
            
            // Update the counter's count.
            WebKit.DOM.HTMLElement counter =
                web_view.get_dom_document().get_element_by_id("selection_counter") as WebKit.DOM.HTMLElement;
            if (selected_count == 0) {
                counter.set_inner_html(_("No conversations selected."));
            } else {
                counter.set_inner_html(_("%u conversations selected.").printf(selected_count));
            }
        } catch (Error e) {
            debug("Error updating counter: %s", e.message);
        }
    }
    
    public void add_message(Geary.Email email) {
        // Make sure the message container is showing and the multi-message counter hidden.
        try {
            web_view.show_element_by_id(MESSAGE_CONTAINER_ID);
            web_view.hide_element_by_id(SELECTION_COUNTER_ID);
        } catch (Error e) {
            debug("Error showing/hiding containers: %s", e.message);
        }

        if (messages.contains(email))
            return;
        
        string message_id = get_div_id(email.id);
        
        WebKit.DOM.Node insert_before = web_view.container.get_last_child();
        
        messages.add(email);
        Geary.Email? higher = messages.higher(email);
        if (higher != null)
            insert_before = web_view.get_dom_document().get_element_by_id(get_div_id(higher.id));
        
        WebKit.DOM.HTMLElement div_message;
        
        try {
            div_message = make_email_div();
            div_message.set_attribute("id", message_id);
            web_view.container.insert_before(div_message, insert_before);
            if (email.is_unread() == Geary.Trillian.FALSE) {
                div_message.get_class_list().add("hide");
            }
        } catch (Error setup_error) {
            warning("Error setting up webkit: %s", setup_error.message);
            
            return;
        }
        email_to_element.set(email.id, div_message);
        
        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);
        }
        
        if (remote_images) {
            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);
            } 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
                        <input type="button" value="%s" class="show_images" />
                        <input type="button" value="%s" class="show_from" />""".printf(
                        remote_images_bar.get_inner_html(),
                        _("This message contains remote images."), _("Show images"),
                        _("Always show from sender")));
                } catch (Error error) {
                    warning("Error showing remote images bar: %s", error.message);
                }
            }
        }
        
        // Set attachment icon and add the attachments container if we have any attachments.
        set_attachment_icon(div_message, email.attachments.size > 0);
        if (email.attachments.size > 0) {
            insert_attachments(div_message, email.attachments);
        }
        
        // Add classes according to the state of the email.
        update_flags(email);
        
        // 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);
        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);
        bind_event(web_view, ".remote_images .show_images", "click", (Callback) on_show_images, this);
        bind_event(web_view, ".remote_images .show_from", "click", (Callback) on_show_images_from, this);
        bind_event(web_view, ".remote_images .close_show_images", "click", (Callback) on_close_show_images, this);
        
        // Update the search results
        if (conversation_find_bar.visible)
            conversation_find_bar.commence_search();
    }
    
    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);

        // Add the avatar.
        Geary.RFC822.MailboxAddress? primary = message.sender;
        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) {
                debug("Failed to inject avatar URL: %s", error.message);
            }
        }
        
        // Insert the preview text.
        try {
            WebKit.DOM.HTMLElement preview =
                Util.DOM.select(div_message, ".header_container .preview");
            string preview_str = message.get_preview();
            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);
        }

        string body_text = "";
        remote_images = false;
        try {
            body_text = message.get_body(true);
            body_text = insert_html_markup(body_text, message, out remote_images);
        } catch (Error err) {
            debug("Could not get message text. %s", err.message);
        }

        // Graft header and email body into the email container.
        try {
            WebKit.DOM.HTMLElement table_header =
                Util.DOM.select(div_email_container, ".header_container .header");
            table_header.set_inner_html(header);
            
            WebKit.DOM.HTMLElement span_body = Util.DOM.select(div_email_container, ".body");
            span_body.set_inner_html(body_text);
        } catch (Error html_error) {
            warning("Error setting HTML for message: %s", html_error.message);
        }

        // 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;
            try {
                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;
            } catch (Error error) {
                debug("Error adding message: %s", error.message);
            }
        }
    }
    
    public void unhide_last_email() {
        WebKit.DOM.HTMLElement last_email = (WebKit.DOM.HTMLElement) web_view.container.get_last_child().previous_sibling;
        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
            }
        }
    }
    
    public void compress_emails() {
        if (messages.size == 0)
            return;
        
        WebKit.DOM.Document document = web_view.get_dom_document();
        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;
        int compress_count = 0;
        bool prev_hidden = false, curr_hidden = false, next_hidden = false;
        try {
            next_hidden = curr_message.get_class_list().contains("hide");
            // The first step of the loop is to advance the hidden statuses.
        } catch (Error error) {
            debug("Error checking hidden status: %s", error.message);
        }
        
        // Note that next_message = span#placeholder when current_message is last in conversation.
        while (next_message != null) {
            try {
                prev_hidden = curr_hidden;
                curr_hidden = next_hidden;
                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");
                    compress_count += 1;
                    if (first_compressed == null)
                        first_compressed = curr_message;
                } else if (compress_count > 0) {
                    if (compress_count == 1) {
                        prev_message.get_class_list().remove("compressed");
                    } else {
                        WebKit.DOM.HTMLElement span =
                            first_compressed.first_element_child.first_element_child
                            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;");
                        span.set_attribute("style", "display:inline-block; top:%ipx".printf(
                            (int) (curr_message.offset_top - first_compressed.offset_top
                            - span.offset_height) / 2));
                    }
                    compress_count = 0;
                    first_compressed = null;
                }
            } catch (Error error) {
                debug("Error compressing emails: %s", error.message);
            }
            prev_message = curr_message;
            curr_message = next_message;
            next_message = curr_message.next_element_sibling;
        }
    }
    
    public void decompress_emails(WebKit.DOM.Element email_element) {
        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;
            }
        } catch (Error error) {
            debug("Error decompressing emails: %s", error.message);
        }
    }
    
    private Geary.Email? get_email_from_element(WebKit.DOM.Element element) {
        // First get the email container.
        WebKit.DOM.Element? email_element = null;
        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;
        }
        
        if (email_element == null)
            return null;
        
        // 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;
            }
        }
        
        if (email_id == null)
            return null;

        // Now lookup the email in our messages set.
        foreach (Geary.Email message in messages) {
            if (message.id == email_id)
                return message;
        }
        
        return null;
    }
    
    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 {
            return email.get_attachment(int64.parse(element.get_attribute("data-attachment-id")));
        } catch (Geary.EngineError err) {
            return null;
        }
    }

    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);
        }
    }

    public void update_flags(Geary.Email email) {
        // Nothing to do if we aren't displaying this email.
        if (!email_to_element.has_key(email.id)) {
            return;
        }

        Geary.EmailFlags flags = email.email_flags;
        
        // Update the flags in our message set.
        foreach (Geary.Email message in messages) {
            if (message.id.equal_to(email.id)) {
                message.set_flags(flags);
                break;
            }
        }
        
        // Get the email div and update its state.
        WebKit.DOM.HTMLElement container = email_to_element.get(email.id);
        try {
            WebKit.DOM.DOMTokenList class_list = container.get_class_list();
            Util.DOM.toggle_class(class_list, "read", !flags.is_unread());
            Util.DOM.toggle_class(class_list, "starred", flags.is_flagged());
        } catch (Error e) {
            warning("Failed to set classes on .email: %s", e.message);
        }
    }

    private static void on_context_menu(WebKit.DOM.Element clicked_element, WebKit.DOM.Event event,
        ConversationViewer conversation_viewer) {
        Geary.Email email = conversation_viewer.get_email_from_element(clicked_element);
        if (email != null)
            conversation_viewer.show_context_menu(email, clicked_element);
    }
    
    private void show_context_menu(Geary.Email email, WebKit.DOM.Element clicked_element) {
        context_menu = build_context_menu(email, clicked_element);
        context_menu.show_all();
        context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
    }
    
    private Gtk.Menu build_context_menu(Geary.Email email, WebKit.DOM.Element clicked_element) {
        Gtk.Menu menu = new Gtk.Menu();
        
        if (web_view.can_copy_clipboard()) {
            // 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) {
            // Add a menu item for copying the link.
            Gtk.MenuItem item = new Gtk.MenuItem.with_mnemonic(_("Copy _Link"));
            item.activate.connect(on_copy_link);
            menu.append(item);
            
            if (Geary.RFC822.MailboxAddress.is_valid_address(hover_url)) {
                // Add a menu item for copying the address.
                item = new Gtk.MenuItem.with_mnemonic(_("Copy _Email Address"));
                item.activate.connect(on_copy_email_address);
                menu.append(item);
            }
        }
        
        // 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);
        }
        
        // 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);
        
        // Inspect.
        if (Args.inspector) {
            Gtk.MenuItem inspect_item = new Gtk.MenuItem.with_mnemonic(_("_Inspect"));<commit_msg>Remove Copy Link: Closes #6989<commit_after>/* Copyright 2011-2013 Yorba Foundation
 *
 * This software is licensed under the GNU Lesser General Public License
 * (version 2.1 or later).  See the COPYING file in this distribution.
 */

public class ConversationViewer : Gtk.Box {
    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
        | Geary.Email.Field.DATE
        | Geary.Email.Field.FLAGS
        | Geary.Email.Field.PREVIEW;
    
    private const int ATTACHMENT_PREVIEW_SIZE = 50;
    private const string MESSAGE_CONTAINER_ID = "message_container";
    private const string SELECTION_COUNTER_ID = "multiple_messages";
    
    // Fired when the user clicks a link.
    public signal void link_selected(string link);
    
    // Fired when the user clicks "reply" in the message menu.
    public signal void reply_to_message(Geary.Email message);

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

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

    // Fired when the user marks a message.
    public signal void mark_message(Geary.Email message, Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove);

    // 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);

    // List of emails in this view.
    public Gee.TreeSet<Geary.Email> messages { get; private set; default = 
        new Geary.Collection.FixedTreeSet<Geary.Email>(Geary.Email.compare_date_ascending); }
    
    // The HTML viewer to view the emails.
    public ConversationWebView web_view { get; private set; }
    
    // Label for displaying overlay messages.
    private Gtk.Label message_overlay_label;
    
    // Maps emails to their corresponding elements.
    private Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement> email_to_element = new
        Gee.HashMap<Geary.EmailIdentifier, WebKit.DOM.HTMLElement>();
    
    private string? hover_url = null;
    private Gtk.Menu? context_menu = null;
    private Gtk.Menu? message_menu = null;
    private Gtk.Menu? attachment_menu = null;
    private weak Geary.Folder? current_folder = null;
    private Geary.AccountInformation? current_account_information = null;
    private ConversationFindBar conversation_find_bar;
    
    public ConversationViewer() {
        Object(orientation: Gtk.Orientation.VERTICAL, spacing: 0);
        
        web_view = new ConversationWebView();
        
        web_view.hovering_over_link.connect(on_hovering_over_link);
        web_view.context_menu.connect(() => { return true; }); // Suppress default context menu.
        web_view.realize.connect( () => { web_view.get_vadjustment().value_changed.connect(mark_read); });
        web_view.size_allocate.connect(mark_read);

        web_view.link_selected.connect((link) => { link_selected(link); });
        
        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);
        
        Gtk.Overlay message_overlay = new Gtk.Overlay();
        message_overlay.add(conversation_viewer_scrolled);
        
        message_overlay_label = new Gtk.Label(null);
        message_overlay_label.ellipsize = Pango.EllipsizeMode.MIDDLE;
        message_overlay_label.halign = Gtk.Align.START;
        message_overlay_label.valign = Gtk.Align.END;
        message_overlay.add_overlay(message_overlay_label);
        
        pack_start(message_overlay);
        
        conversation_find_bar = new ConversationFindBar(web_view);
        conversation_find_bar.no_show_all = true;
        
        pack_start(conversation_find_bar, false);
    }
    
    public Geary.Email? get_last_message() {
        return messages.is_empty ? null : messages.last();
    }
    
    // Removes all displayed e-mails from the view.
    public void clear(Geary.Folder? new_folder, Geary.AccountInformation? account_information) {
        // 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();
        messages.clear();
        
        current_folder = new_folder;
        current_account_information = account_information;
        
        if (conversation_find_bar.visible)
            conversation_find_bar.hide();
    }
    
    // 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());
    }
    
    public void show_multiple_selected(uint selected_count) {
        // Remove any messages and hide the message container, then show the counter.
        clear(current_folder, current_account_information);
        try {
            web_view.hide_element_by_id(MESSAGE_CONTAINER_ID);
            web_view.show_element_by_id(SELECTION_COUNTER_ID);
            
            // Update the counter's count.
            WebKit.DOM.HTMLElement counter =
                web_view.get_dom_document().get_element_by_id("selection_counter") as WebKit.DOM.HTMLElement;
            if (selected_count == 0) {
                counter.set_inner_html(_("No conversations selected."));
            } else {
                counter.set_inner_html(_("%u conversations selected.").printf(selected_count));
            }
        } catch (Error e) {
            debug("Error updating counter: %s", e.message);
        }
    }
    
    public void add_message(Geary.Email email) {
        // Make sure the message container is showing and the multi-message counter hidden.
        try {
            web_view.show_element_by_id(MESSAGE_CONTAINER_ID);
            web_view.hide_element_by_id(SELECTION_COUNTER_ID);
        } catch (Error e) {
            debug("Error showing/hiding containers: %s", e.message);
        }

        if (messages.contains(email))
            return;
        
        string message_id = get_div_id(email.id);
        
        WebKit.DOM.Node insert_before = web_view.container.get_last_child();
        
        messages.add(email);
        Geary.Email? higher = messages.higher(email);
        if (higher != null)
            insert_before = web_view.get_dom_document().get_element_by_id(get_div_id(higher.id));
        
        WebKit.DOM.HTMLElement div_message;
        
        try {
            div_message = make_email_div();
            div_message.set_attribute("id", message_id);
            web_view.container.insert_before(div_message, insert_before);
            if (email.is_unread() == Geary.Trillian.FALSE) {
                div_message.get_class_list().add("hide");
            }
        } catch (Error setup_error) {
            warning("Error setting up webkit: %s", setup_error.message);
            
            return;
        }
        email_to_element.set(email.id, div_message);
        
        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);
        }
        
        if (remote_images) {
            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);
            } 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
                        <input type="button" value="%s" class="show_images" />
                        <input type="button" value="%s" class="show_from" />""".printf(
                        remote_images_bar.get_inner_html(),
                        _("This message contains remote images."), _("Show images"),
                        _("Always show from sender")));
                } catch (Error error) {
                    warning("Error showing remote images bar: %s", error.message);
                }
            }
        }
        
        // Set attachment icon and add the attachments container if we have any attachments.
        set_attachment_icon(div_message, email.attachments.size > 0);
        if (email.attachments.size > 0) {
            insert_attachments(div_message, email.attachments);
        }
        
        // Add classes according to the state of the email.
        update_flags(email);
        
        // 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);
        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);
        bind_event(web_view, ".remote_images .show_images", "click", (Callback) on_show_images, this);
        bind_event(web_view, ".remote_images .show_from", "click", (Callback) on_show_images_from, this);
        bind_event(web_view, ".remote_images .close_show_images", "click", (Callback) on_close_show_images, this);
        
        // Update the search results
        if (conversation_find_bar.visible)
            conversation_find_bar.commence_search();
    }
    
    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);

        // Add the avatar.
        Geary.RFC822.MailboxAddress? primary = message.sender;
        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) {
                debug("Failed to inject avatar URL: %s", error.message);
            }
        }
        
        // Insert the preview text.
        try {
            WebKit.DOM.HTMLElement preview =
                Util.DOM.select(div_message, ".header_container .preview");
            string preview_str = message.get_preview();
            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);
        }

        string body_text = "";
        remote_images = false;
        try {
            body_text = message.get_body(true);
            body_text = insert_html_markup(body_text, message, out remote_images);
        } catch (Error err) {
            debug("Could not get message text. %s", err.message);
        }

        // Graft header and email body into the email container.
        try {
            WebKit.DOM.HTMLElement table_header =
                Util.DOM.select(div_email_container, ".header_container .header");
            table_header.set_inner_html(header);
            
            WebKit.DOM.HTMLElement span_body = Util.DOM.select(div_email_container, ".body");
            span_body.set_inner_html(body_text);
        } catch (Error html_error) {
            warning("Error setting HTML for message: %s", html_error.message);
        }

        // 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;
            try {
                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;
            } catch (Error error) {
                debug("Error adding message: %s", error.message);
            }
        }
    }
    
    public void unhide_last_email() {
        WebKit.DOM.HTMLElement last_email = (WebKit.DOM.HTMLElement) web_view.container.get_last_child().previous_sibling;
        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
            }
        }
    }
    
    public void compress_emails() {
        if (messages.size == 0)
            return;
        
        WebKit.DOM.Document document = web_view.get_dom_document();
        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;
        int compress_count = 0;
        bool prev_hidden = false, curr_hidden = false, next_hidden = false;
        try {
            next_hidden = curr_message.get_class_list().contains("hide");
            // The first step of the loop is to advance the hidden statuses.
        } catch (Error error) {
            debug("Error checking hidden status: %s", error.message);
        }
        
        // Note that next_message = span#placeholder when current_message is last in conversation.
        while (next_message != null) {
            try {
                prev_hidden = curr_hidden;
                curr_hidden = next_hidden;
                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");
                    compress_count += 1;
                    if (first_compressed == null)
                        first_compressed = curr_message;
                } else if (compress_count > 0) {
                    if (compress_count == 1) {
                        prev_message.get_class_list().remove("compressed");
                    } else {
                        WebKit.DOM.HTMLElement span =
                            first_compressed.first_element_child.first_element_child
                            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;");
                        span.set_attribute("style", "display:inline-block; top:%ipx".printf(
                            (int) (curr_message.offset_top - first_compressed.offset_top
                            - span.offset_height) / 2));
                    }
                    compress_count = 0;
                    first_compressed = null;
                }
            } catch (Error error) {
                debug("Error compressing emails: %s", error.message);
            }
            prev_message = curr_message;
            curr_message = next_message;
            next_message = curr_message.next_element_sibling;
        }
    }
    
    public void decompress_emails(WebKit.DOM.Element email_element) {
        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;
            }
        } catch (Error error) {
            debug("Error decompressing emails: %s", error.message);
        }
    }
    
    private Geary.Email? get_email_from_element(WebKit.DOM.Element element) {
        // First get the email container.
        WebKit.DOM.Element? email_element = null;
        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;
        }
        
        if (email_element == null)
            return null;
        
        // 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;
            }
        }
        
        if (email_id == null)
            return null;

        // Now lookup the email in our messages set.
        foreach (Geary.Email message in messages) {
            if (message.id == email_id)
                return message;
        }
        
        return null;
    }
    
    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 {
            return email.get_attachment(int64.parse(element.get_attribute("data-attachment-id")));
        } catch (Geary.EngineError err) {
            return null;
        }
    }

    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);
        }
    }

    public void update_flags(Geary.Email email) {
        // Nothing to do if we aren't displaying this email.
        if (!email_to_element.has_key(email.id)) {
            return;
        }

        Geary.EmailFlags flags = email.email_flags;
        
        // Update the flags in our message set.
        foreach (Geary.Email message in messages) {
            if (message.id.equal_to(email.id)) {
                message.set_flags(flags);
                break;
            }
        }
        
        // Get the email div and update its state.
        WebKit.DOM.HTMLElement container = email_to_element.get(email.id);
        try {
            WebKit.DOM.DOMTokenList class_list = container.get_class_list();
            Util.DOM.toggle_class(class_list, "read", !flags.is_unread());
            Util.DOM.toggle_class(class_list, "starred", flags.is_flagged());
        } catch (Error e) {
            warning("Failed to set classes on .email: %s", e.message);
        }
    }

    private static void on_context_menu(WebKit.DOM.Element clicked_element, WebKit.DOM.Event event,
        ConversationViewer conversation_viewer) {
        Geary.Email email = conversation_viewer.get_email_from_element(clicked_element);
        if (email != null)
            conversation_viewer.show_context_menu(email, clicked_element);
    }
    
    private void show_context_menu(Geary.Email email, WebKit.DOM.Element clicked_element) {
        context_menu = build_context_menu(email, clicked_element);
        context_menu.show_all();
        context_menu.popup(null, null, null, 0, Gtk.get_current_event_time());
    }
    
    private Gtk.Menu build_context_menu(Geary.Email email, WebKit.DOM.Element clicked_element) {
        Gtk.Menu menu = new Gtk.Menu();
        
        if (web_view.can_copy_clipboard()) {
            // 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) {
            if (Geary.RFC822.MailboxAddress.is_valid_address(hover_url)) {
                // Add a menu item for copying the address.
                Gtk.MenuItem item = new Gtk.MenuItem.with_mnemonic(_("Copy _Email Address"));
                item.activate.connect(on_copy_email_address);
                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);
                menu.append(item);
            }
        }
        
        // 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);
        }
        
        // 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);
        
        // Inspect.
        if (Args.inspector) {
            Gtk.MenuItem inspect_item = new Gtk.MenuItem.with_mnemonic(_("_Inspect"));
{'fraction_non_alphanumeric': 0.10229863221884498, 'fraction_numerical': 0.00182370820668693, 'mean_word_length': 2.077521192633733, 'pattern_counts': {'":': 0, '<': 83, '<?xml version=': 0, '>': 103, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '5551630', 'n_tokens_mistral': 14868, 'n_tokens_neox': 14593, 'n_words': 3045}