conversation-list-store.vala 17.7 KB
Newer Older
1
/* Copyright 2016 Software Freedom Conservancy Inc.
2 3
 *
 * This software is licensed under the GNU Lesser General Public License
4
 * (version 2.1 or later).  See the COPYING file in this distribution.
5
 */
6 7 8 9 10 11 12 13 14 15

/**
 * A Gtk.ListStore of sorted {@link Geary.App.Conversation}s.
 *
 * Conversations are sorted by {@link Geary.EmailProperties.date_received} (IMAP's INTERNALDATE)
 * rather than the Date: header, as that ensures newly received email sort to the top where the
 * user expects to see them.  The ConversationViewer sorts by the Date: header, as that presents
 * better to the user.
 */

16
public class ConversationListStore : Gtk.ListStore {
17

18 19 20 21 22 23 24 25 26 27 28 29
    public const Geary.Email.Field REQUIRED_FIELDS = (
        Geary.Email.Field.ENVELOPE |
        Geary.Email.Field.FLAGS |
        Geary.Email.Field.PROPERTIES
    );

    // XXX Remove REQUIRED_FOR_BODY when PREVIEW has been fixed. See Bug 714317.
    public const Geary.Email.Field WITH_PREVIEW_FIELDS = (
        REQUIRED_FIELDS |
        Geary.Email.Field.PREVIEW |
        Geary.Email.REQUIRED_FOR_MESSAGE
    );
30

31
    public enum Column {
32
        CONVERSATION_DATA,
33 34
        CONVERSATION_OBJECT,
        ROW_WRAPPER;
35

36 37
        public static Type[] get_types() {
            return {
38
                typeof (FormattedConversationData), // CONVERSATION_DATA
39 40
                typeof (Geary.App.Conversation),    // CONVERSATION_OBJECT
                typeof (RowWrapper)                 // ROW_WRAPPER
41 42
            };
        }
43

44 45
        public string to_string() {
            switch (this) {
46
                case CONVERSATION_DATA:
47
                    return "data";
48

49
                case CONVERSATION_OBJECT:
50
                    return "envelope";
51

52 53
                case ROW_WRAPPER:
                    return "wrapper";
54

55 56 57 58
                default:
                    assert_not_reached();
            }
        }
59
    }
60

61 62 63
    private class RowWrapper : Geary.BaseObject {
        public Geary.App.Conversation conversation;
        public Gtk.TreeRowReference row;
64

65 66 67 68
        public RowWrapper(Gtk.TreeModel model, Geary.App.Conversation conversation, Gtk.TreePath path) {
            this.conversation = conversation;
            this.row = new Gtk.TreeRowReference(model, path);
        }
69

70 71 72
        public Gtk.TreePath get_path() {
            return row.get_path();
        }
73

Jim Nelson's avatar
Jim Nelson committed
74 75
        public bool get_iter(out Gtk.TreeIter iter) {
            return row.get_model().get_iter(out iter, get_path());
76 77
        }
    }
78

79 80 81 82 83 84 85

    private static int sort_by_date(Gtk.TreeModel model,
                                    Gtk.TreeIter aiter,
                                    Gtk.TreeIter biter) {
        Geary.App.Conversation a, b;
        model.get(aiter, Column.CONVERSATION_OBJECT, out a);
        model.get(biter, Column.CONVERSATION_OBJECT, out b);
86
        return Util.Email.compare_conversation_ascending(a, b);
87 88 89
    }


90 91
    public Geary.App.ConversationMonitor conversations { get; set; }
    public Geary.ProgressMonitor preview_monitor { get; private set; default =
92
        new Geary.SimpleProgressMonitor(Geary.ProgressType.ACTIVITY); }
93

94 95
    private Gee.HashMap<Geary.App.Conversation, RowWrapper> row_map = new Gee.HashMap<
        Geary.App.Conversation, RowWrapper>();
96
    private Geary.App.EmailStore? email_store = null;
97
    private Cancellable cancellable = new Cancellable();
98
    private bool loading_local_only = true;
99
    private Geary.Nonblocking.Mutex refresh_mutex = new Geary.Nonblocking.Mutex();
100
    private uint update_id = 0;
101 102

    public signal void conversations_added(bool start);
103
    public signal void conversations_removed(bool start);
104 105

