forked from ScratchAddons/ScratchAddons
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmessage-cache.js
More file actions
260 lines (238 loc) · 8.9 KB
/
Copy pathmessage-cache.js
File metadata and controls
260 lines (238 loc) · 8.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
export class HTTPError extends Error {
constructor(message, code) {
super(message);
this.code = code;
}
static fromResponse(message, resp) {
return new HTTPError(`${message}: status ${resp.status}`, resp.status);
}
}
class IncognitoTransaction {
constructor(db, stores) {
this.db = db;
this.stores = stores;
}
objectStore(name) {
return {
put: (value) => this.db.put(name, value),
};
}
get done() {
return null;
}
}
export class IncognitoDatabase {
constructor() {
this.messages = [];
this.msgCount = 0;
}
get(name) {
switch (name) {
case "cache":
return this.messages.slice();
case "count":
return this.msgCount;
}
}
put(name, value) {
switch (name) {
case "cache": {
this.messages = value;
return;
}
case "count": {
this.msgCount = value;
return;
}
}
}
close() {}
transaction(stores) {
return new IncognitoTransaction(this, stores);
}
static isIncognito() {
// This API is not deprecated
return chrome.extension.inIncognitoContext;
}
}
const incognitoDatabase = new IncognitoDatabase();
/**
* Fetches the message count from the API.
* Errors are silenced.
* @param {string} username the username
* @returns {number} the message count, or 0 if it errors
*/
export async function fetchMessageCount(username, options) {
const bypassCache = options ? Boolean(options.bypassCache) : false;
const url = `https://api.scratch.mit.edu/users/${username}/messages/count${
!bypassCache ? "" : `?addons_bypass_cache_after_marking_read=1`
}`;
const fetchOptions = {
credentials: "omit", // No need to send cookies, this is a public endpoint
cache: bypassCache ? "reload" : "default",
};
const resp = await fetch(url, fetchOptions);
const json = await resp.json();
const resId = bypassCache ? null : resp.headers.get("X-Amz-Cf-Id");
return { count: json.count || 0, resId };
}
/**
* Fetches the messages, maximum 40 at a time.
* @param {string} username the username
* @param {string} xToken the X-Token value
* @param {number} offset the offset; 0 for latest
* @returns {object[]} the messages
* @throws {HTTPError} when fetching fails
*/
export async function fetchMessages(username, xToken, offset) {
const resp = await fetch(`https://api.scratch.mit.edu/users/${username}/messages?limit=40&offset=${offset}`, {
headers: {
"x-token": xToken,
},
});
if (!resp.ok) {
if (resp.status === 404) return [];
throw HTTPError.fromResponse(`Fetching message offset ${offset} for ${username} failed`, resp);
}
return resp.json();
}
/**
* Opens a messaging cache database.
* Callers must close this in try-finally block.
*/
export async function openDatabase() {
if (IncognitoDatabase.isIncognito()) return incognitoDatabase;
const DB_VERSION = 1; // It's preferred to keep it as 1 when possible
return idb.openDB("messaging", DB_VERSION, {
upgrade(d) {
d.createObjectStore("cache");
d.createObjectStore("lastUpdated");
d.createObjectStore("count");
},
});
}
/**
* Opens a message cache database, and clears if necessary.
* Cache is cleared for the first time the database was created,
* and if the cache is more than 1 hour old.
* To force clear cache (after an reauth, for example) set forceClear to true.
* @param {string} cookieStoreId the cookie store ID for the cache
* @param {boolean} forceClear whether to force clear the cache
*/
export async function openMessageCache(cookieStoreId, forceClear) {
const db = await openDatabase();
if (db instanceof IncognitoDatabase) return;
try {
const tx = await db.transaction(["cache", "lastUpdated", "count"], "readwrite");
const lastUpdated = await tx.objectStore("lastUpdated").get(cookieStoreId);
if (lastUpdated === undefined || forceClear || lastUpdated + 12 * 60 * 60 * 1000 < Date.now()) {
// Clear items last updated more than 1 hour ago
await tx.objectStore("cache").put([], cookieStoreId);
await tx.objectStore("count").put(0, cookieStoreId);
await tx.objectStore("count").put(null, `${cookieStoreId}_resId`);
// lastUpdated is only set when actually fetching
}
await tx.done;
} finally {
await db.close();
}
}
/**
* Fetches new messages, updates the message cache and returns new messages.
* Callers may use the returned value for notification, etc.
* This method fetches unread messages and a page of extra messages
* up to 25 pages in total. For example, if the user has no unread messages, it'll fetch
* 1 page, and if they have 1 unread messages, it'll fetch 2 pages. If the user has more than
* 960 unread messages, it'll only fetch the latest 25 pages.
* By default, this method only fetches new messages and keeps old data from cache,
* up to 1000 messages. To force clear them and fetch new ones, set forceClear to true.
* Fetched data is then stored to the cache, keyed by the cookie store ID.
* If the cache size exceeds 1000, only new 1000 items will be kept.
* @param {string} cookieStoreId the cookie store ID for the cache
* @param {boolean} forceClear whether to force clear the cache before fetching
* @param {string} username the username
* @param {string} xToken the X-Token value
* @returns {object[]} new messages
* @throws {HTTPError} if fetching fails
*/
export async function updateMessages(cookieStoreId, forceClear, username, xToken) {
await openMessageCache(cookieStoreId, forceClear);
if (username === null) return [];
const msgCountData = await fetchMessageCount(username);
const messageCount = await getUpToDateMsgCount(cookieStoreId, msgCountData);
const maxPages = Math.min(Math.ceil(messageCount / 40) + 1, 25);
const db = await openDatabase();
try {
const messages = await db.get("cache", cookieStoreId);
const firstTimestamp = messages[0] ? new Date(messages[0].datetime_created).getTime() : 0;
const newlyAdded = [];
const knownIds = new Set();
fetching: for (let i = 0; i < maxPages; i++) {
const pageMessages = await fetchMessages(username, xToken, i * 40);
for (const pageMessage of pageMessages) {
if (new Date(pageMessage.datetime_created).getTime() <= firstTimestamp) break fetching;
if (knownIds.has(pageMessage.id)) continue;
newlyAdded.push(pageMessage);
knownIds.add(pageMessage.id);
}
}
// [1, 2, 3].unshift(4, 5) => [4, 5, 1, 2, 3]
messages.unshift(...newlyAdded);
// Cap the cache size at maxPages * 40
messages.length = Math.min(messages.length, maxPages * 40);
// Only notify actual new messages, since the cache invalidation may occur
newlyAdded.length = Math.min(newlyAdded.length, messageCount);
const tx = await db.transaction(["cache", "lastUpdated", "count"], "readwrite");
await tx.objectStore("cache").put(messages, cookieStoreId);
await tx.objectStore("lastUpdated").put(Date.now(), cookieStoreId);
await tx.objectStore("count").put(messageCount, cookieStoreId);
if (msgCountData.resId && !(db instanceof IncognitoDatabase)) {
await tx.objectStore("count").put(msgCountData.resId, `${cookieStoreId}_resId`);
}
await tx.done;
return newlyAdded;
} finally {
await db.close();
}
}
/**
* Returns either the received message count or the one in IDB, based on which one is more recent
* @param {string} cookieStoreId the cookie store ID for the IDB cache
* @param {object} msgCountData the return value from fetchMessageCount()
* @returns {number} the most up-to-date message count, possibly identical to the received parameter
*/
export async function getUpToDateMsgCount(cookieStoreId, { count: responseMsgCount, resId }) {
const db = await openDatabase();
if (db instanceof IncognitoDatabase) return responseMsgCount;
try {
const lastResId = await db.get("count", `${cookieStoreId}_resId`);
if (lastResId && lastResId === resId) {
// Since `lastResId` is not null, we know we have a message count in IDB we can use.
// In some cases, the message count in IDB will be more up-to-date. In other cases,
// it will simply match the message count of this response, leading to the same result.
console.log("Ignored network-cached response for message count endpoint.");
const idbCount = await db.get("count", cookieStoreId);
return idbCount;
} else {
return responseMsgCount;
}
} catch (err) {
console.error(err);
return responseMsgCount;
} finally {
await db.close();
}
}
/**
* Marks messages as read.
* @param {string} csrfToken the CSRF token for requesting
* @throws {HTTPError} if operation fails
*/
export function markAsRead(csrfToken) {
return fetch("https://scratch.mit.edu/site-api/messages/messages-clear/?sareferer", {
method: "POST",
headers: { "x-csrftoken": csrfToken, "x-requested-with": "XMLHttpRequest" },
}).then((res) => {
if (!res.ok) throw HTTPError.fromResponse("Marking messages as read failed: ", res);
});
}