diff --git a/compile.ps1 b/compile.ps1 index 55ed3fa..0d3723f 100644 --- a/compile.ps1 +++ b/compile.ps1 @@ -62,6 +62,7 @@ $files = @( "./src/popup.html", "./src/popup.js", "./src/i18n.js", + "./src/exxperimentHack", "./src/styles.css" ) diff --git a/src/background.js b/src/background.js index d00d941..d53f46f 100644 --- a/src/background.js +++ b/src/background.js @@ -19,6 +19,20 @@ function console_error() { console.error('[BetterUnsubscribe][background.js]', ...arguments); } +async function cacheUnsubMethod(message) { + let value = null; + if (funcCache.has(message.id)) { + // Message is in cache + value = funcCache.get(message.id); + } else { + // Message not in cache, call searchUnsub(message) + value = await searchUnsub(message); + // Store the result in cache + funcCache.set(message.id, value); + } + return value; +} + /** * Event listener for message display events. * Disables the action button initially and checks if unsubscribe information is available. @@ -32,19 +46,7 @@ messenger.messageDisplay.onMessageDisplayed.addListener( console_log('Message displayed'); await messenger.messageDisplayAction.disable(tab.id); // Disable action button until processing is complete if (message) { - let value; - - if (funcCache.has(message.id)) { - // Message is in cache - value = funcCache.get(message.id); - } else { - // Message not in cache, call searchUnsub(message) - value = await searchUnsub(message); - // Store the result in cache - funcCache.set(message.id, value); - } - - if (value !== null) { + if ((await cacheUnsubMethod(message)) !== null) { await messenger.messageDisplayAction.enable(tab.id); // Enable action button if unsubscribe info is found } } @@ -54,6 +56,67 @@ messenger.messageDisplay.onMessageDisplayed.addListener( } ); +/** + * cache the first mail tab for lookup later + * //TODO: can there be multiple mailtabs in multiple windows? + * // then we need a way to pass the tab id back and forth between implementation.js and background.js, + * // most likely by adjusting schea.json (the api) + */ +const thisMailTabPromise = messenger.mailTabs.query().then(a => a[0]); + +/** + * listen for a button being clicked in the table view and search for the mail and open a popup + * @param {integer} rowNo - the number of the row in the table view whose button was clicked + */ +messenger.threadPaneButtons.onButtonClicked.addListener(async (rowNo) => { + console_log(`Button in row ${rowNo} clicked`); + + // Trigger a popup using the standard windows API + let message = await getNthMessage( + messenger.mailTabs.getListedMessages((await thisMailTabPromise).id), + rowNo + ); + await cacheUnsubMethod(message); + messenger.windows.create({ + url: `popup.html?messageId=${message.id}`, + type: 'popup', + width: 500, + height: 300, + }); +}); + +/** + * listen for a button being added to the table view. these buttons are disabled by default. + * if the corr. email has an unsub method, enable the button + * do this before calling initInjections so currently visible mails are also handeled + * @param {integer} rowNo - the number of the row in the table view whose button was added to the dom + */ +messenger.threadPaneButtons.onButtonProduced.addListener(async (rowNo) => { + (async function () { + // find message + const message = await getNthMessage( + messenger.mailTabs.getListedMessages((await thisMailTabPromise).id), + rowNo + ); + //see if message has unsub method + if (await cacheUnsubMethod(message)) { + //if yes, enable button + await messenger.threadPaneButtons.enableButton(rowNo); + } + })(); +}); + +/** + * IEFE for injecting the js into the dom that adds buttons (hacky af) + * init after adding listener to onButtonProduced so currently visible mails are also handeled + */ +(async () => { + // 1. Initialize the experiment + await messenger.threadPaneButtons + .initInjections() + .catch((_) => console_error('mail list buttons init failed!')); +})(); + /** * Searches for unsubscribe links and information in the message headers and body. * This function scans for standard unsubscribe headers (RFC 2369) and embedded links. @@ -518,6 +581,23 @@ async function* getMessages(list) { } } +/** + * Retrieves the nth message (0-indexed) from the getMessages async generator. + * @param {Promise} list - The initial paginated list promise. + * @param {number} n - The index of the message to retrieve. + * @returns {Promise} - The nth message or null if not found. + */ +async function getNthMessage(list, n) { + let count = 0; + for await (const message of getMessages(list)) { + if (count === n) { + return message; + } + count++; + } + return null; +} + /** * Generator function to yield messages from all inbox folders across all accounts. * This function finds and processes messages from each inbox folder. diff --git a/src/exxperimentHack/implementation.js b/src/exxperimentHack/implementation.js new file mode 100644 index 0000000..4b5a3b7 --- /dev/null +++ b/src/exxperimentHack/implementation.js @@ -0,0 +1,181 @@ +//core logic +function get3panewindow(services) { + let e = services.wm.getEnumerator('mail:3pane'); + while (e.hasMoreElements()) { + let win = e.getNext(); + // In Supernova, we might need to dig into the tab's chromeBrowser + for (let i = 0; i < win.length; i++) { + if (win[i].location.href === 'about:3pane') return win[i]; + } + } + return null; //couldn't find the win... TODO: handle this better (error?) +} + +async function initInjectionsImpl(Services, eventPasser, context) { + // Monitor windows for about:3pane + //TODO: + //this code for observing newly opened windows is untested. + //is it even necessary if the main email tab cannot be closed? + const observer = { + onOpenWindow(xulWindow) { + const domWindow = xulWindow + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + + domWindow.addEventListener( + 'load', + () => { + if (domWindow.location.href === 'about:3pane') { + injectLogic(domWindow); + } + }, + { once: true } + ); + }, + }; + + const injectLogic = (win) => { + // The Thread Pane in Supernova is an + // We use a MutationObserver to catch new rows as they are rendered + const threadPane = win.document.querySelector( + '[is="tree-view-table-body"]' + ); + if (!threadPane) return; + + //add button to every currently visible mail card + for (let c of threadPane.querySelectorAll('.card-layout')) { + modifyRow(c, win); + } + + const rowObserver = new win.MutationObserver((mutations) => { + for (let mutation of mutations) { + for (let node of mutation.addedNodes) { + if ( + node.nodeName === 'tr' && + node.classList.contains('card-layout') + ) { + modifyRow(node, win); + } + } + } + }); + + rowObserver.observe(threadPane, { childList: true }); + }; + + const modifyRow = (row, win) => { + // Get message key/ID from the row attribute + const rowId = row.getAttribute('id'); + const rowNo = parseInt(rowId.match(/\d+$/)[0]); + + // Find a cell to inject into (e.g., the subject cell) + const subjectCell = row.querySelector('.thread-card-subject-container'); + if (!subjectCell || row.querySelector('.my-custom-btn')) return; + + const btn = win.document.createElement('button'); + btn.className = 'my-custom-btn'; + btn.textContent = '⚡'; + let btnId = `${rowId}-btn`; + btn.setAttribute('id', btnId); + btn.style.marginLeft = '5px'; + btn.style.display = 'none'; + + btn.onclick = (e) => { + e.stopPropagation(); // Don't select the row + // Fire the event back to background.js + eventPasser.pass(rowNo); //notify background.js about button click + }; + + subjectCell.appendChild(btn); + eventPasser.buttonPass(rowNo); //notify background.js about adding of new button + subjectCell.style.display = 'flex'; + }; + + // Initialize for existing windows + const panewin = get3panewindow(Services); + injectLogic(panewin); + + Services.wm.addListener(observer); + + //clean up all the stateful injections by reloading the window + context.callOnClose({ + close() { + panewin.location.reload(); + }, + }); +} + +async function enableButtonImpl(services, rowNo) { + let win = get3panewindow(services); + let el = win.document.querySelector(`#threadTree-row${rowNo}-btn`); + if (el) { + el.style.display = 'block'; + } +} + +//generated boilerplate (https://darktrojan.github.io/generator/generator.html) +var threadPaneButtons = class extends ExtensionCommon.ExtensionAPI { + getAPI(context) { + //hacky object for passing events from the dom to the background. Am pleasantly + //surprised that this works but there's probably a better way to do this tbh. + const eventPasser = { + callback: null, + buttonCallback: null, + pass() { + if (this.callback) { + this.callback(...arguments); + } + }, + async buttonPass() { + if (this.buttonCallback) { + this.buttonCallback(...arguments); + } + }, + }; + const Services = + globalThis.Services || + ChromeUtils.import('resource://gre/modules/Services.jsm').Services; + return { + threadPaneButtons: { + async initInjections() { + await initInjectionsImpl(Services, eventPasser, context); + }, + async enableButton(rowNo) { + await enableButtonImpl(Services, rowNo); + }, + onButtonClicked: new ExtensionCommon.EventManager({ + context, + name: 'threadPaneButtons.onButtonClicked', + register(fire) { + let listener = (rowNo) => { + // Fire any listeners registered with addListener. + fire.async(rowNo); + }; + // Register the listener. + eventPasser.callback = listener; + return () => { + // Return a way to unregister the listener. + eventPasser.callback = null; + }; + }, + }).api(), + onButtonProduced: new ExtensionCommon.EventManager({ + context, + name: 'threadPaneButtons.onButtonProduced', + register(fire) { + let listener = (rowNo) => { + // Fire any listeners registered with addListener. + fire.async(rowNo); + }; + // Register the listener. + eventPasser.buttonCallback = listener; + return () => { + // Return a way to unregister the listener. + eventPasser.buttonCallback = null; + }; + }, + }).api(), + }, + }; + } +}; diff --git a/src/exxperimentHack/schema.json b/src/exxperimentHack/schema.json new file mode 100644 index 0000000..d289aa1 --- /dev/null +++ b/src/exxperimentHack/schema.json @@ -0,0 +1,53 @@ +[ + { + "namespace": "threadPaneButtons", + "functions": [ + { + "name": "initInjections", + "description": "injects the javascript that adds buttons to the messages in the 3pane view", + "type": "function", + "async": true, + "parameters": [] + }, + { + "name": "enableButton", + "description": "enables the button in a specific row, should only be called if the corresponding mail has an unsub method.", + "type": "function", + "async": true, + "parameters": [ + { + "name": "rowNo", + "description": "the row for which the button should be enabled", + "type": "integer" + } + ] + } + ], + "events": [ + { + "name": "onButtonClicked", + "description": "called when a button on a message in the 3pane view is clicked", + "type": "function", + "parameters": [ + { + "name": "rowNo", + "description": "the row number of this button and message in the table view.", + "type": "integer" + } + ] + }, + { + "name": "onButtonProduced", + "description": "fired whenever a button is added to the dom. Button is disabled and must be enabled by a call to enable button (if the mail has an unsub method)", + "type": "function", + "parameters": [ + { + "name": "rowNo", + "description": "the number of the mail in the current list, meaning the number of the row to which this button was added.", + "type": "integer" + } + ] + } + ] + } +] diff --git a/src/manifest.json b/src/manifest.json index d4d0d38..2af42bd 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -42,5 +42,16 @@ } ] }, - "manifest_version": 2 + "manifest_version": 2, + + "experiment_apis": { + "threadPaneButtons": { + "schema": "exxperimentHack/schema.json", + "parent": { + "scopes": ["addon_parent"], + "script": "exxperimentHack/implementation.js", + "paths": [["threadPaneButtons"]] + } + } + } } diff --git a/src/popup.js b/src/popup.js index 3fae486..250c38e 100644 --- a/src/popup.js +++ b/src/popup.js @@ -27,7 +27,14 @@ document.addEventListener('DOMContentLoaded', async () => { active: true, currentWindow: true, }); - const message = await messenger.messageDisplay.getDisplayedMessage(tab.id); + + let message = await messenger.messageDisplay.getDisplayedMessage(tab.id); + //if the id is passed via url, use it + const urlParams = new URLSearchParams(document.location.search); + const mId = urlParams.get('messageId'); + if (mId) { + message = await messenger.messages.get(parseInt(mId)); + } console_log('Message', message.id); // Retrieve and cache references to various DOM elements for later use.