// 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. // // 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 || {}; 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 --- 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++; target.click(); setTimeout(function () { actionFn(target); setTimeout(processNext, 150); }, 100); } processNext(); } function actionDelete(targets) { performAction(targets, function () { var deleteBtn = document.querySelector( '[aria-label*="Delete" i][role="button"], [aria-label*="delete" i][role="menuitem"]' ); if (deleteBtn) { deleteBtn.click(); } else { 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 { 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) { if (targets.length > 0) { 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) { 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; } } 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 key = e.key; var shift = e.shiftKey; // 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; switch (key) { case 'j': case 'ArrowDown': if (shift) { toggleSelect(items, currentIdx); if (currentIdx < items.length - 1) { setFocus(items, currentIdx + 1); toggleSelect(items, currentIdx + 1); } } else { if (currentIdx < items.length - 1) { setFocus(items, currentIdx + 1); } } break; case 'k': case 'ArrowUp': if (shift) { toggleSelect(items, currentIdx); if (currentIdx > 0) { setFocus(items, currentIdx - 1); toggleSelect(items, currentIdx - 1); } } else { if (currentIdx > 0) { setFocus(items, currentIdx - 1); } } break; case 'x': case ' ': e.preventDefault(); toggleSelect(items, currentIdx); break; case '#': targets = getActionTargets(items); actionDelete(targets); break; case 'e': targets = getActionTargets(items); actionArchive(targets); break; case 'I': if (shift) { targets = getActionTargets(items); actionMarkRead(targets); } else { handled = false; } break; case 'U': if (shift) { targets = getActionTargets(items); actionMarkUnread(targets); } else { handled = false; } break; case 'v': targets = getActionTargets(items); actionMove(targets); break; case 'Escape': clearSelection(); break; case 'Enter': case 'o': if (currentIdx >= 0 && currentIdx < items.length) { items[currentIdx].click(); } break; default: handled = false; } if (handled) { e.stopPropagation(); if (key !== 'Enter' && key !== 'o') { 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('[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; if (wasEnabled !== isEnabled) { 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 }; })();