view content/list.js @ 333:495fb632840c

brief and no header options for messages displayed in the list view; also Tools->View->Snowl tweaked, new submenu Layouts which includes layout group, header toggle group, toolbars checkbox toggle. List and sidebar toolbars can be toggled off. Contextual enable/disable of menuitems. New snowl toolbarbutton, mirrors the statusbar button. Layout button removed.
author alta88 <alta88@gmail.com>
date Mon, 20 Oct 2008 02:05:26 -0700
parents 58467ce8f44a
children 62557df25569
line wrap: on
line source

/* ***** BEGIN LICENSE BLOCK *****
 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is Snowl.
 *
 * The Initial Developer of the Original Code is Mozilla.
 * Portions created by the Initial Developer are Copyright (C) 2008
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *   Myk Melez <myk@mozilla.org>
 *   alta88 <alta88@gmail.com>
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 *
 * ***** END LICENSE BLOCK ***** */

// FIXME: import these into an object to avoid name collisions.
Cu.import("resource://snowl/modules/log4moz.js");
Cu.import("resource://snowl/modules/URI.js");
Cu.import("resource://snowl/modules/Preferences.js");

Cu.import("resource://snowl/modules/service.js");
Cu.import("resource://snowl/modules/datastore.js");
Cu.import("resource://snowl/modules/collection.js");
Cu.import("resource://snowl/modules/utils.js");

