Break out ListBox used to display conversations into standalone widget.

The conversation viewer's ListBox is sufficiently complex to warrant its
own widget. Use empty placeholders for the list per the HIG, and
correctly fix mamagement of empty folder vs no conversations selected
this time.

* src/client/application/geary-controller.vala (GearyController):
  Directly manage secondary parts of the conversation viewer, since the
  controller since it has a better and more timely idea of when a
  conversation change is due to folder loading status or from the user
  selecting conversations, and so the viwer doesn't need to hook back
  into the controller. Remove the now-unused conversations_selected
  signal and its callers.

* src/client/conversation-viewer/conversation-listbox.vala: New widget
  for displaying the list of emails for a conversation. Moved relevant
  code from ConversationViewer here. Made adding emails async to get
  better UI responsiveness. Don't implement anything to handle
  conversation changes or emptying the list.

* src/client/conversation-viewer/conversation-viewer.vala: Replace user
  messages - empty folder/search & no/multiple messages selected with new
  EmptyPlaceholder. Remove a lot of the state manage code needed when
  managing the email listbox. Add a new ConversationListBox for every new
  conversation and just throw away.

* src/client/conversation-list/conversation-list-view.vala
  (ConversationListView): Clean up firing the conversations_selected
  signal - don't actually emit it when the model is clearing, and don't
  bother delaying the check either.

* src/client/components/empty-placeholder.vala: New widget for displaying
  empty list and grid placeholders per the HIG.

* src/client/conversation-viewer/conversation-email.vala
  (ConversationEmail): Make manually read a property, since it
  effectively is one.

* src/CMakeLists.txt: Include new source files.

* po/POTFILES.in: Include new source and UI files, and some missing ones.

* ui/CMakeLists.txt: Include new UI files.

* ui/conversation-viewer.ui: Replace user message and splash page with
  placeholders for the new empty placeholders(!).

* ui/empty-placeholder.ui: UI def for new widget class.

* ui/geary.css: Chase widget name/class changes, style new
  empty placeholder UI.
This commit is contained in:
Michael James Gratton 2016-07-25 10:33:42 +10:00
parent 9f1854548d
commit d467647153
14 changed files with 1270 additions and 1090 deletions

View file

@ -24,6 +24,7 @@ src/client/application/main.vala
src/client/application/secret-mediator.vala
src/client/components/conversation-find-bar.vala
src/client/components/count-badge.vala
src/client/components/empty-placeholder.vala
src/client/components/folder-popover.vala
src/client/components/icon-factory.vala
src/client/components/main-toolbar.vala
@ -51,8 +52,10 @@ src/client/conversation-list/conversation-list-cell-renderer.vala
src/client/conversation-list/conversation-list-store.vala
src/client/conversation-list/conversation-list-view.vala
src/client/conversation-list/formatted-conversation-data.vala
src/client/conversation-viewer/conversation-viewer.vala
src/client/conversation-viewer/conversation-email.vala
src/client/conversation-viewer/conversation-listbox.vala
src/client/conversation-viewer/conversation-message.vala
src/client/conversation-viewer/conversation-viewer.vala
src/client/conversation-viewer/conversation-web-view.vala
src/client/dialogs/alert-dialog.vala
src/client/dialogs/attachment-dialog.vala

View file

@ -333,6 +333,7 @@ client/accounts/login-dialog.vala
client/components/conversation-find-bar.vala
client/components/count-badge.vala
client/components/empty-placeholder.vala
client/components/folder-popover.vala
client/components/icon-factory.vala
client/components/main-toolbar.vala
@ -363,6 +364,7 @@ client/conversation-list/conversation-list-view.vala
client/conversation-list/formatted-conversation-data.vala
client/conversation-viewer/conversation-email.vala
client/conversation-viewer/conversation-listbox.vala
client/conversation-viewer/conversation-message.vala
client/conversation-viewer/conversation-viewer.vala
client/conversation-viewer/conversation-web-view.vala

View file

