-
Notifications
You must be signed in to change notification settings - Fork 106
/
conversation.js
1258 lines (1175 loc) · 52.5 KB
/
conversation.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
/* ***** BEGIN LICENSE BLOCK *****
* Version: MPL 1.1/GPL 2.0/LGPL 2.1
*
* The contents of this file are subject to the Mozilla Public License Version
* 1.1 (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS" basis,
* WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
* for the specific language governing rights and limitations under the
* License.
*
* The Original Code is Thunderbird Conversations
*
* The Initial Developer of the Original Code is
* Jonathan Protzenko <jonathan.protzenko@gmail.com>
* Portions created by the Initial Developer are Copyright (C) 2010
* the Initial Developer. All Rights Reserved.
*
* Contributor(s):
*
* Alternatively, the contents of this file may be used under the terms of
* either the GNU General Public License Version 2 or later (the "GPL"), or
* the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
* in which case the provisions of the GPL or the LGPL are applicable instead
* of those above. If you wish to allow use of your version of this file only
* under the terms of either the GPL or the LGPL, and not to allow others to
* use your version of this file under the terms of the MPL, indicate your
* decision by deleting the provisions above and replace them with the notice
* and other provisions required by the GPL or the LGPL. If you do not delete
* the provisions above, a recipient may use your version of this file under
* the terms of any one of the MPL, the GPL or the LGPL.
*
* ***** END LICENSE BLOCK ***** */
"use strict";
var EXPORTED_SYMBOLS = ['Conversation']
const Ci = Components.interfaces;
const Cc = Components.classes;
const Cu = Components.utils;
const Cr = Components.results;
Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource:///modules/StringBundle.js"); // for StringBundle
Cu.import("resource:///modules/gloda/gloda.js");
Cu.import("resource://conversations/modules/log.js");
Cu.import("resource://conversations/modules/prefs.js");
Cu.import("resource://conversations/modules/stdlib/msgHdrUtils.js");
Cu.import("resource://conversations/modules/stdlib/misc.js");
Cu.import("resource://conversations/modules/message.js");
Cu.import("resource://conversations/modules/contact.js");
Cu.import("resource://conversations/modules/misc.js"); // for groupArray
Cu.import("resource://conversations/modules/hook.js");
let Log = setupLogging("Conversations.Conversation");
const kMsgDbHdr = 0;
const kMsgGloda = 1;
const kActionDoNothing = 0;
const kActionExpand = 1;
const kActionCollapse = 2;
const nsMsgViewIndex_None = 0xffffffff;
let strings = new StringBundle("chrome://conversations/locale/message.properties");
// The SignalManager class handles stuff related to spawing asynchronous
// requests and waiting for all of them to complete. Basic, but works well.
// Warning: sometimes yells at the developer.
let SignalManagerMixIn = {
// Because fetching snippets is possibly asynchronous (in the case of a
// MessageFromDbHdr), each message calls "signal" once it's done. After we've
// seen N signals pass by, we wait for the N+1-th signal that says that nodes
// have all been inserted into the DOM, and then we move on.
// Once more, we wait for N signals, because message loading is a
// asynchronous. Once we've done what's right for each message (expand,
// collapse, or do nothing), we do the final cleanup (mark as read, etc.).
_runOnceAfterNSignals: function (f, n) {
if (this._toRun !== null && this._toRun !== undefined)
Log.error("You failed to call signal enough times. Bad developer, bad! Go fix your code!");
this._toRun = [f, n+1];
try {
this._signal();
} catch (e) {
Log.error(e);
dumpCallStack(e);
}
},
// This is the helper function that each of the messages is supposed to call.
_signal: function _Conversation_signal() {
// This is normal, expanding a message after the conversation has been built
// will trigger a signal the first time. We can safely discard these.
if (!this._toRun)
return;
let [f, n] = this._toRun;
n--;
if (n == 0) {
this._toRun = null;
f();
} else {
this._toRun = [f, n];
}
},
}
// The Oracle just decides who to expand and who to scroll into view. As this is
// quite obscure logic and does not really belong to the main control flow, I
// thought it would be better to have it in a separate class
//
let OracleMixIn = {
// Go through all the messages and determine which one is going to be focused
// according to the prefs
_tellMeWhoToScroll: function _Conversation_tellMeWhoToScroll () {
// Determine which message is going to be scrolled into view
let needsScroll = -1;
if (this.scrollMode == Prefs.kScrollUnreadOrLast) {
needsScroll = this.messages.length - 1;
for (let i = 0; i < this.messages.length; ++i) {
if (!this.messages[i].message.read) {
needsScroll = i;
break;
}
}
} else if (this.scrollMode == Prefs.kScrollSelected) {
let gFolderDisplay = topMail3Pane(this).gFolderDisplay;
let key = msgHdrGetUri(gFolderDisplay.selectedMessage);
for (let i = 0; i < this.messages.length; ++i) {
if (this.messages[i].message._uri == key) {
needsScroll = i;
break;
}
}
// I can't see why we wouldn't break at some point in the loop below, but
// just in case...
if (needsScroll < 0) {
Log.error("kScrollSelected && didn't find the selected message");
needsScroll = this.messages.length - 1;
}
} else {
Log.assert(false, "Unknown value for kScroll* constant");
}
return needsScroll;
},
// Go through all the messages and for each one of them, give the expected
// action
_tellMeWhoToExpand: function _Conversation_tellMeWhoToExpand (aNeedsFocus) {
let actions = [];
let collapse = function _collapse (message) {
if (message.collapsed)
actions.push(kActionDoNothing);
else
actions.push(kActionCollapse);
};
let expand = function _expand (message) {
if (message.collapsed)
actions.push(kActionExpand);
else
actions.push(kActionDoNothing);
};
switch (Prefs["expand_who"]) {
case Prefs.kExpandAuto:
// In this mode, we scroll to the first unread message (or the last
// message if all messages are read), and we expand all unread messages
// + the last one (which will probably be unread as well).
if (this.scrollMode == Prefs.kScrollUnreadOrLast) {
for each (let [i, { message }] in Iterator(this.messages)) {
if (!message.read || i == this.messages.length - 1)
expand(message);
else
collapse(message);
}
// In this mode, we scroll to the selected message, and we only expand
// the selected message.
} else if (this.scrollMode == Prefs.kScrollSelected) {
for each (let [i, { message }] in Iterator(this.messages)) {
if (i == aNeedsFocus)
expand(message);
else
collapse(message);
}
} else {
Log.assert(false, "Unknown value for pref scroll_who");
}
break;
case Prefs.kExpandAll:
for each (let [, { message }] in Iterator(this.messages))
expand(message);
break;
case Prefs.kExpandNone:
for each (let [, { message }] in Iterator(this.messages))
collapse(message);
break;
default:
Log.assert(false, "Unknown value for pref expand_who");
}
return actions;
},
}
// -- Some helpers for our message type
// Get the message-id of a message, be it a msgHdr or a glodaMsg.
function getMessageId ({ type, message, msgHdr, glodaMsg }) {
if (type == kMsgGloda)
return glodaMsg.headerMessageID;
else if (type == kMsgDbHdr)
return msgHdr.messageId;
else
Log.error("Bad message type");
}
// Get the underlying msgHdr of a message. Might return undefined if Gloda
// remembers dead messages (and YES this happens).
function toMsgHdr ({ type, message, msgHdr, glodaMsg }) {
if (type == kMsgGloda)
return glodaMsg.folderMessage;
else if (type == kMsgDbHdr)
return msgHdr;
else
Log.error("Bad message type");
}
// Get a Date instance for the given message.
function msgDate ({ type, message, msgHdr, glodaMsg }) {
if (type == kMsgDbHdr)
return new Date(msgHdr.date/1000);
else if (type == kMsgGloda)
return new Date(glodaMsg.date);
else
Log.error("Bad message type");
}
function msgDebugColor (aMsg) {
let msgHdr = toMsgHdr(aMsg);
if (msgHdr) {
if (msgHdr.getUint32Property("pseudoHdr") == 1)
return Colors.yellow; // fake sent header
else
return Colors.blue; // real header
} else {
// red = no message header, shouldn't happen
return Colors.red;
}
}
function messageFromGlodaIfOffline (aSelf, aGlodaMsg, aDebug) {
let aMsgHdr = aGlodaMsg.folderMessage;
let needsLateAttachments =
!(aMsgHdr.folder instanceof Ci.nsIMsgLocalMailFolder) &&
!(aMsgHdr.folder.flags & Ci.nsMsgFolderFlags.Offline) || // online IMAP
aGlodaMsg.isEncrypted || // encrypted message
Prefs.extra_attachments; // user request
return {
type: kMsgGloda,
message: new MessageFromGloda(aSelf, aGlodaMsg, needsLateAttachments), // will fire signal when done
glodaMsg: aGlodaMsg,
msgHdr: null,
debug: aDebug,
};
}
function messageFromDbHdr (aSelf, aMsgHdr, aDebug) {
return {
type: kMsgDbHdr,
message: new MessageFromDbHdr(aSelf, aMsgHdr), // will run signal
msgHdr: aMsgHdr,
glodaMsg: null,
debug: aDebug,
};
}
function ViewWrapper(aConversation) {
this.mainWindow = topMail3Pane(aConversation);
// The trick is, if a thread is collapsed, this._initialSet contains all the
// messages in the thread. We want these to be selected. If a thread is
// expanded, we want messages which are in the current view to be selected.
// We cannot compare messages by message-id (they have the same!), we cannot
// compare them by messageKey (not reliable), but URLs should be enough.
this.byUri = {};
[this.byUri[msgHdrGetUri(x)] = true
for each ([, x] in Iterator(this.mainWindow.gFolderDisplay.selectedMessages))];
}
ViewWrapper.prototype = {
isInView: function _ViewWrapper_isInView(aMsg) {
if (this.mainWindow.gDBView) {
let msgHdr = toMsgHdr(aMsg);
if (!msgHdr)
return false;
let r =
(msgHdrGetUri(msgHdr) in this.byUri) ||
(this.mainWindow.gDBView.findIndexOfMsgHdr(msgHdr, false) != nsMsgViewIndex_None)
;
return r;
} else {
return false;
}
},
}
// -- The actual conversation object
// We maintain the invariant that, once the conversation is built, this.messages
// matches exactly the DOM nodes with class "message" inside this._domNode.
// So the i-th _message is also the i-th DOM node.
function Conversation(aWindow, aSelectedMessages, aScrollMode, aCounter) {
this._contactManager = new ContactManager();
this._window = aWindow;
// This is set by the monkey-patch which knows whether we were viewing a
// message inside a thread or viewing a closed thread.
this.scrollMode = aScrollMode;
// We have the COOL invariant that this._initialSet is a subset of
// [toMsgHdr(x) for each ([, x] in Iterator(this.messages))]
// This is actually trickier than it seems because of the different view modes
// and because we can't directly tell whether a message is in the view if
// it's under a collapsed thread. See the lengthy discussion in
// _filterOutDuplicates
// The invariant doesn't hold if the same message is present twice in the
// thread (like, you sent a message to yourself so it appears twice in your
// inbox that also searches sent folders). But we handle that case well.
this._initialSet = aSelectedMessages;
// === Our "message" composite type ==
//
// this.messages = [
// {
// type: kMsgGloda or kMsgDbHdr
// message: the Message instance (see message.js)
// msgHdr: non-null if type == kMsgDbHdr
// glodaMsg: non-null if type == kMsgGloda
// },
// ... (moar messages) ...
// ]
this.messages = [];
this.counter = aCounter; // RO
// The Gloda query, so that it's not collected.
this._query = null;
// The DOM node that holds all the messages.
this._domNode = null;
// Function provided by the monkey-patch to do cleanup
this._onComplete = null;
this.viewWrapper = null;
// Gloda conversation ID
this.id = null;
// Set to true by the monkey-patch once the conversation is fully built.
this.completed = false;
// Ok, interesting bit. Thunderbird has that non-strict threading thing, i.e.
// it will thread messages together if they have a "Green Llama in your car"
// "Re: Green Llama in your car" subject pattern, and EVEN THOUGH they do not
// have the correct References: header set.
// Until 2.0alpha2, what we would do is:
// - fetch the Gloda message collection,
// - pick the first Gloda message, get the message collection for its
// underlying conversation,
// - merge the results for the conversations with the initially selected set,
// - re-stream all other messages except for the first one, because we only
// have their nsIMsgDbHdr.
// That's sub-optimal, because we actually have the other message's Gloda
// representations at hand, it's just that because the headers do not set the
// threading, gloda hasn't attached them to the first message.
// The solution is to merge the initial set of messages, the gloda messages
// corresponding to the intermediate query, and the initially selected
// messages...
this._intermediateResults = [];
// For timing purposes
this.t0 = Date.now();
}
Conversation.prototype = {
// Before the Gloda query returns, the user might change selection. Don't
// output a conversation unless we're really sure the user hasn't changed his
// mind.
// XXX this logic is weird. Shouldn't we just compare a list of URLs?
_selectionChanged: function _Conversation_selectionChanged () {
let gFolderDisplay = topMail3Pane(this).gFolderDisplay;
let messageIds = [x.messageId for each ([, x] in Iterator(this._initialSet))];
return
!gFolderDisplay.selectedMessage ||
!messageIds.some(function (x) x == gFolderDisplay.selectedMessage.messageId);
},
// This function contains the logic that runs a Gloda query on the initial set
// of messages in order to obtain the conversation. It takes care of filling
// this.messages with the right set of messages, and then moves on to
// _outputMessages.
_fetchMessages: function _Conversation_fetchMessages () {
let self = this;
// This is a "classic query", i.e. the one we use all the time: just obtain
// a GlodaMessage for the selected message headers, and then pick the
// first one, get its underlying GlodaConversation object, and then ask for
// the GlodaConversation's messages.
let classicQuery = function () {
Gloda.getMessageCollectionForHeaders(self._initialSet, {
onItemsAdded: function (aItems) {
if (!aItems.length) {
Log.warn("Warning: gloda query returned no messages");
self._getReady(self._initialSet.length + 1);
// M = msgHdr, I = Initial, NG = there was no gloda query
// will run signal
self.messages = [messageFromDbHdr(self, msgHdr, "MI+NG")
for each ([, msgHdr] in Iterator(self._initialSet))];
self._signal();
} else {
self._intermediateResults = aItems;
self._query = aItems[0].conversation.getMessagesCollection(self, true);
}
},
onItemsModified: function () {},
onItemsRemoved: function () {},
onQueryCompleted: function (aCollection) {},
}, null);
};
// This is a self-service case. GitHub and GetSatisfaction do not thread
// emails related to a common topic, so we're doing it for them. Each
// message is in its own conversation: we get all conversations which sport
// this exact topic, and then, for each conversation, we get its only
// message.
// All the messages are gathered in fusionItems, which is then used to call
// self.onQueryCompleted.
let fusionCount = -1;
let fusionItems = [];
let fusionTop = function () {
fusionCount--;
if (fusionCount == 0) {
if (fusionItems.length)
self.onQueryCompleted({ items: fusionItems });
else
classicQuery();
}
};
let fusionListener = {
onItemsAdded: function (aItems) {},
onItemsModified: function () {},
onItemsRemoved: function () {},
onQueryCompleted: function (aCollection) {
Log.debug("Fusionning", aCollection.items.length, "more items");
fusionItems = fusionItems.concat(aCollection.items);
fusionTop();
}
};
// This is the Gloda query to find out about conversations for a given
// subject. This relies on our subject attribute provider found in
// modules/plugins/glodaAttrProviders.js
let subjectQuery = function (subject) {
let query = Gloda.newQuery(Gloda.NOUN_CONVERSATION);
query.subject(subject);
query.getCollection({
onItemsAdded: function (aItems) {},
onItemsModified: function () {},
onItemsRemoved: function () {},
onQueryCompleted: function (aCollection) {
Log.debug("Custom query found", aCollection.items.length, "items");
if (aCollection.items.length) {
for each (let [k, v] in Iterator(aCollection.items)) {
fusionCount++;
v.getMessagesCollection(fusionListener);
}
}
fusionTop();
},
});
};
let firstEmail = this._initialSet.length == 1 && parseMimeLine(this._initialSet[0].author)[0].email;
switch (firstEmail) {
case "noreply.mozilla_messaging@getsatisfaction.com": {
// Special-casing for Roland and his GetSatisfaction emails.
let subject = this._initialSet[0].mime2DecodedSubject;
subject = subject.replace(/New (reply|comment): /, "");
Log.debug("Found a GetSatisfaction message, searching for subject:", subject);
fusionCount = 3;
subjectQuery("New reply: "+subject);
subjectQuery("New comment: "+subject);
subjectQuery("New question: "+subject);
break;
}
case "noreply@github.com": {
// Special-casing for me and my GitHub emails
let subject = this._initialSet[0].mime2DecodedSubject;
Log.debug("Found a GitHub message, searching for subject:", subject);
fusionCount = 1;
subjectQuery(subject);
break;
}
default:
// This is the regular case.
classicQuery();
}
},
// This is the observer for the second Gloda query, the one that returns a
// conversation.
onItemsAdded: function (aItems) {
// The first batch of messages will be treated in onQueryCompleted, this
// handler is only interested in subsequent messages.
// If we are an old conversation that hasn't been collected, don't go
// polluting some other conversation!
if (!this.completed || this._window.Conversations.counter != this.counter)
return;
// That's XPConnect bug 547088, so remove the setTimeout when it's fixed and
// bump the version requirements in install.rdf.template (might be fixed in
// time for Gecko 42, if we're lucky)
let self = this;
this._window.setTimeout(function _Conversation_onQueryCompleted_bug547088 () {
try {
// The MessageFromGloda constructor cannot work with gloda messages that
// don't have a message header
aItems = aItems.filter(function (glodaMsg) glodaMsg.folderMessage);
// We want at least all messages from the Gloda collection
// will fire signal when done
let messages = [messageFromGlodaIfOffline(self, glodaMsg, "GA")
for each ([, glodaMsg] in Iterator(aItems))];
Log.debug("onItemsAdded",
[msgDebugColor(x) + x.debug + " " + getMessageId(x)
for each (x in messages)].join(" "), Colors.default);
Log.debug(self.messages.length, "messages already in the conversation");
// The message ids we already hold.
let messageIds = {};
// Remove all messages which don't have a msgHdr anymore
for each (let [, message] in Iterator(self.messages)) {
if (!toMsgHdr(message)) {
Log.debug("Removing a message with no msgHdr");
self.removeMessage(message.message);
}
}
[messageIds[getMessageId(m)] = !toMsgHdr(m) || msgHdrIsDraft(toMsgHdr(m))
for each ([i, m] in Iterator(self.messages))];
// If we've got a new header for a message that we used to know as a
// draft, that means either the draft has been updated (autosave), or
// the draft was actually sent. In both cases, we want to remove the old
// draft.
for each (let [, x] in Iterator(messages)) {
let newMessageId = getMessageId(x);
if (messageIds[newMessageId]) {
Log.debug("Removing a draft...");
let draft = self.messages.filter(function (y)
getMessageId(y) == newMessageId
)[0];
self.removeMessage(draft.message);
delete messageIds[newMessageId];
}
}
// Don't add a message if we already have it.
messages = messages.filter(function (x) !(getMessageId(x) in messageIds));
// Sort all the messages according to the date so that they are inserted
// in the right order.
let compare = function (m1, m2) msgDate(m1) - msgDate(m2);
// We can sort now because we don't need the Message instance to be
// fully created to get the date of a message.
messages.sort(compare);
if (messages.length)
self.appendMessages(messages);
} catch (e) {
Log.error(e);
dumpCallStack(e);
}
}, 0);
},
onItemsModified: function _Conversation_onItemsModified (aItems) {
Log.debug("Updating conversation", this.counter, "global state...");
if (!this.completed)
return;
// This updates conversation-wide buttons (the conversation "read" status,
// for instance).
this._updateConversationButtons();
// Now we forward individual updates to each messages (e.g. tags, starred)
let byMessageId = {};
[byMessageId[getMessageId(x)] = x.message
for each ([, x] in Iterator(this.messages))];
for each (let [, glodaMsg] in Iterator(aItems)) {
// If you see big failures coming from the lines below, don't worry: it's
// just that an old conversation hasn't been GC'd and still receives
// notifications from Gloda. However, its DOM nodes are long gone, so the
// call to onAttributesChanged fails.
let message = byMessageId[glodaMsg.headerMessageID];
if (message)
message.onAttributesChanged(glodaMsg);
}
},
onItemsRemoved: function (aItems) {
Log.debug("Updating conversation", this.counter, "global state...");
if (!this.completed)
return;
// We (should) have the invariant that a conversation only has one message
// with a given Message-Id.
let byMessageId = {};
[byMessageId[getMessageId(x)] = x.message
for each ([, x] in Iterator(this.messages))];
for each (let [, glodaMsg] in Iterator(aItems)) {
let msgId = glodaMsg.headerMessageID;
if ((msgId in byMessageId) && byMessageId[msgId]._msgHdr.messageKey == glodaMsg.messageKey)
this.removeMessage(byMessageId[msgId]);
}
this._updateConversationButtons();
},
onQueryCompleted: function _Conversation_onQueryCompleted (aCollection) {
// We'll receive this notification waaaay too many times, so if we've
// already settled on a set of messages, let onItemsAdded handle the rest.
// This is just for the initial building of the conversation.
if (this.messages.length)
return;
// Report!
let delta = Date.now() - this.t0;
try {
let h = Services.telemetry.getHistogramById("THUNDERBIRD_CONVERSATIONS_TIME_TO_2ND_GLODA_QUERY_MS");
h.add(delta);
} catch (e) {
Log.debug("Unable to report telemetry", e);
}
// That's XPConnect bug 547088, so remove the setTimeout when it's fixed and
// bump the version requirements in install.rdf.template (might be fixed in
// time for Gecko 42, if we're lucky)
let self = this;
this._window.setTimeout(function _Conversation_onQueryCompleted_bug547088 () {
try {
// The MessageFromGloda constructor cannot work with gloda messages that
// don't have a message header
aCollection.items = aCollection.items.filter(function (glodaMsg) glodaMsg.folderMessage);
// In most cases, all messages share the same conversation id (i.e. they
// all belong to the same gloda conversations). There are rare cases
// where we lie about this: non-strictly threaded messages regrouped
// together, special queries for GitHub and GetSatisfaction, etc..
// Don't really knows what happens in those cases.
// I've seen cases where we do have intermediate results for the message
// header but the final collection after filtering has zero items.
if (aCollection.items.length)
self.id = aCollection.items[0].conversation.id;
// Beware, some bad things might have happened in the meanwhile...
self._initialSet =
self._initialSet.filter(function (msgHdr) msgHdr && msgHdr.folder.msgDatabase.ContainsKey(msgHdr.messageKey));
self._intermediateResults =
self._intermediateResults.filter(function (glodaMsg) glodaMsg.folderMessage);
// When the right number of signals has been fired, move on...
self._getReady(aCollection.items.length
+ self._intermediateResults.length
+ self._initialSet.length
+ 1
);
// We want at least all messages from the Gloda collection + all
// messages from the intermediate set (see rationale in the
// initialization of this._intermediateResults).
// will fire signal when done
self.messages = [messageFromGlodaIfOffline(self, glodaMsg, "GF")
for each ([, glodaMsg] in Iterator(aCollection.items))
].concat([messageFromGlodaIfOffline(self, glodaMsg, "GM")
for each ([, glodaMsg] in Iterator(self._intermediateResults))
if (glodaMsg.folderMessage) // be paranoid
]);
// Here's the message IDs we know
let messageIds = {};
[messageIds[getMessageId(m)] = true
for each ([i, m] in Iterator(self.messages))];
// But Gloda might also miss some message headers
for each (let [, msgHdr] in Iterator(self._initialSet)) {
// Although _filterOutDuplicates is called eventually, don't uselessly
// create messages. The typical use case is when the user has a
// conversation selected, a new message arrives in that conversation,
// and we get called immediately. So there's only one message gloda
// hasn't indexed yet...
// The extra check should help for cases where the fake header that
// represents the sent message has been replaced in the meanwhile
// with the real header...
if (!(msgHdr.messageId in messageIds)) {
// Will call signal when done.
self.messages.push(messageFromDbHdr(self, msgHdr, "MI+G"));
} else {
self._signal();
}
}
// Sort all the messages according to the date so that they are inserted
// in the right order.
let compare = function (m1, m2) msgDate(m1) - msgDate(m2);
// We can sort now because we don't need the Message instance to be
// fully created to get the date of a message.
self.messages.sort(compare);
// Move on! (Actually, will move on when all messages are ready)
self._signal();
} catch (e) {
Log.error(e);
dumpCallStack(e);
}
}, 0);
},
// This is the function that waits for everyone to be ready (that was a useful
// comment)
_getReady: function _Conversation_getReady(n) {
let self = this;
this._runOnceAfterNSignals(function () {
self._filterOutDuplicates();
self._outputMessages()
}, n);
},
// This is a core function. It decides which messages to keep and which
// messages to filter out. Because Gloda might return many copies of a single
// message, each in a different folder, we use the messageId as the key.
// Then, for different candidates for a single message id, we need to pick the
// best one, giving precedence to those which are selected and/or in the
// current view.
_filterOutDuplicates: function _Conversation_filterOutDuplicates () {
let messages = this.messages;
let mainWindow = topMail3Pane(this);
this.viewWrapper = new ViewWrapper(this);
// Wicked cases, when we're asked to display a draft that's half-saved...
messages = messages.filter(function (x) (toMsgHdr(x) && getMessageId(x)));
messages = groupArray(this.messages, getMessageId);
// The message that's selected has the highest priority to avoid
// inconsistencies in case multiple identical messages are present in the
// same thread (e.g. message from to me).
let self = this;
let selectRightMessage = function (aSimilarMessages) {
let findForCriterion = function (aCriterion) {
let bestChoice;
for each (let [i, msg] in Iterator(aSimilarMessages)) {
if (!toMsgHdr(msg))
continue;
if (aCriterion(msg)) {
bestChoice = msg;
break;
}
}
return bestChoice;
};
let r =
findForCriterion(function (aMsg) self.viewWrapper.isInView(aMsg)) ||
findForCriterion(function (aMsg) msgHdrIsInbox(toMsgHdr(aMsg))) ||
findForCriterion(function (aMsg) msgHdrIsSent(toMsgHdr(aMsg))) ||
findForCriterion(function (aMsg) !msgHdrIsArchive(toMsgHdr(aMsg))) ||
aSimilarMessages[0]
;
return r;
}
// Select right message will try to pick the message that has an
// existing msgHdr.
messages = [selectRightMessage(group)
for each ([i, group] in Iterator(messages))];
// But sometimes it just fails, and gloda remembers dead messages...
messages = messages.filter(toMsgHdr);
this.messages = messages;
},
/**
* Remove a given message from the conversation.
* @param aMessage {Message} a Message as in modules/message.js
*/
removeMessage: function _Conversation_removeMessage (aMessage) {
// Move the quick reply to the previous message
let i = [x.message for each ([, x] in Iterator(this.messages))].indexOf(aMessage);
Log.debug("Removing message", i);
if (i == this.messages.length - 1 && this.messages.length > 1) {
let $ = this._htmlPane.contentWindow.$;
$(".message:last").prev().append($(".quickReply"));
// Re-enable to reply dropdown for the message that previously had the
// quick reply.
$(".messageFooter").removeClass("hide");
if ($(".quickReply").hasClass("expand")) {
$(".message:last .messageFooter").addClass("hide");
}
}
this.messages = this.messages.filter(function (x) x.message != aMessage);
this._initialSet = this._initialSet.filter(function (x) x.message != aMessage);
this._domNode.removeChild(aMessage._domNode);
},
// If a new conversation was launched, and that conversation finds out it can
// reuse us, it will call this method with the set of messages to append at the
// end of this conversation. This only works if the new messages arrive at
// the end of the conversation, I don't support the pathological case of new
// messages arriving in the middle of the conversation.
appendMessages: function _Conversation_appendMessages (aMessages) {
// This is normal, the stupid folder tree view often reflows the
// whole thing and asks for a new ThreadSummary but the user hasn't
// actually changed selections.
if (aMessages.length) {
Log.debug("Appending",
[msgDebugColor(x) + x.debug for each (x in aMessages)].join(" "), Colors.default);
// All your messages are belong to us. This is especially important so
// that contacts query the right _contactManager through their parent
// Message.
[(x.message._conversation = this) for each ([, x] in Iterator(aMessages))];
this.messages = this.messages.concat(aMessages);
let $ = this._htmlPane.contentWindow.$;
for each (let i in range(0, aMessages.length)) {
let oldMsg;
if (i == 0) {
if (this.messages.length)
oldMsg = this.messages[this.messages.length - 1].message;
else
oldMsg = null;
} else {
oldMsg = aMessages[i-1].message;
}
let msg = aMessages[i].message;
msg.updateTmplData(oldMsg);
}
// Update initialPosition
for each (let i in range(this.messages.length - aMessages.length, this.messages.length)) {
this.messages[i].message.initialPosition = i;
}
let tmplData = [m.message.toTmplData(false)
for each ([_i, m] in Iterator(aMessages))];
let w = this._htmlPane.contentWindow;
w.markReadInView.disable();
$("#messageTemplate").tmpl(tmplData).appendTo($(this._domNode));
// Important: don't forget to move the quick reply part into the last
// message.
$(".quickReply").appendTo($(".message:last"));
// Re-enable to reply dropdown for the message that previously had the
// quick reply.
$(".messageFooter").removeClass("hide");
if ($(".quickReply").hasClass("expand")) {
$(".message:last .messageFooter").addClass("hide");
}
// Notify each message that it's been added to the DOM and that it can do
// event registration and stuff...
let domNodes = this._domNode.getElementsByClassName(Message.prototype.cssClass);
for each (let i in range(this.messages.length - aMessages.length, this.messages.length)) {
this.messages[i].message.onAddedToDom(domNodes[i]);
domNodes[i].setAttribute("tabindex", (i+2)+"");
}
}
// Don't forget to update the conversation buttons, even if we have no new
// messages: the reflow might be because some message became unread or
// whatever.
try {
this._updateConversationButtons();
} catch (e) {
Log.warn("Failed to update the conversation buttons", e);
dumpCallStack(e);
}
// Re-do the expand/collapse + scroll to the right node stuff. What this
// means is if: if we just added new messages, don't touch the other ones,
// and expand/collapse only the newer messages. If we have no new messages,
// we probably have a different selection in the thread pane, which means we
// have to redo the expand/collapse.
if (aMessages.length)
this._expandAndScroll(this.messages.length - aMessages.length);
else
this._expandAndScroll();
// Update the folder tags, maybe we were called because we changed folders
this.viewWrapper = new ViewWrapper(this);
[m.message.inView = this.viewWrapper.isInView(m)
for each ([, m] in Iterator(this.messages))];
},
// Once we're confident our set of messages is the right one, we actually
// start outputting them inside the DOM element we were given.
_outputMessages: function _Conversation_outputMessages () {
// XXX I think this test is still valid because of the thread summary
// stabilization interval (we might have changed selection and still be
// waiting to fire the new conversation).
if (this._selectionChanged()) {
Log.debug("Selection changed, aborting...");
return;
}
// In some pathological cases, the folder tree view will fire two consecutive
// thread summaries very fast. This will MITIGATE race conditions, not solve
// them. To solve them, we would need to make sure the two lines below are
// atomic.
// This happens sometimes for drafts, a conversation is fired for the old
// thread, a message in the thread is replaced, a new conversation is
// fired. If the old conversation is conversation #2, and the new one is
// conversation #3, then #3 succeeds and then #2 succeeds. In that case,
// #2 gives up at that point.
// The invariant is that if one conversation has been fired while we were
// fetching our messages, we give up, which implies that #3's output takes
// precedence. If #3 decided to reuse an old conversation, it necessarily
// reused conversation #1, because currentConversation is only set when a
// conversation reaches completion (and #2 never reaches completion).
// I hope I will understand this when I read it again in a few days.
if (this._window.Conversations.counter != this.counter) {
Log.debug("Race condition,", this.counter, "dying for", this._window.Conversations.counter);
return;
}
// Try to reuse the previous conversation if possible
if (this._window.Conversations.currentConversation) {
let currentMsgSet = this._window.Conversations.currentConversation.messages;
// We gotta use URIs, because using Message-IDs can create inconsistencies
// when different messages with the same Message-ID are present in the
// current, expanded thread (breaks the invariant that the selected
// message is also the one that's in this.messages).
// The extra check on valid msgHdrs is required, because some messages
// might have been moved / have disappeared in the meanwhile, and if we
// throw an exception here, we're fucked, and we can't recover ever,
// because every test trying to determine whether we can recycle will end
// up running over the buggy set of messages.
let currentMsgUris = [msgHdrGetUri(toMsgHdr(x))
for each ([, x] in Iterator(currentMsgSet))
if (toMsgHdr(x))];
// Is a1 a prefix of a2? (I wish JS had pattern matching!)
let isPrefix = function _isPrefix (a1, a2) {
if (!a1.length) {
return [true, a2];
} else if (a1.length && !a2.length) {
return [false, null];
} else {
let hd1 = a1[0];
let hd2 = a2[0];
if (hd1 == hd2)
return isPrefix(a1.slice(1, a1.length), a2.slice(1, a2.length));
else
return [false, null];
}
};
let myMsgUris = [msgHdrGetUri(toMsgHdr(x))
for each ([, x] in Iterator(this.messages))
if (toMsgHdr(x))];
let [shouldRecycle, _whichMessageUris] = isPrefix(currentMsgUris, myMsgUris);
// Ok, some explanation needed. How can this possibly happen?
// - Click on a conversation
// - Conversation is built, becomes the global current conversation
// - The message takes forever to stream (happens...)
// - User gets fed up, picks another conversation
// - Bang! Current conversation has no messages.
// Beware, if the previous conversation's messages have been deleted, we
// need to test for currentMsgUri's length, which removes dead msgHdrs,
// not just currentMsgset.
if (currentMsgUris.length == 0)
shouldRecycle = false;
// Be super-conservative (but I fail to see how we could possibly end up
// in a different situation → famous last words): we can recycle the
// conversation only if there's one draft in it and it's the last message
// in the conversation.
let drafts = currentMsgSet.filter(function (x)
!toMsgHdr(x) || msgHdrIsDraft(toMsgHdr(x))
);
if (drafts.length) {
if (drafts.length > 1)
shouldRecycle = false;
else
shouldRecycle = shouldRecycle
&& (currentMsgSet.indexOf(drafts[0]) == currentMsgSet.length - 1);
Log.debug("Found drafts, recycling?", shouldRecycle);
}
if (shouldRecycle) {
// NB: we get here even if there's 0 new messages, understood?
// Just get the extra messages
let whichMessages = this.messages.slice(currentMsgSet.length, this.messages.length);
// So the deal with drafts is a little bit simpler here, because we
// don't know which drafts are new, and which are not...
// - this.messages in the NEW message set
// - currentMsgSet =
// this._window.Conversations.currentConversation.messages is the OLD
// set of messages
// - whichMessages is the set of messages we're about to append
for each (let [, x] in Iterator(currentMsgSet)) {
if (!toMsgHdr(x)) {
Log.debug("Discarding null msgHdr");
// Not much we can do here... since that message hasn't been taken
// into account earlier (see if (toMsgHdr(x))), if we have a
// replacement for it, it's already in "whichMessages".
this._window.Conversations.currentConversation.removeMessage(x.message);
} else if (msgHdrIsDraft(toMsgHdr(x))) {
// 20110801 XXX this codepath is not tested (but you get the idea)
// because I don't know how to possibly trigger it.
Log.debug("Replacing draft...");
this._window.Conversations.currentConversation.removeMessage(x.message);
let uri = msgHdrGetUri(toMsgHdr(x));
// Find the replacement message, and move it back into the list of
// messages we have to append to the old conversation.
let correspondingMessage =
this.messages.filter(function (x) (msgHdrGetUri(toMsgHdr(x)) == uri))[0];
whichMessages.push(correspondingMessage);
}
}
let compare = function (m1, m2) msgDate(m1) - msgDate(m2);