-
Notifications
You must be signed in to change notification settings - Fork 40
/
UnclosedRequestReview.user.js
6408 lines (6064 loc) · 349 KB
/
UnclosedRequestReview.user.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
// ==UserScript==
// @name Unclosed Request Review Script
// @namespace http://github.com/Tiny-Giant
// @version 2.1.1
// @description Adds buttons to the chat buttons controls; clicking on the button takes you to the recent unclosed close vote request, or delete request query, then it scans the results and displays them along with additional information.
// @author @TinyGiant @rene @mogsdad @Makyen
// @include /^https?://chat\.stackoverflow\.com/rooms/(?:41570|90230|126195|68414|111347|126814|123602|167908|167826|253110|253305)(?:\b.*$|$)/
// @include /^https?://chat\.stackoverflow\.com/search.*[?&]room=(?:41570|90230|126195|68414|111347|126814|123602|167908|167826|253110|253305)(?:\b.*$|$)/
// @include /^https?://chat\.stackoverflow\.com/transcript/(?:41570|90230|126195|68414|111347|126814|123602|167908|167826|253110|253305)(?:\b.*$|$)/
// @include /^https?://chat\.stackoverflow\.com/transcript/.*$/
// @include /^https?://chat\.stackoverflow\.com/users/.*$/
// @require https://github.com/SO-Close-Vote-Reviewers/UserScripts/raw/master/gm4-polyfill.js
// @downloadURL https://github.com/SO-Close-Vote-Reviewers/UserScripts/raw/master/UnclosedRequestReview.user.js
// @updateURL https://github.com/SO-Close-Vote-Reviewers/UserScripts/raw/master/UnclosedRequestReview.user.js
// @grant GM_openInTab
// @grant GM.openInTab
// ==/UserScript==
/* jshint -W097 */
/* jshint -W107 */
/* jshint esnext:true */
/* globals CHAT */
(function() {
'use strict';
if (window !== window.top) {
//If this is running in an iframe, then we do nothing.
return;
}
if (window.location.pathname.indexOf('/transcript/message') > -1) {
//This is a transcript without an indicator in the URL that it is a room for which we should be active.
if (document.title.indexOf('SO Close Vote Reviewers') === -1 &&
document.title.indexOf('SOCVR Request Graveyard') === -1 &&
document.title.indexOf('SOCVR /dev/null') === -1 &&
document.title.indexOf('SOCVR Testing Facility') === -1 &&
document.title.indexOf('SOBotics') === -1 &&
document.title.indexOf('CV-PLS old questions') === -1
) {
//The script should not be active on this page.
return;
}
}
const NUMBER_UI_GROUPS = 8;
const LSPREFIX = 'unclosedRequestReview-';
const MAX_DAYS_TO_REMEMBER_VISITED_LINKS = 7;
const MAX_BACKOFF_TIMER_SECONDS = 120;
const MESSAGE_THROTTLE_PROCESSING_ACTIVE = -9999;
const MESSAGE_PROCESSING_DELAY_FOR_MESSAGE_VALID = 1000;
const MESSAGE_PROCESSING_DELAYED_ATTEMPTS = 5;
const MESSAGE_PROCESSING_ASSUMED_MAXIMUM_PROCESSING_SECONDS = 10;
const DEFAULT_MINIMUM_UPDATE_DELAY = 5; // (seconds)
const DEFAULT_AUTO_UPDATE_RATE = 5; // (minutes)
const MESSAGE_PROCESSING_REQUEST_TYPES = ['questions', 'answers', 'posts'];
const UI_CONFIG_DEL_PAGES = 'uiConfigDel';
const UI_CONFIG_CV_PAGES = 'uiConfigCv';
const UI_CONFIG_REOPEN_PAGES = 'uiConfigReopen';
const months3charLowerCase = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const weekdays3charLowerCase = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
/* The following code for detecting browsers is from my answer at:
* http://stackoverflow.com/a/41820692/3773011
* which is based on code from:
* http://stackoverflow.com/a/9851769/3773011
*/
//Opera 8.0+ (tested on Opera 42.0)
const isOpera = (!!window.opr && !!window.opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0;
//Firefox 1.0+ (tested on Firefox 45 - 53)
const isFirefox = typeof InstallTrigger !== 'undefined';
//Internet Explorer 6-11
// Untested on IE (of course). Here because it shows some logic for isEdge.
const isIE = /*@cc_on!@*/false || !!document.documentMode;
//Edge 20+ (tested on Edge 38.14393.0.0)
const isEdge = !isIE && !!window.StyleMedia;
//The other browsers are trying to be more like Chrome, so picking
// capabilities which are in Chrome, but not in others, is a moving
// target. Just default to Chrome if none of the others is detected.
const isChrome = !isOpera && !isFirefox && !isIE && !isEdge;
// Blink engine detection (tested on Chrome 55.0.2883.87 and Opera 42.0)
const isBlink = (isChrome || isOpera) && !!window.CSS; // eslint-disable-line no-unused-vars
//Various objects to hold functions and current state.
const funcs = {
visited: {},
config: {},
backoff: {},
ui: {},
mmo: {},
mp: {},
orSearch: {},
};
//Current state information
const config = {
ui: {},
nonUi: {},
backoff: {},
};
//Global backoff timer, which is synced between tabs.
const backoffTimer = {
timer: 0,
isPrimary: false,
timeActivated: 0,
milliseconds: 0,
};
//State for message processing
const messageProcessing = {
throttle: 0,
throttleTimeActivated: 0,
isRequested: false,
interval: 0,
mostRecentRequestInfoTime: 0,
};
//State information for adding OR functionality to searches.
const orSearch = {
framesToProces: 0,
maxPages: 0,
};
//Update RegExp from list here: https://github.com/AWegnerGitHub/SE_Zephyr_VoteRequest_bot
const pleaseRegExText = '(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)';
const requestTagRegExStandAlonePermittedTags = '(?:spam|off?en[cs]ive|abb?u[cs]ive|(?:re)?-?flag(?:-?(?:naa|spam|off?en[cs]ive|rude|abb?u[cs]ive))|(?:(?:naa|spam|off?en[cs]ive|rude|abb?u[cs]ive)-?(?:re)?-?flag))'; //spam is an actual SO tag, so we're going to need to deal with that.
const requestTagRequirePleaseRegExText = '(?:cv|(?:un-?)?(?:del(?:v)?|dv|delete)|rov?|re-?open|app?rove?|reject|rv|review|(?:re)?-?flag|nuke?|spam|off?en[cs]ive|naa|abbu[cs]ive)';
const requestTagRequirePleaseOrStandAloneRegExText = '(?:' + requestTagRequirePleaseRegExText + '|' + requestTagRegExStandAlonePermittedTags + ')';
const requestTagRequirePleasePleaseFirstRegExText = '(?:' + pleaseRegExText + '[-.]?' + requestTagRequirePleaseOrStandAloneRegExText + ')';
const requestTagRequirePleasePleaseLastRegExText = '(?:' + requestTagRequirePleaseOrStandAloneRegExText + '[-.]?' + pleaseRegExText + ')';
const requestTagRegExText = '\\b(?:' + requestTagRegExStandAlonePermittedTags + '|' + requestTagRequirePleasePleaseFirstRegExText + '|' + requestTagRequirePleasePleaseLastRegExText + ')\\b';
//Current, now older, result: https://regex101.com/r/dPtRnS/3
/*Need to update with (?:re\W?)? for flags
\b(?:(?:spam|off?ensive|abb?usive|flag(?:-?(?:naa|spam|off?ensive|rude|abb?usive))|(?:(?:naa|spam|off?ensive|rude|abb?usive)-?flag))|(?:(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)-(?:(?:cv|(?:un)?(?:del(?:v)?|dv|delete)|rov?|reopen|app?rove?|reject|rv|review|flag|nuke?|spam|off?ensive|naa|abbusive)|(?:spam|off?ensive|abb?usive|flag(?:-?(?:naa|spam|off?ensive|rude|abb?usive))|(?:(?:naa|spam|off?ensive|rude|abb?usive)-?flag))))|(?:(?:(?:cv|(?:un)?(?:del(?:v)?|dv|delete)|rov?|reopen|app?rove?|reject|rv|review|flag|nuke?|spam|off?ensive|naa|abbusive)|(?:spam|off?ensive|abb?usive|flag(?:-?(?:naa|spam|off?ensive|rude|abb?usive))|(?:(?:naa|spam|off?ensive|rude|abb?usive)-?flag)))-(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)))\b
*/
//Used to look in text to see if there are any messages which contain the action tag as text.
//Only a limited set of action types are recognized in text format.
const getActionTagInTextRegEx = /(?:\[(?:tag\W?)?(?:cv|(?:un-?)?del(?:ete|v)?|re-?open)-[^\]]*\])/;
//Detect the type of request based on tag text content.
const tagsInTextContentRegExes = {
delete: /\b(?:delv?|dv|delete)(?:pls)?\b/i,
undelete: /\b(?:un?-?delv?|un?-?dv|un?-?delete)(?:pls)?\b/i,
close: /\b(?:cv)(?:pls)?\b/i,
reopen: /\b(?:re-?open)(?:pls)?\b/i,
spam: /\bspam\b/i,
offensive: /\b(?:off?en[cs]ive|rude|abb?u[cs]ive)\b/i,
flag: /\b(?:re)?-?flag-?(?:pl(?:ease|s|z)|p.?[sz]|.?l[sz]|pl.?|.pl[sz]|p.l[sz]|pl.[sz]|pl[sz].)?\b/i,
reject: /\b(?:reject|review)(?:pls)?\b/i,
//20k+ tags
tag20k: /^(?:20k\+?(?:-only)?)$/i,
tagN0k: /^(?:\d0k\+?(?:-only)?)$/i,
request: new RegExp(requestTagRegExText, 'i'),
};
//The extra escapes in RegExp are due to bugs in the syntax highlighter in an editor. They are only there because it helps make the syntax highlighting not be messed up.
const getQuestionIdFromURLRegEx = /(?:^|[\s"])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:q[^\/]*|posts)\/+(\d+)/g; // eslint-disable-line no-useless-escape
//https://regex101.com/r/QzH8Jf/2
const getSOQuestionIdFfromURLButNotIfAnswerRegEx = /(?:^|[\s"(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:q[^\/]*)\/+(\d+)(?:(?:\/[^#\s]*)#?)?(?:$|[\s")])/g; // eslint-disable-line no-useless-escape
//XXX Temp continue to use above variable name until other uses resolved.
const getSOQuestionIdFfromURLNotPostsNotAnswerRegEx = getSOQuestionIdFfromURLButNotIfAnswerRegEx;
//https://regex101.com/r/w2wQoC/1/
//https://regex101.com/r/SMVJv6/3/
const getSOAnswerIdFfromURLRegExes = [
/(?:^|[\s"(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:a[^\/]*)\/+(\d+)(?:\s*|\/[^/#]*\/?\d*\s*)(?:$|[\s")])/g, // eslint-disable-line no-useless-escape
/(?:^|[\s"'(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:q[^\/]*|posts)[^\s#]*#(\d+)(?:$|[\s"')])/g, // eslint-disable-line no-useless-escape
];
const getSOPostIdFfromURLButNotIfAnswerRegEx = /(?:^|[\s"(])(?:(?:https?:)?(?:(?:\/\/)?(?:www\.|\/\/)?stackoverflow\.com\/))(?:posts)\/+(\d+)(?:\s*|\/[^\/#]*\/?\d*\s*)(?:\s|$|[\s")])/g; // eslint-disable-line no-useless-escape
const getSOQuestionOrAnswerIdFfromURLRegExes = [getSOQuestionIdFfromURLNotPostsNotAnswerRegEx].concat(getSOAnswerIdFfromURLRegExes);
//Some constants which it helps to have some functions in order to determine
const isChat = window.location.pathname.indexOf('/rooms/') === 0;
const isSearch = window.location.pathname === '/search';
const isTranscript = window.location.pathname.indexOf('/transcript') === 0;
const isUserPage = window.location.pathname.indexOf('/users') === 0;
var uiConfigStorage;
//Functions needed on both the chat page and the search page
//Utility functions
funcs.executeInPage = function(functionToRunInPage, leaveInPage, id) { // + any additional JSON-ifiable arguments for functionToRunInPage
//Execute a function in the page context.
// Any additional arguments passed to this function are passed into the page to the
// functionToRunInPage.
// Such arguments must be Object, Array, functions, RegExp,
// Date, and/or other primitives (Boolean, null, undefined,
// Number, String, but not Symbol). Circular references are
// not supported. Prototypes are not copied.
// Using () => doesn't set arguments, so can't use it to define this function.
// This has to be done without jQuery, as jQuery creates the script
// within this context, not the page context, which results in
// permission denied to run the function.
function convertToText(args) {
//This uses the fact that the arguments are converted to text which is
// interpreted within a <script>. That means we can create other types of
// objects by recreating their normal JavaScript representation.
// It's actually easier to do this without JSON.stringify() for the whole
// Object/Array.
var asText = '';
var level = 0;
function lineSeparator(adj, isntLast) {
level += adj - ((typeof isntLast === 'undefined' || isntLast) ? 0 : 1);
asText += (isntLast ? ',' : '') + '\n' + (new Array((level * 2) + 1)).join('');
}
function recurseObject(obj) {
if (Array.isArray(obj)) {
asText += '[';
lineSeparator(1);
obj.forEach(function(value, index, array) {
recurseObject(value);
lineSeparator(0, index !== array.length - 1);
});
asText += ']';
} else if (obj === null) {
asText += 'null';
} else if (obj === void (0)) {
//undefined
asText += 'void(0)';
} else if (Number.isNaN(obj)) {
//Special cases for Number
//Not a Number (NaN)
asText += 'Number.NaN';
} else if (obj === 1 / 0) {
// +Infinity
asText += '1/0';
} else if (obj === 1 / -0) {
// -Infinity
asText += '1/-0';
} else if (obj instanceof RegExp || typeof obj === 'function') {
//function
asText += obj.toString();
} else if (obj instanceof Date) {
asText += 'new Date("' + obj.toJSON() + '")';
} else if (typeof obj === 'object') {
asText += '{';
lineSeparator(1);
Object.keys(obj).forEach(function(prop, index, array) {
asText += JSON.stringify(prop) + ': ';
recurseObject(obj[prop]);
lineSeparator(0, index !== array.length - 1);
});
asText += '}';
} else if (['boolean', 'number', 'string'].indexOf(typeof obj) > -1) {
asText += JSON.stringify(obj);
} else {
console.log('Didn\'t handle: typeof obj:', typeof obj, ':: obj:', obj);
}
}
recurseObject(args);
return asText;
}
var newScript = document.createElement('script');
if (typeof id === 'string' && id) {
newScript.id = id;
}
var args = [];
//Using .slice(), or other Array methods, on arguments prevents optimization.
for (var index = 3; index < arguments.length; index++) {
args.push(arguments[index]);
}
newScript.textContent = '(' + functionToRunInPage.toString() + ').apply(null,' +
convertToText(args) + ');';
(document.head || document.documentElement).appendChild(newScript);
if (!leaveInPage) {
//Synchronous scripts are executed immediately and can be immediately removed.
//Scripts with asynchronous functionality *may* need to remain in the page
// until complete. Exactly what's needed depends on actual usage.
document.head.removeChild(newScript);
}
return newScript;
};
funcs.removeAllRequestInfo = () => {
//Remove old request-info in preparation for replacing them.
[].slice.call(document.querySelectorAll('.request-info')).forEach((request) => {
request.remove();
});
};
funcs.getElementEffectiveWidth = (element) => {
//Get the "width" to which the "width" style needs to be set to match the size of the specified element, assuming the
// margin and padding are the same as on the specified element. Used to match the button spacing to the "maximum"
// defined by the sizing buttons.
const computedWidth = element.getBoundingClientRect().width;
const style = window.getComputedStyle(element);
const paddingLeft = parseInt(style.getPropertyValue('padding-left'));
const paddingRight = parseInt(style.getPropertyValue('padding-right'));
const marginLeft = parseInt(style.getPropertyValue('margin-left'));
const marginRight = parseInt(style.getPropertyValue('margin-right'));
return (computedWidth - paddingLeft - paddingRight - marginLeft - marginRight);
};
funcs.executeIfIsFunction = (doFunction) => {
//Only execute a function if it exists; no frills; does not bother to account for potential arguments
if (typeof doFunction === 'function') {
return doFunction();
}
};
funcs.ifNotNonNullObjectUseDefault = (obj, defaultValue) => {
//Use the supplied default if the first argument is not a non-null Object.
if (typeof obj !== 'object' || obj === null) {
return defaultValue;
}
return obj;
};
funcs.getFirstRegExListMatchInText = (text, regexes) => {
//Make the match only work on host-relative-links, protocol-relative and fully-qualified links to stackoverflow.com only.
// The goal is to pick up plain text that is ' /q/875121087' and stackoverflow.com links, but not links to questions
// on other sites.
// If nothing is found null is returned.
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
return regexes.reduce((accum, regex) => {
if (accum) {
//Already found
return accum;
}
regex.lastIndex = 0;
const match = regex.exec(text);
return match ? match[1] : match;
}, null);
};
funcs.getAllRegExListMatchesInText = (text, regexes) => {
//Make the match only work on host-relative-links, protocol-relative and fully-qualified links to stackoverflow.com only.
// The goal is to pick up plain text that is ' /q/875121087' and stackoverflow.com links, but not links to questions
// on other sites.
// If nothing is found null is returned.
// Relies on the RegExps having the /g flag.
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
return regexes.reduce((accum, regex) => {
regex.lastIndex = 0;
const matches = text.match(regex);
if (matches) {
if (!Array.isArray(accum)) {
accum = [];
}
return accum.concat(matches);
}
return accum;
}, null);
};
funcs.getPostIdFromURL = (url) => {
//In a URL, find the postId, be it an answer, a question, or just stated as a post.
//Test for answers first
var postId = funcs.getFirstRegExListMatchInText(url, getSOAnswerIdFfromURLRegExes);
if (postId) {
return postId;
}
//Only questions
postId = funcs.getFirstRegExListMatchInText(url, getSOQuestionIdFfromURLNotPostsNotAnswerRegEx);
if (postId) {
return postId;
}
//Posts
postId = funcs.getFirstRegExListMatchInText(url, getSOPostIdFfromURLButNotIfAnswerRegEx);
if (postId) {
return postId;
}
return null;
};
funcs.getAllQAPIdsFromLinksInElement = (element) => { // eslint-disable-line arrow-body-style
//Get all the Question, Answer, or Post links contained in an element.
return funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement(element, 'any', false);
};
funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement = (element, what, returnInfo) => {
//Get a list of one unique question, answer IDs which are pointed to by the href of <A> links within an element.
// The RegExp currently restricts this to stackoverflow only.
// If what includes a 'd', then only URLs which point directly to questions (i.e. not #answer number or #comment)
// will be returned.
what = what.toLowerCase();
let regexes;
if (what.indexOf('q') > -1) {
regexes = getQuestionIdFromURLRegEx;
if (what.indexOf('d') > -1) {
regexes = getSOQuestionIdFfromURLNotPostsNotAnswerRegEx;
}
} else if (what.indexOf('a') > -1) {
if (what.indexOf('any') > -1) {
//If we are looking for any, use the regexes for answers, questions w/o answer, and posts.
regexes = [].concat(getSOAnswerIdFfromURLRegExes, getQuestionIdFromURLRegEx, getSOPostIdFfromURLButNotIfAnswerRegEx);
} else {
regexes = getSOAnswerIdFfromURLRegExes;
}
} else if (what.indexOf('p') > -1) {
regexes = getSOPostIdFfromURLButNotIfAnswerRegEx;
} else {
return [];
}
if (!Array.isArray(regexes)) {
regexes = [regexes];
}
if (!element || element.nodeName === '#text') {
//Return an empty array, as there are no valid question IDs in a null element, and no links in text
return [];
}
//Scan the links in the element and return an array of those that are to the appropriate type of question/answer/post.
return [].slice.call(element.querySelectorAll('a')).filter((link) => { // eslint-disable-line arrow-body-style
//Keep the link if it is to a URL that produces the desired ID type (matches the regexes).
return regexes.some((tryRegex) => {
tryRegex.lastIndex = 0;
return tryRegex.test(link.href);
});
}).map((link) => {
//Have List of links which match. Convert them to the data desired: either an ID, or an Object with some of the link's attributes.
if (returnInfo) {
return {
link: link,
text: link.textContent,
url: link.href,
postId: funcs.getFirstRegExListMatchInText(link.href, regexes),
};
} // else
return funcs.getFirstRegExListMatchInText(link.href, regexes);
}).filter((id, index, array) => {
//Remove duplicates
//This is only a reasonable way to remove duplicates in short arrays, which this is.
if (returnInfo) {
//Filter the Objects that are for duplicate postId's.
for (let testIndex = 0; testIndex < index; testIndex++) {
if (+id.postId === +array[testIndex].postId) {
return false;
}
}
return true;
} // else
return array.indexOf(id) === index;
});
};
funcs.sortMessageRequestInfoEntries = (message) => {
//For request info entries that have more than one link, make the order of those entries
// match the order of the links in the content of the associated .message.
const requestInfo = funcs.getRequestInfoFromMessage(message);
if (!requestInfo) {
//Can't do anything without request-info
return;
}
const requestInfoLinks = [].slice.call(funcs.getRequestInfoLinksFromMessage(message));
if (requestInfoLinks.length < 2) {
//No need to sort a single item
return;
}
const content = funcs.getContentFromMessage(message);
if (!content) {
return;
}
//Get the list of question IDs that are in links in the content.
const postsInContent = funcs.getAllQAPIdsFromLinksInElement(content);
requestInfoLinks.sort((a, b) => {
const aIndex = postsInContent.indexOf(a.dataset.postId);
const bIndex = postsInContent.indexOf(b.dataset.postId);
return aIndex - bIndex;
});
//Apply the sort to the request-info links
requestInfoLinks.forEach((link) => {
requestInfo.appendChild(link);
});
};
//Should consider if the criteria for following a back-reference should be expanded. Should a back-reference
// be followed if there is a link in the reply, just not one that is to a question, post, answer. And etc.?
// For now, the back-reference is not followed if there is a link in the referring message, as that is safer.
funcs.getQuestionAnswerOrPostInfoListFromReplyToIfIsRequestAndNoLinks = (message, what) => {
const content = funcs.getContentFromMessage(message);
if (content && funcs.getFirstRequestTagInElement(content) && !funcs.removeTagLinks(content.cloneNode(true)).querySelector('a')) {
//There's no link in the content (e.g. the request is not contain a link to a question, that happens to also be a reply).
return funcs.getQuestionAnswerOrPostInfoListFromReplyTo(message, what);
} //else
return [];
};
funcs.getQuestionAnswerOrPostInfoListFromReplyTo = (message, what) => {
//Obtain the info from a post to which this message is a reply, if it is in the transcript.
const replyInfo = message.querySelector('.reply-info');
if (replyInfo) {
//It is a reply to something.
const refMessageId = replyInfo.href.replace(/^[^#]*#/, '');
if (refMessageId) {
const refMessage = document.getElementById('message-' + refMessageId);
if (refMessage) {
//The referenced comment is currently on the page
const info = funcs.getQuestionAnswerOrPostIdsOrInfoFromLinksInElement(funcs.getContentFromMessage(refMessage), what, true);
return info;
}
}
}
//Is invalid in some way. Return an empty array.
return [];
};
funcs.setDatasetIfNotUndefined = (element, dataProp, value) => {
if (!element || typeof value === 'undefined') {
return;
}
element.dataset[dataProp] = value;
};
//Calculate some values used to adjust what the script does, but which depend on the utility functions.
const currentRoom = (() => {
if (isSearch) {
return funcs.getFirstRegExListMatchInText(window.location.search, /\bRoom=(\d+)/i);
} // else
if (isChat) {
return funcs.getFirstRegExListMatchInText(window.location.pathname, /\/(\d+)\b/i);
} //else
//Transcript (there is not always a room defined).
return funcs.getFirstRegExListMatchInText(document.querySelector('.room-mini .room-name a'), /chat\.stack(?:overflow|exchange)\.com\/rooms\/(\d+)/);
})();
const urlReviewType = funcs.getFirstRegExListMatchInText(window.location.search, /\brequestReviewType=(\w+)/i);
const urlReviewShow = funcs.getFirstRegExListMatchInText(window.location.search, /\brequestReviewShow=(\w+)/i);
const urlSearchString = funcs.getFirstRegExListMatchInText(window.location.search, /\bq=([^?&#]+)/i);
const urlSearchOrs = typeof urlSearchString === 'string' ? urlSearchString.split(/\+(?:or|\||(?:%7c){1,2})\+/ig) : null;
//Allow the URL to specify showing closed and deleted posts.
const isForceShowClosed = /closed?/i.test(urlReviewShow);
const isForceShowOpen = /open/i.test(urlReviewShow);
const isForceShowDeleted = /deleted?/i.test(urlReviewShow);
const isForceShowLinks = /links?/i.test(urlReviewShow);
const isForceShowReplies = /repl(?:y|ies)/i.test(urlReviewShow);
//Allow the URL to specify that it is a cv- search, del- search, or not using the cv-/del- UI.
const isSearchCv = isSearch && ((/(?:tagged%2F|^)cv(?:\b|$)/.test(urlSearchString) || /(?:cv|close)/i.test(urlReviewType)) && !/none/i.test(urlReviewType));
const isSearchDel = isSearch && ((/(?:tagged%2F|^)(?:del(?:ete|v)?|dv)(?:\b|$)/.test(urlSearchString) || /del/i.test(urlReviewType)) && !/none/i.test(urlReviewType));
const isSearchReopen = isSearch && ((/(?:tagged%2F|^)(?:re-?open)(?:\b|$)/.test(urlSearchString) || /del/i.test(urlReviewType)) && !/none/i.test(urlReviewType));
const isSearchReviewUIActive = isSearchCv || isSearchDel || isSearchReopen;
//Adjust the page links to have the same reviewRequest options
if (urlReviewShow || urlReviewType) {
[].slice.call(document.querySelectorAll('a .page-numbers')).forEach((linkSpan) => {
const link = linkSpan.parentNode;
if (link && link.nodeName === 'A') {
if (urlReviewShow) {
link.href += '&requestReviewShow=' + urlReviewShow;
}
if (urlReviewType) {
link.href += '&requestReviewType=' + urlReviewType;
}
}
});
}
//Visited: Watch for user clicks on links to posts
//Use Ctrl-right-click to open the CV-review queue for the tag clicked on.
var ignoreWindowClicks = false;
funcs.windowCtrlClickListener = (event) => {
//Clicks with Alt/Ctrl/Shift do not travel the DOM (at least not in Firefox).
if (ignoreWindowClicks) {
return;
} //else
ignoreWindowClicks = true;
setTimeout(function() {
//Ignore window clicks for 100ms, to prevent the user from double clicking to cause two votes to be attempted,
// as that just causes a notification to be shown.
ignoreWindowClicks = false;
}, 100);
const target = event.target;
if (target.classList.contains('urrs-receiveAllClicks')) {
const detail = {};
[
// These are of primary interest
'ctrlKey',
'shiftKey',
'altKey',
'metaKey',
'button',
// The rest aren't of that much interest
'screenX',
'screenY',
'clientX',
'clientY',
'buttons',
'relatedTarget',
'region',
'layerX',
'layerY',
'movementX',
'movementY',
'offsetX',
'offsetY',
'detail',
'composed',
'mozInputSource',
'mozPresure',
].forEach((prop) => {
detail[prop] = event[prop];
});
const newEvent = new CustomEvent('urrs-allClicks', {
detail: detail,
bubbles: true,
cancelable: true,
});
target.dispatchEvent(newEvent);
} else if (config.nonUi.clickTagTagToOpenCVQ && event.isTrusted && Object.keys(config.nonUi.clickTagTagToOpenCVQButtonInfo).every((key) => event[key] === config.nonUi.clickTagTagToOpenCVQButtonInfo[key])) {
//A real user Ctrl-click on button 2
if (target.classList.contains('ob-post-tag')) {
const tagName = target.textContent;
//Force this to SO. Other sites don't have chat in a separate domain, so would need to find the domain for
// the room.
GM.openInTab('https://stackoverflow.com/review/close/?filter-tags=' + encodeURIComponent(tagName), false);
//These don't prevent Firefox from displaying the context menu.
event.preventDefault();
event.stopPropagation();
}
}
};
//Now done by monitoring mousedown and mouseup, and click and auxclick in order to get around a Chrome "feature" which results in click events not
// being fired for any button other than button 1. Chrome recently implemented that non-button 1 clicks are an "auxclick".
//Remembering visited questions.
if (typeof funcs.visited !== 'object') {
funcs.visited = {};
}
//Work-around for Chrome not firing a 'click' event for buttons other than #1.
var mostRecentMouseDownEvent;
funcs.visited.listenForLinkMouseDown = (event) => {
// Remember which element the mouse is on when the button is pressed.
mostRecentMouseDownEvent = event;
};
funcs.visited.listenForLinkMouseUp = (event) => {
//If a mouseup occurs, consider it a click, if the target is the same as the last mousedown.
// This will have issues with detecting clicks when the user presses multiple buttons at the same time.
if (mostRecentMouseDownEvent.target === event.target && mostRecentMouseDownEvent.button === event.button) {
//Delay so the 'click' event can fire. If not, we may make the message display:none prior to the
// click taking effect.
funcs.visited.listenForClicks(event);
}
};
var mostRecentClick = null;
funcs.visited.listenForClicks = (event) => {
//Clicks are sometimes detected through mousedown/mouseup pairs, click events, or auxclick events.
// But, we want to fire our listeners only once per user action. In addition, we want to have the
// same effect of preventing the default action, on associated events (i.e. click), if our action called preventDefault().
if (mostRecentClick) {
//There has been a prior event, which may be the same user action.
const mustMatch = [
'target',
'button',
'screenX',
'screenY',
'clientX',
'clientY',
'buttons',
'ctrlKey',
'shiftKey',
'altKey',
'metaKey',
];
if (mustMatch.every((key) => mostRecentClick[key] === event[key]) && (event.timeStamp - mostRecentClick.timeStamp) < 50) {
//Same action
if (mostRecentClick.defaultPrevented) {
event.preventDefault();
}
return;
} //else
}
//It's a new user action
funcs.windowCtrlClickListener(event);
if (!event.defaultPrevented) {
funcs.visited.listenForLinkClicks(event);
}
if (event.target.classList.contains('action-link') || (event.target.parentNode && event.target.parentNode.classList && event.target.parentNode.classList.contains('action-link'))) {
funcs.ui.listenForActionLinkClicks(event);
}
mostRecentClick = event;
};
window.addEventListener('click', funcs.visited.listenForClicks, false);
window.addEventListener('auxclick', funcs.visited.listenForClicks, false);
funcs.ui.listenForActionLinkClicks = (event) => {
const target = event.target;
const message = funcs.getContainingMessage(target);
if (!message || !message.classList.contains('urrsRequestComplete')) {
return;
}
setTimeout(() => {
const popup = message.querySelector('.message > .popup');
if (popup) {
message.classList.add('urrsRequestComplete-temp-disable');
const popupObserver = new MutationObserver(function(mutations, observer) {
if (mutations.some((mutation) => (mutation.removedNodes && [].slice.call(mutation.removedNodes).some((node) => node.classList.contains('popup'))))) {
observer.disconnect();
message.classList.remove('urrsRequestComplete-temp-disable');
}
});
popupObserver.observe(message, {
childList: true,
});
}
}, 100);
};
funcs.visited.listenForLinkClicks = (event) => {
//Intended as main listener for clicks on links. Because Chrome doesn't fire click events for buttons other than the main one
// this is called when the listeners to mousedown and mouseup determine that a click should have fired.
if (!config.nonUi.trackVisitedLinks) {
return;
}
var affectedLink = funcs.visited.findYoungestAnchor(event.target);
if (!affectedLink) {
return;
}
funcs.visited.addPostFromURLToVisitedAndUpdateShown(affectedLink.href, event.target);
};
funcs.visited.addPostsFromAnchorListToVisitedAndUpdateShown = (links) => {
// Add the posts associated with a list of links to the
// visited list and update the UI, if so configured on the
// search page (i.e. hide the messages if not showing
// visited).
const ids = [];
const filtered = links.filter((link) => {
const postId = funcs.getPostIdFromURL(link.href);
if (postId === null || isNaN(+postId)) {
//Not a question link.
return false;
}
ids.push(postId);
return true;
});
//Add all the valid Ids to the visited list
funcs.config.addPostIdsToVisitedAndRetainMostRecentList(ids);
funcs.visited.invalidateElementsMessageVisitedAndUpdateUi(filtered);
};
funcs.visited.addPostFromURLToVisitedAndUpdateShown = (url, element) => {
// If a URL is a post, add the post to the visited list, cause
// the element's message to be reevaluated wrt. visited and
// hide the message if in the search page, and so specified by the UI.
const postId = funcs.getPostIdFromURL(url);
if (postId === null || isNaN(+postId)) {
//Not a question link.
return;
}
//This may be running in multiple tabs. Make sure to sync up to the most recently saved config prior to adding
// new questions. If this is not done, then changes in other tabs get lost. While there could be a inter-tab race
// condition here, this running is based on user input, which shouldn't be faster than the code.
//Add the question to the visited list.
funcs.config.addPostIdsToVisitedAndRetainMostRecentList(postId);
funcs.visited.invalidateElementsMessageVisitedAndUpdateUi(element);
};
funcs.visited.invalidateElementsMessageVisitedAndUpdateUi = (elements) => {
//For a single element, or list of elements, clear the visited status and update the UI, if on the search page.
if (!Array.isArray(elements)) {
elements = [elements];
}
let didUpate = false;
elements.forEach((element) => {
//Cause the message to be re-tested wrt. having been visited.
if (element) {
const message = funcs.getContainingMessage(element);
if (message) {
const visited = message.dataset.visited;
if (visited) {
message.dataset.visited = '';
}
didUpate = true;
}
}
});
if (didUpate) {
//Only need to show/hide messages here, not sort, and only on search page.
funcs.executeIfIsFunction(funcs.ui.showHideMessagesPerUI);
}
};
funcs.visited.findYoungestAnchor = (element) => {
//Find the closest ancestor, including the element itself which is an anchor.
while (element && element.nodeName !== 'A') {
element = element.parentNode;
}
return element;
};
funcs.visited.findYoungestInteractiveElement = (element) => {
//Find the closest ancestor, including the element itself which is interactive.
//This would be a bit faster if the Array did not have to be created each time the function is entered.
const interactiveNodeNames = ['A', 'BUTTON', 'INPUT', 'MAP', 'OBJECT', 'TEXTAREA', 'VIDEO'];
while (element && interactiveNodeNames.indexOf(element.nodeName) === -1) {
element = element.parentNode;
}
return element;
};
funcs.visited.beginRememberingPostVisits = () => {
//Start listening to click events so we can record when the user clicks on a link.
//Let all other event handlers deal with or cancel the event.
//We only want to record the click if the default isn't prevented. Thus, we need to
// get it last.
window.addEventListener('mousedown', funcs.visited.listenForLinkMouseDown, false);
window.addEventListener('mouseup', funcs.visited.listenForLinkMouseUp, false);
};
//Functions for the backoff timer (functional across instances)
if (typeof funcs.backoff !== 'object') {
funcs.backoff = {};
}
//XXX The backoff timer needs to be obeyed across different instances of the same script (i.e. across tabs).
//XXX Backoff timer is not fully tested.
funcs.backoff.done = () => {
if (backoffTimer.isPrimary) {
funcs.backoff.clearAndInConfig();
} else {
funcs.backoff.clear();
}
//Update on the chat page.
funcs.executeIfIsFunction(funcs.mp.processAllIfTimeElapsedAndScheduled);
//XXX Need to do something for the search page.
};
funcs.backoff.clearAndInConfig = () => {
//Clear the currently active backoff timer, and store in the config that it is cleared.
// This is only done by the instance which considers itself to be primary.
funcs.backoff.clear();
//Record that the timer has been cleared.
config.backoff.active = false;
config.backoff.timeActivated = 0;
config.backoff.milliseconds = 0;
funcs.config.saveBackoff();
};
funcs.backoff.clear = () => {
//Clear the currently active backoff timer
clearTimeout(backoffTimer.timer);
backoffTimer.timer = 0;
backoffTimer.isPrimary = false;
backoffTimer.timeActivated = 0;
backoffTimer.milliseconds = 0;
};
funcs.backoff.setAndStoreInConfig = (seconds) => {
//Set the backoff timer, and store in the config that it is set.
// This is only done by the instance which considers itself to be primary. Doing so is effectively defined as being primary.
funcs.backoff.set(seconds);
//Record that the timer has been set.
backoffTimer.isPrimary = true;
config.backoff.active = true;
config.backoff.timeActivated = backoffTimer.timeActivated;
config.backoff.milliseconds = backoffTimer.milliseconds;
funcs.config.saveBackoff();
};
funcs.backoff.set = (seconds) => {
//Set the backoff timer.
//Clear it first so multiple timers are not running.
funcs.backoff.clear();
backoffTimer.timer = setTimeout(funcs.backoff.done, seconds * 1000);
backoffTimer.isPrimary = false;
backoffTimer.timeActivated = Date.now();
backoffTimer.milliseconds = seconds * 1000;
};
//Functions for remembering the configuration.
if (typeof funcs.config !== 'object') {
funcs.config = {};
}
funcs.config.localStorageChangeListener = (event) => {
//Listen to changes to localStorage. Only call handlers for those storage locations which are being listened to.
const handlers = {
[LSPREFIX + 'nonUiConfig']: funcs.config.handleNonUiConfigChange,
[LSPREFIX + 'backoff']: funcs.config.handleBackoffTimerChange,
};
if (handlers.hasOwnProperty(event.key)) {
const handler = handlers[event.key];
const key = event.key.replace(LSPREFIX, '');
//Mimic how the handler would be called by GM_addValueChangeListener().
//localStorage only notifies for remote events, never for changes in the current tab.
handler(key, event.oldValue, event.newValue, true);
}
};
funcs.config.listenForConfigChangesIfPossible = () => {
//If the platform permits listening for config changes, then do so.
//Determining if it was possible to listen for changes was only needed when using GM storage,
// as listening for changes wasn't available in Firefox/GM3 (GM4?). This was switched to using
// localStorage. All browsers can listen for localStorage changes.
window.addEventListener('storage', funcs.config.localStorageChangeListener);
};
funcs.config.handleBackoffTimerChange = (name, oldValueJSON, newValueJSON, remote) => {
//Receive an event that the backoff timer changed.
if (remote && name === 'backoff') {
funcs.config.restoreBackoffAndCheckIfNeedBackoff();
}
};
funcs.config.handleNonUiConfigChange = (name, oldValue, newValue, remote) => {
//Receive notification that there was a change in the configuration in another tab.
if (remote && name === 'nonUiConfig') {
//Reading it is redundant vs. the newValue, but there is already a function to do everything needed.
funcs.config.restoreNonUi(config.nonUi);
funcs.addRequestStylesToDOM();
//Only need to show/hide messages here, and only on search page.
funcs.executeIfIsFunction(funcs.ui.showHideMessagesPerUI);
//Update the options dialog
funcs.executeIfIsFunction(funcs.ui.setGeneralOptionsDialogCheckboxesToConfig);
}
};
funcs.config.getStoredNonUiConfigUpdateUiOrOptionsIfNeeded = () => {
//Handle Visited Questions (should probably be storing visited questions in their own storage location).
//XXX This needs to handle a change to the watched/not watched selection.
//The nonUi config is _always_ saved if it is changed in the script, and never changed except
// due to user interaction. Thus, we can accept that the stored version is primary.
funcs.config.getStoredVisitedPostsIntoConfigAndUpdateShownMessagesifNeeded();
//Deal with the properties other than visited questions.
var oldNonUiConfig = config.nonUi;
config.nonUi = {};
funcs.config.setNonUiDefaults();
funcs.config.restoreNonUi();
//delete the visited Posts, as that is not being compared.
delete oldNonUiConfig.visitedPosts;
if (Object.keys(oldNonUiConfig).some((key) => oldNonUiConfig[key] !== config.nonUi[key])) {
//At least one of the config values does not match what is in the current config.
// Update the options UI with the stored values.
funcs.executeIfIsFunction(funcs.ui.setGeneralOptionsDialogCheckboxesToConfig);
funcs.executeIfIsFunction(funcs.ui.setVisitedButtonEnabledDisabledByConfig);
funcs.config.clearVisitedPostsInConfigIfSetNoTracking();
}
};
funcs.config.getStoredVisitedPostsIntoConfigAndUpdateShownMessagesifNeeded = () => {
//Get the most recent version of the stored
funcs.config.pruneVisitedPosts();
const origVisitedPosts = config.nonUi.visitedPosts;
funcs.config.getStoredVisitedPostsIntoConfig();
if (origVisitedPosts.length !== config.nonUi.visitedPosts.length) {
//While this is not an entry by entry comparison, comparing just the
// length of both lists, which were both just pruned, should result in detecting
// any changes which were made in another tab (with possible millisecond differences in what was pruned).
//Update the currently displayed questions.
funcs.executeIfIsFunction(funcs.ui.showHideMessagesPerUI);
}
};
funcs.config.getStoredVisitedPostsIntoConfig = () => {
//This relies on the stored visited questions list to have always been updated when
// that list is updated locally, which is how it is done.
config.nonUi.visitedPosts = funcs.config.getStoredVisitedPosts();
};
funcs.config.getStoredVisitedPosts = () => {
//Read in the stored version of the visited questions list without disturbing the other data
// stored in that location.
var tmpConfig = {};
funcs.config.setNonUiDefaults(tmpConfig);
funcs.config.restoreNonUi(tmpConfig);
return tmpConfig.visitedPosts;
};
funcs.config.addPostIdsToVisitedAndRetainMostRecentList = (idIds) => {
//Add a post IDs to the most recently saved version of the list of visitedPosts list.
// The overall config.nonUi may have been updated in another tab. (possibly updated in another tab)
// This just syncs the visited questions, it does not change the other values in storage to match
// any changes in the local config.nonUi.
var tmpConfig = {};
funcs.config.setNonUiDefaults(tmpConfig);
funcs.config.restoreNonUi(tmpConfig);
if (!Array.isArray(idIds)) {
idIds = [idIds];
}
//Add all ids to the visited list.
const now = Date.now();
idIds.forEach((id) => {
tmpConfig.visitedPosts[id] = now;
});
funcs.config.saveNonUi(tmpConfig);
//Keep the most current version without updating GUI info
config.nonUi.visitedPosts = tmpConfig.visitedPosts;
};
funcs.config.clear = () => {
//Clear all configuration information.
funcs.config.clearUi();
funcs.config.clearNonUi();
funcs.config.clearBackoff();
};
funcs.config.clearItem = (itemName) => {
//Clear a single item from storage
localStorage.removeItem(LSPREFIX + itemName);
};
funcs.config.clearUi = () => {
//Delete all UI configuration information for the UI.
['close', 'delete'].forEach((whichType) => {
for (let group = 1; group <= NUMBER_UI_GROUPS; group++) {
funcs.config.clearItem(funcs.config.getUILocationId(group, whichType));