NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Element Web Minimal Roomlist // @namespace https://gitlab.com/andybalaam/element-web-tweaks/ // @version 0.3 // @description Single list of rooms, with unreads first // @author Andy Balaam // @match https://*.element.io/* // @grant none // @license Apache-2.0 // @copyright 2023, Andy Balaam // @downloadURL https://gitlab.com/andybalaam/element-web-tweaks/-/raw/main/minimal-room-list.user.js // @updateURL https://gitlab.com/andybalaam/element-web-tweaks/-/raw/main/minimal-room-list.user.js // ==/UserScript== (function() { 'use strict'; /* * minimal-roomlist * * Replace Element Web's room list with a more minimal one that prioritises * rooms and chats containing unread messages. * * Keyboard shortcuts: * Control+Shift+Up/Down - move to next/previous room * Control+Shift+Home - move to the first room * (Existing Element Web shortcuts should still work as before.) * * Implementation notes: * * The old room list is still there, but hidden with display: none. We actually * build our new list from the old one. */ // Global variables except ajbIntervalId (because ajbIntervalId should survive // re-running this in the console) all live inside document.ajb. document.ajb = { previousRoomMap: {} }; // Run this script regularly to keep up with any updates if (document.ajbIntervalId) { clearInterval(document.ajbIntervalId); } document.ajbIntervalId = setInterval(minimalRoomList, 4000); const cfgStr = localStorage.getItem("minimal-roomlist-config"); if (cfgStr) { document.ajbConfig = JSON.parse(cfgStr); } if (!document.ajbConfig) { document.ajbConfig = { "ajbShowUnreadLowPriority": true, "ajbShowReadFavourites": true, "ajbShowReadRooms": false, "ajbShowReadPeople": false, "ajbShowReadLowPriority": false }; } function isElementVisible(el, holder) { holder = holder || document.body const { top, bottom, height } = el.getBoundingClientRect() const holderRect = holder.getBoundingClientRect() return top <= holderRect.top ? holderRect.top - top <= height - 20 : bottom - holderRect.bottom <= height - 20 } function addStylesheetRules(rules) { // https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/insertRule const styleEl = document.getElementById("ajbStyle") || (function() { const s = document.createElement('style'); s.id = "ajbStyle"; document.head.appendChild(s); return s; })(); var styleSheet = styleEl.sheet; for (var i = 0; i < rules.length; i++) { var j = 1, rule = rules[i], selector = rule[0], propStr = ''; // If the second argument of a rule is an array of arrays, correct our variables. if (Array.isArray(rule[1][0])) { rule = rule[1]; j = 0; } for (var pl = rule.length; j < pl; j++) { var prop = rule[j]; propStr += prop[0] + ': ' + prop[1] + (prop[2] ? ' !important' : '') + ';\n'; } styleSheet.insertRule(selector + '{' + propStr + '}', styleSheet.cssRules.length); } } addStylesheetRules([ ['.mx_LeftPanel_actualRoomListContainer', ['display', 'none'] ], ['.mx_RoomSublist', ['display', 'none'] ], ['.mx_LeftPanel .mx_LeftPanel_roomListContainer .mx_LeftPanel_roomListWrapper.mx_LeftPanel_roomListWrapper_stickyTop', ['height', 'auto'], ['padding-top', '0px'], ['padding-bottom', '0px'] ], ['.mx_LeftPanel .mx_LeftPanel_roomListContainer div.mx_LeftPanel_roomListWrapper.mx_LeftPanel_roomListWrapper_stickyBottom', ['height', 'auto'], ['padding-top', '0px'], ['padding-bottom', '0px'] ], ['#ajbRoomList', ['overflow', 'scroll'], ['height', '99%'], ['scrollbar-width', 'auto'], ['padding', '4px'] ], ['#ajbRoomList h2', ['font-size', '1.6rem'], ['font-weight', 'bold'], ['margin-bottom', '6px'] ], ['#ajbRoomList .ajbKey', ['font-weight', 'bold'] ], ['#ajbRoomList .ajbTile', ['padding', '1px'], ['white-space', 'nowrap'], ['cursor', 'pointer'], ['color', '#555'] ], ['#ajbRoomList .ajbTile.highlight', ['color', 'red'] ], ['#ajbRoomList .ajbTile.highlight.selected', ['color', 'inherit'] ], ['#ajbRoomList .ajbTile.ajbUnread', ['font-weight', 'bold'], ['color', 'black'] ], ['#ajbRoomList .ajbTile.ajbUnread.ajbSelected', ['font-weight', 'normal'] ], ['#ajbRoomList .ajbTile.ajbSelected', ['background-color', 'rgba(0, 0, 0, 0.15)'], ['color', 'black'] ], ['#ajbRoomList .ajbTile.ajbNewlySelected', ['background-color', 'rgba(0, 255, 0, 0.15)'] ], ['#ajbRoomList .ajbTile.ajbNewlySelected:hover', ['background-color', 'rgba(0, 255, 0, 0.15)'] ], ['#ajbRoomList .ajbTile.ajbSelected:hover', ['background-color', 'rgba(0, 0, 0, 0.1)'] ], ['#ajbRoomList .ajbTile:hover', ['background-color', 'rgba(0, 0, 0, 0.06)'] ], ['#ajbRoomList .ajbTile a', ['color', 'unset'] ], ['#ajbRoomList .ajbTile.ajbLowPriority a', ['color', '#444'] ], ['#ajbRoomList .ajbTile .ajbIcon', ['font-size', '75%'], ['padding-right', '3px'], ['position', 'relative'], ['bottom', '1px'] ], ['#ajbRoomList .ajbTile .ajbRoomAvatarSpan', ['width', '14px'], ['margin-right', '4px'] ], ['#ajbRoomList .ajbTile .ajbRoomAvatarInitial', ['position', 'relative'], ['width', '14px'], ['text-align', 'center'], ['font-size', '90%'], ['display', 'inline-block'], ['color', 'white'], ['font-weight', 'normal'] ], ['#ajbRoomList .ajbTile .ajbRoomAvatarImg', ['width', '14px'], ['height', '14px'], ['border-radius', '5px'], ['margin-top', '1px'], ['margin-left', '-14px'], ['vertical-align', 'top'] ], ['#ajbRoomList .ajbTile .ajbNotification', ['background-color', 'black'], ['color', 'white'], ['border-radius', '7px'], ['display', 'inline-block'], ['text-align', 'center'], ['padding', '1px 4px 1px 3px'], ['margin-left', '4px'], ['margin-top', '2px'], ['vertical-align', 'top'], ['font-size', '70%'] ], ['#ajbRoomList .ajbTile .ajbNotification.ajbHighlight', ['background-color', 'red'] ], ['#ajbRoomList .ajbTile.ajbSelected .ajbNotification', ['display', 'none'] ] ]); function cmp(left, right) { return ( left > right ? 1 : left < right ? -1 : 0 ); } function cmpRooms(left, right) { function priority(tile) { let unread = tile.unread; let notification = tile.notification; let highlight = tile.highlight; if (tile.selected) { // Prevent tile moving when we select it const prevRoom = document.ajb.previousRoomMap[tile.title]; if (prevRoom) { unread = prevRoom.unread; notification = prevRoom.notification; highlight = prevRoom.highlight; } } let priority = ""; if (highlight) { priority += "1"; } else if (notification) { priority += "2"; } else if (unread) { priority += "3" } else { priority += "4"; } function unreadPriority(sublistName) { switch (sublistName) { case "System Alerts": return "A"; case "Invites": return "B"; case "People": return "C"; // In unread rooms, people are case "Favourites": return "D"; // above faves and rooms. case "Rooms": return "E"; case "Low priority": return "F"; case "Suggested Rooms": return "G"; } console.error(`Unexpected sublistName: ${tile.sublistName}`); return "Z"; } function readPriority(sublistName) { switch (sublistName) { case "System Alerts": return "A"; case "Invites": return "B"; case "Favourites": return "C"; // In read rooms, favourites case "Rooms": return "D"; // and rooms are above people. case "People": return "E"; case "Low priority": return "F"; case "Suggested Rooms": return "G"; } console.error(`Unexpected sublistName: ${tile.sublistName}`); return "Z"; } if (highlight || notification || unread) { priority += unreadPriority(tile.sublistName); } else { priority += readPriority(tile.sublistName); } return priority; } return cmp( [priority(left), left.title], [priority(right), right.title] ); } function iconToDescribe(sublistName) { switch (sublistName) { case "Favourites": return "🖤"; case "People": return "♟"; case "Low priority": return "↓"; default: return null; } } function firstRoom() { document.querySelector("#ajbRoomList .ajbTile").click(); } function moveRoom(delta) { const tiles = document.querySelectorAll("#ajbRoomList .ajbTile"); for (let i = 0; i < tiles.length; i++) { if (tiles.item(i).classList.contains("ajbSelected")) { tiles.item(i + delta)?.click(); break; } } } function gatherRoomInfo() { const ajb = document.ajb; const rooms = []; const nextRoomMap = {}; const roomList = document.querySelector(".mx_RoomList"); if (!roomList) { return {rooms, roomList}; } for (const sublist of roomList.querySelectorAll(".mx_RoomSublist")) { const sublistName = sublist.querySelector(".mx_RoomSublist_headerText").children[1].innerText; const sublistHeaderText = sublist.querySelector( ".mx_RoomSublist_headerText" ); if (sublistHeaderText.getAttribute("aria-expanded") !== "true") { sublistHeaderText.click(); } const expandButton = sublist.querySelector(".mx_RoomSublist_showNButton"); if ( expandButton && !(expandButton.getAttribute("aria-label") === "Show less") ) { expandButton.click(); } for (const roomTile of sublist.querySelectorAll(".mx_RoomTile")) { let avatarImage = roomTile.querySelector(".mx_BaseAvatar_image"); let avatarInitial = roomTile.querySelector( ".mx_BaseAvatar_initial" ); if (avatarImage) { avatarImage = avatarImage.src; } if (avatarInitial) { avatarInitial = avatarInitial.innerText; } const selected = roomTile.classList.contains("mx_RoomTile_selected"); let highlight = !!roomTile.querySelector( ".mx_NotificationBadge_highlighted"); const notificationBadge_count = roomTile.querySelector( ".mx_NotificationBadge_count"); let notification = null; if ( notificationBadge_count && notificationBadge_count.innerText !== "" ) { notification = notificationBadge_count.innerText; } const roomTileTitle = roomTile.querySelector(".mx_RoomTile_title"); let unread = ( roomTileTitle ? roomTileTitle.classList.contains( "mx_RoomTile_titleHasUnreadEvents" ) : false ); const title = roomTileTitle?.getAttribute("title"); rooms.push( { selected, highlight, notification, unread, title, sublistName, roomTile, avatarImage, avatarInitial } ); if (selected) { const previousRoom = ajb.previousRoomMap[title]; if (previousRoom) { unread = previousRoom.unread; notification = previousRoom.notification; highlight = previousRoom.highlight; } } nextRoomMap[title] = { unread, notification, highlight, selected }; } } return {rooms, nextRoomMap}; } function configChanged(id) { const elem = document.getElementById(id); document.ajbConfig[id] = elem.checked; localStorage.setItem("minimal-roomlist-config", JSON.stringify(document.ajbConfig)); document.ajb.previousRoomMap = {}; minimalRoomList(); const roomList = document.getElementById("ajbRoomList"); roomList.scrollTo(0, 10000); } function addConfigOptions(roomList) { function configOption(id, title) { const configVal = document.ajbConfig[id]; let val = true; if (typeof(configVal) !== "undefined") { val = configVal; } const div = document.createElement("div"); const inp = document.createElement("input"); inp.type = "checkbox"; inp.id = id; inp.checked = val; inp.onchange = () => configChanged(id); const lab = document.createElement("label"); lab.innerText = title lab.setAttribute("for", id); div.appendChild(inp); div.appendChild(lab); return div; } function shortcut(key, name) { const div = document.createElement("div"); const keySpan = document.createElement("span"); keySpan.className = "ajbKey"; keySpan.innerText = key; const keyDescSpan = document.createElement("span"); keyDescSpan.className = "ajbKeyDesc"; keyDescSpan.innerText = " - " + name; div.appendChild(keySpan); div.appendChild(keyDescSpan); return div; } const configTitle = document.createElement("h2"); configTitle.innerText = "Room list configuration:"; roomList.appendChild(configTitle); roomList.appendChild( configOption("ajbShowUnreadLowPriority", "Show low-priority") ); roomList.appendChild( configOption("ajbShowReadFavourites", "Always show favourites") ); roomList.appendChild( configOption("ajbShowReadRooms", "Always show rooms") ); roomList.appendChild( configOption("ajbShowReadPeople", "Always show people") ); roomList.appendChild( configOption("ajbShowReadLowPriority", "Always show low-priority") ); const shortcutsTitle = document.createElement("h2"); shortcutsTitle.innerText = "Keyboard shortcuts:"; roomList.appendChild(shortcutsTitle); roomList.appendChild(shortcut("Ctrl-Shift-Up", "Previous room")); roomList.appendChild(shortcut("Ctrl-Shift-Down", "Next room")); roomList.appendChild(shortcut("Ctrl-Shift-Home", "First room")); const bottomSpacer = document.createElement("div"); bottomSpacer.style.height = "20px"; roomList.appendChild(bottomSpacer); } function skipThisRoom(room) { if (room.selected) { return false; } const cfg = document.ajbConfig; if (room.unread) { switch (room.sublistName) { case "Low priority": return !cfg["ajbShowUnreadLowPriority"]; } } else { switch (room.sublistName) { case "Favourites": return !cfg["ajbShowReadFavourites"]; case "Rooms": return !cfg["ajbShowReadRooms"]; case "People": return !cfg["ajbShowReadPeople"]; case "Low priority": return !cfg["ajbShowReadLowPriority"]; } } return false; } function createNewRoomList(rooms) { const ajb = document.ajb; const roomListWrapper = document.querySelector( ".mx_LeftPanel_roomListWrapper" ); if (!roomListWrapper) { return; } ajb.roomList = document.querySelector("#ajbRoomList"); if (!ajb.roomList) { ajb.roomList = document.createElement("div"); ajb.roomList.id = "ajbRoomList"; roomListWrapper.prepend(ajb.roomList); } while (ajb.roomList.firstChild) { ajb.roomList.removeChild(ajb.roomList.firstChild); } let newlySelected = null; for (const room of rooms) { if (skipThisRoom(room)) { continue; } const newTile = document.createElement("div"); newTile.className = "ajbTile"; newTile.onclick = () => { newTile.classList.add("ajbNewlySelected"); setTimeout( () => { room.roomTile.click(); setTimeout(minimalRoomList, 0); }, 0 ); }; newTile.classList.toggle("ajbSelected", room.selected); newTile.classList.toggle("ajbUnread", room.unread); newTile.classList.toggle("ajbHighlight", room.highlight); newTile.classList.toggle( "ajbLowPriority", room.sublistName === "Low priority" ); if (room.avatarImage) { const span = document.createElement("span"); span.className = "ajbRoomAvatarSpan"; const img = document.createElement("img"); img.className = "ajbRoomAvatarImg"; img.src = room.avatarImage; const initial = document.createElement("span"); initial.className = "ajbRoomAvatarInitial"; if (room.avatarInitial) { initial.innerText += room.avatarInitial; } span.appendChild(initial); span.appendChild(img); newTile.appendChild(span); } const icon = iconToDescribe(room.sublistName); if (icon) { const iconSpan = document.createElement("span"); iconSpan.className = "ajbIcon"; iconSpan.innerHTML = icon; newTile.append(iconSpan); } const titleA = document.createElement("a"); titleA.innerText = room.title; titleA.href = "#"; newTile.append(titleA); if (room.notification) { const notifSpan = document.createElement("span"); notifSpan.className = "ajbNotification"; notifSpan.classList.toggle("ajbHighlight", room.highlight); notifSpan.innerText = room.notification; newTile.append(notifSpan); } ajb.roomList.appendChild(newTile); if (room.selected) { const previousRoom = ajb.previousRoomMap[room.title]; if (previousRoom && !previousRoom.selected) { newlySelected = newTile; } } } if (newlySelected && !isElementVisible(newlySelected, ajb.roomList)) { newlySelected.scrollIntoView({"block": "center"}); } if (rooms.length === 0) { const loadingTile = document.createElement("div"); loadingTile.className = "ajbTile"; loadingTile.innerText = "Loading..." ajb.roomList.append(loadingTile); } addConfigOptions(ajb.roomList); } function minimalRoomList() { // Pull room info from the real room list const roomInfo = gatherRoomInfo(); const rooms = roomInfo.rooms; const nextRoomMap = roomInfo.nextRoomMap; if ( JSON.stringify(document.ajb.previousRoomMap) === JSON.stringify(nextRoomMap) ) { // Nothing changed: bail out return; } // Get our room info in the right order rooms.sort(cmpRooms); // Build the minimal room list from the info createNewRoomList(rooms); // Remember our state for next time if (nextRoomMap) { document.ajb.previousRoomMap = nextRoomMap; } } window.onkeydown = (e) => { if (e.shiftKey && e.ctrlKey && e.code === "ArrowUp") { e.preventDefault(); moveRoom(-1); } else if (e.shiftKey && e.ctrlKey && e.code === "ArrowDown") { e.preventDefault(); moveRoom(1); } else if (e.shiftKey && e.ctrlKey && e.code === "Home") { e.preventDefault(); firstRoom(); } } minimalRoomList(); })();