Refactor extension to webpack
This change is the biggest in the history of the project. The entire
project has been refactored so it is built with webpack.
This involves:
- Creating webpack and npm config files.
- Fixing some bugs in the code due to the fact that webpack uses strict
mode.
- Merging some pieces of code which were shared throughout the codebase
(not exhaustive, more work should be done in this direction).
- Splitting the console_inject.js file into separate files (it had 1000+
lines).
- Adapting all the build-related files (Makefile, bash scripts, etc.)
- Changing the docs to explain the new build process.
- Changing the Zuul playbook/roles to adapt to the new build process.
Change-Id: I16476d47825461c3a318b3f1a1eddb06b2df2e89
diff --git a/src/background.js b/src/background.js
index 8d0f411..15f62b9 100644
--- a/src/background.js
+++ b/src/background.js
@@ -1,4 +1,5 @@
// IMPORTANT: keep this file in sync with sw.js
+import {cleanUpOptions} from './common/optionsUtils.js'
// When the extension gets updated, set new options to their default value.
chrome.runtime.onInstalled.addListener(function(details) {
diff --git a/src/common/api.js b/src/common/api.js
index 20a6c7d..065d84c 100644
--- a/src/common/api.js
+++ b/src/common/api.js
@@ -2,7 +2,7 @@
// Function to wrap calls to the Community Console API with intelligent error
// handling.
-function CCApi(method, data, authenticated, authuser = 0) {
+export function CCApi(method, data, authenticated, authuser = 0) {
var authuserPart =
authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser);
diff --git a/src/common/commonUtils.js b/src/common/commonUtils.js
new file mode 100644
index 0000000..ee31037
--- /dev/null
+++ b/src/common/commonUtils.js
@@ -0,0 +1,17 @@
+export function parseUrl(url) {
+ var forum_a = url.match(/forum\/([0-9]+)/i);
+ var thread_a = url.match(/thread\/([0-9]+)/i);
+
+ if (forum_a === null || thread_a === null) {
+ return false;
+ }
+
+ return {
+ 'forum': forum_a[1],
+ 'thread': thread_a[1],
+ };
+}
+
+export function isEmpty(obj) {
+ return Object.keys(obj).length === 0;
+}
diff --git a/src/common/communityConsoleUtils.js b/src/common/communityConsoleUtils.js
new file mode 100644
index 0000000..05a283c
--- /dev/null
+++ b/src/common/communityConsoleUtils.js
@@ -0,0 +1,13 @@
+// Escapes username from HTML generated by the Community Console.
+export function escapeUsername(username) {
+ var quoteRegex = /"/g;
+ var commentRegex = /<!---->/g;
+ return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
+}
+
+// Retrieves authuser from the data-startup attribute.
+export function getAuthUser() {
+ var startup =
+ JSON.parse(document.querySelector('html').getAttribute('data-startup'));
+ return startup?.[2]?.[1] || '0';
+}
diff --git a/src/common/content_scripts.js b/src/common/contentScriptsUtils.js
similarity index 60%
rename from src/common/content_scripts.js
rename to src/common/contentScriptsUtils.js
index 1bc9a7d..00bc556 100644
--- a/src/common/content_scripts.js
+++ b/src/common/contentScriptsUtils.js
@@ -1,4 +1,4 @@
-function injectStylesheet(stylesheetName, attributes = {}) {
+export function injectStylesheet(stylesheetName, attributes = {}) {
var link = document.createElement('link');
link.setAttribute('rel', 'stylesheet');
link.setAttribute('href', stylesheetName);
@@ -6,18 +6,12 @@
document.head.appendChild(link);
}
-function injectStyles(css) {
+export function injectStyles(css) {
injectStylesheet('data:text/css;charset=UTF-8,' + encodeURIComponent(css));
}
-function injectScript(scriptName) {
+export function injectScript(scriptName) {
var script = document.createElement('script');
script.src = scriptName;
document.head.appendChild(script);
}
-
-function escapeUsername(username) {
- var quoteRegex = /"/g;
- var commentRegex = /<!---->/g;
- return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
-}
diff --git a/src/common/csEventListener.js b/src/common/csEventListener.js
new file mode 100644
index 0000000..14b4295
--- /dev/null
+++ b/src/common/csEventListener.js
@@ -0,0 +1,43 @@
+// In order to pass i18n strings and settings values to the injected scripts,
+// which don't have access to the chrome.* APIs, we use event listeners.
+
+export function setUpListener() {
+ chrome.storage.sync.get(null, function(options) {
+ window.addEventListener('TWPT_sendRequest', evt => {
+ var request = evt.detail;
+ switch (request.data.action) {
+ case 'geti18nMessage':
+ var data = chrome.i18n.getMessage(
+ request.data.msg,
+ (Array.isArray(request.data.placeholders) ?
+ request.data.placeholders :
+ []));
+ break;
+
+ case 'getProfileIndicatorOptions':
+ var data = {
+ 'indicatorDot': options.profileindicator,
+ 'numPosts': options.profileindicatoralt
+ };
+ break;
+
+ case 'getNumPostMonths':
+ var data = options.profileindicatoralt_months;
+ break;
+
+ default:
+ var data = 'unknownAction';
+ console.warn('Unknown action ' + request.data.action + '.');
+ break;
+ }
+
+ var response = {
+ data,
+ requestId: request.id,
+ prefix: (request.prefix || 'TWPT'),
+ };
+
+ window.postMessage(response, '*');
+ });
+ });
+}
diff --git a/src/common/cs_event_listener.js b/src/common/cs_event_listener.js
deleted file mode 100644
index c7de08f..0000000
--- a/src/common/cs_event_listener.js
+++ /dev/null
@@ -1,40 +0,0 @@
-// In order to pass i18n strings and settings values to the injected scripts,
-// which don't have access to the chrome.* APIs, we use event listeners.
-chrome.storage.sync.get(null, function(options) {
- window.addEventListener('TWPT_sendRequest', evt => {
- var request = evt.detail;
- switch (request.data.action) {
- case 'geti18nMessage':
- var data = chrome.i18n.getMessage(
- request.data.msg,
- (Array.isArray(request.data.placeholders) ?
- request.data.placeholders :
- []));
- break;
-
- case 'getProfileIndicatorOptions':
- var data = {
- 'indicatorDot': options.profileindicator,
- 'numPosts': options.profileindicatoralt
- };
- break;
-
- case 'getNumPostMonths':
- var data = options.profileindicatoralt_months;
- break;
-
- default:
- var data = 'unknownAction';
- console.warn('Unknown action ' + request.data.action + '.');
- break;
- }
-
- var response = {
- data,
- requestId: request.id,
- prefix: (request.prefix || 'TWPT'),
- };
-
- window.postMessage(response, '*');
- });
-});
diff --git a/src/common/extUtils.js b/src/common/extUtils.js
new file mode 100644
index 0000000..a1b4d48
--- /dev/null
+++ b/src/common/extUtils.js
@@ -0,0 +1,13 @@
+// This method is based on the fact that when building the extension for Firefox
+// the browser_specific_settings.gecko entry is included.
+export function isFirefox() {
+ var manifest = chrome.runtime.getManifest();
+ return manifest.browser_specific_settings !== undefined &&
+ manifest.browser_specific_settings.gecko !== undefined;
+}
+
+// Returns whether the extension is a release version.
+export function isReleaseVersion() {
+ var manifest = chrome.runtime.getManifest();
+ return ('version' in manifest) && manifest.version != '0';
+}
diff --git a/src/common/common.js b/src/common/optionsPrototype.json5
similarity index 61%
rename from src/common/common.js
rename to src/common/optionsPrototype.json5
index 4fcae45..191d0e1 100644
--- a/src/common/common.js
+++ b/src/common/optionsPrototype.json5
@@ -1,4 +1,4 @@
-const optionsPrototype = {
+{
// Available options:
'list': {
defaultValue: true,
@@ -118,53 +118,4 @@
defaultValue: false,
context: 'deprecated',
},
-};
-
-const specialOptions = [
- 'profileindicatoralt_months',
- 'ccdarktheme_mode',
- 'ccdarktheme_switch_enabled',
- 'ccdragndropfix',
-];
-
-function isEmpty(obj) {
- return Object.keys(obj).length === 0;
-}
-
-// Adds missing options with their default value. If |dryRun| is set to false,
-// they are also saved to the sync storage area.
-function cleanUpOptions(options, dryRun = false) {
- console.log('[cleanUpOptions] Previous options', JSON.stringify(options));
-
- if (typeof options !== 'object' || options === null) options = {};
-
- var ok = true;
- for (const [opt, optMeta] of Object.entries(optionsPrototype)) {
- if (!(opt in options)) {
- ok = false;
- options[opt] = optMeta['defaultValue'];
- }
- }
-
- console.log('[cleanUpOptions] New options', JSON.stringify(options));
-
- if (!ok && !dryRun) {
- chrome.storage.sync.set(options);
- }
-
- return options;
-}
-
-// This method is based on the fact that when building the extension for Firefox
-// the browser_specific_settings.gecko entry is included.
-function isFirefox() {
- var manifest = chrome.runtime.getManifest();
- return manifest.browser_specific_settings !== undefined &&
- manifest.browser_specific_settings.gecko !== undefined;
-}
-
-// Returns whether the extension is a release version.
-function isReleaseVersion() {
- var manifest = chrome.runtime.getManifest();
- return ('version' in manifest) && manifest.version != '0';
}
diff --git a/src/common/optionsUtils.js b/src/common/optionsUtils.js
new file mode 100644
index 0000000..0efb6c9
--- /dev/null
+++ b/src/common/optionsUtils.js
@@ -0,0 +1,28 @@
+import optionsPrototype from './optionsPrototype.json5';
+import specialOptions from './specialOptions.json5';
+
+export {optionsPrototype, specialOptions};
+
+// Adds missing options with their default value. If |dryRun| is set to false,
+// they are also saved to the sync storage area.
+export function cleanUpOptions(options, dryRun = false) {
+ console.log('[cleanUpOptions] Previous options', JSON.stringify(options));
+
+ if (typeof options !== 'object' || options === null) options = {};
+
+ var ok = true;
+ for (const [opt, optMeta] of Object.entries(optionsPrototype)) {
+ if (!(opt in options)) {
+ ok = false;
+ options[opt] = optMeta['defaultValue'];
+ }
+ }
+
+ console.log('[cleanUpOptions] New options', JSON.stringify(options));
+
+ if (!ok && !dryRun) {
+ chrome.storage.sync.set(options);
+ }
+
+ return options;
+}
diff --git a/src/common/specialOptions.json5 b/src/common/specialOptions.json5
new file mode 100644
index 0000000..55dde20
--- /dev/null
+++ b/src/common/specialOptions.json5
@@ -0,0 +1,6 @@
+[
+ 'profileindicatoralt_months',
+ 'ccdarktheme_mode',
+ 'ccdarktheme_switch_enabled',
+ 'ccdragndropfix',
+]
diff --git a/src/contentScripts/communityConsole/autoRefresh.js b/src/contentScripts/communityConsole/autoRefresh.js
new file mode 100644
index 0000000..c39970f
--- /dev/null
+++ b/src/contentScripts/communityConsole/autoRefresh.js
@@ -0,0 +1,250 @@
+import {createExtBadge} from './utils.js';
+import {getAuthUser} from '../../common/communityConsoleUtils.js';
+
+var authuser = getAuthUser();
+
+export var autoRefresh = {
+ isLookingForUpdates: false,
+ isUpdatePromptShown: false,
+ lastTimestamp: null,
+ filter: null,
+ path: null,
+ snackbar: null,
+ interval: null,
+ firstCallTimeout: null,
+ intervalMs: 3 * 60 * 1000, // 3 minutes
+ firstCallDelayMs: 3 * 1000, // 3 seconds
+ getStartupData() {
+ return JSON.parse(
+ document.querySelector('html').getAttribute('data-startup'));
+ },
+ isOrderedByTimestampDescending() {
+ var startup = this.getStartupData();
+ // Returns orderOptions.by == TIMESTAMP && orderOptions.desc == true
+ return (
+ startup?.[1]?.[1]?.[3]?.[14]?.[1] == 1 &&
+ startup?.[1]?.[1]?.[3]?.[14]?.[2] == true);
+ },
+ getCustomFilter(path) {
+ var searchRegex = /^\/s\/community\/search\/([^\/]*)/;
+ var matches = path.match(searchRegex);
+ if (matches !== null && matches.length > 1) {
+ var search = decodeURIComponent(matches[1]);
+ var params = new URLSearchParams(search);
+ return params.get('query') || '';
+ }
+
+ return '';
+ },
+ filterHasOverride(filter, override) {
+ var escapedOverride = override.replace(/([^\w\d\s])/gi, '\\$1');
+ var regex = new RegExp('[^a-zA-Z0-9]?' + escapedOverride + ':');
+ return regex.test(filter);
+ },
+ getFilter(path) {
+ var query = this.getCustomFilter(path);
+
+ // Note: This logic has been copied and adapted from the
+ // _buildQuery$1$threadId function in the Community Console
+ var conditions = '';
+ var startup = this.getStartupData();
+
+ // TODO(avm99963): if the selected forums are changed without reloading the
+ // page, this will get the old selected forums. Fix this.
+ var forums = startup?.[1]?.[1]?.[3]?.[8] ?? [];
+ if (!this.filterHasOverride(query, 'forum') && forums !== null &&
+ forums.length > 0)
+ conditions += ' forum:(' + forums.join(' | ') + ')';
+
+ var langs = startup?.[1]?.[1]?.[3]?.[5] ?? [];
+ if (!this.filterHasOverride(query, 'lang') && langs !== null &&
+ langs.length > 0)
+ conditions += ' lang:(' + langs.map(l => '"' + l + '"').join(' | ') + ')';
+
+ if (query.length !== 0 && conditions.length !== 0)
+ return '(' + query + ')' + conditions;
+ return query + conditions;
+ },
+ getLastTimestamp() {
+ var APIRequestUrl = 'https://support.google.com/s/community/api/ViewForum' +
+ (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
+
+ return fetch(APIRequestUrl, {
+ 'headers': {
+ 'content-type': 'text/plain; charset=utf-8',
+ },
+ 'body': JSON.stringify({
+ 1: '0', // TODO: Change, when only a forum is selected, it
+ // should be set here
+ 2: {
+ 1: {
+ 2: 2,
+ },
+ 2: {
+ 1: 1,
+ 2: true,
+ },
+ 12: this.filter,
+ },
+ }),
+ 'method': 'POST',
+ 'mode': 'cors',
+ 'credentials': 'include',
+ })
+ .then(res => {
+ if (res.status == 200 || res.status == 400) {
+ return res.json().then(data => ({
+ status: res.status,
+ body: data,
+ }));
+ } else {
+ throw new Error('Status code ' + res.status + ' was not expected.');
+ }
+ })
+ .then(res => {
+ if (res.status == 400) {
+ throw new Error(
+ res.body[4] ||
+ ('Response status: 400. Error code: ' + res.body[2]));
+ }
+
+ return res.body;
+ })
+ .then(body => {
+ var timestamp = body?.[1]?.[2]?.[0]?.[2]?.[17];
+ if (timestamp === undefined)
+ throw new Error(
+ 'Unexpected body of response (' +
+ (body?.[1]?.[2]?.[0] === undefined ?
+ 'no threads were returned' :
+ 'the timestamp value is not present in the first thread') +
+ ').');
+
+ return timestamp;
+ });
+ // TODO(avm99963): Add retry mechanism (sometimes thread lists are empty,
+ // but when loading the next page the thread appears).
+ //
+ // NOTE(avm99963): It seems like loading the first 2 threads instead of only
+ // the first one fixes this (empty lists are now rarely returned).
+ },
+ unregister() {
+ console.debug('autorefresh_list: unregistering');
+
+ if (!this.isLookingForUpdates) return;
+
+ window.clearTimeout(this.firstCallTimeout);
+ window.clearInterval(this.interval);
+ this.isUpdatePromptShown = false;
+ this.isLookingForUpdates = false;
+ },
+ showUpdatePrompt() {
+ this.snackbar.classList.remove('TWPT-hidden');
+ document.title = '[!!!] ' + document.title.replace('[!!!] ', '');
+ this.isUpdatePromptShown = true;
+ },
+ hideUpdatePrompt() {
+ this.snackbar.classList.add('TWPT-hidden');
+ document.title = document.title.replace('[!!!] ', '');
+ this.isUpdatePromptShown = false;
+ },
+ injectUpdatePrompt() {
+ var pane = document.createElement('div');
+ pane.classList.add('TWPT-pane-for-snackbar');
+
+ var snackbar = document.createElement('material-snackbar-panel');
+ snackbar.classList.add('TWPT-snackbar');
+ snackbar.classList.add('TWPT-hidden');
+
+ var ac = document.createElement('div');
+ ac.classList.add('TWPT-animation-container');
+
+ var nb = document.createElement('div');
+ nb.classList.add('TWPT-notification-bar');
+
+ var ft = document.createElement('focus-trap');
+
+ var content = document.createElement('div');
+ content.classList.add('TWPT-focus-content-wrapper');
+
+ var badge = createExtBadge();
+
+ var message = document.createElement('div');
+ message.classList.add('TWPT-message');
+ message.textContent =
+ chrome.i18n.getMessage('inject_autorefresh_list_snackbar_message');
+
+ var action = document.createElement('div');
+ action.classList.add('TWPT-action');
+ action.textContent =
+ chrome.i18n.getMessage('inject_autorefresh_list_snackbar_action');
+
+ action.addEventListener('click', e => {
+ this.hideUpdatePrompt();
+ document.querySelector('.app-title-button').click();
+ });
+
+ content.append(badge, message, action);
+ ft.append(content);
+ nb.append(ft);
+ ac.append(nb);
+ snackbar.append(ac);
+ pane.append(snackbar);
+ document.getElementById('default-acx-overlay-container').append(pane);
+ this.snackbar = snackbar;
+ },
+ checkUpdate() {
+ if (location.pathname != this.path) {
+ this.unregister();
+ return;
+ }
+
+ if (this.isUpdatePromptShown) return;
+
+ console.debug('Checking for update at: ', new Date());
+
+ this.getLastTimestamp()
+ .then(timestamp => {
+ if (timestamp != this.lastTimestamp) this.showUpdatePrompt();
+ })
+ .catch(
+ err => console.error(
+ 'Coudln\'t get last timestamp (while updating): ', err));
+ },
+ firstCall() {
+ console.debug(
+ 'autorefresh_list: now performing first call to finish setup (filter: [' +
+ this.filter + '])');
+
+ if (location.pathname != this.path) {
+ this.unregister();
+ return;
+ }
+
+ this.getLastTimestamp()
+ .then(timestamp => {
+ this.lastTimestamp = timestamp;
+ var checkUpdateCallback = this.checkUpdate.bind(this);
+ this.interval =
+ window.setInterval(checkUpdateCallback, this.intervalMs);
+ })
+ .catch(
+ err => console.error(
+ 'Couldn\'t get last timestamp (while setting up): ', err));
+ },
+ setUp() {
+ if (!this.isOrderedByTimestampDescending()) return;
+
+ this.unregister();
+
+ console.debug('autorefresh_list: starting set up...');
+
+ if (this.snackbar === null) this.injectUpdatePrompt();
+ this.isLookingForUpdates = true;
+ this.path = location.pathname;
+ this.filter = this.getFilter(this.path);
+
+ var firstCall = this.firstCall.bind(this);
+ this.firstCallTimeout = window.setTimeout(firstCall, this.firstCallDelayMs);
+ },
+};
diff --git a/src/contentScripts/communityConsole/avatars.js b/src/contentScripts/communityConsole/avatars.js
new file mode 100644
index 0000000..253fe8f
--- /dev/null
+++ b/src/contentScripts/communityConsole/avatars.js
@@ -0,0 +1,178 @@
+import {CCApi} from '../../common/api.js';
+import {parseUrl} from '../../common/commonUtils.js';
+
+export var avatars = {
+ isFilterSetUp: false,
+ privateForums: [],
+
+ // Gets a list of private forums. If it is already cached, the cached list is
+ // returned; otherwise it is also computed and cached.
+ getPrivateForums() {
+ return new Promise((resolve, reject) => {
+ if (this.isFilterSetUp) return resolve(this.privateForums);
+
+ if (!document.documentElement.hasAttribute('data-startup'))
+ return reject('[threadListAvatars] Couldn\'t get startup data.');
+
+ var startupData =
+ JSON.parse(document.documentElement.getAttribute('data-startup'));
+ var forums = startupData?.['1']?.['2'];
+ if (forums === undefined)
+ return reject(
+ '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
+
+ for (var f of forums) {
+ var forumId = f?.['2']?.['1']?.['1'];
+ var forumVisibility = f?.['2']?.['18'];
+ if (forumId === undefined || forumVisibility === undefined) {
+ console.warn(
+ '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
+ f);
+ continue;
+ }
+
+ // forumVisibility's value 1 means "PUBLIC".
+ if (forumVisibility != 1) this.privateForums.push(forumId);
+ }
+
+ // Forum 51488989 is marked as public but it is in fact private.
+ this.privateForums.push('51488989');
+
+ this.isFilterSetUp = true;
+ return resolve(this.privateForums);
+ });
+ },
+
+ // Some threads belong to private forums, and this feature will not be able to
+ // get its avatars since it makes an anonymomus call to get the contents of
+ // the thread.
+ //
+ // This function returns whether avatars should be retrieved depending on if
+ // the thread belongs to a known private forum.
+ shouldRetrieveAvatars(thread) {
+ return this.getPrivateForums().then(privateForums => {
+ return !privateForums.includes(thread.forum);
+ });
+ },
+
+ // Get an object with the author of the thread and an array of the first |num|
+ // replies from the thread |thread|.
+ getFirstMessages(thread, num = 15) {
+ return CCApi(
+ 'ViewThread', {
+ 1: thread.forum,
+ 2: thread.thread,
+ // options
+ 3: {
+ // pagination
+ 1: {
+ 2: num, // maxNum
+ },
+ 3: true, // withMessages
+ 5: true, // withUserProfile
+ 10: false, // withPromotedMessages
+ 16: false, // withThreadNotes
+ 18: true, // sendNewThreadIfMoved
+ }
+ },
+ // |authentication| is false because otherwise this would mark
+ // the thread as read as a side effect, and that would mark all
+ // threads in the list as read.
+ //
+ // Due to the fact that we have to call this endpoint
+ // anonymously, this means we can't retrieve information about
+ // threads in private forums.
+ /* authentication = */ false)
+ .then(data => {
+ var numMessages = data?.['1']?.['8'];
+ if (numMessages === undefined)
+ throw new Error(
+ 'Request to view thread doesn\'t include the number of messages');
+
+ var messages = numMessages == 0 ? [] : data?.['1']['3'];
+ if (messages === undefined)
+ throw new Error(
+ 'numMessages was ' + numMessages +
+ ' but the response didn\'t include any message.');
+
+ var author = data?.['1']?.['4'];
+ if (author === undefined)
+ throw new Error(
+ 'Author isn\'t included in the ViewThread response.');
+
+ return {
+ messages,
+ author,
+ };
+ });
+ },
+
+ // Get a list of at most |num| avatars for thread |thread|
+ getVisibleAvatars(thread, num = 3) {
+ return this.shouldRetrieveAvatars(thread).then(shouldRetrieve => {
+ if (!shouldRetrieve) {
+ console.debug('[threadListAvatars] Skipping thread', thread);
+ return [];
+ }
+
+ return this.getFirstMessages(thread).then(result => {
+ var messages = result.messages;
+ var author = result.author;
+
+ var avatarUrls = [];
+
+ var authorUrl = author?.['1']?.['2'];
+ if (authorUrl !== undefined) avatarUrls.push(authorUrl);
+
+ for (var m of messages) {
+ var url = m?.['3']?.['1']?.['2'];
+
+ if (url === undefined) continue;
+ if (!avatarUrls.includes(url)) avatarUrls.push(url);
+ if (avatarUrls.length == 3) break;
+ }
+
+ return avatarUrls;
+ });
+ });
+ },
+
+ // Inject avatars for thread summary (thread item) |node| in a thread list.
+ inject(node) {
+ var header = node.querySelector(
+ 'ec-thread-summary .main-header .panel-description a.header');
+ if (header === null) {
+ console.error(
+ '[threadListAvatars] Header is not present in the thread item\'s DOM.');
+ return;
+ }
+
+ var thread = parseUrl(header.href);
+ if (thread === false) {
+ console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
+ return;
+ }
+
+ this.getVisibleAvatars(thread)
+ .then(avatarUrls => {
+ var avatarsContainer = document.createElement('div');
+ avatarsContainer.classList.add('TWPT-avatars');
+
+ var count = Math.floor(Math.random() * 4);
+
+ for (var i = 0; i < avatarUrls.length; ++i) {
+ var avatar = document.createElement('div');
+ avatar.classList.add('TWPT-avatar');
+ avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
+ avatarsContainer.appendChild(avatar);
+ }
+
+ header.appendChild(avatarsContainer);
+ })
+ .catch(err => {
+ console.error(
+ '[threadListAvatars] Could not retrieve avatars for thread',
+ thread, err);
+ });
+ },
+};
diff --git a/src/contentScripts/communityConsole/batchLock.js b/src/contentScripts/communityConsole/batchLock.js
new file mode 100644
index 0000000..5bc0361
--- /dev/null
+++ b/src/contentScripts/communityConsole/batchLock.js
@@ -0,0 +1,133 @@
+import {removeChildNodes, createExtBadge} from './utils.js';
+
+export function nodeIsReadToggleBtn(node) {
+ return ('tagName' in node) && node.tagName == 'MATERIAL-BUTTON' &&
+ node.getAttribute('debugid') !== null &&
+ (node.getAttribute('debugid') == 'mark-read-button' ||
+ node.getAttribute('debugid') == 'mark-unread-button') &&
+ ('parentNode' in node) && node.parentNode !== null &&
+ ('parentNode' in node.parentNode) &&
+ node.parentNode.querySelector('[debugid="batchlock"]') === null &&
+ node.parentNode.parentNode !== null &&
+ ('tagName' in node.parentNode.parentNode) &&
+ node.parentNode.parentNode.tagName == 'EC-BULK-ACTIONS';
+}
+
+export function addBatchLockBtn(readToggle) {
+ var clone = readToggle.cloneNode(true);
+ clone.setAttribute('debugid', 'batchlock');
+ clone.classList.add('TWPT-btn--with-badge');
+ clone.setAttribute('title', chrome.i18n.getMessage('inject_lockbtn'));
+ clone.querySelector('material-icon').setAttribute('icon', 'lock');
+ clone.querySelector('i.material-icon-i').textContent = 'lock';
+
+ var badge = createExtBadge();
+ clone.append(badge);
+
+ clone.addEventListener('click', function() {
+ var modal = document.querySelector('.pane[pane-id="default-1"]');
+
+ var dialog = document.createElement('material-dialog');
+ dialog.setAttribute('role', 'dialog');
+ dialog.setAttribute('aria-modal', 'true');
+ dialog.classList.add('TWPT-dialog');
+
+ var header = document.createElement('header');
+ header.setAttribute('role', 'presentation');
+ header.classList.add('TWPT-dialog-header');
+
+ var title = document.createElement('div');
+ title.classList.add('TWPT-dialog-header--title', 'title');
+ title.textContent = chrome.i18n.getMessage('inject_lockbtn');
+
+ header.append(title);
+
+ var main = document.createElement('main');
+ main.setAttribute('role', 'presentation');
+ main.classList.add('TWPT-dialog-main');
+
+ var p = document.createElement('p');
+ p.textContent = chrome.i18n.getMessage('inject_lockdialog_desc');
+
+ main.append(p);
+
+ dialog.append(header, main);
+
+ var footers = [['lock', 'unlock', 'cancel'], ['reload', 'close']];
+
+ for (var i = 0; i < footers.length; ++i) {
+ var footer = document.createElement('footer');
+ footer.setAttribute('role', 'presentation');
+ footer.classList.add('TWPT-dialog-footer');
+ footer.setAttribute('data-footer-id', i);
+
+ if (i > 0) footer.classList.add('is-hidden');
+
+ footers[i].forEach(action => {
+ var btn = document.createElement('material-button');
+ btn.setAttribute('role', 'button');
+ btn.classList.add('TWPT-dialog-footer-btn');
+ if (i == 1) btn.classList.add('is-disabled');
+
+ switch (action) {
+ case 'lock':
+ case 'unlock':
+ btn.addEventListener('click', _ => {
+ if (btn.classList.contains('is-disabled')) return;
+ var message = {
+ action,
+ prefix: 'TWPT-batchlock',
+ };
+ window.postMessage(message, '*');
+ });
+ break;
+
+ case 'cancel':
+ case 'close':
+ btn.addEventListener('click', _ => {
+ if (btn.classList.contains('is-disabled')) return;
+ modal.classList.remove('visible');
+ modal.style.display = 'none';
+ removeChildNodes(modal);
+ });
+ break;
+
+ case 'reload':
+ btn.addEventListener('click', _ => {
+ if (btn.classList.contains('is-disabled')) return;
+ window.location.reload()
+ });
+ break;
+ }
+
+ var content = document.createElement('div');
+ content.classList.add('content', 'TWPT-dialog-footer-btn--content');
+ content.textContent =
+ chrome.i18n.getMessage('inject_lockdialog_btn_' + action);
+
+ btn.append(content);
+ footer.append(btn);
+ });
+
+ var clear = document.createElement('div');
+ clear.style.clear = 'both';
+
+ footer.append(clear);
+ dialog.append(footer);
+ }
+
+ removeChildNodes(modal);
+ modal.append(dialog);
+ modal.classList.add('visible', 'modal');
+ modal.style.display = 'flex';
+ });
+
+ var duplicateBtn =
+ readToggle.parentNode.querySelector('[debugid="mark-duplicate-button"]');
+ if (duplicateBtn)
+ duplicateBtn.parentNode.insertBefore(
+ clone, (duplicateBtn.nextSibling || duplicateBtn));
+ else
+ readToggle.parentNode.insertBefore(
+ clone, (readToggle.nextSibling || readToggle));
+}
diff --git a/src/contentScripts/communityConsole/darkMode.js b/src/contentScripts/communityConsole/darkMode.js
new file mode 100644
index 0000000..d10a77c
--- /dev/null
+++ b/src/contentScripts/communityConsole/darkMode.js
@@ -0,0 +1,49 @@
+import {createExtBadge} from './utils.js';
+
+export function injectDarkModeButton(rightControl, previousDarkModeOption) {
+ var darkThemeSwitch = document.createElement('material-button');
+ darkThemeSwitch.classList.add('TWPT-dark-theme', 'TWPT-btn--with-badge');
+ darkThemeSwitch.setAttribute('button', '');
+ darkThemeSwitch.setAttribute(
+ 'title', chrome.i18n.getMessage('inject_ccdarktheme_helper'));
+
+ darkThemeSwitch.addEventListener('click', e => {
+ chrome.storage.sync.get(null, currentOptions => {
+ currentOptions.ccdarktheme_switch_status = !previousDarkModeOption;
+ chrome.storage.sync.set(currentOptions, _ => {
+ location.reload();
+ });
+ });
+ });
+
+ var switchContent = document.createElement('div');
+ switchContent.classList.add('content');
+
+ var icon = document.createElement('material-icon');
+
+ var i = document.createElement('i');
+ i.classList.add('material-icon-i', 'material-icons-extended');
+ i.textContent = 'brightness_4';
+
+ icon.appendChild(i);
+ switchContent.appendChild(icon);
+ darkThemeSwitch.appendChild(switchContent);
+
+ var badgeContent = createExtBadge();
+
+ darkThemeSwitch.appendChild(badgeContent);
+
+ rightControl.style.width =
+ (parseInt(window.getComputedStyle(rightControl).width) + 58) + 'px';
+ rightControl.insertAdjacentElement('afterbegin', darkThemeSwitch);
+}
+
+export function isDarkThemeOn(options) {
+ if (!options.ccdarktheme) return false;
+
+ if (options.ccdarktheme_mode == 'switch')
+ return options.ccdarktheme_switch_status;
+
+ return window.matchMedia &&
+ window.matchMedia('(prefers-color-scheme: dark)').matches;
+}
diff --git a/src/contentScripts/communityConsole/dragAndDropFix.js b/src/contentScripts/communityConsole/dragAndDropFix.js
new file mode 100644
index 0000000..1f293f6
--- /dev/null
+++ b/src/contentScripts/communityConsole/dragAndDropFix.js
@@ -0,0 +1,9 @@
+export function applyDragAndDropFix(node) {
+ console.debug('Adding link drag&drop fix to ', node);
+ node.addEventListener('drop', e => {
+ if (e.dataTransfer.types.includes('text/uri-list')) {
+ e.stopImmediatePropagation();
+ console.debug('Stopping link drop event propagation.');
+ }
+ }, true);
+}
diff --git a/src/contentScripts/communityConsole/forceMarkAsRead.js b/src/contentScripts/communityConsole/forceMarkAsRead.js
new file mode 100644
index 0000000..c573788
--- /dev/null
+++ b/src/contentScripts/communityConsole/forceMarkAsRead.js
@@ -0,0 +1,81 @@
+import {CCApi} from '../../common/api.js';
+import {getAuthUser} from '../../common/communityConsoleUtils.js';
+
+var authuser = getAuthUser();
+
+// Send a request to mark the current thread as read
+export function markCurrentThreadAsRead() {
+ console.debug(
+ '[forceMarkAsRead] %cTrying to mark a thread as read.',
+ 'color: #1a73e8;');
+
+ var threadRegex =
+ /\/s\/community\/?.*\/forum\/([0-9]+)\/?.*\/thread\/([0-9]+)/;
+
+ var url = location.href;
+ var matches = url.match(threadRegex);
+ if (matches !== null && matches.length > 2) {
+ var forumId = matches[1];
+ var threadId = matches[2];
+
+ console.debug('[forceMarkAsRead] Thread details:', {forumId, threadId});
+
+ return CCApi(
+ 'ViewThread', {
+ 1: forumId,
+ 2: threadId,
+ // options
+ 3: {
+ // pagination
+ 1: {
+ 2: 0, // maxNum
+ },
+ 3: false, // withMessages
+ 5: false, // withUserProfile
+ 6: true, // withUserReadState
+ 9: false, // withRequestorProfile
+ 10: false, // withPromotedMessages
+ 11: false, // withExpertResponder
+ },
+ },
+ true, authuser)
+ .then(thread => {
+ if (thread?.[1]?.[6] === true) {
+ console.debug(
+ '[forceMarkAsRead] This thread is already marked as read, but marking it as read anyways.');
+ }
+
+ var lastMessageId = thread?.[1]?.[2]?.[10];
+
+ console.debug('[forceMarkAsRead] lastMessageId is:', lastMessageId);
+
+ if (lastMessageId === undefined)
+ throw new Error(
+ 'Couldn\'t find lastMessageId in the ViewThread response.');
+
+ return CCApi(
+ 'SetUserReadStateBulk', {
+ 1: [{
+ 1: forumId,
+ 2: threadId,
+ 3: lastMessageId,
+ }],
+ },
+ true, authuser);
+ })
+ .then(_ => {
+ console.debug(
+ '[forceMarkAsRead] %cSuccessfully set as read!',
+ 'color: #1e8e3e;');
+ })
+ .catch(err => {
+ console.error(
+ '[forceMarkAsRead] Error while marking current thread as read: ',
+ err);
+ });
+ } else {
+ console.error(
+ '[forceMarkAsRead] Couldn\'t retrieve forumId and threadId from the current URL.',
+ url);
+ }
+}
diff --git a/src/contentScripts/communityConsole/main.js b/src/contentScripts/communityConsole/main.js
new file mode 100644
index 0000000..dd11851
--- /dev/null
+++ b/src/contentScripts/communityConsole/main.js
@@ -0,0 +1,235 @@
+import {injectScript, injectStyles, injectStylesheet} from '../../common/contentScriptsUtils.js';
+
+import {autoRefresh} from './autoRefresh.js';
+import {avatars} from './avatars.js';
+import {addBatchLockBtn, nodeIsReadToggleBtn} from './batchLock.js';
+import {injectDarkModeButton, isDarkThemeOn} from './darkMode.js';
+import {applyDragAndDropFix} from './dragAndDropFix.js';
+import {markCurrentThreadAsRead} from './forceMarkAsRead.js';
+import {injectPreviousPostsLinks} from './profileHistoryLink.js';
+import {unifiedProfilesFix} from './unifiedProfiles.js';
+
+var mutationObserver, intersectionObserver, intersectionOptions, options;
+
+const watchedNodesSelectors = [
+ // App container (used to set up the intersection observer and inject the dark
+ // mode button)
+ 'ec-app',
+
+ // Load more bar (for the "load more"/"load all" buttons)
+ '.load-more-bar',
+
+ // Username span/editor inside ec-user (user profile view)
+ 'ec-user .main-card .header > .name > span',
+ 'ec-user .main-card .header > .name > ec-display-name-editor',
+
+ // Rich text editor
+ 'ec-movable-dialog',
+ 'ec-rich-text-editor',
+
+ // Read/unread bulk action in the list of thread, for the batch lock feature
+ 'ec-bulk-actions material-button[debugid="mark-read-button"]',
+ 'ec-bulk-actions material-button[debugid="mark-unread-button"]',
+
+ // Thread list items (used to inject the avatars)
+ 'li',
+
+ // Thread list (used for the autorefresh feature)
+ 'ec-thread-list',
+
+ // Unified profile iframe
+ 'iframe',
+
+ // Thread component
+ 'ec-thread',
+];
+
+function handleCandidateNode(node) {
+ if (typeof node.classList !== 'undefined') {
+ if (('tagName' in node) && node.tagName == 'EC-APP') {
+ // Set up the intersectionObserver
+ if (typeof intersectionObserver === 'undefined') {
+ var scrollableContent = node.querySelector('.scrollable-content');
+ if (scrollableContent !== null) {
+ intersectionOptions = {
+ root: scrollableContent,
+ rootMargin: '0px',
+ threshold: 1.0,
+ };
+
+ intersectionObserver = new IntersectionObserver(
+ intersectionCallback, intersectionOptions);
+ }
+ }
+
+ // Inject the dark mode button
+ if (options.ccdarktheme && options.ccdarktheme_mode == 'switch') {
+ var rightControl = node.querySelector('header .right-control');
+ if (rightControl !== null)
+ injectDarkModeButton(rightControl, options.ccdarktheme_switch_status);
+ }
+ }
+
+ // Start the intersectionObserver for the "load more"/"load all" buttons
+ // inside a thread
+ if ((options.thread || options.threadall) &&
+ node.classList.contains('load-more-bar')) {
+ if (typeof intersectionObserver !== 'undefined') {
+ if (options.thread)
+ intersectionObserver.observe(node.querySelector('.load-more-button'));
+ if (options.threadall)
+ intersectionObserver.observe(node.querySelector('.load-all-button'));
+ } else {
+ console.warn(
+ '[infinitescroll] ' +
+ 'The intersectionObserver is not ready yet.');
+ }
+ }
+
+ // Show the "previous posts" links
+ // Here we're selecting the 'ec-user > div' element (unique child)
+ if (options.history &&
+ (node.matches('ec-user .main-card .header > .name > span') ||
+ node.matches(
+ 'ec-user .main-card .header > .name > ec-display-name-editor'))) {
+ injectPreviousPostsLinks(node);
+ }
+
+ // Fix the drag&drop issue with the rich text editor
+ //
+ // We target both tags because in different contexts different
+ // elements containing the text editor get added to the DOM structure.
+ // Sometimes it's a EC-MOVABLE-DIALOG which already contains the
+ // EC-RICH-TEXT-EDITOR, and sometimes it's the EC-RICH-TEXT-EDITOR
+ // directly.
+ if (options.ccdragndropfix && ('tagName' in node) &&
+ (node.tagName == 'EC-MOVABLE-DIALOG' ||
+ node.tagName == 'EC-RICH-TEXT-EDITOR')) {
+ applyDragAndDropFix(node);
+ }
+
+ // Inject the batch lock button in the thread list
+ if (options.batchlock && nodeIsReadToggleBtn(node)) {
+ addBatchLockBtn(node);
+ }
+
+ // Inject avatar links to threads in the thread list
+ if (options.threadlistavatars && ('tagName' in node) &&
+ (node.tagName == 'LI') &&
+ node.querySelector('ec-thread-summary') !== null) {
+ avatars.inject(node);
+ }
+
+ // Set up the autorefresh list feature
+ if (options.autorefreshlist && ('tagName' in node) &&
+ node.tagName == 'EC-THREAD-LIST') {
+ autoRefresh.setUp();
+ }
+
+ // Redirect unified profile iframe to dark version if applicable
+ if (node.tagName == 'IFRAME' && isDarkThemeOn(options) &&
+ unifiedProfilesFix.checkIframe(node)) {
+ unifiedProfilesFix.fixIframe(node);
+ }
+
+ // Force mark thread as read
+ if (options.forcemarkasread && node.tagName == 'EC-THREAD') {
+ markCurrentThreadAsRead();
+ }
+ }
+}
+
+function handleRemovedNode(node) {
+ // Remove snackbar when exiting thread list view
+ if (options.autorefreshlist && 'tagName' in node &&
+ node.tagName == 'EC-THREAD-LIST') {
+ autoRefresh.hideUpdatePrompt();
+ }
+}
+
+function mutationCallback(mutationList, observer) {
+ mutationList.forEach((mutation) => {
+ if (mutation.type == 'childList') {
+ mutation.addedNodes.forEach(function(node) {
+ handleCandidateNode(node);
+ });
+
+ mutation.removedNodes.forEach(function(node) {
+ handleRemovedNode(node);
+ });
+ }
+ });
+}
+
+function intersectionCallback(entries, observer) {
+ entries.forEach(entry => {
+ if (entry.isIntersecting) {
+ entry.target.click();
+ }
+ });
+};
+
+var observerOptions = {
+ childList: true,
+ subtree: true,
+};
+
+chrome.storage.sync.get(null, function(items) {
+ options = items;
+
+ // Before starting the mutation Observer, check whether we missed any
+ // mutations by manually checking whether some watched nodes already
+ // exist.
+ var cssSelectors = watchedNodesSelectors.join(',');
+ document.querySelectorAll(cssSelectors)
+ .forEach(node => handleCandidateNode(node));
+
+ mutationObserver = new MutationObserver(mutationCallback);
+ mutationObserver.observe(document.body, observerOptions);
+
+ if (options.fixedtoolbar) {
+ injectStyles(
+ 'ec-bulk-actions{position: sticky; top: 0; background: var(--TWPT-primary-background, #fff); z-index: 96;}');
+ }
+
+ if (options.increasecontrast) {
+ injectStyles(
+ '.thread-summary.read:not(.checked){background: var(--TWPT-thread-read-background, #ecedee)!important;}');
+ }
+
+ if (options.stickysidebarheaders) {
+ injectStyles(
+ 'material-drawer .main-header{background: var(--TWPT-drawer-background, #fff)!important; position: sticky; top: 0; z-index: 1;}');
+ }
+
+ if (options.enhancedannouncementsdot) {
+ injectStylesheet(
+ chrome.runtime.getURL('css/enhanced_announcements_dot.css'));
+ }
+
+ if (options.repositionexpandthread) {
+ injectStylesheet(
+ chrome.runtime.getURL('css/reposition_expand_thread.css'));
+ }
+
+ if (options.ccforcehidedrawer) {
+ var drawer = document.querySelector('material-drawer');
+ if (drawer !== null && drawer.classList.contains('mat-drawer-expanded')) {
+ document.querySelector('.material-drawer-button').click();
+ }
+ }
+
+ if (options.batchlock) {
+ injectScript(chrome.runtime.getURL('batchLockInject.bundle.js'));
+ injectStylesheet(chrome.runtime.getURL('css/batchlock_inject.css'));
+ }
+
+ if (options.threadlistavatars) {
+ injectStylesheet(
+ chrome.runtime.getURL('css/thread_list_avatars.css'));
+ }
+
+ if (options.autorefreshlist) {
+ injectStylesheet(chrome.runtime.getURL('css/autorefresh_list.css'));
+ }
+});
diff --git a/src/contentScripts/communityConsole/profileHistoryLink.js b/src/contentScripts/communityConsole/profileHistoryLink.js
new file mode 100644
index 0000000..d53f9ee
--- /dev/null
+++ b/src/contentScripts/communityConsole/profileHistoryLink.js
@@ -0,0 +1,59 @@
+import {getNParent, createExtBadge} from './utils.js';
+import {escapeUsername, getAuthUser} from '../../common/communityConsoleUtils.js';
+
+var authuser = getAuthUser();
+
+function addProfileHistoryLink(node, type, query) {
+ var urlpart = encodeURIComponent('query=' + query);
+ var authuserpart =
+ (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
+ var container = document.createElement('div');
+ container.style.margin = '3px 0';
+
+ var link = document.createElement('a');
+ link.setAttribute(
+ 'href',
+ 'https://support.google.com/s/community/search/' + urlpart +
+ authuserpart);
+ link.innerText = chrome.i18n.getMessage('inject_previousposts_' + type);
+
+ container.appendChild(link);
+ node.appendChild(container);
+}
+
+export function injectPreviousPostsLinks(nameElement) {
+ var mainCardContent = getNParent(nameElement, 3);
+ if (mainCardContent === null) {
+ console.error(
+ '[previousposts] Couldn\'t find |.main-card-content| element.');
+ return;
+ }
+
+ var forumId = location.href.split('/forum/')[1].split('/')[0] || '0';
+
+ var nameTag =
+ (nameElement.tagName == 'EC-DISPLAY-NAME-EDITOR' ?
+ nameElement.querySelector('.top-section > span') ?? nameElement :
+ nameElement);
+ var name = escapeUsername(nameTag.textContent);
+ var query1 = encodeURIComponent(
+ '(creator:"' + name + '" | replier:"' + name + '") forum:' + forumId);
+ var query2 = encodeURIComponent(
+ '(creator:"' + name + '" | replier:"' + name + '") forum:any');
+
+ var container = document.createElement('div');
+ container.classList.add('TWPT-previous-posts');
+
+ var badge = createExtBadge();
+ container.appendChild(badge);
+
+ var linkContainer = document.createElement('div');
+ linkContainer.classList.add('TWPT-previous-posts--links');
+
+ addProfileHistoryLink(linkContainer, 'forum', query1);
+ addProfileHistoryLink(linkContainer, 'all', query2);
+
+ container.appendChild(linkContainer);
+
+ mainCardContent.appendChild(container);
+}
diff --git a/src/content_scripts/console_inject_start.js b/src/contentScripts/communityConsole/start.js
similarity index 83%
rename from src/content_scripts/console_inject_start.js
rename to src/contentScripts/communityConsole/start.js
index 32f3159..318e466 100644
--- a/src/content_scripts/console_inject_start.js
+++ b/src/contentScripts/communityConsole/start.js
@@ -1,3 +1,5 @@
+import {injectStylesheet} from '../../common/contentScriptsUtils.js';
+
const SMEI_SORT_DIRECTION = 8;
const SMEI_UNIFIED_PROFILES = 9;
@@ -30,11 +32,11 @@
switch (items.ccdarktheme_mode) {
case 'switch':
if (items.ccdarktheme_switch_status == true)
- injectStylesheet(chrome.runtime.getURL('injections/ccdarktheme.css'));
+ injectStylesheet(chrome.runtime.getURL('css/ccdarktheme.css'));
break;
case 'system':
- injectStylesheet(chrome.runtime.getURL('injections/ccdarktheme.css'), {
+ injectStylesheet(chrome.runtime.getURL('css/ccdarktheme.css'), {
'media': '(prefers-color-scheme: dark)',
});
break;
diff --git a/src/contentScripts/communityConsole/unifiedProfiles.js b/src/contentScripts/communityConsole/unifiedProfiles.js
new file mode 100644
index 0000000..e37fbbf
--- /dev/null
+++ b/src/contentScripts/communityConsole/unifiedProfiles.js
@@ -0,0 +1,12 @@
+export var unifiedProfilesFix = {
+ checkIframe(iframe) {
+ var srcRegex = /support.*\.google\.com\/profile\//;
+ return srcRegex.test(iframe.src ?? '');
+ },
+ fixIframe(iframe) {
+ console.info('[unifiedProfilesFix] Fixing unified profiles iframe');
+ var url = new URL(iframe.src);
+ url.searchParams.set('dark', 1);
+ iframe.src = url.href;
+ },
+};
diff --git a/src/contentScripts/communityConsole/utils.js b/src/contentScripts/communityConsole/utils.js
new file mode 100644
index 0000000..ca452b3
--- /dev/null
+++ b/src/contentScripts/communityConsole/utils.js
@@ -0,0 +1,27 @@
+export function removeChildNodes(node) {
+ while (node.firstChild) {
+ node.removeChild(node.firstChild);
+ }
+}
+
+export function getNParent(node, n) {
+ if (n <= 0) return node;
+ if (!('parentNode' in node)) return null;
+ return getNParent(node.parentNode, n - 1);
+}
+
+export function createExtBadge() {
+ var badge = document.createElement('div');
+ badge.classList.add('TWPT-badge');
+ badge.setAttribute(
+ 'title', chrome.i18n.getMessage('inject_extension_badge_helper', [
+ chrome.i18n.getMessage('appName')
+ ]));
+
+ var badgeI = document.createElement('i');
+ badgeI.classList.add('material-icon-i', 'material-icons-extended');
+ badgeI.textContent = 'repeat';
+
+ badge.append(badgeI);
+ return badge;
+}
diff --git a/src/content_scripts/profile_inject.js b/src/contentScripts/profile.js
similarity index 96%
rename from src/content_scripts/profile_inject.js
rename to src/contentScripts/profile.js
index d28d3ec..38894d6 100644
--- a/src/content_scripts/profile_inject.js
+++ b/src/contentScripts/profile.js
@@ -1,10 +1,12 @@
+import {escapeUsername} from '../common/communityConsoleUtils.js';
+
var authuser = (new URL(location.href)).searchParams.get('authuser') || '0';
function getSearchUrl(query) {
var urlpart = encodeURIComponent('query=' + encodeURIComponent(query));
var authuserpart =
(authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
- return url = 'https://support.google.com/s/community/search/' + urlpart +
+ return 'https://support.google.com/s/community/search/' + urlpart +
authuserpart;
}
diff --git a/src/contentScripts/profileIndicator.js b/src/contentScripts/profileIndicator.js
new file mode 100644
index 0000000..5edc932
--- /dev/null
+++ b/src/contentScripts/profileIndicator.js
@@ -0,0 +1,13 @@
+import {injectScript, injectStylesheet} from '../common/contentScriptsUtils.js';
+import {setUpListener} from '../common/csEventListener.js';
+
+setUpListener();
+
+chrome.storage.sync.get(null, function(options) {
+ if (options.profileindicator || options.profileindicatoralt) {
+ injectScript(
+ chrome.runtime.getURL('profileIndicatorInject.bundle.js'));
+ injectStylesheet(
+ chrome.runtime.getURL('css/profileindicator_inject.css'));
+ }
+});
diff --git a/src/content_scripts/forum_inject.js b/src/contentScripts/publicForum.js
similarity index 100%
rename from src/content_scripts/forum_inject.js
rename to src/contentScripts/publicForum.js
diff --git a/src/content_scripts/thread_inject.js b/src/contentScripts/publicThread.js
similarity index 100%
rename from src/content_scripts/thread_inject.js
rename to src/contentScripts/publicThread.js
diff --git a/src/content_scripts/console_inject.js b/src/content_scripts/console_inject.js
deleted file mode 100644
index 7503df5..0000000
--- a/src/content_scripts/console_inject.js
+++ /dev/null
@@ -1,1028 +0,0 @@
-var mutationObserver, intersectionObserver, intersectionOptions, options,
- authuser;
-
-function removeChildNodes(node) {
- while (node.firstChild) {
- node.removeChild(node.firstChild);
- }
-}
-
-function getNParent(node, n) {
- if (n <= 0) return node;
- if (!('parentNode' in node)) return null;
- return getNParent(node.parentNode, n - 1);
-}
-
-function parseUrl(url) {
- var forum_a = url.match(/forum\/([0-9]+)/i);
- var thread_a = url.match(/thread\/([0-9]+)/i);
-
- if (forum_a === null || thread_a === null) {
- return false;
- }
-
- return {
- 'forum': forum_a[1],
- 'thread': thread_a[1],
- };
-}
-
-function createExtBadge() {
- var badge = document.createElement('div');
- badge.classList.add('TWPT-badge');
- badge.setAttribute(
- 'title', chrome.i18n.getMessage('inject_extension_badge_helper', [
- chrome.i18n.getMessage('appName')
- ]));
-
- var badgeI = document.createElement('i');
- badgeI.classList.add('material-icon-i', 'material-icons-extended');
- badgeI.textContent = 'repeat';
-
- badge.append(badgeI);
- return badge;
-}
-
-function addProfileHistoryLink(node, type, query) {
- var urlpart = encodeURIComponent('query=' + query);
- var authuserpart =
- (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
- var container = document.createElement('div');
- container.style.margin = '3px 0';
-
- var link = document.createElement('a');
- link.setAttribute(
- 'href',
- 'https://support.google.com/s/community/search/' + urlpart +
- authuserpart);
- link.innerText = chrome.i18n.getMessage('inject_previousposts_' + type);
-
- container.appendChild(link);
- node.appendChild(container);
-}
-
-function applyDragAndDropFix(node) {
- console.debug('Adding link drag&drop fix to ', node);
- node.addEventListener('drop', e => {
- if (e.dataTransfer.types.includes('text/uri-list')) {
- e.stopImmediatePropagation();
- console.debug('Stopping link drop event propagation.');
- }
- }, true);
-}
-
-function nodeIsReadToggleBtn(node) {
- return ('tagName' in node) && node.tagName == 'MATERIAL-BUTTON' &&
- node.getAttribute('debugid') !== null &&
- (node.getAttribute('debugid') == 'mark-read-button' ||
- node.getAttribute('debugid') == 'mark-unread-button') &&
- ('parentNode' in node) && node.parentNode !== null &&
- ('parentNode' in node.parentNode) &&
- node.parentNode.querySelector('[debugid="batchlock"]') === null &&
- node.parentNode.parentNode !== null &&
- ('tagName' in node.parentNode.parentNode) &&
- node.parentNode.parentNode.tagName == 'EC-BULK-ACTIONS';
-}
-
-function injectDarkModeButton(rightControl) {
- var darkThemeSwitch = document.createElement('material-button');
- darkThemeSwitch.classList.add('TWPT-dark-theme', 'TWPT-btn--with-badge');
- darkThemeSwitch.setAttribute('button', '');
- darkThemeSwitch.setAttribute(
- 'title', chrome.i18n.getMessage('inject_ccdarktheme_helper'));
-
- darkThemeSwitch.addEventListener('click', e => {
- chrome.storage.sync.get(null, currentOptions => {
- currentOptions.ccdarktheme_switch_status =
- !options.ccdarktheme_switch_status;
- chrome.storage.sync.set(currentOptions, _ => {
- location.reload();
- });
- });
- });
-
- var switchContent = document.createElement('div');
- switchContent.classList.add('content');
-
- var icon = document.createElement('material-icon');
-
- var i = document.createElement('i');
- i.classList.add('material-icon-i', 'material-icons-extended');
- i.textContent = 'brightness_4';
-
- icon.appendChild(i);
- switchContent.appendChild(icon);
- darkThemeSwitch.appendChild(switchContent);
-
- var badgeContent = createExtBadge();
-
- darkThemeSwitch.appendChild(badgeContent);
-
- rightControl.style.width =
- (parseInt(window.getComputedStyle(rightControl).width) + 58) + 'px';
- rightControl.insertAdjacentElement('afterbegin', darkThemeSwitch);
-}
-
-function addBatchLockBtn(readToggle) {
- var clone = readToggle.cloneNode(true);
- clone.setAttribute('debugid', 'batchlock');
- clone.classList.add('TWPT-btn--with-badge');
- clone.setAttribute('title', chrome.i18n.getMessage('inject_lockbtn'));
- clone.querySelector('material-icon').setAttribute('icon', 'lock');
- clone.querySelector('i.material-icon-i').textContent = 'lock';
-
- var badge = createExtBadge();
- clone.append(badge);
-
- clone.addEventListener('click', function() {
- var modal = document.querySelector('.pane[pane-id="default-1"]');
-
- var dialog = document.createElement('material-dialog');
- dialog.setAttribute('role', 'dialog');
- dialog.setAttribute('aria-modal', 'true');
- dialog.classList.add('TWPT-dialog');
-
- var header = document.createElement('header');
- header.setAttribute('role', 'presentation');
- header.classList.add('TWPT-dialog-header');
-
- var title = document.createElement('div');
- title.classList.add('TWPT-dialog-header--title', 'title');
- title.textContent = chrome.i18n.getMessage('inject_lockbtn');
-
- header.append(title);
-
- var main = document.createElement('main');
- main.setAttribute('role', 'presentation');
- main.classList.add('TWPT-dialog-main');
-
- var p = document.createElement('p');
- p.textContent = chrome.i18n.getMessage('inject_lockdialog_desc');
-
- main.append(p);
-
- dialog.append(header, main);
-
- var footers = [['lock', 'unlock', 'cancel'], ['reload', 'close']];
-
- for (var i = 0; i < footers.length; ++i) {
- var footer = document.createElement('footer');
- footer.setAttribute('role', 'presentation');
- footer.classList.add('TWPT-dialog-footer');
- footer.setAttribute('data-footer-id', i);
-
- if (i > 0) footer.classList.add('is-hidden');
-
- footers[i].forEach(action => {
- var btn = document.createElement('material-button');
- btn.setAttribute('role', 'button');
- btn.classList.add('TWPT-dialog-footer-btn');
- if (i == 1) btn.classList.add('is-disabled');
-
- switch (action) {
- case 'lock':
- case 'unlock':
- btn.addEventListener('click', _ => {
- if (btn.classList.contains('is-disabled')) return;
- var message = {
- action,
- prefix: 'TWPT-batchlock',
- };
- window.postMessage(message, '*');
- });
- break;
-
- case 'cancel':
- case 'close':
- btn.addEventListener('click', _ => {
- if (btn.classList.contains('is-disabled')) return;
- modal.classList.remove('visible');
- modal.style.display = 'none';
- removeChildNodes(modal);
- });
- break;
-
- case 'reload':
- btn.addEventListener('click', _ => {
- if (btn.classList.contains('is-disabled')) return;
- window.location.reload()
- });
- break;
- }
-
- var content = document.createElement('div');
- content.classList.add('content', 'TWPT-dialog-footer-btn--content');
- content.textContent =
- chrome.i18n.getMessage('inject_lockdialog_btn_' + action);
-
- btn.append(content);
- footer.append(btn);
- });
-
- var clear = document.createElement('div');
- clear.style.clear = 'both';
-
- footer.append(clear);
- dialog.append(footer);
- }
-
- removeChildNodes(modal);
- modal.append(dialog);
- modal.classList.add('visible', 'modal');
- modal.style.display = 'flex';
- });
-
- var duplicateBtn =
- readToggle.parentNode.querySelector('[debugid="mark-duplicate-button"]');
- if (duplicateBtn)
- duplicateBtn.parentNode.insertBefore(
- clone, (duplicateBtn.nextSibling || duplicateBtn));
- else
- readToggle.parentNode.insertBefore(
- clone, (readToggle.nextSibling || readToggle));
-}
-
-var avatars = {
- isFilterSetUp: false,
- privateForums: [],
-
- // Gets a list of private forums. If it is already cached, the cached list is
- // returned; otherwise it is also computed and cached.
- getPrivateForums() {
- return new Promise((resolve, reject) => {
- if (this.isFilterSetUp) return resolve(this.privateForums);
-
- if (!document.documentElement.hasAttribute('data-startup'))
- return reject('[threadListAvatars] Couldn\'t get startup data.');
-
- var startupData =
- JSON.parse(document.documentElement.getAttribute('data-startup'));
- var forums = startupData?.['1']?.['2'];
- if (forums === undefined)
- return reject(
- '[threadListAvatars] Couldn\'t retrieve forums from startup data.');
-
- for (var f of forums) {
- var forumId = f?.['2']?.['1']?.['1'];
- var forumVisibility = f?.['2']?.['18'];
- if (forumId === undefined || forumVisibility === undefined) {
- console.warn(
- '[threadListAvatars] Coudln\'t retrieve forum id and/or forum visibility for the following forum:',
- f);
- continue;
- }
-
- // forumVisibility's value 1 means "PUBLIC".
- if (forumVisibility != 1) this.privateForums.push(forumId);
- }
-
- // Forum 51488989 is marked as public but it is in fact private.
- this.privateForums.push('51488989');
-
- this.isFilterSetUp = true;
- return resolve(this.privateForums);
- });
- },
-
- // Some threads belong to private forums, and this feature will not be able to
- // get its avatars since it makes an anonymomus call to get the contents of
- // the thread.
- //
- // This function returns whether avatars should be retrieved depending on if
- // the thread belongs to a known private forum.
- shouldRetrieveAvatars(thread) {
- return this.getPrivateForums().then(privateForums => {
- return !privateForums.includes(thread.forum);
- });
- },
-
- // Get an object with the author of the thread and an array of the first |num|
- // replies from the thread |thread|.
- getFirstMessages(thread, num = 15) {
- return CCApi(
- 'ViewThread', {
- 1: thread.forum,
- 2: thread.thread,
- // options
- 3: {
- // pagination
- 1: {
- 2: num, // maxNum
- },
- 3: true, // withMessages
- 5: true, // withUserProfile
- 10: false, // withPromotedMessages
- 16: false, // withThreadNotes
- 18: true, // sendNewThreadIfMoved
- }
- },
- // |authentication| is false because otherwise this would mark
- // the thread as read as a side effect, and that would mark all
- // threads in the list as read.
- //
- // Due to the fact that we have to call this endpoint
- // anonymously, this means we can't retrieve information about
- // threads in private forums.
- /* authentication = */ false)
- .then(data => {
- var numMessages = data?.['1']?.['8'];
- if (numMessages === undefined)
- throw new Error(
- 'Request to view thread doesn\'t include the number of messages');
-
- var messages = numMessages == 0 ? [] : data?.['1']['3'];
- if (messages === undefined)
- throw new Error(
- 'numMessages was ' + numMessages +
- ' but the response didn\'t include any message.');
-
- var author = data?.['1']?.['4'];
- if (author === undefined)
- throw new Error(
- 'Author isn\'t included in the ViewThread response.');
-
- return {
- messages,
- author,
- };
- });
- },
-
- // Get a list of at most |num| avatars for thread |thread|
- getVisibleAvatars(thread, num = 3) {
- return this.shouldRetrieveAvatars(thread).then(shouldRetrieve => {
- if (!shouldRetrieve) {
- console.debug('[threadListAvatars] Skipping thread', thread);
- return [];
- }
-
- return this.getFirstMessages(thread).then(result => {
- var messages = result.messages;
- var author = result.author;
-
- var avatarUrls = [];
-
- var authorUrl = author?.['1']?.['2'];
- if (authorUrl !== undefined) avatarUrls.push(authorUrl);
-
- for (var m of messages) {
- var url = m?.['3']?.['1']?.['2'];
-
- if (url === undefined) continue;
- if (!avatarUrls.includes(url)) avatarUrls.push(url);
- if (avatarUrls.length == 3) break;
- }
-
- return avatarUrls;
- });
- });
- },
-
- // Inject avatars for thread summary (thread item) |node| in a thread list.
- inject(node) {
- var header = node.querySelector(
- 'ec-thread-summary .main-header .panel-description a.header');
- if (header === null) {
- console.error(
- '[threadListAvatars] Header is not present in the thread item\'s DOM.');
- return;
- }
-
- var thread = parseUrl(header.href);
- if (thread === false) {
- console.error('[threadListAvatars] Thread\'s link cannot be parsed.');
- return;
- }
-
- this.getVisibleAvatars(thread)
- .then(avatarUrls => {
- var avatarsContainer = document.createElement('div');
- avatarsContainer.classList.add('TWPT-avatars');
-
- var count = Math.floor(Math.random() * 4);
-
- for (var i = 0; i < avatarUrls.length; ++i) {
- var avatar = document.createElement('div');
- avatar.classList.add('TWPT-avatar');
- avatar.style.backgroundImage = 'url(\'' + avatarUrls[i] + '\')';
- avatarsContainer.appendChild(avatar);
- }
-
- header.appendChild(avatarsContainer);
- })
- .catch(err => {
- console.error(
- '[threadListAvatars] Could not retrieve avatars for thread',
- thread, err);
- });
- },
-};
-
-var autoRefresh = {
- isLookingForUpdates: false,
- isUpdatePromptShown: false,
- lastTimestamp: null,
- filter: null,
- path: null,
- snackbar: null,
- interval: null,
- firstCallTimeout: null,
- intervalMs: 3 * 60 * 1000, // 3 minutes
- firstCallDelayMs: 3 * 1000, // 3 seconds
- getStartupData() {
- return JSON.parse(
- document.querySelector('html').getAttribute('data-startup'));
- },
- isOrderedByTimestampDescending() {
- var startup = this.getStartupData();
- // Returns orderOptions.by == TIMESTAMP && orderOptions.desc == true
- return (
- startup?.[1]?.[1]?.[3]?.[14]?.[1] == 1 &&
- startup?.[1]?.[1]?.[3]?.[14]?.[2] == true);
- },
- getCustomFilter(path) {
- var searchRegex = /^\/s\/community\/search\/([^\/]*)/;
- var matches = path.match(searchRegex);
- if (matches !== null && matches.length > 1) {
- var search = decodeURIComponent(matches[1]);
- var params = new URLSearchParams(search);
- return params.get('query') || '';
- }
-
- return '';
- },
- filterHasOverride(filter, override) {
- var escapedOverride = override.replace(/([^\w\d\s])/gi, '\\$1');
- var regex = new RegExp('[^a-zA-Z0-9]?' + escapedOverride + ':');
- return regex.test(filter);
- },
- getFilter(path) {
- var query = this.getCustomFilter(path);
-
- // Note: This logic has been copied and adapted from the
- // _buildQuery$1$threadId function in the Community Console
- var conditions = '';
- var startup = this.getStartupData();
-
- // TODO(avm99963): if the selected forums are changed without reloading the
- // page, this will get the old selected forums. Fix this.
- var forums = startup?.[1]?.[1]?.[3]?.[8] ?? [];
- if (!this.filterHasOverride(query, 'forum') && forums !== null &&
- forums.length > 0)
- conditions += ' forum:(' + forums.join(' | ') + ')';
-
- var langs = startup?.[1]?.[1]?.[3]?.[5] ?? [];
- if (!this.filterHasOverride(query, 'lang') && langs !== null &&
- langs.length > 0)
- conditions += ' lang:(' + langs.map(l => '"' + l + '"').join(' | ') + ')';
-
- if (query.length !== 0 && conditions.length !== 0)
- return '(' + query + ')' + conditions;
- return query + conditions;
- },
- getLastTimestamp() {
- var APIRequestUrl = 'https://support.google.com/s/community/api/ViewForum' +
- (authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
-
- return fetch(APIRequestUrl, {
- 'headers': {
- 'content-type': 'text/plain; charset=utf-8',
- },
- 'body': JSON.stringify({
- 1: '0', // TODO: Change, when only a forum is selected, it
- // should be set here
- 2: {
- 1: {
- 2: 2,
- },
- 2: {
- 1: 1,
- 2: true,
- },
- 12: this.filter,
- },
- }),
- 'method': 'POST',
- 'mode': 'cors',
- 'credentials': 'include',
- })
- .then(res => {
- if (res.status == 200 || res.status == 400) {
- return res.json().then(data => ({
- status: res.status,
- body: data,
- }));
- } else {
- throw new Error('Status code ' + res.status + ' was not expected.');
- }
- })
- .then(res => {
- if (res.status == 400) {
- throw new Error(
- res.body[4] ||
- ('Response status: 400. Error code: ' + res.body[2]));
- }
-
- return res.body;
- })
- .then(body => {
- var timestamp = body?.[1]?.[2]?.[0]?.[2]?.[17];
- if (timestamp === undefined)
- throw new Error(
- 'Unexpected body of response (' +
- (body?.[1]?.[2]?.[0] === undefined ?
- 'no threads were returned' :
- 'the timestamp value is not present in the first thread') +
- ').');
-
- return timestamp;
- });
- // TODO(avm99963): Add retry mechanism (sometimes thread lists are empty,
- // but when loading the next page the thread appears).
- //
- // NOTE(avm99963): It seems like loading the first 2 threads instead of only
- // the first one fixes this (empty lists are now rarely returned).
- },
- unregister() {
- console.debug('autorefresh_list: unregistering');
-
- if (!this.isLookingForUpdates) return;
-
- window.clearTimeout(this.firstCallTimeout);
- window.clearInterval(this.interval);
- this.isUpdatePromptShown = false;
- this.isLookingForUpdates = false;
- },
- showUpdatePrompt() {
- this.snackbar.classList.remove('TWPT-hidden');
- document.title = '[!!!] ' + document.title.replace('[!!!] ', '');
- this.isUpdatePromptShown = true;
- },
- hideUpdatePrompt() {
- this.snackbar.classList.add('TWPT-hidden');
- document.title = document.title.replace('[!!!] ', '');
- this.isUpdatePromptShown = false;
- },
- injectUpdatePrompt() {
- var pane = document.createElement('div');
- pane.classList.add('TWPT-pane-for-snackbar');
-
- var snackbar = document.createElement('material-snackbar-panel');
- snackbar.classList.add('TWPT-snackbar');
- snackbar.classList.add('TWPT-hidden');
-
- var ac = document.createElement('div');
- ac.classList.add('TWPT-animation-container');
-
- var nb = document.createElement('div');
- nb.classList.add('TWPT-notification-bar');
-
- var ft = document.createElement('focus-trap');
-
- var content = document.createElement('div');
- content.classList.add('TWPT-focus-content-wrapper');
-
- var badge = createExtBadge();
-
- var message = document.createElement('div');
- message.classList.add('TWPT-message');
- message.textContent =
- chrome.i18n.getMessage('inject_autorefresh_list_snackbar_message');
-
- var action = document.createElement('div');
- action.classList.add('TWPT-action');
- action.textContent =
- chrome.i18n.getMessage('inject_autorefresh_list_snackbar_action');
-
- action.addEventListener('click', e => {
- this.hideUpdatePrompt();
- document.querySelector('.app-title-button').click();
- });
-
- content.append(badge, message, action);
- ft.append(content);
- nb.append(ft);
- ac.append(nb);
- snackbar.append(ac);
- pane.append(snackbar);
- document.getElementById('default-acx-overlay-container').append(pane);
- this.snackbar = snackbar;
- },
- checkUpdate() {
- if (location.pathname != this.path) {
- this.unregister();
- return;
- }
-
- if (this.isUpdatePromptShown) return;
-
- console.debug('Checking for update at: ', new Date());
-
- this.getLastTimestamp()
- .then(timestamp => {
- if (timestamp != this.lastTimestamp) this.showUpdatePrompt();
- })
- .catch(
- err => console.error(
- 'Coudln\'t get last timestamp (while updating): ', err));
- },
- firstCall() {
- console.debug(
- 'autorefresh_list: now performing first call to finish setup (filter: [' +
- this.filter + '])');
-
- if (location.pathname != this.path) {
- this.unregister();
- return;
- }
-
- this.getLastTimestamp()
- .then(timestamp => {
- this.lastTimestamp = timestamp;
- var checkUpdateCallback = this.checkUpdate.bind(this);
- this.interval =
- window.setInterval(checkUpdateCallback, this.intervalMs);
- })
- .catch(
- err => console.error(
- 'Couldn\'t get last timestamp (while setting up): ', err));
- },
- setUp() {
- if (!this.isOrderedByTimestampDescending()) return;
-
- this.unregister();
-
- console.debug('autorefresh_list: starting set up...');
-
- if (this.snackbar === null) this.injectUpdatePrompt();
- this.isLookingForUpdates = true;
- this.path = location.pathname;
- this.filter = this.getFilter(this.path);
-
- var firstCall = this.firstCall.bind(this);
- this.firstCallTimeout = window.setTimeout(firstCall, this.firstCallDelayMs);
- },
-};
-
-function isDarkThemeOn() {
- if (!options.ccdarktheme) return false;
-
- if (options.ccdarktheme_mode == 'switch')
- return options.ccdarktheme_switch_status;
-
- return window.matchMedia &&
- window.matchMedia('(prefers-color-scheme: dark)').matches;
-}
-
-var unifiedProfilesFix = {
- checkIframe(iframe) {
- var srcRegex = /support.*\.google\.com\/profile\//;
- return srcRegex.test(iframe.src ?? '');
- },
- fixIframe(iframe) {
- console.info('[unifiedProfilesFix] Fixing unified profiles iframe');
- var url = new URL(iframe.src);
- url.searchParams.set('dark', 1);
- iframe.src = url.href;
- },
-};
-
-function injectPreviousPostsLinks(nameElement) {
- var mainCardContent = getNParent(nameElement, 3);
- if (mainCardContent === null) {
- console.error(
- '[previousposts] Couldn\'t find |.main-card-content| element.');
- return;
- }
-
- var forumId = location.href.split('/forum/')[1].split('/')[0] || '0';
-
- var nameTag =
- (nameElement.tagName == 'EC-DISPLAY-NAME-EDITOR' ?
- nameElement.querySelector('.top-section > span') ?? nameElement :
- nameElement);
- var name = escapeUsername(nameTag.textContent);
- var query1 = encodeURIComponent(
- '(creator:"' + name + '" | replier:"' + name + '") forum:' + forumId);
- var query2 = encodeURIComponent(
- '(creator:"' + name + '" | replier:"' + name + '") forum:any');
-
- var container = document.createElement('div');
- container.classList.add('TWPT-previous-posts');
-
- var badge = createExtBadge();
- container.appendChild(badge);
-
- var linkContainer = document.createElement('div');
- linkContainer.classList.add('TWPT-previous-posts--links');
-
- addProfileHistoryLink(linkContainer, 'forum', query1);
- addProfileHistoryLink(linkContainer, 'all', query2);
-
- container.appendChild(linkContainer);
-
- mainCardContent.appendChild(container);
-}
-
-// Send a request to mark the current thread as read
-function markCurrentThreadAsRead() {
- console.debug(
- '[forceMarkAsRead] %cTrying to mark a thread as read.',
- 'color: #1a73e8;');
-
- var threadRegex =
- /\/s\/community\/?.*\/forum\/([0-9]+)\/?.*\/thread\/([0-9]+)/;
-
- var url = location.href;
- var matches = url.match(threadRegex);
- if (matches !== null && matches.length > 2) {
- var forumId = matches[1];
- var threadId = matches[2];
-
- console.debug('[forceMarkAsRead] Thread details:', {forumId, threadId});
-
- return CCApi(
- 'ViewThread', {
- 1: forumId,
- 2: threadId,
- // options
- 3: {
- // pagination
- 1: {
- 2: 0, // maxNum
- },
- 3: false, // withMessages
- 5: false, // withUserProfile
- 6: true, // withUserReadState
- 9: false, // withRequestorProfile
- 10: false, // withPromotedMessages
- 11: false, // withExpertResponder
- },
- },
- true, authuser)
- .then(thread => {
- if (thread?.[1]?.[6] === true) {
- console.debug(
- '[forceMarkAsRead] This thread is already marked as read, but marking it as read anyways.');
- }
-
- var lastMessageId = thread?.[1]?.[2]?.[10];
-
- console.debug('[forceMarkAsRead] lastMessageId is:', lastMessageId);
-
- if (lastMessageId === undefined)
- throw new Error(
- 'Couldn\'t find lastMessageId in the ViewThread response.');
-
- return CCApi(
- 'SetUserReadStateBulk', {
- 1: [{
- 1: forumId,
- 2: threadId,
- 3: lastMessageId,
- }],
- },
- true, authuser);
- })
- .then(_ => {
- console.debug(
- '[forceMarkAsRead] %cSuccessfully set as read!',
- 'color: #1e8e3e;');
- })
- .catch(err => {
- console.error(
- '[forceMarkAsRead] Error while marking current thread as read: ',
- err);
- });
- } else {
- console.error(
- '[forceMarkAsRead] Couldn\'t retrieve forumId and threadId from the current URL.',
- url);
- }
-}
-
-const watchedNodesSelectors = [
- // App container (used to set up the intersection observer and inject the dark
- // mode button)
- 'ec-app',
-
- // Load more bar (for the "load more"/"load all" buttons)
- '.load-more-bar',
-
- // Username span/editor inside ec-user (user profile view)
- 'ec-user .main-card .header > .name > span',
- 'ec-user .main-card .header > .name > ec-display-name-editor',
-
- // Rich text editor
- 'ec-movable-dialog',
- 'ec-rich-text-editor',
-
- // Read/unread bulk action in the list of thread, for the batch lock feature
- 'ec-bulk-actions material-button[debugid="mark-read-button"]',
- 'ec-bulk-actions material-button[debugid="mark-unread-button"]',
-
- // Thread list items (used to inject the avatars)
- 'li',
-
- // Thread list (used for the autorefresh feature)
- 'ec-thread-list',
-
- // Unified profile iframe
- 'iframe',
-
- // Thread component
- 'ec-thread',
-];
-
-function handleCandidateNode(node) {
- if (typeof node.classList !== 'undefined') {
- if (('tagName' in node) && node.tagName == 'EC-APP') {
- // Set up the intersectionObserver
- if (typeof intersectionObserver === 'undefined') {
- var scrollableContent = node.querySelector('.scrollable-content');
- if (scrollableContent !== null) {
- intersectionOptions = {
- root: scrollableContent,
- rootMargin: '0px',
- threshold: 1.0,
- };
-
- intersectionObserver = new IntersectionObserver(
- intersectionCallback, intersectionOptions);
- }
- }
-
- // Inject the dark mode button
- if (options.ccdarktheme && options.ccdarktheme_mode == 'switch') {
- var rightControl = node.querySelector('header .right-control');
- if (rightControl !== null) injectDarkModeButton(rightControl);
- }
- }
-
- // Start the intersectionObserver for the "load more"/"load all" buttons
- // inside a thread
- if ((options.thread || options.threadall) &&
- node.classList.contains('load-more-bar')) {
- if (typeof intersectionObserver !== 'undefined') {
- if (options.thread)
- intersectionObserver.observe(node.querySelector('.load-more-button'));
- if (options.threadall)
- intersectionObserver.observe(node.querySelector('.load-all-button'));
- } else {
- console.warn(
- '[infinitescroll] ' +
- 'The intersectionObserver is not ready yet.');
- }
- }
-
- // Show the "previous posts" links
- // Here we're selecting the 'ec-user > div' element (unique child)
- if (options.history &&
- (node.matches('ec-user .main-card .header > .name > span') ||
- node.matches(
- 'ec-user .main-card .header > .name > ec-display-name-editor'))) {
- injectPreviousPostsLinks(node);
- }
-
- // Fix the drag&drop issue with the rich text editor
- //
- // We target both tags because in different contexts different
- // elements containing the text editor get added to the DOM structure.
- // Sometimes it's a EC-MOVABLE-DIALOG which already contains the
- // EC-RICH-TEXT-EDITOR, and sometimes it's the EC-RICH-TEXT-EDITOR
- // directly.
- if (options.ccdragndropfix && ('tagName' in node) &&
- (node.tagName == 'EC-MOVABLE-DIALOG' ||
- node.tagName == 'EC-RICH-TEXT-EDITOR')) {
- applyDragAndDropFix(node);
- }
-
- // Inject the batch lock button in the thread list
- if (options.batchlock && nodeIsReadToggleBtn(node)) {
- addBatchLockBtn(node);
- }
-
- // Inject avatar links to threads in the thread list
- if (options.threadlistavatars && ('tagName' in node) &&
- (node.tagName == 'LI') &&
- node.querySelector('ec-thread-summary') !== null) {
- avatars.inject(node);
- }
-
- // Set up the autorefresh list feature
- if (options.autorefreshlist && ('tagName' in node) &&
- node.tagName == 'EC-THREAD-LIST') {
- autoRefresh.setUp();
- }
-
- // Redirect unified profile iframe to dark version if applicable
- if (node.tagName == 'IFRAME' && isDarkThemeOn() &&
- unifiedProfilesFix.checkIframe(node)) {
- unifiedProfilesFix.fixIframe(node);
- }
-
- // Force mark thread as read
- if (options.forcemarkasread && node.tagName == 'EC-THREAD') {
- markCurrentThreadAsRead();
- }
- }
-}
-
-function handleRemovedNode(node) {
- // Remove snackbar when exiting thread list view
- if (options.autorefreshlist && 'tagName' in node &&
- node.tagName == 'EC-THREAD-LIST') {
- autoRefresh.hideUpdatePrompt();
- }
-}
-
-function mutationCallback(mutationList, observer) {
- mutationList.forEach((mutation) => {
- if (mutation.type == 'childList') {
- mutation.addedNodes.forEach(function(node) {
- handleCandidateNode(node);
- });
-
- mutation.removedNodes.forEach(function(node) {
- handleRemovedNode(node);
- });
- }
- });
-}
-
-function intersectionCallback(entries, observer) {
- entries.forEach(entry => {
- if (entry.isIntersecting) {
- entry.target.click();
- }
- });
-};
-
-var observerOptions = {
- childList: true,
- subtree: true,
-};
-
-chrome.storage.sync.get(null, function(items) {
- options = items;
-
- var startup =
- JSON.parse(document.querySelector('html').getAttribute('data-startup'));
- authuser = startup[2][1] || '0';
-
- // Before starting the mutation Observer, check whether we missed any
- // mutations by manually checking whether some watched nodes already
- // exist.
- var cssSelectors = watchedNodesSelectors.join(',');
- document.querySelectorAll(cssSelectors)
- .forEach(node => handleCandidateNode(node));
-
- mutationObserver = new MutationObserver(mutationCallback);
- mutationObserver.observe(document.body, observerOptions);
-
- if (options.fixedtoolbar) {
- injectStyles(
- 'ec-bulk-actions{position: sticky; top: 0; background: var(--TWPT-primary-background, #fff); z-index: 96;}');
- }
-
- if (options.increasecontrast) {
- injectStyles(
- '.thread-summary.read:not(.checked){background: var(--TWPT-thread-read-background, #ecedee)!important;}');
- }
-
- if (options.stickysidebarheaders) {
- injectStyles(
- 'material-drawer .main-header{background: var(--TWPT-drawer-background, #fff)!important; position: sticky; top: 0; z-index: 1;}');
- }
-
- if (options.enhancedannouncementsdot) {
- injectStylesheet(
- chrome.runtime.getURL('injections/enhanced_announcements_dot.css'));
- }
-
- if (options.repositionexpandthread) {
- injectStylesheet(
- chrome.runtime.getURL('injections/reposition_expand_thread.css'));
- }
-
- if (options.ccforcehidedrawer) {
- var drawer = document.querySelector('material-drawer');
- if (drawer !== null && drawer.classList.contains('mat-drawer-expanded')) {
- document.querySelector('.material-drawer-button').click();
- }
- }
-
- if (options.batchlock) {
- injectScript(chrome.runtime.getURL('injections/batchlock_inject.js'));
- injectStylesheet(chrome.runtime.getURL('injections/batchlock_inject.css'));
- }
-
- if (options.threadlistavatars) {
- injectStylesheet(
- chrome.runtime.getURL('injections/thread_list_avatars.css'));
- }
-
- if (options.autorefreshlist) {
- injectStylesheet(chrome.runtime.getURL('injections/autorefresh_list.css'));
- }
-});
diff --git a/src/content_scripts/profileindicator_inject.js b/src/content_scripts/profileindicator_inject.js
deleted file mode 100644
index 8cd1cfc..0000000
--- a/src/content_scripts/profileindicator_inject.js
+++ /dev/null
@@ -1,8 +0,0 @@
-chrome.storage.sync.get(null, function(options) {
- if (options.profileindicator || options.profileindicatoralt) {
- injectScript(
- chrome.runtime.getURL('injections/profileindicator_inject.js'));
- injectStylesheet(
- chrome.runtime.getURL('injections/profileindicator_inject.css'));
- }
-});
diff --git a/src/injections/batchlock_inject.js b/src/injections/batchLock.js
similarity index 91%
rename from src/injections/batchlock_inject.js
rename to src/injections/batchLock.js
index 3ffd298..9a91203 100644
--- a/src/injections/batchlock_inject.js
+++ b/src/injections/batchLock.js
@@ -1,16 +1,5 @@
-function parseUrl(url) {
- var forum_a = url.match(/forum\/([0-9]+)/i);
- var thread_a = url.match(/thread\/([0-9]+)/i);
-
- if (forum_a === null || thread_a === null) {
- return false;
- }
-
- return {
- 'forum': forum_a[1],
- 'thread': thread_a[1],
- };
-}
+import {parseUrl} from '../common/commonUtils.js';
+import {getAuthUser} from '../common/communityConsoleUtils.js';
function recursiveParentElement(el, tag) {
while (el !== document.documentElement) {
@@ -20,12 +9,6 @@
return undefined;
}
-function returnAuthUser() {
- var startup =
- JSON.parse(document.querySelector('html').getAttribute('data-startup'));
- return startup[2][1] || '0';
-}
-
// Source:
// https://stackoverflow.com/questions/33063774/communication-from-an-injected-script-to-the-content-script-with-a-response
var contentScriptRequest = (function() {
@@ -122,7 +105,7 @@
modal.querySelector('main').textContent = '';
modal.querySelector('main').append(p, log);
- var authuser = returnAuthUser();
+ var authuser = getAuthUser();
var APIRequestUrl =
'https://support.google.com/s/community/api/SetThreadAttribute' +
(authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
diff --git a/src/injections/profileindicator_inject.js b/src/injections/profileIndicator.js
similarity index 97%
rename from src/injections/profileindicator_inject.js
rename to src/injections/profileIndicator.js
index b3bbdc6..ee897fe 100644
--- a/src/injections/profileindicator_inject.js
+++ b/src/injections/profileIndicator.js
@@ -1,3 +1,5 @@
+import {escapeUsername} from '../common/communityConsoleUtils.js';
+
var CCProfileRegex =
/^(?:https:\/\/support\.google\.com)?\/s\/community(?:\/forum\/[0-9]*)?\/user\/(?:[0-9]+)$/;
var CCRegex = /^https:\/\/support\.google\.com\/s\/community/;
@@ -39,12 +41,6 @@
return false;
}
-function escapeUsername(username) {
- var quoteRegex = /"/g;
- var commentRegex = /<!---->/g;
- return username.replace(quoteRegex, '\\"').replace(commentRegex, '');
-}
-
function APIRequest(action, body) {
var authuserPart =
(authuser == '0' ? '' : '?authuser=' + encodeURIComponent(authuser));
@@ -381,7 +377,7 @@
getOptionsAndHandleIndicators(node, true);
}
- mutationObserver = new MutationObserver(mutationCallback);
+ var mutationObserver = new MutationObserver(mutationCallback);
mutationObserver.observe(document.body, observerOptions);
} else {
// We are in TW
diff --git a/src/options/options_common.js b/src/optionsCommon.js
similarity index 94%
rename from src/options/options_common.js
rename to src/optionsCommon.js
index 098a632..afb6bde 100644
--- a/src/options/options_common.js
+++ b/src/optionsCommon.js
@@ -1,3 +1,6 @@
+import {cleanUpOptions, optionsPrototype, specialOptions} from './common/optionsUtils.js';
+import {isFirefox, isReleaseVersion} from './common/extUtils.js';
+
var savedSuccessfullyTimeout = null;
const exclusiveOptions = [['thread', 'threadall']];
@@ -88,7 +91,10 @@
chrome.storage.sync.get(null, function(items) {
items = cleanUpOptions(items, false);
- for ([opt, optMeta] of Object.entries(optionsPrototype)) {
+ for (var entry of Object.entries(optionsPrototype)) {
+ var opt = entry[0];
+ var optMeta = entry[1];
+
if (!isOptionShown(opt)) continue;
if (specialOptions.includes(opt)) {
diff --git a/src/LICENSE b/src/static/LICENSE
similarity index 100%
rename from src/LICENSE
rename to src/static/LICENSE
diff --git a/src/_locales/ca/messages.json b/src/static/_locales/ca/messages.json
similarity index 100%
rename from src/_locales/ca/messages.json
rename to src/static/_locales/ca/messages.json
diff --git a/src/_locales/en/messages.json b/src/static/_locales/en/messages.json
similarity index 100%
rename from src/_locales/en/messages.json
rename to src/static/_locales/en/messages.json
diff --git a/src/_locales/es/messages.json b/src/static/_locales/es/messages.json
similarity index 100%
rename from src/_locales/es/messages.json
rename to src/static/_locales/es/messages.json
diff --git a/src/_locales/ru/OWNERS b/src/static/_locales/ru/OWNERS
similarity index 100%
rename from src/_locales/ru/OWNERS
rename to src/static/_locales/ru/OWNERS
diff --git a/src/_locales/ru/messages.json b/src/static/_locales/ru/messages.json
similarity index 100%
rename from src/_locales/ru/messages.json
rename to src/static/_locales/ru/messages.json
diff --git a/src/injections/autorefresh_list.css b/src/static/css/autorefresh_list.css
similarity index 100%
rename from src/injections/autorefresh_list.css
rename to src/static/css/autorefresh_list.css
diff --git a/src/injections/batchlock_inject.css b/src/static/css/batchlock_inject.css
similarity index 100%
rename from src/injections/batchlock_inject.css
rename to src/static/css/batchlock_inject.css
diff --git a/src/injections/ccdarktheme.css b/src/static/css/ccdarktheme.css
similarity index 100%
rename from src/injections/ccdarktheme.css
rename to src/static/css/ccdarktheme.css
diff --git a/src/common/console.css b/src/static/css/common/console.css
similarity index 100%
rename from src/common/console.css
rename to src/static/css/common/console.css
diff --git a/src/common/forum.css b/src/static/css/common/forum.css
similarity index 100%
rename from src/common/forum.css
rename to src/static/css/common/forum.css
diff --git a/src/injections/enhanced_announcements_dot.css b/src/static/css/enhanced_announcements_dot.css
similarity index 100%
rename from src/injections/enhanced_announcements_dot.css
rename to src/static/css/enhanced_announcements_dot.css
diff --git a/src/injections/profileindicator_inject.css b/src/static/css/profileindicator_inject.css
similarity index 100%
rename from src/injections/profileindicator_inject.css
rename to src/static/css/profileindicator_inject.css
diff --git a/src/injections/reposition_expand_thread.css b/src/static/css/reposition_expand_thread.css
similarity index 100%
rename from src/injections/reposition_expand_thread.css
rename to src/static/css/reposition_expand_thread.css
diff --git a/src/injections/thread_list_avatars.css b/src/static/css/thread_list_avatars.css
similarity index 100%
rename from src/injections/thread_list_avatars.css
rename to src/static/css/thread_list_avatars.css
diff --git a/src/icons/128.png b/src/static/icons/128.png
similarity index 100%
rename from src/icons/128.png
rename to src/static/icons/128.png
Binary files differ
diff --git a/src/icons/512.png b/src/static/icons/512.png
similarity index 100%
rename from src/icons/512.png
rename to src/static/icons/512.png
Binary files differ
diff --git a/src/options/chrome_style/chrome_style.css b/src/static/options/chrome_style/chrome_style.css
similarity index 100%
rename from src/options/chrome_style/chrome_style.css
rename to src/static/options/chrome_style/chrome_style.css
diff --git a/src/options/experiments.html b/src/static/options/experiments.html
similarity index 90%
rename from src/options/experiments.html
rename to src/static/options/experiments.html
index d132305..81c8a10 100644
--- a/src/options/experiments.html
+++ b/src/static/options/experiments.html
@@ -18,8 +18,7 @@
</form>
<div id="save-indicator"></div>
</main>
- <script src="../common/common.js"></script>
<script src="experiments_bit.js"></script>
- <script src="options_common.js"></script>
+ <script src="../../optionsCommon.bundle.js"></script>
</body>
</html>
diff --git a/src/options/experiments_bit.js b/src/static/options/experiments_bit.js
similarity index 100%
rename from src/options/experiments_bit.js
rename to src/static/options/experiments_bit.js
diff --git a/src/options/options.css b/src/static/options/options.css
similarity index 100%
rename from src/options/options.css
rename to src/static/options/options.css
diff --git a/src/options/options.html b/src/static/options/options.html
similarity index 98%
rename from src/options/options.html
rename to src/static/options/options.html
index 8a09d04..09ce1bb 100644
--- a/src/options/options.html
+++ b/src/static/options/options.html
@@ -54,8 +54,7 @@
</form>
<div id="save-indicator"></div>
</main>
- <script src="../common/common.js"></script>
<script src="options_bit.js"></script>
- <script src="options_common.js"></script>
+ <script src="../../optionsCommon.bundle.js"></script>
</body>
</html>
diff --git a/src/options/options_bit.js b/src/static/options/options_bit.js
similarity index 100%
rename from src/options/options_bit.js
rename to src/static/options/options_bit.js
diff --git a/src/sw.js b/src/sw.js
index 2f181cb..b8dccf2 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,6 +1,5 @@
// IMPORTANT: keep this file in sync with background.js
-
-importScripts('common/common.js')
+import {cleanUpOptions} from './common/optionsUtils.js'
// When the extension gets updated, set new options to their default value.
chrome.runtime.onInstalled.addListener(details => {