    public ConversationListStore(Geary.App.ConversationMonitor conversations) {
106
        set_column_types(Column.get_types());
107
        set_default_sort_func(ConversationListStore.sort_by_date);
108
        set_sort_column_id(Gtk.SortColumn.DEFAULT, Gtk.SortType.DESCENDING);
109 110 111 112 113 114

        this.conversations = conversations;
        this.update_id = Timeout.add_seconds_full(
            Priority.LOW, 60, update_date_strings
        );
        this.email_store = new Geary.App.EmailStore(
115
            conversations.base_folder.account
116
        );
117 118
        GearyApplication.instance.config.settings.changed[Configuration.DISPLAY_PREVIEW_KEY].connect(
            on_display_preview_changed);
119 120 121

        conversations.scan_completed.connect(on_scan_completed);
        conversations.conversations_added.connect(on_conversations_added);
122
        conversations.conversations_removed.connect(on_conversations_removed);
123 124 125 126 127
        conversations.conversation_appended.connect(on_conversation_appended);
        conversations.conversation_trimmed.connect(on_conversation_trimmed);
        conversations.email_flags_changed.connect(on_email_flags_changed);

        // add all existing conversations
128
        on_conversations_added(conversations.read_only_view);
129
    }
130 131 132

    public void destroy() {
        this.cancellable.cancel();
133
        this.email_store = null;
134
        clear();
135 136 137 138 139 140

        // Release circular refs.
        this.row_map.clear();
        if (this.update_id != 0) {
            Source.remove(this.update_id);
            this.update_id = 0;
141
        }
142
    }
143

144
    public Geary.App.Conversation? get_conversation_at_path(Gtk.TreePath path) {
145 146 147
        Gtk.TreeIter iter;
        if (!get_iter(out iter, path))
            return null;
148

149 150
        return get_conversation_at_iter(iter);
    }
151

152
    private async void refresh_previews_async(Geary.App.ConversationMonitor conversation_monitor) {
153 154 155 156 157 158
        // Use a mutex because it's possible for the conversation monitor to fire multiple
        // "scan-started" signals as messages come in fast and furious, but only want to process
        // previews one at a time, otherwise it's possible to issue multiple requests for the
        // same set
        int token;
        try {
159
            token = yield refresh_mutex.claim_async(this.cancellable);
160 161
        } catch (Error err) {
            debug("Unable to claim refresh mutex: %s", err.message);
162

163 164
            return;
        }
165

166
        preview_monitor.notify_start();
167

168
        yield do_refresh_previews_async(conversation_monitor);
169

170
        preview_monitor.notify_finish();
171

172 173 174 175 176 177
        try {
            refresh_mutex.release(ref token);
        } catch (Error err) {
            debug("Unable to release refresh mutex: %s", err.message);
        }
    }
178

179
    // should only be called by refresh_previews_async()
180
    private async void do_refresh_previews_async(Geary.App.ConversationMonitor conversation_monitor) {
181
        if (conversation_monitor == null || !GearyApplication.instance.config.display_preview)
182
            return;
183

184
        Gee.Set<Geary.EmailIdentifier> needing_previews = get_emails_needing_previews();
185

186
        Gee.ArrayList<Geary.Email> emails = new Gee.ArrayList<Geary.Email>();
187 188
        if (needing_previews.size > 0)
            emails.add_all(yield do_get_previews_async(needing_previews));
189
        if (emails.size < 1)
190
            return;
191

192
        foreach (Geary.Email email in emails) {
193
            Geary.App.Conversation? conversation = conversation_monitor.get_by_email_identifier(email.id);
194 195 196 197
            // The conversation can be null if e.g. a search is
            // changing quickly and the original has evaporated
            // already.
            if (conversation != null) {
198
                set_preview_for_conversation(conversation, email);
199
            }
200 201
        }
    }
202

203
    private async Gee.Collection<Geary.Email> do_get_previews_async(
204
        Gee.Collection<Geary.EmailIdentifier> emails_needing_previews) {
205 206
        Geary.Folder.ListFlags flags = (loading_local_only) ? Geary.Folder.ListFlags.LOCAL_ONLY
            : Geary.Folder.ListFlags.NONE;
207
        Gee.Collection<Geary.Email>? emails = null;
208
        try {
209
            emails = yield email_store.list_email_by_sparse_id_async(emails_needing_previews,
210
                ConversationListStore.WITH_PREVIEW_FIELDS, flags, cancellable);
211 212 213 214 215 216 217
        } catch (GLib.IOError.CANCELLED err) {
            // All good
        } catch (Geary.EngineError.NOT_FOUND err) {
            // All good also, as that's entirely possible when waiting
            // for the remote to open
        } catch (GLib.Error err) {
            warning("Unable to fetch preview: %s", err.message);
218
        }
219

220 221
        return emails ?? new Gee.ArrayList<Geary.Email>();
    }
222

223 224
    private Gee.Set<Geary.EmailIdentifier> get_emails_needing_previews() {
        Gee.Set<Geary.EmailIdentifier> needing = new Gee.HashSet<Geary.EmailIdentifier>();
225

226 227
        // sort the conversations so the previews are fetched from the newest to the oldest, matching
        // the user experience
228 229 230 231
        Gee.TreeSet<Geary.App.Conversation> sorted_conversations =
            new Gee.TreeSet<Geary.App.Conversation>(
                Util.Email.compare_conversation_descending
            );
232
        sorted_conversations.add_all(this.conversations.read_only_view);
233
        foreach (Geary.App.Conversation conversation in sorted_conversations) {
234 235
            // find oldest unread message for the preview
            Geary.Email? need_preview = null;
236
            foreach (Geary.Email email in conversation.get_emails(Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING)) {
237 238
                if (email.email_flags.is_unread()) {
                    need_preview = email;
239

240 241 242
                    break;
                }
            }
243

244 245 246
            // if all are read, use newest in-folder message, then newest out-of-folder if not
            // present
            if (need_preview == null) {
247
                need_preview = conversation.get_latest_recv_email(Geary.App.Conversation.Location.IN_FOLDER_OUT_OF_FOLDER);
248 249 250
                if (need_preview == null)
                    continue;
            }
251

252
            Geary.Email? current_preview = get_preview_for_conversation(conversation);
253

254
            // if all preview fields present and it's the same email, don't need to refresh
255
            if (current_preview != null
Eric Gregory's avatar
Eric Gregory committed
256
                && need_preview.id.equal_to(current_preview.id)
257
                && current_preview.fields.is_all_set(ConversationListStore.WITH_PREVIEW_FIELDS)) {
258 259
                continue;
            }
260

261
            needing.add(need_preview.id);
262
        }
263

264
        return needing;
265
    }
266

267
    private Geary.Email? get_preview_for_conversation(Geary.App.Conversation conversation) {
268 269 270
        Gtk.TreeIter iter;
        if (!get_iter_for_conversation(conversation, out iter)) {
            debug("Unable to find preview for conversation");
271

272
            return null;
273
        }
274

275
        FormattedConversationData? message_data = get_message_data_at_iter(iter);
276
        return message_data == null ? null : message_data.preview;
277
    }
278

279
    private void set_preview_for_conversation(Geary.App.Conversation conversation, Geary.Email preview) {
280 281 282 283 284
        Gtk.TreeIter iter;
        if (get_iter_for_conversation(conversation, out iter))
            set_row(iter, conversation, preview);
        else
            debug("Unable to find preview for conversation");
285
    }
286

287
    private void set_row(Gtk.TreeIter iter, Geary.App.Conversation conversation, Geary.Email preview) {
288 289 290
        FormattedConversationData conversation_data = new FormattedConversationData(
            conversation,
            preview,
291
            this.conversations.base_folder,
292
            this.conversations.base_folder.account.information.sender_mailboxes
293 294
        );

295 296 297
        Gtk.TreePath? path = get_path(iter);
        assert(path != null);
        RowWrapper wrapper = new RowWrapper(this, conversation, path);
298

299
        set(iter,
300
            Column.CONVERSATION_DATA, conversation_data,
301 302 303
            Column.CONVERSATION_OBJECT, conversation,
            Column.ROW_WRAPPER, wrapper
        );
304

305
        row_map.set(conversation, wrapper);
306
    }
307

308
    private void refresh_conversation(Geary.App.Conversation conversation) {
309
        Gtk.TreeIter iter;
310 311 312 313 314
        if (!get_iter_for_conversation(conversation, out iter)) {
            // Unknown conversation, attempt to append it.
            add_conversation(conversation);
            return;
        }
315

316
        Geary.Email? last_email = conversation.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE);
317
        if (last_email == null) {
318
            debug("Cannot refresh conversation: last email is null");
319

320 321 322
#if VALA_0_36
            remove(ref iter);
#else
323
            remove(iter);
324
#endif
325 326
            return;
        }
327

328
        set_row(iter, conversation, last_email);
329

330 331 332 333 334
        Gtk.TreePath? path = get_path(iter);
        if (path != null)
            row_changed(path, iter);
        else
            debug("Cannot refresh conversation: no path for iterator");
335
    }
