blob: 2936fac6ab9eb22c95008de91b72ec212a00dff5 [file] [log] [blame]
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +01001import {Mutex, withTimeout} from 'async-mutex';
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +02002import {waitFor} from 'poll-until-promise';
3
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +02004import {CCApi} from '../../common/api.js';
5import {parseUrl} from '../../common/commonUtils.js';
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +02006import {isOptionEnabled} from '../../common/optionsUtils.js';
avm999632485a3e2021-09-08 22:18:38 +02007import {createPlainTooltip} from '../../common/tooltip.js';
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +02008
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +02009import AvatarsDB from './utils/AvatarsDB.js'
10
11export default class AvatarsHandler {
12 constructor() {
13 this.isFilterSetUp = false;
14 this.privateForums = [];
15 this.db = new AvatarsDB();
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +010016 this.featureEnabled = false;
17 this.featureEnabledIsStale = true;
18 this.featureEnabledMutex = withTimeout(new Mutex(), 60 * 1000);
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +020019
20 // Preload whether the option is enabled or not. This is because in the case
21 // avatars should be injected, if we don't preload this the layout will
22 // shift when injecting the first avatar.
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +010023 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +020024 if (isEnabled)
25 document.body.classList.add('TWPT-threadlistavatars-enabled');
26 });
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +010027
28 // If the extension settings change, set this.featureEnabled as stale. We
29 // could try only doing this only when we're sure it has changed, but there
30 // are many factors (if the user has changed it manually, if a kill switch
31 // was activated, etc.) so we'll do it every time.
32 chrome.storage.sync.onChanged.addListener(() => {
33 console.debug('[threadListAvatars] Marking featureEnabled as stale.');
34 this.featureEnabledIsStale = true;
35 });
36 }
37
38 // Returns a promise resolving to whether the threadlistavatars feature is
39 // enabled.
40 isEnabled() {
41 // When this.featureEnabled is marked as stale, the next time avatars are
42 // injected there is a flood of calls to isEnabled(), which in turn causes a
43 // flood of calls to isOptionEnabled() because it takes some time for it to
44 // be marked as not stale. Thus, hiding the logic behind a mutex fixes this.
45 return this.featureEnabledMutex.runExclusive(() => {
46 if (!this.featureEnabledIsStale)
47 return Promise.resolve(this.featureEnabled);
48
49 return isOptionEnabled('threadlistavatars').then(isEnabled => {
50 this.featureEnabled = isEnabled;
51 this.featureEnabledIsStale = false;
52 return isEnabled;
53 });
54 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020055 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020056
57 // Gets a list of private forums. If it is already cached, the cached list is
58 // returned; otherwise it is also computed and cached.
59 getPrivateForums() {
60 return new Promise((resolve, reject) => {
61 if (this.isFilterSetUp) return resolve(this.privateForums);
62
63 if (!document.documentElement.hasAttribute('data-startup'))
64 return reject('[threadListAvatars] Couldn\'t get startup data.');
65
66 var startupData =
67 JSON.parse(document.documentElement.getAttribute('data-startup'));
68 var forums = startupData?.['1']?.['2'];
69 if (forums === undefined)
70 return reject(
71 '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
72
73 for (var f of forums) {
74 var forumId = f?.['2']?.['1']?.['1'];
75 var forumVisibility = f?.['2']?.['18'];
76 if (forumId === undefined || forumVisibility === undefined) {
77 console.warn(
78 '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
79 f);
80 continue;
81 }
82
83 // forumVisibility's value 1 means "PUBLIC".
84 if (forumVisibility != 1) this.privateForums.push(forumId);
85 }
86
87 // Forum 51488989 is marked as public but it is in fact private.
88 this.privateForums.push('51488989');
89
90 this.isFilterSetUp = true;
91 return resolve(this.privateForums);
92 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020093 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020094
95 // Some threads belong to private forums, and this feature will not be able to
96 // get its avatars since it makes an anonymomus call to get the contents of
97 // the thread.
98 //
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020099 // This function returns whether the thread belongs to a known private forum.
100 isPrivateThread(thread) {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200101 return this.getPrivateForums().then(privateForums => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200102 if (privateForums.includes(thread.forum)) return true;
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200103
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200104 return this.db.isForumUnauthorized(thread.forum);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200105 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200106 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200107
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200108 // Get an object with the author of the thread, an array of the first |num|
109 // replies from the thread |thread|, and additional information about the
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200110 // thread. It also returns whether the thread is private, in which case the
111 // previous properties will be missing.
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200112 getFirstMessages(thread, num = 15) {
113 return CCApi(
114 'ViewThread', {
115 1: thread.forum,
116 2: thread.thread,
117 // options
118 3: {
119 // pagination
120 1: {
121 2: num, // maxNum
122 },
123 3: true, // withMessages
124 5: true, // withUserProfile
125 10: false, // withPromotedMessages
126 16: false, // withThreadNotes
127 18: true, // sendNewThreadIfMoved
128 }
129 },
130 // |authentication| is false because otherwise this would mark
131 // the thread as read as a side effect, and that would mark all
132 // threads in the list as read.
133 //
134 // Due to the fact that we have to call this endpoint
135 // anonymously, this means we can't retrieve information about
136 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200137 /* authentication = */ false, /* authuser = */ 0,
138 /* returnUnauthorizedStatus = */ true)
139 .then(response => {
140 if (response.unauthorized)
141 return this.db.putUnauthorizedForum(thread.forum).then(() => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200142 return {
143 isPrivate: true,
144 };
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200145 });
146
147 var data = response.body;
148
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200149 var numMessages = data?.['1']?.['8'];
150 if (numMessages === undefined)
151 throw new Error(
152 'Request to view thread doesn\'t include the number of messages');
153
154 var messages = numMessages == 0 ? [] : data?.['1']['3'];
155 if (messages === undefined)
156 throw new Error(
157 'numMessages was ' + numMessages +
158 ' but the response didn\'t include any message.');
159
160 var author = data?.['1']?.['4'];
161 if (author === undefined)
162 throw new Error(
163 'Author isn\'t included in the ViewThread response.');
164
165 return {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200166 isPrivate: false,
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200167 messages,
168 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200169
170 // The following fields are useful for the cache and can be
171 // undefined, but this is checked before adding an entry to the
172 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200173 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200174 };
175 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200176 }
177
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200178 // Get the following data:
179 // - |isPrivate|: whether the thread is private.
180 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
181 // the API, if |isPrivate| is false.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200182 getVisibleAvatarsFromServer(thread, num) {
183 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200184 if (result.isPrivate)
185 return {
186 isPrivate: true,
187 };
188
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200189 var messages = result.messages;
190 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200191 var lastMessageId = result.lastMessageId;
192
193 var avatarUrls = [];
194
195 var authorUrl = author?.['1']?.['2'];
196 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
197
198 for (var m of messages) {
199 var url = m?.['3']?.['1']?.['2'];
200
201 if (url === undefined) continue;
202 if (!avatarUrls.includes(url)) avatarUrls.push(url);
203 if (avatarUrls.length == 3) break;
204 }
205
206 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200207 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200208 this.db.putCacheEntry({
209 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200210 lastMessageId,
211 avatarUrls,
212 num,
213 lastUsedTimestamp: Math.floor(Date.now() / 1000),
214 });
215
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200216 return {
217 isPrivate: false,
218 avatars: avatarUrls,
219 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200220 });
221 }
222
223 // Returns an object with a cache entry that matches the request if found (via
224 // the |entry| property). The property |found| indicates whether the cache
225 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200226 //
227 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
228 // must be within the last 30 seconds (which means that the thread has been
229 // checked for a potential invalidation).
230 getVisibleAvatarsFromCache(thread, num, checkRecent) {
231 return this.db.getCacheEntry(thread.thread).then(entry => {
232 if (entry === undefined || entry.num < num)
233 return {
234 found: false,
235 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200236
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200237 if (checkRecent) {
238 var now = Math.floor(Date.now() / 1000);
239 var diff = now - entry.lastUsedTimestamp;
240 if (diff > 30)
241 throw new Error(
242 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
243 thread.thread + ' the difference is: ' + diff + ').');
244 }
245
246 return {
247 found: true,
248 entry,
249 };
250 });
251 }
252
253 // Waits for the XHR interceptor to invalidate any outdated threads and
254 // returns what getVisibleAvatarsFromCache returns. If this times out, it
255 // returns the current cache entry anyways if it exists.
256 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
257 return waitFor(
258 () => this.getVisibleAvatarsFromCache(
259 thread, num, /* checkRecent = */ true),
260 {
261 interval: 450,
262 timeout: 2 * 1000,
263 })
264 .catch(err => {
265 console.debug(
266 '[threadListAvatars] Error while retrieving avatars from cache ' +
267 '(probably timed out waiting for lastUsedTimestamp to change):',
268 err);
269
270 // Sometimes when going back to a thread list, the API call to load
271 // the thread list is not made, and so the previous piece of code
272 // times out waiting to intercept that API call and handle thread
273 // invalidations.
274 //
275 // If this is the case, this point will be reached. We'll assume we
276 // intercept all API calls, so reaching this point means that an API
277 // call wasn't made. Therefore, try again to get visible avatars from
278 // the cache without checking whether the entry has been checked for
279 // potential invalidation.
280 //
281 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
282 return this.getVisibleAvatarsFromCache(
283 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200284 });
285 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200286
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200287 // Get an object with the following data:
288 // - |state|: 'ok' (the avatars list could be retrieved) or 'private' (the
289 // thread is private, so the avatars list could not be retrieved).
290 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200291 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200292 return this.isPrivateThread(thread).then(isPrivate => {
293 if (isPrivate)
294 return {
295 state: 'private',
296 avatars: [],
297 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200298
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200299 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200300 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200301 if (!res.found) {
302 var err = new Error('Cache entry doesn\'t exist.');
303 err.name = 'notCached';
304 throw err;
305 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200306 return {
307 state: 'ok',
308 avatars: res.entry.avatarUrls,
309 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200310 })
311 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200312 // If the name is "notCached", then this is not an actual error so
313 // don't log an error, but still get avatars from the server.
314 if (err?.name !== 'notCached')
315 console.error(
316 '[threadListAvatars] Error while accessing avatars cache:',
317 err);
318
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200319 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
320 if (res.isPrivate)
321 return {
322 state: 'private',
323 avatars: [],
324 };
325
326 return {
327 state: 'ok',
328 avatars: res.avatars,
329 };
330 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200331 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200332 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200333 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200334
335 // Inject avatars for thread summary (thread item) |node| in a thread list.
336 inject(node) {
337 var header = node.querySelector(
338 'ec-thread-summary .main-header .panel-description a.header');
339 if (header === null) {
340 console.error(
341 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
342 return;
343 }
344
345 var thread = parseUrl(header.href);
346 if (thread === false) {
347 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
348 return;
349 }
350
351 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200352 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200353 var avatarsContainer = document.createElement('div');
354 avatarsContainer.classList.add('TWPT-avatars');
355
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200356 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200357
avm999632485a3e2021-09-08 22:18:38 +0200358 let singleAvatar;
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200359 if (res.state == 'private') {
avm999632485a3e2021-09-08 22:18:38 +0200360 singleAvatar = document.createElement('div');
361 singleAvatar.classList.add('TWPT-avatar-private-placeholder');
362 singleAvatar.textContent = 'person_off';
363 avatarsContainer.appendChild(singleAvatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200364 } else {
365 for (var i = 0; i < avatarUrls.length; ++i) {
366 var avatar = document.createElement('div');
367 avatar.classList.add('TWPT-avatar');
368 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
369 avatarsContainer.appendChild(avatar);
370 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200371 }
372
373 header.appendChild(avatarsContainer);
avm999632485a3e2021-09-08 22:18:38 +0200374
375 if (res.state == 'private') {
376 var label = chrome.i18n.getMessage(
377 'inject_threadlistavatars_private_thread_indicator_label');
378 createPlainTooltip(singleAvatar, label);
379 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200380 })
381 .catch(err => {
382 console.error(
383 '[threadListAvatars] Could not retrieve avatars for thread',
384 thread, err);
385 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200386 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200387
388 // Inject avatars for thread summary (thread item) |node| in a thread list if
389 // the threadlistavatars option is enabled.
390 injectIfEnabled(node) {
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +0100391 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200392 if (isEnabled) {
393 document.body.classList.add('TWPT-threadlistavatars-enabled');
394 this.inject(node);
395 } else {
396 document.body.classList.remove('TWPT-threadlistavatars-enabled');
397 }
398 });
399 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200400};