blob: acea6feaf25cf4d11214b556a3722ebaf587d386 [file] [log] [blame]
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +01001import {MDCTooltip} from '@material/tooltip';
2import {waitFor} from 'poll-until-promise';
3
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +01004import {parseUrl} from '../../common/commonUtils.js';
Adrià Vilanova Martínez17106dc2022-01-24 23:48:36 +01005import OptionsWatcher from '../../common/optionsWatcher.js';
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +01006import {createPlainTooltip} from '../../common/tooltip.js';
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +01007
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +01008import {createExtBadge, getDisplayLanguage} from './utils/common.js';
Adrià Vilanova Martínez69c30502022-01-28 20:47:08 +01009import PerForumStatsSection from './utils/PerForumStatsSection.js';
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +010010
11const kViewUnifiedUserResponseEvent = 'TWPT_ViewUnifiedUserResponse';
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +010012const kListCannedResponsesResponse = 'TWPT_ListCannedResponsesResponse';
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +010013const kViewThreadResponse = 'TWPT_ViewThreadResponse';
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +010014const kViewForumResponse = 'TWPT_ViewForumResponse';
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +010015
16const kAbuseCategories = [
17 ['1', 'Account'],
18 ['2', 'Display name'],
19 ['3', 'Avatar'],
20];
21const kAbuseViolationCategories = {
22 0: 'NO_VIOLATION',
23 1: 'COMMUNITY_POLICY_VIOLATION',
24 2: 'LEGAL_VIOLATION',
25 3: 'CSAI_VIOLATION',
26 4: 'OTHER_VIOLATION',
27};
28const kAbuseViolationTypes = {
29 0: 'UNSPECIFIED',
30 23: 'ACCOUNT_DISABLED',
31 55: 'ACCOUNT_HAS_SERVICES_DISABLED',
32 35: 'ACCOUNT_HIJACKED',
33 96: 'ACCOUNT_LEAKED_CREDENTIALS',
34 92: 'ACCOUNT_NOT_SUPPORTED',
35 81: 'ARTISTIC_NUDITY',
36 66: 'BAD_BEHAVIOR_PATTERN',
37 78: 'BAD_ENGAGEMENT_BEHAVIOR_PATTERN',
38 79: 'BORDERLINE_HARASSMENT',
39 80: 'BORDERLINE_HATE_SPEECH',
40 38: 'BOTNET',
41 32: 'BRANDING_VIOLATION',
42 100: 'CAPITALIZING_TRAGIC_EVENTS',
43 105: 'CLOAKING',
44 49: 'COIN_MINING',
45 7: 'COMMERCIAL_CONTENT',
46 97: 'COPPA_REGULATED',
47 57: 'COPYRIGHT_CIRCUMVENTION',
48 8: 'COPYRIGHTED_CONTENT',
49 58: 'COURT_ORDER',
50 51: 'CSAI',
51 94: 'CSAI_INSPECT',
52 52: 'CSAI_CARTOON_HUMOR',
53 53: 'CSAI_SOLICITATION',
54 108: 'CSAI_NON_APPARENT',
55 67: 'DANGEROUS',
56 37: 'DATA_SCRAPING',
57 86: 'DECEPTIVE_OAUTH_IMPLEMENTATION',
58 46: 'DEFAMATORY_CONTENT',
59 36: 'DELINQUENT_BILLING',
60 30: 'DISRUPTION_ATTEMPT',
61 112: 'DOMESTIC_INTERFERENCE',
62 22: 'DOS',
63 9: 'DUPLICATE_CONTENT',
64 68: 'DUPLICATE_LOCAL_PAGE',
65 121: 'NON_QUALIFYING_ORGANIZATION',
66 115: 'EGREGIOUS_INTERACTION_WITH_MINOR',
67 83: 'ENGAGEMENT_COLLUSION',
68 41: 'EXPLOIT_ATTACKS',
69 65: 'FAKE_USER',
70 2: 'FRAUD',
71 21: 'FREE_TRIAL_VIOLATION',
72 43: 'GIBBERISH',
73 101: 'FOREIGN_INTERFERENCE',
74 59: 'GOVERNMENT_ORDER',
75 10: 'GRAPHICAL_VIOLENCE',
76 11: 'HARASSMENT',
77 12: 'HATE_SPEECH',
78 90: 'IDENTICAL_PRODUCT_NAME',
79 60: 'ILLEGAL_DRUGS',
80 13: 'IMPERSONATION',
81 69: 'IMPERSONATION_WITH_PII',
82 116: 'INAPPROPRIATE_INTERACTION_WITH_MINOR',
83 45: 'INAPPROPRIATE_CONTENT_SPEECH',
84 106: 'INTENTIONAL_THWARTING',
85 27: 'INTRUSION_ATTEMPT',
86 87: 'INVALID_API_USAGE',
87 14: 'INVALID_CONTENT',
88 20: 'INVALID_GCE_USAGE',
89 120: 'INVALID_STORAGE_USAGE',
90 15: 'INVALID_IMAGE_QUALITY',
91 88: 'INVALID_API_PRIVACY_POLICY_DISCLOSURE',
92 54: 'INVALID_USAGE_OF_IP_PROXYING',
93 99: 'KEYWORD_STUFFING',
94 61: 'LEGAL_COUNTERFEIT',
95 62: 'LEGAL_EXPORT',
96 63: 'LEGAL_PRIVACY',
97 33: 'LEGAL_REVIEW',
98 91: 'LEGAL_PROTECTED',
99 70: 'LOW_QUALITY_CONTENT',
100 93: 'LOW_REPUTATION_PHONE_NUMBER',
101 6: 'MALICIOUS_SOFTWARE',
102 40: 'MALWARE',
103 113: 'MISLEADING',
104 114: 'MISREP_OF_ID',
105 89: 'MEMBER_OF_ABUSIVE_GCE_NETWORK',
106 84: 'NON_CONSENSUAL_EXPLICIT_IMAGERY',
107 1: 'NONE',
108 102: 'OFF_TOPIC',
109 31: 'OPEN_PROXY',
110 28: 'PAYMENT_FRAUD',
111 16: 'PEDOPHILIA',
112 71: 'PERSONAL_INFORMATION_CONTENT',
113 25: 'PHISHING',
114 34: 'POLICY_REVIEW',
115 17: 'PORNOGRAPHY',
116 29: 'QUOTA_CIRCUMVENTION',
117 72: 'QUOTA_EXCEEDED',
118 73: 'REGULATED',
119 24: 'REPEATED_POLICY_VIOLATION',
120 104: 'RESOURCE_COMPROMISED',
121 107: 'REWARD_PROGRAMS_ABUSE',
122 74: 'ROGUE_PHARMA',
123 82: 'ESCORT',
124 75: 'SPAMMY_LOCAL_VERTICAL',
125 39: 'SEND_EMAIL_SPAM',
126 117: 'SEXTORTION',
127 118: 'SEX_TRAFFICKING',
128 44: 'SEXUALLY_EXPLICIT_CONTENT',
129 3: 'SHARDING',
130 95: 'SOCIAL_ENGINEERING',
131 109: 'SUSPICIOUS',
132 19: 'TRADEMARK_CONTENT',
133 50: 'TRAFFIC_PUMPING',
134 76: 'UNSAFE_RACY',
135 103: 'UNUSUAL_ACTIVITY_ALERT',
136 64: 'UNWANTED_CONTENT',
137 26: 'UNWANTED_SOFTWARE',
138 77: 'VIOLENT_EXTREMISM',
139 119: 'UNAUTH_IMAGES_OF_MINORS',
140 85: 'UNAUTHORIZED_SERVICE_RESELLING',
141 98: 'CSAI_EXTERNAL',
142 5: 'SPAM',
143 4: 'UNSAFE',
144 47: 'CHILD_PORNOGRAPHY_INCITATION',
145 18: 'TERRORISM_SUPPORT',
146 56: 'CSAI_WORST_OF_WORST',
147};
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100148const kItemMetadataState = {
149 0: 'UNDEFINED',
150 1: 'PUBLISHED',
151 2: 'DRAFT',
152 3: 'AUTOMATED_ABUSE_TAKE_DOWN_HIDE',
153 4: 'AUTOMATED_ABUSE_TAKE_DOWN_DELETE',
154 13: 'AUTOMATED_ABUSE_REINSTATE',
155 10: 'AUTOMATED_OFF_TOPIC_HIDE',
156 14: 'AUTOMATED_FLAGGED_PENDING_MANUAL_REVIEW',
157 5: 'USER_FLAGGED_PENDING_MANUAL_REVIEW',
158 6: 'OWNER_DELETED',
159 7: 'MANUAL_TAKE_DOWN_HIDE',
160 17: 'MANUAL_PROFILE_TAKE_DOWN_SUSPEND',
161 8: 'MANUAL_TAKE_DOWN_DELETE',
162 18: 'REINSTATE_PROFILE_TAKEDOWN',
163 9: 'REINSTATE_ABUSE_TAKEDOWN',
164 11: 'CLEAR_OFF_TOPIC',
165 12: 'CONFIRM_OFF_TOPIC',
166 15: 'GOOGLER_OFF_TOPIC_HIDE',
167 16: 'EXPERT_FLAGGED_PENDING_MANUAL_REVIEW',
168};
169const kShadowBlockReason = {
170 0: 'REASON_UNDEFINED',
171 1: 'ULTRON_LOW_QUALITY',
172};
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100173
174export default class ExtraInfo {
175 constructor() {
176 this.lastProfile = {
177 body: {},
178 id: -1,
179 timestamp: 0,
180 };
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100181 this.lastCRsList = {
182 body: {},
183 id: -1,
184 duplicateNames: new Set(),
185 };
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100186 this.lastThread = {
187 body: {},
188 id: -1,
189 timestamp: 0,
190 };
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100191 this.lastThreadList = null;
192 this.lastThreadListTimestamp = 0;
193 this.lastThreadListRequestId = -1;
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100194 this.displayLanguage = getDisplayLanguage();
195 this.optionsWatcher = new OptionsWatcher(['extrainfo', 'perforumstats']);
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100196 this.setUpHandlers();
197 }
198
199 setUpHandlers() {
200 window.addEventListener(kViewUnifiedUserResponseEvent, e => {
201 if (e.detail.id < this.lastProfile.id) return;
202
203 this.lastProfile = {
204 body: e.detail.body,
205 id: e.detail.id,
206 timestamp: Date.now(),
207 };
208 });
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100209 window.addEventListener(kListCannedResponsesResponse, e => {
210 if (e.detail.id < this.lastCRsList.id) return;
211
212 // Look if there are duplicate names
213 const crs = e.detail.body?.['1'] ?? [];
214 const names = crs.map(cr => cr?.['7']).slice().sort();
215 let duplicateNames = new Set();
216 for (let i = 1; i < names.length; i++)
217 if (names[i - 1] == names[i]) duplicateNames.add(names[i]);
218
219 this.lastCRsList = {
220 body: e.detail.body,
221 id: e.detail.id,
222 duplicateNames,
223 };
224 });
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100225 window.addEventListener(kViewThreadResponse, e => {
226 if (e.detail.id < this.lastThread.id) return;
227
228 this.lastThread = {
229 body: e.detail.body,
230 id: e.detail.id,
231 timestamp: Date.now(),
232 };
233 });
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100234 window.addEventListener(kViewThreadResponse, e => {
235 if (e.detail.id < this.lastThread.id) return;
236
237 this.lastThread = {
238 body: e.detail.body,
239 id: e.detail.id,
240 timestamp: Date.now(),
241 };
242 });
243 window.addEventListener(kViewForumResponse, e => {
244 if (e.detail.id < this.lastThreadListRequestId) return;
245
246 this.lastThreadList = e.detail.body;
247 this.lastThreadListTimestamp = Date.now();
248 });
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100249 }
250
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100251 // Whether |feature| is enabled
252 isEnabled(feature) {
253 return this.optionsWatcher.isEnabled(feature);
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100254 }
255
256 // Add a pretty component which contains |info| to |node|.
257 addExtraInfoElement(info, node) {
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100258 // Don't create if there's nothing to show
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100259 if (info.length == 0) return;
260
261 let container = document.createElement('div');
262 container.classList.add('TWPT-extrainfo-container');
263
264 let badgeCell = document.createElement('div');
265 badgeCell.classList.add('TWPT-extrainfo-badge-cell');
266
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100267 const [badge, badgeTooltip] = createExtBadge();
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100268 badgeCell.append(badge);
269
270 let infoCell = document.createElement('div');
271 infoCell.classList.add('TWPT-extrainfo-info-cell');
272
273 for (const i of info) {
274 let iRow = document.createElement('div');
275 iRow.append(i);
276 infoCell.append(iRow);
277 }
278
279 container.append(badgeCell, infoCell);
280 node.append(container);
281 new MDCTooltip(badgeTooltip);
282 }
283
284 fieldInfo(field, value) {
285 let span = document.createElement('span');
286 span.append(document.createTextNode(field + ': '));
287
288 let valueEl = document.createElement('span');
289 valueEl.style.fontFamily = 'monospace';
290 valueEl.textContent = value;
291
292 span.append(valueEl);
293 return span;
294 }
295
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100296 /**
297 * Profile functionality
298 */
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100299 injectAtProfile(card) {
300 waitFor(
301 () => {
302 let now = Date.now();
303 if (now - this.lastProfile.timestamp < 15 * 1000)
304 return Promise.resolve(this.lastProfile);
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100305 return Promise.reject(
306 new Error('Didn\'t receive profile information'));
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100307 },
308 {
309 interval: 500,
310 timeout: 15 * 1000,
311 })
312 .then(profile => {
313 let info = [];
314 const abuseViolationCategory = profile.body?.['1']?.['6'];
315 if (abuseViolationCategory) {
316 info.push(this.fieldInfo(
317 'Abuse category',
318 kAbuseViolationCategories[abuseViolationCategory] ??
319 abuseViolationCategory));
320 }
321
322 const profileAbuse = profile.body?.['1']?.['1']?.['8'];
323
324 for (const [index, category] of kAbuseCategories) {
325 const violation = profileAbuse?.[index]?.['1']?.['1'];
326 if (violation) {
327 info.push(this.fieldInfo(
328 category + ' policy violation',
329 kAbuseViolationTypes[violation]));
330 }
331 }
332
333 const appealCount = profileAbuse?.['4'];
334 if (appealCount !== undefined)
335 info.push(this.fieldInfo('Number of appeals', appealCount));
336
337 this.addExtraInfoElement(info, card);
338 })
339 .catch(err => {
340 console.error(
341 'extraInfo: error while injecting profile extra info: ', err);
342 });
343 }
344
345 injectAtProfileIfEnabled(card) {
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100346 this.isEnabled('extrainfo').then(isEnabled => {
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100347 if (isEnabled) return this.injectAtProfile(card);
348 });
349 }
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100350
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100351 /**
352 * Canned responses (CRs) functionality
353 */
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100354 getCRName(tags, isExpanded) {
355 if (!isExpanded)
356 return tags.parentNode?.querySelector?.('.text .name')?.textContent;
357
358 // https://www.youtube.com/watch?v=Z6_ZNW1DACE
359 return tags.parentNode?.parentNode?.parentNode?.parentNode?.parentNode
360 ?.parentNode?.parentNode?.querySelector?.('.text .name')
361 ?.textContent;
362 }
363
364 // Inject usage stats in the |tags| component of a CR
365 injectAtCR(tags, isExpanded) {
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100366 waitFor(
367 () => {
368 if (this.lastCRsList.id != -1)
369 return Promise.resolve(this.lastCRsList);
370 return Promise.reject(
371 new Error('Didn\'t receive canned responses list'));
372 },
373 {
374 interval: 500,
375 timeout: 15 * 1000,
376 })
377 .then(crs => {
378 let name = this.getCRName(tags, isExpanded);
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100379
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100380 // If another CR has the same name, there's no easy way to distinguish
381 // them, so don't show the usage stats.
382 if (crs.duplicateNames.has(name)) {
383 console.info(
384 'CR "' + name +
385 '" is duplicate, so skipping the injection of usage stats.');
386 return;
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100387 }
388
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100389 for (const cr of (crs.body?.['1'] ?? [])) {
390 if (cr['7'] == name) {
391 let tag = document.createElement('material-chip');
392 tag.classList.add('TWPT-tag');
393
394 let container = document.createElement('div');
395 container.classList.add('TWPT-chip-content-container');
396
397 let content = document.createElement('div');
398 content.classList.add('TWPT-content');
399
400 const [badge, badgeTooltip] = createExtBadge();
401
402 let label = document.createElement('span');
403 label.textContent = 'Used ' + (cr['8'] ?? '0') + ' times';
404
405 content.append(badge, label);
406 container.append(content);
407 tag.append(container);
408 tags.append(tag);
409
410 new MDCTooltip(badgeTooltip);
411
412 if (cr['9']) {
413 const lastUsedTime = Math.floor(parseInt(cr['9']) / 1e3);
414 let date = (new Date(lastUsedTime)).toLocaleString();
415 createPlainTooltip(label, 'Last used: ' + date);
416 }
417
418 break;
419 }
420 }
421 })
422 .catch(err => {
423 console.error(
424 'extraInfo: error while injecting profile extra info: ', err);
425 });
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100426 }
427
428 injectAtCRIfEnabled(tags, isExpanded) {
429 // If the tag has already been injected, exit.
430 if (tags.querySelector('.TWPT-tag')) return;
431
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100432 this.isEnabled('extrainfo').then(isEnabled => {
Adrià Vilanova Martínez74a25202022-01-23 23:36:58 +0100433 if (isEnabled) return this.injectAtCR(tags, isExpanded);
434 });
435 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100436
437 /**
438 * Thread view functionality
439 */
440
441 getPendingStateInfo(endPendingStateTimestampMicros) {
442 const endPendingStateTimestamp =
443 Math.floor(endPendingStateTimestampMicros / 1e3);
444 const now = Date.now();
445 if (endPendingStateTimestampMicros && endPendingStateTimestamp > now) {
446 let span = document.createElement('span');
447 span.textContent = 'Only visible to badged users';
448
449 let date = new Date(endPendingStateTimestamp).toLocaleString();
450 let pendingTooltip =
451 createPlainTooltip(span, 'Visible after ' + date, false);
452 return [span, pendingTooltip];
453 }
454
455 return [null, null];
456 }
457
458 getMetadataInfo(itemMetadata) {
459 let info = [];
460
461 const state = itemMetadata?.['1'];
462 if (state && state != 1)
463 info.push(this.fieldInfo('State', kItemMetadataState[state] ?? state));
464
465 const shadowBlockInfo = itemMetadata?.['10'];
466 const blockedTimestampMicros = shadowBlockInfo?.['2'];
467 if (blockedTimestampMicros) {
468 const isBlocked = shadowBlockInfo?.['1'];
469 let span = document.createElement('span');
470 span.textContent =
471 isBlocked ? 'Shadow block active' : 'Shadow block no longer active';
472 if (isBlocked) span.classList.add('TWPT-extrainfo-bad');
473 info.push(span);
474 }
475
476 return info;
477 }
478
479 getLiveReviewStatusInfo(liveReviewStatus) {
480 const verdict = liveReviewStatus?.['1'];
481 if (!verdict) return [null, null];
482 let label, labelClass;
483 switch (verdict) {
484 case 1: // LIVE_REVIEW_RELEVANT
485 label = 'Relevant';
486 labelClass = 'TWPT-extrainfo-good';
487 break;
488
489 case 2: // LIVE_REVIEW_OFF_TOPIC
490 label = 'Off-topic';
491 labelClass = 'TWPT-extrainfo-bad';
492 break;
493
494 case 3: // LIVE_REVIEW_ABUSE
495 label = 'Abuse';
496 labelClass = 'TWPT-extrainfo-bad';
497 break;
498 }
499 const reviewedBy = liveReviewStatus?.['2'];
500 const timestamp = liveReviewStatus?.['3'];
501 const date = (new Date(Math.floor(timestamp / 1e3))).toLocaleString();
502
503 let a = document.createElement('a');
504 a.href = 'https://support.google.com/s/community/user/' + reviewedBy;
505 a.classList.add(labelClass);
506 a.textContent = 'Live review verdict: ' + label;
507 let liveReviewTooltip = createPlainTooltip(a, date, false);
508 return [a, liveReviewTooltip];
509 }
510
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100511 // Get an |info| array with the info related to the thread, and a |tooltips|
512 // array with the corresponding tooltips which should be initialized after the
513 // info is added to the DOM.
514 //
515 // This is used by the injectAtQuestion() and injectAtThreadList() functions.
516 getThreadInfo(thread) {
517 let info = [];
518 let tooltips = [];
519
520 const endPendingStateTimestampMicros = thread?.['2']?.['39'];
521 const [pendingStateInfo, pendingTooltip] =
522 this.getPendingStateInfo(endPendingStateTimestampMicros);
523 if (pendingStateInfo) info.push(pendingStateInfo);
524 if (pendingTooltip) tooltips.push(pendingTooltip);
525
526 const isTrending = thread?.['2']?.['25'];
527 const isTrendingAutoMarked = thread?.['39'];
528 if (isTrendingAutoMarked)
529 info.push(document.createTextNode('Automatically marked as trending'));
530 else if (isTrending)
531 info.push(document.createTextNode('Trending'));
532
533 const itemMetadata = thread?.['2']?.['12'];
534 const mdInfo = this.getMetadataInfo(itemMetadata);
535 info.push(...mdInfo);
536
537 const liveReviewStatus = thread?.['2']?.['38'];
538 const [liveReviewInfo, liveReviewTooltip] =
539 this.getLiveReviewStatusInfo(liveReviewStatus);
540 if (liveReviewInfo) info.push(liveReviewInfo);
541 if (liveReviewTooltip) tooltips.push(liveReviewTooltip);
542
543 return [info, tooltips];
544 }
545
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100546 injectAtQuestion(question) {
547 let currentPage = parseUrl(location.href);
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100548 if (currentPage === false) {
549 console.error('extraInfo: couldn\'t parse current URL:', location.href);
550 return;
551 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100552
553 let content = question.querySelector('ec-question > .content');
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100554 if (!content) {
555 console.error('extraInfo: question doesn\'t have .content:', messageNode);
556 return;
557 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100558
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100559 waitFor(
560 () => {
561 let now = Date.now();
562 let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
563 if (now - this.lastThread.timestamp < 30 * 1000 &&
564 threadInfo?.['1'] == currentPage.thread &&
565 threadInfo?.['3'] == currentPage.forum)
566 return Promise.resolve(this.lastThread);
567 return Promise.reject(
568 new Error('Didn\'t receive thread information'));
569 },
570 {
571 interval: 500,
572 timeout: 30 * 1000,
573 })
574 .then(thread => {
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100575 const [info, tooltips] = this.getThreadInfo(thread.body?.['1']);
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100576 this.addExtraInfoElement(info, content);
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100577 for (const tooltip of tooltips) new MDCTooltip(tooltip);
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100578 })
579 .catch(err => {
580 console.error(
581 'extraInfo: error while injecting question extra info: ', err);
582 });
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100583 }
584
585 injectAtQuestionIfEnabled(question) {
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100586 this.isEnabled('extrainfo').then(isEnabled => {
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100587 if (isEnabled) return this.injectAtQuestion(question);
588 });
589 }
590
591 getMessagesByType(thread, type) {
592 if (type === 'reply') return thread?.['1']?.['3'];
593 if (type === 'lastMessage') return thread?.['1']?.['17']?.['3'];
594 if (type === 'suggested') return thread?.['1']?.['17']?.['4'];
595 if (type === 'recommended') return thread?.['1']?.['17']?.['1'];
596 }
597
598 getMessageByTypeAndIndex(thread, type, index) {
599 return this.getMessagesByType(thread, type)?.[index];
600 }
601
602 // Returns true if the last message is included in the messages array (in the
603 // extension context, we say those messages are of the type "reply").
604 lastMessageInReplies(thread) {
605 const lastMessageId = thread?.['1']?.['17']?.['3']?.[0]?.['1']?.['1'];
606 if (!lastMessageId) return true;
607
608 // If the last message is included in the lastMessage array, check if it
609 // also exists in the messages/replies array.
610 const replies = thread?.['1']?.['3'];
611 if (!replies?.length) return false;
612 const lastReplyIndex = replies.length - 1;
613 const lastReplyId = replies[lastReplyIndex]?.['1']?.['1'];
614 return lastMessageId && lastMessageId == lastReplyId;
615 }
616
617 getMessageInfo(thread, message) {
618 const section = message.parentNode;
619
620 let type = 'reply';
621 if (section?.querySelector?.('.heading material-icon[icon="auto_awesome"]'))
622 type = 'suggested';
623 if (section?.querySelector?.('.heading material-icon[icon="check_circle"]'))
624 type = 'recommended';
625
626 let index = -1;
627 let messagesInDom = section.querySelectorAll('ec-message');
628
629 // Number of messages in the DOM.
630 const n = messagesInDom.length;
631
632 if (type !== 'reply') {
633 for (let i = 0; i < n; ++i) {
634 if (message.isEqualNode(messagesInDom[i])) {
635 index = i;
636 break;
637 }
638 }
639 } else {
640 // If the type of the message is a reply, things are slightly more
641 // complex, since replies are paginated and the last message should be
642 // treated separately (it is included diferently in the API response).
643 let lastMessageInReplies = this.lastMessageInReplies(thread);
644 if (message.isEqualNode(messagesInDom[n - 1]) && !lastMessageInReplies) {
645 type = 'lastMessage';
646 index = 0
647 } else {
648 // Number of messages in the current API response.
649 const messagesInResponse = this.getMessagesByType(thread, type);
650 const m = messagesInResponse.length;
651 // If the last message is included in the replies array, we also have to
652 // consider the last message in the DOM.
653 let modifier = lastMessageInReplies ? 1 : 0;
654 for (let k = 0; k < m; ++k) {
655 let i = n - 2 - k + modifier;
656 if (message.isEqualNode(messagesInDom[i])) {
657 index = m - 1 - k;
658 break;
659 }
660 }
661 }
662 }
663
664 return [type, index];
665 }
666
667 injectAtMessage(messageNode) {
668 let currentPage = parseUrl(location.href);
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100669 if (currentPage === false) {
670 console.error('extraInfo: couldn\'t parse current URL:', location.href);
671 return;
672 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100673
674 let footer = messageNode.querySelector('.footer-fill');
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100675 if (!footer) {
676 console.error('extraInfo: message doesn\'t have a footer:', messageNode);
677 return;
678 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100679
680 const [type, index] =
681 this.getMessageInfo(this.lastThread.body, messageNode);
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100682 if (index == -1) {
683 console.error('extraInfo: this.getMessageInfo() returned index -1.');
684 return;
685 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100686
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100687 waitFor(
688 () => {
689 let now = Date.now();
690 let threadInfo = this.lastThread.body['1']?.['2']?.['1'];
691 if (now - this.lastThread.timestamp < 30 * 1000 &&
692 threadInfo?.['1'] == currentPage.thread &&
693 threadInfo?.['3'] == currentPage.forum) {
694 const message = this.getMessageByTypeAndIndex(
695 this.lastThread.body, type, index);
696 if (message) return Promise.resolve(message);
697 }
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100698
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100699 return Promise.reject(new Error(
700 'Didn\'t receive thread information (type: ' + type +
701 ', index: ' + index + ')'));
702 },
703 {
704 interval: 1000,
705 timeout: 30 * 1000,
706 })
707 .then(message => {
708 let info = [];
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100709
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100710 const endPendingStateTimestampMicros = message['1']?.['17'];
711 const [pendingStateInfo, pendingTooltip] =
712 this.getPendingStateInfo(endPendingStateTimestampMicros);
713 if (pendingStateInfo) info.push(pendingStateInfo);
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100714
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100715 const itemMetadata = message['1']?.['5'];
716 const mdInfo = this.getMetadataInfo(itemMetadata);
717 info.push(...mdInfo);
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100718
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100719 const liveReviewStatus = message['1']?.['36'];
720 const [liveReviewInfo, liveReviewTooltip] =
721 this.getLiveReviewStatusInfo(liveReviewStatus);
722 if (liveReviewInfo) info.push(liveReviewInfo);
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100723
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100724 this.addExtraInfoElement(info, footer);
725 if (pendingTooltip) new MDCTooltip(pendingTooltip);
726 if (liveReviewTooltip) new MDCTooltip(liveReviewTooltip);
727 })
728 .catch(err => {
729 console.error(
730 'extraInfo: error while injecting message extra info: ', err);
731 });
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100732 }
733
734 injectAtMessageIfEnabled(message) {
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100735 this.isEnabled('extrainfo').then(isEnabled => {
Adrià Vilanova Martínez6e4a68d2022-01-24 21:44:32 +0100736 if (isEnabled) return this.injectAtMessage(message);
737 });
738 }
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100739
740 /**
Adrià Vilanova Martínez854cb912022-02-02 16:18:23 +0100741 * Thread list functionality
742 */
743 injectAtThreadList(li) {
744 waitFor(
745 () => {
746 const header = li.querySelector(
747 'ec-thread-summary .main-header .panel-description a.header');
748 if (header === null) {
749 console.error(
750 'extraInfo: Header is not present in the thread item\'s DOM.');
751 return;
752 }
753
754 const threadInfo = parseUrl(header.href);
755 if (threadInfo === false) {
756 console.error('extraInfo: Thread\'s link cannot be parsed.');
757 return;
758 }
759
760 let authorLine = li.querySelector(
761 'ec-thread-summary .header-content .top-row .author-line');
762 if (!authorLine) {
763 console.error(
764 'extraInfo: Author line is not present in the thread item\'s DOM.');
765 return;
766 }
767
768 let thread = this.lastThreadList?.['1']?.['2']?.find?.(t => {
769 return t?.['2']?.['1']?.['1'] == threadInfo.thread &&
770 t?.['2']?.['1']?.['3'] == threadInfo.forum;
771 });
772 if (thread) return Promise.resolve([thread, authorLine]);
773 return Promise.reject(
774 new Error('Didn\'t receive thread information'));
775 },
776 {
777 interval: 500,
778 timeout: 7 * 1000,
779 })
780 .then(response => {
781 const [thread, authorLine] = response;
782 const state = thread?.['2']?.['12']?.['1'];
783 if (state && ![1, 13, 18, 9].includes(state)) {
784 let label = document.createElement('div');
785 label.classList.add('TWPT-label');
786
787 const [badge, badgeTooltip] = createExtBadge();
788
789 let span = document.createElement('span');
790 span.textContent = kItemMetadataState[state] ?? 'State ' + state;
791
792 label.append(badge, span);
793 authorLine.prepend(label);
794 new MDCTooltip(badgeTooltip);
795 }
796 })
797 .catch(err => {
798 console.error(
799 'extraInfo: error while injecting thread list extra info: ', err);
800 });
801 }
802
803 injectAtThreadListIfEnabled(li) {
804 this.isEnabled('extrainfo').then(isEnabled => {
805 if (isEnabled) this.injectAtThreadList(li);
806 });
807 }
808
809 injectAtExpandedThreadList(toolbelt) {
810 const header =
811 toolbelt?.parentNode?.parentNode?.parentNode?.querySelector?.(
812 '.main-header .panel-description a.header');
813 if (header === null) {
814 console.error(
815 'extraInfo: Header is not present in the thread item\'s DOM.');
816 return;
817 }
818
819 const threadInfo = parseUrl(header.href);
820 if (threadInfo === false) {
821 console.error('extraInfo: Thread\'s link cannot be parsed.');
822 return;
823 }
824
825 waitFor(
826 () => {
827 let thread = this.lastThreadList?.['1']?.['2']?.find?.(t => {
828 return t?.['2']?.['1']?.['1'] == threadInfo.thread &&
829 t?.['2']?.['1']?.['3'] == threadInfo.forum;
830 });
831 if (thread) return Promise.resolve(thread);
832 return Promise.reject(
833 new Error('Didn\'t receive thread information'));
834 },
835 {
836 interval: 500,
837 timeout: 7 * 1000,
838 })
839 .then(thread => {
840 const [info, tooltips] = this.getThreadInfo(thread);
841 this.addExtraInfoElement(info, toolbelt);
842 for (const tooltip of tooltips) new MDCTooltip(tooltip);
843 })
844 .catch(err => {
845 console.error(
846 'extraInfo: error while injecting thread list extra info: ', err);
847 });
848 }
849
850 injectAtExpandedThreadListIfEnabled(toolbelt) {
851 this.isEnabled('extrainfo').then(isEnabled => {
852 if (isEnabled) this.injectAtExpandedThreadList(toolbelt);
853 });
854 }
855
856 /**
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100857 * Per-forum stats in user profiles.
858 */
859
860 injectPerForumStats(chart) {
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100861 waitFor(
862 () => {
863 let now = Date.now();
864 if (now - this.lastProfile.timestamp < 15 * 1000)
865 return Promise.resolve(this.lastProfile);
866 return Promise.reject(new Error(
867 'Didn\'t receive profile information (for per-profile stats)'));
868 },
869 {
870 interval: 500,
871 timeout: 15 * 1000,
872 })
873 .then(profile => {
Adrià Vilanova Martínez69c30502022-01-28 20:47:08 +0100874 new PerForumStatsSection(
875 chart?.parentNode, profile.body, this.displayLanguage);
Adrià Vilanova Martínez102041d2022-01-28 18:39:29 +0100876 })
877 .catch(err => {
878 console.error(
879 'extraInfo: error while preparing to inject per-forum stats: ',
880 err);
881 });
Adrià Vilanova Martínez4f56d562022-01-26 00:23:27 +0100882 }
883
884 injectPerForumStatsIfEnabled(chart) {
885 this.isEnabled('perforumstats').then(isEnabled => {
886 if (isEnabled) this.injectPerForumStats(chart);
887 });
888 }
Adrià Vilanova Martínez7e8796c2022-01-23 21:46:46 +0100889}