[gnome-shell] calendar: Split out message list base classes



commit ee8fd1e6131bae3752addba0e6e2e962e6ad12be
Author: Florian Müllner <fmuellner gnome org>
Date:   Mon Feb 15 12:02:31 2016 +0100

    calendar: Split out message list base classes
    
    Currently both the base classes for messages/sections and the message
    list itself that instantiates the available sections are located in
    the same module. As a result, it isn't possible to define sections
    in a different module without introducing circular dependencies. The
    Calendar module is already unwieldily large, so split it up a bit to
    avoid it growing even bigger in the future.
    
    https://bugzilla.gnome.org/show_bug.cgi?id=756491

 js/js-resources.gresource.xml       |    1 +
 js/ui/calendar.js                   |  761 ++---------------------------------
 js/ui/components/telepathyClient.js |    4 +-
 js/ui/dateMenu.js                   |    2 +-
 js/ui/messageList.js                |  713 ++++++++++++++++++++++++++++++++
 po/POTFILES.in                      |    1 +
 6 files changed, 747 insertions(+), 735 deletions(-)
---
diff --git a/js/js-resources.gresource.xml b/js/js-resources.gresource.xml
index 169d952..e0c522b 100644
--- a/js/js-resources.gresource.xml
+++ b/js/js-resources.gresource.xml
@@ -63,6 +63,7 @@
     <file>ui/magnifierDBus.js</file>
     <file>ui/main.js</file>
     <file>ui/messageTray.js</file>
+    <file>ui/messageList.js</file>
     <file>ui/modalDialog.js</file>
     <file>ui/notificationDaemon.js</file>
     <file>ui/osdWindow.js</file>
diff --git a/js/ui/calendar.js b/js/ui/calendar.js
index d2a7b9c..c9a6222 100644
--- a/js/ui/calendar.js
+++ b/js/ui/calendar.js
@@ -1,23 +1,18 @@
 // -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*-
 
-const Atk = imports.gi.Atk;
 const Clutter = imports.gi.Clutter;
 const Gio = imports.gi.Gio;
 const GLib = imports.gi.GLib;
-const GObject = imports.gi.GObject;
 const Gtk = imports.gi.Gtk;
 const Lang = imports.lang;
 const St = imports.gi.St;
 const Signals = imports.signals;
-const Pango = imports.gi.Pango;
 const Gettext_gtk30 = imports.gettext.domain('gtk30');
-const Mainloop = imports.mainloop;
-const Meta = imports.gi.Meta;
 const Shell = imports.gi.Shell;
 
 const Main = imports.ui.main;
+const MessageList = imports.ui.messageList;
 const MessageTray = imports.ui.messageTray;
-const Tweener = imports.ui.tweener;
 const Util = imports.misc.util;
 
 const MSECS_IN_DAY = 24 * 60 * 60 * 1000;
@@ -26,28 +21,24 @@ const ELLIPSIS_CHAR = '\u2026';
 
 const MESSAGE_ICON_SIZE = 32;
 
-const MESSAGE_ANIMATION_TIME = 0.1;
-
-const DEFAULT_EXPAND_LINES = 6;
-
 // alias to prevent xgettext from picking up strings translated in GTK+
 const gtk30_ = Gettext_gtk30.gettext;
 const NC_ = function(context, str) { return context + '\u0004' + str; };
 
