blob: b125a17ff736131bc030862d996113f55a04f5f6 [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';
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +02006
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +02007import AvatarsDB from './utils/AvatarsDB.js'
8
9export default class AvatarsHandler {
10 constructor() {
11 this.isFilterSetUp = false;
12 this.privateForums = [];
13 this.db = new AvatarsDB();
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +020014
15 // Preload whether the option is enabled or not. This is because in the case
16 // avatars should be injected, if we don't preload this the layout will
17 // shift when injecting the first avatar.
18 isOptionEnabled('threadlistavatars').then(isEnabled => {
19 if (isEnabled)
20 document.body.classList.add('TWPT-threadlistavatars-enabled');
21 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020022 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020023
24 // Gets a list of private forums. If it is already cached, the cached list is
25 // returned; otherwise it is also computed and cached.
26 getPrivateForums() {
27 return new Promise((resolve, reject) => {
28 if (this.isFilterSetUp) return resolve(this.privateForums);
29
30 if (!document.documentElement.hasAttribute('data-startup'))
31 return reject('[threadListAvatars] Couldn\'t get startup data.');
32
33 var startupData =
34 JSON.parse(document.documentElement.getAttribute('data-startup'));
35 var forums = startupData?.['1']?.['2'];
36 if (forums === undefined)
37 return reject(
38 '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
39
40 for (var f of forums) {
41 var forumId = f?.['2']?.['1']?.['1'];
42 var forumVisibility = f?.['2']?.['18'];
43 if (forumId === undefined || forumVisibility === undefined) {
44 console.warn(
45 '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
46 f);
47 continue;
48 }
49
50 // forumVisibility's value 1 means "PUBLIC".
51 if (forumVisibility != 1) this.privateForums.push(forumId);
52 }
53
54 // Forum 51488989 is marked as public but it is in fact private.
55 this.privateForums.push('51488989');
56
57 this.isFilterSetUp = true;
58 return resolve(this.privateForums);
59 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020060 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020061
62 // Some threads belong to private forums, and this feature will not be able to
63 // get its avatars since it makes an anonymomus call to get the contents of
64 // the thread.
65 //
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020066 // This function returns whether the thread belongs to a known private forum.
67 isPrivateThread(thread) {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020068 return this.getPrivateForums().then(privateForums => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020069 if (privateForums.includes(thread.forum)) return true;
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +020070
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020071 return this.db.isForumUnauthorized(thread.forum);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020072 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020073 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020074
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020075 // Get an object with the author of the thread, an array of the first |num|
76 // replies from the thread |thread|, and additional information about the
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +020077 // thread. It also returns whether the thread is private, in which case the
78 // previous properties will be missing.
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020079 getFirstMessages(thread, num = 15) {
80 return CCApi(
81 'ViewThread', {
82 1: thread.forum,
83 2: thread.thread,
84 // options
85 3: {
86 // pagination
87 1: {
88 2: num, // maxNum
89 },
90 3: true, // withMessages
91 5: true, // withUserProfile
92 10: false, // withPromotedMessages
93 16: false, // withThreadNotes
94 18: true, // sendNewThreadIfMoved
95 }
96 },
97 // |authentication| is false because otherwise this would mark
98 // the thread as read as a side effect, and that would mark all
99 // threads in the list as read.
100 //
101 // Due to the fact that we have to call this endpoint
102 // anonymously, this means we can't retrieve information about
103 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200104 /* authentication = */ false, /* authuser = */ 0,
105 /* returnUnauthorizedStatus = */ true)
106 .then(response => {
107 if (response.unauthorized)
108 return this.db.putUnauthorizedForum(thread.forum).then(() => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200109 return {
110 isPrivate: true,
111 };
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200112 });
113
114 var data = response.body;
115
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200116 var numMessages = data?.['1']?.['8'];
117 if (numMessages === undefined)
118 throw new Error(
119 'Request to view thread doesn\'t include the number of messages');
120
121 var messages = numMessages == 0 ? [] : data?.['1']['3'];
122 if (messages === undefined)
123 throw new Error(
124 'numMessages was ' + numMessages +
125 ' but the response didn\'t include any message.');
126
127 var author = data?.['1']?.['4'];
128 if (author === undefined)
129 throw new Error(
130 'Author isn\'t included in the ViewThread response.');
131
132 return {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200133 isPrivate: false,
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200134 messages,
135 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200136
137 // The following fields are useful for the cache and can be
138 // undefined, but this is checked before adding an entry to the
139 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200140 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200141 };
142 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200143 }
144
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200145 // Get the following data:
146 // - |isPrivate|: whether the thread is private.
147 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
148 // the API, if |isPrivate| is false.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200149 getVisibleAvatarsFromServer(thread, num) {
150 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200151 if (result.isPrivate)
152 return {
153 isPrivate: true,
154 };
155
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200156 var messages = result.messages;
157 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200158 var lastMessageId = result.lastMessageId;
159
160 var avatarUrls = [];
161
162 var authorUrl = author?.['1']?.['2'];
163 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
164
165 for (var m of messages) {
166 var url = m?.['3']?.['1']?.['2'];
167
168 if (url === undefined) continue;
169 if (!avatarUrls.includes(url)) avatarUrls.push(url);
170 if (avatarUrls.length == 3) break;
171 }
172
173 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200174 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200175 this.db.putCacheEntry({
176 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200177 lastMessageId,
178 avatarUrls,
179 num,
180 lastUsedTimestamp: Math.floor(Date.now() / 1000),
181 });
182
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200183 return {
184 isPrivate: false,
185 avatars: avatarUrls,
186 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200187 });
188 }
189
190 // Returns an object with a cache entry that matches the request if found (via
191 // the |entry| property). The property |found| indicates whether the cache
192 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200193 //
194 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
195 // must be within the last 30 seconds (which means that the thread has been
196 // checked for a potential invalidation).
197 getVisibleAvatarsFromCache(thread, num, checkRecent) {
198 return this.db.getCacheEntry(thread.thread).then(entry => {
199 if (entry === undefined || entry.num < num)
200 return {
201 found: false,
202 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200203
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200204 if (checkRecent) {
205 var now = Math.floor(Date.now() / 1000);
206 var diff = now - entry.lastUsedTimestamp;
207 if (diff > 30)
208 throw new Error(
209 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
210 thread.thread + ' the difference is: ' + diff + ').');
211 }
212
213 return {
214 found: true,
215 entry,
216 };
217 });
218 }
219
220 // Waits for the XHR interceptor to invalidate any outdated threads and
221 // returns what getVisibleAvatarsFromCache returns. If this times out, it
222 // returns the current cache entry anyways if it exists.
223 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
224 return waitFor(
225 () => this.getVisibleAvatarsFromCache(
226 thread, num, /* checkRecent = */ true),
227 {
228 interval: 450,
229 timeout: 2 * 1000,
230 })
231 .catch(err => {
232 console.debug(
233 '[threadListAvatars] Error while retrieving avatars from cache ' +
234 '(probably timed out waiting for lastUsedTimestamp to change):',
235 err);
236
237 // Sometimes when going back to a thread list, the API call to load
238 // the thread list is not made, and so the previous piece of code
239 // times out waiting to intercept that API call and handle thread
240 // invalidations.
241 //
242 // If this is the case, this point will be reached. We'll assume we
243 // intercept all API calls, so reaching this point means that an API
244 // call wasn't made. Therefore, try again to get visible avatars from
245 // the cache without checking whether the entry has been checked for
246 // potential invalidation.
247 //
248 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
249 return this.getVisibleAvatarsFromCache(
250 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200251 });
252 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200253
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200254 // Get an object with the following data:
255 // - |state|: 'ok' (the avatars list could be retrieved) or 'private' (the
256 // thread is private, so the avatars list could not be retrieved).
257 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200258 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200259 return this.isPrivateThread(thread).then(isPrivate => {
260 if (isPrivate)
261 return {
262 state: 'private',
263 avatars: [],
264 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200265
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200266 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200267 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200268 if (!res.found) {
269 var err = new Error('Cache entry doesn\'t exist.');
270 err.name = 'notCached';
271 throw err;
272 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200273 return {
274 state: 'ok',
275 avatars: res.entry.avatarUrls,
276 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200277 })
278 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200279 // If the name is "notCached", then this is not an actual error so
280 // don't log an error, but still get avatars from the server.
281 if (err?.name !== 'notCached')
282 console.error(
283 '[threadListAvatars] Error while accessing avatars cache:',
284 err);
285
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200286 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
287 if (res.isPrivate)
288 return {
289 state: 'private',
290 avatars: [],
291 };
292
293 return {
294 state: 'ok',
295 avatars: res.avatars,
296 };
297 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200298 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200299 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200300 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200301
302 // Inject avatars for thread summary (thread item) |node| in a thread list.
303 inject(node) {
304 var header = node.querySelector(
305 'ec-thread-summary .main-header .panel-description a.header');
306 if (header === null) {
307 console.error(
308 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
309 return;
310 }
311
312 var thread = parseUrl(header.href);
313 if (thread === false) {
314 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
315 return;
316 }
317
318 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200319 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200320 var avatarsContainer = document.createElement('div');
321 avatarsContainer.classList.add('TWPT-avatars');
322
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200323 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200324
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200325 if (res.state == 'private') {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200326 var avatar = document.createElement('div');
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200327 avatar.classList.add('TWPT-avatar-private-placeholder');
avm99963cb552482021-08-22 18:42:35 +0200328 avatar.textContent = 'person_off';
329 var label = chrome.i18n.getMessage(
330 'inject_threadlistavatars_private_thread_indicator_label');
331 avatar.setAttribute('title', label);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200332 avatarsContainer.appendChild(avatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200333 } else {
334 for (var i = 0; i < avatarUrls.length; ++i) {
335 var avatar = document.createElement('div');
336 avatar.classList.add('TWPT-avatar');
337 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
338 avatarsContainer.appendChild(avatar);
339 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200340 }
341
342 header.appendChild(avatarsContainer);
343 })
344 .catch(err => {
345 console.error(
346 '[threadListAvatars] Could not retrieve avatars for thread',
347 thread, err);
348 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200349 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200350
351 // Inject avatars for thread summary (thread item) |node| in a thread list if
352 // the threadlistavatars option is enabled.
353 injectIfEnabled(node) {
354 isOptionEnabled('threadlistavatars').then(isEnabled => {
355 if (isEnabled) {
356 document.body.classList.add('TWPT-threadlistavatars-enabled');
357 this.inject(node);
358 } else {
359 document.body.classList.remove('TWPT-threadlistavatars-enabled');
360 }
361 });
362 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200363};