let SnowlMessageView = {
  // Logger
  get _log() {
    delete this._log;
    return this._log = Log4Moz.Service.getLogger("Snowl.ListView");
  },

  // Observer Service
  // FIXME: switch to using the Observers module.
  get _obsSvc() {
    delete this._obsSvc;
    return this._obsSvc = Cc["@mozilla.org/observer-service;1"].
                          getService(Ci.nsIObserverService);
  },

  // Atom Service
  get _atomSvc() {
    delete this._atomSvc;
    return this._atomSvc = Cc["@mozilla.org/atom-service;1"].
                           getService(Ci.nsIAtomService);
  },

  // The ID of the source to display.  The sidebar can set this to the source
  // selected by the user.
  // FIXME: make this an array of sources, and let the user select multiple
  // sources to view multiple sources simultaneously.
  sourceID: null,

  get _filter() {
    delete this._filter;
    return this._filter = document.getElementById("snowlFilter");
  },

  get _tree() {
    delete this._tree;
    return this._tree = document.getElementById("snowlView");
  },

  get _snowlViewContainer() {
    delete this._snowlViewContainer;
    return this._snowlViewContainer = document.getElementById("snowlViewContainer");
  },

  get _snowlViewSplitter() {
    delete this._snowlViewSplitter;
    return this._snowlViewSplitter = document.getElementById("snowlViewSplitter");
  },

  get _snowlSidebar() {
    delete this._snowlSidebar;
    return this._snowlSidebar = document.getElementById("snowlSidebar");
  },

  get _currentButton() {
    delete this._currentButton;
    return this._currentButton = document.getElementById("snowlCurrentButton");
  },

  get _unreadButton() {
    delete this._unreadButton;
    return this._unreadButton = document.getElementById("snowlUnreadButton");
  },

  // Maps XUL tree column IDs to collection properties.
  _columnProperties: {
    "snowlAuthorCol": "author",
    "snowlSubjectCol": "subject",
    "snowlTimestampCol": "timestamp"
  },


  //**************************************************************************//
  // nsITreeView

  get rowCount() {
this._log.info("get rowCount: " + this._collection.messages.length);
    return this._collection.messages.length;
  },

  getCellText: function(aRow, aColumn) {
    // FIXME: use _columnProperties instead of hardcoding column
    // IDs and property names here.
    switch(aColumn.id) {
      case "snowlAuthorCol":
        return this._collection.messages[aRow].author;
      case "snowlSubjectCol":
        return this._collection.messages[aRow].subject;
      case "snowlTimestampCol":
        return SnowlUtils._formatDate(this._collection.messages[aRow].timestamp);
      default:
        return null;
    }
  },

  _treebox: null,
  setTree: function(treebox){ this._treebox = treebox; },
  cycleHeader: function(aColumn) {},

  isContainer: function(aRow) { return false },
  isSeparator: function(aRow) { return false },
  isSorted: function() { return false },
  getLevel: function(aRow) { return 0 },
  getImageSrc: function(aRow, aColumn) { return null },
  getRowProperties: function (aRow, aProperties) {},

  getCellProperties: function (aRow, aColumn, aProperties) {
    // We have to set this on each cell rather than on the row as a whole
    // because the styling we apply to unread messages (bold text) has to be
    // specified by the ::-moz-tree-cell-text pseudo-element, which inherits
    // only the cell's properties.
    if (!this._collection.messages[aRow].read)
      aProperties.AppendElement(this._atomSvc.getAtom("unread"));
  },

  getColumnProperties: function(aColumnID, aColumn, aProperties) {},

  // We could implement inline tagging with an editable "Tags" column
  // by making this true, adding editable="true" to the tree tag, and
  // then marking only the tags column as editable.
  isEditable: function() { return false },


  //**************************************************************************//
  // Initialization and Destruction

  init: function() {
    // Move sidebar-box into our box for layouts
    let sidebarBox = document.getElementById("sidebar-box");
    this._snowlSidebar.appendChild(sidebarBox);

    // Listen for sidebar-box hidden attr change, to toggle properly
    sidebarBox.addEventListener("DOMAttrModified",
        function(aEvent) { 
          if (aEvent.target.id == "sidebar-box" && aEvent.attrName == "hidden")
            SnowlMessageView._snowlSidebar.hidden = (aEvent.newValue == "true");
        }, false);

    // Restore previous layout view and set menuitem checked
    let mainWindow = document.getElementById("main-window");
    let layout = mainWindow.getAttribute("snowlLayout");
    // If error or first time default to 'classic' view
    let layoutIndex = this.layoutName.indexOf(layout) < 0 ? 
        0 : this.layoutName.indexOf(layout);
    this.layout(layoutIndex);
  },

  show: function() {
    this._obsSvc.addObserver(this, "messages:changed", true);

    this._collection = new SnowlCollection();
    this._sort();
    this._tree.view = this;

    this._snowlViewContainer.hidden = false;
    this._snowlViewSplitter.hidden = false;

    Snowl._initSnowlToolbar();
  },

  hide: function() {
    this._snowlViewContainer.hidden = true;
    this._snowlViewSplitter.hidden = true;

    // XXX Should we somehow destroy the view here (f.e. by setting
    // this._tree.view to null)?

    this._obsSvc.removeObserver(this, "messages:changed");
  },


  //**************************************************************************//
  // Misc XPCOM Interfaces

  // nsISupports
  QueryInterface: function(aIID) {
    if (aIID.equals(Ci.nsIObserver) ||
        aIID.equals(Ci.nsISupportsWeakReference) ||
        aIID.equals(Ci.nsISupports))
      return this;
    
    throw Cr.NS_ERROR_NO_INTERFACE;
  },

  // nsIObserver
  observe: function(subject, topic, data) {
    switch (topic) {
      case "messages:changed":
        this._onMessagesChanged();
        break;
    }
  },


  //**************************************************************************//
  // Event & Notification Handling

  _onMessagesChanged: function() {
    // FIXME: make the collection listen for message changes and invalidate
    // itself, then rebuild the view in a timeout to give the collection time
    // to do so.
    this._collection.invalidate();

    // Don't rebuild the view if the list view hasn't been made visible yet
    // (in which case the tree won't yet have a view property).
    if (this._tree.view)
      this._rebuildView();
  },

  onFilter: function() {
    this._applyFilters();
  },

  onCommandCurrentButton: function(aEvent) {
    this._applyFilters();
  },

  onCommandUnreadButton: function(aEvent) {
    // XXX Instead of rebuilding from scratch each time, when going from
    // all to unread, simply hide the ones that are read (f.e. by setting a CSS
    // class on read items and then using a CSS rule to hide them)?
    this._applyFilters();
  },

  _applyFilters: function() {
    let filters = [];

    if (this._currentButton.checked)
      filters.push({ expression: "current = 1", parameters: {} });

    if (this._unreadButton.checked)
      filters.push({ expression: "read = 0", parameters: {} });

    // FIXME: use a left join here once the SQLite bug breaking left joins to
    // virtual tables has been fixed (i.e. after we upgrade to SQLite 3.5.7+).
    if (this._filter.value)
      filters.push({ expression: "messages.id IN (SELECT messageID FROM parts WHERE content MATCH :filter)",
                     parameters: { filter: this._filter.value } });

    this._collection.filters = filters;
    this._collection.invalidate();
    this._rebuildView();
  },

  setCollection: function(collection) {
    this._collection = collection;
    this._rebuildView();
  },

  _rebuildView: function() {
    // Clear the selection before we rebuild the view, since it won't apply
    // to the new data.
    this._tree.view.selection.select(-1);

    // Since the number of rows might have changed, we rebuild the view
    // by reinitializing it instead of merely invalidating the box object
    // (which wouldn't accommodate changes to the number of rows).
    // XXX Is there a better way to do this?
    this._tree.view = this;

    // Scroll back to the top of the tree.
    this._tree.boxObject.scrollToRow(this._tree.boxObject.getFirstVisibleRow());
  },

  switchLayout: function(layout) {
    // Build the layout
    this.layout(layout);

    // Because we've moved the tree, we have to reattach the view to it,
    // or we will get the error: "this._tree.boxObject.invalidate is not
    // a function" when we switch sources.
    this._tree.view = this;
  },

  // Layout views
  kClassicLayout: 0,
  kVerticalLayout: 1,
  kWideMessageLayout: 2,
  kWideThreadLayout: 3,
  kStackedLayout: 4,
  gCurrentLayout: null,
  layoutName: ["classic", "vertical", "widemessage", "widethread", "stacked"],

  layout: function(layout) {
    let mainWindow = document.getElementById("main-window");
    let browser = document.getElementById("browser");
    let appcontent = document.getElementById("appcontent");
    let content = document.getElementById("content");
    let sidebarSplitter = document.getElementById("sidebar-splitter");
    let snowlThreadContainer = this._snowlViewContainer;
    let snowlThreadSplitter = this._snowlViewSplitter;

    let layoutThreadPaneParent = ["appcontent", "browser", "snowlSidebar", "main-window", "sidebar-box"];
    // A 'null' is an effective appendChild, code is nice and reusable..
    let layoutThreadPaneInsertBefore = [content, appcontent, null, browser, null];
    // 0=horizontal, 1=vertical for orient arrays..
    let layoutsnowlThreadSplitterOrient = [1, 0, 0, 1, 1];
    let sidebarSplitterOrient = [0, 0, 1, 0, 0];
    let layoutSnowlBoxFlex = [0, 1, 1, 0, 0];

    var desiredParent = document.getElementById(layoutThreadPaneParent[layout]);
    if (snowlThreadContainer.parentNode.id != desiredParent.id) {
      switch (layout) {
        case this.kClassicLayout:
        case this.kVerticalLayout:
        case this.kWideThreadLayout:
          desiredParent.insertBefore(snowlThreadContainer, layoutThreadPaneInsertBefore[layout]);
          desiredParent.insertBefore(snowlThreadSplitter, layoutThreadPaneInsertBefore[layout]);
          break;
        case this.kStackedLayout:
        case this.kWideMessageLayout:
          desiredParent.insertBefore(snowlThreadSplitter, layoutThreadPaneInsertBefore[layout]);
          desiredParent.insertBefore(snowlThreadContainer, layoutThreadPaneInsertBefore[layout]);
          break;
      }
    }

    // Adjust orient and flex for all layouts
    browser.orient = sidebarSplitterOrient[layout] ? "vertical" : "horizontal";
    snowlThreadSplitter.orient = layoutsnowlThreadSplitterOrient[layout] ? "vertical" : "horizontal";
    sidebarSplitter.orient = sidebarSplitterOrient[layout] ? "vertical" : "horizontal";
    snowlThreadContainer.setAttribute("flex", layoutSnowlBoxFlex[layout]);

    // Store the layout
    mainWindow.setAttribute("snowlLayout", this.layoutName[layout]);
    this.gCurrentLayout = layout;
  },

  onSelect: function(aEvent) {
    if (this._tree.currentIndex == -1)
      return;

    // When we support opening multiple links in the background,
    // perhaps use this code:
    // http://lxr.mozilla.org/mozilla/source/browser/base/content/browser.js#1482

    let row = this._tree.currentIndex;
    let message = this._collection.messages[row];

    //window.loadURI(message.link, null, null, false);
    let url = "chrome://snowl/content/message/message.xul?id=" + message.id;
    window.loadURI(url, null, null, false);

    this._setRead(true);
  },

  onKeyPress: function(aEvent) {
    if (aEvent.altKey || aEvent.metaKey || aEvent.ctrlKey)
      return;

    // which is either the charCode or the keyCode, depending on which is set.
    this._log.info("onKeyPress: which = " + aEvent.which);

    if (aEvent.charCode == "r".charCodeAt(0))
      this._toggleRead(false);
    if (aEvent.charCode == "R".charCodeAt(0))
      this._toggleRead(true);
    else if (aEvent.charCode == " ".charCodeAt(0))
      this._onSpacePress(aEvent);
  },

  // Based on SpaceHit in mailWindowOverlay.js
  _onSpacePress: function(aEvent) {
    if (aEvent.shiftKey) {
      // if at the start of the message, go to the previous one
      if (gBrowser.contentWindow.scrollY > 0)
        gBrowser.contentWindow.scrollByPages(-1);
      else
        this._goToPreviousUnreadMessage();
    }
    else {
      // if at the end of the message, go to the next one
      if (gBrowser.contentWindow.scrollY < gBrowser.contentWindow.scrollMaxY)
        gBrowser.contentWindow.scrollByPages(1);
      else
        this._goToNextUnreadMessage();
    }
  },

  _goToPreviousUnreadMessage: function() {
    let currentIndex = this._tree.currentIndex;
    let i = currentIndex - 1;

    while (i != currentIndex) {
      if (i < 0) {
        i = this._collection.messages.length - 1;
        continue;
      }

      if (!this._collection.messages[i].read) {
        this.selection.select(i);
        this._tree.treeBoxObject.ensureRowIsVisible(i);
        break;
      }

      i--;
    }
  },

  _goToNextUnreadMessage: function() {
    let currentIndex = this._tree.currentIndex;
    let i = currentIndex + 1;

    while (i != currentIndex) {
      if (i > this._collection.messages.length - 1) {
        i = 0;
        continue;
      }
this._log.info(i);
      if (!this._collection.messages[i].read) {
        this.selection.select(i);
        this._tree.treeBoxObject.ensureRowIsVisible(i);
        break;
      }

      i++;
    }
  },

  _toggleRead: function(aAll) {
this._log.info("_toggleRead: all? " + aAll);
    if (this._tree.currentIndex == -1)
      return;

    let row = this._tree.currentIndex;
    let message = this._collection.messages[row];
    if (aAll)
      this._setAllRead(!message.read);
    else
      this._setRead(!message.read);
  },

  _setRead: function(aRead) {
    let row = this._tree.currentIndex;
    let message = this._collection.messages[row];
    message.read = aRead;
    this._tree.boxObject.invalidateRow(row);
  },

  _setAllRead: function(aRead) {
    let ids = this._collection.messages.map(function(v) { return v.id });
    this._collection.messages.forEach(function(v) { v.read = aRead });
    this._tree.boxObject.invalidate();
  },

  onClickColumnHeader: function(aEvent) {
    let column = aEvent.target;
    let property = this._columnProperties[column.id];
    let sortResource = this._tree.getAttribute("sortResource");
    let sortDirection = this._tree.getAttribute("sortDirection");

    // FIXME: don't sort if the user right- or middle-clicked the header.

    // Determine the sort order.  If the user clicked on the header for
    // the current sort column, we sort in the reverse of the current order.
    // Otherwise we sort in ascending order.
    let oldOrder = (sortDirection == "ascending" ? 1 : -1);
    let newOrder = (column.id == sortResource ? -oldOrder : 1);

    // Persist the new sort resource and direction.
    let direction = (newOrder == 1 ? "ascending" : "descending");
    this._tree.setAttribute("sortResource", column.id);
    this._tree.setAttribute("sortDirection", direction);

    // Update the sort indicator to appear on the current column.
    let columns = this._tree.getElementsByTagName("treecol");
    for (let i = 0; i < columns.length; i++)
      columns[i].removeAttribute("sortDirection");
    column.setAttribute("sortDirection", direction);

    // Perform the sort.
    this._sort();
  },

  _sort: function() {
    let resource = this._tree.getAttribute("sortResource");
    let property = this._columnProperties[resource];

    let direction = this._tree.getAttribute("sortDirection");
    let order = (direction == "ascending" ? 1 : -1);

    // Perform the sort.
    this._collection.sortProperties = [property];
    this._collection.sortOrder = order;
    this._collection.sort();
  }
};

window.addEventListener("load", function() { SnowlMessageView.init() }, false);