blob: 03d6bcbce28f0e4ad4ededd0887eccdfdbe79093 [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ínezd269c622021-09-04 18:35:55 +02005import {isOptionEnabled} from '../../common/optionsUtils.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ínezd269c622021-09-04 18:35:55 +020015
16 // Preload whether the option is enabled or not. This is because in the case
17 // avatars should be injected, if we don't preload this the layout will
18 // shift when injecting the first avatar.
19 isOptionEnabled('threadlistavatars').then(isEnabled => {
20 if (isEnabled)
21 document.body.classList.add('TWPT-threadlistavatars-enabled');
22 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020023 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020024
25 // Gets a list of private forums. If it is already cached, the cached list is
26 // returned; otherwise it is also computed and cached.
27 getPrivateForums() {
28 return new Promise((resolve, reject) => {
29 if (this.isFilterSetUp) return resolve(this.privateForums);
30
31 if (!document.documentElement.hasAttribute('data-startup'))
32 return reject('[threadListAvatars] Couldn\'t get startup data.');
33
34 var startupData =
35 JSON.parse(document.documentElement.getAttribute('data-startup'));
36 var forums = startupData?.['1']?.['2'];
37 if (forums === undefined)
38 return reject(
39 '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
40
41 for (var f of forums) {
42 var forumId = f?.['2']?.['1']?.['1'];
43 var forumVisibility = f?.['2']?.['18'];
44 if (forumId === undefined || forumVisibility === undefined) {
45 console.warn(
46 '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
47 f);
48 continue;
49 }
50
51 // forumVisibility's value 1 means "PUBLIC".
52 if (forumVisibility != 1) this.privateForums.push(forumId);
53 }
54
55 // Forum 51488989 is marked as public but it is in fact private.
56 this.privateForums.push('51488989');
57
58 this.isFilterSetUp = true;
59 return resolve(this.privateForums);
60 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020061 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020062
63 // Some threads belong to private forums, and this feature will not be able to
64 // get its avatars since it makes an anonymomus call to get the contents of
65 // the thread.
66 //
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020067 // This function returns whether the thread belongs to a known private forum.
68 isPrivateThread(thread) {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020069 return this.getPrivateForums().then(privateForums => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020070 if (privateForums.includes(thread.forum)) return true;
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +020071
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020072 return this.db.isForumUnauthorized(thread.forum);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020073 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020074 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020075
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020076 // Get an object with the author of the thread, an array of the first |num|
77 // replies from the thread |thread|, and additional information about the
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020078 // thread. It also returns whether the thread is private, in which case the
79 // previous properties will be missing.
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020080 getFirstMessages(thread, num = 15) {
81 return CCApi(
82 'ViewThread', {
83 1: thread.forum,
84 2: thread.thread,
85 // options
86 3: {
87 // pagination
88 1: {
89 2: num, // maxNum
90 },
91 3: true, // withMessages
92 5: true, // withUserProfile
93 10: false, // withPromotedMessages
94 16: false, // withThreadNotes
95 18: true, // sendNewThreadIfMoved
96 }
97 },
98 // |authentication| is false because otherwise this would mark
99 // the thread as read as a side effect, and that would mark all
100 // threads in the list as read.
101 //
102 // Due to the fact that we have to call this endpoint
103 // anonymously, this means we can't retrieve information about
104 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200105 /* authentication = */ false, /* authuser = */ 0,
106 /* returnUnauthorizedStatus = */ true)
107 .then(response => {
108 if (response.unauthorized)
109 return this.db.putUnauthorizedForum(thread.forum).then(() => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200110 return {
111 isPrivate: true,
112 };
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200113 });
114
115 var data = response.body;
116
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200117 var numMessages = data?.['1']?.['8'];
118 if (numMessages === undefined)
119 throw new Error(
120 'Request to view thread doesn\'t include the number of messages');
121
122 var messages = numMessages == 0 ? [] : data?.['1']['3'];
123 if (messages === undefined)
124 throw new Error(
125 'numMessages was ' + numMessages +
126 ' but the response didn\'t include any message.');
127
128 var author = data?.['1']?.['4'];
129 if (author === undefined)
130 throw new Error(
131 'Author isn\'t included in the ViewThread response.');
132
133 return {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200134 isPrivate: false,
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200135 messages,
136 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200137
138 // The following fields are useful for the cache and can be
139 // undefined, but this is checked before adding an entry to the
140 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200141 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200142 };
143 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200144 }
145
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200146 // Get the following data:
147 // - |isPrivate|: whether the thread is private.
148 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
149 // the API, if |isPrivate| is false.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200150 getVisibleAvatarsFromServer(thread, num) {
151 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200152 if (result.isPrivate)
153 return {
154 isPrivate: true,
155 };
156
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200157 var messages = result.messages;
158 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200159 var lastMessageId = result.lastMessageId;
160
161 var avatarUrls = [];
162
163 var authorUrl = author?.['1']?.['2'];
164 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
165
166 for (var m of messages) {
167 var url = m?.['3']?.['1']?.['2'];
168
169 if (url === undefined) continue;
170 if (!avatarUrls.includes(url)) avatarUrls.push(url);
171 if (avatarUrls.length == 3) break;
172 }
173
174 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200175 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200176 this.db.putCacheEntry({
177 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200178 lastMessageId,
179 avatarUrls,
180 num,
181 lastUsedTimestamp: Math.floor(Date.now() / 1000),
182 });
183
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200184 return {
185 isPrivate: false,
186 avatars: avatarUrls,
187 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200188 });
189 }
190
191 // Returns an object with a cache entry that matches the request if found (via
192 // the |entry| property). The property |found| indicates whether the cache
193 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200194 //
195 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
196 // must be within the last 30 seconds (which means that the thread has been
197 // checked for a potential invalidation).
198 getVisibleAvatarsFromCache(thread, num, checkRecent) {
199 return this.db.getCacheEntry(thread.thread).then(entry => {
200 if (entry === undefined || entry.num < num)
201 return {
202 found: false,
203 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200204
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200205 if (checkRecent) {
206 var now = Math.floor(Date.now() / 1000);
207 var diff = now - entry.lastUsedTimestamp;
208 if (diff > 30)
209 throw new Error(
210 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
211 thread.thread + ' the difference is: ' + diff + ').');
212 }
213
214 return {
215 found: true,
216 entry,
217 };
218 });
219 }
220
221 // Waits for the XHR interceptor to invalidate any outdated threads and
222 // returns what getVisibleAvatarsFromCache returns. If this times out, it
223 // returns the current cache entry anyways if it exists.
224 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
225 return waitFor(
226 () => this.getVisibleAvatarsFromCache(
227 thread, num, /* checkRecent = */ true),
228 {
229 interval: 450,
230 timeout: 2 * 1000,
231 })
232 .catch(err => {
233 console.debug(
234 '[threadListAvatars] Error while retrieving avatars from cache ' +
235 '(probably timed out waiting for lastUsedTimestamp to change):',
236 err);
237
238 // Sometimes when going back to a thread list, the API call to load
239 // the thread list is not made, and so the previous piece of code
240 // times out waiting to intercept that API call and handle thread
241 // invalidations.
242 //
243 // If this is the case, this point will be reached. We'll assume we
244 // intercept all API calls, so reaching this point means that an API
245 // call wasn't made. Therefore, try again to get visible avatars from
246 // the cache without checking whether the entry has been checked for
247 // potential invalidation.
248 //
249 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
250 return this.getVisibleAvatarsFromCache(
251 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200252 });
253 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200254
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200255 // Get an object with the following data:
256 // - |state|: 'ok' (the avatars list could be retrieved) or 'private' (the
257 // thread is private, so the avatars list could not be retrieved).
258 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200259 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200260 return this.isPrivateThread(thread).then(isPrivate => {
261 if (isPrivate)
262 return {
263 state: 'private',
264 avatars: [],
265 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200266
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200267 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200268 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200269 if (!res.found) {
270 var err = new Error('Cache entry doesn\'t exist.');
271 err.name = 'notCached';
272 throw err;
273 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200274 return {
275 state: 'ok',
276 avatars: res.entry.avatarUrls,
277 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200278 })
279 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200280 // If the name is "notCached", then this is not an actual error so
281 // don't log an error, but still get avatars from the server.
282 if (err?.name !== 'notCached')
283 console.error(
284 '[threadListAvatars] Error while accessing avatars cache:',
285 err);
286
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200287 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
288 if (res.isPrivate)
289 return {
290 state: 'private',
291 avatars: [],
292 };
293
294 return {
295 state: 'ok',
296 avatars: res.avatars,
297 };
298 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200299 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200300 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200301 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200302
303 // Inject avatars for thread summary (thread item) |node| in a thread list.
304 inject(node) {
305 var header = node.querySelector(
306 'ec-thread-summary .main-header .panel-description a.header');
307 if (header === null) {
308 console.error(
309 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
310 return;
311 }
312
313 var thread = parseUrl(header.href);
314 if (thread === false) {
315 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
316 return;
317 }
318
319 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200320 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200321 var avatarsContainer = document.createElement('div');
322 avatarsContainer.classList.add('TWPT-avatars');
323
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200324 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200325
avm999632485a3e2021-09-08 22:18:38 +0200326 let singleAvatar;
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200327 if (res.state == 'private') {
avm999632485a3e2021-09-08 22:18:38 +0200328 singleAvatar = document.createElement('div');
329 singleAvatar.classList.add('TWPT-avatar-private-placeholder');
330 singleAvatar.textContent = 'person_off';
331 avatarsContainer.appendChild(singleAvatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200332 } else {
333 for (var i = 0; i < avatarUrls.length; ++i) {
334 var avatar = document.createElement('div');
335 avatar.classList.add('TWPT-avatar');
336 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
337 avatarsContainer.appendChild(avatar);
338 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200339 }
340
341 header.appendChild(avatarsContainer);
avm999632485a3e2021-09-08 22:18:38 +0200342
343 if (res.state == 'private') {
344 var label = chrome.i18n.getMessage(
345 'inject_threadlistavatars_private_thread_indicator_label');
346 createPlainTooltip(singleAvatar, label);
347 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200348 })
349 .catch(err => {
350 console.error(
351 '[threadListAvatars] Could not retrieve avatars for thread',
352 thread, err);
353 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200354 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200355
356 // Inject avatars for thread summary (thread item) |node| in a thread list if
357 // the threadlistavatars option is enabled.
358 injectIfEnabled(node) {
359 isOptionEnabled('threadlistavatars').then(isEnabled => {
360 if (isEnabled) {
361 document.body.classList.add('TWPT-threadlistavatars-enabled');
362 this.inject(node);
363 } else {
364 document.body.classList.remove('TWPT-threadlistavatars-enabled');
365 }
366 });
367 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200368};