[Thunderbird] Adding a quoting options GUI to Thunderbird

Actually I didn’t set out on Sunday to add quoting option choices to Thunderbird’s preferences dialog. My goal was a loftier one, to fix the broken quoting in Thunderbird 3.x – Thunderbird 2 had its small quirks, but Thunderbird 3 collapses any sequence of spaces to a single space in quoted text, when you’re replying to a flowed format message. It rather restricts which Usenet messages one can respond to when using Thunderbird 3, and some other similar bugs (or moron’s fancy functionality, MFFs) also make life difficult for those who try to use it to submit patches. Hence the name of this extension, TBQuoteFix.

Paraphrasing former US vice president Dan Quayle: in a word, this extension may or may not live up to its name, later.

For now, as far as I got in the weekend it just reveals some “hidden” or “advanced” Thunderbird options, letting the user configure those via the ordinary options dialog:

I implemented this GUI side of things first because I already had nearly-finished code for it from my old NewsWorthy Thunderbird extension.

And I stopped there because I realized that I’d forgotten how excruciatingly slow it is to develop Thunderbird or Firefox extension code… It’s not for the impatient, or for anyone who has any pressing matters to attend to. Mostly, because so little is documented, it’s experimentation, including experiments to try to identify erratic behavior and devise more reliable work-arounds.

Essential tools

You absolutely need the DOMInspector extension, which lets you inspect the XML and JavaScript and current state inside e.g. Thunderbird.

Unfortunately TB3 flatly refuses to find a TB3-compatible update of DOMInspector. But, as Mozilla’s general DOMInspector page explains, you can download such a version from the DOM inspector for Firefox page, and/or from the DOM inspector for Thunderbird page. Apparently it’s the same extension in both pages.

Thunderbird’s error console is also essential. Happily, when it detects an error it provides a link to the source code. The error console + the DOMInspector almost constitute a kind of debugger.

For tracing to the error console (also indispensable!) you can employ a code snippet like …

In your Thunderbird (or Firefox) JavaScript extension code:

function myExtensionPrefixDump( s )
{
    var consoleService = Components.classes['@mozilla.org/consoleservice;1']
                               .getService(Components.interfaces.nsIConsoleService);
    consoleService.logStringMessage( s );
}

Oh, I forgot to mention, it’s almost all JavaScript + XML + CSS. GUI layout is defined using an XML-based language called XUL, with associated JavaScript event handlers. It’s very much like in a web page, including use of CSS styling (except that you apparently have to define your own DTD for common HTML entities).

A developer’s extension installation

Since you probably have no existing extension code you can look at the extension development tutorials. Anyway, all your extension’s files should be in a single directory, with subdirectories, organized in a special way. The extension’s directory should directly contain a file [install.rdf] that describes the extension, like…

File [install.rdf]:

<?xml version="1.0"?>
<RDF:RDF
    xmlns:em="http://www.mozilla.org/2004/em-rdf#"
    xmlns:NC="http://home.netscape.com/NC-rdf#"
    xmlns:RDF="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
>
    <RDF:Description
        RDF:about="urn:mozilla:install-manifest"
        em:id="tbquotefix@snurrepip.invalid"
        em:name="TBQuoteFix for Thunderbird 3.x"
        em:version="0.1.0.0"
        em:description="Fixing the broken quoting in Thunderbird"
        em:creator="Alf P. Steinbach"
        em:homepageURL="https://alfps.wordpress.com"
        em:iconURL="chrome://tbquotefix/content/icon.png"
    >
        <em:targetApplication RDF:resource="rdf:#$cQwHy3"/>
    </RDF:Description>
    <RDF:Description
        RDF:about="rdf:#$cQwHy3"
        em:id="{3550f703-e582-4d05-9a08-453d09bdfdc6}"
        em:minVersion="3.0"
        em:maxVersion="3.9.0.*"
    />
</RDF:RDF>

