blob: 30b55d02a09165b5896c793bf03be016935af495 [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ínez87110e92021-08-11 19:23:16 +020085 // thread. It also returns whether the thread is private, in which case the
86 // previous properties will be missing.
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020087 getFirstMessages(thread, num = 15) {
88 return CCApi(
89 'ViewThread', {
90 1: thread.forum,
91 2: thread.thread,
92 // options
93 3: {
94 // pagination
95 1: {
96 2: num, // maxNum
97 },
98 3: true, // withMessages
99 5: true, // withUserProfile
100 10: false, // withPromotedMessages
101 16: false, // withThreadNotes
102 18: true, // sendNewThreadIfMoved
103 }
104 },
105 // |authentication| is false because otherwise this would mark
106 // the thread as read as a side effect, and that would mark all
107 // threads in the list as read.
108 //
109 // Due to the fact that we have to call this endpoint
110 // anonymously, this means we can't retrieve information about
111 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200112 /* authentication = */ false, /* authuser = */ 0,
113 /* returnUnauthorizedStatus = */ true)
114 .then(response => {
115 if (response.unauthorized)
116 return this.db.putUnauthorizedForum(thread.forum).then(() => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200117 return {
118 isPrivate: true,
119 };
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +0200120 });
121
122 var data = response.body;
123
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200124 var numMessages = data?.['1']?.['8'];
125 if (numMessages === undefined)
126 throw new Error(
127 'Request to view thread doesn\'t include the number of messages');
128
129 var messages = numMessages == 0 ? [] : data?.['1']['3'];
130 if (messages === undefined)
131 throw new Error(
132 'numMessages was ' + numMessages +
133 ' but the response didn\'t include any message.');
134
135 var author = data?.['1']?.['4'];
136 if (author === undefined)
137 throw new Error(
138 'Author isn\'t included in the ViewThread response.');
139
140 return {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200141 isPrivate: false,
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200142 messages,
143 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200144
145 // The following fields are useful for the cache and can be
146 // undefined, but this is checked before adding an entry to the
147 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200148 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200149 };
150 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200151 }
152
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200153 // Get the following data:
154 // - |isPrivate|: whether the thread is private.
155 // - |avatars|: a list of at most |num| avatars for thread |thread| by calling
156 // the API, if |isPrivate| is false.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200157 getVisibleAvatarsFromServer(thread, num) {
158 return this.getFirstMessages(thread).then(result => {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200159 if (result.isPrivate)
160 return {
161 isPrivate: true,
162 };
163
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200164 var messages = result.messages;
165 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200166 var lastMessageId = result.lastMessageId;
167
168 var avatarUrls = [];
169
170 var authorUrl = author?.['1']?.['2'];
171 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
172
173 for (var m of messages) {
174 var url = m?.['3']?.['1']?.['2'];
175
176 if (url === undefined) continue;
177 if (!avatarUrls.includes(url)) avatarUrls.push(url);
178 if (avatarUrls.length == 3) break;
179 }
180
181 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200182 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200183 this.db.putCacheEntry({
184 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200185 lastMessageId,
186 avatarUrls,
187 num,
188 lastUsedTimestamp: Math.floor(Date.now() / 1000),
189 });
190
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200191 return {
192 isPrivate: false,
193 avatars: avatarUrls,
194 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200195 });
196 }
197
198 // Returns an object with a cache entry that matches the request if found (via
199 // the |entry| property). The property |found| indicates whether the cache
200 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200201 //
202 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
203 // must be within the last 30 seconds (which means that the thread has been
204 // checked for a potential invalidation).
205 getVisibleAvatarsFromCache(thread, num, checkRecent) {
206 return this.db.getCacheEntry(thread.thread).then(entry => {
207 if (entry === undefined || entry.num < num)
208 return {
209 found: false,
210 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200211
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200212 if (checkRecent) {
213 var now = Math.floor(Date.now() / 1000);
214 var diff = now - entry.lastUsedTimestamp;
215 if (diff > 30)
216 throw new Error(
217 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
218 thread.thread + ' the difference is: ' + diff + ').');
219 }
220
221 return {
222 found: true,
223 entry,
224 };
225 });
226 }
227
228 // Waits for the XHR interceptor to invalidate any outdated threads and
229 // returns what getVisibleAvatarsFromCache returns. If this times out, it
230 // returns the current cache entry anyways if it exists.
231 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
232 return waitFor(
233 () => this.getVisibleAvatarsFromCache(
234 thread, num, /* checkRecent = */ true),
235 {
236 interval: 450,
237 timeout: 2 * 1000,
238 })
239 .catch(err => {
240 console.debug(
241 '[threadListAvatars] Error while retrieving avatars from cache ' +
242 '(probably timed out waiting for lastUsedTimestamp to change):',
243 err);
244
245 // Sometimes when going back to a thread list, the API call to load
246 // the thread list is not made, and so the previous piece of code
247 // times out waiting to intercept that API call and handle thread
248 // invalidations.
249 //
250 // If this is the case, this point will be reached. We'll assume we
251 // intercept all API calls, so reaching this point means that an API
252 // call wasn't made. Therefore, try again to get visible avatars from
253 // the cache without checking whether the entry has been checked for
254 // potential invalidation.
255 //
256 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
257 return this.getVisibleAvatarsFromCache(
258 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200259 });
260 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200261
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200262 // Get an object with the following data:
263 // - |state|: 'ok' (the avatars list could be retrieved) or 'private' (the
264 // thread is private, so the avatars list could not be retrieved).
265 // - |avatars|: list of at most |num| avatars for thread |thread|
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200266 getVisibleAvatars(thread, num = 3) {
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200267 return this.isPrivateThread(thread).then(isPrivate => {
268 if (isPrivate)
269 return {
270 state: 'private',
271 avatars: [],
272 };
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200273
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200274 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200275 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200276 if (!res.found) {
277 var err = new Error('Cache entry doesn\'t exist.');
278 err.name = 'notCached';
279 throw err;
280 }
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200281 return {
282 state: 'ok',
283 avatars: res.entry.avatarUrls,
284 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200285 })
286 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200287 // If the name is "notCached", then this is not an actual error so
288 // don't log an error, but still get avatars from the server.
289 if (err?.name !== 'notCached')
290 console.error(
291 '[threadListAvatars] Error while accessing avatars cache:',
292 err);
293
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200294 return this.getVisibleAvatarsFromServer(thread, num).then(res => {
295 if (res.isPrivate)
296 return {
297 state: 'private',
298 avatars: [],
299 };
300
301 return {
302 state: 'ok',
303 avatars: res.avatars,
304 };
305 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200306 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200307 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200308 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200309
310 // Inject avatars for thread summary (thread item) |node| in a thread list.
311 inject(node) {
312 var header = node.querySelector(
313 'ec-thread-summary .main-header .panel-description a.header');
314 if (header === null) {
315 console.error(
316 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
317 return;
318 }
319
320 var thread = parseUrl(header.href);
321 if (thread === false) {
322 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
323 return;
324 }
325
326 this.getVisibleAvatars(thread)
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200327 .then(res => {
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200328 var avatarsContainer = document.createElement('div');
329 avatarsContainer.classList.add('TWPT-avatars');
330
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200331 var avatarUrls = res.avatars;
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200332
avm999632485a3e2021-09-08 22:18:38 +0200333 let singleAvatar;
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200334 if (res.state == 'private') {
avm999632485a3e2021-09-08 22:18:38 +0200335 singleAvatar = document.createElement('div');
336 singleAvatar.classList.add('TWPT-avatar-private-placeholder');
337 singleAvatar.textContent = 'person_off';
338 avatarsContainer.appendChild(singleAvatar);
Adrià Vilanova Martínez87110e92021-08-11 19:23:16 +0200339 } else {
340 for (var i = 0; i < avatarUrls.length; ++i) {
341 var avatar = document.createElement('div');
342 avatar.classList.add('TWPT-avatar');
343 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
344 avatarsContainer.appendChild(avatar);
345 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200346 }
347
348 header.appendChild(avatarsContainer);
avm999632485a3e2021-09-08 22:18:38 +0200349
350 if (res.state == 'private') {
351 var label = chrome.i18n.getMessage(
352 'inject_threadlistavatars_private_thread_indicator_label');
353 createPlainTooltip(singleAvatar, label);
354 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200355 })
356 .catch(err => {
357 console.error(
358 '[threadListAvatars] Could not retrieve avatars for thread',
359 thread, err);
360 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200361 }
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200362
363 // Inject avatars for thread summary (thread item) |node| in a thread list if
364 // the threadlistavatars option is enabled.
365 injectIfEnabled(node) {
Adrià Vilanova Martínezd03e39d2022-01-15 18:23:51 +0100366 this.isEnabled().then(isEnabled => {
Adrià Vilanova Martínezd269c622021-09-04 18:35:55 +0200367 if (isEnabled) {
368 document.body.classList.add('TWPT-threadlistavatars-enabled');
369 this.inject(node);
370 } else {
371 document.body.classList.remove('TWPT-threadlistavatars-enabled');
372 }
373 });
374 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200375};