diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..e896bc2 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,29 @@ +# Outcut Privacy Policy + +Outcut is a browser extension that adds keyboard shortcuts to Outlook Web App. + +## Data Collection + +Outcut does **not** collect, transmit, or store any personal data. + +## Storage + +Outcut stores your preferences (keyboard shortcut preset selection and toggle states) using Chrome's built-in `chrome.storage.sync` API. This data: +- Stays on your device and within your Chrome profile +- Syncs across your Chrome instances if you use Chrome Sync (a Google feature, not ours) +- Is never sent to any external server + +## Permissions + +- **storage**: Save your extension preferences locally +- No other permissions are required + +## Email Content + +Outcut never reads, processes, stores, or transmits the content of your emails. It only interacts with the Outlook Web App's user interface elements (buttons, message list items) to provide keyboard shortcut functionality. + +## Contact + +For questions about this privacy policy, open an issue on the project's GitHub repository. + +*Last updated: April 2026* diff --git a/README.md b/README.md new file mode 100644 index 0000000..f5bff21 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# Outcut + +Keyboard shortcuts for Outlook Web App — Gmail-style multi-select, delete, archive, and more. + +## Features + +- **Gmail or Outlook shortcut presets** — choose your preferred key bindings +- **Multi-select** — select multiple messages and act on them at once +- **Batch actions** — delete, archive, flag, pin, move, read/unread on selected messages +- **Works everywhere** — outlook.office.com, outlook.cloud.microsoft, and Outlook PWA + +### Gmail Preset (default) +| Key | Action | +|-----|--------| +| j / Down | Next message | +| k / Up | Previous message | +| x / Space | Toggle select | +| Shift+Down/Up | Extend selection | +| # | Delete | +| e | Archive | +| Shift+i / Shift+u | Toggle read/unread | +| v | Move to folder | +| f | Flag/unflag | +| p | Pin/unpin | +| Escape | Deselect all | +| Enter / o | Open message | + +### Outlook Preset +| Key | Action | +|-----|--------| +| Down / j | Next message | +| Up / k | Previous message | +| Space | Toggle select | +| Shift+Down/Up | Extend selection | +| Delete / Backspace | Delete | +| e | Archive | +| q / Shift+i | Toggle read/unread | +| v | Move to folder | +| f / Insert | Flag/unflag | +| p | Pin/unpin | +| Escape | Deselect all | +| Enter | Open message | + +## Install + +### From Chrome Web Store +Coming soon. + +### Development +1. Clone this repo +2. Open `chrome://extensions` in Chrome +3. Enable "Developer mode" +4. Click "Load unpacked" and select this directory +5. Open Outlook Web App — the extension activates automatically + +## Usage + +Click the extension icon to choose your shortcut preset (Gmail or Outlook) and toggle keyboard navigation on/off. + +## Privacy + +Outcut stores only your preferences locally. No data is collected or transmitted. See [PRIVACY.md](PRIVACY.md). diff --git a/content/behavior.js b/content/behavior.js new file mode 100644 index 0000000..e984a58 --- /dev/null +++ b/content/behavior.js @@ -0,0 +1,292 @@ +// Outlook Relook — Behavior Patches +// JS-based UX improvements, each gated by a setting key. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.Behavior = (function () { + 'use strict'; + + var OR = window.OutlookRelook; + var currentSettings = {}; + var cleanupFns = []; + + // --- Auto-collapse ribbon on page load --- + function setupAutoCollapseRibbon() { + if (!currentSettings.autoCollapseRibbon) return; + + // Wait for OWA to finish rendering, then collapse the ribbon + var timer = setTimeout(function () { + var elements = OR.resolveSelector('ribbon-collapse-button'); + for (var i = 0; i < elements.length; i++) { + if (elements[i].getAttribute('aria-expanded') === 'true') { + elements[i].click(); + console.log('[Outcut] Auto-collapsed ribbon'); + } + } + }, 2000); + + cleanupFns.push(function () { clearTimeout(timer); }); + } + + // --- Remember sidebar collapsed/expanded state --- + function setupRememberSidebar() { + if (!currentSettings.rememberSidebarState) return; + + chrome.storage.local.get({ sidebarCollapsed: false }, function (result) { + if (result.sidebarCollapsed) { + var timer = setTimeout(function () { + var pane = OR.resolveSelector('folder-pane'); + for (var i = 0; i < pane.length; i++) { + var toggle = pane[i].closest('[aria-expanded]') || pane[i].querySelector('[aria-expanded]'); + if (toggle && toggle.getAttribute('aria-expanded') === 'true') { + toggle.click(); + } + } + }, 2000); + cleanupFns.push(function () { clearTimeout(timer); }); + } + }); + + // Watch for sidebar toggle changes + var sidebarObserver = new MutationObserver(function () { + var pane = OR.resolveSelector('folder-pane'); + if (pane.length > 0) { + var isVisible = pane[0].offsetWidth > 50; + chrome.storage.local.set({ sidebarCollapsed: !isVisible }); + } + }); + + var timer2 = setTimeout(function () { + var pane = OR.resolveSelector('folder-pane'); + if (pane.length > 0 && pane[0].parentElement) { + sidebarObserver.observe(pane[0].parentElement, { attributes: true, subtree: true }); + } + }, 3000); + + cleanupFns.push(function () { + clearTimeout(timer2); + sidebarObserver.disconnect(); + }); + } + + // --- Suppress contact card hover popups --- + function setupSuppressContactHover() { + if (!currentSettings.suppressContactHover) return; + + var handler = function (e) { + // Check if the hovered element or its ancestors trigger a contact card + var target = e.target.closest('[data-lpc-hover-target], [aria-haspopup="dialog"]'); + if (target) { + e.stopPropagation(); + e.preventDefault(); + } + }; + + document.addEventListener('mouseenter', handler, true); + cleanupFns.push(function () { document.removeEventListener('mouseenter', handler, true); }); + } + + // --- Auto-advance to next email after delete --- + function setupAutoAdvance() { + if (!currentSettings.autoAdvanceAfterDelete) return; + + var handler = function (e) { + // Detect delete key press + if (e.key === 'Delete' || e.key === 'Backspace') { + var selected = document.querySelector( + '[role="option"][aria-selected="true"], [role="listitem"][aria-selected="true"]' + ); + if (selected) { + var next = selected.nextElementSibling; + if (next) { + // Wait for OWA to process the delete, then focus next + setTimeout(function () { + next.click(); + next.focus(); + }, 200); + } + } + } + }; + + document.addEventListener('keydown', handler, true); + cleanupFns.push(function () { document.removeEventListener('keydown', handler, true); }); + } + + // --- Auto-dismiss notification toasts --- + function setupAutoDismissToasts() { + if (currentSettings.autoDismissToasts === 'off') return; + var delay = parseInt(currentSettings.autoDismissToasts, 10) * 1000; + if (isNaN(delay) || delay <= 0) return; + + var toastObserver = new MutationObserver(function (mutations) { + for (var m = 0; m < mutations.length; m++) { + var addedNodes = mutations[m].addedNodes; + for (var n = 0; n < addedNodes.length; n++) { + var node = addedNodes[n]; + if (node.nodeType !== 1) continue; + var toasts = []; + if (node.matches && node.matches('[role="alert"], [role="status"][aria-live]')) { + toasts.push(node); + } + if (node.querySelectorAll) { + var found = node.querySelectorAll('[role="alert"], [role="status"][aria-live]'); + for (var t = 0; t < found.length; t++) toasts.push(found[t]); + } + + for (var i = 0; i < toasts.length; i++) { + (function (toast) { + setTimeout(function () { + // Try to find a dismiss button + var dismiss = toast.querySelector('[aria-label*="Close" i], [aria-label*="Dismiss" i]'); + if (dismiss) { + dismiss.click(); + } else { + toast.style.display = 'none'; + } + }, delay); + })(toasts[i]); + } + } + } + }); + + toastObserver.observe(document.body, { childList: true, subtree: true }); + cleanupFns.push(function () { toastObserver.disconnect(); }); + } + + // --- Reposition toast notifications --- + function setupToastPosition() { + if (currentSettings.toastPosition === 'bottom-left') return; // OWA default + + var style = document.createElement('style'); + style.id = 'or-toast-position'; + style.textContent = [ + '[role="alert"], [role="status"][aria-live] {', + ' position: fixed !important;', + ' top: 8px !important;', + ' right: 8px !important;', + ' bottom: auto !important;', + ' left: auto !important;', + ' z-index: 999999 !important;', + '}' + ].join('\n'); + document.head.appendChild(style); + cleanupFns.push(function () { style.remove(); }); + } + + // --- Sticky Reply/Forward bar --- + function setupStickyReplyBar() { + if (!currentSettings.stickyReplyBar) return; + + var style = document.createElement('style'); + style.id = 'or-sticky-reply'; + style.textContent = [ + '[aria-label*="Reply all" i][role="button"],', + '[aria-label*="Reply" i][role="button"],', + '[aria-label*="Forward" i][role="button"] {', + ' position: sticky !important;', + ' bottom: 0 !important;', + ' z-index: 10 !important;', + ' background-color: var(--or-bg-primary, #fff) !important;', + '}' + ].join('\n'); + document.head.appendChild(style); + cleanupFns.push(function () { style.remove(); }); + } + + // --- Auto-resize compose window --- + function setupAutoResizeCompose() { + if (!currentSettings.autoResizeCompose) return; + + var composeObserver = new MutationObserver(function (mutations) { + for (var m = 0; m < mutations.length; m++) { + var addedNodes = mutations[m].addedNodes; + for (var n = 0; n < addedNodes.length; n++) { + var node = addedNodes[n]; + if (node.nodeType !== 1) continue; + var composeWindows = []; + if (node.matches && node.matches('[aria-label*="compose" i][role="dialog"], [aria-label*="New message" i]')) { + composeWindows.push(node); + } + if (node.querySelectorAll) { + var found = node.querySelectorAll('[aria-label*="compose" i][role="dialog"], [aria-label*="New message" i]'); + for (var i = 0; i < found.length; i++) composeWindows.push(found[i]); + } + + for (var w = 0; w < composeWindows.length; w++) { + composeWindows[w].style.minHeight = '60vh'; + composeWindows[w].style.height = '60vh'; + console.log('[Outcut] Auto-resized compose window'); + } + } + } + }); + + composeObserver.observe(document.body, { childList: true, subtree: true }); + cleanupFns.push(function () { composeObserver.disconnect(); }); + } + + // --- Throttle desktop notifications --- + function setupThrottleNotifications() { + if (!currentSettings.throttleNotifications) return; + + var OrigNotification = window.Notification; + + // Only patch if Notification API exists + if (!OrigNotification) return; + + var lastNotificationTime = 0; + var MIN_INTERVAL = 30000; // 30 seconds between notifications + + window.Notification = function (title, options) { + var now = Date.now(); + if (now - lastNotificationTime < MIN_INTERVAL) { + console.log('[Outcut] Throttled notification: "' + title + '"'); + return {}; + } + lastNotificationTime = now; + return new OrigNotification(title, options); + }; + window.Notification.permission = OrigNotification.permission; + window.Notification.requestPermission = OrigNotification.requestPermission.bind(OrigNotification); + + cleanupFns.push(function () { + window.Notification = OrigNotification; + }); + } + + // --- Public API --- + + function start(settings) { + currentSettings = settings; + + setupAutoCollapseRibbon(); + setupRememberSidebar(); + setupSuppressContactHover(); + setupAutoAdvance(); + setupAutoDismissToasts(); + setupToastPosition(); + setupStickyReplyBar(); + setupAutoResizeCompose(); + setupThrottleNotifications(); + + console.log('[Outcut] Behavior patches applied'); + } + + function updateSettings(settings) { + // Tear down existing behaviors and re-apply + stop(); + currentSettings = settings; + start(settings); + } + + function stop() { + for (var i = 0; i < cleanupFns.length; i++) { + try { cleanupFns[i](); } catch (e) { /* ignore */ } + } + cleanupFns = []; + } + + return { start: start, updateSettings: updateSettings, stop: stop }; +})(); diff --git a/content/content.js b/content/content.js new file mode 100644 index 0000000..7ec6136 --- /dev/null +++ b/content/content.js @@ -0,0 +1,165 @@ +// Outlook Relook — Content Script Entry Point +// +// Primary feature: Keyboard navigation (always active) +// Experimental: Design tweaks (themes, density, hiding, behavior) — gated by enableDesignTweaks + +(function () { + 'use strict'; + + const OR = window.OutlookRelook; + + // Map setting keys to data attributes on (design tweaks only) + const SETTING_TO_ATTR = { + compactTopBar: 'data-or-compact-topbar', + compactCommandBar: 'data-or-compact-commandbar', + compactMessageList: 'data-or-compact-messagelist', + compactReadingPane: 'data-or-compact-readingpane', + compactFolderPane: 'data-or-compact-folderpane', + narrowDateColumn: 'data-or-narrow-datecol', + compressComposeToolbar:'data-or-compact-compose', + readingPaneMaxWidth: 'data-or-reading-maxwidth', + unifiedHeader: 'data-or-unified-header', + hideCopilot: 'data-or-hide-copilot', + hideSuggestedReplies: 'data-or-hide-suggestedreplies', + hidePromoBanners: 'data-or-hide-promos', + hideFocusedOtherTabs: 'data-or-hide-focusedtabs', + hideSidebarAppIcons: 'data-or-hide-sidebaricons', + hideGroupsSection: 'data-or-hide-groups', + hideMyDayButtons: 'data-or-hide-myday', + hideSenderAvatars: 'data-or-hide-avatars', + hideFeatureDiscovery: 'data-or-hide-discovery', + hideVivaInsights: 'data-or-hide-viva', + hideUnreadOtherBanner: 'data-or-hide-unreadother', + hideActivityFeed: 'data-or-hide-activity', + unreadDistinction: 'data-or-unread-distinction', + previewOwnLine: 'data-or-preview-own-line', + normalizeFontWeight: 'data-or-normalize-font', + darkModeEmailFix: 'data-or-darkmode-fix', + }; + + const SETTING_TO_ATTR_VALUE = { + messageListFontSize: 'data-or-fontsize', + }; + + let designTweaksActive = false; + + function applyColorScheme(scheme) { + let resolved = scheme; + if (scheme === 'system') { + resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + document.documentElement.setAttribute('data-outlook-relook-scheme', resolved); + } + + function applySettingsToDOM(settings) { + for (const [key, attr] of Object.entries(SETTING_TO_ATTR)) { + document.documentElement.setAttribute(attr, String(!!settings[key])); + } + for (const [key, attr] of Object.entries(SETTING_TO_ATTR_VALUE)) { + document.documentElement.setAttribute(attr, settings[key] || ''); + } + applyColorScheme(settings.colorScheme); + if (settings.accentColor) { + document.documentElement.style.setProperty('--or-accent-override', settings.accentColor); + } + console.log('[Outcut] Settings applied to DOM'); + } + + function clearDesignFromDOM() { + // Remove all data-or-* attributes + for (const attr of Object.values(SETTING_TO_ATTR)) { + document.documentElement.removeAttribute(attr); + } + for (const attr of Object.values(SETTING_TO_ATTR_VALUE)) { + document.documentElement.removeAttribute(attr); + } + document.documentElement.removeAttribute('data-outlook-relook-scheme'); + document.documentElement.style.removeProperty('--or-accent-override'); + + // Remove injected theme CSS + var themeLink = document.getElementById('outlook-relook-theme'); + if (themeLink) themeLink.remove(); + + console.log('[Outcut] Design tweaks cleared from DOM'); + } + + function injectThemeCSS(theme) { + var existing = document.getElementById('outlook-relook-theme'); + if (existing) existing.remove(); + var link = document.createElement('link'); + link.id = 'outlook-relook-theme'; + link.rel = 'stylesheet'; + link.href = chrome.runtime.getURL('themes/' + theme + '.css'); + document.head.appendChild(link); + console.log('[Outcut] Theme loaded: ' + theme); + } + + function startDesignTweaks(settings) { + applySettingsToDOM(settings); + injectThemeCSS(settings.theme); + OR.Observer.start(settings); + OR.Behavior.start(settings); + OR.Injector.start(settings); + designTweaksActive = true; + console.log('[Outcut] Design tweaks enabled'); + } + + function stopDesignTweaks() { + OR.Observer.stop(); + OR.Behavior.stop(); + OR.Injector.stop(); + clearDesignFromDOM(); + designTweaksActive = false; + console.log('[Outcut] Design tweaks disabled'); + } + + async function init() { + const settings = await OR.loadSettings(); + console.log('[Outcut] Loaded settings:', settings); + + // Keyboard navigation — always starts (gated by its own toggle internally) + OR.Keyboard.start(settings); + + // Design tweaks — only if enabled + if (settings.enableDesignTweaks) { + startDesignTweaks(settings); + } + + // Listen for setting changes from popup + chrome.storage.onChanged.addListener((changes, area) => { + if (area !== 'sync') return; + + OR.loadSettings().then((updated) => { + // Keyboard — always update + OR.Keyboard.updateSettings(updated); + + // Design tweaks — toggle on/off or update + if (updated.enableDesignTweaks) { + if (!designTweaksActive) { + startDesignTweaks(updated); + } else { + applySettingsToDOM(updated); + OR.Observer.updateSettings(updated); + OR.Behavior.updateSettings(updated); + OR.Injector.updateSettings(updated); + if (changes.theme) { + injectThemeCSS(changes.theme.newValue); + } + } + } else if (designTweaksActive) { + stopDesignTweaks(); + } + }); + }); + } + + // Listen for system theme changes + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => { + const settings = await OR.loadSettings(); + if (settings.enableDesignTweaks && settings.colorScheme === 'system') { + applyColorScheme('system'); + } + }); + + init(); +})(); diff --git a/content/injector.js b/content/injector.js new file mode 100644 index 0000000..8f76938 --- /dev/null +++ b/content/injector.js @@ -0,0 +1,264 @@ +// Outlook Relook — DOM Injector +// Injects custom UI elements: mark-all-read button, folder jump dialog. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.Injector = (function () { + 'use strict'; + + var OR = window.OutlookRelook; + var currentSettings = {}; + var cleanupFns = []; + + // --- "Mark all as read" button --- + function setupMarkAllRead() { + if (!currentSettings.markAllReadButton) return; + + function injectButton() { + // Find folder headers that don't already have our button + var headers = OR.resolveSelector('folder-header'); + for (var i = 0; i < headers.length; i++) { + var header = headers[i]; + if (header.querySelector('.or-mark-all-read')) continue; + + var btn = document.createElement('button'); + btn.className = 'or-mark-all-read'; + btn.textContent = '\u2713\u2713'; // double checkmark + btn.title = 'Mark all as read'; + btn.style.cssText = [ + 'background: none;', + 'border: 1px solid var(--or-border, #ccc);', + 'color: var(--or-text-secondary, #666);', + 'cursor: pointer;', + 'font-size: 11px;', + 'padding: 2px 6px;', + 'margin-left: 8px;', + 'border-radius: 3px;', + 'line-height: 1;', + 'vertical-align: middle;' + ].join(' '); + + btn.addEventListener('click', (function (hdr) { + return function (e) { + e.stopPropagation(); + // Find the context menu "Mark all as read" option + var folder = hdr.closest('[role="treeitem"]'); + if (folder) { + // Dispatch a contextmenu event to open OWA's context menu + var rect = folder.getBoundingClientRect(); + var contextEvent = new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + clientX: rect.x + 10, + clientY: rect.y + 10, + }); + folder.dispatchEvent(contextEvent); + + // Wait for context menu to render, then find and click "Mark all as read" + setTimeout(function () { + var menuItems = document.querySelectorAll('[role="menuitem"]'); + for (var j = 0; j < menuItems.length; j++) { + if (/mark all as read/i.test(menuItems[j].textContent)) { + menuItems[j].click(); + console.log('[Outcut] Marked all as read'); + return; + } + } + // Close context menu if option not found + document.body.click(); + console.warn('[Outcut] "Mark all as read" menu item not found'); + }, 300); + } + }; + })(header)); + + header.appendChild(btn); + } + } + + // Inject on load and watch for new headers + var timer = setTimeout(injectButton, 3000); + var injectObserver = new MutationObserver(function () { injectButton(); }); + injectObserver.observe(document.body, { childList: true, subtree: true }); + + cleanupFns.push(function () { + clearTimeout(timer); + injectObserver.disconnect(); + var btns = document.querySelectorAll('.or-mark-all-read'); + for (var k = 0; k < btns.length; k++) btns[k].remove(); + }); + } + + // --- Quick folder jump (Ctrl+Shift+K) --- + function setupFolderJump() { + if (!currentSettings.quickFolderJump) return; + + var dialog = null; + + function createDialog() { + var overlay = document.createElement('div'); + overlay.id = 'or-folder-jump'; + overlay.style.cssText = [ + 'position: fixed;', + 'top: 0; left: 0; right: 0; bottom: 0;', + 'background: rgba(0,0,0,0.4);', + 'z-index: 999999;', + 'display: flex;', + 'align-items: flex-start;', + 'justify-content: center;', + 'padding-top: 20vh;' + ].join(' '); + + var box = document.createElement('div'); + box.style.cssText = [ + 'background: var(--or-bg-primary, #fff);', + 'border: 1px solid var(--or-border, #ccc);', + 'border-radius: 8px;', + 'padding: 12px;', + 'width: 400px;', + 'max-height: 400px;', + 'box-shadow: 0 8px 32px rgba(0,0,0,0.2);', + 'font-family: inherit;' + ].join(' '); + + var input = document.createElement('input'); + input.type = 'text'; + input.placeholder = 'Jump to folder...'; + input.style.cssText = [ + 'width: 100%;', + 'padding: 8px 12px;', + 'border: 1px solid var(--or-border, #ccc);', + 'border-radius: 4px;', + 'font-size: 14px;', + 'outline: none;', + 'box-sizing: border-box;', + 'background: var(--or-bg-secondary, #f5f5f5);', + 'color: var(--or-text-primary, #000);' + ].join(' '); + + var results = document.createElement('div'); + results.style.cssText = 'margin-top: 8px; max-height: 300px; overflow-y: auto;'; + + input.addEventListener('input', function () { + var query = input.value.toLowerCase().trim(); + // Clear results using safe DOM methods + while (results.firstChild) { + results.removeChild(results.firstChild); + } + + if (!query) return; + + // Find all folder tree items + var folders = document.querySelectorAll('[role="treeitem"]'); + var matches = []; + for (var i = 0; i < folders.length; i++) { + var name = (folders[i].textContent || '').trim(); + if (name.toLowerCase().indexOf(query) !== -1) { + matches.push({ name: name, element: folders[i] }); + } + } + + var limit = Math.min(matches.length, 10); + for (var j = 0; j < limit; j++) { + (function (match) { + var item = document.createElement('div'); + item.textContent = match.name; + item.style.cssText = [ + 'padding: 6px 12px;', + 'cursor: pointer;', + 'border-radius: 4px;', + 'color: var(--or-text-primary, #000);' + ].join(' '); + item.addEventListener('mouseenter', function () { + item.style.backgroundColor = 'var(--or-bg-hover, #e0e0e0)'; + }); + item.addEventListener('mouseleave', function () { + item.style.backgroundColor = ''; + }); + item.addEventListener('click', function () { + match.element.click(); + closeDialog(); + }); + results.appendChild(item); + })(matches[j]); + } + }); + + box.appendChild(input); + box.appendChild(results); + overlay.appendChild(box); + + overlay.addEventListener('click', function (e) { + if (e.target === overlay) closeDialog(); + }); + + input.addEventListener('keydown', function (e) { + if (e.key === 'Escape') closeDialog(); + if (e.key === 'Enter') { + var firstResult = results.querySelector('div'); + if (firstResult) firstResult.click(); + } + }); + + return { overlay: overlay, input: input }; + } + + function openDialog() { + if (dialog) return; + var created = createDialog(); + document.body.appendChild(created.overlay); + dialog = created.overlay; + setTimeout(function () { created.input.focus(); }, 50); + } + + function closeDialog() { + if (dialog) { + dialog.remove(); + dialog = null; + } + } + + var handler = function (e) { + // Ctrl+Shift+K (or Cmd+Shift+K on Mac) + if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'K') { + e.preventDefault(); + e.stopPropagation(); + if (dialog) { + closeDialog(); + } else { + openDialog(); + } + } + }; + + document.addEventListener('keydown', handler, true); + cleanupFns.push(function () { + document.removeEventListener('keydown', handler, true); + closeDialog(); + }); + } + + // --- Public API --- + + function start(settings) { + currentSettings = settings; + setupMarkAllRead(); + setupFolderJump(); + console.log('[Outcut] Injector started'); + } + + function updateSettings(settings) { + stop(); + currentSettings = settings; + start(settings); + } + + function stop() { + for (var i = 0; i < cleanupFns.length; i++) { + try { cleanupFns[i](); } catch (e) { /* ignore */ } + } + cleanupFns = []; + } + + return { start: start, updateSettings: updateSettings, stop: stop }; +})(); diff --git a/content/keyboard.js b/content/keyboard.js new file mode 100644 index 0000000..e287ed6 --- /dev/null +++ b/content/keyboard.js @@ -0,0 +1,537 @@ +// Outcut — Keyboard Navigation & Multi-Select +// Adds keyboard focus cursor and multi-select to OWA's message list. +// Gated by the keyboardMultiSelect setting. +// +// Tracks selections by stable message ID (data attribute or aria-label) +// rather than DOM element reference, since OWA frequently re-renders rows. + +window.OutlookRelook = window.OutlookRelook || {}; + +// Keyboard shortcut presets +var KEY_PRESETS = { + gmail: { + nextMessage: [{key: 'j'}, {key: 'ArrowDown'}], + prevMessage: [{key: 'k'}, {key: 'ArrowUp'}], + selectExtendDown: [{key: 'j', shift: true}, {key: 'ArrowDown', shift: true}], + selectExtendUp: [{key: 'k', shift: true}, {key: 'ArrowUp', shift: true}], + toggleSelect: [{key: 'x'}, {key: ' '}], + delete: [{key: '#'}], + archive: [{key: 'e'}], + readUnread: [{key: 'I', shift: true}, {key: 'U', shift: true}], + move: [{key: 'v'}], + flag: [{key: 'f'}], + pin: [{key: 'p'}], + deselect: [{key: 'Escape'}], + open: [{key: 'Enter'}, {key: 'o'}], + label: 'Gmail-style: j/k nav, x select, # del, e archive' + }, + outlook: { + nextMessage: [{key: 'ArrowDown'}, {key: 'j'}], + prevMessage: [{key: 'ArrowUp'}, {key: 'k'}], + selectExtendDown: [{key: 'ArrowDown', shift: true}], + selectExtendUp: [{key: 'ArrowUp', shift: true}], + toggleSelect: [{key: ' '}], + delete: [{key: 'Delete'}, {key: 'Backspace'}], + archive: [{key: 'e'}], + readUnread: [{key: 'q'}, {key: 'I', shift: true}], + move: [{key: 'v'}], + flag: [{key: 'Insert'}, {key: 'f'}], + pin: [{key: 'p'}], + deselect: [{key: 'Escape'}], + open: [{key: 'Enter'}], + label: 'Outlook-style: arrows nav, Space select, Del delete' + } +}; + +function matchesAction(e, actionBindings) { + if (!actionBindings) return false; + for (var i = 0; i < actionBindings.length; i++) { + var b = actionBindings[i]; + if (e.key === b.key && !!e.shiftKey === !!b.shift && !!e.ctrlKey === !!b.ctrl) { + return true; + } + } + return false; +} + +window.OutlookRelook.Keyboard = (function () { + 'use strict'; + + var OR = window.OutlookRelook; + var currentSettings = {}; + var cleanupFns = []; + + // State — track by ID strings, not DOM references + var focusedId = null; // ID of the focused message + var selectedIds = new Set(); // Set of selected message IDs + var countBadge = null; + + // --- Message ID extraction --- + // OWA messages have various attributes we can use as stable IDs. + // Try data-convid, data-itemid, id, or fall back to aria-label. + + function getMessageId(el) { + return el.getAttribute('data-convid') + || el.getAttribute('data-itemid') + || el.getAttribute('data-tid') + || el.getAttribute('id') + || el.getAttribute('aria-label') + || null; + } + + function getMessageItems() { + var items = document.querySelectorAll( + '[role="listbox"] [role="option"], [role="list"] [role="listitem"]' + ); + return Array.from(items); + } + + function findItemById(items, id) { + if (!id) return -1; + for (var i = 0; i < items.length; i++) { + if (getMessageId(items[i]) === id) return i; + } + return -1; + } + + function isComposeOrDialogActive() { + var active = document.activeElement; + if (!active) return false; + var tag = active.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea') return true; + if (active.getAttribute('contenteditable') === 'true') return true; + if (active.closest('[role="dialog"]')) return true; + if (active.closest('[role="search"]')) return true; + if (active.closest('[aria-label*="compose" i]')) return true; + if (active.closest('[aria-label*="New message" i]')) return true; + return false; + } + + // --- Visual state application --- + // Re-applies focus and selection classes to current DOM elements + // based on the ID-based state. Called after every key action and + // by the MutationObserver when OWA re-renders. + + function applyVisualState(items) { + // Clear all visual markers first + var oldFocused = document.querySelectorAll('.or-kb-focused'); + for (var f = 0; f < oldFocused.length; f++) oldFocused[f].classList.remove('or-kb-focused'); + var oldSelected = document.querySelectorAll('.or-kb-selected'); + for (var s = 0; s < oldSelected.length; s++) oldSelected[s].classList.remove('or-kb-selected'); + + if (!items) items = getMessageItems(); + + // Apply focus + if (focusedId) { + var focusIdx = findItemById(items, focusedId); + if (focusIdx >= 0) { + items[focusIdx].classList.add('or-kb-focused'); + } + } + + // Apply selections + selectedIds.forEach(function (id) { + var idx = findItemById(items, id); + if (idx >= 0) { + items[idx].classList.add('or-kb-selected'); + } + }); + + updateCountBadge(); + } + + // --- Focus management --- + + function getFocusedIndex(items) { + if (!focusedId) return -1; + return findItemById(items, focusedId); + } + + function setFocus(items, index) { + if (index < 0 || index >= items.length) return; + focusedId = getMessageId(items[index]); + applyVisualState(items); + items[index].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + // --- Selection management --- + + function toggleSelect(items, index) { + if (index < 0 || index >= items.length) return; + var id = getMessageId(items[index]); + if (!id) return; + + if (selectedIds.has(id)) { + selectedIds.delete(id); + } else { + selectedIds.add(id); + } + applyVisualState(items); + } + + function clearSelection() { + selectedIds.clear(); + var oldSelected = document.querySelectorAll('.or-kb-selected'); + for (var i = 0; i < oldSelected.length; i++) oldSelected[i].classList.remove('or-kb-selected'); + updateCountBadge(); + } + + function getActionTargets(items) { + var targets = []; + if (selectedIds.size > 0) { + selectedIds.forEach(function (id) { + var idx = findItemById(items, id); + if (idx >= 0) targets.push(items[idx]); + }); + } else { + var fi = getFocusedIndex(items); + if (fi >= 0) targets.push(items[fi]); + } + return targets; + } + + // --- Selection count badge --- + + function createCountBadge() { + if (!document.body) return null; + var badge = document.createElement('div'); + badge.className = 'or-kb-selection-count'; + badge.style.opacity = '0'; + document.body.appendChild(badge); + return badge; + } + + function updateCountBadge() { + if (!countBadge) countBadge = createCountBadge(); + if (!countBadge) return; + var count = selectedIds.size; + if (count === 0) { + countBadge.style.opacity = '0'; + } else { + countBadge.textContent = count + ' selected'; + countBadge.style.opacity = '1'; + } + } + + // --- Actions --- + + // --- Find a button by aria-label, searching within a target element first, + // then the global toolbar, then the whole document --- + function findButton(target, label) { + // 1. Look for inline button within/near the message row + // OWA renders Delete/Archive/Flag buttons on each row on hover + var btn = target.querySelector('button[aria-label="' + label + '"]'); + if (btn) return btn; + + // 2. Check the parent (some buttons are siblings of the row) + if (target.parentElement) { + btn = target.parentElement.querySelector('button[aria-label="' + label + '"]'); + if (btn) return btn; + } + + // 3. Look in the global toolbar area + btn = document.querySelector('[role="toolbar"] button[aria-label="' + label + '"], .fui-Toolbar button[aria-label="' + label + '"]'); + if (btn) return btn; + + // 4. Anywhere in the document + btn = document.querySelector('button[aria-label="' + label + '"]'); + return btn; + } + + // Simulate hover on a message row to make OWA's inline action buttons appear + function triggerHover(target) { + var rect = target.getBoundingClientRect(); + var enterEvent = new MouseEvent('mouseenter', { + bubbles: true, cancelable: true, + clientX: rect.x + rect.width - 30, + clientY: rect.y + rect.height / 2, + }); + var overEvent = new MouseEvent('mouseover', { + bubbles: true, cancelable: true, + clientX: rect.x + rect.width - 30, + clientY: rect.y + rect.height / 2, + }); + target.dispatchEvent(enterEvent); + target.dispatchEvent(overEvent); + } + + function performAction(targets, actionFn) { + if (targets.length === 0) return; + var i = 0; + function processNext() { + if (i >= targets.length) { + clearSelection(); + return; + } + var target = targets[i]; + i++; + + // First, try to find the inline button without clicking the row. + // Hover to make OWA's inline action buttons appear. + triggerHover(target); + + setTimeout(function () { + // Try inline button first (avoids opening the email in fill-screen mode) + var inlineHandled = actionFn(target); + + // If inline button wasn't found, fall back to clicking the row + toolbar + if (inlineHandled === false) { + target.click(); + setTimeout(function () { + actionFn(target); + setTimeout(processNext, 200); + }, 150); + } else { + setTimeout(processNext, 200); + } + }, 100); + } + processNext(); + } + + function actionDelete(targets) { + performAction(targets, function (target) { + var btn = findButton(target, 'Delete'); + if (btn) { btn.click(); return true; } + console.warn('[Outcut] Delete button not found'); + return false; + }); + } + + function actionArchive(targets) { + performAction(targets, function (target) { + var btn = findButton(target, 'Archive'); + if (btn) { btn.click(); return true; } + console.warn('[Outcut] Archive button not found'); + return false; + }); + } + + function actionMarkReadUnread(targets) { + performAction(targets, function (target) { + var btn = findButton(target, 'Read / Unread'); + if (btn) { btn.click(); return true; } + triggerContextMenuAction(target, /mark as read|mark as unread|read \/ unread/i); + return true; // context menu handles it + }); + } + + function actionMove(targets) { + if (targets.length > 0) { + triggerHover(targets[0]); + setTimeout(function () { + var btn = findButton(targets[0], 'Move to'); + if (btn) { + btn.click(); + } else { + triggerContextMenuAction(targets[0], /move to/i); + } + }, 150); + } + } + + function actionFlag(targets) { + performAction(targets, function (target) { + var btn = findButton(target, 'Flag this message') + || findButton(target, 'Flag / Unflag'); + if (btn) { btn.click(); return true; } + console.warn('[Outcut] Flag button not found'); + return false; + }); + } + + function actionPin(targets) { + performAction(targets, function (target) { + var btn = findButton(target, 'Pin / Unpin'); + if (btn) { btn.click(); return true; } + console.warn('[Outcut] Pin button not found'); + return false; + }); + } + + function triggerContextMenuAction(target, pattern) { + var el = target || document.querySelector('.or-kb-focused'); + if (!el) return; + var rect = el.getBoundingClientRect(); + var contextEvent = new MouseEvent('contextmenu', { + bubbles: true, cancelable: true, + clientX: rect.x + 10, clientY: rect.y + 10, + }); + el.dispatchEvent(contextEvent); + setTimeout(function () { + var menuItems = document.querySelectorAll('[role="menuitem"]'); + for (var j = 0; j < menuItems.length; j++) { + if (pattern.test(menuItems[j].textContent || menuItems[j].getAttribute('aria-label') || '')) { + menuItems[j].click(); + return; + } + } + document.body.click(); + }, 300); + } + + // --- Key handler --- + + function handleKeydown(e) { + if (!currentSettings.keyboardMultiSelect) return; + if (isComposeOrDialogActive()) return; + + var items = getMessageItems(); + if (items.length === 0) return; + + var presetName = currentSettings.keyboardPreset || 'gmail'; + var preset = KEY_PRESETS[presetName] || KEY_PRESETS.gmail; + + // Initialize focus if not set + var currentIdx = getFocusedIndex(items); + if (currentIdx < 0) { + for (var f = 0; f < items.length; f++) { + if (items[f].getAttribute('aria-selected') === 'true' || + items[f].classList.contains('is-selected')) { + currentIdx = f; + focusedId = getMessageId(items[f]); + break; + } + } + if (currentIdx < 0) { + currentIdx = 0; + focusedId = getMessageId(items[0]); + } + applyVisualState(items); + } + + var handled = true; + var targets; + + if (matchesAction(e, preset.selectExtendDown)) { + toggleSelect(items, currentIdx); + if (currentIdx < items.length - 1) { + setFocus(items, currentIdx + 1); + toggleSelect(items, currentIdx + 1); + } + } else if (matchesAction(e, preset.selectExtendUp)) { + toggleSelect(items, currentIdx); + if (currentIdx > 0) { + setFocus(items, currentIdx - 1); + toggleSelect(items, currentIdx - 1); + } + } else if (matchesAction(e, preset.nextMessage)) { + if (currentIdx < items.length - 1) setFocus(items, currentIdx + 1); + } else if (matchesAction(e, preset.prevMessage)) { + if (currentIdx > 0) setFocus(items, currentIdx - 1); + } else if (matchesAction(e, preset.toggleSelect)) { + e.preventDefault(); + toggleSelect(items, currentIdx); + } else if (matchesAction(e, preset.delete)) { + targets = getActionTargets(items); + actionDelete(targets); + } else if (matchesAction(e, preset.archive)) { + targets = getActionTargets(items); + actionArchive(targets); + } else if (matchesAction(e, preset.readUnread)) { + targets = getActionTargets(items); + actionMarkReadUnread(targets); + } else if (matchesAction(e, preset.move)) { + targets = getActionTargets(items); + actionMove(targets); + } else if (matchesAction(e, preset.flag)) { + targets = getActionTargets(items); + actionFlag(targets); + } else if (matchesAction(e, preset.pin)) { + targets = getActionTargets(items); + actionPin(targets); + } else if (matchesAction(e, preset.deselect)) { + clearSelection(); + } else if (matchesAction(e, preset.open)) { + if (currentIdx >= 0 && currentIdx < items.length) items[currentIdx].click(); + } else { + handled = false; + } + + if (handled) { + e.stopPropagation(); + if (e.key !== 'Enter') e.preventDefault(); + } + } + + // --- Re-apply visual state when OWA re-renders the message list --- + + function setupListObserver() { + var listObserver = new MutationObserver(function () { + // Prune IDs that no longer exist in DOM + var items = getMessageItems(); + var currentIds = new Set(); + for (var i = 0; i < items.length; i++) { + var id = getMessageId(items[i]); + if (id) currentIds.add(id); + } + selectedIds.forEach(function (id) { + if (!currentIds.has(id)) selectedIds.delete(id); + }); + if (focusedId && !currentIds.has(focusedId)) focusedId = null; + + // Re-apply classes to new DOM elements + applyVisualState(items); + }); + + var checkInterval = setInterval(function () { + var list = document.querySelector('[role="listbox"], [role="list"]'); + if (list) { + clearInterval(checkInterval); + listObserver.observe(list, { childList: true, subtree: true }); + } + }, 1000); + + cleanupFns.push(function () { + clearInterval(checkInterval); + listObserver.disconnect(); + }); + } + + // --- Public API --- + + function start(settings) { + currentSettings = settings; + if (!settings.keyboardMultiSelect) return; + + document.addEventListener('keydown', handleKeydown, true); + setupListObserver(); + + console.log('[Outcut] Keyboard navigation started'); + cleanupFns.push(function () { + document.removeEventListener('keydown', handleKeydown, true); + }); + } + + function updateSettings(settings) { + var wasEnabled = currentSettings.keyboardMultiSelect; + var isEnabled = settings.keyboardMultiSelect; + var oldPreset = currentSettings.keyboardPreset; + var newPreset = settings.keyboardPreset; + currentSettings = settings; + + if (wasEnabled !== isEnabled || oldPreset !== newPreset) { + stop(); + start(settings); + } + } + + function stop() { + for (var i = 0; i < cleanupFns.length; i++) { + try { cleanupFns[i](); } catch (e) { /* ignore */ } + } + cleanupFns = []; + + clearSelection(); + var focused = document.querySelector('.or-kb-focused'); + if (focused) focused.classList.remove('or-kb-focused'); + focusedId = null; + + if (countBadge) { + countBadge.remove(); + countBadge = null; + } + } + + return { start: start, updateSettings: updateSettings, stop: stop }; +})(); diff --git a/content/observer.js b/content/observer.js new file mode 100644 index 0000000..43282a0 --- /dev/null +++ b/content/observer.js @@ -0,0 +1,126 @@ +// Outlook Relook — MutationObserver +// Watches OWA's DOM and removes dynamically injected elements +// based on active settings. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.Observer = (function () { + 'use strict'; + + let observer = null; + let currentSettings = {}; + + // Map setting keys to selector registry names + // Each setting can suppress one or more logical elements + const SETTING_TO_SELECTORS = { + hideCopilot: ['copilot-button', 'copilot-pane', 'copilot-compose-suggestions'], + hideSuggestedReplies: ['suggested-replies'], + hidePromoBanners: ['promo-banners'], + hideFocusedOtherTabs: ['focused-other-tabs'], + hideSidebarAppIcons: ['sidebar-app-icons'], + hideGroupsSection: ['groups-section'], + hideMyDayButtons: ['my-day-buttons'], + hideSenderAvatars: ['sender-avatars'], + hideFeatureDiscovery: ['feature-discovery'], + hideVivaInsights: ['viva-insights'], + hideUnreadOtherBanner: ['unread-other-banner'], + hideActivityFeed: ['activity-feed'], + }; + + // Text content patterns to match elements by their inner text. + // Used when aria/data selectors don't catch dynamically injected content. + const TEXT_PATTERNS = { + hidePromoBanners: [ + /try the new outlook/i, + /upgrade to premium/i, + /get the outlook app/i, + /switch to the new/i, + ], + hideFeatureDiscovery: [ + /what's new/i, + /new feature/i, + /did you know/i, + ], + }; + + function suppressElements() { + for (const [settingKey, selectorNames] of Object.entries(SETTING_TO_SELECTORS)) { + if (!currentSettings[settingKey]) continue; + + for (const name of selectorNames) { + const elements = window.OutlookRelook.resolveSelector(name); + for (const el of elements) { + if (el.style.display !== 'none') { + el.style.display = 'none'; + console.log('[Outcut] Suppressed: ' + name, el); + } + } + } + } + + // Text-based suppression for elements missed by selectors + for (const [settingKey, patterns] of Object.entries(TEXT_PATTERNS)) { + if (!currentSettings[settingKey]) continue; + + for (const pattern of patterns) { + // Check buttons, banners, and generic containers + const candidates = document.querySelectorAll( + '[role="alert"], [role="banner"], [role="dialog"], [role="status"], button, a' + ); + for (const el of candidates) { + if (el.style.display === 'none') continue; + var text = el.textContent || ''; + if (pattern.test(text) && text.length < 200) { + el.style.display = 'none'; + console.log('[Outcut] Suppressed by text: "' + text.trim().substring(0, 50) + '"', el); + } + } + } + } + } + + function start(settings) { + currentSettings = settings; + + // Initial pass + suppressElements(); + + // Watch for DOM mutations + observer = new MutationObserver(function (mutations) { + // Debounce: only process if nodes were actually added + var hasAddedNodes = false; + for (var i = 0; i < mutations.length; i++) { + if (mutations[i].addedNodes.length > 0) { + hasAddedNodes = true; + break; + } + } + if (hasAddedNodes) { + suppressElements(); + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + console.log('[Outcut] Observer started'); + } + + function updateSettings(settings) { + currentSettings = settings; + // Re-run suppression with new settings + suppressElements(); + } + + function stop() { + if (observer) { + observer.disconnect(); + observer = null; + console.log('[Outcut] Observer stopped'); + } + } + + return { start: start, updateSettings: updateSettings, stop: stop }; +})(); diff --git a/content/selectors.js b/content/selectors.js new file mode 100644 index 0000000..f6e8e54 --- /dev/null +++ b/content/selectors.js @@ -0,0 +1,165 @@ +// Outlook Relook — Selector Registry +// Maps logical element names to resilient CSS selectors. +// Selectors use aria-label, data-*, role, and structural patterns +// to avoid depending on OWA's obfuscated class names. +// +// Selectors marked /* INSPECT OWA */ must be verified against the live DOM. +// Use Chrome DevTools on outlook.office.com to find correct selectors. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.SELECTORS = { + // --- Hide Elements --- + 'copilot-button': { + primary: '[aria-label*="Copilot" i]', + fallbacks: ['[data-app-section="Copilot"]', '[title*="Copilot" i]'], + }, + 'copilot-pane': { + primary: '[aria-label*="Copilot" i][role="complementary"]', + fallbacks: [], + }, + 'copilot-compose-suggestions': { + primary: '[aria-label*="writing suggestion" i]', + fallbacks: ['[aria-label*="Copilot" i][role="listbox"]'], + }, + 'suggested-replies': { + primary: '[aria-label*="Suggested repl" i]', + fallbacks: ['[role="group"][aria-label*="Reply suggestion" i]'], + }, + 'promo-banners': { + primary: '[aria-label*="Try the new" i], [aria-label*="Upgrade" i], [aria-label*="Get the app" i], [aria-label*="premium" i]', + fallbacks: [], + }, + 'focused-other-tabs': { + primary: '[role="tablist"][aria-label*="Focused" i]', + fallbacks: ['[aria-label*="Focused Inbox" i]'], + }, + 'sidebar-app-icons': { + primary: 'nav[aria-label*="App" i], [role="navigation"][aria-label*="Module" i]', + fallbacks: [], + }, + 'groups-section': { + primary: '[aria-label*="Groups" i][role="tree"], [aria-label*="Groups" i][role="treeitem"]', + fallbacks: [], + }, + 'my-day-buttons': { + primary: '[aria-label*="My Day" i], [aria-label*="To Do" i][role="button"]', + fallbacks: [], + }, + 'sender-avatars': { + primary: '[role="listbox"] [aria-hidden="true"] img[src*="profile"], [role="listbox"] [aria-label*="avatar" i]', + fallbacks: [], + }, + 'feature-discovery': { + primary: '[role="dialog"][aria-label*="new feature" i], [role="dialog"][aria-label*="what\'s new" i], [aria-label*="teaching" i]', + fallbacks: [], + }, + 'viva-insights': { + primary: '[aria-label*="Viva" i], [aria-label*="Daily Briefing" i], [aria-label*="Briefing" i]', + fallbacks: [], + }, + 'unread-other-banner': { + primary: '[aria-label*="unread in Other" i]', + fallbacks: [], + }, + 'activity-feed': { + primary: '[aria-label*="Activity" i][role="complementary"], [aria-label*="mentioned you" i]', + fallbacks: [], + }, + + // --- Layout Regions (for density CSS) --- + 'top-bar': { + primary: '[role="banner"]', + fallbacks: ['header'], + }, + 'command-bar': { + primary: '[role="toolbar"][aria-label*="command" i], [role="toolbar"][aria-label*="action" i]', + fallbacks: [], + }, + 'message-list': { + primary: '[role="listbox"][aria-label*="Message list" i], [role="list"][aria-label*="Message" i]', + fallbacks: [], + }, + 'reading-pane': { + primary: '[role="main"][aria-label*="Reading" i], [aria-label*="Message body" i]', + fallbacks: [], + }, + 'folder-pane': { + primary: '[role="navigation"][aria-label*="Folder" i], [role="tree"][aria-label*="Folder" i]', + fallbacks: [], + }, + 'compose-toolbar': { + primary: '[role="toolbar"][aria-label*="Format" i]', + fallbacks: [], + }, + 'search-bar': { + primary: '[role="search"], [aria-label*="Search" i] input', + fallbacks: [], + }, + + // --- Behavior targets --- + 'ribbon-collapse-button': { + primary: '[aria-label*="Ribbon" i][aria-expanded], [aria-label*="collapse" i][role="button"]', + fallbacks: [], + }, + 'contact-hover-card': { + primary: '[role="dialog"][aria-label*="contact" i], [role="tooltip"][aria-label*="contact" i]', + fallbacks: [], + }, + 'notification-toast': { + primary: '[role="alert"], [role="status"][aria-live]', + fallbacks: [], + }, + 'reply-forward-bar': { + primary: '[aria-label*="Reply" i][role="button"], [aria-label*="Forward" i][role="button"]', + fallbacks: [], + }, + 'compose-window': { + primary: '[aria-label*="compose" i][role="dialog"], [aria-label*="New message" i]', + fallbacks: [], + }, + 'folder-header': { + primary: '[role="heading"][aria-level]', + fallbacks: [], + }, +}; + +// Resolve a logical name to matching DOM elements. +// Tries primary selector first, then fallbacks in order. +// Returns an array of elements (possibly empty). +window.OutlookRelook.resolveSelector = function (name) { + const entry = window.OutlookRelook.SELECTORS[name]; + if (!entry) { + console.warn('[Outlook Relook] Unknown selector: ' + name); + return []; + } + + const selectors = [entry.primary, ...entry.fallbacks]; + for (const selector of selectors) { + try { + const elements = document.querySelectorAll(selector); + if (elements.length > 0) return Array.from(elements); + } catch (e) { + console.warn('[Outlook Relook] Invalid selector for "' + name + '": ' + selector, e); + } + } + + return []; +}; + +// Resolve a logical name to a CSS selector string that currently matches. +// Returns the first matching selector string, or null. +window.OutlookRelook.resolveSelectorString = function (name) { + const entry = window.OutlookRelook.SELECTORS[name]; + if (!entry) return null; + + const selectors = [entry.primary, ...entry.fallbacks]; + for (const selector of selectors) { + try { + if (document.querySelector(selector)) return selector; + } catch (e) { + // skip invalid + } + } + return null; +}; diff --git a/content/settings-defaults.js b/content/settings-defaults.js new file mode 100644 index 0000000..6ae20db --- /dev/null +++ b/content/settings-defaults.js @@ -0,0 +1,116 @@ +// Outlook Relook — Settings Defaults +// Loaded first by manifest. Exposes window.OutlookRelook.DEFAULTS and helpers. + +window.OutlookRelook = window.OutlookRelook || {}; + +window.OutlookRelook.DEFAULTS = { + // Keyboard Navigation (primary feature, always visible) + keyboardMultiSelect: true, + keyboardPreset: 'gmail', + + // Design Tweaks (experimental, hidden by default) + enableDesignTweaks: false, + + // Theme & Appearance + theme: 'swiss', + colorScheme: 'system', // 'light' | 'dark' | 'system' + accentColor: '', // empty = theme default + + // Density & Spacing + densityPreset: 'compact', // 'comfortable' | 'compact' | 'ultra-compact' + compactTopBar: true, + compactCommandBar: true, + compactMessageList: true, + compactReadingPane: true, + compactFolderPane: true, + narrowDateColumn: true, + compressComposeToolbar: true, + readingPaneMaxWidth: true, + unifiedHeader: false, + + // Hide Elements + hideCopilot: true, + hideSuggestedReplies: true, + hidePromoBanners: true, + hideFocusedOtherTabs: true, + hideSidebarAppIcons: false, + hideGroupsSection: false, + hideMyDayButtons: false, + hideSenderAvatars: false, + hideFeatureDiscovery: true, + hideVivaInsights: true, + hideUnreadOtherBanner: true, + hideActivityFeed: true, + + // Readability + unreadDistinction: true, + previewOwnLine: false, + normalizeFontWeight: true, + darkModeEmailFix: true, + messageListFontSize: 'medium', // 'small' | 'medium' | 'large' + + // Behavior + autoCollapseRibbon: true, + rememberSidebarState: true, + suppressContactHover: true, + autoAdvanceAfterDelete: true, + autoDismissToasts: '5', // 'off' | '3' | '5' | '10' (seconds) + toastPosition: 'top-right', // 'bottom-left' | 'top-right' + stickyReplyBar: true, + autoResizeCompose: true, + throttleNotifications: false, + + // Quick Actions + markAllReadButton: true, + quickFolderJump: true, +}; + +// Density presets define which individual toggles each preset sets +window.OutlookRelook.DENSITY_PRESETS = { + comfortable: { + compactTopBar: false, + compactCommandBar: false, + compactMessageList: false, + compactReadingPane: false, + compactFolderPane: false, + narrowDateColumn: false, + compressComposeToolbar: false, + readingPaneMaxWidth: true, + }, + compact: { + compactTopBar: true, + compactCommandBar: true, + compactMessageList: true, + compactReadingPane: true, + compactFolderPane: true, + narrowDateColumn: true, + compressComposeToolbar: true, + readingPaneMaxWidth: true, + }, + 'ultra-compact': { + compactTopBar: true, + compactCommandBar: true, + compactMessageList: true, + compactReadingPane: true, + compactFolderPane: true, + narrowDateColumn: true, + compressComposeToolbar: true, + readingPaneMaxWidth: true, + }, +}; + +// Load settings from chrome.storage.sync, filling in defaults for missing keys +window.OutlookRelook.loadSettings = function () { + return new Promise((resolve) => { + chrome.storage.sync.get(window.OutlookRelook.DEFAULTS, (settings) => { + resolve(settings); + }); + }); +}; + +// Save a partial settings object to chrome.storage.sync +window.OutlookRelook.saveSettings = function (partial) { + return new Promise((resolve) => { + chrome.storage.sync.set(partial, resolve); + }); +}; diff --git a/icons/icon-128.png b/icons/icon-128.png new file mode 100644 index 0000000..01698e9 Binary files /dev/null and b/icons/icon-128.png differ diff --git a/icons/icon-16.png b/icons/icon-16.png new file mode 100644 index 0000000..9d02133 Binary files /dev/null and b/icons/icon-16.png differ diff --git a/icons/icon-48.png b/icons/icon-48.png new file mode 100644 index 0000000..d773a83 Binary files /dev/null and b/icons/icon-48.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..5ae3844 --- /dev/null +++ b/manifest.json @@ -0,0 +1,43 @@ +{ + "manifest_version": 3, + "name": "Outcut", + "version": "1.0.0", + "description": "Keyboard shortcuts for Outlook — Gmail-style multi-select, delete, archive, and more.", + "homepage_url": "https://github.com/joelbrockcoluminate/outcut", + "permissions": ["storage"], + "content_scripts": [ + { + "matches": ["https://outlook.office.com/*", "https://outlook.office365.com/*", "https://outlook.cloud.microsoft/*"], + "css": ["themes/base.css"], + "js": [ + "content/settings-defaults.js", + "content/selectors.js", + "content/observer.js", + "content/behavior.js", + "content/keyboard.js", + "content/injector.js", + "content/content.js" + ], + "run_at": "document_idle" + } + ], + "action": { + "default_popup": "popup/popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "icons": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + }, + "web_accessible_resources": [ + { + "resources": ["themes/*.css"], + "matches": ["https://outlook.office.com/*", "https://outlook.office365.com/*", "https://outlook.cloud.microsoft/*"] + } + ] +} diff --git a/popup/popup.css b/popup/popup.css new file mode 100644 index 0000000..4345e0d --- /dev/null +++ b/popup/popup.css @@ -0,0 +1,250 @@ +/* Outlook Relook — Settings Popup */ + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + width: 360px; + max-height: 540px; + overflow-y: auto; + font-family: system-ui, -apple-system, sans-serif; + font-size: 13px; + line-height: 1.4; + color: #222; + background: #fff; + padding: 0; +} + +/* Header */ +.or-header { + padding: 12px 16px; + border-bottom: 1px solid #e5e5e5; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + background: #fff; + z-index: 10; +} + +.or-header h1 { + font-size: 15px; + font-weight: 600; + letter-spacing: -0.02em; +} + +.or-header .or-version { + font-size: 11px; + color: #999; +} + +/* Sections */ +.or-section { + border-bottom: 1px solid #f0f0f0; +} + +.or-section-header { + padding: 10px 16px 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #888; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + user-select: none; +} + +.or-section-header::after { + content: '\25B6'; /* right-pointing triangle */ + font-size: 8px; + transition: transform 0.15s; +} + +.or-section.open .or-section-header::after { + transform: rotate(90deg); +} + +.or-section-body { + display: none; + padding: 0 16px 10px; +} + +.or-section.open .or-section-body { + display: block; +} + +/* Toggle rows */ +.or-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; + gap: 8px; +} + +.or-toggle-row label { + flex: 1; + cursor: pointer; + font-size: 12.5px; +} + +/* Toggle switch */ +.or-switch { + position: relative; + width: 36px; + height: 20px; + flex-shrink: 0; +} + +.or-switch input { + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + cursor: pointer; + z-index: 2; + margin: 0; +} + +.or-switch .slider { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: #ccc; + border-radius: 10px; + cursor: pointer; + transition: background 0.2s; +} + +.or-switch .slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 2px; + top: 2px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.or-switch input:checked + .slider { + background: #1976d2; +} + +.or-switch input:checked + .slider::before { + transform: translateX(16px); +} + +/* Select/dropdown rows */ +.or-select-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; + gap: 8px; +} + +.or-select-row label { + flex: 1; + font-size: 12.5px; +} + +.or-select-row select { + padding: 3px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 12px; + background: #fff; + color: #222; + cursor: pointer; +} + +/* Color picker */ +.or-color-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; + gap: 8px; +} + +.or-color-row label { + flex: 1; + font-size: 12.5px; +} + +.or-color-row input[type="color"] { + width: 32px; + height: 24px; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + padding: 0; +} + +/* Radio group */ +.or-radio-group { + display: flex; + gap: 12px; + padding: 5px 0; +} + +.or-radio-group label { + display: flex; + align-items: center; + gap: 4px; + font-size: 12.5px; + cursor: pointer; +} + +/* Footer */ +.or-footer { + padding: 10px 16px; + display: flex; + gap: 8px; + justify-content: flex-end; +} + +.or-footer button { + padding: 5px 12px; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + font-size: 12px; + cursor: pointer; + color: #222; +} + +.or-footer button:hover { + background: #f0f0f0; +} + +.or-footer button.danger { + color: #d32f2f; + border-color: #d32f2f; +} + +.or-footer button.danger:hover { + background: #fce4ec; +} + +/* Scrollbar */ +body::-webkit-scrollbar { + width: 6px; +} + +body::-webkit-scrollbar-track { + background: transparent; +} + +body::-webkit-scrollbar-thumb { + background: #ccc; + border-radius: 3px; +} diff --git a/popup/popup.html b/popup/popup.html new file mode 100644 index 0000000..e987fde --- /dev/null +++ b/popup/popup.html @@ -0,0 +1,47 @@ + + +
+ + + + + +Mock OWA DOM fragments. Click "Run Tests" to verify selectors match.
+ + +