~hkdb/geary/disco-3.34.0

« back to all changes in this revision

Viewing changes to src/client/composer/contact-entry-completion.vala

  • Committer: hkdb
  • Date: 2019-09-27 11:28:53 UTC
  • Revision ID: hkdb@3df.io-20190927112853-x02njxcww51q9jfp
Tags: upstream-3.34.0-disco
ImportĀ upstreamĀ versionĀ 3.34.0-disco

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
/*
 
2
 * Copyright 2016 Software Freedom Conservancy Inc.
 
3
 * Copyright 2019 Michael Gratton <mike@vee.net>
 
4
 *
 
5
 * This software is licensed under the GNU Lesser General Public License
 
6
 * (version 2.1 or later). See the COPYING file in this distribution.
 
7
 */
 
8
 
 
9
public class ContactEntryCompletion : Gtk.EntryCompletion, Geary.BaseInterface {
 
10
 
 
11
 
 
12
    // Minimum visibility for the contact to appear in autocompletion.
 
13
    private const Geary.Contact.Importance VISIBILITY_THRESHOLD =
 
14
        Geary.Contact.Importance.RECEIVED_FROM;
 
15
 
 
16
 
 
17
    public enum Column {
 
18
        CONTACT,
 
19
        MAILBOX;
 
20
 
 
21
        public static Type[] get_types() {
 
22
            return {
 
23
                typeof(Application.Contact), // CONTACT
 
24
                typeof(Geary.RFC822.MailboxAddress) // MAILBOX
 
25
            };
 
26
        }
 
27
    }
 
28
 
 
29
 
 
30
    private Application.ContactStore contacts;
 
31
 
 
32
    // Text between the start of the entry or of the previous email
 
33
    // address and the current position of the cursor, if any.
 
34
    private string current_key = "";
 
35
 
 
36
    // List of (possibly incomplete) email addresses in the entry.
 
37
    private string[] email_addresses = {};
 
38
 
 
39
    // Index of the email address the cursor is currently at
 
40
    private int cursor_at_address = -1;
 
41
 
 
42
    private GLib.Cancellable? search_cancellable = null;
 
43
    private Gtk.TreeIter? last_iter = null;
 
44
 
 
45
 
 
46
    public ContactEntryCompletion(Application.ContactStore contacts) {
 
47
        base_ref();
 
48
        this.contacts = contacts;
 
49
        this.model = new_model();
 
50
 
 
51
        // Always match all rows, since the model will only contain
 
52
        // matching addresses from the search query
 
53
        set_match_func(() => true);
 
54
 
 
55
        Gtk.CellRendererPixbuf icon_renderer = new Gtk.CellRendererPixbuf();
 
56
        icon_renderer.xpad = 2;
 
57
        icon_renderer.ypad = 2;
 
58
        pack_start(icon_renderer, false);
 
59
        set_cell_data_func(icon_renderer, cell_icon_data);
 
60
 
 
61
        Gtk.CellRendererText text_renderer = new Gtk.CellRendererText();
 
62
        icon_renderer.ypad = 2;
 
63
        pack_start(text_renderer, true);
 
64
        set_cell_data_func(text_renderer, cell_text_data);
 
65
 
 
66
        this.match_selected.connect(on_match_selected);
 
67
        this.cursor_on_match.connect(on_cursor_on_match);
 
68
    }
 
69
 
 
70
    ~ContactEntryCompletion() {
 
71
        base_unref();
 
72
    }
 
73
 
 
74
    public void update_model() {
 
75
        this.last_iter = null;
 
76
 
 
77
        update_addresses();
 
78
 
 
79
        if (this.search_cancellable != null) {
 
80
            this.search_cancellable.cancel();
 
81
            this.search_cancellable = null;
 
82
        }
 
83
 
 
84
        Gtk.ListStore model = (Gtk.ListStore) this.model;
 
85
        string completion_key = this.current_key;
 
86
        if (!Geary.String.is_empty_or_whitespace(completion_key)) {
 
87
            // Append a placeholder row here if the model is empty to
 
88
            // work around the issue descried in
 
89
            // https://gitlab.gnome.org/GNOME/gtk/merge_requests/939
 
90
            Gtk.TreeIter iter;
 
91
            if (!model.get_iter_first(out iter)) {
 
92
                model.append(out iter);
 
93
            }
 
94
 
 
95
            this.search_cancellable = new GLib.Cancellable();
 
96
            this.search_contacts.begin(completion_key, this.search_cancellable);
 
97
        } else {
 
98
            model.clear();
 
99
        }
 
100
    }
 
101
    public void trigger_selection() {
 
102
        if (last_iter != null) {
 
103
            on_match_selected(model, last_iter);
 
104
            last_iter = null;
 
105
        }
 
106
    }
 
107
 
 
108
    private void update_addresses() {
 
109
        Gtk.Entry? entry = get_entry() as Gtk.Entry;
 
110
        if (entry != null) {
 
111
            this.current_key = "";
 
112
            this.cursor_at_address = -1;
 
113
            this.email_addresses = {};
 
114
 
 
115
            string text = entry.get_text();
 
116
            int cursor_pos = entry.get_position();
 
117
 
 
118
            int start_idx = 0;
 
119
            int next_idx = 0;
 
120
            unichar c = 0;
 
121
            int current_char = 0;
 
122
            bool in_quote = false;
 
123
            while (text.get_next_char(ref next_idx, out c)) {
 
124
                if (current_char == cursor_pos) {
 
125
                    this.current_key = text.slice(start_idx, next_idx).strip();
 
126
                    this.cursor_at_address = this.email_addresses.length;
 
127
                }
 
128
 
 
129
                switch (c) {
 
130
                case ',':
 
131
                    if (!in_quote) {
 
132
                        // Don't include the comma in the address
 
133
                        string address = text.slice(start_idx, next_idx -1);
 
134
                        this.email_addresses += address.strip();
 
135
                        // Don't include it in the next one, either
 
136
                        start_idx = next_idx;
 
137
                    }
 
138
                    break;
 
139
 
 
140
                case '"':
 
141
                    in_quote = !in_quote;
 
142
                    break;
 
143
                }
 
144
 
 
145
                current_char++;
 
146
            }
 
147
 
 
148
            // Add any remaining text after the last comma
 
149
            string address = text.substring(start_idx);
 
150
            this.email_addresses += address.strip();
 
151
        }
 
152
    }
 
153
 
 
154
    public async void search_contacts(string query,
 
155
                                      GLib.Cancellable? cancellable) {
 
156
        Gee.Collection<Application.Contact>? results = null;
 
157
        try {
 
158
            results = yield this.contacts.search(
 
159
                query,
 
160
                VISIBILITY_THRESHOLD,
 
161
                20,
 
162
                cancellable
 
163
            );
 
164
        } catch (GLib.IOError.CANCELLED err) {
 
165
            // All good
 
166
        } catch (GLib.Error err) {
 
167
            debug("Error searching contacts for completion: %s", err.message);
 
168
        }
 
169
 
 
170
        if (!cancellable.is_cancelled()) {
 
171
            Gtk.ListStore model = new_model();
 
172
            foreach (Application.Contact contact in results) {
 
173
                foreach (Geary.RFC822.MailboxAddress addr
 
174
                         in contact.email_addresses) {
 
175
                    Gtk.TreeIter iter;
 
176
                    model.append(out iter);
 
177
                    model.set(iter, Column.CONTACT, contact);
 
178
                    model.set(iter, Column.MAILBOX, addr);
 
179
                }
 
180
            }
 
181
            this.model = model;
 
182
            complete();
 
183
        }
 
184
    }
 
185
 
 
186
    private string match_prefix_contact(Geary.RFC822.MailboxAddress mailbox) {
 
187
        string email = match_prefix_string(mailbox.address);
 
188
        if (mailbox.name != null && !mailbox.is_spoofed()) {
 
189
            string real_name = match_prefix_string(mailbox.name);
 
190
            // email and real_name were already escaped, then <b></b> tags
 
191
            // were added to highlight matches. We don't want to escape
 
192
            // them again.
 
193
            email = (
 
194
                real_name +
 
195
                Markup.escape_text(" <") + email + Markup.escape_text(">")
 
196
            );
 
197
        }
 
198
        return email;
 
199
    }
 
200
 
 
201
    private string? match_prefix_string(string haystack) {
 
202
        string value = haystack;
 
203
        if (!Geary.String.is_empty(this.current_key)) {
 
204
            bool matched = false;
 
205
            try {
 
206
                string escaped_needle = Regex.escape_string(
 
207
                    this.current_key.normalize()
 
208
                );
 
209
                Regex regex = new Regex(
 
210
                    "\\b" + escaped_needle,
 
211
                    RegexCompileFlags.CASELESS
 
212
                );
 
213
                string haystack_normalized = haystack.normalize();
 
214
                if (regex.match(haystack_normalized)) {
 
215
                    value = regex.replace_eval(
 
216
                        haystack_normalized, -1, 0, 0, eval_callback
 
217
                    );
 
218
                    matched = true;
 
219
                }
 
220
            } catch (RegexError err) {
 
221
                debug("Error matching regex: %s", err.message);
 
222
            }
 
223
 
 
224
            value = Markup.escape_text(value)
 
225
                .replace("&#x91;", "<b>")
 
226
                .replace("&#x92;", "</b>");
 
227
        }
 
228
 
 
229
        return value;
 
230
    }
 
231
 
 
232
    private bool eval_callback(GLib.MatchInfo match_info,
 
233
                               GLib.StringBuilder result) {
 
234
        string? match = match_info.fetch(0);
 
235
        if (match != null) {
 
236
            result.append("\xc2\x91%s\xc2\x92".printf(match));
 
237
            // This is UTF-8 encoding of U+0091 and U+0092
 
238
        }
 
239
        return false;
 
240
    }
 
241
 
 
242
    private void cell_icon_data(Gtk.CellLayout cell_layout,
 
243
                                Gtk.CellRenderer cell,
 
244
                                Gtk.TreeModel tree_model,
 
245
                                Gtk.TreeIter iter) {
 
246
        GLib.Value value;
 
247
        tree_model.get_value(iter, Column.CONTACT, out value);
 
248
        Application.Contact? contact = value.get_object() as Application.Contact;
 
249
 
 
250
        string icon = "";
 
251
        if (contact != null) {
 
252
            if (contact.is_favourite) {
 
253
                icon = "starred-symbolic";
 
254
            } else if (contact.is_desktop_contact) {
 
255
                icon = "avatar-default-symbolic";
 
256
            }
 
257
        }
 
258
 
 
259
        Gtk.CellRendererPixbuf renderer = (Gtk.CellRendererPixbuf) cell;
 
260
        renderer.icon_name = icon;
 
261
    }
 
262
 
 
263
    private void cell_text_data(Gtk.CellLayout cell_layout,
 
264
                                Gtk.CellRenderer cell,
 
265
                                Gtk.TreeModel tree_model,
 
266
                                Gtk.TreeIter iter) {
 
267
        GLib.Value value;
 
268
        tree_model.get_value(iter, Column.MAILBOX, out value);
 
269
        Geary.RFC822.MailboxAddress? mailbox =
 
270
            value.get_object() as Geary.RFC822.MailboxAddress;
 
271
 
 
272
        string markup = "";
 
273
        if (mailbox != null) {
 
274
            markup = this.match_prefix_contact(mailbox);
 
275
        }
 
276
 
 
277
        Gtk.CellRendererText renderer = (Gtk.CellRendererText) cell;
 
278
        renderer.markup = markup;
 
279
    }
 
280
 
 
281
    private inline Gtk.ListStore new_model() {
 
282
        return new Gtk.ListStore.newv(Column.get_types());
 
283
    }
 
284
 
 
285
    private bool on_match_selected(Gtk.TreeModel model, Gtk.TreeIter iter) {
 
286
        Gtk.Entry? entry = get_entry() as Gtk.Entry;
 
287
        if (entry != null) {
 
288
            // Update the address
 
289
            GLib.Value value;
 
290
            model.get_value(iter, Column.MAILBOX, out value);
 
291
            Geary.RFC822.MailboxAddress mailbox =
 
292
                (Geary.RFC822.MailboxAddress) value.get_object();
 
293
            this.email_addresses[this.cursor_at_address] =
 
294
                mailbox.to_full_display();
 
295
 
 
296
            // Update the entry text
 
297
            bool current_is_last = (
 
298
                this.cursor_at_address == this.email_addresses.length - 1
 
299
            );
 
300
            int new_cursor_pos = -1;
 
301
            GLib.StringBuilder text = new GLib.StringBuilder();
 
302
            int i = 0;
 
303
            while (i < this.email_addresses.length) {
 
304
                text.append(this.email_addresses[i]);
 
305
                if (i == this.cursor_at_address) {
 
306
                    new_cursor_pos = text.str.char_count();
 
307
                }
 
308
 
 
309
                i++;
 
310
                if (i != this.email_addresses.length || current_is_last) {
 
311
                    text.append(", ");
 
312
                }
 
313
            }
 
314
            entry.text = text.str;
 
315
            entry.set_position(current_is_last ? -1 : new_cursor_pos);
 
316
        }
 
317
        return true;
 
318
    }
 
319
 
 
320
    private bool on_cursor_on_match(Gtk.TreeModel model, Gtk.TreeIter iter) {
 
321
        this.last_iter = iter;
 
322
        return true;
 
323
    }
 
324
 
 
325
}