// Outlook Relook — Gmail-Style Keyboard Navigation & Multi-Select // Adds keyboard focus cursor and multi-select to OWA's message list. // Gated by the keyboardMultiSelect setting. window.OutlookRelook = window.OutlookRelook || {}; window.OutlookRelook.Keyboard = (function () { 'use strict'; var OR = window.OutlookRelook; var currentSettings = {}; var cleanupFns = []; // State var focusedIndex = -1; // Index of the focused message in the list var selectedSet = new Set(); // Set of selected message DOM elements var countBadge = null; // Selection count badge element // --- Helpers --- function getMessageItems() { // Get all message items from the message list var items = document.querySelectorAll( '[role="listbox"] [role="option"], [role="list"] [role="listitem"]' ); return Array.from(items); } function isComposeOrDialogActive() { var active = document.activeElement; if (!active) return false; // Check if focus is in a compose area, search bar, or dialog 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; } function setFocus(items, index) { // Remove old focus var oldFocused = document.querySelector('.or-kb-focused'); if (oldFocused) oldFocused.classList.remove('or-kb-focused'); if (index < 0 || index >= items.length) return; focusedIndex = index; var item = items[index]; item.classList.add('or-kb-focused'); // Scroll into view if needed item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } function toggleSelect(item) { if (selectedSet.has(item)) { selectedSet.delete(item); item.classList.remove('or-kb-selected'); } else { selectedSet.add(item); item.classList.add('or-kb-selected'); } updateCountBadge(); } function clearSelection() { selectedSet.forEach(function (item) { item.classList.remove('or-kb-selected'); }); selectedSet.clear(); updateCountBadge(); } function getActionTargets(items) { // If messages are selected, return those. Otherwise return the focused message. if (selectedSet.size > 0) { return Array.from(selectedSet); } if (focusedIndex >= 0 && focusedIndex < items.length) { return [items[focusedIndex]]; } return []; } // --- 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 = selectedSet.size; if (count === 0) { countBadge.style.opacity = '0'; } else { countBadge.textContent = count + ' selected'; countBadge.style.opacity = '1'; } } // --- Actions --- // Each action simulates what a user would do in OWA to perform the operation. // We click the message to select it in OWA, then trigger the toolbar action. function performAction(targets, actionFn) { if (targets.length === 0) return; // Process targets one at a time with a small delay between each var i = 0; function processNext() { if (i >= targets.length) { // After all targets processed, clear selection clearSelection(); return; } var target = targets[i]; i++; // Click the message to make it OWA-selected target.click(); // Small delay to let OWA register the selection, then perform action setTimeout(function () { actionFn(target); // Delay before next target setTimeout(processNext, 150); }, 100); } processNext(); } function actionDelete(targets) { performAction(targets, function () { // Find and click the delete button in the toolbar var deleteBtn = document.querySelector( '[aria-label*="Delete" i][role="button"], [aria-label*="delete" i][role="menuitem"]' ); if (deleteBtn) { deleteBtn.click(); } else { // Fallback: try dispatching Delete key var deleteEvent = new KeyboardEvent('keydown', { key: 'Delete', code: 'Delete', bubbles: true, cancelable: true }); document.activeElement.dispatchEvent(deleteEvent); } }); } function actionArchive(targets) { performAction(targets, function () { var archiveBtn = document.querySelector( '[aria-label*="Archive" i][role="button"], [aria-label*="archive" i][role="menuitem"]' ); if (archiveBtn) { archiveBtn.click(); } else { console.warn('[Outlook Relook] Archive button not found'); } }); } function actionMarkRead(targets) { performAction(targets, function () { var readBtn = document.querySelector( '[aria-label*="Mark as read" i][role="button"], [aria-label*="Mark as read" i][role="menuitem"]' ); if (readBtn) { readBtn.click(); } else { // Try context menu approach triggerContextMenuAction(/mark as read/i); } }); } function actionMarkUnread(targets) { performAction(targets, function () { var unreadBtn = document.querySelector( '[aria-label*="Mark as unread" i][role="button"], [aria-label*="Mark as unread" i][role="menuitem"]' ); if (unreadBtn) { unreadBtn.click(); } else { triggerContextMenuAction(/mark as unread/i); } }); } function actionMove(targets) { // For move, we just need to trigger OWA's move dialog on the current selection if (targets.length > 0) { // Click the first target to ensure something is selected targets[0].click(); setTimeout(function () { var moveBtn = document.querySelector( '[aria-label*="Move to" i][role="button"], [aria-label*="Move" i][role="menuitem"]' ); if (moveBtn) { moveBtn.click(); } else { triggerContextMenuAction(/move to/i); } }, 100); } } function triggerContextMenuAction(pattern) { // Open context menu on the focused/selected element var focused = document.querySelector('.or-kb-focused'); if (!focused) return; var rect = focused.getBoundingClientRect(); var contextEvent = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: rect.x + 10, clientY: rect.y + 10, }); focused.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].click(); return; } } // Close context menu if not found document.body.click(); }, 300); } // --- Key handler --- function handleKeydown(e) { // Skip if keyboard mode is off or compose/dialog is active if (!currentSettings.keyboardMultiSelect) return; if (isComposeOrDialogActive()) return; var items = getMessageItems(); if (items.length === 0) return; var key = e.key; var shift = e.shiftKey; // Initialize focus if not set if (focusedIndex < 0 || focusedIndex >= items.length) { // Find the currently OWA-selected item, or start at 0 for (var f = 0; f < items.length; f++) { if (items[f].getAttribute('aria-selected') === 'true') { focusedIndex = f; break; } } if (focusedIndex < 0) focusedIndex = 0; } var handled = true; var targets; switch (key) { case 'j': case 'ArrowDown': if (shift) { // Select current and move down if (focusedIndex >= 0 && focusedIndex < items.length) { if (!selectedSet.has(items[focusedIndex])) { toggleSelect(items[focusedIndex]); } } if (focusedIndex < items.length - 1) { setFocus(items, focusedIndex + 1); toggleSelect(items[focusedIndex]); } } else { if (focusedIndex < items.length - 1) { setFocus(items, focusedIndex + 1); } } break; case 'k': case 'ArrowUp': if (shift) { // Select current and move up if (focusedIndex >= 0 && focusedIndex < items.length) { if (!selectedSet.has(items[focusedIndex])) { toggleSelect(items[focusedIndex]); } } if (focusedIndex > 0) { setFocus(items, focusedIndex - 1); toggleSelect(items[focusedIndex]); } } else { if (focusedIndex > 0) { setFocus(items, focusedIndex - 1); } } break; case 'x': case ' ': // Toggle select on focused message e.preventDefault(); // Prevent page scroll on Space if (focusedIndex >= 0 && focusedIndex < items.length) { toggleSelect(items[focusedIndex]); } break; case '#': // Delete selected/focused messages targets = getActionTargets(items); actionDelete(targets); break; case 'e': // Archive selected/focused messages targets = getActionTargets(items); actionArchive(targets); break; case 'I': // Shift+i — Mark as read if (shift) { targets = getActionTargets(items); actionMarkRead(targets); } else { handled = false; } break; case 'U': // Shift+u — Mark as unread if (shift) { targets = getActionTargets(items); actionMarkUnread(targets); } else { handled = false; } break; case 'v': // Move selected messages targets = getActionTargets(items); actionMove(targets); break; case 'Escape': // Deselect all clearSelection(); break; case 'Enter': case 'o': // Open focused message in reading pane if (focusedIndex >= 0 && focusedIndex < items.length) { items[focusedIndex].click(); } break; default: handled = false; } if (handled) { e.stopPropagation(); // Only preventDefault for keys we handle (except Enter which OWA should also process) if (key !== 'Enter' && key !== 'o') { e.preventDefault(); } } } // --- Cleanup stale selections when message list re-renders --- function setupListObserver() { var listObserver = new MutationObserver(function () { // Remove stale entries from selectedSet (elements no longer in DOM) selectedSet.forEach(function (item) { if (!document.contains(item)) { selectedSet.delete(item); } }); updateCountBadge(); // Reset focusedIndex if the focused item is gone var focusedEl = document.querySelector('.or-kb-focused'); if (!focusedEl || !document.contains(focusedEl)) { focusedIndex = -1; } }); // Watch the message list container for child changes 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('[Outlook Relook] Keyboard navigation started'); cleanupFns.push(function () { document.removeEventListener('keydown', handleKeydown, true); }); } function updateSettings(settings) { var wasEnabled = currentSettings.keyboardMultiSelect; var isEnabled = settings.keyboardMultiSelect; currentSettings = settings; // Only tear down and restart if the keyboard setting itself changed if (wasEnabled !== isEnabled) { stop(); start(settings); } } function stop() { for (var i = 0; i < cleanupFns.length; i++) { try { cleanupFns[i](); } catch (e) { /* ignore */ } } cleanupFns = []; // Clean up DOM state clearSelection(); var focused = document.querySelector('.or-kb-focused'); if (focused) focused.classList.remove('or-kb-focused'); focusedIndex = -1; if (countBadge) { countBadge.remove(); countBadge = null; } } return { start: start, updateSettings: updateSettings, stop: stop }; })();