For an end-user’s installation all the files will be packaged in a ZIP archive with an [.xpi] filename extension, but this is impractical for development. So for development you can give Thunderbird a text file that just contains the path of the directory where you’re working on the extension’s files. The file should be named as an e-mail address, or at least, that works:

File [tbquotefix@snurrepip.invalid]:

C:\projects\utility\Thunderbird\TBQuoteFix

This file should be copied to wherever TB places extensions on your system, e.g. [%appdata%\Thunderbird\Profiles\tcmakonb.default\extensions]. It loads your extension from the specified directory every time you start TB. Which is great for development.

Use a manifest file to tell TB about overlays & DTDs

A TB (or Firefox) extension works by hooking into existing windows and other elements, to add its own stuff there. It does this by defining overlays: essentially you define a GUI element with the same id as an existing one, and your definition will be merged with the existing one. It’s not exactly well-defined, in particular how well contained names are or not, but it works for simple things.

The overlays etc. are at top level referred to by a file called [chrome.manifest], at top level in your extension’s directory. This is the file that provides Thunderbird with the info it needs to load your extension. Unfortunately it has a line-oriented special purpose syntax, i.e. it’s not XML.

Here’s my manifest for TBQuoteFix:

File [chrome.manifest]:

content tbquotefix  content/

# Options window
overlay chrome://messenger/content/preferences/preferences.xul  chrome://tbquotefix/content/preferences.xul

locale  tbquotefix      en-US   locale/en-US/

