[geary/gnumdk/conversation-listbox2] client: conversation-list: Migrate from `TreeView` to `ListBox`
- From: Cédric Bellegarde <cbellegarde src gnome org>
- To: commits-list gnome org
- Cc:
- Subject: [geary/gnumdk/conversation-listbox2] client: conversation-list: Migrate from `TreeView` to `ListBox`
- Date: Wed, 14 Sep 2022 20:48:09 +0000 (UTC)
commit 39096cc9144b622c7b75a98be2e5521c0e25b7b9
Author: Cédric Bellegarde <cedric bellegarde adishatz org>
Date: Wed Sep 14 15:12:49 2022 +0200
client: conversation-list: Migrate from `TreeView` to `ListBox`
- Replace ConversationListStore with ConversationListModel
- Get ListBox rendering
- Implement proper multiselection for ListBox
- Build row widget
Unread status
Multiple sender addresses
Per-sender unread styling
Buttons for mark as read and flag
Allow disabling preview
Occasionally update timestamp displays
- Build List widget
Port all selection (get/set/etc) APIs
Build ListModel for subscribing to ConversationMonitor
Context Menu
Use Gestures when possible
Drag and Drop
- Build Scroll widget
load more when scroll reaches near bottom
Report currently visible conversations
Fork of John Renner <john jrenner net> merge request !698
po/POTFILES.in | 8 +-
src/client/application/application-client.vala | 2 +-
.../application/application-main-window.vala | 94 +-
.../components-conversation-actions.vala | 7 +
.../components-headerbar-conversation-list.vala | 7 +
src/client/components/count-badge.vala | 5 +-
.../conversation-list-cell-renderer.vala | 73 --
.../conversation-list/conversation-list-model.vala | 127 +++
.../conversation-list-participant.vala | 61 ++
.../conversation-list/conversation-list-row.vala | 184 ++++
.../conversation-list/conversation-list-store.vala | 494 -----------
.../conversation-list/conversation-list-view.vala | 978 +++++++++------------
.../formatted-conversation-data.vala | 476 ----------
.../conversation-viewer/conversation-viewer.vala | 6 +-
src/client/meson.build | 6 +-
.../sidebar/sidebar-count-cell-renderer.vala | 2 +-
src/engine/util/util-numeric.vala | 14 +
ui/application-main-window.ui | 6 -
ui/components-conversation-actions.ui | 18 +-
ui/components-headerbar-conversation-list.ui | 19 +-
ui/conversation-list-row.ui | 255 ++++++
ui/conversation-list-view.ui | 26 +
ui/geary.css | 97 +-
ui/org.gnome.Geary.gresource.xml | 2 +
24 files changed, 1274 insertions(+), 1693 deletions(-)
---
diff --git a/po/POTFILES.in b/po/POTFILES.in
index bc7192dfe..e243c4295 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -70,10 +70,10 @@ src/client/composer/composer-widget.vala
src/client/composer/composer-window.vala
src/client/composer/contact-entry-completion.vala
src/client/composer/spell-check-popover.vala
-src/client/conversation-list/conversation-list-cell-renderer.vala
-src/client/conversation-list/conversation-list-store.vala
+src/client/conversation-list/conversation-list-model.vala
+src/client/conversation-list/conversation-list-row.vala
src/client/conversation-list/conversation-list-view.vala
-src/client/conversation-list/formatted-conversation-data.vala
+src/client/conversation-list/conversation-list-participant.vala
src/client/conversation-viewer/conversation-email.vala
src/client/conversation-viewer/conversation-list-box.vala
src/client/conversation-viewer/conversation-message.vala
@@ -467,6 +467,8 @@ ui/components-placeholder-pane.ui
ui/conversation-contact-popover.ui
ui/conversation-email.ui
ui/conversation-email-menus.ui
+ui/conversation-list-row.ui
+ui/conversation-list-view.ui
ui/conversation-message-link-popover.ui
ui/conversation-message-menus.ui
ui/conversation-message.ui
diff --git a/src/client/application/application-client.vala b/src/client/application/application-client.vala
index 04b73f8d0..ddebe6ed4 100644
--- a/src/client/application/application-client.vala
+++ b/src/client/application/application-client.vala
@@ -1203,7 +1203,7 @@ public class Application.Client : Gtk.Application {
MainWindow? current = this.last_active_main_window;
if (current != null) {
folder = current.selected_folder;
- conversations = current.conversation_list_view.copy_selected();
+ conversations = current.conversation_list_view.get_selected();
}
this.new_window.begin(folder, conversations);
}
diff --git a/src/client/application/application-main-window.vala
b/src/client/application/application-main-window.vala
index f7793bcac..539a57de1 100644
--- a/src/client/application/application-main-window.vala
+++ b/src/client/application/application-main-window.vala
@@ -355,7 +355,7 @@ public class Application.MainWindow :
// Widget descendants
public FolderList.Tree folder_list { get; private set; default = new FolderList.Tree(); }
public SearchBar search_bar { get; private set; }
- public ConversationListView conversation_list_view { get; private set; }
+ public ConversationList.View conversation_list_view { get; private set; }
public ConversationViewer conversation_viewer { get; private set; }
public Components.InfoBarStack conversation_list_info_bars {
@@ -402,10 +402,10 @@ public class Application.MainWindow :
[GtkChild] private unowned Gtk.ScrolledWindow folder_list_scrolled;
[GtkChild] private unowned Gtk.Box conversation_list_box;
- [GtkChild] private unowned Gtk.ScrolledWindow conversation_list_scrolled;
[GtkChild] private unowned Gtk.Revealer conversation_list_actions_revealer;
[GtkChild] private unowned Components.ConversationActions conversation_list_actions;
+ [GtkChild] private unowned Gtk.Frame conversation_frame;
[GtkChild] private unowned Gtk.Box conversation_viewer_box;
[GtkChild] private unowned Gtk.Revealer conversation_viewer_actions_revealer;
@@ -760,6 +760,8 @@ public class Application.MainWindow :
this.folder_open.cancel();
var cancellable = this.folder_open = new GLib.Cancellable();
+ this.conversation_list_headerbar.selection_open = false;
+
// Dispose of all existing objects for the currently
// selected model.
@@ -776,11 +778,7 @@ public class Application.MainWindow :
this.progress_monitor.remove(this.conversations.progress_monitor);
close_conversation_monitor(this.conversations);
this.conversations = null;
- }
- var conversations_model = this.conversation_list_view.get_model();
- if (conversations_model != null) {
- this.progress_monitor.remove(conversations_model.preview_monitor);
- this.conversation_list_view.set_model(null);
+ this.conversation_list_view.set_monitor(null);
}
this.conversation_list_info_bars.remove_all();
@@ -829,22 +827,17 @@ public class Application.MainWindow :
// Include fields for the conversation viewer as well so
// conversations can be displayed without having to go
// back to the db
- ConversationListStore.REQUIRED_FIELDS |
+ ConversationList.View.REQUIRED_FIELDS |
ConversationListBox.REQUIRED_FIELDS |
ConversationEmail.REQUIRED_FOR_CONSTRUCT,
MIN_CONVERSATION_COUNT
);
this.progress_monitor.add(this.conversations.progress_monitor);
- conversations_model = new ConversationListStore(
- this.conversations, this.application.config
-
- );
- this.progress_monitor.add(conversations_model.preview_monitor);
if (inhibit_autoselect) {
this.conversation_list_view.inhibit_next_autoselect();
}
- this.conversation_list_view.set_model(conversations_model);
+ this.conversation_list_view.set_monitor(this.conversations);
// disable copy/move to the new folder
foreach (var menu in this.folder_popovers) {
@@ -1323,15 +1316,13 @@ public class Application.MainWindow :
this.conversation_list_box.pack_start(
this.conversation_list_info_bars, false, false, 0
);
- this.conversation_list_view = new ConversationListView(
- this.application.config
- );
- this.conversation_list_view.load_more.connect(on_load_more);
+
+ this.conversation_list_view = new ConversationList.View(this.application.config);
this.conversation_list_view.mark_conversations.connect(on_mark_conversations);
this.conversation_list_view.conversations_selected.connect(on_conversations_selected);
this.conversation_list_view.conversation_activated.connect(on_conversation_activated);
- this.conversation_list_view.visible_conversations_changed.connect(on_visible_conversations_changed);
- this.conversation_list_scrolled.add(conversation_list_view);
+ this.conversation_list_view.visible_conversations.notify.connect(on_visible_conversations_changed);
+ this.conversation_frame.add(this.conversation_list_view);
// Conversation viewer
this.conversation_viewer = new ConversationViewer(
@@ -1349,6 +1340,11 @@ public class Application.MainWindow :
this.search_bar, "search-mode-enabled",
SYNC_CREATE | BIDIRECTIONAL
);
+ this.conversation_list_headerbar.bind_property(
+ "selection-open",
+ this.conversation_list_view, "selection-mode-enabled",
+ SYNC_CREATE | BIDIRECTIONAL
+ );
this.conversation_headerbar.bind_property(
"find-open",
this.conversation_viewer.conversation_find_bar, "search-mode-enabled",
@@ -1371,6 +1367,8 @@ public class Application.MainWindow :
this.status_bar.add(this.spinner);
this.status_bar.show_all();
+ this.conversation_list_actions.set_mark_inverted();
+
this.folder_conversation_actions = {
this.conversation_headerbar.full_actions,
this.conversation_list_actions
@@ -1540,11 +1538,7 @@ public class Application.MainWindow :
this.conversation_viewer.current_list.update_display();
}
- ConversationListStore? list_store =
- this.conversation_list_view.get_model() as ConversationListStore;
- if (list_store != null) {
- list_store.update_display();
- }
+ this.conversation_list_view.refresh_times();
}
}
@@ -1719,13 +1713,6 @@ public class Application.MainWindow :
}
}
- private void load_more() {
- if (this.is_conversation_list_shown &&
- this.conversations != null) {
- this.conversations.min_window_count += MIN_CONVERSATION_COUNT;
- }
- }
-
private void on_conversations_selected(Gee.Set<Geary.App.Conversation> selected) {
this.select_conversations.begin(selected, Gee.Collection.empty(), true);
}
@@ -1747,7 +1734,7 @@ public class Application.MainWindow :
// conversations_selected firing from the convo list,
// so we need to stop the loading spinner here.
if (!this.application.config.autoselect &&
- this.conversation_list_view.get_selection().count_selected_rows() == 0) {
+ this.conversation_list_view.get_selected().size == 0) {
this.conversation_viewer.show_none_selected();
update_conversation_actions(NONE);
}
@@ -1836,14 +1823,11 @@ public class Application.MainWindow :
case NONE:
this.conversation_list_actions_revealer.reveal_child = false;
break;
- case SINGLE:
+ default:
this.conversation_list_actions_revealer.reveal_child = (
this.outer_leaflet.folded
);
break;
- case MULTIPLE:
- this.conversation_list_actions_revealer.reveal_child = true;
- break;
}
this.update_context_dependent_actions.begin(sensitive);
@@ -2022,7 +2006,7 @@ public class Application.MainWindow :
// Done scanning. Check if we have enough messages to fill
// the conversation list; if not, trigger a load_more();
Gtk.Scrollbar? scrollbar = (
- this.conversation_list_scrolled.get_vscrollbar() as Gtk.Scrollbar
+ this.conversation_list_view.get_vscrollbar() as Gtk.Scrollbar
);
if (is_visible() &&
(scrollbar == null || !scrollbar.get_visible()) &&
@@ -2030,7 +2014,7 @@ public class Application.MainWindow :
monitor.can_load_more) {
debug("Not enough messages, loading more for folder %s",
this.selected_folder.to_string());
- load_more();
+ this.conversation_list_view.load_more(MIN_CONVERSATION_COUNT);
}
}
@@ -2043,10 +2027,6 @@ public class Application.MainWindow :
);
}
- private void on_load_more() {
- load_more();
- }
-
[GtkCallback]
private void on_map() {
this.update_ui_timeout.start();
@@ -2288,7 +2268,7 @@ public class Application.MainWindow :
if (this.selected_folder != null) {
this.controller.clear_new_messages(
this.selected_folder,
- this.conversation_list_view.get_visible_conversations()
+ this.conversation_list_view.visible_conversations
);
}
}
@@ -2322,9 +2302,9 @@ public class Application.MainWindow :
}
}
- private void on_visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible) {
+ private void on_visible_conversations_changed() {
if (this.selected_folder != null) {
- this.controller.clear_new_messages(this.selected_folder, visible);
+ this.controller.clear_new_messages(this.selected_folder,
this.conversation_list_view.visible_conversations);
}
}
@@ -2342,7 +2322,7 @@ public class Application.MainWindow :
if (this.selected_folder.used_as != DRAFTS) {
this.application.new_window.begin(
this.selected_folder,
- this.conversation_list_view.copy_selected()
+ this.conversation_list_view.get_selected()
);
} else {
// TODO: Determine how to map between conversations
@@ -2496,7 +2476,7 @@ public class Application.MainWindow :
if (location != null) {
this.controller.mark_conversations.begin(
location,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
Geary.EmailFlags.UNREAD,
false,
(obj, res) => {
@@ -2515,7 +2495,7 @@ public class Application.MainWindow :
if (location != null) {
this.controller.mark_conversations.begin(
location,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
Geary.EmailFlags.UNREAD,
true,
(obj, res) => {
@@ -2534,7 +2514,7 @@ public class Application.MainWindow :
if (location != null) {
this.controller.mark_conversations.begin(
location,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
Geary.EmailFlags.FLAGGED,
true,
(obj, res) => {
@@ -2553,7 +2533,7 @@ public class Application.MainWindow :
if (location != null) {
this.controller.mark_conversations.begin(
location,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
Geary.EmailFlags.FLAGGED,
false,
(obj, res) => {
@@ -2577,7 +2557,7 @@ public class Application.MainWindow :
this.controller.move_conversations_special.begin(
source,
destination,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
(obj, res) => {
try {
this.controller.move_conversations_special.end(res);
@@ -2596,7 +2576,7 @@ public class Application.MainWindow :
this.controller.move_conversations.begin(
source,
destination,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
(obj, res) => {
try {
this.controller.move_conversations.end(res);
@@ -2616,7 +2596,7 @@ public class Application.MainWindow :
this.controller.copy_conversations.begin(
source,
destination,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
(obj, res) => {
try {
this.controller.copy_conversations.end(res);
@@ -2635,7 +2615,7 @@ public class Application.MainWindow :
this.controller.move_conversations_special.begin(
source,
ARCHIVE,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
(obj, res) => {
try {
this.controller.move_conversations_special.end(res);
@@ -2653,7 +2633,7 @@ public class Application.MainWindow :
this.controller.move_conversations_special.begin(
source,
TRASH,
- this.conversation_list_view.copy_selected(),
+ this.conversation_list_view.get_selected(),
(obj, res) => {
try {
this.controller.move_conversations_special.end(res);
@@ -2669,7 +2649,7 @@ public class Application.MainWindow :
Geary.FolderSupport.Remove target =
this.selected_folder as Geary.FolderSupport.Remove;
Gee.Collection<Geary.App.Conversation> conversations =
- this.conversation_list_view.copy_selected();
+ this.conversation_list_view.get_selected();
if (target != null && this.prompt_delete_conversations(conversations.size)) {
this.controller.delete_conversations.begin(
target,
diff --git a/src/client/components/components-conversation-actions.vala
b/src/client/components/components-conversation-actions.vala
index 8030c424b..7ff8f2f28 100644
--- a/src/client/components/components-conversation-actions.vala
+++ b/src/client/components/components-conversation-actions.vala
@@ -95,6 +95,13 @@ public class Components.ConversationActions : Gtk.Box {
this.copy_message_button.clicked();
}
+ public void set_mark_inverted() {
+ var image = new Gtk.Image.from_icon_name(
+ "pan-up-symbolic", Gtk.IconSize.BUTTON
+ );
+ this.mark_message_button.set_image(image);
+ }
+
public void update_trash_button(bool show_trash) {
this.show_trash_button = show_trash;
update_conversation_buttons();
diff --git a/src/client/components/components-headerbar-conversation-list.vala
b/src/client/components/components-headerbar-conversation-list.vala
index 319b2a4ef..78f24d351 100644
--- a/src/client/components/components-headerbar-conversation-list.vala
+++ b/src/client/components/components-headerbar-conversation-list.vala
@@ -19,8 +19,10 @@ public class Components.ConversationListHeaderBar : Hdy.HeaderBar {
public string account { get; set; }
public string folder { get; set; }
public bool search_open { get; set; default = false; }
+ public bool selection_open { get; set; default = false; }
[GtkChild] private unowned Gtk.ToggleButton search_button;
+ [GtkChild] private unowned Gtk.ToggleButton selection_button;
[GtkChild] public unowned Gtk.Button back_button;
@@ -33,5 +35,10 @@ public class Components.ConversationListHeaderBar : Hdy.HeaderBar {
this.search_button, "active",
SYNC_CREATE | BIDIRECTIONAL
);
+ this.bind_property(
+ "selection-open",
+ this.selection_button, "active",
+ SYNC_CREATE | BIDIRECTIONAL
+ );
}
}
diff --git a/src/client/components/count-badge.vala b/src/client/components/count-badge.vala
index a0a1963d3..b5a833b01 100644
--- a/src/client/components/count-badge.vala
+++ b/src/client/components/count-badge.vala
@@ -9,6 +9,7 @@
*/
public class CountBadge : Geary.BaseObject {
public const string UNREAD_BG_COLOR = "#888888";
+ public const int SPACING = 6;
private const int FONT_SIZE_MESSAGE_COUNT = 8;
@@ -63,7 +64,7 @@ public class CountBadge : Geary.BaseObject {
Pango.Rectangle? logical_rect;
layout_num.get_pixel_extents(out ink_rect, out logical_rect);
if (ctx != null) {
- double bg_width = logical_rect.width + FormattedConversationData.SPACING;
+ double bg_width = logical_rect.width + SPACING;
double bg_height = logical_rect.height;
double radius = bg_height / 2.0;
double degrees = Math.PI / 180.0;
@@ -87,7 +88,7 @@ public class CountBadge : Geary.BaseObject {
Pango.cairo_show_layout(ctx, layout_num);
}
- width = logical_rect.width + FormattedConversationData.SPACING;
+ width = logical_rect.width + SPACING;
height = logical_rect.height;
}
}
diff --git a/src/client/conversation-list/conversation-list-model.vala
b/src/client/conversation-list/conversation-list-model.vala
new file mode 100644
index 000000000..d74cf1f0d
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-model.vala
@@ -0,0 +1,127 @@
+// The whole goal of this class to wrap the ConversationMonitor with a view that presents a sorted list
+public class ConversationList.Model {
+ internal ListStore store = new ListStore(typeof(Geary.App.Conversation));
+ internal Geary.App.ConversationMonitor monitor { get; set; }
+
+ private bool scanning = false;
+
+ internal Model (Geary.App.ConversationMonitor monitor) {
+ this.monitor = monitor;
+ foreach (Geary.App.Conversation convo in monitor.read_only_view) {
+ insert(convo);
+ }
+
+ monitor.conversations_added.connect(on_conversations_added);
+ monitor.conversation_appended.connect(on_conversation_updated);
+ monitor.conversation_trimmed.connect(on_conversation_updated);
+ monitor.conversations_removed.connect(on_conversations_removed);
+ monitor.scan_started.connect(on_scan_started);
+ monitor.scan_completed.connect(on_scan_completed);
+ }
+
+ ~Model() {
+ this.monitor.conversations_added.disconnect(on_conversations_added);
+ this.monitor.conversation_appended.disconnect(on_conversation_updated);
+ this.monitor.conversation_trimmed.disconnect(on_conversation_updated);
+ this.monitor.conversations_removed.disconnect(on_conversations_removed);
+ this.monitor.scan_started.disconnect(on_scan_started);
+ this.monitor.scan_completed.disconnect(on_scan_completed);
+ }
+
+ /**
+ * Informs observers that batch of updates is complete.
+ *
+ * GTK's ListModel interface reports updates as additions and subtractions
+ * at a specific index, meaning the results of a scan can require several
+ * invocations of items_changed. This signal allows consumers to know when
+ * those invocations have stopped.
+ */
+ internal signal void update_complete();
+
+ private static int compare(Object a, Object b) {
+ return Util.Email.compare_conversation_descending(a as Geary.App.Conversation, b as
Geary.App.Conversation);
+ }
+
+ private void insert(Geary.App.Conversation convo) {
+ store.insert_sorted(convo, compare);
+ }
+
+ // ------------------------
+ // Scanning and load_more
+ // ------------------------
+
+
+ private void on_scan_started(Geary.App.ConversationMonitor source) {
+ this.scanning = true;
+ }
+ private void on_scan_completed(Geary.App.ConversationMonitor source) {
+ this.scanning = false;
+ update_complete();
+ }
+
+ public bool load_more(int amount) {
+ if (this.scanning) {
+ return false;
+ }
+
+ this.monitor.min_window_count += amount;
+ return true;
+ }
+
+
+ // Monitor Lifecycle handles
+ private void on_conversations_added(Gee.Collection<Geary.App.Conversation> conversations) {
+ debug("Adding %d conversations.", conversations.size);
+ int added = 0;
+ foreach (Geary.App.Conversation conversation in conversations) {
+ if (upsert_conversation(conversation)) {
+ added++;
+ }
+ }
+ debug("Added %d/%d conversations.", added, conversations.size);
+ }
+
+ private void on_conversations_removed(Gee.Collection<Geary.App.Conversation> conversations) {
+ debug("Removing %d conversations.", conversations.size);
+ int removed = 0;
+ foreach (Geary.App.Conversation conversation in conversations) {
+ if (remove_conversation(conversation)) {
+ removed++;
+ }
+ }
+ if (!this.scanning)
+ update_complete();
+ debug("Removed %d/%d conversations.", removed, conversations.size);
+ }
+
+ private void on_conversation_updated(Geary.App.ConversationMonitor sender, Geary.App.Conversation convo,
Gee.Collection<Geary.Email> emails) {
+ upsert_conversation(convo);
+ }
+
+ // Monitor helpers
+ private bool upsert_conversation(Geary.App.Conversation convo) {
+ // The conversation may be bogus, if so don't do anything
+ Geary.Email? last_email = convo.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE);
+
+ if (last_email == null) {
+ debug("Cannot add conversation: last email is null");
+ return false;
+ }
+
+ remove_conversation(convo);
+ insert(convo);
+
+ return true;
+ }
+
+ private bool remove_conversation(Geary.App.Conversation conversation) {
+ uint index;
+ if (store.find(conversation, out index)) {
+ store.remove(index);
+ return true;
+ }
+
+ return false;
+ }
+
+}
diff --git a/src/client/conversation-list/conversation-list-participant.vala
b/src/client/conversation-list/conversation-list-participant.vala
new file mode 100644
index 000000000..0f0fe9095
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-participant.vala
@@ -0,0 +1,61 @@
+internal class ConversationList.Participant : Geary.BaseObject, Gee.Hashable<Participant> {
+ private const string ME = "Me";
+ public Geary.RFC822.MailboxAddress address;
+
+ public Participant(Geary.RFC822.MailboxAddress address) {
+ this.address = address;
+ }
+
+ public string get_full_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
+ return get_as_markup((address in account_mailboxes) ? ME : address.to_short_display());
+ }
+
+ public string get_short_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
+ if (address in account_mailboxes)
+ return get_as_markup(ME);
+
+ if (address.is_spoofed()) {
+ return get_full_markup(account_mailboxes);
+ }
+
+ string short_address = Markup.escape_text(address.to_short_display());
+
+ if (", " in short_address) {
+ // assume address is in Last, First format
+ string[] tokens = short_address.split(", ", 2);
+ short_address = tokens[1].strip();
+ if (Geary.String.is_empty(short_address))
+ return get_full_markup(account_mailboxes);
+ }
+
+ // use first name as delimited by a space
+ string[] tokens = short_address.split(" ", 2);
+ if (tokens.length < 1)
+ return get_full_markup(account_mailboxes);
+
+ string first_name = tokens[0].strip();
+ if (Geary.String.is_empty_or_whitespace(first_name))
+ return get_full_markup(account_mailboxes);
+
+ return get_as_markup(first_name);
+ }
+
+ private string get_as_markup(string participant) {
+ string markup = Geary.HTML.escape_markup(participant);
+
+ if (this.address.is_spoofed()) {
+ markup = "<s>%s</s>".printf(markup);
+ }
+
+ return markup;
+ }
+
+ public bool equal_to(Participant other) {
+ return address.equal_to(other.address)
+ && address.name == other.address.name;
+ }
+
+ public uint hash() {
+ return address.hash();
+ }
+}
diff --git a/src/client/conversation-list/conversation-list-row.vala
b/src/client/conversation-list/conversation-list-row.vala
new file mode 100644
index 000000000..3a222bea0
--- /dev/null
+++ b/src/client/conversation-list/conversation-list-row.vala
@@ -0,0 +1,184 @@
+[GtkTemplate (ui = "/org/gnome/Geary/conversation-list-row.ui")]
+internal class ConversationList.Row : Gtk.ListBoxRow {
+
+ private Gee.List<Geary.RFC822.MailboxAddress>? user_accounts {
+ owned get {
+ return conversation.base_folder.account.information.sender_mailboxes;
+ }
+ }
+
+ [GtkChild] unowned Gtk.Label preview;
+ [GtkChild] unowned Gtk.Box preview_row;
+ [GtkChild] unowned Gtk.Label subject;
+ [GtkChild] unowned Gtk.Label participants;
+ [GtkChild] unowned Gtk.Label date;
+ [GtkChild] unowned Gtk.Button unread;
+ [GtkChild] unowned Gtk.Button flagged;
+ [GtkChild] unowned Gtk.Label count_badge;
+
+ [GtkChild] unowned Gtk.Image unread_icon;
+ [GtkChild] unowned Gtk.Image read_icon;
+ [GtkChild] unowned Gtk.Image flagged_icon;
+ [GtkChild] unowned Gtk.Image unflagged_icon;
+
+ [GtkChild] unowned Gtk.Stack stack;
+ [GtkChild] unowned Gtk.CheckButton selected_button;
+
+ internal Geary.App.Conversation conversation;
+ private Application.Configuration config;
+ private DateTime? recv_time;
+
+ internal signal void toggle_flag(ConversationList.Row row,
+ Geary.NamedFlag flag);
+
+ internal signal void toggle_selected(ConversationList.Row row,
+ bool selected);
+
+
+ internal Row(Application.Configuration config,
+ Geary.App.Conversation conversation,
+ bool selection_mode_enabled) {
+ this.config = config;
+ this.conversation = conversation;
+
+ Geary.Email? last_email = conversation.get_latest_recv_email(
+ Geary.App.Conversation.Location.ANYWHERE);
+ if (last_email != null) {
+ var text = Util.Email.strip_subject_prefixes(last_email);
+ this.subject.set_text(text);
+ this.preview.set_text(last_email.get_preview_as_string());
+ this.recv_time = last_email.properties.date_received.to_local();
+ refresh_time();
+ }
+
+ this.participants.set_markup(get_participants());
+
+ var count = conversation.get_count();
+ if (count > 1) {
+ this.count_badge.set_text(conversation.get_count().to_string());
+ } else {
+ this.count_badge.hide();
+ }
+
+ conversation.email_flags_changed.connect(update_flags);
+ update_flags(null);
+
+ config.bind(Application.Configuration.DISPLAY_PREVIEW_KEY,
+ this.preview_row, "visible");
+
+ if (selection_mode_enabled)
+ set_selection_enabled(true);
+ }
+
+ internal void set_selection_enabled(bool enabled) {
+ if (enabled) {
+ this.selected_button.show();
+ this.selected_button.set_active(this.is_selected());
+ this.stack.set_visible_child_name("selection-button");
+ } else {
+ this.stack.set_visible_child_name("buttons");
+ this.selected_button.hide();
+ this.selected_button.set_active(false);
+ }
+ }
+
+ internal void update_state() {
+ bool selected = this.is_selected();
+ bool active = this.selected_button.get_active();
+ if (selected && !active)
+ this.selected_button.set_active(true);
+ else if (!selected && active)
+ this.selected_button.set_active(false);
+ }
+
+ internal void refresh_time() {
+ if (this.recv_time != null) {
+ // conversation list store sorts by date-received, so display that
+ // instead of the sent time
+ this.date.set_text(Util.Date.pretty_print(
+ this.recv_time,
+ this.config.clock_format
+ ));
+ }
+ }
+
+
+ private void update_flags(Geary.Email? email) {
+ if (conversation.is_unread()) {
+ get_style_context().add_class("unread");
+ unread.set_image(unread_icon);
+ } else {
+ get_style_context().remove_class("unread");
+ unread.set_image(read_icon);
+ }
+
+ if (conversation.is_flagged()) {
+ get_style_context().add_class("flagged");
+ flagged.set_image(flagged_icon);
+ } else {
+ get_style_context().remove_class("flagged");
+ flagged.set_image(unflagged_icon);
+ }
+ }
+
+ [GtkCallback] private void on_unread_button_clicked() {
+ toggle_flag(this, Geary.EmailFlags.UNREAD);
+ }
+
+ [GtkCallback] private void on_flagged_button_clicked() {
+ toggle_flag(this, Geary.EmailFlags.FLAGGED);
+ }
+
+ [GtkCallback] private void on_selected_button_clicked() {
+ toggle_selected(this, this.selected_button.active);
+ }
+
+
+ private string get_participants() {
+ var participants = new Gee.ArrayList<Participant>();
+ Gee.List<Geary.Email> emails = conversation.get_emails(
+ Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING);
+
+ foreach (Geary.Email message in emails) {
+ Geary.RFC822.MailboxAddresses? addresses =
+ conversation.base_folder.used_as.is_outgoing()
+ ? new Geary.RFC822.MailboxAddresses.single(Util.Email.get_primary_originator(message))
+ : message.from;
+
+ if (addresses == null)
+ continue;
+
+ foreach (Geary.RFC822.MailboxAddress address in addresses) {
+ Participant participant_display = new Participant(address);
+
+ int existing_index = participants.index_of(participant_display);
+ if (existing_index < 0) {
+ participants.add(participant_display);
+
+ continue;
+ }
+ }
+ }
+
+ if (participants.size == 0) {
+ return "";
+ }
+
+ if(participants.size == 1) {
+ return participants[0].get_full_markup(this.user_accounts);
+ }
+
+ StringBuilder builder = new StringBuilder();
+ bool first = true;
+ foreach (Participant participant in participants) {
+ if (!first) {
+ builder.append(", ");
+ }
+
+ builder.append(participant.get_short_markup(this.user_accounts));
+ first = false;
+ }
+
+ return builder.str;
+ }
+}
diff --git a/src/client/conversation-list/conversation-list-view.vala
b/src/client/conversation-list/conversation-list-view.vala
index 37a2a6401..ecf7ef70c 100644
--- a/src/client/conversation-list/conversation-list-view.vala
+++ b/src/client/conversation-list/conversation-list-view.vala
@@ -1,666 +1,522 @@
-/* Copyright 2016 Software Freedom Conservancy Inc.
- *
- * This software is licensed under the GNU Lesser General Public License
- * (version 2.1 or later). See the COPYING file in this distribution.
- */
-
-public class ConversationListView : Gtk.TreeView, Geary.BaseInterface {
- const int LOAD_MORE_HEIGHT = 100;
-
+[GtkTemplate (ui = "/org/gnome/Geary/conversation-list-view.ui")]
+public class ConversationList.View : Gtk.ScrolledWindow, Geary.BaseInterface {
+ /**
+ * The fields that must be available on any ConversationMonitor
+ * passed to ConversationList.View
+ */
+ public const Geary.Email.Field REQUIRED_FIELDS = (
+ Geary.Email.Field.ENVELOPE |
+ Geary.Email.Field.FLAGS |
+ Geary.Email.Field.PROPERTIES
+ );
+
+ public bool selection_mode_enabled { get; set; default = false; }
private Application.Configuration config;
+ private Gtk.GestureMultiPress press_gesture;
+ private Gtk.GestureLongPress long_press_gesture;
+ private Gtk.EventControllerKey key_event_controller;
- private bool enable_load_more = true;
-
- private bool reset_adjustment = false;
- private Gee.Set<Geary.App.Conversation>? current_visible_conversations = null;
- private Geary.Scheduler.Scheduled? scheduled_update_visible_conversations = null;
- private Gee.Set<Geary.App.Conversation> selected = new Gee.HashSet<Geary.App.Conversation>();
- private Geary.IdleManager selection_update;
- private Gtk.GestureMultiPress gesture;
-
- // Determines if the next folder scan should avoid selecting a
- // conversation when autoselect is enabled
- private bool should_inhibit_autoselect = false;
-
-
- public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected);
-
- // Signal for when a conversation has been double-clicked, or selected and enter is pressed.
- public signal void conversation_activated(Geary.App.Conversation activated, bool single = false);
-
- public virtual signal void load_more() {
- enable_load_more = false;
- }
-
- public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations,
- Geary.NamedFlag flag);
-
- public signal void visible_conversations_changed(Gee.Set<Geary.App.Conversation> visible);
-
+ [GtkChild] private unowned Gtk.ListBox list;
- public ConversationListView(Application.Configuration config) {
- base_ref();
- set_show_expanders(false);
- set_headers_visible(false);
- set_grid_lines(Gtk.TreeViewGridLines.HORIZONTAL);
+ public View(Application.Configuration config) {
this.config = config;
- append_column(create_column(ConversationListStore.Column.CONVERSATION_DATA,
- new ConversationListCellRenderer(), ConversationListStore.Column.CONVERSATION_DATA.to_string(),
- 0));
-
- Gtk.TreeSelection selection = get_selection();
- selection.set_mode(Gtk.SelectionMode.MULTIPLE);
- style_updated.connect(on_style_changed);
+ this.notify["selection-mode-enabled"].connect(on_selection_mode_changed);
- notify["vadjustment"].connect(on_vadjustment_changed);
+ this.list.selected_rows_changed.connect(() => {
+ conversations_selected(get_selected());
+ });
+ this.list.row_selected.connect((row) => {
+ if (row != null)
+ ((Row) row).update_state();
+ });
- key_press_event.connect(on_key_press);
- button_press_event.connect(on_button_press);
- gesture = new Gtk.GestureMultiPress(this);
- gesture.pressed.connect(on_gesture_pressed);
-
- // Set up drag and drop.
- Gtk.drag_source_set(this, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST,
- Gdk.DragAction.COPY | Gdk.DragAction.MOVE);
+ this.list.set_header_func(header_func);
- this.config.settings.changed[
- Application.Configuration.DISPLAY_PREVIEW_KEY
- ].connect(on_display_preview_changed);
+ this.vadjustment.value_changed.connect(maybe_load_more);
+ this.vadjustment.value_changed.connect(update_visible_conversations);
- // Watch for mouse events.
- motion_notify_event.connect(on_motion_notify_event);
- leave_notify_event.connect(on_leave_notify_event);
+ this.press_gesture = new Gtk.GestureMultiPress(this.list);
+ this.press_gesture.released.connect(on_press_gesture_released);
- // GtkTreeView binds Ctrl+N to "move cursor to next". Not so interested in that, so we'll
- // remove it.
- unowned Gtk.BindingSet? binding_set = Gtk.BindingSet.find("GtkTreeView");
- assert(binding_set != null);
- Gtk.BindingEntry.remove(binding_set, Gdk.Key.N, Gdk.ModifierType.CONTROL_MASK);
+ this.long_press_gesture = new Gtk.GestureLongPress(this.list);
+ this.long_press_gesture.propagation_phase = CAPTURE;
+ this.long_press_gesture.pressed.connect((n_press, x, y) => {
+ var row = (Row) this.list.get_row_at_y((int) y);
+ context_menu(row);
+ });
- this.selection_update = new Geary.IdleManager(do_selection_changed);
- this.selection_update.priority = Geary.IdleManager.Priority.LOW;
+ this.key_event_controller = new Gtk.EventControllerKey(this);
+ this.key_event_controller.key_released.connect(on_key_event_controller_key_released);
- this.visible = true;
- }
-
- ~ConversationListView() {
- base_unref();
- }
-
- public override void destroy() {
- this.selection_update.reset();
- base.destroy();
- }
-
- public new ConversationListStore? get_model() {
- return base.get_model() as ConversationListStore;
- }
-
- public new void set_model(ConversationListStore? new_store) {
- ConversationListStore? old_store = get_model();
- if (old_store != null) {
- old_store.conversations.scan_started.disconnect(on_scan_started);
- old_store.conversations.scan_completed.disconnect(on_scan_completed);
-
- old_store.conversations_added.disconnect(on_conversations_added);
- old_store.conversations_removed.disconnect(on_conversations_removed);
- old_store.row_inserted.disconnect(on_rows_changed);
- old_store.rows_reordered.disconnect(on_rows_changed);
- old_store.row_changed.disconnect(on_rows_changed);
- old_store.row_deleted.disconnect(on_rows_changed);
- old_store.destroy();
- }
-
- if (new_store != null) {
- new_store.conversations.scan_started.connect(on_scan_started);
- new_store.conversations.scan_completed.connect(on_scan_completed);
-
- new_store.row_inserted.connect(on_rows_changed);
- new_store.rows_reordered.connect(on_rows_changed);
- new_store.row_changed.connect(on_rows_changed);
- new_store.row_deleted.connect(on_rows_changed);
- new_store.conversations_removed.connect(on_conversations_removed);
- new_store.conversations_added.connect(on_conversations_added);
- }
-
- // Disconnect the selection handler since we don't want to
- // fire selection signals while changing the model.
- Gtk.TreeSelection selection = get_selection();
- selection.changed.disconnect(on_selection_changed);
- base.set_model(new_store);
- this.selected.clear();
- selection.changed.connect(on_selection_changed);
- }
-
- /** Returns a read-only iteration of the current selection. */
- public Gee.Set<Geary.App.Conversation> get_selected() {
- return this.selected.read_only_view;
- }
-
- /** Returns a copy of the current selection. */
- public Gee.Set<Geary.App.Conversation> copy_selected() {
- var copy = new Gee.HashSet<Geary.App.Conversation>();
- copy.add_all(this.selected);
- return copy;
- }
-
- public void inhibit_next_autoselect() {
- this.should_inhibit_autoselect = true;
- }
-
- public void scroll(Gtk.ScrollType where) {
- Gtk.TreeSelection selection = get_selection();
- weak Gtk.TreeModel model;
- GLib.List<Gtk.TreePath> selected = selection.get_selected_rows(out model);
- Gtk.TreePath? target_path = null;
- Gtk.TreeIter? target_iter = null;
- if (selected.length() > 0) {
- switch (where) {
- case STEP_UP:
- target_path = selected.first().data;
- model.get_iter(out target_iter, target_path);
- if (model.iter_previous(ref target_iter)) {
- target_path = model.get_path(target_iter);
- } else {
- this.get_window().beep();
- }
- break;
-
- case STEP_DOWN:
- target_path = selected.last().data;
- model.get_iter(out target_iter, target_path);
- if (model.iter_next(ref target_iter)) {
- target_path = model.get_path(target_iter);
- } else {
- this.get_window().beep();
- }
- break;
-
- default:
- // no-op
- break;
- }
-
- set_cursor(target_path, null, false);
- }
- }
-
- private void check_load_more() {
- ConversationListStore? model = get_model();
- Geary.App.ConversationMonitor? conversations = (model != null)
- ? model.conversations
- : null;
- if (conversations != null) {
- // Check if we're at the very bottom of the list. If we
- // are, it's time to issue a load_more signal.
- Gtk.Adjustment adjustment = ((Gtk.Scrollable) this).get_vadjustment();
- double upper = adjustment.get_upper();
- double threshold = upper - adjustment.page_size - LOAD_MORE_HEIGHT;
- if (this.is_visible() &&
- conversations.can_load_more &&
- adjustment.get_value() >= threshold) {
- load_more();
- }
-
- schedule_visible_conversations_changed();
- }
+ Gtk.drag_source_set(this.list, Gdk.ModifierType.BUTTON1_MASK, FolderList.Tree.TARGET_ENTRY_LIST,
+ Gdk.DragAction.COPY | Gdk.DragAction.MOVE);
+ this.list.drag_begin.connect(on_drag_begin);
+ this.list.drag_end.connect(on_drag_end);
}
- private void on_scan_started() {
- this.enable_load_more = false;
+ static construct {
+ set_css_name("conversation-list");
}
- private void on_scan_completed() {
- this.enable_load_more = true;
- check_load_more();
-
- // Select the first conversation, if autoselect is enabled,
- // nothing has been selected yet and we're not showing a
- // composer.
- if (this.config.autoselect &&
- !this.should_inhibit_autoselect &&
- get_selection().count_selected_rows() == 0) {
- var parent = get_toplevel() as Application.MainWindow;
- if (parent != null && !parent.has_composer) {
- set_cursor(new Gtk.TreePath.from_indices(0, -1), null, false);
- }
+ // -------
+ // UI
+ // -------
+ private void header_func(Gtk.ListBoxRow row, Gtk.ListBoxRow? before) {
+ if (before != null) {
+ var sep = new Gtk.Separator(Gtk.Orientation.HORIZONTAL);
+ sep.show();
+ row.set_header(sep);
}
-
- this.should_inhibit_autoselect = false;
}
- private void on_conversations_added(bool start) {
- Gtk.Adjustment? adjustment = get_adjustment();
- if (start) {
- // If we were at the top, we want to stay there after
- // conversations are added.
- this.reset_adjustment = adjustment != null && adjustment.get_value() == 0;
- } else if (this.reset_adjustment && adjustment != null) {
- // Pump the loop to make sure the new conversations are
- // taking up space in the window. Without this, setting
- // the adjustment here is a no-op because as far as it's
- // concerned, it's already at the top.
- while (Gtk.events_pending())
- Gtk.main_iteration();
-
- adjustment.set_value(0);
+ /**
+ * Updates the display of the received time on each list row.
+ *
+ * Because the received time is displayed as relative to the current time,
+ * it must be periodically updated. ConversationList.View does not do this
+ * automatically but instead it must be externally scheduled
+ */
+ public void refresh_times() {
+ this.list.foreach((child) => {
+ var row = (Row) child;
+ row.refresh_time();
+ });
+ }
+
+ // -------------------
+ // Model Management
+ // -------------------
+
+ /**
+ * The currently bound model
+ */
+ private Model? model;
+
+ /**
+ * Set the conversation monitor which the listview is displaying
+ */
+ public void set_monitor(Geary.App.ConversationMonitor? monitor) {
+ if (this.model != null) {
+ this.model.update_complete.disconnect(on_model_items_changed);
}
- this.reset_adjustment = false;
- }
-
- private void on_conversations_removed(bool start) {
- if (!this.config.autoselect) {
- Gtk.SelectionMode mode = start
- // Stop GtkTreeView from automatically selecting the
- // next row after the removed rows
- ? Gtk.SelectionMode.NONE
- // Allow the user to make selections again
- : Gtk.SelectionMode.MULTIPLE;
- get_selection().set_mode(mode);
+ if (monitor == null) {
+ this.model = null;
+ this.list.bind_model(null, row_factory);
+ } else {
+ this.model = new Model(monitor);
+ this.list.bind_model(this.model.store, row_factory);
+ this.model.update_complete.connect(on_model_items_changed);
}
}
- private Gtk.Adjustment? get_adjustment() {
- Gtk.ScrolledWindow? parent = get_parent() as Gtk.ScrolledWindow;
- if (parent == null) {
- debug("Parent was not scrolled window");
- return null;
+ /**
+ * Attempt to load more conversations from the current monitor
+ */
+ public void load_more(int request) {
+ if (model != null) {
+ model.load_more(request);
}
-
- return parent.get_vadjustment();
}
- private void on_gesture_pressed(int n_press, double x, double y) {
- if (gesture.get_current_button() != Gdk.BUTTON_PRIMARY)
+ public void scroll(Gtk.ScrollType scroll_type) {
+ Gtk.ListBoxRow row = this.list.get_selected_row();
+ if (row == null)
return;
- Gtk.TreePath? path;
- get_path_at_pos((int) x, (int) y, out path, null, null, null);
-
- // If the user clicked in an empty area, do nothing.
- if (path == null)
- return;
-
- Geary.App.Conversation? c = get_model().get_conversation_at_path(path);
- if (c == null)
- return;
+ int index = row.get_index();
+ if (scroll_type == Gtk.ScrollType.STEP_UP)
+ row = this.list.get_row_at_index(index + 1);
+ else
+ row = this.list.get_row_at_index(index - 1);
- Gdk.Event event = gesture.get_last_event(gesture.get_current_sequence());
- Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
-
- Gdk.ModifierType state_mask;
- event.get_state(out state_mask);
-
- if ((state_mask & modifiers) == 0 && n_press == 1) {
- conversation_activated(c, true);
- } else if ((state_mask & modifiers) == Gdk.ModifierType.SHIFT_MASK && n_press == 2) {
- conversation_activated(c);
- }
+ if (row != null)
+ this.list.select_row(row);
}
- private bool on_key_press(Gdk.EventKey event) {
- if (this.selected.size != 1)
- return false;
-
- Geary.App.Conversation? c = this.selected.to_array()[0];
- if (c == null)
- return false;
-
- Gdk.ModifierType modifiers = Gtk.accelerator_get_default_mod_mask();
-
- if (event.keyval == Gdk.Key.Return ||
- event.keyval == Gdk.Key.ISO_Enter ||
- event.keyval == Gdk.Key.KP_Enter ||
- event.keyval == Gdk.Key.space ||
- event.keyval == Gdk.Key.KP_Space)
- conversation_activated(c, !((event.state & modifiers) == Gdk.ModifierType.SHIFT_MASK));
- return false;
+ private Gtk.Widget row_factory(Object convo_obj) {
+ var convo = (Geary.App.Conversation) convo_obj;
+ var row = new Row(config, convo, this.selection_mode_enabled);
+ row.toggle_flag.connect(on_toggle_flags);
+ row.toggle_selected.connect(on_toggle_selected);
+ return row;
}
- private bool on_button_press(Gdk.EventButton event) {
- // Get the coordinates on the cell as well as the clicked path.
- int cell_x;
- int cell_y;
- Gtk.TreePath? path;
- get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
-
- // If the user clicked in an empty area, do nothing.
- if (path == null)
- return false;
-
- // Handle clicks to toggle read and starred status.
- if ((event.state & Gdk.ModifierType.SHIFT_MASK) == 0 &&
- (event.state & Gdk.ModifierType.CONTROL_MASK) == 0 &&
- event.type == Gdk.EventType.BUTTON_PRESS) {
-
- // Click positions depend on whether the preview is enabled.
- bool read_clicked = false;
- bool star_clicked = false;
- if (this.config.display_preview) {
- read_clicked = cell_x < 25 && cell_y >= 14 && cell_y <= 30;
- star_clicked = cell_x < 25 && cell_y >= 40 && cell_y <= 62;
- } else {
- read_clicked = cell_x < 25 && cell_y >= 8 && cell_y <= 22;
- star_clicked = cell_x < 25 && cell_y >= 28 && cell_y <= 43;
- }
-
- // Get the current conversation. If it's selected, we'll apply the mark operation to
- // all selected conversations; otherwise, it just applies to this one.
- Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
- Gee.Collection<Geary.App.Conversation> to_mark = (
- this.selected.contains(conversation)
- ? copy_selected()
- : Geary.Collection.single(conversation)
- );
- if (read_clicked) {
- mark_conversations(to_mark, Geary.EmailFlags.UNREAD);
- return true;
- } else if (star_clicked) {
- mark_conversations(to_mark, Geary.EmailFlags.FLAGGED);
- return true;
- }
- }
-
- // Check if changing the selection will require any composers
- // to be closed, but only on the first click of a
- // double/triple click, so that double-clicking a draft
- // doesn't attempt to load it then close it straight away.
- if (event.type == Gdk.EventType.BUTTON_PRESS &&
- !get_selection().path_is_selected(path)) {
- var parent = get_toplevel() as Application.MainWindow;
- if (parent != null && !parent.close_composer(false)) {
- return true;
- }
+ // --------------------
+ // Right-click Popup
+ // --------------------
+ private void context_menu(Row row) {
+ if (!row.is_selected()) {
+ this.list.unselect_all();
+ this.list.select_row(row);
}
- if (event.button == 3 && event.type == Gdk.EventType.BUTTON_PRESS) {
- Geary.App.Conversation conversation = get_model().get_conversation_at_path(path);
-
- GLib.Menu context_menu_model = new GLib.Menu();
- var main = get_toplevel() as Application.MainWindow;
- if (main != null) {
- if (!main.is_shift_down) {
- context_menu_model.append(
- /// Translators: Context menu item
- ngettext(
- "Move conversation to _Trash",
- "Move conversations to _Trash",
- this.selected.size
- ),
- Action.Window.prefix(
- Application.MainWindow.ACTION_TRASH_CONVERSATION
- )
- );
- } else {
- context_menu_model.append(
- /// Translators: Context menu item
- ngettext(
- "_Delete conversation",
- "_Delete conversations",
- this.selected.size
- ),
- Action.Window.prefix(
- Application.MainWindow.ACTION_DELETE_CONVERSATION
- )
- );
- }
- }
-
- if (conversation.is_unread())
- context_menu_model.append(
- _("Mark as _Read"),
- Action.Window.prefix(
- Application.MainWindow.ACTION_MARK_AS_READ
- )
- );
-
- if (conversation.has_any_read_message())
- context_menu_model.append(
- _("Mark as _Unread"),
- Action.Window.prefix(
- Application.MainWindow.ACTION_MARK_AS_UNREAD
- )
- );
+ var popup_menu = construct_popover(row, this.list.get_selected_rows().length());
+ popup_menu.popup();
+ }
- if (conversation.is_flagged()) {
+ private Gtk.Popover construct_popover(Row row, uint selection_size) {
+ GLib.Menu context_menu_model = new GLib.Menu();
+ var main = get_toplevel() as Application.MainWindow;
+ if (main != null) {
+ if (!main.is_shift_down) {
context_menu_model.append(
- _("U_nstar"),
+ /// Translators: Context menu item
+ ngettext(
+ "Move conversation to _Trash",
+ "Move conversations to _Trash",
+ selection_size
+ ),
Action.Window.prefix(
- Application.MainWindow.ACTION_MARK_AS_UNSTARRED
+ Application.MainWindow.ACTION_TRASH_CONVERSATION
)
);
} else {
context_menu_model.append(
- _("_Star"),
+ /// Translators: Context menu item
+ ngettext(
+ "_Delete conversation",
+ "_Delete conversations",
+ selection_size
+ ),
Action.Window.prefix(
- Application.MainWindow.ACTION_MARK_AS_STARRED
- )
- );
- }
- if ((conversation.base_folder.used_as != ARCHIVE) && (conversation.base_folder.used_as !=
ALL_MAIL)) {
- context_menu_model.append(
- _("Archive conversation"),
- Action.Window.prefix(
- Application.MainWindow.ACTION_ARCHIVE_CONVERSATION
+ Application.MainWindow.ACTION_DELETE_CONVERSATION
)
);
}
+ }
- Menu actions_section = new Menu();
- actions_section.append(
- _("_Reply"),
+ if (row.conversation.is_unread())
+ context_menu_model.append(
+ _("Mark as _Read"),
Action.Window.prefix(
- Application.MainWindow.ACTION_REPLY_CONVERSATION
+ Application.MainWindow.ACTION_MARK_AS_READ
)
);
- actions_section.append(
- _("R_eply All"),
+
+ if (row.conversation.has_any_read_message())
+ context_menu_model.append(
+ _("Mark as _Unread"),
Action.Window.prefix(
- Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION
+ Application.MainWindow.ACTION_MARK_AS_UNREAD
)
);
- actions_section.append(
- _("_Forward"),
+
+ if (row.conversation.is_flagged()) {
+ context_menu_model.append(
+ _("U_nstar"),
Action.Window.prefix(
- Application.MainWindow.ACTION_FORWARD_CONVERSATION
+ Application.MainWindow.ACTION_MARK_AS_UNSTARRED
)
);
- context_menu_model.append_section(null, actions_section);
-
- // Use a popover rather than a regular context menu since
- // the latter grabs the event queue, so the MainWindow
- // will not receive events if the user releases Shift,
- // making the trash/delete header bar state wrong.
- Gtk.Popover context_menu = new Gtk.Popover.from_model(
- this, context_menu_model
+ } else {
+ context_menu_model.append(
+ _("_Star"),
+ Action.Window.prefix(
+ Application.MainWindow.ACTION_MARK_AS_STARRED
+ )
);
- Gdk.Rectangle dest = Gdk.Rectangle();
- dest.x = (int) event.x;
- dest.y = (int) event.y;
- context_menu.set_pointing_to(dest);
- context_menu.popup();
-
- // When the conversation under the mouse is selected, stop event propagation
- return get_selection().path_is_selected(path);
+ }
+ if ((row.conversation.base_folder.used_as != ARCHIVE) &&
+ (row.conversation.base_folder.used_as != ALL_MAIL)) {
+ context_menu_model.append(
+ ngettext(
+ "_Archive conversation",
+ "_Archive conversations",
+ selection_size
+ ),
+ Action.Window.prefix(
+ Application.MainWindow.ACTION_ARCHIVE_CONVERSATION
+ )
+ );
}
- return false;
- }
+ Menu actions_section = new Menu();
+ actions_section.append(
+ _("_Reply"),
+ Action.Window.prefix(
+ Application.MainWindow.ACTION_REPLY_CONVERSATION
+ )
+ );
+ actions_section.append(
+ _("R_eply All"),
+ Action.Window.prefix(
+ Application.MainWindow.ACTION_REPLY_ALL_CONVERSATION
+ )
+ );
+ actions_section.append(
+ _("_Forward"),
+ Action.Window.prefix(
+ Application.MainWindow.ACTION_FORWARD_CONVERSATION
+ )
+ );
+ context_menu_model.append_section(null, actions_section);
+
+ // Use a popover rather than a regular context menu since
+ // the latter grabs the event queue, so the MainWindow
+ // will not receive events if the user releases Shift,
+ // making the trash/delete header bar state wrong.
+ Gtk.Popover context_menu = new Gtk.Popover.from_model(
+ row, context_menu_model
+ );
+
+ return context_menu;
+ }
+
+ // -------------------
+ // Selection
+ // -------------------
+
+ /**
+ * Emitted when one or more conversations are selected
+ */
+ public signal void conversations_selected(Gee.Set<Geary.App.Conversation> selected);
- private void on_style_changed() {
- // Recalculate dimensions of child cells.
- ConversationListCellRenderer.style_changed(this);
+ /**
+ * Emitted when one or more conversations are activated
+ *
+ * If more than one conversation is activated, this signal is emitted
+ * multiple times with the single flag false
+ */
+ public signal void conversation_activated(Geary.App.Conversation activated, bool single = false);
- schedule_visible_conversations_changed();
+ /**
+ * Gets the conversations represented by the current selection in the ListBox
+ */
+ public Gee.Set<Geary.App.Conversation> get_selected() {
+ var selected = new Gee.HashSet<Geary.App.Conversation>();
+ foreach (var row in this.list.get_selected_rows()) {
+ selected.add(((Row) row).conversation);
+ }
+ return selected;
+ }
+
+ /**
+ * Selects the rows for a given collection of conversations
+ *
+ * If a conversation is not present in the ListBox, it is ignored.
+ */
+ public void select_conversations(Gee.Collection<Geary.App.Conversation> selection) {
+ this.list.foreach((child) => {
+ var row = (Row) child;
+ Geary.App.Conversation conversation = row.conversation;
+ if (selection.contains(conversation)) {
+ this.list.select_row(row);
+ }
+
+ });
}
- private void on_value_changed() {
- if (this.enable_load_more) {
- check_load_more();
- }
+ /**
+ * Unselects all conversations
+ */
+ public void unselect_all() {
+ this.list.unselect_all();
}
- private static Gtk.TreeViewColumn create_column(ConversationListStore.Column column,
- Gtk.CellRenderer renderer, string attr, int width = 0) {
- Gtk.TreeViewColumn view_column = new Gtk.TreeViewColumn.with_attributes(column.to_string(),
- renderer, attr, column);
- view_column.set_resizable(true);
+ // -----------------
+ // Button Actions
+ // ----------------
- if (width != 0) {
- view_column.set_sizing(Gtk.TreeViewColumnSizing.FIXED);
- view_column.set_fixed_width(width);
- }
+ /**
+ * Emitted when the user expresses intent to update the flags on a set of conversations
+ */
+ public signal void mark_conversations(Gee.Collection<Geary.App.Conversation> conversations,
+ Geary.NamedFlag flag);
- return view_column;
- }
- private List<Gtk.TreePath> get_all_selected_paths() {
- Gtk.TreeModel model;
- return get_selection().get_selected_rows(out model);
+ private void on_toggle_flags(ConversationList.Row row, Geary.NamedFlag flag) {
+ if (row.is_selected()) {
+ mark_conversations(get_selected(), flag);
+ } else {
+ mark_conversations(Geary.Collection.single(row.conversation), flag);
+ }
}
- private void on_selection_changed() {
- // Schedule processing selection changes at low idle for
- // two reasons: (a) if a lot of changes come in
- // back-to-back, this allows for all that activity to
- // settle before updating state and firing signals (which
- // results in a lot of I/O), and (b) it means the
- // ConversationMonitor's signals may be processed in any
- // order by this class and the ConversationListView and
- // not result in a lot of screen flashing and (again)
- // unnecessary I/O as both classes update selection state.
- this.selection_update.schedule();
+ private void on_toggle_selected(ConversationList.Row row, bool selected) {
+ if (selected)
+ this.list.select_row(row);
+ else
+ this.list.unselect_row(row);
+ }
+
+ // ----------------
+ // Visibility
+ // ---------------
+
+ /**
+ * If the number of pixels between the bottom of the viewport and the bottom of
+ * of the listbox is less than LOAD_MORE_THRESHOLD, request more from the
+ * monitor.
+ */
+ private double LOAD_MORE_THRESHOLD = 100;
+ private int LOAD_MORE_COUNT = 50;
+
+ /**
+ * Called on scroll to possibly load more conversations from the model
+ */
+ private void maybe_load_more(Gtk.Adjustment adjustment) {
+ double upper = adjustment.get_upper();
+ double threshold = upper - adjustment.page_size - LOAD_MORE_THRESHOLD;
+ if (this.is_visible() && adjustment.get_value() >= threshold) {
+ this.load_more(LOAD_MORE_COUNT);
+ }
}
- // Gtk.TreeSelection can fire its "changed" signal even when
- // nothing's changed, so look for that to avoid subscribers from
- // doing the same things (in particular, I/O) multiple times
- private void do_selection_changed() {
- Gee.HashSet<Geary.App.Conversation> new_selection =
- new Gee.HashSet<Geary.App.Conversation>();
- List<Gtk.TreePath> paths = get_all_selected_paths();
- if (paths.length() != 0) {
- // Conversations are selected, so collect them and
- // signal if different
- foreach (Gtk.TreePath path in paths) {
- Geary.App.Conversation? conversation =
- get_model().get_conversation_at_path(path);
- if (conversation != null)
- new_selection.add(conversation);
- }
+ /**
+ * Time in milliseconds to delay updating the set of visible conversations.
+ * If another update is triggered during this delay, it will be discarded
+ * and the delay begins again.
+ */
+ private int VISIBILITY_UPDATE_DELAY_MS = 1000;
+
+ /**
+ * The set of all conversations currently displayed in the viewport
+ */
+ public Gee.Set<Geary.App.Conversation> visible_conversations {get; private set; default = new
Gee.HashSet<Geary.App.Conversation>(); }
+ private Geary.Scheduler.Scheduled? scheduled_visible_update;
+
+ /**
+ * Called on scroll to update the set of visible conversations
+ */
+ private void update_visible_conversations() {
+ if(scheduled_visible_update != null) {
+ scheduled_visible_update.cancel();
}
- // only notify if different than what was previously reported
- if (this.selected.size != new_selection.size ||
- !this.selected.contains_all(new_selection)) {
- this.selected = new_selection;
- conversations_selected(this.selected.read_only_view);
- }
- }
+ scheduled_visible_update = Geary.Scheduler.after_msec(VISIBILITY_UPDATE_DELAY_MS, () => {
+ var visible = new Gee.HashSet<Geary.App.Conversation>();
+ Gtk.ListBoxRow? first = this.list.get_row_at_y((int) this.vadjustment.value);
+ if (first == null) {
+ this.visible_conversations = visible;
+ return Source.REMOVE;
+ }
- public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
- Gee.HashSet<Geary.App.Conversation> visible_conversations = new
Gee.HashSet<Geary.App.Conversation>();
+ uint start_index = ((uint) first.get_index());
+ uint end_index = uint.min(
+ // Assume that all messages are the same height
+ start_index + (uint) (this.vadjustment.page_size / first.get_allocated_height()),
+ this.model.store.get_n_items()
+ );
- Gtk.TreePath start_path;
- Gtk.TreePath end_path;
- if (!get_visible_range(out start_path, out end_path))
- return visible_conversations;
+ for (uint i = start_index; i < end_index; i++) {
+ visible_conversations.add(this.model.store.get_item(i) as Geary.App.Conversation);
+ }
- while (start_path.compare(end_path) <= 0) {
- Geary.App.Conversation? conversation = get_model().get_conversation_at_path(start_path);
- if (conversation != null)
- visible_conversations.add(conversation);
+ this.visible_conversations = visible;
+ return Source.REMOVE;
+ }, GLib.Priority.DEFAULT_IDLE);
+ }
- start_path.next();
- }
+ // ------------
+ // Autoselect
+ // ------------
+ private bool should_inhibit_autoselect = false;
- return visible_conversations;
+ /**
+ * Informs the listbox to suppress autoselect behavior on the next update
+ */
+ public void inhibit_next_autoselect() {
+ should_inhibit_autoselect = true;
}
- // Always returns false, so it can be used as a one-time SourceFunc
- private bool update_visible_conversations() {
- bool changed = false;
- Gee.Set<Geary.App.Conversation> visible_conversations = get_visible_conversations();
- if (this.current_visible_conversations == null ||
- this.current_visible_conversations.size != visible_conversations.size ||
- !this.current_visible_conversations.contains_all(visible_conversations)) {
- this.current_visible_conversations = visible_conversations;
- visible_conversations_changed(
- this.current_visible_conversations.read_only_view
- );
- changed = true;
- }
- return changed;
- }
+ private void on_model_items_changed() {
+ if (this.config.autoselect &&
+ !this.should_inhibit_autoselect &&
+ this.list.get_selected_rows().length() == 0) {
- private void schedule_visible_conversations_changed() {
- scheduled_update_visible_conversations = Geary.Scheduler.on_idle(update_visible_conversations);
+ Gtk.ListBoxRow first_row = this.list.get_row_at_index(0);
+ if (first_row != null) {
+ this.list.select_row(first_row);
+ }
+ }
+ this.should_inhibit_autoselect = false;
}
- public void select_conversations(Gee.Collection<Geary.App.Conversation> new_selection) {
- if (this.selected.size != new_selection.size ||
- !this.selected.contains_all(new_selection)) {
- var selection = get_selection();
- selection.unselect_all();
- var model = get_model();
- if (model != null) {
- foreach (var conversation in new_selection) {
- var path = model.get_path_for_conversation(conversation);
- if (path != null) {
- selection.select_path(path);
- }
+ private void on_press_gesture_released(int n_press, double x, double y) {
+ if (this.press_gesture.get_current_button() == 1) {
+ Gdk.EventSequence sequence = this.press_gesture.get_current_sequence();
+ Gdk.Event event = this.press_gesture.get_last_event(sequence);
+ Gdk.ModifierType modifier_type;
+ event.get_state(out modifier_type);
+ if ((modifier_type & Gdk.ModifierType.SHIFT_MASK) ==
+ Gdk.ModifierType.SHIFT_MASK) {
+ var rows = this.list.get_selected_rows();
+ if (rows.length() != 0) {
+ var to_select = (Row) this.list.get_row_at_y((int) y);
+ var start_index = rows.last().data.get_index();
+ var end_index = to_select.get_index();
+ this.selection_mode_enabled = true;
+ for (int i = Geary.Numeric.min(start_index, end_index);
+ i < Geary.Numeric.max(start_index, end_index);
+ i++) {
+ this.list.select_row(this.list.get_row_at_index(i));
+ }
}
+
+ } else if ((modifier_type & Gdk.ModifierType.CONTROL_MASK) ==
+ Gdk.ModifierType.CONTROL_MASK) {
+ this.selection_mode_enabled = true;
}
+ } else {
+ var row = (Row) this.list.get_row_at_y((int) y);
+ context_menu(row);
}
}
- private void on_rows_changed() {
- schedule_visible_conversations_changed();
+ private void on_key_event_controller_key_released(uint keyval, uint keycode, Gdk.ModifierType state) {
+ if (keyval == Gdk.Key.Escape)
+ this.selection_mode_enabled = false;
}
- private void on_display_preview_changed() {
- style_updated();
- model.foreach(refresh_path);
+ /**
+ * Widgets used as drag icons have to be explicitly destroyed after the drag
+ * so we track the widget as a private member
+ */
+ private Row? drag_widget = null;
- schedule_visible_conversations_changed();
- }
-
- private bool refresh_path(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) {
- model.row_changed(path, iter);
- return false;
- }
+ private void on_drag_begin(Gdk.DragContext ctx) {
+ int screen_x, screen_y;
+ Gdk.ModifierType _modifier;
+ this.get_window().get_device_position(ctx.get_device(), out screen_x, out screen_y, out _modifier);
- // Enable/disable hover effect on all selected cells.
- private void set_hover_selected(bool hover) {
- ConversationListCellRenderer.set_hover_selected(hover);
- queue_draw();
- }
+ // If the user has a selection but drags starting from an unselected
+ // row, we need to set the selection to that row
+ Row? row = this.list.get_row_at_y(screen_y + (int) this.vadjustment.value) as Row?;
+ if (!row.is_selected()) {
+ this.list.unselect_all();
+ this.list.select_row(row);
+ }
- private bool on_motion_notify_event(Gdk.EventMotion event) {
- if (get_selection().count_selected_rows() > 0) {
- Gtk.TreePath? path = null;
- int cell_x, cell_y;
- get_path_at_pos((int) event.x, (int) event.y, out path, null, out cell_x, out cell_y);
+ this.drag_widget = new Row(this.config, row.conversation, false);
+ this.drag_widget.width_request = row.get_allocated_width();
+ this.drag_widget.get_style_context().add_class("drag-n-drop");
+ this.drag_widget.visible = true;
- set_hover_selected(path != null && get_selection().path_is_selected(path));
- }
- return Gdk.EVENT_PROPAGATE;
+ int hot_x, hot_y;
+ this.translate_coordinates(row, screen_x, screen_y, out hot_x, out hot_y);
+ Gtk.drag_set_icon_widget(ctx, this.drag_widget, hot_x, hot_y);
}
- private bool on_leave_notify_event() {
- if (get_selection().count_selected_rows() > 0) {
- set_hover_selected(false);
+ private void on_drag_end(Gdk.DragContext ctx) {
+ if (this.drag_widget != null) {
+ this.drag_widget.destroy();
+ this.drag_widget = null;
}
- return Gdk.EVENT_PROPAGATE;
-
}
- private void on_vadjustment_changed() {
- this.vadjustment.value_changed.connect(on_value_changed);
- }
+ private void on_selection_mode_changed() {
+ if (this.selection_mode_enabled)
+ this.list.set_selection_mode(Gtk.SelectionMode.MULTIPLE);
+ else
+ this.list.set_selection_mode(Gtk.SelectionMode.SINGLE);
+ this.list.foreach((child) => {
+ var row = (Row) child;
+ row.set_selection_enabled(this.selection_mode_enabled);
+ });
+ }
}
diff --git a/src/client/conversation-viewer/conversation-viewer.vala
b/src/client/conversation-viewer/conversation-viewer.vala
index 983d6539e..0a4f576d5 100644
--- a/src/client/conversation-viewer/conversation-viewer.vala
+++ b/src/client/conversation-viewer/conversation-viewer.vala
@@ -156,9 +156,9 @@ public class ConversationViewer : Gtk.Stack, Geary.BaseInterface {
// XXX move the ConversationListView management code into
// MainWindow or somewhere more appropriate
- ConversationListView conversation_list = main_window.conversation_list_view;
- this.selection_while_composing = conversation_list.copy_selected();
- conversation_list.get_selection().unselect_all();
+ ConversationList.View conversation_list = main_window.conversation_list_view;
+ this.selection_while_composing = conversation_list.get_selected();
+ conversation_list.unselect_all();
box.vanished.connect(on_composer_closed);
this.composer_page.add(box);
diff --git a/src/client/meson.build b/src/client/meson.build
index b0b11e87c..dfc3af2e4 100644
--- a/src/client/meson.build
+++ b/src/client/meson.build
@@ -88,10 +88,10 @@ client_vala_sources = files(
'composer/contact-entry-completion.vala',
'composer/spell-check-popover.vala',
- 'conversation-list/conversation-list-cell-renderer.vala',
- 'conversation-list/conversation-list-store.vala',
+ 'conversation-list/conversation-list-model.vala',
+ 'conversation-list/conversation-list-participant.vala',
+ 'conversation-list/conversation-list-row.vala',
'conversation-list/conversation-list-view.vala',
- 'conversation-list/formatted-conversation-data.vala',
'conversation-viewer/conversation-contact-popover.vala',
'conversation-viewer/conversation-email.vala',
diff --git a/src/client/sidebar/sidebar-count-cell-renderer.vala
b/src/client/sidebar/sidebar-count-cell-renderer.vala
index 615cb8641..c6bd1bfb5 100644
--- a/src/client/sidebar/sidebar-count-cell-renderer.vala
+++ b/src/client/sidebar/sidebar-count-cell-renderer.vala
@@ -23,7 +23,7 @@ public class SidebarCountCellRenderer : Gtk.CellRenderer {
public override void get_preferred_width(Gtk.Widget widget, out int minimum_size, out int natural_size) {
unread_count.count = counter;
- minimum_size = unread_count.get_width(widget) + FormattedConversationData.SPACING;
+ minimum_size = unread_count.get_width(widget) + CountBadge.SPACING;
natural_size = minimum_size;
}
diff --git a/src/engine/util/util-numeric.vala b/src/engine/util/util-numeric.vala
index 4c1ac9d79..646f2525e 100644
--- a/src/engine/util/util-numeric.vala
+++ b/src/engine/util/util-numeric.vala
@@ -56,5 +56,19 @@ public int int64_compare(void* a, void *b) {
return 0;
}
+public int max(int a, int b) {
+ if (a > b)
+ return a;
+ else
+ return b;
+}
+
+public int min(int a, int b) {
+ if (a < b)
+ return a;
+ else
+ return b;
+}
+
}
diff --git a/ui/application-main-window.ui b/ui/application-main-window.ui
index 36ae7a780..1a6295ee0 100644
--- a/ui/application-main-window.ui
+++ b/ui/application-main-window.ui
@@ -99,12 +99,6 @@
<property name="visible">True</property>
<property name="label_xalign">0</property>
<property name="shadow_type">none</property>
- <child>
- <object class="GtkScrolledWindow" id="conversation_list_scrolled">
- <property name="width_request">250</property>
- <property name="visible">True</property>
- </object>
- </child>
<style>
<class name="geary-conversation-frame"/>
</style>
diff --git a/ui/components-conversation-actions.ui b/ui/components-conversation-actions.ui
index f823dfcf4..7cdb538bd 100644
--- a/ui/components-conversation-actions.ui
+++ b/ui/components-conversation-actions.ui
@@ -82,15 +82,15 @@
<object class="GtkBox" id="mark_copy_move_buttons">
<property name="visible">True</property>
<child>
- <object class="GtkMenuButton" id="mark_message_button">
+ <object class="GtkMenuButton" id="copy_message_button">
<property name="visible">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">False</property>
<property name="always_show_image">True</property>
<child>
- <object class="GtkImage" id="mark_message_image">
+ <object class="GtkImage" id="copy_message_image">
<property name="visible">True</property>
- <property name="icon_name">checkbox-checked-symbolic</property>
+ <property name="icon_name">tag-symbolic</property>
</object>
</child>
</object>
@@ -101,15 +101,15 @@
</packing>
</child>
<child>
- <object class="GtkMenuButton" id="copy_message_button">
+ <object class="GtkMenuButton" id="move_message_button">
<property name="visible">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">False</property>
<property name="always_show_image">True</property>
<child>
- <object class="GtkImage" id="copy_message_image">
+ <object class="GtkImage" id="move_message_image">
<property name="visible">True</property>
- <property name="icon_name">tag-symbolic</property>
+ <property name="icon_name">folder-symbolic</property>
</object>
</child>
</object>
@@ -120,15 +120,15 @@
</packing>
</child>
<child>
- <object class="GtkMenuButton" id="move_message_button">
+ <object class="GtkMenuButton" id="mark_message_button">
<property name="visible">True</property>
<property name="focus_on_click">False</property>
<property name="receives_default">False</property>
<property name="always_show_image">True</property>
<child>
- <object class="GtkImage" id="move_message_image">
+ <object class="GtkImage" id="mark_message_image">
<property name="visible">True</property>
- <property name="icon_name">folder-symbolic</property>
+ <property name="icon_name">pan-down-symbolic</property>
</object>
</child>
</object>
diff --git a/ui/components-headerbar-conversation-list.ui b/ui/components-headerbar-conversation-list.ui
index 9addaab67..0abf89fc4 100644
--- a/ui/components-headerbar-conversation-list.ui
+++ b/ui/components-headerbar-conversation-list.ui
@@ -60,7 +60,24 @@
</object>
<packing>
<property name="pack_type">end</property>
- <property name="position">3</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkToggleButton" id="selection_button">
+ <property name="visible">True</property>
+ <property name="focus_on_click">False</property>
+ <property name="receives_default">False</property>
+ <property name="tooltip_text" translatable="yes">Selection conversations</property>
+ <property name="always_show_image">True</property>
+ <child>
+ <object class="GtkImage" id="selection_button_image">
+ <property name="visible">True</property>
+ <property name="icon_name">selection-mode-symbolic</property>
+ </object>
+ </child>
+ </object>
+ <packing>
+ <property name="pack_type">end</property>
</packing>
</child>
</template>
diff --git a/ui/conversation-list-row.ui b/ui/conversation-list-row.ui
new file mode 100644
index 000000000..6c07638bc
--- /dev/null
+++ b/ui/conversation-list-row.ui
@@ -0,0 +1,255 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.38.2 -->
+<interface>
+ <requires lib="gtk+" version="3.24"/>
+ <object class="GtkImage" id="flagged_icon">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="icon-name">starred-symbolic</property>
+ <property name="use-fallback">True</property>
+ <style>
+ <class name="conversation-row-button"/>
+ </style>
+ </object>
+ <object class="GtkImage" id="read_icon">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="icon-name">mail-read-symbolic</property>
+ <property name="use-fallback">True</property>
+ </object>
+ <object class="GtkImage" id="unflagged_icon">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="icon-name">non-starred-symbolic</property>
+ <property name="use-fallback">True</property>
+ </object>
+ <object class="GtkImage" id="unread_icon">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="pixel-size">16</property>
+ <property name="icon-name">mail-unread-symbolic</property>
+ <property name="use-fallback">True</property>
+ </object>
+ <template class="ConversationListRow" parent="GtkListBoxRow">
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkEventBox" id="eventbox">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkBox" id="container">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="has-tooltip">True</property>
+ <property name="spacing">5</property>
+ <property name="baseline-position">top</property>
+ <child>
+ <object class="GtkStack" id="stack">
+ <property name="visible">True</property>
+ <child>
+ <object class="GtkBox" id="buttons">
+ <property name="width-request">36</property>
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="vexpand">True</property>
+ <property name="orientation">vertical</property>
+ <property name="homogeneous">True</property>
+ <child>
+ <object class="GtkButton" id="unread">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="focus-on-click">False</property>
+ <property name="receives-default">True</property>
+ <property name="image">unread_icon</property>
+ <property name="relief">none</property>
+ <property name="valign">center</property>
+ <property name="always-show-image">True</property>
+ <signal name="clicked" handler="on_unread_button_clicked"/>
+ <style>
+ <class name="conversation-ephemeral-button"/>
+ <class name="unread-button"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkButton" id="flagged">
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="receives-default">True</property>
+ <property name="image">flagged_icon</property>
+ <property name="relief">none</property>
+ <property name="valign">center</property>
+ <property name="always-show-image">True</property>
+ <signal name="clicked" handler="on_flagged_button_clicked"/>
+ <style>
+ <class name="conversation-ephemeral-button"/>
+ <class name="flagged-button"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="name">buttons</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkCheckButton" id="selected_button">
+ <property name="can-focus">True</property>
+ <property name="receives-default">True</property>
+ <signal name="toggled" handler="on_selected_button_clicked"/>
+ <style>
+ <class name="selection-mode"/>
+ </style>
+ </object>
+ <packing>
+ <property name="name">selection-button</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="Details">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="orientation">vertical</property>
+ <property name="baseline-position">top</property>
+ <child>
+ <object class="GtkBox" id="Header">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkLabel" id="participants">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="use-markup">True</property>
+ <property name="ellipsize">end</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="participants"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="date">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <style>
+ <class name="date"/>
+ <class name="tertiary"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="pack-type">end</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">False</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="subject">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="halign">start</property>
+ <property name="ellipsize">end</property>
+ <property name="single-line-mode">True</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="subject"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">False</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkBox" id="preview_row">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <child>
+ <object class="GtkLabel" id="preview">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="halign">start</property>
+ <property name="wrap">True</property>
+ <property name="wrap-mode">word-char</property>
+ <property name="ellipsize">end</property>
+ <property name="lines">1</property>
+ <property name="xalign">0</property>
+ <style>
+ <class name="preview"/>
+ <class name="tertiary"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">0</property>
+ </packing>
+ </child>
+ <child>
+ <object class="GtkLabel" id="count_badge">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="valign">center</property>
+ <style>
+ <class name="count-badge"/>
+ </style>
+ </object>
+ <packing>
+ <property name="expand">False</property>
+ <property name="fill">True</property>
+ <property name="position">1</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">False</property>
+ <property name="position">2</property>
+ </packing>
+ </child>
+ </object>
+ <packing>
+ <property name="expand">True</property>
+ <property name="fill">True</property>
+ <property name="position">3</property>
+ </packing>
+ </child>
+ </object>
+ </child>
+ </object>
+ </child>
+ <style>
+ <class name="conversation-list"/>
+ </style>
+ </template>
+</interface>
diff --git a/ui/conversation-list-view.ui b/ui/conversation-list-view.ui
new file mode 100644
index 000000000..016eb69d7
--- /dev/null
+++ b/ui/conversation-list-view.ui
@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Generated with glade 3.38.2 -->
+<interface>
+ <requires lib="gtk+" version="3.24"/>
+ <template class="ConversationListView" parent="GtkScrolledWindow">
+ <property name="width-request">250</property>
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <child>
+ <object class="GtkViewport">
+ <property name="visible">True</property>
+ <property name="can-focus">False</property>
+ <property name="shadow-type">none</property>
+ <child>
+ <object class="GtkListBox" id="list">
+ <property name="name">conversation-list</property>
+ <property name="visible">True</property>
+ <property name="can-focus">True</property>
+ <property name="selection-mode">single</property>
+ <property name="activate-on-single-click">True</property>
+ </object>
+ </child>
+ </object>
+ </child>
+ </template>
+</interface>
diff --git a/ui/geary.css b/ui/geary.css
index 84533f5ff..bcbae7494 100644
--- a/ui/geary.css
+++ b/ui/geary.css
@@ -57,7 +57,7 @@ geary-conversation-viewer {
}
infobar flowboxchild {
- padding: 0px;
+ padding: 0px;
}
revealer components-conversation-actions {
@@ -66,6 +66,92 @@ revealer components-conversation-actions {
padding: 6px;
}
+
+/* Conversation List */
+row.conversation-list {
+ padding-top: 0.5em;
+ padding-bottom: 0.5em;
+ padding-right: 0.5em;
+}
+
+row.conversation-list.drag-n-drop {
+ background: @theme_base_color;
+ opacity: 0.7;
+ box-shadow: none;
+}
+
+row.conversation-list label {
+ margin-bottom: .4em;
+}
+
+row.conversation-list .tertiary {
+ opacity: 0.7;
+ font-size: 0.8em;
+}
+
+row.conversation-list .subject {
+ font-size: 0.9em;
+}
+
+row.conversation-list .date {
+ margin-left: 1em;
+}
+
+/* Unread styling */
+row.conversation-list.unread .preview {
+ opacity: 1;
+}
+
+row.conversation-list.unread .subject {
+ font-weight: bold;
+}
+
+row.conversation-list.unread .participants {
+ font-weight: bold;
+}
+
+row.conversation-list.unread .unread-button {
+ opacity: 1;
+}
+
+/* Hover buttons */
+row.conversation-list .conversation-ephemeral-button {
+ opacity: 0;
+ margin: 2px;
+ border-radius: 50%;
+ border: none;
+}
+
+row.conversation-list:hover .conversation-ephemeral-button {
+ opacity: 1;
+}
+
+row.conversation-list:selected .conversation-ephemeral-button {
+ opacity: 1;
+}
+
+
+row.conversation-list.flagged .flagged-button {
+ opacity: 1;
+}
+
+
+row.conversation-list .count-badge {
+ background: #888888;
+ color: white;
+ min-width: 1.5em;
+ border-radius: 1em;
+ font-size: .8em;
+ font-weight: bold;
+}
+
+row.conversation-list .selection-mode > check {
+ border-radius: 50%;
+ padding: 6px;
+ margin: 6px;
+}
+
+
/* FolderPopover */
row.geary-folder-popover-list-row {
@@ -407,11 +493,11 @@ popover.geary-editor > grid > button.geary-setting-remove {
}
dialog.geary-remove-confirm .dialog-vbox {
- margin: 12px;
+ margin: 12px;
}
dialog.geary-remove-confirm .dialog-action-box {
- margin: 6px;
+ margin: 6px;
}
/* FolderList.Tree */
@@ -420,6 +506,11 @@ treeview.sidebar {
border: none;
}
+treeview:selected.sidebar {
+ background: alpha(@theme_text_color, 0.1);
+ color: @theme_text_color;
+}
+
treeview.sidebar .cell {
padding: 9px 6px;
}
diff --git a/ui/org.gnome.Geary.gresource.xml b/ui/org.gnome.Geary.gresource.xml
index d25e3b596..c98d476f8 100644
--- a/ui/org.gnome.Geary.gresource.xml
+++ b/ui/org.gnome.Geary.gresource.xml
@@ -33,6 +33,8 @@
<file compressed="true">composer-web-view.css</file>
<file compressed="true">composer-web-view.js</file>
<file compressed="true" preprocess="xml-stripblanks">conversation-contact-popover.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks">conversation-list-row.ui</file>
+ <file compressed="true" preprocess="xml-stripblanks">conversation-list-view.ui</file>
<file compressed="true" preprocess="xml-stripblanks">conversation-email.ui</file>
<file compressed="true" preprocess="xml-stripblanks">conversation-email-menus.ui</file>
<file compressed="true" preprocess="xml-stripblanks">conversation-message.ui</file>
[
Date Prev][
Date Next] [
Thread Prev][
Thread Next]
[
Thread Index]
[
Date Index]
[
Author Index]