Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions compile.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ $files = @(
"./src/popup.html",
"./src/popup.js",
"./src/i18n.js",
"./src/exxperimentHack",
"./src/styles.css"
)

Expand Down
106 changes: 93 additions & 13 deletions src/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
}
Expand All @@ -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.
Expand Down Expand Up @@ -518,6 +581,23 @@ async function* getMessages(list) {
}
}

/**
* Retrieves the nth message (0-indexed) from the getMessages async generator.
* @param {Promise<object>} list - The initial paginated list promise.
* @param {number} n - The index of the message to retrieve.
* @returns {Promise<object|null>} - 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.
Expand Down
181 changes: 181 additions & 0 deletions src/exxperimentHack/implementation.js
Original file line number Diff line number Diff line change
@@ -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 <table is="thread-pane">
// 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(),
},
};
}
};
53 changes: 53 additions & 0 deletions src/exxperimentHack/schema.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
]
}
]
13 changes: 12 additions & 1 deletion src/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]]
}
}
}
}
Loading