336

337
    private void refresh_flags(Geary.App.Conversation conversation) {
338
        Gtk.TreeIter iter;
339 340 341 342
        if (!get_iter_for_conversation(conversation, out iter)) {
            // Unknown conversation, attempt to append it.
            add_conversation(conversation);
            return;
343
        }
344

345
        FormattedConversationData? existing_message_data = get_message_data_at_iter(iter);
346 347
        if (existing_message_data == null)
            return;
348

349 350
        existing_message_data.is_unread = conversation.is_unread();
        existing_message_data.is_flagged = conversation.is_flagged();
351

352 353 354
        Gtk.TreePath? path = get_path(iter);
        if (path != null)
            row_changed(path, iter);
355
    }
356

357 358
    public Gtk.TreePath? get_path_for_conversation(Geary.App.Conversation conversation) {
        RowWrapper? wrapper = row_map.get(conversation);
359

360 361
        return (wrapper != null) ? wrapper.get_path() : null;
    }
362

363 364 365
    private bool get_iter_for_conversation(Geary.App.Conversation conversation, out Gtk.TreeIter iter) {
        RowWrapper? wrapper = row_map.get(conversation);
        if (wrapper != null)
Jim Nelson's avatar
Jim Nelson committed
366
            return wrapper.get_iter(out iter);
367

Jim Nelson's avatar
Jim Nelson committed
368 369 370
        // use get_iter_first() because boxing Gtk.TreeIter with a nullable is problematic with
        // current bindings
        get_iter_first(out iter);
371

Jim Nelson's avatar
Jim Nelson committed
372
        return false;
373
    }
