blob: 41ccd1c4a36afc958ec66326a2a49f512196ecf1 [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
105 }
106 },
107 // |authentication| is false because otherwise this would mark
108 // the thread as read as a side effect, and that would mark all
109 // threads in the list as read.
110 //
111 // Due to the fact that we have to call this endpoint
112 // anonymously, this means we can't retrieve information about
113 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200114 /* authentication = */ false, /* authuser = */ 0,
115 /* returnUnauthorizedStatus = */ true)
116 .then(response => {
117 if (response.unauthorized)
118 return this.db.putUnauthorizedForum(thread.forum).then(() => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200119 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100120 state: 'private',
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200121 };
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200122 });
123
124 var data = response.body;
125
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200126 var numMessages = data?.['1']?.['8'];
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100127 if (numMessages === undefined) {
128 if (data?.['1']?.['10'] === false) {
129 return {
130 state: 'notVisible',
131 };
132 } else {
133 throw new Error(
134 'Request to view thread doesn\'t include the number of messages');
135 }
136 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200137
138 var messages = numMessages == 0 ? [] : data?.['1']['3'];
139 if (messages === undefined)
140 throw new Error(
141 'numMessages was ' + numMessages +
142 ' but the response didn\'t include any message.');
143
144 var author = data?.['1']?.['4'];
145 if (author === undefined)
146 throw new Error(
147 'Author isn\'t included in the ViewThread response.');
148
149 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100150 state: 'ok',
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200151 messages,
152 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200153
154 // The following fields are useful for the cache and can be
155 // undefined, but this is checked before adding an entry to the
156 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200157 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200158 };
159 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200160 }
161
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200162 // Get the following data:
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100163 // - |state|: the state of the request (can be 'ok', 'private' or
164 // 'notVisible').
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200165 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100166 // the API, if |state| is 'ok'.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200167 getVisibleAvatarsFromServer(thread, num) {
168 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100169 if (result.state != 'ok')
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200170 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100171 state: result.state,
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200172 };
173
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200174 var messages = result.messages;
175 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200176 var lastMessageId = result.lastMessageId;
177
178 var avatarUrls = [];
179
180 var authorUrl = author?.['1']?.['2'];
181 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
182
183 for (var m of messages) {
184 var url = m?.['3']?.['1']?.['2'];
185
186 if (url === undefined) continue;
187 if (!avatarUrls.includes(url)) avatarUrls.push(url);
188 if (avatarUrls.length == 3) break;
189 }
190
191 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200192 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200193 this.db.putCacheEntry({
194 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200195 lastMessageId,
196 avatarUrls,
197 num,
198 lastUsedTimestamp: Math.floor(Date.now() / 1000),
199 });
200
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200201 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100202 state: 'ok',
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200203 avatars: avatarUrls,
204 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200205 });
206 }
207
208 // Returns an object with a cache entry that matches the request if found (via
209 // the |entry| property). The property |found| indicates whether the cache
210 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200211 //
212 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
213 // must be within the last 30 seconds (which means that the thread has been
214 // checked for a potential invalidation).
215 getVisibleAvatarsFromCache(thread, num, checkRecent) {
216 return this.db.getCacheEntry(thread.thread).then(entry => {
217 if (entry === undefined || entry.num < num)
218 return {
219 found: false,
220 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200221
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200222 if (checkRecent) {
223 var now = Math.floor(Date.now() / 1000);
224 var diff = now - entry.lastUsedTimestamp;
225 if (diff > 30)
226 throw new Error(
227 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
228 thread.thread + ' the difference is: ' + diff + ').');
229 }
230
231 return {
232 found: true,
233 entry,
234 };
235 });
236 }
237
238 // Waits for the XHR interceptor to invalidate any outdated threads and
239 // returns what getVisibleAvatarsFromCache returns. If this times out, it
240 // returns the current cache entry anyways if it exists.
241 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
242 return waitFor(
243 () => this.getVisibleAvatarsFromCache(
244 thread, num, /* checkRecent = */ true),
245 {
246 interval: 450,
247 timeout: 2 * 1000,
248 })
249 .catch(err => {
250 console.debug(
251 '[threadListAvatars] Error while retrieving avatars from cache ' +
252 '(probably timed out waiting for lastUsedTimestamp to change):',
253 err);
254
255 // Sometimes when going back to a thread list, the API call to load
256 // the thread list is not made, and so the previous piece of code
257 // times out waiting to intercept that API call and handle thread
258 // invalidations.
259 //
260 // If this is the case, this point will be reached. We'll assume we
261 // intercept all API calls, so reaching this point means that an API
262 // call wasn't made. Therefore, try again to get visible avatars from
263 // the cache without checking whether the entry has been checked for
264 // potential invalidation.
265 //
266 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
267 return this.getVisibleAvatarsFromCache(
268 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200269 });
270 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200271
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200272 // Get an object with the following data:
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100273 // - |state|: 'ok' (the avatars list could be retrieved), 'private' (the
274 // thread is in a private forum, so the avatars list could not be retrieved),
275 // or 'notVisible' (the thread has the visible field set to false).
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200276 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200277 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200278 return this.isPrivateThread(thread).then(isPrivate => {
279 if (isPrivate)
280 return {
281 state: 'private',
282 avatars: [],
283 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200284
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200285 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200286 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200287 if (!res.found) {
288 var err = new Error('Cache entry doesn\'t exist.');
289 err.name = 'notCached';
290 throw err;
291 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200292 return {
293 state: 'ok',
294 avatars: res.entry.avatarUrls,
295 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200296 })
297 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200298 // If the name is "notCached", then this is not an actual error so
299 // don't log an error, but still get avatars from the server.
300 if (err?.name !== 'notCached')
301 console.error(
302 '[threadListAvatars] Error while accessing avatars cache:',
303 err);
304
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200305 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100306 if (res.state != 'ok')
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200307 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100308 state: res.state,
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200309 avatars: [],
310 };
311
312 return {
313 state: 'ok',
314 avatars: res.avatars,
315 };
316 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200317 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200318 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200319 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200320
321 // Inject avatars for thread summary (thread item) |node| in a thread list.
322 inject(node) {
323 var header = node.querySelector(
324 'ec-thread-summary .main-header .panel-description a.header');
325 if (header === null) {
326 console.error(
327 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
328 return;
329 }
330
331 var thread = parseUrl(header.href);
332 if (thread === false) {
333 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
334 return;
335 }
336
337 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200338 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200339 var avatarsContainer = document.createElement('div');
340 avatarsContainer.classList.add('TWPT-avatars');
341
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200342 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200343
avm999632485a3e2021-09-08 22:18:38 +0200344 let singleAvatar;
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100345 if (res.state == 'private' || res.state == 'notVisible') {
avm999632485a3e2021-09-08 22:18:38 +0200346 singleAvatar = document.createElement('div');
347 singleAvatar.classList.add('TWPT-avatar-private-placeholder');
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100348 singleAvatar.textContent =
349 (res.state == 'private' ? 'person_off' : 'visibility_off');
avm999632485a3e2021-09-08 22:18:38 +0200350 avatarsContainer.appendChild(singleAvatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200351 } else {
352 for (var i = 0; i < avatarUrls.length; ++i) {
353 var avatar = document.createElement('div');
354 avatar.classList.add('TWPT-avatar');
355 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
356 avatarsContainer.appendChild(avatar);
357 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200358 }
359
360 header.appendChild(avatarsContainer);
avm999632485a3e2021-09-08 22:18:38 +0200361
362 if (res.state == 'private') {
363 var label = chrome.i18n.getMessage(
364 'inject_threadlistavatars_private_thread_indicator_label');
365 createPlainTooltip(singleAvatar, label);
366 }
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100367 if (res.state == 'notVisible') {
368 var label = chrome.i18n.getMessage(
369 'inject_threadlistavatars_invisible_thread_indicator_label');
370 createPlainTooltip(singleAvatar, label);
371 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200372 })
373 .catch(err => {
374 console.error(
375 '[threadListAvatars] Could not retrieve avatars for thread',
376 thread, err);
377 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200378 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200379
380 // Inject avatars for thread summary (thread item) |node| in a thread list if
381 // the threadlistavatars option is enabled.
382 injectIfEnabled(node) {
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +0100383 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200384 if (isEnabled) {
385 document.body.classList.add('TWPT-threadlistavatars-enabled');
386 this.inject(node);
387 } else {
388 document.body.classList.remove('TWPT-threadlistavatars-enabled');
389 }
390 });
391 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200392};