blob: 9c555b9fec48cf660c4a06309a1eb45175f841bd [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 };
Adrià Vilanova Martínez96382a92022-04-03 20:48:36 +0200159 })
160 .catch(cause => {
161 throw new Error('Failed ViewThread request.', {cause});
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200162 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200163 }
164
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200165 // Get the following data:
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100166 // - |state|: the state of the request (can be 'ok', 'private' or
167 // 'notVisible').
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200168 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100169 // the API, if |state| is 'ok'.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200170 getVisibleAvatarsFromServer(thread, num) {
171 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100172 if (result.state != 'ok')
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200173 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100174 state: result.state,
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200175 };
176
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200177 var messages = result.messages;
178 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200179 var lastMessageId = result.lastMessageId;
180
181 var avatarUrls = [];
182
183 var authorUrl = author?.['1']?.['2'];
184 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
185
186 for (var m of messages) {
187 var url = m?.['3']?.['1']?.['2'];
188
189 if (url === undefined) continue;
190 if (!avatarUrls.includes(url)) avatarUrls.push(url);
191 if (avatarUrls.length == 3) break;
192 }
193
194 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200195 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200196 this.db.putCacheEntry({
197 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200198 lastMessageId,
199 avatarUrls,
200 num,
201 lastUsedTimestamp: Math.floor(Date.now() / 1000),
202 });
203
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200204 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100205 state: 'ok',
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200206 avatars: avatarUrls,
207 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200208 });
209 }
210
211 // Returns an object with a cache entry that matches the request if found (via
212 // the |entry| property). The property |found| indicates whether the cache
213 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200214 //
215 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
216 // must be within the last 30 seconds (which means that the thread has been
217 // checked for a potential invalidation).
218 getVisibleAvatarsFromCache(thread, num, checkRecent) {
219 return this.db.getCacheEntry(thread.thread).then(entry => {
220 if (entry === undefined || entry.num < num)
221 return {
222 found: false,
223 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200224
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200225 if (checkRecent) {
226 var now = Math.floor(Date.now() / 1000);
227 var diff = now - entry.lastUsedTimestamp;
228 if (diff > 30)
229 throw new Error(
230 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
231 thread.thread + ' the difference is: ' + diff + ').');
232 }
233
234 return {
235 found: true,
236 entry,
237 };
238 });
239 }
240
241 // Waits for the XHR interceptor to invalidate any outdated threads and
242 // returns what getVisibleAvatarsFromCache returns. If this times out, it
243 // returns the current cache entry anyways if it exists.
244 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
245 return waitFor(
246 () => this.getVisibleAvatarsFromCache(
247 thread, num, /* checkRecent = */ true),
248 {
249 interval: 450,
250 timeout: 2 * 1000,
251 })
252 .catch(err => {
253 console.debug(
254 '[threadListAvatars] Error while retrieving avatars from cache ' +
255 '(probably timed out waiting for lastUsedTimestamp to change):',
256 err);
257
258 // Sometimes when going back to a thread list, the API call to load
259 // the thread list is not made, and so the previous piece of code
260 // times out waiting to intercept that API call and handle thread
261 // invalidations.
262 //
263 // If this is the case, this point will be reached. We'll assume we
264 // intercept all API calls, so reaching this point means that an API
265 // call wasn't made. Therefore, try again to get visible avatars from
266 // the cache without checking whether the entry has been checked for
267 // potential invalidation.
268 //
269 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
270 return this.getVisibleAvatarsFromCache(
271 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200272 });
273 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200274
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200275 // Get an object with the following data:
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100276 // - |state|: 'ok' (the avatars list could be retrieved), 'private' (the
277 // thread is in a private forum, so the avatars list could not be retrieved),
278 // or 'notVisible' (the thread has the visible field set to false).
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200279 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200280 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200281 return this.isPrivateThread(thread).then(isPrivate => {
282 if (isPrivate)
283 return {
284 state: 'private',
285 avatars: [],
286 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200287
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200288 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200289 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200290 if (!res.found) {
291 var err = new Error('Cache entry doesn\'t exist.');
292 err.name = 'notCached';
293 throw err;
294 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200295 return {
296 state: 'ok',
297 avatars: res.entry.avatarUrls,
298 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200299 })
300 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200301 // If the name is "notCached", then this is not an actual error so
302 // don't log an error, but still get avatars from the server.
303 if (err?.name !== 'notCached')
304 console.error(
305 '[threadListAvatars] Error while accessing avatars cache:',
306 err);
307
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200308 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100309 if (res.state != 'ok')
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200310 return {
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100311 state: res.state,
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200312 avatars: [],
313 };
314
315 return {
316 state: 'ok',
317 avatars: res.avatars,
318 };
319 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200320 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200321 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200322 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200323
324 // Inject avatars for thread summary (thread item) |node| in a thread list.
325 inject(node) {
326 var header = node.querySelector(
327 'ec-thread-summary .main-header .panel-description a.header');
328 if (header === null) {
329 console.error(
330 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
331 return;
332 }
333
334 var thread = parseUrl(header.href);
335 if (thread === false) {
336 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
337 return;
338 }
339
340 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200341 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200342 var avatarsContainer = document.createElement('div');
343 avatarsContainer.classList.add('TWPT-avatars');
344
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200345 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200346
avm999632485a3e2021-09-08 22:18:38 +0200347 let singleAvatar;
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100348 if (res.state == 'private' || res.state == 'notVisible') {
avm999632485a3e2021-09-08 22:18:38 +0200349 singleAvatar = document.createElement('div');
350 singleAvatar.classList.add('TWPT-avatar-private-placeholder');
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100351 singleAvatar.textContent =
352 (res.state == 'private' ? 'person_off' : 'visibility_off');
avm999632485a3e2021-09-08 22:18:38 +0200353 avatarsContainer.appendChild(singleAvatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200354 } else {
355 for (var i = 0; i < avatarUrls.length; ++i) {
356 var avatar = document.createElement('div');
357 avatar.classList.add('TWPT-avatar');
358 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
359 avatarsContainer.appendChild(avatar);
360 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200361 }
362
363 header.appendChild(avatarsContainer);
avm999632485a3e2021-09-08 22:18:38 +0200364
365 if (res.state == 'private') {
366 var label = chrome.i18n.getMessage(
367 'inject_threadlistavatars_private_thread_indicator_label');
368 createPlainTooltip(singleAvatar, label);
369 }
Adrià Vilanova Martíneza0862382022-02-02 19:05:28 +0100370 if (res.state == 'notVisible') {
371 var label = chrome.i18n.getMessage(
372 'inject_threadlistavatars_invisible_thread_indicator_label');
373 createPlainTooltip(singleAvatar, label);
374 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200375 })
376 .catch(err => {
377 console.error(
378 '[threadListAvatars] Could not retrieve avatars for thread',
379 thread, err);
380 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200381 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200382
383 // Inject avatars for thread summary (thread item) |node| in a thread list if
384 // the threadlistavatars option is enabled.
385 injectIfEnabled(node) {
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +0100386 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200387 if (isEnabled) {
388 document.body.classList.add('TWPT-threadlistavatars-enabled');
389 this.inject(node);
390 } else {
391 document.body.classList.remove('TWPT-threadlistavatars-enabled');
392 }
393 });
394 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200395};