-function _sameYear(dateA, dateB) {
+function sameYear(dateA, dateB) {
     return (dateA.getYear() == dateB.getYear());
 }
 
-function _sameMonth(dateA, dateB) {
-    return _sameYear(dateA, dateB) && (dateA.getMonth() == dateB.getMonth());
+function sameMonth(dateA, dateB) {
+    return sameYear(dateA, dateB) && (dateA.getMonth() == dateB.getMonth());
 }
 
-function _sameDay(dateA, dateB) {
-    return _sameMonth(dateA, dateB) && (dateA.getDate() == dateB.getDate());
+function sameDay(dateA, dateB) {
+    return sameMonth(dateA, dateB) && (dateA.getDate() == dateB.getDate());
 }
 
-function _isToday(date) {
-    return _sameDay(new Date(), date);
+function isToday(date) {
+    return sameDay(new Date(), date);
 }
 
 function _isWorkDay(date) {
@@ -98,148 +89,6 @@ function _getCalendarDayAbbreviation(dayNumber) {
     return Shell.util_translate_time_string(abbreviations[dayNumber]);
 }
 
-function _fixMarkup(text, allowMarkup) {
-    if (allowMarkup) {
-        // Support &amp;, &quot;, &apos;, &lt; and &gt;, escape all other
-        // occurrences of '&'.
-        let _text = text.replace(/&(?!amp;|quot;|apos;|lt;|gt;)/g, '&amp;');
-
-        // Support <b>, <i>, and <u>, escape anything else
-        // so it displays as raw markup.
-        _text = _text.replace(/<(?!\/?[biu]>)/g, '&lt;');
-
-        try {
-            Pango.parse_markup(_text, -1, '');
-            return _text;
-        } catch (e) {}
-    }
-
-    // !allowMarkup, or invalid markup
-    return GLib.markup_escape_text(text, -1);
-}
-
-const URLHighlighter = new Lang.Class({
-    Name: 'URLHighlighter',
-
-    _init: function(text, lineWrap, allowMarkup) {
-        if (!text)
-            text = '';
-        this.actor = new St.Label({ reactive: true, style_class: 'url-highlighter',
-                                    x_expand: true, x_align: Clutter.ActorAlign.START });
-        this._linkColor = '#ccccff';
-        this.actor.connect('style-changed', Lang.bind(this, function() {
-            let [hasColor, color] = this.actor.get_theme_node().lookup_color('link-color', false);
-            if (hasColor) {
-                let linkColor = color.to_string().substr(0, 7);
-                if (linkColor != this._linkColor) {
-                    this._linkColor = linkColor;
-                    this._highlightUrls();
-                }
-            }
-        }));
-        this.actor.clutter_text.line_wrap = lineWrap;
-        this.actor.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
-
-        this.setMarkup(text, allowMarkup);
-        this.actor.connect('button-press-event', Lang.bind(this, function(actor, event) {
-            // Don't try to URL highlight when invisible.
-            // The MessageTray doesn't actually hide us, so
-            // we need to check for paint opacities as well.
-            if (!actor.visible || actor.get_paint_opacity() == 0)
-                return Clutter.EVENT_PROPAGATE;
-
-            // Keep Notification.actor from seeing this and taking
-            // a pointer grab, which would block our button-release-event
-            // handler, if an URL is clicked
-            return this._findUrlAtPos(event) != -1;
-        }));
-        this.actor.connect('button-release-event', Lang.bind(this, function (actor, event) {
-            if (!actor.visible || actor.get_paint_opacity() == 0)
-                return Clutter.EVENT_PROPAGATE;
-
-            let urlId = this._findUrlAtPos(event);
-            if (urlId != -1) {
-                let url = this._urls[urlId].url;
-                if (url.indexOf(':') == -1)
-                    url = 'http://' + url;
-
-                Gio.app_info_launch_default_for_uri(url, global.create_app_launch_context(0, -1));
-                return Clutter.EVENT_STOP;
-            }
-            return Clutter.EVENT_PROPAGATE;
-        }));
-        this.actor.connect('motion-event', Lang.bind(this, function(actor, event) {
-            if (!actor.visible || actor.get_paint_opacity() == 0)
-                return Clutter.EVENT_PROPAGATE;
-
-            let urlId = this._findUrlAtPos(event);
-            if (urlId != -1 && !this._cursorChanged) {
-                global.screen.set_cursor(Meta.Cursor.POINTING_HAND);
-                this._cursorChanged = true;
-            } else if (urlId == -1) {
-                global.screen.set_cursor(Meta.Cursor.DEFAULT);
-                this._cursorChanged = false;
-            }
-            return Clutter.EVENT_PROPAGATE;
-        }));
-        this.actor.connect('leave-event', Lang.bind(this, function() {
-            if (!this.actor.visible || this.actor.get_paint_opacity() == 0)
-                return Clutter.EVENT_PROPAGATE;
-
-            if (this._cursorChanged) {
-                this._cursorChanged = false;
-                global.screen.set_cursor(Meta.Cursor.DEFAULT);
-            }
-            return Clutter.EVENT_PROPAGATE;
-        }));
-    },
-
-    setMarkup: function(text, allowMarkup) {
-        text = text ? _fixMarkup(text, allowMarkup) : '';
-        this._text = text;
-
-        this.actor.clutter_text.set_markup(text);
-        /* clutter_text.text contain text without markup */
-        this._urls = Util.findUrls(this.actor.clutter_text.text);
-        this._highlightUrls();
-    },
-
-    _highlightUrls: function() {
-        // text here contain markup
-        let urls = Util.findUrls(this._text);
-        let markup = '';
-        let pos = 0;
-        for (let i = 0; i < urls.length; i++) {
-            let url = urls[i];
-            let str = this._text.substr(pos, url.pos - pos);
-            markup += str + '<span foreground="' + this._linkColor + '"><u>' + url.url + '</u></span>';
-            pos = url.pos + url.url.length;
-        }
-        markup += this._text.substr(pos);
-        this.actor.clutter_text.set_markup(markup);
-    },
-
-    _findUrlAtPos: function(event) {
-        let success;
-        let [x, y] = event.get_coords();
-        [success, x, y] = this.actor.transform_stage_point(x, y);
-        let find_pos = -1;
-        for (let i = 0; i < this.actor.clutter_text.text.length; i++) {
-            let [success, px, py, line_height] = this.actor.clutter_text.position_to_coords(i);
-            if (py > y || py + line_height < y || x < px)
-                continue;
-            find_pos = i;
-        }
-        if (find_pos != -1) {
-            for (let i = 0; i < this._urls.length; i++)
-            if (find_pos >= this._urls[i].pos &&
-                this._urls[i].pos + this._urls[i].url.length > find_pos)
-                return i;
-        }
-        return -1;
-    }
-});
-
 // Abstraction for an appointment/event in a calendar
 
 const CalendarEvent = new Lang.Class({
@@ -544,7 +393,7 @@ const Calendar = new Lang.Class({
 
     // Sets the calendar to show a specific date
     setDate: function(date) {
-        if (_sameDay(date, this._selectedDate))
+        if (sameDay(date, this._selectedDate))
             return;
 
         this._selectedDate = date;
@@ -757,7 +606,7 @@ const Calendar = new Lang.Class({
             if (leftMost)
                 styleClass = 'calendar-day-left ' + styleClass;
 
-            if (_sameDay(now, iter))
+            if (sameDay(now, iter))
                 styleClass += ' calendar-today';
             else if (iter.getMonth() != this._selectedDate.getMonth())
                 styleClass += ' calendar-other-month-day';
@@ -800,16 +649,16 @@ const Calendar = new Lang.Class({
     _update: function() {
         let now = new Date();
 
-        if (_sameYear(this._selectedDate, now))
+        if (sameYear(this._selectedDate, now))
             this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormatWithoutYear);
         else
             this._monthLabel.text = this._selectedDate.toLocaleFormat(this._headerFormat);
 
-        if (!this._calendarBegin || !_sameMonth(this._selectedDate, this._calendarBegin) || !_sameDay(now, 
this._markedAsToday))
+        if (!this._calendarBegin || !sameMonth(this._selectedDate, this._calendarBegin) || !sameDay(now, 
this._markedAsToday))
             this._rebuildCalendar();
 
         this._buttons.forEach(Lang.bind(this, function(button) {
-            if (_sameDay(button._date, this._selectedDate)) {
+            if (sameDay(button._date, this._selectedDate)) {
                 button.add_style_pseudo_class('active');
                 if (this._shouldDateGrabFocus)
                     button.grab_key_focus();
@@ -821,353 +670,9 @@ const Calendar = new Lang.Class({
 });
 Signals.addSignalMethods(Calendar.prototype);
 
-const ScaleLayout = new Lang.Class({
-    Name: 'ScaleLayout',
-    Extends: Clutter.BinLayout,
-
-    _connectContainer: function(container) {
-        if (this._container == container)
-            return;
-
-        if (this._container)
-            for (let id of this._signals)
-                this._container.disconnect(id);
-
-        this._container = container;
-        this._signals = [];
-
-        if (this._container)
-            for (let signal of ['notify::scale-x', 'notify::scale-y']) {
-                let id = this._container.connect(signal, Lang.bind(this,
-                    function() {
-                        this.layout_changed();
-                    }));
-                this._signals.push(id);
-            }
-    },
-
-    vfunc_get_preferred_width: function(container, forHeight) {
-        this._connectContainer(container);
-
-        let [min, nat] = this.parent(container, forHeight);
-        return [Math.floor(min * container.scale_x),
-                Math.floor(nat * container.scale_x)];
-    },
-
-    vfunc_get_preferred_height: function(container, forWidth) {
-        this._connectContainer(container);
-
-        let [min, nat] = this.parent(container, forWidth);
-        return [Math.floor(min * container.scale_y),
-                Math.floor(nat * container.scale_y)];
-    }
-});
-
-const LabelExpanderLayout = new Lang.Class({
-    Name: 'LabelExpanderLayout',
-    Extends: Clutter.LayoutManager,
-    Properties: { 'expansion': GObject.ParamSpec.double('expansion',
-                                                        'Expansion',
-                                                        'Expansion of the layout, between 0 (collapsed) ' +
-                                                        'and 1 (fully expanded',
-                                                         GObject.ParamFlags.READABLE | 
GObject.ParamFlags.WRITABLE,
-                                                         0, 1, 0)},
-
-    _init: function(params) {
-        this._expansion = 0;
-        this._expandLines = DEFAULT_EXPAND_LINES;
-
-        this.parent(params);
-    },
-
-    get expansion() {
-        return this._expansion;
-    },
-
-    set expansion(v) {
-        if (v == this._expansion)
-            return;
-        this._expansion = v;
-        this.notify('expansion');
-
-        let visibleIndex = this._expansion > 0 ? 1 : 0;
-        for (let i = 0; this._container && i < this._container.get_n_children(); i++)
-            this._container.get_child_at_index(i).visible = (i == visibleIndex);
-
-        this.layout_changed();
-    },
-
-    set expandLines(v) {
-        if (v == this._expandLines)
-            return;
-        this._expandLines = v;
-        if (this._expansion > 0)
-            this.layout_changed();
-    },
-
-    vfunc_set_container: function(container) {
-        this._container = container;
-    },
-
-    vfunc_get_preferred_width: function(container, forHeight) {
-        let [min, nat] = [0, 0];
-
-        for (let i = 0; i < container.get_n_children(); i++) {
-            if (i > 1)
-                break; // we support one unexpanded + one expanded child
-
-            let child = container.get_child_at_index(i);
-            let [childMin, childNat] = child.get_preferred_width(forHeight);
-            [min, nat] = [Math.max(min, childMin), Math.max(nat, childNat)];
-        }
-
-        return [min, nat];
-    },
-
-    vfunc_get_preferred_height: function(container, forWidth) {
-        let [min, nat] = [0, 0];
-
-        let children = container.get_children();
-        if (children[0])
-            [min, nat] = children[0].get_preferred_height(forWidth);
-
-        if (children[1]) {
-            let [min2, nat2] = children[1].get_preferred_height(forWidth);
-            let [expMin, expNat] = [Math.min(min2, min * this._expandLines),
-                                    Math.min(nat2, nat * this._expandLines)];
-            [min, nat] = [min + this._expansion * (expMin - min),
-                          nat + this._expansion * (expNat - nat)];
-        }
-
-        return [min, nat];
-    },
-
-    vfunc_allocate: function(container, box, flags) {
-        for (let i = 0; i < container.get_n_children(); i++) {
-            let child = container.get_child_at_index(i);
-
-            if (child.visible)
-                child.allocate(box, flags);
-        }
-
-    }
-});
-
-const Message = new Lang.Class({
-    Name: 'Message',
-
-    _init: function(title, body) {
-        this.expanded = false;
-
-        this.actor = new St.Button({ style_class: 'message',
-                                     accessible_role: Atk.Role.NOTIFICATION,
-                                     can_focus: true,
-                                     x_expand: true, x_fill: true });
-        this.actor.connect('key-press-event',
-                           Lang.bind(this, this._onKeyPressed));
-
-        let vbox = new St.BoxLayout({ vertical: true });
-        this.actor.set_child(vbox);
-
-        let hbox = new St.BoxLayout();
-        vbox.add_actor(hbox);
-
-        this._actionBin = new St.Widget({ layout_manager: new ScaleLayout(),
-                                          visible: false });
-        vbox.add_actor(this._actionBin);
-
-        this._iconBin = new St.Bin({ style_class: 'message-icon-bin',
-                                     y_expand: true,
-                                     visible: false });
-        hbox.add_actor(this._iconBin);
-
-        let contentBox = new St.BoxLayout({ style_class: 'message-content',
-                                            vertical: true, x_expand: true });
-        hbox.add_actor(contentBox);
-
-        let titleBox = new St.BoxLayout();
-        contentBox.add_actor(titleBox);
-
-        this.titleLabel = new St.Label({ style_class: 'message-title',
-                                         x_expand: true,
-                                         x_align: Clutter.ActorAlign.START });
-        this.setTitle(title);
-        titleBox.add_actor(this.titleLabel);
-
-        this._secondaryBin = new St.Bin({ style_class: 'message-secondary-bin' });
-        titleBox.add_actor(this._secondaryBin);
-
-        let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic',
-                                      icon_size: 16 });
-        this._closeButton = new St.Button({ child: closeIcon, visible: false });
-        titleBox.add_actor(this._closeButton);
-
-        this._bodyStack = new St.Widget({ x_expand: true });
-        this._bodyStack.layout_manager = new LabelExpanderLayout();
-        contentBox.add_actor(this._bodyStack);
-
-        this.bodyLabel = new URLHighlighter('', false, this._useBodyMarkup);
-        this.bodyLabel.actor.add_style_class_name('message-body');
-        this._bodyStack.add_actor(this.bodyLabel.actor);
-        this.setBody(body);
-
-        this._closeButton.connect('clicked', Lang.bind(this, this.close));
-        this.actor.connect('notify::hover', Lang.bind(this, this._sync));
-        this.actor.connect('clicked', Lang.bind(this, this._onClicked));
-        this.actor.connect('destroy', Lang.bind(this, this._onDestroy));
-        this._sync();
-    },
-
-    close: function() {
-        this.emit('close');
-    },
-
-    setIcon: function(actor) {
-        this._iconBin.child = actor;
-        this._iconBin.visible = (actor != null);
-    },
-
-    setSecondaryActor: function(actor) {
-        this._secondaryBin.child = actor;
-    },
-
-    setTitle: function(text) {
-        let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : '';
-        this.titleLabel.clutter_text.set_markup(title);
-    },
-
-    setBody: function(text) {
-        this._bodyText = text;
-        this.bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '',
-                                 this._useBodyMarkup);
-        if (this._expandedLabel)
-            this._expandedLabel.setMarkup(text, this._useBodyMarkup);
-    },
-
-    setUseBodyMarkup: function(enable) {
-        if (this._useBodyMarkup === enable)
-            return;
-        this._useBodyMarkup = enable;
-        if (this.bodyLabel)
-            this.setBody(this._bodyText);
-    },
-
-    setActionArea: function(actor) {
-        if (actor == null) {
-            if (this._actionBin.get_n_children() > 0)
-                this._actionBin.get_child_at_index(0).destroy();
-            return;
-        }
-
-        if (this._actionBin.get_n_children() > 0)
-            throw new Error('Message already has an action area');
-
-        this._actionBin.add_actor(actor);
-        this._actionBin.visible = this.expanded;
-    },
-
-    setExpandedBody: function(actor) {
-        if (actor == null) {
-            if (this._bodyStack.get_n_children() > 1)
-                this._bodyStack.get_child_at_index(1).destroy();
-            return;
-        }
-
-        if (this._bodyStack.get_n_children() > 1)
-            throw new Error('Message already has an expanded body actor');
-
-        this._bodyStack.insert_child_at_index(actor, 1);
-    },
-
-    setExpandedLines: function(nLines) {
-        this._bodyStack.layout_manager.expandLines = nLines;
-    },
-
-    expand: function(animate) {
-        this.expanded = true;
-
-        this._actionBin.visible = (this._actionBin.get_n_children() > 0);
-
-        if (this._bodyStack.get_n_children() < 2) {
-            this._expandedLabel = new URLHighlighter(this._bodyText,
-                                                     true, this._useBodyMarkup);
-            this.setExpandedBody(this._expandedLabel.actor);
-        }
-
-        if (animate) {
-            Tweener.addTween(this._bodyStack.layout_manager,
-                             { expansion: 1,
-                               time: MessageTray.ANIMATION_TIME,
-                               transition: 'easeOutQuad' });
-            this._actionBin.scale_y = 0;
-            Tweener.addTween(this._actionBin,
-                             { scale_y: 1,
-                               time: MessageTray.ANIMATION_TIME,
-                               transition: 'easeOutQuad' });
-        } else {
-            this._bodyStack.layout_manager.expansion = 1;
-            this._actionBin.scale_y = 1;
-        }
-
-        this.emit('expanded');
-    },
-
-    unexpand: function(animate) {
-        if (animate) {
-            Tweener.addTween(this._bodyStack.layout_manager,
-                             { expansion: 0,
-                               time: MessageTray.ANIMATION_TIME,
-                               transition: 'easeOutQuad' });
-            Tweener.addTween(this._actionBin,
-                             { scale_y: 0,
-                               time: MessageTray.ANIMATION_TIME,
-                               transition: 'easeOutQuad',
-                               onCompleteScope: this,
-                               onComplete: function() {
-                                   this._actionBin.hide();
-                                   this.expanded = false;
-                               }});
-        } else {
-            this._bodyStack.layout_manager.expansion = 0;
-            this._actionBin.scale_y = 0;
-            this.expanded = false;
-        }
-
-        this.emit('unexpanded');
-    },
-
-    canClose: function() {
-        return true;
-    },
-
-    _sync: function() {
-        let hovered = this.actor.hover;
-        this._closeButton.visible = hovered && this.canClose();
-        this._secondaryBin.visible = !hovered;
-    },
-
-    _onClicked: function() {
-    },
-
-    _onDestroy: function() {
-    },
-
-    _onKeyPressed: function(a, event) {
-        let keysym = event.get_key_symbol();
-
-        if (keysym == Clutter.KEY_Delete ||
-            keysym == Clutter.KEY_KP_Delete) {
-            this.close();
-            return Clutter.EVENT_STOP;
-        }
-        return Clutter.EVENT_PROPAGATE;
-    }
-});
-Signals.addSignalMethods(Message.prototype);
-
 const EventMessage = new Lang.Class({
     Name: 'EventMessage',
-    Extends: Message,
+    Extends: MessageList.Message,
 
     _init: function(event, date) {
         this._event = event;
@@ -1210,13 +715,13 @@ const EventMessage = new Lang.Class({
     },
 
     canClose: function() {
-        return _isToday(this._date);
+        return isToday(this._date);
     }
 });
 
 const NotificationMessage = new Lang.Class({
     Name: 'NotificationMessage',
-    Extends: Message,
+    Extends: MessageList.Message,
 
     _init: function(notification) {
         this.notification = notification;
@@ -1270,217 +775,9 @@ const NotificationMessage = new Lang.Class({
     }
 });
 
-const MessageListSection = new Lang.Class({
-    Name: 'MessageListSection',
-
-    _init: function(title) {
-        this.actor = new St.BoxLayout({ style_class: 'message-list-section',
-                                        clip_to_allocation: true,
-                                        x_expand: true, vertical: true });
-        let titleBox = new St.BoxLayout({ style_class: 'message-list-section-title-box' });
-        this.actor.add_actor(titleBox);
-
-        this._title = new St.Button({ style_class: 'message-list-section-title',
-                                      label: title,
-                                      can_focus: true,
-                                      x_expand: true,
-                                      x_align: St.Align.START });
-        titleBox.add_actor(this._title);
-
-        this._title.connect('clicked', Lang.bind(this, this._onTitleClicked));
-        this._title.connect('key-focus-in', Lang.bind(this, this._onKeyFocusIn));
-
-        let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic' });
-        this._closeButton = new St.Button({ style_class: 'message-list-section-close',
-                                            child: closeIcon,
-                                            accessible_name: _("Clear section"),
-                                            can_focus: true });
-        this._closeButton.set_x_align(Clutter.ActorAlign.END);
-        titleBox.add_actor(this._closeButton);
-
-        this._closeButton.connect('clicked', Lang.bind(this, this.clear));
-
-        this._list = new St.BoxLayout({ style_class: 'message-list-section-list',
-                                        vertical: true });
-        this.actor.add_actor(this._list);
-
-        this._list.connect('actor-added', Lang.bind(this, this._sync));
-        this._list.connect('actor-removed', Lang.bind(this, this._sync));
-
-        let id = Main.sessionMode.connect('updated',
-                                          Lang.bind(this, this._sync));
-        this.actor.connect('destroy', function() {
-            Main.sessionMode.disconnect(id);
-        });
-
-        this._messages = new Map();
-        this._date = new Date();
-        this.empty = true;
-        this._sync();
-    },
-
-    _onTitleClicked: function() {
-        Main.overview.hide();
-        Main.panel.closeCalendar();
-    },
-
-    _onKeyFocusIn: function(actor) {
-        this.emit('key-focus-in', actor);
-    },
-
-    get allowed() {
-        return true;
-    },
-
-    setDate: function(date) {
-        if (_sameDay(date, this._date))
-            return;
-        this._date = date;
-        this._sync();
-    },
-
-    addMessage: function(message, animate) {
-        this.addMessageAtIndex(message, -1, animate);
-    },
-
-    addMessageAtIndex: function(message, index, animate) {
-        let obj = {
-            container: null,
-            destroyId: 0,
-            keyFocusId: 0,
-            closeId: 0
-        };
-        let pivot = new Clutter.Point({ x: .5, y: .5 });
-        let scale = animate ? 0 : 1;
-        obj.container = new St.Widget({ layout_manager: new ScaleLayout(),
-                                        pivot_point: pivot,
-                                        scale_x: scale, scale_y: scale });
-        obj.keyFocusId = message.actor.connect('key-focus-in',
-            Lang.bind(this, this._onKeyFocusIn));
-        obj.destroyId = message.actor.connect('destroy',
-            Lang.bind(this, function() {
-                this.removeMessage(message, false);
-            }));
-        obj.closeId = message.connect('close',
-            Lang.bind(this, function() {
-                this.removeMessage(message, true);
-            }));
-
-        this._messages.set(message, obj);
-        obj.container.add_actor(message.actor);
-
-        this._list.insert_child_at_index(obj.container, index);
-
-        if (animate)
-            Tweener.addTween(obj.container, { scale_x: 1,
-                                              scale_y: 1,
-                                              time: MESSAGE_ANIMATION_TIME,
-                                              transition: 'easeOutQuad' });
-    },
-
-    moveMessage: function(message, index, animate) {
-        let obj = this._messages.get(message);
-
-        if (!animate) {
-            this._list.set_child_at_index(obj.container, index);
-            return;
-        }
-
-        let onComplete = Lang.bind(this, function() {
-            this._list.set_child_at_index(obj.container, index);
-            Tweener.addTween(obj.container, { scale_x: 1,
-                                              scale_y: 1,
-                                              time: MESSAGE_ANIMATION_TIME,
-                                              transition: 'easeOutQuad' });
-        });
-        Tweener.addTween(obj.container, { scale_x: 0,
-                                          scale_y: 0,
-                                          time: MESSAGE_ANIMATION_TIME,
-                                          transition: 'easeOutQuad',
-                                          onComplete: onComplete });
-    },
-
-    removeMessage: function(message, animate) {
-        let obj = this._messages.get(message);
-
-        message.actor.disconnect(obj.destroyId);
-        message.actor.disconnect(obj.keyFocusId);
-        message.disconnect(obj.closeId);
-
-        this._messages.delete(message);
-
-        if (animate) {
-            Tweener.addTween(obj.container, { scale_x: 0, scale_y: 0,
-                                              time: MESSAGE_ANIMATION_TIME,
-                                              transition: 'easeOutQuad',
-                                              onComplete: function() {
-                                                  obj.container.destroy();
-                                                  global.sync_pointer();
-                                              }});
-        } else {
-            obj.container.destroy();
-            global.sync_pointer();
-        }
-    },
-
-    clear: function() {
-        let messages = [...this._messages.keys()].filter(function(message) {
-            return message.canClose();
-        });
-
-        // If there are few messages, letting them all zoom out looks OK
-        if (messages.length < 2) {
-            messages.forEach(function(message) {
-                message.close();
-            });
-        } else {
-            // Otherwise we slide them out one by one, and then zoom them
-            // out "off-screen" in the end to smoothly shrink the parent
-            let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5);
-            for (let i = 0; i < messages.length; i++) {
-                let message = messages[i];
-                let obj = this._messages.get(message);
-                Tweener.addTween(obj.container,
-                                 { anchor_x: this._list.width,
-                                   opacity: 0,
-                                   time: MESSAGE_ANIMATION_TIME,
-                                   delay: i * delay,
-                                   transition: 'easeOutQuad',
-                                   onComplete: function() {
-                                       message.close();
-                                   }});
-            }
-        }
-    },
-
-    _canClear: function() {
-        for (let message of this._messages.keys())
-            if (message.canClose())
-                return true;
-        return false;
-    },
-
-    _shouldShow: function() {
-        return !this.empty;
-    },
-
-    _sync: function() {
-        let empty = this._list.get_n_children() == 0;
-        let changed = this.empty !== empty;
-        this.empty = empty;
-
-        if (changed)
-            this.emit('empty-changed');
-
-        this._closeButton.visible = this._canClear();
-        this.actor.visible = this.allowed && this._shouldShow();
-    }
-});
-Signals.addSignalMethods(MessageListSection.prototype);
-
 const EventsSection = new Lang.Class({
     Name: 'EventsSection',
-    Extends: MessageListSection,
+    Extends: MessageList.MessageListSection,
 
     _init: function() {
         this._desktopSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
@@ -1519,14 +816,14 @@ const EventsSection = new Lang.Class({
     },
 
     _updateTitle: function() {
-        if (_isToday(this._date)) {
+        if (isToday(this._date)) {
             this._title.label = _("Events");
             return;
         }
 
         let dayFormat;
         let now = new Date();
-        if (_sameYear(this._date, now))
+        if (sameYear(this._date, now))
             /* Translators: Shown on calendar heading when selected day occurs on current year */
             dayFormat = Shell.util_translate_time_string(NC_("calendar heading",
                                                              "%A, %B %d"));
@@ -1602,7 +899,7 @@ const EventsSection = new Lang.Class({
     },
 
     _shouldShow: function() {
-        return !this.empty || !_isToday(this._date);
+        return !this.empty || !isToday(this._date);
     },
 
     _sync: function() {
@@ -1615,7 +912,7 @@ const EventsSection = new Lang.Class({
 
 const NotificationSection = new Lang.Class({
     Name: 'NotificationSection',
-    Extends: MessageListSection,
+    Extends: MessageList.MessageListSection,
 
     _init: function() {
         this.parent(_("Notifications"));
@@ -1721,7 +1018,7 @@ const NotificationSection = new Lang.Class({
     },
 
     _shouldShow: function() {
-        return !this.empty && _isToday(this._date);
+        return !this.empty && isToday(this._date);
     },
 
     _sync: function() {
@@ -1754,20 +1051,20 @@ const Placeholder = new Lang.Class({
     },
 
     setDate: function(date) {
-        if (_sameDay(this._date, date))
+        if (sameDay(this._date, date))
             return;
         this._date = date;
         this._sync();
     },
 
     _sync: function() {
-        let isToday = _isToday(this._date);
-        if (isToday && this._icon.gicon == this._todayIcon)
+        let today = isToday(this._date);
+        if (today && this._icon.gicon == this._todayIcon)
             return;
-        if (!isToday && this._icon.gicon == this._otherIcon)
+        if (!today && this._icon.gicon == this._otherIcon)
             return;
 
-        if (isToday) {
+        if (today) {
             this._icon.gicon = this._todayIcon;
             this._label.text = _("No Notifications");
         } else {
@@ -1777,8 +1074,8 @@ const Placeholder = new Lang.Class({
     }
 });
 
-const MessageList = new Lang.Class({
-    Name: 'MessageList',
+const CalendarMessageList = new Lang.Class({
+    Name: 'CalendarMessageList',
 
     _init: function() {
         this.actor = new St.Widget({ style_class: 'message-list',
diff --git a/js/ui/components/telepathyClient.js b/js/ui/components/telepathyClient.js
index f571319..bde21a8 100644
--- a/js/ui/components/telepathyClient.js
+++ b/js/ui/components/telepathyClient.js
@@ -12,9 +12,9 @@ const St = imports.gi.St;
 const Tpl = imports.gi.TelepathyLogger;
 const Tp = imports.gi.TelepathyGLib;
 
-const Calendar = imports.ui.calendar;
 const History = imports.misc.history;
 const Main = imports.ui.main;
+const MessageList = imports.ui.messageList;
 const MessageTray = imports.ui.messageTray;
 const Params = imports.misc.params;
 const PopupMenu = imports.ui.popupMenu;
@@ -866,7 +866,7 @@ const ChatNotificationBanner = new Lang.Class({
     },
 
     _addMessage: function(message) {
-        let highlighter = new Calendar.URLHighlighter(message.body, true, true);
+        let highlighter = new MessageList.URLHighlighter(message.body, true, true);
         let body = highlighter.actor;
 
         let styles = message.styles;
diff --git a/js/ui/dateMenu.js b/js/ui/dateMenu.js
index 12d7eea..98c4aa4 100644
--- a/js/ui/dateMenu.js
+++ b/js/ui/dateMenu.js
@@ -360,7 +360,7 @@ const DateMenuButton = new Lang.Class({
         }));
 
         // Fill up the first column
-        this._messageList = new Calendar.MessageList();
+        this._messageList = new Calendar.CalendarMessageList();
         hbox.add(this._messageList.actor, { expand: true, y_fill: false, y_align: St.Align.START });
 
         // Fill up the second column
diff --git a/js/ui/messageList.js b/js/ui/messageList.js
new file mode 100644
index 0000000..344eee9
--- /dev/null
+++ b/js/ui/messageList.js
@@ -0,0 +1,713 @@
+const Atk = imports.gi.Atk;
+const Clutter = imports.gi.Clutter;
+const GLib = imports.gi.GLib;
+const GObject = imports.gi.GObject;
+const Lang = imports.lang;
+const Main = imports.ui.main;
+const MessageTray = imports.ui.messageTray;
+const Meta = imports.gi.Meta;
+const Pango = imports.gi.Pango;
+const Signals = imports.signals;
+const St = imports.gi.St;
+
+const Calendar = imports.ui.calendar;
+const Tweener = imports.ui.tweener;
+const Util = imports.misc.util;
+
+const MESSAGE_ANIMATION_TIME = 0.1;
+
+const DEFAULT_EXPAND_LINES = 6;
+
+function _fixMarkup(text, allowMarkup) {
+    if (allowMarkup) {
+        // Support &amp;, &quot;, &apos;, &lt; and &gt;, escape all other
+        // occurrences of '&'.
+        let _text = text.replace(/&(?!amp;|quot;|apos;|lt;|gt;)/g, '&amp;');
+
+        // Support <b>, <i>, and <u>, escape anything else
+        // so it displays as raw markup.
+        _text = _text.replace(/<(?!\/?[biu]>)/g, '&lt;');
+
+        try {
+            Pango.parse_markup(_text, -1, '');
+            return _text;
+        } catch (e) {}
+    }
+
+    // !allowMarkup, or invalid markup
+    return GLib.markup_escape_text(text, -1);
+}
+
+const URLHighlighter = new Lang.Class({
+    Name: 'URLHighlighter',
+
+    _init: function(text, lineWrap, allowMarkup) {
+        if (!text)
+            text = '';
+        this.actor = new St.Label({ reactive: true, style_class: 'url-highlighter',
+                                    x_expand: true, x_align: Clutter.ActorAlign.START });
+        this._linkColor = '#ccccff';
+        this.actor.connect('style-changed', Lang.bind(this, function() {
+            let [hasColor, color] = this.actor.get_theme_node().lookup_color('link-color', false);
+            if (hasColor) {
+                let linkColor = color.to_string().substr(0, 7);
+                if (linkColor != this._linkColor) {
+                    this._linkColor = linkColor;
+                    this._highlightUrls();
+                }
+            }
+        }));
+        this.actor.clutter_text.line_wrap = lineWrap;
+        this.actor.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR;
+
+        this.setMarkup(text, allowMarkup);
+        this.actor.connect('button-press-event', Lang.bind(this, function(actor, event) {
+            // Don't try to URL highlight when invisible.
+            // The MessageTray doesn't actually hide us, so
+            // we need to check for paint opacities as well.
+            if (!actor.visible || actor.get_paint_opacity() == 0)
+                return Clutter.EVENT_PROPAGATE;
+
+            // Keep Notification.actor from seeing this and taking
+            // a pointer grab, which would block our button-release-event
+            // handler, if an URL is clicked
+            return this._findUrlAtPos(event) != -1;
+        }));
+        this.actor.connect('button-release-event', Lang.bind(this, function (actor, event) {
+            if (!actor.visible || actor.get_paint_opacity() == 0)
+                return Clutter.EVENT_PROPAGATE;
+
+            let urlId = this._findUrlAtPos(event);
+            if (urlId != -1) {
+                let url = this._urls[urlId].url;
+                if (url.indexOf(':') == -1)
+                    url = 'http://' + url;
+
+                Gio.app_info_launch_default_for_uri(url, global.create_app_launch_context(0, -1));
+                return Clutter.EVENT_STOP;
+            }
+            return Clutter.EVENT_PROPAGATE;
+        }));
+        this.actor.connect('motion-event', Lang.bind(this, function(actor, event) {
+            if (!actor.visible || actor.get_paint_opacity() == 0)
+                return Clutter.EVENT_PROPAGATE;
+
+            let urlId = this._findUrlAtPos(event);
+            if (urlId != -1 && !this._cursorChanged) {
+                global.screen.set_cursor(Meta.Cursor.POINTING_HAND);
+                this._cursorChanged = true;
+            } else if (urlId == -1) {
+                global.screen.set_cursor(Meta.Cursor.DEFAULT);
+                this._cursorChanged = false;
+            }
+            return Clutter.EVENT_PROPAGATE;
+        }));
+        this.actor.connect('leave-event', Lang.bind(this, function() {
+            if (!this.actor.visible || this.actor.get_paint_opacity() == 0)
+                return Clutter.EVENT_PROPAGATE;
+
+            if (this._cursorChanged) {
+                this._cursorChanged = false;
+                global.screen.set_cursor(Meta.Cursor.DEFAULT);
+            }
+            return Clutter.EVENT_PROPAGATE;
+        }));
+    },
+
+    setMarkup: function(text, allowMarkup) {
+        text = text ? _fixMarkup(text, allowMarkup) : '';
+        this._text = text;
+
+        this.actor.clutter_text.set_markup(text);
+        /* clutter_text.text contain text without markup */
+        this._urls = Util.findUrls(this.actor.clutter_text.text);
+        this._highlightUrls();
+    },
+
+    _highlightUrls: function() {
+        // text here contain markup
+        let urls = Util.findUrls(this._text);
+        let markup = '';
+        let pos = 0;
+        for (let i = 0; i < urls.length; i++) {
+            let url = urls[i];
+            let str = this._text.substr(pos, url.pos - pos);
+            markup += str + '<span foreground="' + this._linkColor + '"><u>' + url.url + '</u></span>';
+            pos = url.pos + url.url.length;
+        }
+        markup += this._text.substr(pos);
+        this.actor.clutter_text.set_markup(markup);
+    },
+
+    _findUrlAtPos: function(event) {
+        let success;
+        let [x, y] = event.get_coords();
+        [success, x, y] = this.actor.transform_stage_point(x, y);
+        let find_pos = -1;
+        for (let i = 0; i < this.actor.clutter_text.text.length; i++) {
+            let [success, px, py, line_height] = this.actor.clutter_text.position_to_coords(i);
+            if (py > y || py + line_height < y || x < px)
+                continue;
+            find_pos = i;
+        }
+        if (find_pos != -1) {
+            for (let i = 0; i < this._urls.length; i++)
+            if (find_pos >= this._urls[i].pos &&
+                this._urls[i].pos + this._urls[i].url.length > find_pos)
+                return i;
+        }
+        return -1;
+    }
+});
+
+const ScaleLayout = new Lang.Class({
+    Name: 'ScaleLayout',
+    Extends: Clutter.BinLayout,
+
+    _connectContainer: function(container) {
+        if (this._container == container)
+            return;
+
+        if (this._container)
+            for (let id of this._signals)
+                this._container.disconnect(id);
+
+        this._container = container;
+        this._signals = [];
+
+        if (this._container)
+            for (let signal of ['notify::scale-x', 'notify::scale-y']) {
+                let id = this._container.connect(signal, Lang.bind(this,
+                    function() {
+                        this.layout_changed();
+                    }));
+                this._signals.push(id);
+            }
+    },
+
+    vfunc_get_preferred_width: function(container, forHeight) {
+        this._connectContainer(container);
+
+        let [min, nat] = this.parent(container, forHeight);
+        return [Math.floor(min * container.scale_x),
+                Math.floor(nat * container.scale_x)];
+    },
+
+    vfunc_get_preferred_height: function(container, forWidth) {
+        this._connectContainer(container);
+
+        let [min, nat] = this.parent(container, forWidth);
+        return [Math.floor(min * container.scale_y),
+                Math.floor(nat * container.scale_y)];
+    }
+});
+
+const LabelExpanderLayout = new Lang.Class({
+    Name: 'LabelExpanderLayout',
+    Extends: Clutter.LayoutManager,
+    Properties: { 'expansion': GObject.ParamSpec.double('expansion',
+                                                        'Expansion',
+                                                        'Expansion of the layout, between 0 (collapsed) ' +
+                                                        'and 1 (fully expanded',
+                                                         GObject.ParamFlags.READABLE | 
GObject.ParamFlags.WRITABLE,
+                                                         0, 1, 0)},
+
+    _init: function(params) {
+        this._expansion = 0;
+        this._expandLines = DEFAULT_EXPAND_LINES;
+
+        this.parent(params);
+    },
+
+    get expansion() {
+        return this._expansion;
+    },
+
+    set expansion(v) {
+        if (v == this._expansion)
+            return;
+        this._expansion = v;
+        this.notify('expansion');
+
+        let visibleIndex = this._expansion > 0 ? 1 : 0;
+        for (let i = 0; this._container && i < this._container.get_n_children(); i++)
+            this._container.get_child_at_index(i).visible = (i == visibleIndex);
+
+        this.layout_changed();
+    },
+
+    set expandLines(v) {
+        if (v == this._expandLines)
+            return;
+        this._expandLines = v;
+        if (this._expansion > 0)
+            this.layout_changed();
+    },
+
+    vfunc_set_container: function(container) {
+        this._container = container;
+    },
+
+    vfunc_get_preferred_width: function(container, forHeight) {
+        let [min, nat] = [0, 0];
+
+        for (let i = 0; i < container.get_n_children(); i++) {
+            if (i > 1)
+                break; // we support one unexpanded + one expanded child
+
+            let child = container.get_child_at_index(i);
+            let [childMin, childNat] = child.get_preferred_width(forHeight);
+            [min, nat] = [Math.max(min, childMin), Math.max(nat, childNat)];
+        }
+
+        return [min, nat];
+    },
+
+    vfunc_get_preferred_height: function(container, forWidth) {
+        let [min, nat] = [0, 0];
+
+        let children = container.get_children();
+        if (children[0])
+            [min, nat] = children[0].get_preferred_height(forWidth);
+
+        if (children[1]) {
+            let [min2, nat2] = children[1].get_preferred_height(forWidth);
+            let [expMin, expNat] = [Math.min(min2, min * this._expandLines),
+                                    Math.min(nat2, nat * this._expandLines)];
+            [min, nat] = [min + this._expansion * (expMin - min),
+                          nat + this._expansion * (expNat - nat)];
+        }
+
+        return [min, nat];
+    },
+
+    vfunc_allocate: function(container, box, flags) {
+        for (let i = 0; i < container.get_n_children(); i++) {
+            let child = container.get_child_at_index(i);
+
+            if (child.visible)
+                child.allocate(box, flags);
+        }
+
+    }
+});
+
+const Message = new Lang.Class({
+    Name: 'Message',
+
+    _init: function(title, body) {
+        this.expanded = false;
+
+        this.actor = new St.Button({ style_class: 'message',
+                                     accessible_role: Atk.Role.NOTIFICATION,
+                                     can_focus: true,
+                                     x_expand: true, x_fill: true });
+        this.actor.connect('key-press-event',
+                           Lang.bind(this, this._onKeyPressed));
+
+        let vbox = new St.BoxLayout({ vertical: true });
+        this.actor.set_child(vbox);
+
+        let hbox = new St.BoxLayout();
+        vbox.add_actor(hbox);
+
+        this._actionBin = new St.Widget({ layout_manager: new ScaleLayout(),
+                                          visible: false });
+        vbox.add_actor(this._actionBin);
+
+        this._iconBin = new St.Bin({ style_class: 'message-icon-bin',
+                                     y_expand: true,
+                                     visible: false });
+        hbox.add_actor(this._iconBin);
+
+        let contentBox = new St.BoxLayout({ style_class: 'message-content',
+                                            vertical: true, x_expand: true });
+        hbox.add_actor(contentBox);
+
+        let titleBox = new St.BoxLayout();
+        contentBox.add_actor(titleBox);
+
+        this.titleLabel = new St.Label({ style_class: 'message-title',
+                                         x_expand: true,
+                                         x_align: Clutter.ActorAlign.START });
+        this.setTitle(title);
+        titleBox.add_actor(this.titleLabel);
+
+        this._secondaryBin = new St.Bin({ style_class: 'message-secondary-bin' });
+        titleBox.add_actor(this._secondaryBin);
+
+        let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic',
+                                      icon_size: 16 });
+        this._closeButton = new St.Button({ child: closeIcon, visible: false });
+        titleBox.add_actor(this._closeButton);
+
+        this._bodyStack = new St.Widget({ x_expand: true });
+        this._bodyStack.layout_manager = new LabelExpanderLayout();
+        contentBox.add_actor(this._bodyStack);
+
+        this.bodyLabel = new URLHighlighter('', false, this._useBodyMarkup);
+        this.bodyLabel.actor.add_style_class_name('message-body');
+        this._bodyStack.add_actor(this.bodyLabel.actor);
+        this.setBody(body);
+
+        this._closeButton.connect('clicked', Lang.bind(this, this.close));
+        this.actor.connect('notify::hover', Lang.bind(this, this._sync));
+        this.actor.connect('clicked', Lang.bind(this, this._onClicked));
+        this.actor.connect('destroy', Lang.bind(this, this._onDestroy));
+        this._sync();
+    },
+
+    close: function() {
+        this.emit('close');
+    },
+
+    setIcon: function(actor) {
+        this._iconBin.child = actor;
+        this._iconBin.visible = (actor != null);
+    },
+
+    setSecondaryActor: function(actor) {
+        this._secondaryBin.child = actor;
+    },
+
+    setTitle: function(text) {
+        let title = text ? _fixMarkup(text.replace(/\n/g, ' '), false) : '';
+        this.titleLabel.clutter_text.set_markup(title);
+    },
+
+    setBody: function(text) {
+        this._bodyText = text;
+        this.bodyLabel.setMarkup(text ? text.replace(/\n/g, ' ') : '',
+                                 this._useBodyMarkup);
+        if (this._expandedLabel)
+            this._expandedLabel.setMarkup(text, this._useBodyMarkup);
+    },
+
+    setUseBodyMarkup: function(enable) {
+        if (this._useBodyMarkup === enable)
+            return;
+        this._useBodyMarkup = enable;
+        if (this.bodyLabel)
+            this.setBody(this._bodyText);
+    },
+
+    setActionArea: function(actor) {
+        if (actor == null) {
+            if (this._actionBin.get_n_children() > 0)
+                this._actionBin.get_child_at_index(0).destroy();
+            return;
+        }
+
+        if (this._actionBin.get_n_children() > 0)
+            throw new Error('Message already has an action area');
+
+        this._actionBin.add_actor(actor);
+        this._actionBin.visible = this.expanded;
+    },
+
+    setExpandedBody: function(actor) {
+        if (actor == null) {
+            if (this._bodyStack.get_n_children() > 1)
+                this._bodyStack.get_child_at_index(1).destroy();
+            return;
+        }
+
+        if (this._bodyStack.get_n_children() > 1)
+            throw new Error('Message already has an expanded body actor');
+
+        this._bodyStack.insert_child_at_index(actor, 1);
+    },
+
+    setExpandedLines: function(nLines) {
+        this._bodyStack.layout_manager.expandLines = nLines;
+    },
+
+    expand: function(animate) {
+        this.expanded = true;
+
+        this._actionBin.visible = (this._actionBin.get_n_children() > 0);
+
+        if (this._bodyStack.get_n_children() < 2) {
+            this._expandedLabel = new URLHighlighter(this._bodyText,
+                                                     true, this._useBodyMarkup);
+            this.setExpandedBody(this._expandedLabel.actor);
+        }
+
+        if (animate) {
+            Tweener.addTween(this._bodyStack.layout_manager,
+                             { expansion: 1,
+                               time: MessageTray.ANIMATION_TIME,
+                               transition: 'easeOutQuad' });
+            this._actionBin.scale_y = 0;
+            Tweener.addTween(this._actionBin,
+                             { scale_y: 1,
+                               time: MessageTray.ANIMATION_TIME,
+                               transition: 'easeOutQuad' });
+        } else {
+            this._bodyStack.layout_manager.expansion = 1;
+            this._actionBin.scale_y = 1;
+        }
+
+        this.emit('expanded');
+    },
+
+    unexpand: function(animate) {
+        if (animate) {
+            Tweener.addTween(this._bodyStack.layout_manager,
+                             { expansion: 0,
+                               time: MessageTray.ANIMATION_TIME,
+                               transition: 'easeOutQuad' });
+            Tweener.addTween(this._actionBin,
+                             { scale_y: 0,
+                               time: MessageTray.ANIMATION_TIME,
+                               transition: 'easeOutQuad',
+                               onCompleteScope: this,
+                               onComplete: function() {
+                                   this._actionBin.hide();
+                                   this.expanded = false;
+                               }});
+        } else {
+            this._bodyStack.layout_manager.expansion = 0;
+            this._actionBin.scale_y = 0;
+            this.expanded = false;
+        }
+
+        this.emit('unexpanded');
+    },
+
+    canClose: function() {
+        return true;
+    },
+
+    _sync: function() {
+        let hovered = this.actor.hover;
+        this._closeButton.visible = hovered && this.canClose();
+        this._secondaryBin.visible = !hovered;
+    },
+
+    _onClicked: function() {
+    },
+
+    _onDestroy: function() {
+    },
+
+    _onKeyPressed: function(a, event) {
+        let keysym = event.get_key_symbol();
+
+        if (keysym == Clutter.KEY_Delete ||
+            keysym == Clutter.KEY_KP_Delete) {
+            this.close();
+            return Clutter.EVENT_STOP;
+        }
+        return Clutter.EVENT_PROPAGATE;
+    }
+});
+Signals.addSignalMethods(Message.prototype);
+
+const MessageListSection = new Lang.Class({
+    Name: 'MessageListSection',
+
+    _init: function(title) {
+        this.actor = new St.BoxLayout({ style_class: 'message-list-section',
+                                        clip_to_allocation: true,
+                                        x_expand: true, vertical: true });
+        let titleBox = new St.BoxLayout({ style_class: 'message-list-section-title-box' });
+        this.actor.add_actor(titleBox);
+
+        this._title = new St.Button({ style_class: 'message-list-section-title',
+                                      label: title,
+                                      can_focus: true,
+                                      x_expand: true,
+                                      x_align: St.Align.START });
+        titleBox.add_actor(this._title);
+
+        this._title.connect('clicked', Lang.bind(this, this._onTitleClicked));
+        this._title.connect('key-focus-in', Lang.bind(this, this._onKeyFocusIn));
+
+        let closeIcon = new St.Icon({ icon_name: 'window-close-symbolic' });
+        this._closeButton = new St.Button({ style_class: 'message-list-section-close',
+                                            child: closeIcon,
+                                            accessible_name: _("Clear section"),
+                                            can_focus: true });
+        this._closeButton.set_x_align(Clutter.ActorAlign.END);
+        titleBox.add_actor(this._closeButton);
+
+        this._closeButton.connect('clicked', Lang.bind(this, this.clear));
+
+        this._list = new St.BoxLayout({ style_class: 'message-list-section-list',
+                                        vertical: true });
+        this.actor.add_actor(this._list);
+
+        this._list.connect('actor-added', Lang.bind(this, this._sync));
+        this._list.connect('actor-removed', Lang.bind(this, this._sync));
+
+        let id = Main.sessionMode.connect('updated',
+                                          Lang.bind(this, this._sync));
+        this.actor.connect('destroy', function() {
+            Main.sessionMode.disconnect(id);
+        });
+
+        this._messages = new Map();
+        this._date = new Date();
+        this.empty = true;
+        this._sync();
+    },
+
+    _onTitleClicked: function() {
+        Main.overview.hide();
+        Main.panel.closeCalendar();
+    },
+
+    _onKeyFocusIn: function(actor) {
+        this.emit('key-focus-in', actor);
+    },
+
+    get allowed() {
+        return true;
+    },
+
+    setDate: function(date) {
+        if (Calendar.sameDay(date, this._date))
+            return;
+        this._date = date;
+        this._sync();
+    },
+
+    addMessage: function(message, animate) {
+        this.addMessageAtIndex(message, -1, animate);
+    },
+
+    addMessageAtIndex: function(message, index, animate) {
+        let obj = {
+            container: null,
+            destroyId: 0,
+            keyFocusId: 0,
+            closeId: 0
+        };
+        let pivot = new Clutter.Point({ x: .5, y: .5 });
+        let scale = animate ? 0 : 1;
+        obj.container = new St.Widget({ layout_manager: new ScaleLayout(),
+                                        pivot_point: pivot,
+                                        scale_x: scale, scale_y: scale });
+        obj.keyFocusId = message.actor.connect('key-focus-in',
+            Lang.bind(this, this._onKeyFocusIn));
+        obj.destroyId = message.actor.connect('destroy',
+            Lang.bind(this, function() {
+                this.removeMessage(message, false);
+            }));
+        obj.closeId = message.connect('close',
+            Lang.bind(this, function() {
+                this.removeMessage(message, true);
+            }));
+
+        this._messages.set(message, obj);
+        obj.container.add_actor(message.actor);
+
+        this._list.insert_child_at_index(obj.container, index);
+
+        if (animate)
+            Tweener.addTween(obj.container, { scale_x: 1,
+                                              scale_y: 1,
+                                              time: MESSAGE_ANIMATION_TIME,
+                                              transition: 'easeOutQuad' });
+    },
+
+    moveMessage: function(message, index, animate) {
+        let obj = this._messages.get(message);
+
+        if (!animate) {
+            this._list.set_child_at_index(obj.container, index);
+            return;
+        }
+
+        let onComplete = Lang.bind(this, function() {
+            this._list.set_child_at_index(obj.container, index);
+            Tweener.addTween(obj.container, { scale_x: 1,
+                                              scale_y: 1,
+                                              time: MESSAGE_ANIMATION_TIME,
+                                              transition: 'easeOutQuad' });
+        });
+        Tweener.addTween(obj.container, { scale_x: 0,
+                                          scale_y: 0,
+                                          time: MESSAGE_ANIMATION_TIME,
+                                          transition: 'easeOutQuad',
+                                          onComplete: onComplete });
+    },
+
+    removeMessage: function(message, animate) {
+        let obj = this._messages.get(message);
+
+        message.actor.disconnect(obj.destroyId);
+        message.actor.disconnect(obj.keyFocusId);
+        message.disconnect(obj.closeId);
+
+        this._messages.delete(message);
+
+        if (animate) {
+            Tweener.addTween(obj.container, { scale_x: 0, scale_y: 0,
+                                              time: MESSAGE_ANIMATION_TIME,
+                                              transition: 'easeOutQuad',
+                                              onComplete: function() {
+                                                  obj.container.destroy();
+                                                  global.sync_pointer();
+                                              }});
+        } else {
+            obj.container.destroy();
+            global.sync_pointer();
+        }
+    },
+
+    clear: function() {
+        let messages = [...this._messages.keys()].filter(function(message) {
+            return message.canClose();
+        });
+
+        // If there are few messages, letting them all zoom out looks OK
+        if (messages.length < 2) {
+            messages.forEach(function(message) {
+                message.close();
+            });
+        } else {
+            // Otherwise we slide them out one by one, and then zoom them
+            // out "off-screen" in the end to smoothly shrink the parent
+            let delay = MESSAGE_ANIMATION_TIME / Math.max(messages.length, 5);
+            for (let i = 0; i < messages.length; i++) {
+                let message = messages[i];
+                let obj = this._messages.get(message);
+                Tweener.addTween(obj.container,
+                                 { anchor_x: this._list.width,
+                                   opacity: 0,
+                                   time: MESSAGE_ANIMATION_TIME,
+                                   delay: i * delay,
+                                   transition: 'easeOutQuad',
+                                   onComplete: function() {
+                                       message.close();
+                                   }});
+            }
+        }
+    },
+
+    _canClear: function() {
+        for (let message of this._messages.keys())
+            if (message.canClose())
+                return true;
+        return false;
+    },
+
+    _shouldShow: function() {
+        return !this.empty;
+    },
+
+    _sync: function() {
+        let empty = this._list.get_n_children() == 0;
+        let changed = this.empty !== empty;
+        this.empty = empty;
+
+        if (changed)
+            this.emit('empty-changed');
+
+        this._closeButton.visible = this._canClear();
+        this.actor.visible = this.allowed && this._shouldShow();
+    }
+});
+Signals.addSignalMethods(MessageListSection.prototype);
diff --git a/po/POTFILES.in b/po/POTFILES.in
index ef12962..14a9f63 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -33,6 +33,7 @@ js/ui/keyboard.js
 js/ui/legacyTray.js
 js/ui/lookingGlass.js
 js/ui/main.js
+js/ui/messageList.js
 js/ui/messageTray.js
 js/ui/notificationDaemon.js
 js/ui/overviewControls.js


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]