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