Add theme support with dynamic icons

This commit is contained in:
Jordan Wages 2025-07-08 01:30:37 -05:00
commit 7e64a428ac
5 changed files with 132 additions and 65 deletions

View file

@ -16,6 +16,7 @@ message meets a specified criterion.
- **Advanced parameters** tune generation settings like temperature, topp and more from the options page.
- **Markdown conversion** optionally convert HTML bodies to Markdown before sending them to the AI service.
- **Debug logging** optional colorized logs help troubleshoot interactions with the AI service.
- **Light/Dark themes** automatically match Thunderbird's appearance with optional manual override.
- **Automatic rules** create rules that tag or move new messages based on AI classification.
- **Rule ordering** drag rules to prioritize them and optionally stop processing after a match.
- **Context menu** apply AI rules from the message list or the message display action button.

View file

@ -27,24 +27,41 @@ let stripUrlParams = false;
let altTextImages = false;
let collapseWhitespace = false;
let TurndownService = null;
let userTheme = 'auto';
let currentTheme = 'light';
function iconPaths(name) {
return {
16: `resources/img/${name}-${currentTheme}-16.png`,
32: `resources/img/${name}-${currentTheme}-32.png`,
64: `resources/img/${name}-${currentTheme}-64.png`
};
}
async function detectSystemTheme() {
try {
const t = await browser.theme.getCurrent();
const scheme = t?.properties?.color_scheme;
if (scheme === 'dark' || scheme === 'light') {
return scheme;
}
const color = t?.colors?.frame || t?.colors?.toolbar;
if (color && /^#/.test(color)) {
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return lum < 0.5 ? 'dark' : 'light';
}
} catch {}
return 'light';
}
const ICONS = {
logo: "resources/img/logo.png",
circledots: {
16: "resources/img/circledots-16.png",
32: "resources/img/circledots-32.png",
64: "resources/img/circledots-64.png"
},
circle: {
16: "resources/img/circle-16.png",
32: "resources/img/circle-32.png",
64: "resources/img/circle-64.png"
},
average: {
16: "resources/img/average-16.png",
32: "resources/img/average-32.png",
64: "resources/img/average-64.png"
}
logo: () => 'resources/img/logo.png',
circledots: () => iconPaths('circledots'),
circle: () => iconPaths('circle'),
average: () => iconPaths('average')
};
function setIcon(path) {
@ -57,19 +74,29 @@ function setIcon(path) {
}
function updateActionIcon() {
let path = ICONS.logo;
let path = ICONS.logo();
if (processing || queuedCount > 0) {
path = ICONS.circledots;
path = ICONS.circledots();
}
setIcon(path);
}
function showTransientIcon(path, delay = 1500) {
function showTransientIcon(factory, delay = 1500) {
clearTimeout(iconTimer);
const path = typeof factory === 'function' ? factory() : factory;
setIcon(path);
iconTimer = setTimeout(updateActionIcon, delay);
}
function refreshMenuIcons() {
browser.menus.update('apply-ai-rules-list', { icons: iconPaths('eye') });
browser.menus.update('apply-ai-rules-display', { icons: iconPaths('eye') });
browser.menus.update('clear-ai-cache-list', { icons: iconPaths('trash') });
browser.menus.update('clear-ai-cache-display', { icons: iconPaths('trash') });
browser.menus.update('view-ai-reason-list', { icons: iconPaths('clipboarddata') });
browser.menus.update('view-ai-reason-display', { icons: iconPaths('clipboarddata') });
}
function byteSize(str) {
return new TextEncoder().encode(str || "").length;
@ -286,9 +313,11 @@ async function clearCacheForMessages(idsInput) {
}
try {
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules"]);
const store = await storage.local.get(["endpoint", "templateName", "customTemplate", "customSystemPrompt", "aiParams", "debugLogging", "htmlToMarkdown", "stripUrlParams", "altTextImages", "collapseWhitespace", "aiRules", "theme"]);
logger.setDebug(store.debugLogging);
await AiClassifier.setConfig(store);
userTheme = store.theme || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
await AiClassifier.init();
htmlToMarkdown = store.htmlToMarkdown === true;
stripUrlParams = store.stripUrlParams === true;
@ -341,12 +370,19 @@ async function clearCacheForMessages(idsInput) {
collapseWhitespace = changes.collapseWhitespace.newValue === true;
logger.aiLog("collapseWhitespace updated from storage change", { debug: true }, collapseWhitespace);
}
if (changes.theme) {
userTheme = changes.theme.newValue || 'auto';
currentTheme = userTheme === 'auto' ? await detectSystemTheme() : userTheme;
updateActionIcon();
refreshMenuIcons();
}
});
} catch (err) {
logger.aiLog("failed to load config", { level: 'error' }, err);
}
logger.aiLog("background.js loaded ready to classify", { debug: true });
updateActionIcon();
if (browser.messageDisplayAction) {
browser.messageDisplayAction.setTitle({ title: "Details" });
if (browser.messageDisplayAction.setLabel) {
@ -359,62 +395,39 @@ async function clearCacheForMessages(idsInput) {
id: "apply-ai-rules-list",
title: "Apply AI Rules",
contexts: ["message_list"],
icons: {
16: "resources/img/eye-16.png",
32: "resources/img/eye-32.png",
64: "resources/img/eye-64.png"
}
icons: iconPaths('eye')
});
browser.menus.create({
id: "apply-ai-rules-display",
title: "Apply AI Rules",
contexts: ["message_display_action"],
icons: {
16: "resources/img/eye-16.png",
32: "resources/img/eye-32.png",
64: "resources/img/eye-64.png"
}
icons: iconPaths('eye')
});
browser.menus.create({
id: "clear-ai-cache-list",
title: "Clear AI Cache",
contexts: ["message_list"],
icons: {
16: "resources/img/trash-16.png",
32: "resources/img/trash-32.png",
64: "resources/img/trash-64.png"
}
icons: iconPaths('trash')
});
browser.menus.create({
id: "clear-ai-cache-display",
title: "Clear AI Cache",
contexts: ["message_display_action"],
icons: {
16: "resources/img/trash-16.png",
32: "resources/img/trash-32.png",
64: "resources/img/trash-64.png"
}
icons: iconPaths('trash')
});
browser.menus.create({
id: "view-ai-reason-list",
title: "View Reasoning",
contexts: ["message_list"],
icons: {
16: "resources/img/clipboarddata-16.png",
32: "resources/img/clipboarddata-32.png",
64: "resources/img/clipboarddata-64.png"
}
icons: iconPaths('clipboarddata')
});
browser.menus.create({
id: "view-ai-reason-display",
title: "View Reasoning",
contexts: ["message_display_action"],
icons: {
16: "resources/img/clipboarddata-16.png",
32: "resources/img/clipboarddata-32.png",
64: "resources/img/clipboarddata-64.png"
}
icons: iconPaths('clipboarddata')
});
refreshMenuIcons();
browser.menus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === "apply-ai-rules-list" || info.menuItemId === "apply-ai-rules-display") {

View file

@ -1,4 +1,10 @@
const aiLog = (await import(browser.runtime.getURL("logger.js"))).aiLog;
const storage = (globalThis.messenger ?? browser).storage;
const { theme } = await storage.local.get('theme');
const mode = (theme || 'auto') === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.dataset.theme = mode;
const qMid = parseInt(new URLSearchParams(location.search).get("mid"), 10);
if (!isNaN(qMid)) {

View file

@ -37,22 +37,22 @@
<section class="section">
<div class="container" id="options-container">
<figure class="has-text-centered mb-4">
<img src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
<img data-icon="full-logo" src="../resources/img/full-logo.png" alt="AI Filter Logo" style="max-height:40px;">
</figure>
<div class="level mb-4">
<div class="level-left">
<div class="tabs" id="main-tabs">
<ul>
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img src="../resources/svg/settings.svg" alt=""></span><span>Settings</span></a></li>
<li data-tab="rules"><a><span class="icon is-small"><img src="../resources/svg/clipboarddata.svg" alt=""></span><span>Rules</span></a></li>
<li data-tab="maintenance"><a><span class="icon is-small"><img src="../resources/svg/gear.svg" alt=""></span><span>Maintenance</span></a></li>
<li class="is-active" data-tab="settings"><a><span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span><span>Settings</span></a></li>
<li data-tab="rules"><a><span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span><span>Rules</span></a></li>
<li data-tab="maintenance"><a><span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span><span>Maintenance</span></a></li>
</ul>
</div>
</div>
<div class="level-right">
<button class="button is-primary" id="save" disabled>
<span class="icon is-small"><img src="../resources/svg/flag.svg" alt=""></span>
<span class="icon is-small"><img data-icon="flag" data-size="16" src="../resources/img/flag-light-16.png" alt=""></span>
<span>Save</span>
</button>
</div>
@ -60,7 +60,7 @@
<div id="settings-tab" class="tab-content">
<h2 class="title is-4">
<span class="icon is-small"><img src="../resources/svg/settings.svg" alt=""></span>
<span class="icon is-small"><img data-icon="settings" data-size="16" src="../resources/img/settings-light-16.png" alt=""></span>
<span>Settings</span>
</h2>
<div class="field">
@ -94,13 +94,26 @@
</div>
</div>
<div class="field">
<label class="label" for="theme-select">Theme</label>
<div class="control">
<div class="select">
<select id="theme-select">
<option value="auto">Match Thunderbird</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
</div>
</div>
<div class="buttons">
<button class="button is-danger" id="reset-system">
<span class="icon is-small"><img src="../resources/svg/reply.svg" alt=""></span>
<span class="icon is-small"><img data-icon="reply" data-size="16" src="../resources/img/reply-light-16.png" alt=""></span>
<span>Reset to default</span>
</button>
<button class="button" id="toggle-advanced" type="button">
<span class="icon is-small"><img src="../resources/svg/gear.svg" alt=""></span>
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
<span>Advanced</span>
</button>
</div>
@ -202,7 +215,7 @@
<div id="rules-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img src="../resources/svg/clipboarddata.svg" alt=""></span>
<span class="icon is-small"><img data-icon="clipboarddata" data-size="16" src="../resources/img/clipboarddata-light-16.png" alt=""></span>
<span>Classification Rules</span>
</h2>
<div id="rules-container"></div>
@ -211,7 +224,7 @@
<div id="maintenance-tab" class="tab-content is-hidden">
<h2 class="title is-4">
<span class="icon is-small"><img src="../resources/svg/gear.svg" alt=""></span>
<span class="icon is-small"><img data-icon="gear" data-size="16" src="../resources/img/gear-light-16.png" alt=""></span>
<span>Maintenance</span>
</h2>
<table class="table is-fullwidth">
@ -226,7 +239,7 @@
</tbody>
</table>
<button class="button is-danger" id="clear-cache" type="button">
<span class="icon is-small"><img src="../resources/svg/trash.svg" alt=""></span>
<span class="icon is-small"><img data-icon="trash" data-size="16" src="../resources/img/trash-light-16.png" alt=""></span>
<span>Clear Cache</span>
</button>
<div class="field mt-4">
@ -240,13 +253,13 @@
<div class="field is-grouped mt-4">
<p class="control">
<button class="button" id="export-data" type="button">
<span class="icon is-small"><img src="../resources/svg/download.svg" alt=""></span>
<span class="icon is-small"><img data-icon="download" data-size="16" src="../resources/img/download-light-16.png" alt=""></span>
<span>Export Data</span>
</button>
</p>
<p class="control">
<button class="button" id="import-data" type="button">
<span class="icon is-small"><img src="../resources/svg/upload.svg" alt=""></span>
<span class="icon is-small"><img data-icon="upload" data-size="16" src="../resources/img/upload-light-16.png" alt=""></span>
<span>Import Data</span>
</button>
<input class="is-hidden" type="file" id="import-file" accept="application/json">

View file

@ -15,7 +15,8 @@ document.addEventListener('DOMContentLoaded', async () => {
'altTextImages',
'collapseWhitespace',
'aiRules',
'aiCache'
'aiCache',
'theme'
]);
const tabButtons = document.querySelectorAll('#main-tabs li');
const tabs = document.querySelectorAll('.tab-content');
@ -37,6 +38,37 @@ document.addEventListener('DOMContentLoaded', async () => {
document.addEventListener('input', markDirty, true);
document.addEventListener('change', markDirty, true);
logger.setDebug(defaults.debugLogging === true);
const themeSelect = document.getElementById('theme-select');
themeSelect.value = defaults.theme || 'auto';
function systemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function updateIcons(theme) {
document.querySelectorAll('img[data-icon]').forEach(img => {
const name = img.dataset.icon;
const size = img.dataset.size || 16;
if (name === 'full-logo') {
img.src = `../resources/img/full-logo${theme === 'dark' ? '-white' : ''}.png`;
} else {
img.src = `../resources/img/${name}-${theme}-${size}.png`;
}
});
}
function applyTheme(setting) {
const mode = setting === 'auto' ? systemTheme() : setting;
document.documentElement.dataset.theme = mode;
updateIcons(mode);
}
applyTheme(themeSelect.value);
themeSelect.addEventListener('change', () => {
markDirty();
applyTheme(themeSelect.value);
});
const DEFAULT_AI_PARAMS = {
max_tokens: 4096,
temperature: 0.6,
@ -452,7 +484,9 @@ document.addEventListener('DOMContentLoaded', async () => {
const stripUrlParams = stripUrlToggle.checked;
const altTextImages = altTextToggle.checked;
const collapseWhitespace = collapseWhitespaceToggle.checked;
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, aiRules: rules });
const theme = themeSelect.value;
await storage.local.set({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging, htmlToMarkdown, stripUrlParams, altTextImages, collapseWhitespace, aiRules: rules, theme });
applyTheme(theme);
try {
await AiClassifier.setConfig({ endpoint, templateName, customTemplate: customTemplateText, customSystemPrompt, aiParams: aiParamsSave, debugLogging });
logger.setDebug(debugLogging);