Line 1 maps the internal-in-Thunderbird URL [chrome://tbquotefix/content/] to the [content] folder, which contains XML and JavaScript files for the overlays. Line 4 registers one overlay, namely for the TB preferences dialog, defined by the XML file [content/preferences.xul] (this file in turn drags in the JavaScript for the functionality). Line 6 defines a locale, which in turn implicitly directs TB to the extension’s [locale/en-US] directory for important things such as a DTD file that defines XML entitites.

I do not know how well the DTD lookup works with some other locale; trying to place the DTD file anywhere else than in [locale/en-US] did not work :-(.

Use e.g. round quote characters by defining a DTD

Possibly and likely this can be done in Better Ways™, like referring to a W3C DTD…

But, the only way I found to use e.g. “round quotation marks” in Thunderbird GUI text, was to define a custom DTD, defining named entities like &qf.ldquo; as alternatives to standard &ldquo; etc. that XUL apparently does not support:

File [locale/en-US/html_entities.dtd]:

<!-- Character entitites; defined here because TB doesn't support 'em -->
<!ENTITY qf.ndash   "–">
<!ENTITY qf.lsquo   "‘">
<!ENTITY qf.rsquo   "’">
<!ENTITY qf.ldquo   "“">
<!ENTITY qf.rdquo   "”">
<!ENTITY qf.hellip  "…">

The main purpose of a custom DTD is localization, because a named entity can be defined as any text whatsoever; you then use one DTD per supported locale, definining the named entitites in English, Norwegian Bokmål and Norwegian Nynorsk, say. Perhaps that’s why the file needs to be down in the [locale/en-US] directory. By the way, the use of [en-US] directory comes from the locale definition in the [crome.manifest] file.

Define the GUI layout in XML

Mozilla’s XUL language for defining GUI elements pretty cool! One would hope that it could become an international standard. Alas, it’s ever-changing and only half documented (if that), but, it’s nice! 🙂

File [content/preferences.xul]:

<?xml version="1.0"?>
<!--?xml-stylesheet href="chrome://tbquotefix/skin/overlay.css" type="text/css"?-->

<!-- Localization Information -->
<!DOCTYPE overlay SYSTEM "chrome://tbquotefix/locale/html_entities.dtd">

<overlay id="tbquotefix-preferences-overlay"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">

    <!-- Merge with the Thunderbird 'MailPreferences' window -->
    <prefwindow id="MailPreferences">

        <!-- Merge with the Thunderbird 'Compose' pane -->
        <prefpane id="paneCompose">
            <preferences id="qfComposePreferences">
                <!-- Reply header -->
                <preference id="prefReplyHeaderType" name="mailnews.reply_header_type" type="int"/>
                <preference id="prefReplyHeaderDate" name="mailnews.reply_header_ondate" type="unichar"/>
                <preference id="prefReplyHeaderSeparator" name="mailnews.reply_header_separator" type="unichar"/>
                <preference id="prefReplyHeaderAuthor" name="mailnews.reply_header_authorwrote" type="unichar"/>
                <preference id="prefReplyHeaderEndtext" name="mailnews.reply_header_colon" type="unichar"/>
                    <!-- Type wstring causes lookup of default value from property file. -->
                <preference id="prefReplyOriginal" name="mailnews.reply_header_originalmessage" type="wstring"/>

                <!-- Quoting -->
                <preference id="prefQuotedGraphical" name="mail.quoted_graphical" type="bool"/>
            </preferences>

            <!-- The GUI content below is dynamically merged via script code -->
            <tabbox id="qfComposeTabBox">
                <tabs id="qfComposeTabs">
                    <tab label="Reply header" hidden="true"/>
                    <tab label="Quoting" hidden="true"/>
                </tabs>
                <tabpanels hidden="true" id="qfComposePanels">
                    <tabpanel id="qfReplyHeaderOptions" orient="vertical">
                        <hbox>
                            <hbox flex="1">
                                <description style="font-style: italic; color: gray" flex="1">
                                    TBQuoteFix &qf.ndash; Exposing hidden Thunderbird options &amp; fixin&qf.rsquo; bugs
                                </description>
                            </hbox>
                            <spacer flex="1"/>
                            <vbox>
                                <button label="Stock settings" type="menu" align="right">
                                    <menupopup>
                                        <menuitem label="Simple" oncommand="qfOnSetSimpleReply()"/>
                                        <menuitem label="Elaborate" oncommand="qfOnSetElaborateReply()"/>
                                    </menupopup>
                                </button>
                                <spacer flex="1"/>
                            </vbox>
                        </hbox>
                        <groupbox align="left" orient="vertical">
                            <caption label="Elements:"/>
                            <description>
                                For the Date and Author elements, use &qf.ldquo;%s&qf.rdquo; to
                                indicate where you want the date or author name inserted.
                            </description>
                            <grid>
                                <rows>
                                    <row>
                                        <label value="Date:"
                                            tooltiptext="TB advanced option &qf.ldquo;mailnews.reply_header_ondate&qf.rdquo;"/>
                                        <textbox id="editReplyHeaderDate" preference="prefReplyHeaderDate"
                                            oninput="qfUpdateReplyExampleDisplay()"/>
                                        <label value="Separator:"
                                            tooltiptext="TB advanced option &qf.ldquo;mailnews.reply_header_separator&qf.rdquo;"/>
                                        <textbox id="editReplyHeaderSeparator" preference="prefReplyHeaderSeparator"
                                            oninput="qfUpdateReplyExampleDisplay()"/>
                                    </row>
                                    <row>
                                        <label value="Author:"
                                            tooltiptext="TB advanced option &qf.ldquo;mailnews.reply_header_authorwrote&qf.rdquo;"/>
                                        <textbox id="editReplyHeaderAuthor" preference="prefReplyHeaderAuthor"
                                            oninput="qfUpdateReplyExampleDisplay()"/>
                                        <label value="Endtext:"
                                            tooltiptext="TB advanced option &qf.ldquo;mailnews.reply_header_colon&qf.rdquo;"/>
                                        <textbox id="editReplyHeaderEndtext" preference="prefReplyHeaderEndtext"
                                            oninput="qfUpdateReplyExampleDisplay()"/>
                                    </row>
                                </rows>
                            </grid>
                        </groupbox>
                        <groupbox align="left" orient="vertical">
                            <caption label="Format:"
                                tooltiptext="TB advanced option &qf.ldquo;mailnews.reply_header_type&qf.rdquo;"/>
                            <!-- radiogroup scripted via "addEventHandler" on pref pane load -->
                            <radiogroup id="rgReplyHeaderType" preference="prefReplyHeaderType" orient="vertical">
                                <radio value="0" label="None"/>
                                <radio value="1" label="&lt;Author&gt;&lt;Endtext&gt;"/>
                                <radio value="2" label="&lt;Date&gt;&lt;Separator&gt;&lt;Author&gt;&lt;Endtext&gt;"/>
                                <radio value="3" label="&lt;Author&gt;&lt;Separator&gt;&lt;Date&gt;&lt;Endtext&gt;"/>
                            </radiogroup>
                        </groupbox>
                        <hbox>
                            <hbox flex="1">
                                <label value="Example:"/>
                                <label id="qfReplyHeaderExample" value="xxx" style="font-weight: bold" crop="end" flex="1"/>
                            </hbox>
                        </hbox>
                        <spacer flex="1"/>
                    </tabpanel>

                    <tabpanel id="qfQuotingOptions" orient="vertical">
                        <hbox>
                            <description style="font-style: italic; color: gray" flex="1">
                                TBQuoteFix &qf.ndash; Exposing hidden Thunderbird options &amp; fixin&qf.rsquo; bugs
                            </description>
                            <spacer flex="1"/>
                            <vbox>
                                <button label="Stock settings" type="menu" disabled="true">
                                    <menupopup>
                                        <menuitem label="Nothing here yet" oncommand=""/>
                                    </menupopup>
                                </button>
                                <spacer flex="1"/>
                            </vbox>
                        </hbox>

                        <checkbox label="Display quote levels graphically, i.e. hide quote markers"
                            id = "cbGraphicQuote" preference="prefQuotedGraphical"
                            tooltiptext="TB advanced option &qf.ldquo;mail.quoted_graphical&qf.rdquo;"/>
                    </tabpanel>
                </tabpanels>
            </tabbox>
        </prefpane>

    </prefwindow>

    <script type="application/x-javascript" src="chrome://tbquotefix/content/preferences.js" />
</overlay>

The tab and tab panel elements are set as initially hidden because there’s apparently no way to merge them in using XUL. So instead of merging them in via the XML, they’re merged in dynamically via JavaScript code. The XML definitions just provide the JavaScript code with existing elements so that it doesn’t have to create them from scratch (and this also allows everything to be defined in the XML).

I hope you’ll forgive me for specifying text directly in the XML, instead of using named entitites defined by localizable DTDs…

Define the functionality in JavaScript

The JavaScript in Thunderbird 3 has one nice difference from ordinary JavaScript: it detects use of undeclared variables! 🙂 Apart from that it’s just JavaScript. It’s like doing a web-page, except that to the user it does not look like one…

Below I just present the code with all the warts that stem from developing in mostly undocumented and uncharted territory, building on some earlier mostly forgotten code.

File [content/preferences.js]:

function qfDump( s )
{
    var consoleService = Components.classes['@mozilla.org/consoleservice;1']
                               .getService(Components.interfaces.nsIConsoleService);
    consoleService.logStringMessage( s );
}

//-------------------------------------------- Preference pane, common functionality

function qfPreferencePane()
{
    return document.getElementById( "paneCompose" );
    //return document.getElementById( "tbquotefixPrefPane" );
}

function qfSetPreferenceValue( widget, value, isCheckbox )
{
    // This should perhaps instead be done by letting the preference object update
    // the GUI, but it takes time to check whether that works...
    if( isCheckbox )
    {
        widget.checked = value;
    }
    else
    {
        widget.value = value;
    }
    qfPreferencePane().userChangedValue( widget );
}

function qfOnComposePaneLoaded()
{
    try
    {
        var tbComposeTabBox     = document.getElementById( "composePrefs" );

        var qfComposeTabs       = document.getElementById( "qfComposeTabs" );
        var tbComposeTabs       = tbComposeTabBox.firstChild;
        var tab;

        while( tab = qfComposeTabs.firstChild )
        {
            tab.hidden = false;
            tbComposeTabs.appendChild( tab );
            tab.setAttribute( "selected", false );
        }

        var qfComposePanels     = document.getElementById( "qfComposePanels" );
        var tbComposePanels     = tbComposeTabBox.lastChild;
        var panel;

        while( panel = qfComposePanels.firstChild )
        {
            tbComposePanels.appendChild( panel );
        }
        var qfComposeTabBox = qfComposeTabs.parentNode;
        qfComposeTabBox.parentNode.removeChild( qfComposeTabBox );

        var prefs = document.getElementById( "qfComposePreferences" )
        var pref = prefs.firstChild;
        while( pref )
        {
            pref.updateElements()
            pref = pref.nextSibling
        }

        qfReplyTypeRadiogroup().addEventListener( "RadioStateChange", qfOnReplyTypeChanged, false );
        qfUpdateReplyExampleDisplay();
    }
    catch( err )
    {
        qfDump( "!Oops, exception: " + err.description )
    }
}

function qfOnOptionsWindowLoaded()
{
    var ComposePane = document.getElementById( "paneCompose" );
    if( ComposePane.loaded )    // "loaded" is an undocumented property.
    {
        qfOnComposePaneLoaded();
    }
    else
    {
        ComposePane.addEventListener( "paneload", qfOnComposePaneLoaded, false );
    }
}

window.addEventListener( "load", qfOnOptionsWindowLoaded, false );

//-------------------------------------------- Support functions

function qfDateTimeFormatter()
{
    var clazz   = Components.classes["@mozilla.org/intl/scriptabledateformat;1"];
    var iface   = Components.interfaces.nsIScriptableDateFormat;

    return clazz.createInstance( iface );
}

function qfShortFormDateTime( aDateTime )
{
    const   dateFormatNone                  = 0;
    const   dateFormatLong                  = 1;
    const   dateFormatShort                 = 2;
    const   dateFormatYearMonth             = 3;
    const   dateFormatWeekday               = 4;

    const   timeFormatNone                  = 0;
    const   timeFormatSeconds               = 1;
    const   timeFormatNoSeconds             = 2;
    const   timeFormatSecondsForce24Hour    = 3;
    const   timeFormatNoSecondsForce24Hour  = 4;

    return qfDateTimeFormatter().FormatDateTime(
        "",                 // Locale id, "" works for default Windows settings.
        dateFormatShort,
        timeFormatNoSeconds,
        aDateTime.getFullYear(),
        aDateTime.getMonth() + 1,       // 1...12
        aDateTime.getDate(),            // 1...31
        aDateTime.getHours(),           // 0...23
        aDateTime.getMinutes(),         // 0...59
        aDateTime.getSeconds()          // 0...59
        );
}

function qfTest()
{
    document.getElementById( "testEdit" ).value = qfShortFormDateTime( new Date() );
}

//-------------------------------------------- Reply header tab

function qfPrefReplyOriginal()
{
    return document.getElementById( "prefReplyOriginal" );
}

function qfPrefReplyHeaderDate()
{
    return document.getElementById( "prefReplyHeaderDate" );
}

function qfPrefReplyHeaderAuthor()
{
    return document.getElementById( "prefReplyHeaderAuthor" );
}

function qfReplyExampleLabel()
{
    return document.getElementById( "qfReplyHeaderExample" );
}

function qfReplyTypeRadiogroup()
{
    return document.getElementById( "rgReplyHeaderType" );
}

function qfDateTextbox()
{
    return document.getElementById( "editReplyHeaderDate" );
}

function qfSeparatorTextbox()
{
    return document.getElementById( "editReplyHeaderSeparator" );
}

function qfAuthorTextbox()
{
    return document.getElementById( "editReplyHeaderAuthor" );
}

function qfEndtextTextbox()
{
    return document.getElementById( "editReplyHeaderEndtext" );
}

function qfNumericDateTimeString()
{
    return qfShortFormDateTime( new Date() );   // Localized, at least in Windows.
}

function qfPercentSReplaced( s, replacement )
{
    s = s.replace( "%S", "[GARBLE]" );          // Thunderbird bug?
    var i = s.indexOf( "%s" );
    if( i == -1 )
    {
        return s;
    }
    else
    {
        var before = s.substr( 0, i );
        var after = s.substr( i+2 ).replace( "%s", "" );
        return before + replacement + after;
    }
}

function qfComputedReplyExampleText()
{
    var elemDate        = qfPercentSReplaced( qfDateTextbox().value, qfNumericDateTimeString() );
    var elemSeparator   = qfSeparatorTextbox().value;
    var elemAuthor      = qfPercentSReplaced( qfAuthorTextbox().value, "SomeOne" );
    var elemEndtext     = qfEndtextTextbox().value;

    switch( qfReplyTypeRadiogroup().value )
    {
        case "0":   return qfPrefReplyOriginal().value; // "-------- Original Message --------";
        case "1":   return elemAuthor + elemEndtext;
        case "2":   return elemDate + elemSeparator + elemAuthor + elemEndtext;
        case "3":   return elemAuthor + elemSeparator + elemDate + elemEndtext;
        default:    return "?";
    }
}

function qfUpdateReplyExampleLabel()
{
    qfReplyExampleLabel().value = qfComputedReplyExampleText();
}

function qfUpdateReplyExampleDisplay()
{
    qfUpdateReplyExampleLabel();
}

function qfSetDefaultReplyValues()
{
    //var dateElementValue = qfPrefReplyHeaderDate().defaultValue;
    var dateElementValue = "on %s";
    if( dateElementValue.length > 0 )
    {
        dateElementValue =
            dateElementValue.charAt( 0 ).toLowerCase() + dateElementValue.substr( 1 );
    }
    qfSetPreferenceValue( qfDateTextbox(), dateElementValue );
    qfSetPreferenceValue( qfSeparatorTextbox(), ", " );
}

function qfOnSetSimpleReply()
{
    qfSetDefaultReplyValues();
    qfSetPreferenceValue( qfAuthorTextbox(), "* %s" );
    qfSetPreferenceValue( qfEndtextTextbox(), ":" );
    qfSetPreferenceValue( qfReplyTypeRadiogroup(), "1" );
    qfUpdateReplyExampleDisplay();
}

function qfOnSetElaborateReply()
{
    qfSetDefaultReplyValues();
    //qfSetPreferenceValue( qfAuthorTextbox(), "* " + qfPrefReplyHeaderAuthor().defaultValue );
    qfSetPreferenceValue( qfAuthorTextbox(), "* %s" );
    qfSetPreferenceValue( qfEndtextTextbox(), ":" );
    qfSetPreferenceValue( qfReplyTypeRadiogroup(), "3" );
    qfUpdateReplyExampleDisplay();
}

function qfOnReplyTypeChanged()
{
    qfUpdateReplyExampleDisplay();
}

As you can see, developing Thunderbird (or Firefox) extensions is simple!

… Not! 🙂

Cheers, & – enjoy!

Advertisements

One comment on “[Thunderbird] Adding a quoting options GUI to Thunderbird

  1. I’m now not sure where that you are getting the facts, however excellent subject matter. I have to spend a little while understanding a lot more or perhaps understanding more. Thank you for amazing data I’d been hunting for this info for my mission.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s