blob: 42ea8112f5094bb3923ff9a2c81233429652c458 [file] [log] [blame]
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +02001import {waitFor} from 'poll-until-promise';
2
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +02003import {CCApi} from '../../common/api.js';
4import {parseUrl} from '../../common/commonUtils.js';
Adrià Vilanova Martínez6e4f9c72022-01-24 23:27:11 +01005import OptionsWatcher from '../../common/optionsWatcher.js';
avm999632485a3e2021-09-08 22:18:38 +02006import {createPlainTooltip} from '../../common/tooltip.js';
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +02007
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +02008import AvatarsDB from './utils/AvatarsDB.js'
9
10export default class AvatarsHandler {
11 constructor() {
12 this.isFilterSetUp = false;
13 this.privateForums = [];
14 this.db = new AvatarsDB();
Adrià Vilanova Martínez6e4f9c72022-01-24 23:27:11 +010015 this.optionsWatcher = new OptionsWatcher(['threadlistavatars']);
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +020016
17 // Preload whether the option is enabled or not. This is because in the case
18 // avatars should be injected, if we don't preload this the layout will
19 // shift when injecting the first avatar.
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +010020 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +020021 if (isEnabled)
22 document.body.classList.add('TWPT-threadlistavatars-enabled');
23 });
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +010024 }
25
26 // Returns a promise resolving to whether the threadlistavatars feature is
27 // enabled.
28 isEnabled() {
Adrià Vilanova Martínez6e4f9c72022-01-24 23:27:11 +010029 return this.optionsWatcher.isEnabled('threadlistavatars');
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020030 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020031
32 // Gets a list of private forums. If it is already cached, the cached list is
33 // returned; otherwise it is also computed and cached.
34 getPrivateForums() {
35 return new Promise((resolve, reject) => {
36 if (this.isFilterSetUp) return resolve(this.privateForums);
37
38 if (!document.documentElement.hasAttribute('data-startup'))
39 return reject('[threadListAvatars] Couldn\'t get startup data.');
40
41 var startupData =
42 JSON.parse(document.documentElement.getAttribute('data-startup'));
43 var forums = startupData?.['1']?.['2'];
44 if (forums === undefined)
45 return reject(
46 '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
47
48 for (var f of forums) {
49 var forumId = f?.['2']?.['1']?.['1'];
50 var forumVisibility = f?.['2']?.['18'];
51 if (forumId === undefined || forumVisibility === undefined) {
52 console.warn(
53 '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
54 f);
55 continue;
56 }
57
58 // forumVisibility's value 1 means "PUBLIC".
59 if (forumVisibility != 1) this.privateForums.push(forumId);
60 }
61
62 // Forum 51488989 is marked as public but it is in fact private.
63 this.privateForums.push('51488989');
64
65 this.isFilterSetUp = true;
66 return resolve(this.privateForums);
67 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020068 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020069
70 // Some threads belong to private forums, and this feature will not be able to
71 // get its avatars since it makes an anonymomus call to get the contents of
72 // the thread.
73 //
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020074 // This function returns whether the thread belongs to a known private forum.
75 isPrivateThread(thread) {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020076 return this.getPrivateForums().then(privateForums => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020077 if (privateForums.includes(thread.forum)) return true;
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +020078
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020079 return this.db.isForumUnauthorized(thread.forum);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020080 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020081 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020082
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020083 // Get an object with the author of the thread, an array of the first |num|
84 // replies from the thread |thread|, and additional information about the
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +010085 // thread.
86 //
87 // It also returns |state| which can be 'ok', 'private' or 'notVisible'. If it
88 // is 'private' or 'notVisible', the previous properties will be missing.
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020089 getFirstMessages(thread, num = 15) {
90 return CCApi(
91 'ViewThread', {
92 1: thread.forum,
93 2: thread.thread,
94 // options
95 3: {
96 // pagination
97 1: {
98 2: num, // maxNum
99 },
100 3: true, // withMessages
101 5: true, // withUserProfile
102 10: false, // withPromotedMessages
103 16: false, // withThreadNotes
104 18: true, // sendNewThreadIfMoved
Adrià Vilanova Martínez4b6c1cb2022-09-30 13:54:18 +0200105 23: true, // withFlattenedMessages
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200106 }
107 },
108 // |authentication| is false because otherwise this would mark
109 // the thread as read as a side effect, and that would mark all
110 // threads in the list as read.
111 //
112 // Due to the fact that we have to call this endpoint
113 // anonymously, this means we can't retrieve information about
114 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200115 /* authentication = */ false, /* authuser = */ 0,
116 /* returnUnauthorizedStatus = */ true)
117 .then(response => {
118 if (response.unauthorized)
119 return this.db.putUnauthorizedForum(thread.forum).then(() => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200120 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100121 state: 'private',
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200122 };
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200123 });
124
125 var data = response.body;
126
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200127 var numMessages = data?.['1']?.['8'];
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100128 if (numMessages === undefined) {
129 if (data?.['1']?.['10'] === false) {
130 return {
131 state: 'notVisible',
132 };
133 } else {
134 throw new Error(
135 'Request to view thread doesn\'t include the number of messages');
136 }
137 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200138
139 var messages = numMessages == 0 ? [] : data?.['1']['3'];
140 if (messages === undefined)
141 throw new Error(
142 'numMessages was ' + numMessages +
143 ' but the response didn\'t include any message.');
144
145 var author = data?.['1']?.['4'];
146 if (author === undefined)
147 throw new Error(
148 'Author isn\'t included in the ViewThread response.');
149
150 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100151 state: 'ok',
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200152 messages,
153 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200154
155 // The following fields are useful for the cache and can be
156 // undefined, but this is checked before adding an entry to the
157 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200158 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200159 };
Adrià Vilanova Martínez96382a92022-04-03 20:48:36 +0200160 })
161 .catch(cause => {
162 throw new Error('Failed ViewThread request.', {cause});
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200163 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200164 }
165
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200166 // Get the following data:
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100167 // - |state|: the state of the request (can be 'ok', 'private' or
168 // 'notVisible').
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200169 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100170 // the API, if |state| is 'ok'.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200171 getVisibleAvatarsFromServer(thread, num) {
172 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100173 if (result.state != 'ok')
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200174 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100175 state: result.state,
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200176 };
177
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200178 var messages = result.messages;
179 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200180 var lastMessageId = result.lastMessageId;
181
Adrià Vilanova Martínez4b6c1cb2022-09-30 13:54:18 +0200182 var avatars = [];
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200183
184 var authorUrl = author?.['1']?.['2'];
Adrià Vilanova Martínez4b6c1cb2022-09-30 13:54:18 +0200185 if (authorUrl !== undefined) avatars.push({url: authorUrl, timestamp: 0});
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200186
187 for (var m of messages) {
188 var url = m?.['3']?.['1']?.['2'];
Adrià Vilanova Martínez4b6c1cb2022-09-30 13:54:18 +0200189 if (url === undefined) continue;
190
191 var timestamp = m?.['1']?.['1']?.['2'];
192 avatars.push({url, timestamp});
193
194 m?.[12]?.forEach?.(messageOrGap => {
195 if (!messageOrGap[1]) return;
196
197 var url = messageOrGap[1]?.[3]?.[1]?.[2];
198 if (url === undefined) return;
199
200 var timestamp = messageOrGap[1]?.[1]?.[1]?.[2];
201 avatars.push({url, timestamp});
202 });
203 }
204 avatars.sort((a, b) => {
205 // If a timestamp is undefined, we'll push it to the end of the list
206 if (a === undefined && b === undefined) return 0; // both are equal
207 if (a === undefined) return 1; // b goes first
208 if (b === undefined) return -1; // a goes first
209 return a.timestamp - b.timestamp; // Old avatars go first
210 });
211
212 var avatarUrls = [];
213
214 for (var a of avatars) {
215 var url = a.url;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200216
217 if (url === undefined) continue;
218 if (!avatarUrls.includes(url)) avatarUrls.push(url);
219 if (avatarUrls.length == 3) break;
220 }
221
222 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200223 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200224 this.db.putCacheEntry({
225 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200226 lastMessageId,
227 avatarUrls,
228 num,
229 lastUsedTimestamp: Math.floor(Date.now() / 1000),
230 });
231
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200232 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100233 state: 'ok',
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200234 avatars: avatarUrls,
235 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200236 });
237 }
238
239 // Returns an object with a cache entry that matches the request if found (via
240 // the |entry| property). The property |found| indicates whether the cache
241 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200242 //
243 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
244 // must be within the last 30 seconds (which means that the thread has been
245 // checked for a potential invalidation).
246 getVisibleAvatarsFromCache(thread, num, checkRecent) {
247 return this.db.getCacheEntry(thread.thread).then(entry => {
248 if (entry === undefined || entry.num < num)
249 return {
250 found: false,
251 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200252
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200253 if (checkRecent) {
254 var now = Math.floor(Date.now() / 1000);
255 var diff = now - entry.lastUsedTimestamp;
256 if (diff > 30)
257 throw new Error(
258 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
259 thread.thread + ' the difference is: ' + diff + ').');
260 }
261
262 return {
263 found: true,
264 entry,
265 };
266 });
267 }
268
269 // Waits for the XHR interceptor to invalidate any outdated threads and
270 // returns what getVisibleAvatarsFromCache returns. If this times out, it
271 // returns the current cache entry anyways if it exists.
272 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
273 return waitFor(
274 () => this.getVisibleAvatarsFromCache(
275 thread, num, /* checkRecent = */ true),
276 {
277 interval: 450,
278 timeout: 2 * 1000,
279 })
280 .catch(err => {
281 console.debug(
282 '[threadListAvatars] Error while retrieving avatars from cache ' +
283 '(probably timed out waiting for lastUsedTimestamp to change):',
284 err);
285
286 // Sometimes when going back to a thread list, the API call to load
287 // the thread list is not made, and so the previous piece of code
288 // times out waiting to intercept that API call and handle thread
289 // invalidations.
290 //
291 // If this is the case, this point will be reached. We'll assume we
292 // intercept all API calls, so reaching this point means that an API
293 // call wasn't made. Therefore, try again to get visible avatars from
294 // the cache without checking whether the entry has been checked for
295 // potential invalidation.
296 //
297 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
298 return this.getVisibleAvatarsFromCache(
299 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200300 });
301 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200302
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200303 // Get an object with the following data:
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100304 // - |state|: 'ok' (the avatars list could be retrieved), 'private' (the
305 // thread is in a private forum, so the avatars list could not be retrieved),
306 // or 'notVisible' (the thread has the visible field set to false).
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200307 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200308 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200309 return this.isPrivateThread(thread).then(isPrivate => {
310 if (isPrivate)
311 return {
312 state: 'private',
313 avatars: [],
314 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200315
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200316 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200317 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200318 if (!res.found) {
319 var err = new Error('Cache entry doesn\'t exist.');
320 err.name = 'notCached';
321 throw err;
322 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200323 return {
324 state: 'ok',
325 avatars: res.entry.avatarUrls,
326 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200327 })
328 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200329 // If the name is "notCached", then this is not an actual error so
330 // don't log an error, but still get avatars from the server.
331 if (err?.name !== 'notCached')
332 console.error(
333 '[threadListAvatars] Error while accessing avatars cache:',
334 err);
335
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200336 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100337 if (res.state != 'ok')
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200338 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100339 state: res.state,
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200340 avatars: [],
341 };
342
343 return {
344 state: 'ok',
345 avatars: res.avatars,
346 };
347 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200348 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200349 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200350 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200351
352 // Inject avatars for thread summary (thread item) |node| in a thread list.
353 inject(node) {
avm99963d08c37e2022-09-25 20:41:58 +0200354 var header =
355 node.querySelector('ec-thread-summary .main-header .action .header');
356 var headerContent = header.querySelector(':scope > .header-content');
357 var expandBtn = header.querySelector(':scope > .expand-button');
358 if (headerContent === null) {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200359 console.error(
360 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
361 return;
362 }
avm99963d08c37e2022-09-25 20:41:58 +0200363 if (expandBtn === null) {
364 console.error(
365 '[threadListAvatars] Expand button is not present in the thread item\'s DOM.');
366 return;
367 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200368
avm99963d08c37e2022-09-25 20:41:58 +0200369 var thread = parseUrl(headerContent.href);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200370 if (thread === false) {
371 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
372 return;
373 }
374
375 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200376 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200377 var avatarsContainer = document.createElement('div');
378 avatarsContainer.classList.add('TWPT-avatars');
379
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200380 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200381
avm999632485a3e2021-09-08 22:18:38 +0200382 let singleAvatar;
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100383 if (res.state == 'private' || res.state == 'notVisible') {
avm999632485a3e2021-09-08 22:18:38 +0200384 singleAvatar = document.createElement('div');
385 singleAvatar.classList.add('TWPT-avatar-private-placeholder');
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100386 singleAvatar.textContent =
387 (res.state == 'private' ? 'person_off' : 'visibility_off');
avm999632485a3e2021-09-08 22:18:38 +0200388 avatarsContainer.appendChild(singleAvatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200389 } else {
390 for (var i = 0; i < avatarUrls.length; ++i) {
391 var avatar = document.createElement('div');
392 avatar.classList.add('TWPT-avatar');
393 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
394 avatarsContainer.appendChild(avatar);
395 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200396 }
397
avm99963d08c37e2022-09-25 20:41:58 +0200398 header.insertBefore(avatarsContainer, expandBtn);
avm999632485a3e2021-09-08 22:18:38 +0200399
400 if (res.state == 'private') {
401 var label = chrome.i18n.getMessage(
402 'inject_threadlistavatars_private_thread_indicator_label');
403 createPlainTooltip(singleAvatar, label);
404 }
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100405 if (res.state == 'notVisible') {
406 var label = chrome.i18n.getMessage(
407 'inject_threadlistavatars_invisible_thread_indicator_label');
408 createPlainTooltip(singleAvatar, label);
409 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200410 })
411 .catch(err => {
412 console.error(
413 '[threadListAvatars] Could not retrieve avatars for thread',
414 thread, err);
415 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200416 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200417
418 // Inject avatars for thread summary (thread item) |node| in a thread list if
419 // the threadlistavatars option is enabled.
420 injectIfEnabled(node) {
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +0100421 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200422 if (isEnabled) {
423 document.body.classList.add('TWPT-threadlistavatars-enabled');
424 this.inject(node);
425 } else {
426 document.body.classList.remove('TWPT-threadlistavatars-enabled');
427 }
428 });
429 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200430};