Cache thread avatars
This change adds the AvatarsDB class, which lets the threadListAvatars
feature interact with a cache of thread avatars.
This is an implementation of points 1 and 2 in the "Idea" section of the
following doc: go/eu7T9m (public link available in the linked bug). The
doc includes a rationale for this change and what it does.
Bug: 2
Change-Id: Ida9fcd909e3bd4a552361317b9013cb8734272a6
diff --git a/src/contentScripts/communityConsole/utils/AvatarsDB.js b/src/contentScripts/communityConsole/utils/AvatarsDB.js
new file mode 100644
index 0000000..b59460d
--- /dev/null
+++ b/src/contentScripts/communityConsole/utils/AvatarsDB.js
@@ -0,0 +1,130 @@
+import {openDB} from 'idb';
+
+const dbName = 'TWPTAvatarsDB';
+const threadListLoadEvent = 'TWPT_ViewForumResponse';
+// Time after the last use when a cache entry should be deleted (in s):
+const expirationTime = 4 * 24 * 60 * 60; // 4 days
+// Probability of running the piece of code to remove unused cache entries after
+// loading the thread list.
+const probRemoveUnusedCacheEntries = 0.10; // 10%
+
+export default class AvatarsDB {
+ constructor() {
+ this.dbPromise = undefined;
+ this.openDB();
+ this.setUpInvalidationsHandler();
+ }
+
+ openDB() {
+ if (this.dbPromise === undefined)
+ this.dbPromise = openDB(dbName, 1, {
+ upgrade: (udb, oldVersion, newVersion, transaction) => {
+ switch (oldVersion) {
+ case 0:
+ var cache = udb.createObjectStore('avatarsCache', {
+ keyPath: 'threadId',
+ });
+ cache.createIndex(
+ 'lastUsedTimestamp', 'lastUsedTimestamp', {unique: false});
+
+ var unauthedForums = udb.createObjectStore('unauthorizedForums', {
+ keyPath: 'forumId',
+ });
+ unauthedForums.createIndex(
+ 'expirationTimestamp', 'expirationTimestamp',
+ {unique: false});
+ }
+ },
+ });
+ }
+
+ getCacheEntry(threadId) {
+ return this.dbPromise.then(db => db.get('avatarsCache', threadId));
+ }
+
+ putCacheEntry(entry) {
+ return this.dbPromise.then(db => db.put('avatarsCache', entry));
+ }
+
+ invalidateCacheEntryIfExists(threadId) {
+ return this.dbPromise.then(db => db.delete('avatarsCache', threadId));
+ }
+
+ removeUnusedCacheEntries() {
+ console.debug('[threadListAvatars] Removing unused cache entries...');
+ return this.dbPromise
+ .then(db => {
+ var upperBoundTimestamp =
+ Math.floor(Date.now() / 1000) - expirationTime;
+ var range = IDBKeyRange.upperBound(upperBoundTimestamp);
+
+ var tx = db.transaction('avatarsCache', 'readwrite');
+ var index = tx.store.index('lastUsedTimestamp');
+ return index.openCursor(range);
+ })
+ .then(function iterateCursor(cursor) {
+ if (!cursor) return;
+ cursor.delete();
+ return cursor.continue().then(iterateCursor);
+ });
+ }
+
+ setUpInvalidationsHandler() {
+ window.addEventListener(
+ threadListLoadEvent, e => this.handleInvalidations(e));
+ }
+
+ handleInvalidations(e) {
+ var response = e?.detail?.body;
+ var threads = response?.['1']?.['2'];
+ if (threads === undefined) {
+ console.warn(
+ '[threadListAvatars] The thread list doesn\'t contain any threads.');
+ return;
+ }
+
+ var promises = [];
+ threads.forEach(t => {
+ var id = t?.['2']?.['1']?.['1'];
+ var currentUpdatedTimestamp =
+ Math.floor(Number.parseInt(t?.['2']?.['1']?.['4']) / 1000000);
+ var currentLastMessageId = t?.['2']?.['10'];
+
+ if (id === undefined || currentUpdatedTimestamp === undefined ||
+ currentLastMessageId === undefined)
+ return;
+
+ promises.push(this.getCacheEntry(id).then(entry => {
+ if (entry === undefined) return;
+
+ // If the cache entry is still valid.
+ if (currentLastMessageId == entry.lastMessageId ||
+ currentUpdatedTimestamp <= entry.updatedTimestamp) {
+ entry.lastUsedTimestamp = Math.floor(Date.now() / 1000);
+ return this.putCacheEntry(entry).catch(err => {
+ console.error(
+ '[threadListAvatars] Error while updating lastUsedTimestamp from thread in cache:',
+ err);
+ });
+ }
+
+ console.debug(
+ '[threadListAvatars] Invalidating thread', entry.threadId);
+ return this.invalidateCacheEntryIfExists(entry.threadId).catch(err => {
+ console.error(
+ '[threadListAvatars] Error while invalidating thread from cache:',
+ err);
+ });
+ }));
+ });
+
+ Promise.allSettled(promises).then(() => {
+ if (Math.random() < probRemoveUnusedCacheEntries)
+ this.removeUnusedCacheEntries().catch(err => {
+ console.error(
+ '[threadListAvatars] Error while removing unused cache entries:',
+ err);
+ });
+ });
+ }
+};