forked from zulip/zulip
-
Notifications
You must be signed in to change notification settings - Fork 1
/
message_list_view.js
1384 lines (1181 loc) · 56.5 KB
/
message_list_view.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
const render_bookend = require('../templates/bookend.hbs');
const render_message_group = require('../templates/message_group.hbs');
const render_recipient_row = require('../templates/recipient_row.hbs');
const render_single_message = require('../templates/single_message.hbs');
function MessageListView(list, table_name, collapse_messages) {
this.list = list;
this.collapse_messages = collapse_messages;
this._rows = {};
this.message_containers = {};
this.table_name = table_name;
if (this.table_name) {
this.clear_table();
}
this._message_groups = [];
// Half-open interval of the indices that define the current render window
this._render_win_start = 0;
this._render_win_end = 0;
}
function get_user_id_for_mention_button(elem) {
const user_id_string = $(elem).attr('data-user-id');
// Handle legacy markdown that was rendered before we cut
// over to using data-user-id.
const email = $(elem).attr('data-user-email');
if (user_id_string === "*" || email === "*") {
return "*";
}
if (user_id_string) {
return parseInt(user_id_string, 10);
}
if (email) {
// Will return undefined if there's no match
const user = people.get_by_email(email);
if (user) {
return user.user_id;
}
return;
}
return;
}
function get_user_group_id_for_mention_button(elem) {
const user_group_id = $(elem).attr('data-user-group-id');
if (user_group_id) {
return parseInt(user_group_id, 10);
}
return;
}
function same_day(earlier_msg, later_msg) {
if (earlier_msg === undefined || later_msg === undefined) {
return false;
}
const earlier_time = new XDate(earlier_msg.msg.timestamp * 1000);
const later_time = new XDate(later_msg.msg.timestamp * 1000);
return earlier_time.toDateString() === later_time.toDateString();
}
function same_sender(a, b) {
if (a === undefined || b === undefined) {
return false;
}
return util.same_sender(a.msg, b.msg);
}
function same_recipient(a, b) {
if (a === undefined || b === undefined) {
return false;
}
return util.same_recipient(a.msg, b.msg);
}
function render_group_display_date(group, message_container) {
const time = new XDate(message_container.msg.timestamp * 1000);
const today = new XDate();
const date_element = timerender.render_date(time, undefined, today)[0];
group.date = date_element.outerHTML;
}
function update_group_date_divider(group, message_container, prev) {
const time = new XDate(message_container.msg.timestamp * 1000);
const today = new XDate();
if (prev !== undefined) {
const prev_time = new XDate(prev.msg.timestamp * 1000);
if (time.toDateString() !== prev_time.toDateString()) {
// NB: group_date_divider_html is HTML, inserted into the document without escaping.
group.group_date_divider_html = timerender.render_date(time, prev_time,
today)[0].outerHTML;
group.show_group_date_divider = true;
}
} else {
// Show the date in the recipient bar, but not a date separator bar.
group.show_group_date_divider = false;
group.group_date_divider_html = timerender.render_date(time, undefined, today)[0].outerHTML;
}
}
function clear_group_date_divider(group) {
group.show_group_date_divider = false;
group.group_date_divider_html = undefined;
}
function clear_message_date_divider(msg) {
// see update_message_date_divider for how
// these get set
msg.want_date_divider = false;
msg.date_divider_html = undefined;
}
function update_message_date_divider(opts) {
const prev_msg_container = opts.prev_msg_container;
const curr_msg_container = opts.curr_msg_container;
if (!prev_msg_container || same_day(curr_msg_container, prev_msg_container)) {
clear_message_date_divider(curr_msg_container);
return;
}
const prev_time = new XDate(prev_msg_container.msg.timestamp * 1000);
const curr_time = new XDate(curr_msg_container.msg.timestamp * 1000);
const today = new XDate();
curr_msg_container.want_date_divider = true;
curr_msg_container.date_divider_html =
timerender.render_date(curr_time, prev_time, today)[0].outerHTML;
}
function set_timestr(message_container) {
const time = new XDate(message_container.msg.timestamp * 1000);
message_container.timestr = timerender.stringify_time(time);
}
function set_topic_edit_properties(group, message) {
group.realm_allow_message_editing = page_params.realm_allow_message_editing;
group.always_visible_topic_edit = false;
group.on_hover_topic_edit = false;
// Messages with no topics should always have an edit icon visible
// to encourage updating them. Admins can also edit any topic.
if (util.get_message_topic(message) === compose.empty_topic_placeholder()) {
group.always_visible_topic_edit = true;
} else if (message_edit.is_topic_editable(message)) {
group.on_hover_topic_edit = true;
}
}
function populate_group_from_message_container(group, message_container) {
group.is_stream = message_container.msg.is_stream;
group.is_private = message_container.msg.is_private;
if (group.is_stream) {
group.background_color = stream_data.get_color(message_container.msg.stream);
group.color_class = stream_color.get_color_class(group.background_color);
group.invite_only = stream_data.get_invite_only(message_container.msg.stream);
group.topic = util.get_message_topic(message_container.msg);
group.match_topic = util.get_match_topic(message_container.msg);
group.stream_url = message_container.stream_url;
group.topic_url = message_container.topic_url;
const sub = stream_data.get_sub(message_container.msg.stream);
if (sub === undefined) {
// Hack to handle unusual cases like the tutorial where
// the streams used don't actually exist in the subs
// module. Ideally, we'd clean this up by making the
// tutorial populate subs.js "properly".
group.stream_id = -1;
} else {
group.stream_id = sub.stream_id;
}
} else if (group.is_private) {
group.pm_with_url = message_container.pm_with_url;
group.display_reply_to = message_store.get_pm_full_names(message_container.msg);
}
group.display_recipient = message_container.msg.display_recipient;
group.topic_links = util.get_topic_links(message_container.msg);
set_topic_edit_properties(group, message_container.msg);
render_group_display_date(group, message_container);
}
MessageListView.prototype = {
// Number of messages to render at a time
_RENDER_WINDOW_SIZE: 400,
// Number of messages away from edge of render window at which we
// trigger a re-render
_RENDER_THRESHOLD: 50,
_get_msg_timestring: function (message_container) {
let last_edit_timestamp;
if (message_container.msg.local_edit_timestamp !== undefined) {
last_edit_timestamp = message_container.msg.local_edit_timestamp;
} else {
last_edit_timestamp = message_container.msg.last_edit_timestamp;
}
if (last_edit_timestamp !== undefined) {
const last_edit_time = new XDate(last_edit_timestamp * 1000);
const today = new XDate();
return timerender.render_date(last_edit_time, undefined, today)[0].textContent +
" at " + timerender.stringify_time(last_edit_time);
}
},
_add_msg_edited_vars: function (message_container) {
// This adds variables to message_container object which calculate bools for
// checking position of "(EDITED)" label as well as the edited timestring
// The bools can be defined only when the message is edited
// (or when the `last_edit_timestr` is defined). The bools are:
// * `edited_in_left_col` -- when label appears in left column.
// * `edited_alongside_sender` -- when label appears alongside sender info.
// * `edited_status_msg` -- when label appears for a "/me" message.
const last_edit_timestr = this._get_msg_timestring(message_container);
const include_sender = message_container.include_sender;
const status_message = Boolean(message_container.status_message);
if (last_edit_timestr !== undefined) {
message_container.last_edit_timestr = last_edit_timestr;
message_container.edited_in_left_col = !include_sender;
message_container.edited_alongside_sender = include_sender && !status_message;
message_container.edited_status_msg = include_sender && status_message;
} else {
delete message_container.last_edit_timestr;
message_container.edited_in_left_col = false;
message_container.edited_alongside_sender = false;
message_container.edited_status_msg = false;
}
},
add_subscription_marker: function (group, last_msg_container, first_msg_container) {
if (last_msg_container === undefined) {
return;
}
const last_subscribed = !last_msg_container.msg.historical;
const first_subscribed = !first_msg_container.msg.historical;
const stream = first_msg_container.msg.stream;
if (!last_subscribed && first_subscribed) {
group.bookend_top = true;
group.subscribed = stream;
group.bookend_content = this.list.subscribed_bookend_content(stream);
return;
}
if (last_subscribed && !first_subscribed) {
group.bookend_top = true;
group.unsubscribed = stream;
group.bookend_content = this.list.unsubscribed_bookend_content(stream);
return;
}
},
build_message_groups: function (message_containers) {
function start_group() {
return {
message_containers: [],
message_group_id: _.uniqueId('message_group_'),
};
}
const self = this;
let current_group = start_group();
const new_message_groups = [];
let prev;
function add_message_container_to_group(message_container) {
if (same_sender(prev, message_container)) {
prev.next_is_same_sender = true;
}
current_group.message_containers.push(message_container);
}
function finish_group() {
if (current_group.message_containers.length > 0) {
populate_group_from_message_container(current_group,
current_group.message_containers[0]);
current_group
.message_containers[current_group.message_containers.length - 1]
.include_footer = true;
new_message_groups.push(current_group);
}
}
_.each(message_containers, function (message_container) {
const message_reactions = reactions.get_message_reactions(message_container.msg);
message_container.msg.message_reactions = message_reactions;
message_container.include_recipient = false;
message_container.include_footer = false;
if (same_recipient(prev, message_container) && self.collapse_messages &&
prev.msg.historical === message_container.msg.historical) {
add_message_container_to_group(message_container);
update_message_date_divider({
prev_msg_container: prev,
curr_msg_container: message_container,
});
} else {
finish_group();
current_group = start_group();
add_message_container_to_group(message_container);
update_group_date_divider(current_group, message_container, prev);
clear_message_date_divider(message_container);
message_container.include_recipient = true;
message_container.subscribed = false;
message_container.unsubscribed = false;
// This home_msg_list condition can be removed
// once we filter historical messages from the
// home view on the server side (which requires
// having an index on UserMessage.flags)
if (self.list !== home_msg_list) {
self.add_subscription_marker(current_group, prev, message_container);
}
if (message_container.msg.stream) {
message_container.stream_url =
hash_util.by_stream_uri(message_container.msg.stream_id);
message_container.topic_url =
hash_util.by_stream_topic_uri(
message_container.msg.stream_id,
util.get_message_topic(message_container.msg));
} else {
message_container.pm_with_url =
message_container.msg.pm_with_url;
}
}
set_timestr(message_container);
message_container.include_sender = true;
if (!message_container.include_recipient &&
!prev.status_message &&
same_day(prev, message_container) &&
same_sender(prev, message_container)) {
message_container.include_sender = false;
}
message_container.sender_is_bot = people.sender_is_bot(message_container.msg);
message_container.sender_is_guest = people.sender_is_guest(message_container.msg);
message_container.small_avatar_url = people.small_avatar_url(message_container.msg);
if (message_container.msg.stream) {
message_container.background_color =
stream_data.get_color(message_container.msg.stream);
message_container.restrict_emoji_reaction =
reactions.set_restrict_emoji_reaction(message_container.msg);
}
message_container.contains_mention = message_container.msg.mentioned;
self._maybe_format_me_message(message_container);
// Once all other variables are updated
self._add_msg_edited_vars(message_container);
prev = message_container;
});
finish_group();
return new_message_groups;
},
join_message_groups: function (first_group, second_group) {
// join_message_groups will combine groups if they have the
// same_recipient and the view supports collapsing, otherwise
// it may add a subscription_marker if required. It returns
// true if the two groups were joined in to one and the
// second_group should be ignored.
if (first_group === undefined || second_group === undefined) {
return false;
}
const last_msg_container = _.last(first_group.message_containers);
const first_msg_container = _.first(second_group.message_containers);
// Join two groups into one.
if (this.collapse_messages && same_recipient(last_msg_container, first_msg_container) &&
last_msg_container.msg.historical === first_msg_container.msg.historical) {
if (!last_msg_container.status_message && !first_msg_container.msg.is_me_message
&& same_day(last_msg_container, first_msg_container)
&& same_sender(last_msg_container, first_msg_container)) {
first_msg_container.include_sender = false;
}
if (same_sender(last_msg_container, first_msg_container)) {
last_msg_container.next_is_same_sender = true;
}
first_group.message_containers =
first_group.message_containers.concat(second_group.message_containers);
return true;
// Add a subscription marker
} else if (this.list !== home_msg_list &&
last_msg_container.msg.historical !== first_msg_container.msg.historical) {
second_group.bookend_top = true;
this.add_subscription_marker(second_group, last_msg_container, first_msg_container);
}
return false;
},
merge_message_groups: function (new_message_groups, where) {
// merge_message_groups takes a list of new messages groups to add to
// this._message_groups and a location where to merge them currently
// top or bottom. It returns an object of changes which needed to be
// rendered in to the page. The types of actions are append_group,
// prepend_group, rerender_group, append_message.
//
// append_groups are groups to add to the top of the rendered DOM
// prepend_groups are group to add to the bottom of the rendered DOM
// rerender_groups are group that should be updated in place in the DOM
// append_messages are messages which should be added to the last group in the DOM
// rerender_messages are messages which should be updated in place in the DOM
const message_actions = {
append_groups: [],
prepend_groups: [],
rerender_groups: [],
append_messages: [],
rerender_messages_next_same_sender: [],
};
let first_group;
let second_group;
let curr_msg_container;
let prev_msg_container;
if (where === 'top') {
first_group = _.last(new_message_groups);
second_group = _.first(this._message_groups);
} else {
first_group = _.last(this._message_groups);
second_group = _.first(new_message_groups);
}
if (first_group) {
prev_msg_container = _.last(first_group.message_containers);
}
if (second_group) {
curr_msg_container = _.first(second_group.message_containers);
}
const was_joined = this.join_message_groups(first_group, second_group);
if (was_joined) {
update_message_date_divider({
prev_msg_container: prev_msg_container,
curr_msg_container: curr_msg_container,
});
} else {
clear_message_date_divider(curr_msg_container);
}
if (where === 'top') {
if (was_joined) {
// join_message_groups moved the old message to the end of the
// new group. We need to replace the old rendered message
// group. So we will reuse its ID.
first_group.message_group_id = second_group.message_group_id;
message_actions.rerender_groups.push(first_group);
// Swap the new group in
this._message_groups.shift();
this._message_groups.unshift(first_group);
new_message_groups = _.initial(new_message_groups);
} else if (!same_day(second_group.message_containers[0],
first_group.message_containers[0])) {
// The groups did not merge, so we need up update the date row for the old group
update_group_date_divider(second_group, curr_msg_container, prev_msg_container);
// We could add an action to update the date row, but for now rerender the group.
message_actions.rerender_groups.push(second_group);
}
message_actions.prepend_groups = new_message_groups;
this._message_groups = new_message_groups.concat(this._message_groups);
} else {
if (was_joined) {
// rerender the last message
message_actions.rerender_messages_next_same_sender.push(prev_msg_container);
message_actions.append_messages = _.first(new_message_groups).message_containers;
new_message_groups = _.rest(new_message_groups);
} else if (first_group !== undefined && second_group !== undefined) {
if (same_day(prev_msg_container, curr_msg_container)) {
clear_group_date_divider(second_group);
} else {
// If we just sent the first message on a new day
// in a narrow, make sure we render a date separator.
update_group_date_divider(second_group, curr_msg_container, prev_msg_container);
}
}
message_actions.append_groups = new_message_groups;
this._message_groups = this._message_groups.concat(new_message_groups);
}
return message_actions;
},
_put_row: function (row) {
// row is a jQuery object wrapping one message row
if (row.hasClass('message_row')) {
this._rows[row.attr('zid')] = row;
}
},
_post_process: function ($message_rows) {
// $message_rows wraps one or more message rows
if ($message_rows.constructor !== jQuery) {
// An assertion check that we're calling this properly
blueslip.error('programming error--pass in jQuery objects');
}
const self = this;
_.each($message_rows, function (dom_row) {
const row = $(dom_row);
self._put_row(row);
self._post_process_single_row(row);
});
},
_post_process_single_row: function (row) {
// For message formatting that requires some post-processing
// (and is not possible to handle solely via CSS), this is
// where we modify the content. It is a goal to minimize how
// much logic is present in this function; wherever possible,
// we should implement features with the markdown processor,
// HTML and CSS.
if (row.length !== 1) {
blueslip.error('programming error--expected single element');
}
const content = row.find('.message_content');
// Set the rtl class if the text has an rtl direction
if (rtl.get_direction(content.text()) === 'rtl') {
content.addClass('rtl');
}
content.find('.user-mention').each(function () {
const user_id = get_user_id_for_mention_button(this);
// We give special highlights to the mention buttons
// that refer to the current user.
if (user_id === "*" || people.is_my_user_id(user_id)) {
// Either a wildcard mention or us, so mark it.
$(this).addClass('user-mention-me');
}
if (user_id && user_id !== "*" && !$(this).find(".highlight").length) {
// If it's a mention of a specific user, edit the
// mention text to show the user's current name,
// assuming that you're not searching for text
// inside the highlight.
const person = people.get_person_from_user_id(user_id);
if (person !== undefined) {
// Note that person might be undefined in some
// unpleasant corner cases involving data import.
markdown.set_name_in_mention_element(this, person.full_name);
}
}
});
content.find('.user-group-mention').each(function () {
const user_group_id = get_user_group_id_for_mention_button(this);
const user_group = user_groups.get_user_group_from_id(user_group_id, true);
if (user_group === undefined) {
// This is a user group the current user doesn't have
// data on. This can happen when user groups are
// deleted.
blueslip.info("Rendered unexpected user group " + user_group_id);
return;
}
const my_user_id = people.my_current_user_id();
// Mark user group you're a member of.
if (user_groups.is_member_of(user_group_id, my_user_id)) {
$(this).addClass('user-mention-me');
}
if (user_group_id && !$(this).find(".highlight").length) {
// Edit the mention to show the current name for the
// user group, if its not in search.
$(this).text("@" + user_group.name);
}
});
content.find('a.stream').each(function () {
const stream_id = parseInt($(this).attr('data-stream-id'), 10);
if (stream_id && !$(this).find(".highlight").length) {
// Display the current name for stream if it is not
// being displayed in search highlight.
const stream_name = stream_data.maybe_get_stream_name(stream_id);
if (stream_name !== undefined) {
// If the stream has been deleted,
// stream_data.maybe_get_stream_name might return
// undefined. Otherwise, display the current stream name.
$(this).text("#" + stream_name);
}
}
});
content.find('a.stream-topic').each(function () {
const stream_id = parseInt($(this).attr('data-stream-id'), 10);
if (stream_id && !$(this).find(".highlight").length) {
// Display the current name for stream if it is not
// being displayed in search highlight.
const text = $(this).text();
const topic = text.split('>', 2)[1];
const stream_name = stream_data.maybe_get_stream_name(stream_id);
if (stream_name !== undefined) {
// If the stream has been deleted,
// stream_data.maybe_get_stream_name might return
// undefined. Otherwise, display the current stream name.
$(this).text("#" + stream_name + ' > ' + topic);
}
}
});
// Display emoji (including realm emoji) as text if
// page_params.emojiset is 'text'.
if (page_params.emojiset === 'text') {
content.find(".emoji").replaceWith(function () {
const text = $(this).attr("title");
return ":" + text + ":";
});
}
const id = rows.id(row);
message_edit.maybe_show_edit(row, id);
submessage.process_submessages({
row: row,
message_id: id,
});
},
_get_message_template: function (message_container) {
const msg_reactions = reactions.get_message_reactions(message_container.msg);
message_container.msg.message_reactions = msg_reactions;
const msg_to_render = _.extend(message_container, {
table_name: this.table_name,
});
return render_single_message(msg_to_render);
},
_render_group: function (opts) {
const message_groups = opts.message_groups;
const use_match_properties = opts.use_match_properties;
const table_name = opts.table_name;
return $(render_message_group({
message_groups: message_groups,
use_match_properties: use_match_properties,
table_name: table_name,
}));
},
render: function (messages, where, messages_are_new) {
// This function processes messages into chunks with separators between them,
// and templates them to be inserted as table rows into the DOM.
// Store this in a separate variable so it doesn't get
// confusingly masked in upcoming loops.
const self = this;
if (messages.length === 0 || self.table_name === undefined) {
return;
}
const list = self.list; // for convenience
const table_name = self.table_name;
const table = rows.get_table(table_name);
let orig_scrolltop_offset;
// If we start with the message feed scrolled up (i.e.
// the bottom message is not visible), then we will respect
// the user's current position after rendering, rather
// than auto-scrolling.
const started_scrolled_up = message_viewport.is_scrolled_up();
// The messages we are being asked to render are shared with between
// all messages lists. To prevent having both list views overwriting
// each others data we will make a new message object to add data to
// for rendering.
const message_containers = _.map(messages, function (message) {
if (message.starred) {
message.starred_status = i18n.t("Unstar");
} else {
message.starred_status = i18n.t("Star");
}
return {msg: message};
});
function save_scroll_position() {
if (orig_scrolltop_offset === undefined && self.selected_row().length > 0) {
orig_scrolltop_offset = self.selected_row().offset().top;
}
}
function restore_scroll_position() {
if (list === current_msg_list && orig_scrolltop_offset !== undefined) {
list.view.set_message_offset(orig_scrolltop_offset);
list.reselect_selected_id();
}
}
// This function processes messages into chunks with separators between them,
// and templates them to be inserted as table rows into the DOM.
if (message_containers.length === 0 || self.table_name === undefined) {
return;
}
const new_message_groups = self.build_message_groups(message_containers, self.table_name);
const message_actions = self.merge_message_groups(new_message_groups, where);
let new_dom_elements = [];
let rendered_groups;
let dom_messages;
let last_message_row;
let last_group_row;
_.each(message_containers, function (message_container) {
self.message_containers[message_container.msg.id] = message_container;
});
// Render new message groups on the top
if (message_actions.prepend_groups.length > 0) {
save_scroll_position();
rendered_groups = self._render_group({
message_groups: message_actions.prepend_groups,
use_match_properties: self.list.is_search(),
table_name: self.table_name,
});
dom_messages = rendered_groups.find('.message_row');
new_dom_elements = new_dom_elements.concat(rendered_groups);
self._post_process(dom_messages);
// The date row will be included in the message groups or will be
// added in a rerenderd in the group below
table.find('.recipient_row').first().prev('.date_row').remove();
table.prepend(rendered_groups);
condense.condense_and_collapse(dom_messages);
}
// Rerender message groups
if (message_actions.rerender_groups.length > 0) {
save_scroll_position();
_.each(message_actions.rerender_groups, function (message_group) {
const old_message_group = $('#' + message_group.message_group_id);
// Remove the top date_row, we'll re-add it after rendering
old_message_group.prev('.date_row').remove();
rendered_groups = self._render_group({
message_groups: [message_group],
use_match_properties: self.list.is_search(),
table_name: self.table_name,
});
dom_messages = rendered_groups.find('.message_row');
// Not adding to new_dom_elements it is only used for autoscroll
self._post_process(dom_messages);
old_message_group.replaceWith(rendered_groups);
condense.condense_and_collapse(dom_messages);
});
}
// Update the rendering for message rows which used to be last
// and now know whether the following message has the same
// sender.
//
// It is likely the case that we can just remove the block
// entirely, since it appears the next_is_same_sender CSS
// class doesn't do anything.
if (message_actions.rerender_messages_next_same_sender.length > 0) {
const targets = message_actions.rerender_messages_next_same_sender;
_.each(targets, function (message_container) {
const row = self.get_row(message_container.msg.id);
$(row).find("div.messagebox").toggleClass("next_is_same_sender",
message_container.next_is_same_sender);
});
}
// Insert new messages in to the last message group
if (message_actions.append_messages.length > 0) {
last_message_row = table.find('.message_row').last().expectOne();
last_group_row = rows.get_message_recipient_row(last_message_row);
dom_messages = $(_.map(message_actions.append_messages, function (message_container) {
return self._get_message_template(message_container);
}).join('')).filter('.message_row');
self._post_process(dom_messages);
last_group_row.append(dom_messages);
condense.condense_and_collapse(dom_messages);
new_dom_elements = new_dom_elements.concat(dom_messages);
}
// Add new message groups to the end
if (message_actions.append_groups.length > 0) {
// Remove the trailing bookend; it'll be re-added after we do our rendering
self.clear_trailing_bookend();
rendered_groups = self._render_group({
message_groups: message_actions.append_groups,
use_match_properties: self.list.is_search(),
table_name: self.table_name,
});
dom_messages = rendered_groups.find('.message_row');
new_dom_elements = new_dom_elements.concat(rendered_groups);
self._post_process(dom_messages);
// This next line is a workaround for a weird scrolling
// bug on Chrome. Basically, in Chrome 64, we had a
// highly reproducible bug where if you hit the "End" key
// 5 times in a row in a `near:1` narrow (or any other
// narrow with enough content below to try this), the 5th
// time (because RENDER_WINDOW_SIZE / batch_size = 4,
// i.e. the first time we need to rerender to show the
// message "End" jumps to) would trigger an unexpected
// scroll, resulting in some chaotic scrolling and
// additional fetches (from bottom_whitespace ending up in
// the view). During debugging, we found that this adding
// this next line seems to prevent the Chrome bug from firing.
message_viewport.scrollTop();
table.append(rendered_groups);
condense.condense_and_collapse(dom_messages);
}
restore_scroll_position();
const last_message_group = _.last(self._message_groups);
if (last_message_group !== undefined) {
list.last_message_historical =
_.last(last_message_group.message_containers).msg.historical;
}
const stream_name = narrow_state.stream();
if (stream_name !== undefined) {
// If user narrows to a stream, doesn't update
// trailing bookend if user is subscribed.
const sub = stream_data.get_sub(stream_name);
if (sub === undefined || !sub.subscribed) {
list.update_trailing_bookend();
}
}
if (list === current_msg_list) {
// Update the fade.
const get_element = function (message_group) {
// We don't have a MessageGroup class, but we can at least hide the messy details
// of rows.js from compose_fade. We provide a callback function to be lazy--
// compose_fade may not actually need the elements depending on its internal
// state.
const message_row = self.get_row(message_group.message_containers[0].msg.id);
return rows.get_message_recipient_row(message_row);
};
compose_fade.update_rendered_message_groups(new_message_groups, get_element);
}
if (list === current_msg_list && messages_are_new) {
// First, in single-recipient narrows, potentially
// auto-scroll to the latest message if it was sent by us.
if (narrow_state.narrowed_by_reply()) {
const selected_id = list.selected_id();
let i;
// Iterate backwards to find the last message
// sent_by_me, stopping at the pointer position.
// There's a reasonable argument that this search
// should be limited in how far offscreen it's willing
// to go.
for (i = messages.length - 1; i >= 0; i -= 1) {
const id = messages[i].id;
if (id <= selected_id) {
break;
}
if (messages[i].sent_by_me && list.get(id) !== undefined) {
// If this is a reply we just sent, advance the pointer to it.
list.select_id(messages[i].id, {then_scroll: true, from_scroll: true});
return {
need_user_to_scroll: false,
};
}
}
}
if (started_scrolled_up) {
return {
need_user_to_scroll: true,
};
}
const new_messages_height = self._new_messages_height(new_dom_elements);
const need_user_to_scroll = self._maybe_autoscroll(new_messages_height);
if (need_user_to_scroll) {
return {
need_user_to_scroll: true,
};
}
}
},
_new_messages_height: function (rendered_elems) {
let new_messages_height = 0;
let id_of_last_message_sent_by_us = -1;
// C++ iterators would have made this less painful
_.each(rendered_elems.reverse(), function (elem) {
// Sometimes there are non-DOM elements in rendered_elems; only
// try to get the heights of actual trs.
if (elem.is("div")) {
new_messages_height += elem.height();
// starting from the last message, ignore message heights that weren't sent by me.
if (id_of_last_message_sent_by_us > -1) {
return;
}
const row_id = rows.id(elem);
// check for `row_id` NaN in case we're looking at a date row or bookend row
if (row_id > -1 &&
people.is_current_user(this.get_message(row_id).sender_email)) {
id_of_last_message_sent_by_us = rows.id(elem);
}
}
}, this);
return new_messages_height;
},
_scroll_limit: function (selected_row, viewport_info) {
// This scroll limit is driven by the TOP of the feed, and
// it's the max amount that we can scroll down (or "skooch
// up" the messages) before knocking the selected message
// out of the feed.
const selected_row_top = selected_row.offset().top;
let scroll_limit = selected_row_top - viewport_info.visible_top;
if (scroll_limit < 0) {
// This shouldn't happen, but if we're off by a pixel or
// something, we can deal with it, and just warn.
blueslip.warn('Selected row appears too high on screen.');
scroll_limit = 0;
}
return scroll_limit;
},
_maybe_autoscroll: function (new_messages_height) {
// If we are near the bottom of our feed (the bottom is visible) and can
// scroll up without moving the pointer out of the viewport, do so, by
// up to the amount taken up by the new message.
//
// returns `true` if we need the user to scroll
const selected_row = this.selected_row();
const last_visible = rows.last_visible();
// Make sure we have a selected row and last visible row. (defensive)
if (!(selected_row && selected_row.length > 0 && last_visible)) {
return false;
}
if (new_messages_height <= 0) {
return false;
}
if (!activity.client_is_active) {
// Don't autoscroll if the window hasn't had focus
// recently. This in intended to help protect us from
// auto-scrolling downwards when the window is in the
// background and might be having some functionality
// throttled by modern Chrome's aggressive power-saving
// features.
blueslip.log("Suppressing scrolldown due to inactivity");
return false;
}
// do not scroll if there are any active popovers.
if (popovers.any_active()) {
// If a popover is active, then we are pretty sure the
// incoming message is not from the user themselves, so
// we don't need to tell users to scroll down.
return false;
}
const info = message_viewport.message_viewport_info();
const scroll_limit = this._scroll_limit(selected_row, info);