blob: d0a91df6aa84494751ad32ce1af26ddc0d79eff4 [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';
5
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +02006import AvatarsDB from './utils/AvatarsDB.js'
7
8export default class AvatarsHandler {
9 constructor() {
10 this.isFilterSetUp = false;
11 this.privateForums = [];
12 this.db = new AvatarsDB();
13 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020014
15 // Gets a list of private forums. If it is already cached, the cached list is
16 // returned; otherwise it is also computed and cached.
17 getPrivateForums() {
18 return new Promise((resolve, reject) => {
19 if (this.isFilterSetUp) return resolve(this.privateForums);
20
21 if (!document.documentElement.hasAttribute('data-startup'))
22 return reject('[threadListAvatars] Couldn\'t get startup data.');
23
24 var startupData =
25 JSON.parse(document.documentElement.getAttribute('data-startup'));
26 var forums = startupData?.['1']?.['2'];
27 if (forums === undefined)
28 return reject(
29 '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
30
31 for (var f of forums) {
32 var forumId = f?.['2']?.['1']?.['1'];
33 var forumVisibility = f?.['2']?.['18'];
34 if (forumId === undefined || forumVisibility === undefined) {
35 console.warn(
36 '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
37 f);
38 continue;
39 }
40
41 // forumVisibility's value 1 means "PUBLIC".
42 if (forumVisibility != 1) this.privateForums.push(forumId);
43 }
44
45 // Forum 51488989 is marked as public but it is in fact private.
46 this.privateForums.push('51488989');
47
48 this.isFilterSetUp = true;
49 return resolve(this.privateForums);
50 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020051 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020052
53 // Some threads belong to private forums, and this feature will not be able to
54 // get its avatars since it makes an anonymomus call to get the contents of
55 // the thread.
56 //
57 // This function returns whether avatars should be retrieved depending on if
58 // the thread belongs to a known private forum.
59 shouldRetrieveAvatars(thread) {
60 return this.getPrivateForums().then(privateForums => {
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +020061 if (privateForums.includes(thread.forum)) return false;
62
63 return this.db.isForumUnauthorized(thread.forum).then(res => !res);
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020064 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020065 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020066
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +020067 // Get an object with the author of the thread, an array of the first |num|
68 // replies from the thread |thread|, and additional information about the
69 // thread.
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +020070 getFirstMessages(thread, num = 15) {
71 return CCApi(
72 'ViewThread', {
73 1: thread.forum,
74 2: thread.thread,
75 // options
76 3: {
77 // pagination
78 1: {
79 2: num, // maxNum
80 },
81 3: true, // withMessages
82 5: true, // withUserProfile
83 10: false, // withPromotedMessages
84 16: false, // withThreadNotes
85 18: true, // sendNewThreadIfMoved
86 }
87 },
88 // |authentication| is false because otherwise this would mark
89 // the thread as read as a side effect, and that would mark all
90 // threads in the list as read.
91 //
92 // Due to the fact that we have to call this endpoint
93 // anonymously, this means we can't retrieve information about
94 // threads in private forums.
Adrià Vilanova Martínezc41edf42021-07-18 02:06:55 +020095 /* authentication = */ false, /* authuser = */ 0,
96 /* returnUnauthorizedStatus = */ true)
97 .then(response => {
98 if (response.unauthorized)
99 return this.db.putUnauthorizedForum(thread.forum).then(() => {
100 throw new Error('Permission denied to load thread.');
101 });
102
103 var data = response.body;
104
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200105 var numMessages = data?.['1']?.['8'];
106 if (numMessages === undefined)
107 throw new Error(
108 'Request to view thread doesn\'t include the number of messages');
109
110 var messages = numMessages == 0 ? [] : data?.['1']['3'];
111 if (messages === undefined)
112 throw new Error(
113 'numMessages was ' + numMessages +
114 ' but the response didn\'t include any message.');
115
116 var author = data?.['1']?.['4'];
117 if (author === undefined)
118 throw new Error(
119 'Author isn\'t included in the ViewThread response.');
120
121 return {
122 messages,
123 author,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200124
125 // The following fields are useful for the cache and can be
126 // undefined, but this is checked before adding an entry to the
127 // cache.
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200128 lastMessageId: data?.['1']?.['2']?.['10'],
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200129 };
130 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200131 }
132
133 // Get a list of at most |num| avatars for thread |thread| by calling the API
134 getVisibleAvatarsFromServer(thread, num) {
135 return this.getFirstMessages(thread).then(result => {
136 var messages = result.messages;
137 var author = result.author;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200138 var lastMessageId = result.lastMessageId;
139
140 var avatarUrls = [];
141
142 var authorUrl = author?.['1']?.['2'];
143 if (authorUrl !== undefined) avatarUrls.push(authorUrl);
144
145 for (var m of messages) {
146 var url = m?.['3']?.['1']?.['2'];
147
148 if (url === undefined) continue;
149 if (!avatarUrls.includes(url)) avatarUrls.push(url);
150 if (avatarUrls.length == 3) break;
151 }
152
153 // Add entry to cache if all the extra metadata could be retrieved.
Adrià Vilanova Martínezac9fc9e2021-07-22 12:45:32 +0200154 if (lastMessageId !== undefined)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200155 this.db.putCacheEntry({
156 threadId: thread.thread,
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200157 lastMessageId,
158 avatarUrls,
159 num,
160 lastUsedTimestamp: Math.floor(Date.now() / 1000),
161 });
162
163 return avatarUrls;
164 });
165 }
166
167 // Returns an object with a cache entry that matches the request if found (via
168 // the |entry| property). The property |found| indicates whether the cache
169 // entry was found.
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200170 //
171 // The |checkRecent| parameter is used to indicate whether lastUsedTimestamp
172 // must be within the last 30 seconds (which means that the thread has been
173 // checked for a potential invalidation).
174 getVisibleAvatarsFromCache(thread, num, checkRecent) {
175 return this.db.getCacheEntry(thread.thread).then(entry => {
176 if (entry === undefined || entry.num < num)
177 return {
178 found: false,
179 };
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200180
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200181 if (checkRecent) {
182 var now = Math.floor(Date.now() / 1000);
183 var diff = now - entry.lastUsedTimestamp;
184 if (diff > 30)
185 throw new Error(
186 'lastUsedTimestamp isn\'t within the last 30 seconds (id: ' +
187 thread.thread + ' the difference is: ' + diff + ').');
188 }
189
190 return {
191 found: true,
192 entry,
193 };
194 });
195 }
196
197 // Waits for the XHR interceptor to invalidate any outdated threads and
198 // returns what getVisibleAvatarsFromCache returns. If this times out, it
199 // returns the current cache entry anyways if it exists.
200 getVisibleAvatarsFromCacheAfterInvalidations(thread, num) {
201 return waitFor(
202 () => this.getVisibleAvatarsFromCache(
203 thread, num, /* checkRecent = */ true),
204 {
205 interval: 450,
206 timeout: 2 * 1000,
207 })
208 .catch(err => {
209 console.debug(
210 '[threadListAvatars] Error while retrieving avatars from cache ' +
211 '(probably timed out waiting for lastUsedTimestamp to change):',
212 err);
213
214 // Sometimes when going back to a thread list, the API call to load
215 // the thread list is not made, and so the previous piece of code
216 // times out waiting to intercept that API call and handle thread
217 // invalidations.
218 //
219 // If this is the case, this point will be reached. We'll assume we
220 // intercept all API calls, so reaching this point means that an API
221 // call wasn't made. Therefore, try again to get visible avatars from
222 // the cache without checking whether the entry has been checked for
223 // potential invalidation.
224 //
225 // See https://bugs.avm99963.com/p/twpowertools/issues/detail?id=10.
226 return this.getVisibleAvatarsFromCache(
227 thread, num, /* checkRecent = */ false);
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200228 });
229 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200230
231 // Get a list of at most |num| avatars for thread |thread|
232 getVisibleAvatars(thread, num = 3) {
233 return this.shouldRetrieveAvatars(thread).then(shouldRetrieve => {
234 if (!shouldRetrieve) {
235 console.debug('[threadListAvatars] Skipping thread', thread);
236 return [];
237 }
238
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200239 return this.getVisibleAvatarsFromCacheAfterInvalidations(thread, num)
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200240 .then(res => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200241 if (!res.found) {
242 var err = new Error('Cache entry doesn\'t exist.');
243 err.name = 'notCached';
244 throw err;
245 }
246 return res.entry.avatarUrls;
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200247 })
248 .catch(err => {
Adrià Vilanova Martínez4cfa32f2021-07-22 17:29:03 +0200249 // If the name is "notCached", then this is not an actual error so
250 // don't log an error, but still get avatars from the server.
251 if (err?.name !== 'notCached')
252 console.error(
253 '[threadListAvatars] Error while accessing avatars cache:',
254 err);
255
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200256 return this.getVisibleAvatarsFromServer(thread, num);
257 });
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200258 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200259 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200260
261 // Inject avatars for thread summary (thread item) |node| in a thread list.
262 inject(node) {
263 var header = node.querySelector(
264 'ec-thread-summary .main-header .panel-description a.header');
265 if (header === null) {
266 console.error(
267 '[threadListAvatars] Header is not present in the thread item\'s DOM.');
268 return;
269 }
270
271 var thread = parseUrl(header.href);
272 if (thread === false) {
273 console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
274 return;
275 }
276
277 this.getVisibleAvatars(thread)
278 .then(avatarUrls => {
279 var avatarsContainer = document.createElement('div');
280 avatarsContainer.classList.add('TWPT-avatars');
281
282 var count = Math.floor(Math.random() * 4);
283
284 for (var i = 0; i < avatarUrls.length; ++i) {
285 var avatar = document.createElement('div');
286 avatar.classList.add('TWPT-avatar');
287 avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
288 avatarsContainer.appendChild(avatar);
289 }
290
291 header.appendChild(avatarsContainer);
292 })
293 .catch(err => {
294 console.error(
295 '[threadListAvatars] Could not retrieve avatars for thread',
296 thread, err);
297 });
Adrià Vilanova Martínez27c69962021-07-17 23:32:51 +0200298 }
Adrià Vilanova Martínez3465e772021-07-11 19:18:41 +0200299};