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// Stores formatted data for a message.
8public class FormattedConversationData : Geary.BaseObject {
9    struct Participants {
10        string? markup;
11
12        // markup may look different depending on whether widget is selected
13        bool was_widget_selected;
14    }
15
16    public const int SPACING = 6;
17
18    private const string ME = _("Me");
19    private const string STYLE_EXAMPLE = "Gg"; // Use both upper and lower case to get max height.
20    private const int TEXT_LEFT = SPACING * 2 + IconFactory.UNREAD_ICON_SIZE;
21    private const double DIM_TEXT_AMOUNT = 0.05;
22    private const double DIM_PREVIEW_TEXT_AMOUNT = 0.25;
23
24
25    private class ParticipantDisplay : Geary.BaseObject, Gee.Hashable<ParticipantDisplay> {
26        public Geary.RFC822.MailboxAddress address;
27        public bool is_unread;
28
29        public ParticipantDisplay(Geary.RFC822.MailboxAddress address, bool is_unread) {
30            this.address = address;
31            this.is_unread = is_unread;
32        }
33
34        public string get_full_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
35            return get_as_markup((address in account_mailboxes) ? ME : address.to_short_display());
36        }
37
38        public string get_short_markup(Gee.List<Geary.RFC822.MailboxAddress> account_mailboxes) {
39            if (address in account_mailboxes)
40                return get_as_markup(ME);
41
42            if (address.is_spoofed()) {
43                return get_full_markup(account_mailboxes);
44            }
45
46            string short_address = Markup.escape_text(address.to_short_display());
47
48            if (", " in short_address) {
49                // assume address is in Last, First format
50                string[] tokens = short_address.split(", ", 2);
51                short_address = tokens[1].strip();
52                if (Geary.String.is_empty(short_address))
53                    return get_full_markup(account_mailboxes);
54            }
55
56            // use first name as delimited by a space
57            string[] tokens = short_address.split(" ", 2);
58            if (tokens.length < 1)
59                return get_full_markup(account_mailboxes);
60
61            string first_name = tokens[0].strip();
62            if (Geary.String.is_empty_or_whitespace(first_name))
63                return get_full_markup(account_mailboxes);
64
65            return get_as_markup(first_name);
66        }
67
68        private string get_as_markup(string participant) {
69            string markup = Geary.HTML.escape_markup(participant);
70
71            if (is_unread) {
72                markup = "<b>%s</b>".printf(markup);
73            }
74
75            if (this.address.is_spoofed()) {
76                markup = "<s>%s</s>".printf(markup);
77            }
78
79            return markup;
80        }
81
82        public bool equal_to(ParticipantDisplay other) {
83            return address.equal_to(other.address)
84                && address.name == other.address.name;
85        }
86
87        public uint hash() {
88            return address.hash();
89        }
90    }
91
92    private static int cell_height = -1;
93    private static int preview_height = -1;
94
95    public bool is_unread { get; set; }
96    public bool is_flagged { get; set; }
97    public string date { get; private set; }
98    public string? body { get; private set; default = null; } // optional
99    public int num_emails { get; set; }
100    public Geary.Email? preview { get; private set; default = null; }
101
102    private Application.Configuration config;
103
104    private Gtk.Settings? gtk;
105    private Pango.FontDescription font;
106
107    private Geary.App.Conversation? conversation = null;
108    private Gee.List<Geary.RFC822.MailboxAddress>? account_owner_emails = null;
109    private bool use_to = true;
110    private CountBadge count_badge = new CountBadge(2);
111    private string subject_html_escaped;
112    private Participants participants = Participants(){markup = null};
113
114    // Creates a formatted message data from an e-mail.
115    public FormattedConversationData(Application.Configuration config,
116                                     Geary.App.Conversation conversation,
117                                     Geary.Email preview,
118                                     Gee.List<Geary.RFC822.MailboxAddress> account_owner_emails) {
119        this.config = config;
120        this.gtk = Gtk.Settings.get_default();
121        this.conversation = conversation;
122        this.account_owner_emails = account_owner_emails;
123        this.use_to = conversation.base_folder.used_as.is_outgoing();
124
125        this.gtk.notify["gtk-font-name"].connect(this.update_font);
126        update_font();
127
128        // Load preview-related data.
129        update_date_string();
130        this.subject_html_escaped
131            = Geary.HTML.escape_markup(Util.Email.strip_subject_prefixes(preview));
132        this.body = Geary.String.reduce_whitespace(preview.get_preview_as_string());
133        this.preview = preview;
134
135        // Load conversation-related data.
136        this.is_unread = conversation.is_unread();
137        this.is_flagged = conversation.is_flagged();
138        this.num_emails = conversation.get_count();
139
140        // todo: instead of clearing the cache update it
141        this.conversation.appended.connect(clear_participants_cache);
142        this.conversation.trimmed.connect(clear_participants_cache);
143        this.conversation.email_flags_changed.connect(clear_participants_cache);
144    }
145
146    // Creates an example message (used internally for styling calculations.)
147    public FormattedConversationData.create_example(Application.Configuration config) {
148        this.config = config;
149        this.is_unread = false;
150        this.is_flagged = false;
151        this.date = STYLE_EXAMPLE;
152        this.subject_html_escaped = STYLE_EXAMPLE;
153        this.body = STYLE_EXAMPLE + "\n" + STYLE_EXAMPLE;
154        this.num_emails = 1;
155
156        this.font = Pango.FontDescription.from_string(
157            this.config.gnome_interface.get_string("font-name")
158        );
159    }
160
161    private void clear_participants_cache(Geary.Email email) {
162        participants.markup = null;
163    }
164
165    public bool update_date_string() {
166        // get latest email *in folder* for the conversation's date, fall back on out-of-folder
167        Geary.Email? latest = conversation.get_latest_recv_email(Geary.App.Conversation.Location.IN_FOLDER_OUT_OF_FOLDER);
168        if (latest == null || latest.properties == null)
169            return false;
170
171        // conversation list store sorts by date-received, so display that instead of sender's
172        // Date:
173        string new_date = Util.Date.pretty_print(
174            latest.properties.date_received.to_local(),
175            this.config.clock_format
176        );
177        if (new_date == date)
178            return false;
179
180        date = new_date;
181
182        return true;
183    }
184
185    private uint8 gdk_to_rgb(double gdk) {
186        return (uint8) (gdk.clamp(0.0, 1.0) * 255.0);
187    }
188
189    private Gdk.RGBA dim_rgba(Gdk.RGBA rgba, double amount) {
190        amount = amount.clamp(0.0, 1.0);
191
192        // can't use ternary in struct initializer due to this bug:
193        // https://bugzilla.gnome.org/show_bug.cgi?id=684742
194        double dim_red = (rgba.red >= 0.5) ? -amount : amount;
195        double dim_green = (rgba.green >= 0.5) ? -amount : amount;
196        double dim_blue = (rgba.blue >= 0.5) ? -amount : amount;
197
198        return Gdk.RGBA() {
199            red = (rgba.red + dim_red).clamp(0.0, 1.0),
200            green = (rgba.green + dim_green).clamp(0.0, 1.0),
201            blue = (rgba.blue + dim_blue).clamp(0.0, 1.0),
202            alpha = rgba.alpha
203        };
204    }
205
206    private string rgba_to_markup(Gdk.RGBA rgba) {
207        return "#%02x%02x%02x".printf(
208            gdk_to_rgb(rgba.red), gdk_to_rgb(rgba.green), gdk_to_rgb(rgba.blue));
209    }
210
211    private Gdk.RGBA get_foreground_rgba(Gtk.Widget widget, bool selected) {
212        // Do the https://bugzilla.gnome.org/show_bug.cgi?id=763796 dance
213        Gtk.StyleContext context = widget.get_style_context();
214        context.save();
215        context.set_state(
216            selected ? Gtk.StateFlags.SELECTED : Gtk.StateFlags.NORMAL
217        );
218        Gdk.RGBA colour = context.get_color(context.get_state());
219        context.restore();
220        return colour;
221    }
222
223    private string get_participants_markup(Gtk.Widget widget, bool selected) {
224        if (participants.markup != null && participants.was_widget_selected == selected)
225            return participants.markup;
226
227        if (conversation == null || account_owner_emails == null || account_owner_emails.size == 0)
228            return "";
229
230        // Build chronological list of unique AuthorDisplay records, setting to
231        // unread if any message by that author is unread
232        Gee.ArrayList<ParticipantDisplay> list = new Gee.ArrayList<ParticipantDisplay>();
233        foreach (Geary.Email message in conversation.get_emails(Geary.App.Conversation.Ordering.RECV_DATE_ASCENDING)) {
234            // only display if something to display
235            Geary.RFC822.MailboxAddresses? addresses = use_to
236                ? new Geary.RFC822.MailboxAddresses.single(Util.Email.get_primary_originator(message))
237                : message.from;
238            if (addresses == null || addresses.size < 1)
239                continue;
240
241            foreach (Geary.RFC822.MailboxAddress address in addresses) {
242                ParticipantDisplay participant_display = new ParticipantDisplay(address,
243                    message.email_flags.is_unread());
244
245                int existing_index = list.index_of(participant_display);
246                if (existing_index < 0) {
247                    list.add(participant_display);
248
249                    continue;
250                }
251
252                // if present and this message is unread but the prior were read,
253                // this author is now unread
254                if (message.email_flags.is_unread())
255                    list[existing_index].is_unread = true;
256            }
257        }
258
259        if (list.size == 1) {
260            // if only one participant, use full name
261            participants.markup = "<span foreground='%s'>%s</span>"
262                .printf(rgba_to_markup(get_foreground_rgba(widget, selected)),
263                        list[0].get_full_markup(account_owner_emails));
264        } else {
265            StringBuilder builder = new StringBuilder("<span foreground='%s'>".printf(
266                rgba_to_markup(get_foreground_rgba(widget, selected))));
267            bool first = true;
268            foreach (ParticipantDisplay participant in list) {
269                if (!first)
270                    builder.append(", ");
271
272                builder.append(participant.get_short_markup(account_owner_emails));
273                first = false;
274            }
275            builder.append("</span>");
276            participants.markup = builder.str;
277        }
278        participants.was_widget_selected = selected;
279        return participants.markup;
280    }
281
282    public void render(Cairo.Context ctx, Gtk.Widget widget, Gdk.Rectangle background_area,
283        Gdk.Rectangle cell_area, Gtk.CellRendererState flags, bool hover_select) {
284        render_internal(widget, cell_area, ctx, flags, false, hover_select);
285    }
286
287    // Call this on style changes.
288    public void calculate_sizes(Gtk.Widget widget) {
289        render_internal(widget, null, null, 0, true, false);
290    }
291
292    // Must call calculate_sizes() first.
293    public int get_height() {
294        assert(cell_height != -1); // ensures calculate_sizes() was called.
295        return cell_height;
296    }
297
298    // Can be used for rendering or calculating height.
299    private void render_internal(Gtk.Widget widget, Gdk.Rectangle? cell_area,
300        Cairo.Context? ctx, Gtk.CellRendererState flags, bool recalc_dims,
301        bool hover_select) {
302        bool display_preview = this.config.display_preview;
303        int y = SPACING + (cell_area != null ? cell_area.y : 0);
304
305        bool selected = (flags & Gtk.CellRendererState.SELECTED) != 0;
306        bool hover = (flags & Gtk.CellRendererState.PRELIT) != 0 || (selected && hover_select);
307
308        // Date field.
309        Pango.Rectangle ink_rect = render_date(widget, cell_area, ctx, y, selected);
310
311        // From field.
312        ink_rect = render_from(widget, cell_area, ctx, y, selected, ink_rect);
313        y += ink_rect.height + ink_rect.y + SPACING;
314
315        // If we are displaying a preview then the message counter goes on the same line as the
316        // preview, otherwise it is with the subject.
317        int preview_height = 0;
318
319        // Setup counter badge.
320        count_badge.count = num_emails;
321        int counter_width = count_badge.get_width(widget) + SPACING;
322        int counter_x = cell_area != null ? cell_area.width - cell_area.x - counter_width +
323            (SPACING / 2) : 0;
324
325        if (display_preview) {
326            // Subject field.
327            render_subject(widget, cell_area, ctx, y, selected);
328            y += ink_rect.height + ink_rect.y + (SPACING / 2);
329
330            // Number of e-mails field.
331            count_badge.render(widget, ctx, counter_x, y + (SPACING / 2), selected);
332
333            // Body preview.
334            ink_rect = render_preview(widget, cell_area, ctx, y, selected, counter_width);
335            preview_height = ink_rect.height + ink_rect.y + (int) (SPACING * 1.2);
336        } else {
337            // Number of e-mails field.
338            count_badge.render(widget, ctx, counter_x, y, selected);
339
340            // Subject field.
341            render_subject(widget, cell_area, ctx, y, selected, counter_width);
342            y += ink_rect.height + ink_rect.y + (int) (SPACING * 1.2);
343        }
344
345        if (recalc_dims) {
346            FormattedConversationData.preview_height = preview_height;
347            FormattedConversationData.cell_height = y + preview_height;
348        } else {
349            int unread_y = display_preview ? cell_area.y + SPACING * 2 : cell_area.y +
350                SPACING;
351
352            // Unread indicator.
353            if (is_unread || hover) {
354                Gdk.Pixbuf read_icon = IconFactory.instance.load_symbolic(
355                    is_unread ? "mail-unread-symbolic" : "mail-read-symbolic",
356                    IconFactory.UNREAD_ICON_SIZE, widget.get_style_context());
357                Gdk.cairo_set_source_pixbuf(ctx, read_icon, cell_area.x + SPACING, unread_y);
358                ctx.paint();
359            }
360
361            // Starred indicator.
362            if (is_flagged || hover) {
363                int star_y = cell_area.y + (cell_area.height / 2) + (display_preview ? SPACING : 0);
364                Gdk.Pixbuf starred_icon = IconFactory.instance.load_symbolic(
365                    is_flagged ? "starred-symbolic" : "non-starred-symbolic",
366                    IconFactory.STAR_ICON_SIZE, widget.get_style_context());
367                Gdk.cairo_set_source_pixbuf(ctx, starred_icon, cell_area.x + SPACING, star_y);
368                ctx.paint();
369            }
370        }
371    }
372
373    private Pango.Rectangle render_date(Gtk.Widget widget, Gdk.Rectangle? cell_area,
374        Cairo.Context? ctx, int y, bool selected) {
375        string date_markup = "<span size='smaller' foreground='%s'>%s</span>".printf(
376            rgba_to_markup(dim_rgba(get_foreground_rgba(widget, selected), DIM_TEXT_AMOUNT)),
377            Geary.HTML.escape_markup(date));
378
379        Pango.Rectangle? ink_rect;
380        Pango.Rectangle? logical_rect;
381        Pango.Layout layout_date = widget.create_pango_layout(null);
382        layout_date.set_font_description(this.font);
383        layout_date.set_markup(date_markup, -1);
384        layout_date.set_alignment(Pango.Alignment.RIGHT);
385        layout_date.get_pixel_extents(out ink_rect, out logical_rect);
386        if (ctx != null && cell_area != null) {
387            ctx.move_to(cell_area.width - cell_area.x - ink_rect.width - ink_rect.x - SPACING, y);
388            Pango.cairo_show_layout(ctx, layout_date);
389        }
390        return ink_rect;
391    }
392
393    private Pango.Rectangle render_from(Gtk.Widget widget, Gdk.Rectangle? cell_area,
394        Cairo.Context? ctx, int y, bool selected, Pango.Rectangle ink_rect) {
395        string from_markup = (conversation != null) ? get_participants_markup(widget, selected) : STYLE_EXAMPLE;
396
397        Pango.FontDescription font = this.font;
398        if (is_unread) {
399            font = font.copy();
400            font.set_weight(Pango.Weight.BOLD);
401        }
402        Pango.Layout layout_from = widget.create_pango_layout(null);
403        layout_from.set_font_description(font);
404        layout_from.set_markup(from_markup, -1);
405        layout_from.set_ellipsize(Pango.EllipsizeMode.END);
406        if (ctx != null && cell_area != null) {
407            layout_from.set_width((cell_area.width - ink_rect.width - ink_rect.x - (SPACING * 3) -
408                TEXT_LEFT)
409            * Pango.SCALE);
410            ctx.move_to(cell_area.x + TEXT_LEFT, y);
411            Pango.cairo_show_layout(ctx, layout_from);
412        }
413        return ink_rect;
414    }
415
416    private void render_subject(Gtk.Widget widget, Gdk.Rectangle? cell_area, Cairo.Context? ctx,
417        int y, bool selected, int counter_width = 0) {
418        string subject_markup = "<span size='smaller' foreground='%s'>%s</span>".printf(
419            rgba_to_markup(dim_rgba(get_foreground_rgba(widget, selected), DIM_TEXT_AMOUNT)),
420            subject_html_escaped);
421
422        Pango.FontDescription font = this.font;
423        if (is_unread) {
424            font = font.copy();
425            font.set_weight(Pango.Weight.BOLD);
426        }
427        Pango.Layout layout_subject = widget.create_pango_layout(null);
428        layout_subject.set_font_description(font);
429        layout_subject.set_markup(subject_markup, -1);
430        if (cell_area != null)
431            layout_subject.set_width((cell_area.width - TEXT_LEFT - counter_width) * Pango.SCALE);
432        layout_subject.set_ellipsize(Pango.EllipsizeMode.END);
433        if (ctx != null && cell_area != null) {
434            ctx.move_to(cell_area.x + TEXT_LEFT, y);
435            Pango.cairo_show_layout(ctx, layout_subject);
436        }
437    }
438
439    private Pango.Rectangle render_preview(Gtk.Widget widget, Gdk.Rectangle? cell_area,
440        Cairo.Context? ctx, int y, bool selected, int counter_width = 0) {
441        double dim = selected ? DIM_TEXT_AMOUNT : DIM_PREVIEW_TEXT_AMOUNT;
442        string preview_markup = "<span size='smaller' foreground='%s'>%s</span>".printf(
443            rgba_to_markup(dim_rgba(get_foreground_rgba(widget, selected), dim)),
444            Geary.String.is_empty(body) ? "" : Geary.HTML.escape_markup(body));
445
446        Pango.Layout layout_preview = widget.create_pango_layout(null);
447        layout_preview.set_font_description(this.font);
448        layout_preview.set_markup(preview_markup, -1);
449        layout_preview.set_wrap(Pango.WrapMode.WORD);
450        layout_preview.set_ellipsize(Pango.EllipsizeMode.END);
451        if (ctx != null && cell_area != null) {
452            layout_preview.set_width((cell_area.width - TEXT_LEFT - counter_width - SPACING) * Pango.SCALE);
453            layout_preview.set_height(preview_height * Pango.SCALE);
454
455            ctx.move_to(cell_area.x + TEXT_LEFT, y);
456            Pango.cairo_show_layout(ctx, layout_preview);
457        } else {
458            layout_preview.set_width(int.MAX);
459            layout_preview.set_height(int.MAX);
460        }
461
462        Pango.Rectangle? ink_rect;
463        Pango.Rectangle? logical_rect;
464        layout_preview.get_pixel_extents(out ink_rect, out logical_rect);
465        return ink_rect;
466    }
467
468    private void update_font() {
469        var name = "Cantarell 11";
470        if (this.gtk != null) {
471            name = this.gtk.gtk_font_name;
472        }
473        this.font = Pango.FontDescription.from_string(name);
474    }
475
476}
477