374

375
    private bool has_conversation(Geary.App.Conversation conversation) {
376
        return row_map.has_key(conversation);
377
    }
378

379 380
    private Geary.App.Conversation? get_conversation_at_iter(Gtk.TreeIter iter) {
        Geary.App.Conversation? conversation;
381
        get(iter, Column.CONVERSATION_OBJECT, out conversation);
382

383
        return conversation;
384
    }
385

386 387 388
    private FormattedConversationData? get_message_data_at_iter(Gtk.TreeIter iter) {
        FormattedConversationData? message_data;
        get(iter, Column.CONVERSATION_DATA, out message_data);
389

390
        return message_data;
391
    }
392

393
    private void remove_conversation(Geary.App.Conversation conversation) {
394 395
        Gtk.TreeIter iter;
        if (get_iter_for_conversation(conversation, out iter))
396 397 398
#if VALA_0_36
            remove(ref iter);
#else
399
            remove(iter);
400
#endif
401

402
        row_map.unset(conversation);
403
    }
404

405
    private bool add_conversation(Geary.App.Conversation conversation) {
406
        Geary.Email? last_email = conversation.get_latest_recv_email(Geary.App.Conversation.Location.ANYWHERE);
407 408
        if (last_email == null) {
            debug("Cannot add conversation: last email is null");
409

410
            return false;
411
        }
412

413 414
        if (has_conversation(conversation)) {
            debug("Conversation already present; not adding");
415

416
            return false;
417
        }
418

419 420 421
        Gtk.TreeIter iter;
        append(out iter);
        set_row(iter, conversation, last_email);
422

423
        return true;
424
    }
425

426
    private void on_scan_completed(Geary.App.ConversationMonitor sender) {
427 428 429
        refresh_previews_async.begin(sender);
        loading_local_only = false;
    }
430

431
    private void on_conversations_added(Gee.Collection<Geary.App.Conversation> conversations) {
432 433 434 435
        // this handler is used to initialize the display, so it's possible for an empty list to
        // be passed in (the ConversationMonitor signal should never do this)
        if (conversations.size == 0)
            return;
436

437
        conversations_added(true);
438

439
        debug("Adding %d conversations.", conversations.size);
440
        int added = 0;
441
        foreach (Geary.App.Conversation conversation in conversations) {
442 443 444
            if (add_conversation(conversation))
                added++;
        }
445
        debug("Added %d/%d conversations.", added, conversations.size);
446

447
        conversations_added(false);
448
    }
449

450 451 452 453 454
    private void on_conversations_removed(Gee.Collection<Geary.App.Conversation> conversations) {
        conversations_removed(true);
        foreach (Geary.App.Conversation removed in conversations)
            remove_conversation(removed);
        conversations_removed(false);
455
    }
456

457
    private void on_conversation_appended(Geary.App.Conversation conversation) {
458
        if (has_conversation(conversation)) {
459
            refresh_conversation(conversation);
460 461 462
        } else {
            debug("Unable to append conversation; conversation not present in list store");
        }
463
    }
464

465
    private void on_conversation_trimmed(Geary.App.Conversation conversation) {
466 467
        refresh_conversation(conversation);
    }
468

469
    private void on_display_preview_changed() {
470
        refresh_previews_async.begin(this.conversations);
471
    }
472

473
    private void on_email_flags_changed(Geary.App.Conversation conversation) {
474
        refresh_flags(conversation);
475

476 477 478
        // refresh previews because the oldest unread message is displayed as the preview, and if
        // that's changed, need to change the preview
        // TODO: need support code to load preview for single conversation, not scan all
479
        refresh_previews_async.begin(this.conversations);
480
    }
481

482 483
    private bool update_date_strings() {
        this.foreach(update_date_string);
484
        return Source.CONTINUE;
485
    }
486

487 488 489
    private bool update_date_string(Gtk.TreeModel model, Gtk.TreePath path, Gtk.TreeIter iter) {
        FormattedConversationData? message_data;
        model.get(iter, Column.CONVERSATION_DATA, out message_data);
490

491 492
        if (message_data != null && message_data.update_date_string())
            row_changed(path, iter);
493

494 495 496
        // Continue iterating, don't stop
        return false;
    }
497

498 499
}