Raw Source
andybalaam / Element Web Minimal Roomlist

// ==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 "&#x1F5A4;";
        case "People": return "&#x265F;";
        case "Low priority": return "&darr;";
        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();

})();