1/* Copyright 2016 Software Freedom Conservancy Inc.
2 *
3 * This software is licensed under the GNU Lesser General Public License
4 * (version 2.1 or later).  See the COPYING file in this distribution.
5 */
6
7public class ConversationListView : Gtk.TreeView, Geary.BaseInterface {
8    const int LOAD_MORE_HEIGHT = 100;
9
10
11    private Application.Configuration config;
12
13    private bool enable_load_more = true;
14
15    private bool reset_adjustment = false;
16    private Gee.Set<Geary.App.Conversation>? current_visible_conversations = null;
17    private Geary.Scheduler.Scheduled? scheduled_update_visible_conversations = null;
18    private Gee.Set<Geary.App.Conversation> selected = new Gee.HashSet<Geary.App.Conversation>();
19    private Geary.IdleManager selection_update;
20    private Gtk.GestureMultiPress gesture;
21
22    // Determines if the next folder scan should avoid selecting a
23    // conversation when autoselect is enabled
24    private bool should_inhibit_autoselect = false;
25
26
27    public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected);
28
29    // Signal for when a conversation has been double-clicked, or selected and enter is pressed.
30    public signal void conversation_activated(Geary.App.Conversation activated, bool single = false);
31
32    public virtual signal void load_more() {
33        enable_load_more = false;
34    }
35
36    public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations,
37                                          Geary.NamedFlag flag);
38
39    public signal void visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible);
40
41
42    public ConversationListView(Application.Configuration config) {
43        base_ref();
44        set_show_expanders(false);
45        set_headers_visible(false);
46        set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL);
47
48        this.config = config;
49
50        append_column(create_column(ConversationListStore.Column.CONVERSATION_DATA,
51            new ConversationListCellRenderer(), ConversationListStore.Column.CONVERSATION_DATA.to_string(),
52            0));
53
54        Gtk.TreeSelection selection = get_selection();
55        selection.set_mode(Gtk.SelectionMode.MULTIPLE);
56        style_updated.connect(on_style_changed);
57
58        notify["vadjustment"].connect(on_vadjustment_changed);
59
60        key_press_event.connect(on_key_press);
61        button_press_event.connect(on_button_press);
62        gesture = new Gtk.GestureMultiPress(this);
63        gesture.pressed.connect(on_gesture_pressed);
64
65        // Set up drag and drop.
66        Gtk.drag_source_set(this, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST,
67            Gdk.DragAction.COPY | Gdk.DragAction.MOVE);
68
69        this.config.settings.changed[
70            Application.Configuration.DISPLAY_PREVIEW_KEY
71        ].connect(on_display_preview_changed);
72
73        // Watch for mouse events.
74        motion_notify_event.connect(on_motion_notify_event);
75        leave_notify_event.connect(on_leave_notify_event);
76
77        // GtkTreeView binds Ctrl+N to "move cursor to next".  Not so interested in that, so we'll
78        // remove it.
79        unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView");
80        assert(binding_set != null);
81        Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK);
82
83        this.selection_update = new Geary.IdleManager(do_selection_changed);
84        this.selection_update.priority = Geary.IdleManager.Priority.LOW;
85
86        this.visible = true;
87    }
88
89    ~ConversationListView() {
90        base_unref();
91    }
92
93    public override void destroy() {
94        this.selection_update.reset();
95        base.destroy();
96    }
97
98    public new ConversationListStore? get_model() {
99        return base.get_model() as ConversationListStore;
100    }
101
102    public new void set_model(ConversationListStore? new_store) {
103        ConversationListStore? old_store = get_model();
104        if (old_store != null) {
105            old_store.conversations.scan_started.disconnect(on_scan_started);
106            old_store.conversations.scan_completed.disconnect(on_scan_completed);
107
108            old_store.conversations_added.disconnect(on_conversations_added);
109            old_store.conversations_removed.disconnect(on_conversations_removed);
110            old_store.row_inserted.disconnect(on_rows_changed);
111            old_store.rows_reordered.disconnect(on_rows_changed);
112            old_store.row_changed.disconnect(on_rows_changed);
113            old_store.row_deleted.disconnect(on_rows_changed);
114            old_store.destroy();
115        }
116
117        if (new_store != null) {
118            new_store.conversations.scan_started.connect(on_scan_started);
119            new_store.conversations.scan_completed.connect(on_scan_completed);
120
121            new_store.row_inserted.connect(on_rows_changed);
122            new_store.rows_reordered.connect(on_rows_changed);
123            new_store.row_changed.connect(on_rows_changed);
124            new_store.row_deleted.connect(on_rows_changed);
125            new_store.conversations_removed.connect(on_conversations_removed);
126            new_store.conversations_added.connect(on_conversations_added);
127        }
128
129        // Disconnect the selection handler since we don't want to
130        // fire selection signals while changing the model.
131        Gtk.TreeSelection selection = get_selection();
132        selection.changed.disconnect(on_selection_changed);
133        base.set_model(new_store);
134        this.selected.clear();
135        selection.changed.connect(on_selection_changed);
136    }
137
138    /** Returns a read-only iteration of the current selection. */
139    public Gee.Set<Geary.App.Conversation> get_selected() {
140        return this.selected.read_only_view;
141    }
142
143    /** Returns a copy of the current selection. */
144    public Gee.Set<Geary.App.Conversation> copy_selected() {
145        var copy = new Gee.HashSet<Geary.App.Conversation>();
146        copy.add_all(this.selected);
147        return copy;
148    }
149
150    public void inhibit_next_autoselect() {
151        this.should_inhibit_autoselect = true;
152    }
153
154    public void scroll(Gtk.ScrollType where) {
155        Gtk.TreeSelection selection = get_selection();
156        weak Gtk.TreeModel model;
157        GLib.List<Gtk.TreePath> selected = selection.get_selected_rows(out model);
158        Gtk.TreePath? target_path = null;
159        Gtk.TreeIter? target_iter = null;
160        if (selected.length() > 0) {
161            switch (where) {
162            case STEP_UP:
163                target_path = selected.first().data;
164                model.get_iter(out target_iter, target_path);
165                if (model.iter_previous(ref target_iter)) {
166                    target_path = model.get_path(target_iter);
167                } else {
168                    this.get_window().beep();
169                }
170                break;
171
172            case STEP_DOWN:
173                target_path = selected.last().data;
174                model.get_iter(out target_iter, target_path);
175                if (model.iter_next(ref target_iter)) {
176                    target_path = model.get_path(target_iter);
177                } else {
178                    this.get_window().beep();
179                }
180                break;
181
182            default:
183                // no-op
184                break;
185            }
186
187            set_cursor(target_path, null, false);
188        }
189    }
190
191    private void check_load_more() {
192        ConversationListStore? model = get_model();
193        Geary.App.ConversationMonitor? conversations = (model != null)
194            ? model.conversations
195            : null;
196        if (conversations != null) {
197            // Check if we're at the very bottom of the list. If we
198            // are, it's time to issue a load_more signal.
199            Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment();
200            double upper = adjustment.get_upper();
201            double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT;
202            if (this.is_visible() &&
203                conversations.can_load_more &&
204                adjustment.get_value() >= threshold) {
205                load_more();
206            }
207
208            schedule_visible_conversations_changed();
209        }
210    }
211
212    private void on_scan_started() {
213        this.enable_load_more = false;
214    }
215
216    private void on_scan_completed() {
217        this.enable_load_more = true;
218        check_load_more();
219
220        // Select the first conversation, if autoselect is enabled,
221        // nothing has been selected yet and we're not showing a
222        // composer.
223        if (this.config.autoselect &&
224            !this.should_inhibit_autoselect &&
225            get_selection().count_selected_rows() == 0) {
226            var parent = get_toplevel() as Application.MainWindow;
227            if (parent != null && !parent.has_composer) {
228                set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false);
229            }
230        }
231
232        this.should_inhibit_autoselect = false;
233    }
234
235    private void on_conversations_added(bool start) {
236        Gtk.Adjustment? adjustment = get_adjustment();
237        if (start) {
238            // If we were at the top, we want to stay there after
239            // conversations are added.
240            this.reset_adjustment = adjustment != null && adjustment.get_value() == 0;
241        } else if (this.reset_adjustment && adjustment != null) {
242            // Pump the loop to make sure the new conversations are
243            // taking up space in the window.  Without this, setting
244            // the adjustment here is a no-op because as far as it's
245            // concerned, it's already at the top.
246            while (Gtk.events_pending())
247                Gtk.main_iteration();
248
249            adjustment.set_value(0);
250        }
251        this.reset_adjustment = false;
252    }
253
254    private void on_conversations_removed(bool start) {
255        if (!this.config.autoselect) {
256            Gtk.SelectionMode mode = start
257                // Stop GtkTreeView from automatically selecting the
258                // next row after the removed rows
259                ? Gtk.SelectionMode.NONE
260                // Allow the user to make selections again
261                : Gtk.SelectionMode.MULTIPLE;
262            get_selection().set_mode(mode);
263        }
264    }
265
266    private Gtk.Adjustment? get_adjustment() {
267        Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow;
268        if (parent == null) {
269            debug("Parent was not scrolled window");
270            return null;
271        }
272
273        return parent.get_vadjustment();
274    }
275
276    private void on_gesture_pressed(int n_press, double x, double y) {
277        if (gesture.get_current_button() != Gdk.BUTTON_PRIMARY)
278            return;
279
280        Gtk.TreePath? path;
281        get_path_at_pos((int) x, (int) y, out path, null, null, null);
282
283        // If the user clicked in an empty area, do nothing.
284        if (path == null)
285            return;
286
287        Geary.App.Conversation? c = get_model().get_conversation_at_path(path);
288        if (c == null)
289            return;
290
291        Gdk.Event event = gesture.get_last_event(gesture.get_current_sequence());
292        Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
293
294        Gdk.ModifierType state_mask;
295        event.get_state(out state_mask);
296
297        if ((state_mask & modifiers) == 0 && n_press == 1) {
298            conversation_activated(c, true);
299        } else if ((state_mask & modifiers) == Gdk.ModifierType.SHIFT_MASK && n_press == 2) {
300            conversation_activated(c);
301        }
302    }
303
304    private bool on_key_press(Gdk.EventKey event) {
305        if (this.selected.size != 1)
306            return false;
307
308        Geary.App.Conversation? c = this.selected.to_array()[0];
309        if (c == null)
310            return false;
311
312        Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
313
314        if (event.keyval == Gdk.Key.Return ||
315            event.keyval == Gdk.Key.ISO_Enter ||
316            event.keyval == Gdk.Key.KP_Enter ||
317            event.keyval == Gdk.Key.space ||
318            event.keyval == Gdk.Key.KP_Space)
319            conversation_activated(c, !((event.state & modifiers) == Gdk.ModifierType.SHIFT_MASK));
320        return false;
321    }
322
323    private bool on_button_press(Gdk.EventButton event) {
324        // Get the coordinates on the cell as well as the clicked path.
325        int cell_x;
326        int cell_y;
327        Gtk.TreePath? path;
328        get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
329
330        // If the user clicked in an empty area, do nothing.
331        if (path == null)
332            return false;
333
334        // Handle clicks to toggle read and starred status.
335        if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0 &&
336            (event.state & Gdk.ModifierType.CONTROL_MASK) == 0 &&
337            event.type == Gdk.EventType.BUTTON_PRESS) {
338
339            // Click positions depend on whether the preview is enabled.
340            bool read_clicked = false;
341            bool star_clicked = false;
342            if (this.config.display_preview) {
343                read_clicked = cell_x < 25 && cell_y >= 14 && cell_y <= 30;
344                star_clicked = cell_x < 25 && cell_y >= 40 && cell_y <= 62;
345            } else {
346                read_clicked = cell_x < 25 && cell_y >= 8 && cell_y <= 22;
347                star_clicked = cell_x < 25 && cell_y >= 28 && cell_y <= 43;
348            }
349
350            // Get the current conversation.  If it's selected, we'll apply the mark operation to
351            // all selected conversations; otherwise, it just applies to this one.
352            Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
353            Gee.Collection<Geary.App.Conversation> to_mark = (
354                this.selected.contains(conversation)
355                ? copy_selected()
356                : Geary.Collection.single(conversation)
357            );
358
359            if (read_clicked) {
360                mark_conversations(to_mark, Geary.EmailFlags.UNREAD);
361                return true;
362            } else if (star_clicked) {
363                mark_conversations(to_mark, Geary.EmailFlags.FLAGGED);
364                return true;
365            }
366        }
367
368        // Check if changing the selection will require any composers
369        // to be closed, but only on the first click of a
370        // double/triple click, so that double-clicking a draft
371        // doesn't attempt to load it then close it straight away.
372        if (event.type == Gdk.EventType.BUTTON_PRESS &&
373            !get_selection().path_is_selected(path)) {
374            var parent = get_toplevel() as Application.MainWindow;
375            if (parent != null && !parent.close_composer(false)) {
376                return true;
377            }
378        }
379
380        if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) {
381            Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
382
383            GLib.Menu context_menu_model = new GLib.Menu();
384            var main = get_toplevel() as Application.MainWindow;
385            if (main != null) {
386                if (!main.is_shift_down) {
387                    context_menu_model.append(
388                        /// Translators: Context menu item
389                        ngettext(
390                            "Move conversation to _Trash",
391                            "Move conversations to _Trash",
392                            this.selected.size
393                        ),
394                        Action.Window.prefix(
395                            Application.MainWindow.ACTION_TRASH_CONVERSATION
396                        )
397                    );
398                } else {
399                    context_menu_model.append(
400                        /// Translators: Context menu item
401                        ngettext(
402                            "_Delete conversation",
403                            "_Delete conversations",
404                            this.selected.size
405                        ),
406                        Action.Window.prefix(
407                            Application.MainWindow.ACTION_DELETE_CONVERSATION
408                        )
409                    );
410                }
411            }
412
413            if (conversation.is_unread())
414                context_menu_model.append(
415                    _("Mark as _Read"),
416                    Action.Window.prefix(
417                        Application.MainWindow.ACTION_MARK_AS_READ
418                    )
419                );
420
421            if (conversation.has_any_read_message())
422                context_menu_model.append(
423                    _("Mark as _Unread"),
424                    Action.Window.prefix(
425                        Application.MainWindow.ACTION_MARK_AS_UNREAD
426                    )
427                );
428
429            if (conversation.is_flagged()) {
430                context_menu_model.append(
431                    _("U_nstar"),
432                    Action.Window.prefix(
433                        Application.MainWindow.ACTION_MARK_AS_UNSTARRED
434                    )
435                );
436            } else {
437                context_menu_model.append(
438                    _("_Star"),
439                    Action.Window.prefix(
440                        Application.MainWindow.ACTION_MARK_AS_STARRED
441                    )
442                );
443            }
444            if ((conversation.base_folder.used_as != ARCHIVE) && (conversation.base_folder.used_as != ALL_MAIL)) {
445                context_menu_model.append(
446                    _("Archive conversation"),
447                    Action.Window.prefix(
448                        Application.MainWindow.ACTION_ARCHIVE_CONVERSATION
449                    )
450                );
451            }
452
453            Menu actions_section = new Menu();
454            actions_section.append(
455                _("_Reply"),
456                Action.Window.prefix(
457                    Application.MainWindow.ACTION_REPLY_CONVERSATION
458                )
459            );
460            actions_section.append(
461                _("R_eply All"),
462                Action.Window.prefix(
463                    Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION
464                )
465            );
466            actions_section.append(
467                _("_Forward"),
468                Action.Window.prefix(
469                    Application.MainWindow.ACTION_FORWARD_CONVERSATION
470                )
471            );
472            context_menu_model.append_section(null, actions_section);
473
474            // Use a popover rather than a regular context menu since
475            // the latter grabs the event queue, so the MainWindow
476            // will not receive events if the user releases Shift,
477            // making the trash/delete header bar state wrong.
478            Gtk.Popover context_menu = new Gtk.Popover.from_model(
479                this, context_menu_model
480            );
481            Gdk.Rectangle dest = Gdk.Rectangle();
482            dest.x = (int) event.x;
483            dest.y = (int) event.y;
484            context_menu.set_pointing_to(dest);
485            context_menu.popup();
486
487            // When the conversation under the mouse is selected, stop event propagation
488            return get_selection().path_is_selected(path);
489        }
490
491        return false;
492    }
493
494    private void on_style_changed() {
495        // Recalculate dimensions of child cells.
496        ConversationListCellRenderer.style_changed(this);
497
498        schedule_visible_conversations_changed();
499    }
500
501    private void on_value_changed() {
502        if (this.enable_load_more) {
503            check_load_more();
504        }
505    }
506
507    private static Gtk.TreeViewColumn create_column(ConversationListStore.Column column,
508        Gtk.CellRenderer renderer, string attr, int width = 0) {
509        Gtk.TreeViewColumn view_column = new Gtk.TreeViewColumn.with_attributes(column.to_string(),
510            renderer, attr, column);
511        view_column.set_resizable(true);
512
513        if (width != 0) {
514            view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);
515            view_column.set_fixed_width(width);
516        }
517
518        return view_column;
519    }
520
521    private List<Gtk.TreePath> get_all_selected_paths() {
522        Gtk.TreeModel model;
523        return get_selection().get_selected_rows(out model);
524    }
525
526    private void on_selection_changed() {
527        // Schedule processing selection changes at low idle for
528        // two reasons: (a) if a lot of changes come in
529        // back-to-back, this allows for all that activity to
530        // settle before updating state and firing signals (which
531        // results in a lot of I/O), and (b) it means the
532        // ConversationMonitor's signals may be processed in any
533        // order by this class and the ConversationListView and
534        // not result in a lot of screen flashing and (again)
535        // unnecessary I/O as both classes update selection state.
536        this.selection_update.schedule();
537    }
538
539    // Gtk.TreeSelection can fire its "changed" signal even when
540    // nothing's changed, so look for that to avoid subscribers from
541    // doing the same things (in particular, I/O) multiple times
542    private void do_selection_changed() {
543        Gee.HashSet<Geary.App.Conversation> new_selection =
544            new Gee.HashSet<Geary.App.Conversation>();
545        List<Gtk.TreePath> paths = get_all_selected_paths();
546        if (paths.length() != 0) {
547            // Conversations are selected, so collect them and
548            // signal if different
549            foreach (Gtk.TreePath path in paths) {
550                Geary.App.Conversation? conversation =
551                get_model().get_conversation_at_path(path);
552                if (conversation != null)
553                    new_selection.add(conversation);
554            }
555        }
556
557        // only notify if different than what was previously reported
558        if (this.selected.size != new_selection.size ||
559            !this.selected.contains_all(new_selection)) {
560            this.selected = new_selection;
561            conversations_selected(this.selected.read_only_view);
562        }
563    }
564
565    public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
566        Gee.HashSet<Geary.App.Conversation> visible_conversations = new Gee.HashSet<Geary.App.Conversation>();
567
568        Gtk.TreePath start_path;
569        Gtk.TreePath end_path;
570        if (!get_visible_range(out start_path, out end_path))
571            return visible_conversations;
572
573        while (start_path.compare(end_path) <= 0) {
574            Geary.App.Conversation? conversation = get_model().get_conversation_at_path(start_path);
575            if (conversation != null)
576                visible_conversations.add(conversation);
577
578            start_path.next();
579        }
580
581        return visible_conversations;
582    }
583
584    // Always returns false, so it can be used as a one-time SourceFunc
585    private bool update_visible_conversations() {
586        bool changed = false;
587        Gee.Set<Geary.App.Conversation> visible_conversations = get_visible_conversations();
588        if (this.current_visible_conversations == null ||
589            this.current_visible_conversations.size != visible_conversations.size ||
590            !this.current_visible_conversations.contains_all(visible_conversations)) {
591            this.current_visible_conversations = visible_conversations;
592            visible_conversations_changed(
593                this.current_visible_conversations.read_only_view
594            );
595            changed = true;
596        }
597        return changed;
598    }
599
600    private void schedule_visible_conversations_changed() {
601        scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations);
602    }
603
604    public void select_conversations(Gee.Collection<Geary.App.Conversation> new_selection) {
605        if (this.selected.size != new_selection.size ||
606            !this.selected.contains_all(new_selection)) {
607            var selection = get_selection();
608            selection.unselect_all();
609            var model = get_model();
610            if (model != null) {
611                foreach (var conversation in new_selection) {
612                    var path = model.get_path_for_conversation(conversation);
613                    if (path != null) {
614                        selection.select_path(path);
615                    }
616                }
617            }
618        }
619    }
620
621    private void on_rows_changed() {
622        schedule_visible_conversations_changed();
623    }
624
625    private void on_display_preview_changed() {
626        style_updated();
627        model.foreach(refresh_path);
628
629        schedule_visible_conversations_changed();
630    }
631
632    private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) {
633        model.row_changed(path, iter);
634        return false;
635    }
636
637    // Enable/disable hover effect on all selected cells.
638    private void set_hover_selected(bool hover) {
639        ConversationListCellRenderer.set_hover_selected(hover);
640        queue_draw();
641    }
642
643    private bool on_motion_notify_event(Gdk.EventMotion event) {
644        if (get_selection().count_selected_rows() > 0) {
645            Gtk.TreePath? path = null;
646            int cell_x, cell_y;
647            get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
648
649            set_hover_selected(path != null && get_selection().path_is_selected(path));
650        }
651        return Gdk.EVENT_PROPAGATE;
652    }
653
654    private bool on_leave_notify_event() {
655        if (get_selection().count_selected_rows() > 0) {
656            set_hover_selected(false);
657        }
658        return Gdk.EVENT_PROPAGATE;
659
660    }
661
662    private void on_vadjustment_changed() {
663        this.vadjustment.value_changed.connect(on_value_changed);
664    }
665
666}
667