@ -142,12 +142,6 @@ public class GearyController : Geary.BaseObject {
*/
public signal void folder_selected(Geary.Folder? folder);
/**
* Fired when the currently selected conversation(s) has/have changed.
*/
public signal void conversations_selected(Gee.Set<Geary.App.Conversation> conversations,
Geary.Folder current_folder);
/**
* Fired when the number of conversations changes.
*/
@ -228,12 +222,15 @@ public class GearyController : Geary.BaseObject {
main_window.main_toolbar.copy_folder_menu.folder_selected.connect(on_copy_conversation);
main_window.main_toolbar.move_folder_menu.folder_selected.connect(on_move_conversation);
main_window.search_bar.search_text_changed.connect(on_search_text_changed);
main_window.conversation_viewer.email_row_added.connect(on_email_row_added);
main_window.conversation_viewer.email_row_removed.connect(on_email_row_removed);
main_window.conversation_viewer.mark_emails.connect(on_conversation_viewer_mark_emails);
main_window.conversation_viewer.conversation_added.connect(
on_conversation_view_added
);
main_window.conversation_viewer.conversation_removed.connect(
on_conversation_view_removed
);
new_messages_monitor = new NewMessagesMonitor(should_notify_new_messages);
main_window.folder_list.set_new_messages_monitor(new_messages_monitor);
// New messages indicator (Ubuntuism)
new_messages_indicator = NewMessagesIndicator.create(new_messages_monitor);
new_messages_indicator.application_activated.connect(on_indicator_activated_application);
@ -289,8 +286,8 @@ public class GearyController : Geary.BaseObject {
Geary.Engine.instance.account_available.disconnect(on_account_available);
Geary.Engine.instance.account_unavailable.disconnect(on_account_unavailable);
Geary.Engine.instance.untrusted_host.disconnect(on_untrusted_host);
// Connect to various UI signals.
// Disconnect from various UI signals.
main_window.conversation_list_view.conversations_selected.disconnect(on_conversations_selected);
main_window.conversation_list_view.conversation_activated.disconnect(on_conversation_activated);
main_window.conversation_list_view.load_more.disconnect(on_load_more);
@ -302,9 +299,12 @@ public class GearyController : Geary.BaseObject {
main_window.main_toolbar.copy_folder_menu.folder_selected.disconnect(on_copy_conversation);
main_window.main_toolbar.move_folder_menu.folder_selected.disconnect(on_move_conversation);
main_window.search_bar.search_text_changed.disconnect(on_search_text_changed);
main_window.conversation_viewer.email_row_added.disconnect(on_email_row_added);
main_window.conversation_viewer.email_row_removed.disconnect(on_email_row_removed);
main_window.conversation_viewer.mark_emails.disconnect(on_conversation_viewer_mark_emails);
main_window.conversation_viewer.conversation_added.disconnect(
on_conversation_view_added
);
main_window.conversation_viewer.conversation_removed.disconnect(
on_conversation_view_removed
);
// hide window while shutting down, as this can take a few seconds under certain conditions
main_window.hide();
@ -1297,14 +1297,15 @@ public class GearyController : Geary.BaseObject {
private void on_folder_selected(Geary.Folder? folder) {
debug("Folder %s selected", folder != null ? folder.to_string() : "(null)");
this.main_window.conversation_viewer.show_loading();
// If the folder is being unset, clear the message list and exit here.
if (folder == null) {
current_folder = null;
main_window.conversation_list_store.clear();
main_window.main_toolbar.folder = null;
folder_selected(null);
return;
}
@ -1444,12 +1445,22 @@ public class GearyController : Geary.BaseObject {
on_load_more();
}
}
private void on_conversation_count_changed() {
if (current_conversations != null)
conversation_count_changed(current_conversations.get_conversation_count());
if (this.current_conversations != null) {
int count = this.current_conversations.get_conversation_count();
if (count == 0) {
// Let the user know if there's no available conversations
if (this.current_folder is Geary.SearchFolder) {
this.main_window.conversation_viewer.show_empty_search();
} else {
this.main_window.conversation_viewer.show_empty_folder();
}
}
conversation_count_changed(count);
}
}
private void on_libnotify_invoked(Geary.Folder? folder, Geary.Email? email) {
new_messages_monitor.clear_all_new_messages();
@ -1491,11 +1502,13 @@ public class GearyController : Geary.BaseObject {
debug("Unable to select folder: %s", err.message);
}
}
private void on_conversations_selected(Gee.Set<Geary.App.Conversation> selected) {
selected_conversations = selected;
if (current_folder != null) {
conversations_selected(selected_conversations, current_folder);
if (this.current_folder != null) {
this.main_window.conversation_viewer.load_conversations.begin(
selected, this.current_folder
);
}
}
@ -1811,9 +1824,13 @@ public class GearyController : Geary.BaseObject {
Gee.ArrayList<Geary.EmailIdentifier> ids = get_selected_email_ids(false);
mark_email(ids, null, flags);
foreach (Geary.EmailIdentifier id in ids)
main_window.conversation_viewer.mark_manual_read(id);
ConversationListBox? list =
main_window.conversation_viewer.current_list;
if (list != null) {
foreach (Geary.EmailIdentifier id in ids)
list.mark_manual_read(id);
}
}
private void on_mark_as_unread() {
@ -1822,9 +1839,13 @@ public class GearyController : Geary.BaseObject {
Gee.ArrayList<Geary.EmailIdentifier> ids = get_selected_email_ids(true);
mark_email(ids, flags, null);
foreach (Geary.EmailIdentifier id in ids)
main_window.conversation_viewer.mark_manual_read(id);
ConversationListBox? list =
main_window.conversation_viewer.current_list;
if (list != null) {
foreach (Geary.EmailIdentifier id in ids)
list.mark_manual_unread(id);
}
}
private void on_mark_as_starred() {
@ -2142,15 +2163,19 @@ public class GearyController : Geary.BaseObject {
// was triggered. If null, this was triggered from the headerbar
// or shortcut.
private void create_reply_forward_widget(ComposerWidget.ComposeType compose_type,
owned ConversationEmail? view) {
if (view == null) {
view = main_window.conversation_viewer.get_reply_email_view();
owned ConversationEmail? email_view) {
if (email_view == null) {
ConversationListBox? list_view =
main_window.conversation_viewer.current_list;
if (list_view != null) {
email_view = list_view.reply_target;
}
}
string? quote = null;
if (view != null) {
quote = view.get_body_selection();
if (email_view != null) {
quote = email_view.get_body_selection();
}
create_compose_widget(compose_type, view.email, quote);
create_compose_widget(compose_type, email_view.email, quote);
}
private void create_compose_widget(ComposerWidget.ComposeType compose_type,
@ -2177,7 +2202,10 @@ public class GearyController : Geary.BaseObject {
bool inline;
if (!should_create_new_composer(compose_type, referred, quote, is_draft, out inline))
return;
ConversationListBox? conversation_view =
main_window.conversation_viewer.current_list;
ComposerWidget widget;
if (mailto != null) {
widget = new ComposerWidget.from_mailto(current_account, mailto);
@ -2196,7 +2224,9 @@ public class GearyController : Geary.BaseObject {
widget = new ComposerWidget(current_account, compose_type, full, quote, is_draft);
if (is_draft) {
yield widget.restore_draft_state_async(current_account);
main_window.conversation_viewer.blacklist_by_id(referred.id);
if (conversation_view != null) {
conversation_view.blacklist_by_id(referred.id);
}
}
}
widget.show_all();
@ -2212,7 +2242,14 @@ public class GearyController : Geary.BaseObject {
widget.state == ComposerWidget.ComposerState.PANED) {
main_window.conversation_viewer.do_compose(widget);
} else {
main_window.conversation_viewer.do_embedded_composer(widget, referred);
ComposerEmbed embed = new ComposerEmbed(
referred,
widget,
main_window.conversation_viewer.conversation_page
);
if (conversation_view != null) {
conversation_view.add_embedded_composer(embed);
}
}
} else {
new ComposerWindow(widget);
@ -2471,13 +2508,15 @@ public class GearyController : Geary.BaseObject {
Cancellable? cancellable) throws Error {
if (!can_switch_conversation_view())
return;
if (main_window.conversation_viewer.current_conversation != null
&& main_window.conversation_viewer.current_conversation == last_deleted_conversation) {
ConversationListBox list_view =
main_window.conversation_viewer.current_list;
if (list_view != null &&
list_view.conversation == last_deleted_conversation) {
debug("Not archiving/trashing/deleting; viewed conversation is last deleted conversation");
return;
}
last_deleted_conversation = selected_conversations.size > 0
? Geary.traverse<Geary.App.Conversation>(selected_conversations).first() : null;
@ -2606,15 +2645,27 @@ public class GearyController : Geary.BaseObject {
}
private void on_zoom_in() {
this.main_window.conversation_viewer.zoom_in();
ConversationListBox? view =
main_window.conversation_viewer.current_list;
if (view != null) {
view.zoom_in();
}
}
private void on_zoom_out() {
this.main_window.conversation_viewer.zoom_out();
ConversationListBox? view =
main_window.conversation_viewer.current_list;
if (view != null) {
view.zoom_out();
}
}
private void on_zoom_normal() {
this.main_window.conversation_viewer.zoom_reset();
ConversationListBox? view =
main_window.conversation_viewer.current_list;
if (view != null) {
view.zoom_reset();
}
}
private void on_search() {
@ -2629,28 +2680,40 @@ public class GearyController : Geary.BaseObject {
Libnotify.play_sound("message-sent-email");
}
private void on_email_row_added(ConversationEmail message) {
message.reply_to_message.connect(on_reply_to_message);
message.reply_all_message.connect(on_reply_all_message);
message.forward_message.connect(on_forward_message);
message.link_activated.connect(on_link_activated);
message.attachments_activated.connect(on_attachments_activated);
message.save_attachments.connect(on_save_attachments);
message.edit_draft.connect(on_edit_draft);
message.view_source.connect(on_view_source);
message.save_image.connect(on_save_buffer_to_file);
private void on_conversation_view_added(ConversationListBox list) {
list.email_added.connect(on_conversation_viewer_email_added);
list.email_removed.connect(on_conversation_viewer_email_removed);
list.mark_emails.connect(on_conversation_viewer_mark_emails);
}
private void on_email_row_removed(ConversationEmail message) {
message.reply_to_message.disconnect(on_reply_to_message);
message.reply_all_message.disconnect(on_reply_all_message);
message.forward_message.disconnect(on_forward_message);
message.link_activated.disconnect(on_link_activated);
message.attachments_activated.disconnect(on_attachments_activated);
message.save_attachments.disconnect(on_save_attachments);
message.edit_draft.disconnect(on_edit_draft);
message.view_source.disconnect(on_view_source);
message.save_image.disconnect(on_save_buffer_to_file);
private void on_conversation_view_removed(ConversationListBox list) {
list.email_added.disconnect(on_conversation_viewer_email_added);
list.email_removed.disconnect(on_conversation_viewer_email_removed);
list.mark_emails.disconnect(on_conversation_viewer_mark_emails);
}
private void on_conversation_viewer_email_added(ConversationEmail view) {
view.reply_to_message.connect(on_reply_to_message);
view.reply_all_message.connect(on_reply_all_message);
view.forward_message.connect(on_forward_message);
view.link_activated.connect(on_link_activated);
view.attachments_activated.connect(on_attachments_activated);
view.save_attachments.connect(on_save_attachments);
view.edit_draft.connect(on_edit_draft);
view.view_source.connect(on_view_source);
view.save_image.connect(on_save_buffer_to_file);
}
private void on_conversation_viewer_email_removed(ConversationEmail view) {
view.reply_to_message.disconnect(on_reply_to_message);
view.reply_all_message.disconnect(on_reply_all_message);
view.forward_message.disconnect(on_forward_message);
view.link_activated.disconnect(on_link_activated);
view.attachments_activated.disconnect(on_attachments_activated);
view.save_attachments.disconnect(on_save_attachments);
view.edit_draft.disconnect(on_edit_draft);
view.view_source.disconnect(on_view_source);
view.save_image.disconnect(on_save_buffer_to_file);
}
private void on_link_activated(string link) {

View file

@ -0,0 +1,38 @@
/*
* Copyright 2016 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* A placeholder image and message for empty views.
*/
[GtkTemplate (ui = "/org/gnome/Geary/empty-placeholder.ui")]
public class EmptyPlaceholder : Gtk.Grid {
public string image_name {
owned get { return this.placeholder_image.icon_name; }
set { this.placeholder_image.icon_name = value; }
}
public string title {
get { return this.title_label.get_text(); }
set { this.title_label.set_text(value); }
}
public string subtitle {
get { return this.subtitle_label.get_text(); }
set { this.subtitle_label.set_text(value); }
}
[GtkChild]
private Gtk.Image placeholder_image;
[GtkChild]
private Gtk.Label title_label;
[GtkChild]
private Gtk.Label subtitle_label;
}

View file

@ -10,13 +10,14 @@
*/
public class ComposerBox : Gtk.Frame, ComposerContainer {
public Gtk.ApplicationWindow top_window {
get { return (Gtk.ApplicationWindow) get_toplevel(); }
}
protected ComposerWidget composer { get; set; }
protected Gee.MultiMap<string, string>? old_accelerators { get; set; }
public Gtk.ApplicationWindow top_window {
get { return (Gtk.ApplicationWindow) get_toplevel(); }
}
public signal void vanished();

View file

@ -555,9 +555,10 @@ public class ComposerWidget : Gtk.EventBox {
// from being finalised when closed.
ConversationViewer conversation_viewer =
GearyApplication.instance.controller.main_window.conversation_viewer;
conversation_viewer.cleared.connect((viewer) => {
if (this.draft_manager != null)
viewer.blacklist_by_id(this.draft_manager.current_draft_id);
conversation_viewer.conversation_added.connect((list_view) => {
if (this.draft_manager != null) {
list_view.blacklist_by_id(this.draft_manager.current_draft_id);
}
});
// Don't do this in an overridden version of the destroy
@ -1413,8 +1414,11 @@ public class ComposerWidget : Gtk.EventBox {
}
private void on_draft_id_changed() {
GearyApplication.instance.controller.main_window.conversation_viewer.blacklist_by_id(
this.draft_manager.current_draft_id);
ConversationListBox? list_view =
GearyApplication.instance.controller.main_window.conversation_viewer.current_list;
if (list_view != null) {
list_view.blacklist_by_id(this.draft_manager.current_draft_id);
}
}
private void on_draft_manager_fatal(Error err) {
@ -1514,11 +1518,12 @@ public class ComposerWidget : Gtk.EventBox {
} catch (Error err) {
// ignored
}
if (this.draft_manager != null)
GearyApplication.instance.controller.main_window.conversation_viewer
.unblacklist_by_id(this.draft_manager.current_draft_id);
this.container.close_container();
ConversationListBox? list_view =
GearyApplication.instance.controller.main_window.conversation_viewer.current_list;
if (this.draft_manager != null && list_view != null) {
list_view.unblacklist_by_id(this.draft_manager.current_draft_id);
}
container.close_container();
}
private async void discard_and_exit_async() {

View file

@ -311,69 +311,66 @@ public class ConversationListView : Gtk.TreeView {
private Gtk.TreePath? get_selected_path() {
return get_all_selected_paths().nth_data(0);
}
private void on_selection_changed() {
if (selection_changed_id != 0)
Source.remove(selection_changed_id);
// 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.
selection_changed_id = Idle.add(() => {
// no longer scheduled
selection_changed_id = 0;
do_selection_changed();
return false;
}, Priority.LOW);
if (this.selection_changed_id != 0)
Source.remove(this.selection_changed_id);
if (this.conversation_list_store.is_clearing) {
// The list store is clearing, so the folder has changed
// and we don't want to notify about the selection
// changing, so just clear it.
this.selected.clear();
} else {
// 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_changed_id = Idle.add(() => {
// no longer scheduled
this.selection_changed_id = 0;
// Pass the is_clearing flag through here so the value is
// accurate later on, when the idle callback actually
// happens.
do_selection_changed();
return false;
}, Priority.LOW);
}
}
// 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
// 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() {
// if the ConversationListStore is clearing, then this is called repeatedly as the elements
// are removed, causing signals to fire and a flurry of I/O that is immediately cancelled
// this prevents that, merely firing the signal once to indicate all selections are
// dropped while clearing
if (conversation_list_store.is_clearing) {
if (selected.size > 0) {
selected.clear();
conversations_selected(selected.read_only_view);
}
return;
}
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) {
// only notify if this is different than what was previously reported
if (selected.size != 0) {
selected.clear();
conversations_selected(selected.read_only_view);
if (paths.length() != 0) {
// Conversations are selected, so collect them and
// signal if different
foreach (Gtk.TreePath path in paths) {
Geary.App.Conversation? conversation =
this.conversation_list_store.get_conversation_at_path(path);
if (conversation != null)
new_selection.add(conversation);
}
return;
}
// Conversations are selected, so collect them and signal if different
Gee.HashSet<Geary.App.Conversation> new_selected = new Gee.HashSet<Geary.App.Conversation>();
foreach (Gtk.TreePath path in paths) {
Geary.App.Conversation? conversation = conversation_list_store.get_conversation_at_path(path);
if (conversation != null)
new_selected.add(conversation);
}
// only notify if different than what was previously reported
if (!Geary.Collection.are_sets_equal<Geary.App.Conversation>(selected, new_selected)) {
selected = new_selected;
conversations_selected(selected.read_only_view);
if (!Geary.Collection.are_sets_equal<Geary.App.Conversation>(
this.selected, new_selection)) {
this.selected = new_selection;
conversations_selected(this.selected.read_only_view);
}
}
public Gee.Set<Geary.App.Conversation> get_visible_conversations() {
Gee.HashSet<Geary.App.Conversation> visible_conversations = new Gee.HashSet<Geary.App.Conversation>();

View file

@ -119,6 +119,7 @@ public class ConversationEmail : Gtk.Box {
private const string ACTION_UNSTAR = "unstar";
private const string ACTION_VIEW_SOURCE = "view_source";
private const string MANUAL_READ_CLASS = "geary-manual-read";
/** The specific email that is displayed by this view. */
public Geary.Email email { get; private set; }
@ -126,6 +127,18 @@ public class ConversationEmail : Gtk.Box {
/** Determines if the email is showing a preview or the full message. */
public bool is_collapsed = true;
/** Determines if the email has been manually marked as being read. */
public bool is_manually_read {
get { return get_style_context().has_class(MANUAL_READ_CLASS); }
set {
if (value) {
get_style_context().add_class(MANUAL_READ_CLASS);
} else {
get_style_context().remove_class(MANUAL_READ_CLASS);
}
}
}
/** The view displaying the email's primary message headers and body. */
public ConversationMessage primary_message { get; private set; }
@ -421,20 +434,6 @@ public class ConversationEmail : Gtk.Box {
update_email_state();
}
/**
* Determines if the email is flagged as read on the client side only.
*/
public bool is_manual_read() {
return get_style_context().has_class("geary_manual_read");
}
/**
* Displays the message as read, even if not reflected in its flags.
*/
public void mark_manual_read() {
get_style_context().add_class("geary_manual_read");
}
/**
* Returns user-selected body HTML from a message, if any.
*/

View file

@ -0,0 +1,736 @@
/*
* Copyright 2016 Software Freedom Conservancy Inc.
* Copyright 2016 Michael Gratton <mike@vee.net>
*
* This software is licensed under the GNU Lesser General Public License
* (version 2.1 or later). See the COPYING file in this distribution.
*/
/**
* A widget for displaying conversations as a list of emails.
*
* The view displays the current selected {@link
* Geary.App.Conversation} from the conversation list. To do so, it
* listens to signals from both the list and the current conversation
* monitor, updating the email list as needed.
*
* Unlike ConversationListStore (which sorts by date received),
* ConversationListBox sorts by the {@link Geary.Email.date} field
* (the Date: header), as that's the date displayed to the user.
*/
public class ConversationListBox : Gtk.ListBox {
/** Fields that must be available for display as a conversation. */
public const Geary.Email.Field REQUIRED_FIELDS =
Geary.Email.Field.HEADER
| Geary.Email.Field.BODY
| Geary.Email.Field.ORIGINATORS
| Geary.Email.Field.RECEIVERS
| Geary.Email.Field.SUBJECT
| Geary.Email.Field.DATE
| Geary.Email.Field.FLAGS
| Geary.Email.Field.PREVIEW;
// Offset from the top of the list box which emails views will
// scrolled to, so the user can see there are additional messages
// above it. XXX This is currently approx 1.5 times the height of
// a collapsed ConversationEmail, it should probably calculated
// somehow so that differences user's font size are taken into
// account.
private const int EMAIL_TOP_OFFSET = 92;
// Custom class used to display ConversationEmail views in the
// conversation listbox.
private class EmailRow : Gtk.ListBoxRow {
private const string EXPANDED_CLASS = "geary-expanded";
private const string LAST_CLASS = "geary-last";
// Is the row showing the email's message body?
public bool is_expanded {
get { return get_style_context().has_class(EXPANDED_CLASS); }
}
// Designate this row as the last visible row in the
// conversation listbox, or not. See Bug 764710 and
// ::update_last_row() below.
public bool is_last {
get { return get_style_context().has_class(LAST_CLASS); }
set {
if (value) {
get_style_context().add_class(LAST_CLASS);
} else {
get_style_context().remove_class(LAST_CLASS);
}
}
}
// We can only scroll to a specific row once it has been
// allocated space. This signal allows the viewer to hook up
// to appropriate times to try to do that scroll.
public signal void should_scroll();
public ConversationEmail view {
get { return (ConversationEmail) get_child(); }
}
public EmailRow(ConversationEmail view) {
add(view);
}
public new void expand(bool include_transitions=true) {
get_style_context().add_class(EXPANDED_CLASS);
this.view.expand_email(include_transitions);
}
public void collapse() {
get_style_context().remove_class(EXPANDED_CLASS);
this.view.collapse_email();
}
public void enable_should_scroll() {
this.size_allocate.connect(on_size_allocate);
}
private void on_size_allocate() {
// We need to wait the web view to load first, so that the
// message has a non-trivial height, and then wait for it
// to be reallocated, so that it picks up the web_view's
// height.
ConversationWebView web_view = view.primary_message.web_view;
if (web_view.load_status == WebKit.LoadStatus.FINISHED) {
// Disable should_scroll after the message body has
// been loaded so we don't keep on scrolling later,
// like when the window has been resized.
this.size_allocate.disconnect(on_size_allocate);
}
should_scroll();
}
}
/**
* Returns the view for the email to be replied to, if any.
*
* If an email view has selected body text that view will be
* returned. Else the last message by sort order will be returned,
* if any.
*/
public ConversationEmail? reply_target {
get {
unowned ConversationEmail? view = this.body_selected_view;
if (view == null && this.last_email_row != null) {
view = this.last_email_row.view;
}
return view;
}
}
/** Conversation being displayed. */
public Geary.App.Conversation conversation { get; private set; }
// Contacts for the account this conversation exists in
private Geary.ContactStore contact_store;
private Geary.App.EmailStore email_store;
// Was this conversation loaded from the drafts folder?
private bool is_draft_folder;
// Cancellable for this conversation's data loading.
private Cancellable cancellable = new Cancellable();
// Email view with selected text, if any
private ConversationEmail? body_selected_view = null;
// Maps displayed emails to their corresponding EmailRow.
private Gee.HashMap<Geary.EmailIdentifier, EmailRow> id_to_row = new
Gee.HashMap<Geary.EmailIdentifier, EmailRow>();
// Last visible row in the list, if any
private EmailRow? last_email_row = null;
/** Fired when an email view is added to the conversation list. */
public signal void email_added(ConversationEmail email);
/** Fired when an email view is removed from the conversation list. */
public signal void email_removed(ConversationEmail email);
/** Fired when the user updates the flags for a set of emails. */
public signal void mark_emails(Gee.Collection<Geary.EmailIdentifier> emails,
Geary.EmailFlags? flags_to_add, Geary.EmailFlags? flags_to_remove);
/**
* Constructs a new conversation list box instance.
*/
public ConversationListBox(Geary.App.Conversation conversation,
Geary.ContactStore contact_store,
Geary.App.EmailStore? email_store,
bool is_draft_folder,
Gtk.Adjustment adjustment) {
this.conversation = conversation;
this.contact_store = contact_store;
this.email_store = email_store;
this.is_draft_folder = is_draft_folder;
get_style_context().add_class("background");
get_style_context().add_class("conversation-listbox");
set_adjustment(adjustment);
set_selection_mode(Gtk.SelectionMode.NONE);
this.key_press_event.connect(on_key_press);
this.realize.connect(() => {
adjustment.value_changed.connect(check_mark_read);
});
this.row_activated.connect(on_row_activated);
this.set_sort_func(on_sort);
this.size_allocate.connect(() => { check_mark_read(); });
this.conversation.appended.connect(on_conversation_appended);
this.conversation.trimmed.connect(on_conversation_trimmed);
this.conversation.email_flags_changed.connect(on_update_flags);
}
~ConversationListBox() {
this.cancellable.cancel();
this.conversation.email_flags_changed.disconnect(on_update_flags);
this.conversation.trimmed.disconnect(on_conversation_trimmed);
this.conversation.appended.disconnect(on_conversation_appended);
get_adjustment().value_changed.disconnect(check_mark_read);
}
public async void load_conversation()
throws Error {
// Fetch full emails.
Gee.Collection<Geary.Email>? emails_to_add =
yield list_full_emails_async(
this.conversation.get_emails(
Geary.App.Conversation.Ordering.SENT_DATE_ASCENDING
),
this.cancellable
);
if (emails_to_add != null) {
foreach (Geary.Email email in emails_to_add) {
if (this.cancellable.is_cancelled()) {
return;
}
yield add_email(
email, conversation.is_in_current_folder(email.id)
);
}
}
if (this.cancellable.is_cancelled()) {
return;
}
// Work out what the first expanded row is. We can't do this
// in the foreach above since that is not adding messages in
// order.
EmailRow? first_expanded_row = null;
this.foreach((child) => {
if (first_expanded_row == null) {
EmailRow row = (EmailRow) child;
row.should_scroll.connect(scroll_to);
if (row.is_expanded) {
first_expanded_row = row;
}
}
});
if (this.last_email_row != null) {
// The last email should always be expanded so the user
// isn't presented with a list of collapsed headers when a
// conversation has no unread messages.
this.last_email_row.expand(true);
if (first_expanded_row == null) {
first_expanded_row = this.last_email_row;
}
// The first expanded row (i.e. first unread or simply the
// last message) should always be scrolled to the top of
// the visible area.
first_expanded_row.enable_should_scroll();
}
debug("Conversation loading complete");
}
/**
* Cancel all loading activity for the conversation.
*/
public void cancel_load() {
this.cancellable.cancel();
}
/**
* Adds an an embedded composer to the view.
*/
public void add_embedded_composer(ComposerEmbed embed) {
EmailRow? row = this.id_to_row.get(embed.referred.id);
if (row != null) {
row.view.attach_composer(embed);
embed.loaded.connect((box) => {
embed.grab_focus();
});
embed.vanished.connect((box) => {
row.view.remove_composer(embed);
});
} else {
error("Could not find referred email for embedded composer: %s",
embed.referred.id.to_string());
}
}
/**
* Finds any currently visible messages, marks them as being read.
*/
public void check_mark_read() {
Gee.ArrayList<Geary.EmailIdentifier> email_ids =
new Gee.ArrayList<Geary.EmailIdentifier>();
Gtk.Adjustment adj = get_adjustment();
int top_bound = (int) adj.value;
int bottom_bound = top_bound + (int) adj.page_size;
email_view_iterator().foreach((email_view) => {
const int TEXT_PADDING = 50;
ConversationMessage conversation_message = email_view.primary_message;
// Don't bother with not-yet-loaded emails since the
// size of the body will be off, affecting the visibility
// of emails further down the conversation.
if (email_view.email.email_flags.is_unread() &&
conversation_message.is_loading_complete &&
!email_view.is_manually_read) {
int body_top = 0;
int body_left = 0;
conversation_message.web_view.translate_coordinates(
this,
0, 0,
out body_left, out body_top
);
int body_bottom = body_top +
conversation_message.web_view_allocation.height;
// Only mark the email as read if it's actually visible
if (body_bottom > top_bound &&
body_top + TEXT_PADDING < bottom_bound) {
email_ids.add(email_view.email.id);
// Since it can take some time for the new flags
// to round-trip back to our signal handlers,
// mark as manually read here
email_view.is_manually_read = true;
}
}
return true;
});
if (email_ids.size > 0) {
Geary.EmailFlags flags = new Geary.EmailFlags();
flags.add(Geary.EmailFlags.UNREAD);
mark_emails(email_ids, null, flags);
}
}
/**
* Displays an email as being read, regardless of its actual flags.
*/
public void mark_manual_read(Geary.EmailIdentifier id) {
EmailRow? row = this.id_to_row.get(id);
if (row != null) {
row.view.is_manually_read = true;
}
}
/**
* Displays an email as being unread, regardless of its actual flags.
*/
public void mark_manual_unread(Geary.EmailIdentifier id) {
EmailRow? row = this.id_to_row.get(id);
if (row != null) {
row.view.is_manually_read = false;
}
}
/**
* Hides a specific email in the conversation.
*/
public void blacklist_by_id(Geary.EmailIdentifier? id) {
EmailRow? row = this.id_to_row.get(id);
if (row != null) {
row.hide();
update_last_row();
}
}
/**
* Re-displays a previously blacklisted email.
*/
public void unblacklist_by_id(Geary.EmailIdentifier? id) {
EmailRow? row = this.id_to_row.get(id);
if (row != null) {
row.show();
update_last_row();
}
}
/**
* Loads search term matches for this list's emails.
*/
public async void load_search_terms(Geary.SearchFolder search) {
Geary.SearchQuery? query = search.search_query;
if (query != null) {
// List all IDs of emails we're viewing.
Gee.Collection<Geary.EmailIdentifier> ids =
new Gee.ArrayList<Geary.EmailIdentifier>();
foreach (Gee.Map.Entry<Geary.EmailIdentifier, EmailRow> entry
in this.id_to_row.entries) {
if (entry.value.get_visible()) {
ids.add(entry.key);
}
}
Gee.Set<string>? search_matches = null;
try {
search_matches = yield search.get_search_matches_async(
ids, cancellable
);
} catch (Error e) {
debug("Error highlighting search results: %s", e.message);
// Continue on here since if nothing else we have the
// fudging to fall back on immediately below.
}
if (search_matches == null)
search_matches = new Gee.HashSet<string>();
// This applies a fudge-factor set of matches when the database results
// aren't entirely satisfactory, such as when you search for an email
// address and the database tokenizes out the @ and ., etc. It's not meant
// to be comprehensive, just a little extra highlighting applied to make
// the results look a little closer to what you typed.
foreach (string word in query.raw.split(" ")) {
if (word.has_suffix("\""))
word = word.substring(0, word.length - 1);
if (word.has_prefix("\""))
word = word.substring(1);
if (!Geary.String.is_empty_or_whitespace(word))
search_matches.add(word);
}
if (!this.cancellable.is_cancelled()) {
highlight_search_terms(search_matches);
}
}
}
/**
* Applies search term highlighting to all email views.
*/
public void highlight_search_terms(Gee.Set<string>? search_matches) {
// Webkit's highlighting is ... weird. In order to actually
// see all the highlighting you're applying, it seems
// necessary to start with the shortest string and work up.
// If you don't, it seems that shorter strings will overwrite
// longer ones, and you're left with incomplete highlighting.
Gee.ArrayList<string> ordered_matches = new Gee.ArrayList<string>();
ordered_matches.add_all(search_matches);
ordered_matches.sort((a, b) => a.length - b.length);
message_view_iterator().foreach((msg_view) => {
msg_view.highlight_search_terms(search_matches);
return true;
});
}
/**
* Removes search term highlighting from all messages.
*/
public void unmark_search_terms() {
message_view_iterator().foreach((msg_view) => {
msg_view.unmark_search_terms();
return true;
});
}
/**
* Increases the magnification level used for displaying messages.
*/
public void zoom_in() {
message_view_iterator().foreach((msg_view) => {
msg_view.web_view.zoom_in();
return true;
});
}
/**
* Decreases the magnification level used for displaying messages.
*/
public void zoom_out() {
message_view_iterator().foreach((msg_view) => {
msg_view.web_view.zoom_out();
return true;
});
}
/**
* Resets magnification level used for displaying messages to the default.
*/
public void zoom_reset() {
message_view_iterator().foreach((msg_view) => {
msg_view.web_view.zoom_level = 1.0f;
return true;
});
}
private async void add_email(Geary.Email email, bool is_in_folder) {
if (this.id_to_row.contains(email.id)) {
return;
}
// Should be able to edit draft emails from any
// conversation. This test should be more like "is in drafts
// folder"
bool is_draft = (this.is_draft_folder && is_in_folder);
ConversationEmail view = new ConversationEmail(
email,
this.contact_store,
is_draft
);
view.mark_email.connect(on_mark_email);
view.mark_email_from_here.connect(on_mark_email_from_here);
view.body_selection_changed.connect((email, has_selection) => {
this.body_selected_view = has_selection ? email : null;
});
ConversationMessage conversation_message = view.primary_message;
conversation_message.body_box.button_release_event.connect_after((event) => {
// Consume all non-consumed clicks so the row is not
// inadvertently activated after clicking on the
// email body.
return true;
});
// Capture key events on the email's web views to allow
// scrolling on Space, etc. need to do this after loading so
// attached messages are present
view.message_view_iterator().foreach((msg_view) => {
msg_view.web_view.key_press_event.connect(on_key_press);
return true;
});
EmailRow row = new EmailRow(view);
row.show();
this.id_to_row.set(email.id, row);
add(row);
update_last_row();
email_added(view);
if (email.is_unread() == Geary.Trillian.TRUE ||
email.is_flagged() == Geary.Trillian.TRUE) {
row.expand(false);
}
yield view.start_loading(this.cancellable);
}
private void remove_email(Geary.Email email) {
EmailRow? row = null;
if (this.id_to_row.unset(email.id, out row)) {
remove(row);
email_removed(row.view);
}
}
private void scroll_to(EmailRow row) {
Gtk.Allocation? alloc = null;
row.get_allocation(out alloc);
int y = 0;
if (alloc.y > EMAIL_TOP_OFFSET) {
y = alloc.y - EMAIL_TOP_OFFSET;
}
// XXX This doesn't always quite work right, maybe since it's
// hard getting a reliable height out of WebKitGTK, or maybe
// because we stop calling this method when the email message
// body has finished loading, but attachments and sub-messages
// may still be loading. Or both?
get_adjustment().set_value(y);
}
// Due to Bug 764710, we can only use the CSS :last-child selector
// for GTK themes after 3.20.3, so for now manually maintain a
// class on the last box so we can emulate it
private void update_last_row() {
EmailRow? last = null;
this.foreach((child) => {
if (child.get_visible()) {
last = (EmailRow) child;
}
});
if (this.last_email_row != last) {
if (this.last_email_row != null) {
this.last_email_row.is_last = false;
}
this.last_email_row = last;
this.last_email_row.is_last = true;
}
}
// Given some emails, fetch the full versions with all required fields.
private async Gee.Collection<Geary.Email>? list_full_emails_async(
Gee.Collection<Geary.Email> emails, Cancellable? cancellable) throws Error {
Geary.Email.Field required_fields = ConversationListBox.REQUIRED_FIELDS |
Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
Gee.ArrayList<Geary.EmailIdentifier> ids = new Gee.ArrayList<Geary.EmailIdentifier>();
foreach (Geary.Email email in emails)
ids.add(email.id);
return yield this.email_store.list_email_by_sparse_id_async(ids, required_fields,
Geary.Folder.ListFlags.NONE, cancellable);
}
// Given an email, fetch the full version with all required fields.
private async Geary.Email fetch_full_email_async(Geary.Email email,
Cancellable? cancellable) throws Error {
Geary.Email.Field required_fields = ConversationListBox.REQUIRED_FIELDS |
Geary.ComposedEmail.REQUIRED_REPLY_FIELDS;
return yield this.email_store.fetch_email_async(email.id, required_fields,
Geary.Folder.ListFlags.NONE, cancellable);
}
/**
* Returns an new Iterable over all email views in the viewer
*/
private Gee.Iterator<ConversationEmail> email_view_iterator() {
return this.id_to_row.values.map<ConversationEmail>((row) => {
return (ConversationEmail) row.get_child();
});
}
/**
* Returns a new Iterable over all message views in the viewer
*/
private Gee.Iterator<ConversationMessage> message_view_iterator() {
return Gee.Iterator.concat<ConversationMessage>(
email_view_iterator().map<Gee.Iterator<ConversationMessage>>(
(email_view) => { return email_view.message_view_iterator(); }
)
);
}
private void on_conversation_appended(Geary.App.Conversation conversation, Geary.Email email) {
on_conversation_appended_async.begin(conversation, email, on_conversation_appended_complete);
}
private async void on_conversation_appended_async(Geary.App.Conversation conversation,
Geary.Email email) throws Error {
yield add_email(yield fetch_full_email_async(email, this.cancellable),
conversation.is_in_current_folder(email.id));
}
private void on_conversation_appended_complete(Object? source, AsyncResult result) {
try {
on_conversation_appended_async.end(result);
} catch (Error err) {
debug("Unable to append email to conversation: %s", err.message);
}
}
private void on_conversation_trimmed(Geary.Email email) {
remove_email(email);
}
private void on_update_flags(Geary.Email email) {
if (!this.id_to_row.has_key(email.id)) {
return;
}
EmailRow row = this.id_to_row.get(email.id);
row.view.update_flags(email);
}
private void on_mark_email(ConversationEmail view,
Geary.NamedFlag? to_add,
Geary.NamedFlag? to_remove) {
Gee.Collection<Geary.EmailIdentifier> ids =
new Gee.LinkedList<Geary.EmailIdentifier>();
ids.add(view.email.id);
mark_emails(ids, flag_to_flags(to_add), flag_to_flags(to_remove));
}
private void on_mark_email_from_here(ConversationEmail view,
Geary.NamedFlag? to_add,
Geary.NamedFlag? to_remove) {
Geary.Email email = view.email;
Gee.Collection<Geary.EmailIdentifier> ids =
new Gee.LinkedList<Geary.EmailIdentifier>();
ids.add(email.id);
this.foreach((row) => {
if (row.get_visible()) {
Geary.Email other = ((EmailRow) row).view.email;
if (Geary.Email.compare_sent_date_ascending(
email, other) < 0) {
ids.add(other.id);
}
}
});
mark_emails(ids, flag_to_flags(to_add), flag_to_flags(to_remove));
}
private Geary.EmailFlags? flag_to_flags(Geary.NamedFlag? flag) {
Geary.EmailFlags flags = null;
if (flag != null) {
flags = new Geary.EmailFlags();
flags.add(flag);
}
return flags;
}
private bool on_key_press(Gtk.Widget widget, Gdk.EventKey event) {
// Override some key bindings to get something that works more
// like a browser page.
if (event.keyval == Gdk.Key.space) {
Gtk.ScrollType dir = Gtk.ScrollType.PAGE_DOWN;
if ((event.state & Gdk.ModifierType.SHIFT_MASK) ==
Gdk.ModifierType.SHIFT_MASK) {
dir = Gtk.ScrollType.PAGE_UP;
}
this.move_cursor(Gtk.MovementStep.PAGES, 1);
return true;
}
return false;
}
private void on_row_activated(Gtk.ListBoxRow widget) {
EmailRow row = (EmailRow) widget;
if (!row.is_last) {
if (row.is_expanded) {
row.collapse();
} else {
row.expand();
}
}
}
private int on_sort(Gtk.ListBoxRow row1, Gtk.ListBoxRow row2) {
ConversationEmail? msg1 = row1.get_child() as ConversationEmail;
ConversationEmail? msg2 = row2.get_child() as ConversationEmail;
return Geary.Email.compare_sent_date_ascending(msg1.email, msg2.email);
}
}

File diff suppressed because it is too large Load diff

View file

@ -15,6 +15,7 @@ set(RESOURCE_LIST
STRIPBLANKS "conversation-viewer.ui"
"conversation-web-view.css"
STRIPBLANKS "edit_alternate_emails.glade"
STRIPBLANKS "empty-placeholder.ui"
STRIPBLANKS "find_bar.glade"
STRIPBLANKS "folder-popover.ui"
STRIPBLANKS "gtk/help-overlay.ui"

View file

@ -7,18 +7,6 @@
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="transition_type">crossfade</property>
<child>
<object class="GtkImage" id="splash_page">
<property name="name">splash_page</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">256</property>
<property name="icon_name">mail-inbox-symbolic</property>
</object>
<packing>
<property name="name">splash_page</property>
</packing>
</child>
<child>
<object class="GtkSpinner" id="loading_page">
<property name="visible">True</property>
@ -27,6 +15,19 @@
</object>
<packing>
<property name="name">loading_page</property>
</packing>
</child>
<child>
<object class="GtkBox" id="no_conversations_page">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">no_conversations_page</property>
<property name="position">1</property>
</packing>
</child>
@ -34,26 +35,10 @@
<object class="GtkScrolledWindow" id="conversation_page">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="events">GDK_KEY_PRESS_MASK | GDK_STRUCTURE_MASK</property>
<property name="hscrollbar_policy">never</property>
<property name="shadow_type">in</property>
<signal name="key-press-event" handler="on_conversation_key_press" swapped="no"/>
<child>
<object class="GtkViewport">
<property name="visible">True</property>
<property name="can_focus">False</property>
<child>
<object class="GtkListBox" id="conversation_listbox">
<property name="name">conversation_listbox</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="selection_mode">none</property>
<style>
<class name="background"/>
</style>
</object>
</child>
</object>
<placeholder/>
</child>
</object>
<packing>
@ -62,31 +47,47 @@
</packing>
</child>
<child>
<object class="GtkBox" id="user_message_page">
<object class="GtkBox" id="multiple_conversations_page">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<object class="GtkLabel" id="user_message_label">
<property name="name">user_message</property>
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="xpad">18</property>
<property name="ypad">18</property>
<property name="label">🎔</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
<placeholder/>
</child>
</object>
<packing>
<property name="name">user_message_page</property>
<property name="name">multiple_conversations_page</property>
<property name="position">3</property>
</packing>
</child>
<child>
<object class="GtkBox" id="empty_folder_page">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">empty_folder_page</property>
<property name="position">4</property>
</packing>
</child>
<child>
<object class="GtkBox" id="empty_search_page">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<child>
<placeholder/>
</child>
</object>
<packing>
<property name="name">empty_search_page</property>
<property name="position">5</property>
</packing>
</child>
<child>
<object class="GtkBox" id="composer_page">
<property name="visible">True</property>
@ -98,7 +99,7 @@
</object>
<packing>
<property name="name">composer_page</property>
<property name="position">4</property>
<property name="position">6</property>
</packing>
</child>
</template>

56
ui/empty-placeholder.ui Normal file
View file

@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated with glade 3.20.0 -->
<interface>
<requires lib="gtk+" version="3.20"/>
<template class="EmptyPlaceholder" parent="GtkGrid">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="halign">center</property>
<property name="valign">center</property>
<child>
<object class="GtkImage" id="placeholder_image">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="pixel_size">72</property>
<property name="icon_name">folder-symbolic</property>
<property name="icon_size">6</property>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="title_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label">Mea navis volitans</property>
<style>
<class name="title"/>
</style>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">1</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="subtitle_label">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="label">Mea navis volitans anguillis plena est.</property>
<style>
<class name="subtitle"/>
</style>
</object>
<packing>
<property name="left_attach">0</property>
<property name="top_attach">2</property>
</packing>
</child>
<style>
<class name="dim-label"/>
<class name="geary-empty-placeholder"/>
</style>
</template>
</interface>

View file

@ -52,10 +52,10 @@ row.geary-folder-popover-list-row > label {
color: @theme_text_color;
}
#conversation_listbox {
.conversation-listbox {
padding: 18px;
}
#conversation_listbox > row {
.conversation-listbox > row {
margin: 0;
border: 1px solid @borders;
border-bottom-width: 0;
@ -63,18 +63,18 @@ row.geary-folder-popover-list-row > label {
box-shadow: 0 4px 8px 1px rgba(0,0,0,0.4);
transition: margin 0.1s;
}
#conversation_listbox > row > box {
.conversation-listbox > row > box {
background: @theme_base_color;
transition: background 0.25s;
}
#conversation_listbox > row:hover > box {
.conversation-listbox > row:hover > box {
background: shade(@theme_base_color, 0.96);
}
#conversation_listbox > row.geary-expanded {
.conversation-listbox > row.geary-expanded {
margin-bottom: 6px;
border-bottom-width: 1px;
}
#conversation_listbox > row.geary-last {
.conversation-listbox > row.geary-last {
margin-bottom: 0;
}
@ -97,9 +97,9 @@ row.geary-folder-popover-list-row > label {
border-radius: 0px;
}
#user_message {
border: 1px solid @borders;
border-left: 0;
border-right: 0;
background: @theme_base_color;
.geary-empty-placeholder > image {
margin-bottom: 12px;
}
.geary-empty-placeholder > .title {
font-weight: bold;
}