From 8e60b217f9602f529c240922425baede88f6b92d Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Mon, 7 Mar 2022 17:29:36 +0000 Subject: [PATCH] client: refactor how the room list is rendered --- client/devtools.js | 2 +- client/index.js | 108 ++++++++++----------- client/list.js | 28 +++++- client/render.js | 228 +++++++++++++++++++-------------------------- 4 files changed, 180 insertions(+), 186 deletions(-) diff --git a/client/devtools.js b/client/devtools.js index e0ae5ca..f4fe69b 100644 --- a/client/devtools.js +++ b/client/devtools.js @@ -139,7 +139,7 @@ export function svgify(domNode, activeLists, resp) { }); // this is expensive so only do it on smaller accounts - if (height < 500 && false) { + if (height < 500) { const fifth = horizontalPixelWidth / 5; // draw white dot for each room which has some kind of data stored activeLists.forEach((al, index) => { diff --git a/client/index.js b/client/index.js index 80f3196..35aa609 100644 --- a/client/index.js +++ b/client/index.js @@ -14,6 +14,14 @@ import * as devtools from "./devtools.js"; import * as matrix from "./matrix.js"; import { List } from "./list.js"; +const roomIdAttrPrefix = (listIndex) => { + return "room-" + listIndex + "-"; +}; + +const roomIdAttr = (listIndex, roomIndex) => { + return roomIdAttrPrefix(listIndex) + roomIndex; +}; + let syncv2ServerUrl; // will be populated with the URL of the CS API e.g 'https://matrix-client.matrix.org' let slidingSync; let syncConnection = new SlidingSyncConnection(); @@ -21,6 +29,7 @@ let syncConnection = new SlidingSyncConnection(); // The lists present on the UI. // You can add/remove these at will to experiment with sliding sync filters. let activeLists = [ + // the data model new SlidingList("Direct Messages", { is_dm: true, }), @@ -29,7 +38,8 @@ let activeLists = [ }), ]; let roomDomLists = activeLists.map((al, index) => { - return new List("room-" + index + "-", 100, (start, end) => { + // the DOM model for UI purposes. It has no reference to SlidingList at all, it just knows about DOM/UI + return new List(roomIdAttrPrefix(index), 100, (start, end) => { console.log("Intersection indexes for list ", index, ":", start, end); const bufferRange = 5; start = start - bufferRange < 0 ? 0 : start - bufferRange; @@ -45,6 +55,7 @@ let roomDomLists = activeLists.map((al, index) => { if (start < 20) { start = 20; } + // update the data model al.activeRanges[1] = [start, end]; // interrupt the sync connection to send up new ranges syncConnection.abort(); @@ -128,17 +139,10 @@ const accumulateRoomData = (r) => { rooms.roomIdToRoom[existingRoom.room_id] = existingRoom; }; -const renderMessage = (container, ev) => { - const eventIdKey = "msg" + ev.event_id; - const msgCell = render.renderEvent(eventIdKey, ev); - container.appendChild(msgCell); -}; - const onRoomClick = (e) => { let listIndex = -1; let index = -1; // walk up the pointer event path until we find a room-##-## id= - // TODO: move to render.js where these attrs are defined? const path = e.composedPath(); for (let i = 0; i < path.length; i++) { if (path[i].id && path[i].id.startsWith("room-")) { @@ -157,10 +161,7 @@ const onRoomClick = (e) => { activeLists[listIndex].roomIndexToRoomId[index]; renderRoomTimeline(rooms.roomIdToRoom[slidingSync.roomSubscription], true); // get the highlight on the room - const roomListElements = document.getElementsByClassName("roomlist"); - for (let i = 0; i < roomListElements.length; i++) { - renderList(roomListElements[i], i); - } + renderLists(); // interrupt the sync to get extra state events syncConnection.abort(); }; @@ -175,58 +176,61 @@ const renderRoomTimeline = (room, refresh) => { } const container = document.getElementById("messages"); if (refresh) { - document.getElementById("selectedroomname").textContent = ""; // wipe all message entries while (container.hasChildNodes()) { container.removeChild(container.firstChild); } } - document.getElementById("selectedroomname").textContent = - room.name || room.room_id; - if (room.avatar) { - // TODO move to render.js - document.getElementById("selectedroomavatar").src = - render.mxcToUrl(syncv2ServerUrl, room.avatar) || - "/client/placeholder.svg"; - } else { - document.getElementById("selectedroomavatar").src = - "/client/placeholder.svg"; - } - if (room.topic) { - document.getElementById("selectedroomtopic").textContent = room.topic; - } else { - document.getElementById("selectedroomtopic").textContent = ""; - } + render.renderRoomHeader(room, syncv2ServerUrl); // insert timeline messages (room.timeline || []).forEach((ev) => { - renderMessage(container, ev); + const eventIdKey = "msg" + ev.event_id; + const msgCell = render.renderEvent(eventIdKey, ev); + container.appendChild(msgCell); }); if (container.lastChild) { container.lastChild.scrollIntoView(); } }; -const renderList = (container, listIndex) => { - const listData = activeLists[listIndex]; - if (!listData) { - console.error( - "renderList(): cannot render list at index ", - listIndex, - " no data associated with this index!" - ); - return; +const renderLists = () => { + const roomListElements = document.getElementsByClassName("roomlist"); + for (let i = 0; i < roomListElements.length; i++) { + let listContainer = roomListElements[i]; + let slidingList = activeLists[i]; + let domList = roomDomLists[i]; + if (!domList || !slidingList) { + console.error( + "renderLists(): cannot render list at index ", + i, + " no data associated with this index!" + ); + continue; + } + domList.resize(listContainer, slidingList.joinedCount, (roomIndex) => { + const template = document.getElementById("roomCellTemplate"); + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template#avoiding_documentfragment_pitfall + const roomCell = template.content.firstElementChild.cloneNode(true); + roomCell.setAttribute("id", roomIdAttr(i, roomIndex)); + roomCell.addEventListener("click", onRoomClick); + return roomCell; + }); + + // loop all elements and modify the contents + for (let i = 0; i < listContainer.children.length; i++) { + const roomCell = listContainer.children[i]; + const roomId = slidingList.roomIndexToRoomId[i]; + const r = rooms.roomIdToRoom[roomId]; + render.renderRoomCell( + roomCell, + r, + i, + r ? r.room_id === slidingSync.roomSubscription : false, + syncv2ServerUrl + ); + } } - render.renderRoomList( - container, - syncv2ServerUrl, - listIndex, - listData, - slidingSync.roomSubscription, - rooms.roomIdToRoom, - roomDomLists[listIndex].intersectionObserver, - onRoomClick - ); }; const doSyncLoop = async (accessToken) => { @@ -241,11 +245,7 @@ const doSyncLoop = async (accessToken) => { // The sync has been processed and we can now re-render the UI. case LifecycleSyncComplete: // this list matches the list in activeLists - const roomListElements = - document.getElementsByClassName("roomlist"); - for (let i = 0; i < roomListElements.length; i++) { - renderList(roomListElements[i], i); - } + renderLists(); // check for duplicates and rooms outside tracked ranges which should never happen but can if there's a bug activeLists.forEach((list, listIndex) => { diff --git a/client/list.js b/client/list.js index 82c0b6c..51df2a6 100644 --- a/client/list.js +++ b/client/list.js @@ -15,7 +15,7 @@ export class List { this.debounceTimeoutId = null; this.debounceTime = debounceTime; this.idPrefix = idPrefix; - this.visibleIndexes = {}; // e.g "1-44" meaning list 1 index 44 + this.visibleIndexes = {}; // e.g "44" meaning index 44 this.intersectionObserver = new IntersectionObserver( (entries) => { @@ -50,4 +50,30 @@ export class List { } ); } + + resize(container, count, createElement) { + let addCount = 0; + let removeCount = 0; + // ensure we have the right number of children, remove or add appropriately. + while (container.childElementCount > count) { + this.intersectionObserver.unobserve(container.lastChild); + container.removeChild(container.lastChild); + removeCount += 1; + } + for (let i = container.childElementCount; i < count; i++) { + const cell = createElement(i); + container.appendChild(cell); + this.intersectionObserver.observe(cell); + addCount += 1; + } + if (addCount > 0 || removeCount > 0) { + console.log( + "resize: added ", + addCount, + "nodes, removed", + removeCount, + "nodes" + ); + } + } } diff --git a/client/render.js b/client/render.js index d036e75..309d329 100644 --- a/client/render.js +++ b/client/render.js @@ -85,10 +85,6 @@ const randomName = (i, long) => { } }; -const roomIdAttr = (listIndex, roomIndex) => { - return "room-" + listIndex + "-" + roomIndex; -}; - const zeroPad = (n) => { if (n < 10) { return "0" + n; @@ -109,7 +105,7 @@ const formatTimestamp = (originServerTs) => { ); }; -export const mxcToUrl = (syncv2ServerUrl, mxc) => { +const mxcToUrl = (syncv2ServerUrl, mxc) => { const path = mxc.substr("mxc://".length); if (!path) { return; @@ -117,6 +113,23 @@ export const mxcToUrl = (syncv2ServerUrl, mxc) => { return `${syncv2ServerUrl}/_matrix/media/r0/thumbnail/${path}?width=64&height=64&method=crop`; }; +export const renderRoomHeader = (room, syncv2ServerUrl) => { + document.getElementById("selectedroomname").textContent = + room.name || room.room_id; + if (room.avatar) { + document.getElementById("selectedroomavatar").src = + mxcToUrl(syncv2ServerUrl, room.avatar) || "/client/placeholder.svg"; + } else { + document.getElementById("selectedroomavatar").src = + "/client/placeholder.svg"; + } + if (room.topic) { + document.getElementById("selectedroomtopic").textContent = room.topic; + } else { + document.getElementById("selectedroomtopic").textContent = ""; + } +}; + export const renderEvent = (eventIdKey, ev) => { const template = document.getElementById("messagetemplate"); // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template#avoiding_documentfragment_pitfall @@ -131,138 +144,93 @@ export const renderEvent = (eventIdKey, ev) => { }; /** - * Render the room list in the given `container`. This is read-only and does not modify internal data structures. - * @param {Element} container The DOM node to attach the list to. - * @param {string} syncv2ServerUrl The CS API URL. Used for tags for room avatars. - * @param {Number} listIndex The index position of this list. Used for setting non-clashing ID attributes. - * @param {SlidingList} slidingList The list to render. - * @param {string} highlightedRoomID The room being viewed currently. - * @param {object} roomIdToRoom The data store containing the room data. - * @param {IntersectionObserver} intersectionObserver The intersection observer for observing scroll positions. - * @param {function} onRoomClick The DOM callback to invoke when a room cell is clicked. + * Render a room cell for the room list. + * @param {Element} roomCell The DOM element to put the details into. The cell must be already initialised with `roomCellTemplate`. + * @param {object} room The room data model, which can be null to indicate a placeholder. */ -export const renderRoomList = ( - container, - syncv2ServerUrl, - listIndex, - slidingList, - highlightedRoomID, - roomIdToRoom, - intersectionObserver, - onRoomClick +export const renderRoomCell = ( + roomCell, + room, + index, + isHighlighted, + syncv2ServerUrl ) => { - let addCount = 0; - let removeCount = 0; - // ensure we have the right number of children, remove or add appropriately. - while (container.childElementCount > slidingList.joinedCount) { - intersectionObserver.unobserve(container.lastChild); - container.removeChild(container.lastChild); - removeCount += 1; + // if this child is a placeholder and it was previously a placeholder then do nothing. + if (!room && roomCell.getAttribute("x-placeholder") === "yep") { + return; } - for ( - let i = container.childElementCount; - i < slidingList.joinedCount; - i++ - ) { - const template = document.getElementById("roomCellTemplate"); - // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template#avoiding_documentfragment_pitfall - const roomCell = template.content.firstElementChild.cloneNode(true); - roomCell.setAttribute("id", roomIdAttr(listIndex, i)); - container.appendChild(roomCell); - intersectionObserver.observe(roomCell); - roomCell.addEventListener("click", onRoomClick); - addCount += 1; - } - if (addCount > 0 || removeCount > 0) { - console.log( - "render: added ", - addCount, - "nodes, removed", - removeCount, - "nodes" - ); - } - // loop all elements and modify the contents - for (let i = 0; i < container.children.length; i++) { - const roomCell = container.children[i]; - const roomId = slidingList.roomIndexToRoomId[i]; - const r = roomIdToRoom[roomId]; - // if this child is a placeholder and it was previously a placeholder then do nothing. - if (!r && roomCell.getAttribute("x-placeholder") === "yep") { - continue; - } - const roomNameSpan = roomCell.getElementsByClassName("roomname")[0]; - const roomContentSpan = - roomCell.getElementsByClassName("roomcontent")[0]; - const roomSenderSpan = roomCell.getElementsByClassName("roomsender")[0]; - const roomTimestampSpan = - roomCell.getElementsByClassName("roomtimestamp")[0]; - const unreadCountSpan = - roomCell.getElementsByClassName("unreadcount")[0]; - unreadCountSpan.textContent = ""; - unreadCountSpan.classList.remove("unreadcountnotify"); - unreadCountSpan.classList.remove("unreadcounthighlight"); - if (!r) { - // placeholder - roomNameSpan.textContent = randomName(i, false); - roomNameSpan.style = "background: #e0e0e0; color: #e0e0e0;"; - roomContentSpan.textContent = randomName(i, true); - roomContentSpan.style = "background: #e0e0e0; color: #e0e0e0;"; - roomSenderSpan.textContent = ""; - roomTimestampSpan.textContent = ""; - roomCell.getElementsByClassName("roomavatar")[0].src = - "/client/placeholder.svg"; - roomCell.style = ""; - roomCell.setAttribute("x-placeholder", "yep"); - continue; - } - roomCell.removeAttribute("x-placeholder"); + const roomNameSpan = roomCell.getElementsByClassName("roomname")[0]; + const roomContentSpan = roomCell.getElementsByClassName("roomcontent")[0]; + const roomSenderSpan = roomCell.getElementsByClassName("roomsender")[0]; + const roomTimestampSpan = + roomCell.getElementsByClassName("roomtimestamp")[0]; + const unreadCountSpan = roomCell.getElementsByClassName("unreadcount")[0]; + + // remove previous unread counts + unreadCountSpan.textContent = ""; + unreadCountSpan.classList.remove("unreadcountnotify"); + unreadCountSpan.classList.remove("unreadcounthighlight"); + + if (!room) { + // make a placeholder + roomNameSpan.textContent = randomName(index, false); + roomNameSpan.style = "background: #e0e0e0; color: #e0e0e0;"; + roomContentSpan.textContent = randomName(index, true); + roomContentSpan.style = "background: #e0e0e0; color: #e0e0e0;"; + roomSenderSpan.textContent = ""; + roomTimestampSpan.textContent = ""; + roomCell.getElementsByClassName("roomavatar")[0].src = + "/client/placeholder.svg"; roomCell.style = ""; - roomNameSpan.textContent = r.name || r.room_id; - roomNameSpan.style = ""; - roomContentSpan.style = ""; - if (r.avatar) { - roomCell.getElementsByClassName("roomavatar")[0].src = - mxcToUrl(syncv2ServerUrl, r.avatar) || - "/client/placeholder.svg"; - } else { - roomCell.getElementsByClassName("roomavatar")[0].src = - "/client/placeholder.svg"; - } - if (roomId === highlightedRoomID) { - roomCell.style = "background: #d7d7f7"; - } - if (r.highlight_count > 0) { - // use the notification count instead to avoid counts dropping down. This matches ele-web - unreadCountSpan.textContent = r.notification_count + ""; - unreadCountSpan.classList.add("unreadcounthighlight"); - } else if (r.notification_count > 0) { - unreadCountSpan.textContent = r.notification_count + ""; - unreadCountSpan.classList.add("unreadcountnotify"); - } else { - unreadCountSpan.textContent = ""; - } + roomCell.setAttribute("x-placeholder", "yep"); + return; + } - if (r.obsolete) { - roomContentSpan.textContent = ""; - roomSenderSpan.textContent = r.obsolete; - } else if (r.timeline && r.timeline.length > 0) { - const mostRecentEvent = r.timeline[r.timeline.length - 1]; - roomSenderSpan.textContent = mostRecentEvent.sender; - // TODO: move to render.js - roomTimestampSpan.textContent = formatTimestamp( - mostRecentEvent.origin_server_ts - ); + roomCell.removeAttribute("x-placeholder"); // in case this was previously a placeholder + roomCell.style = ""; + roomNameSpan.textContent = room.name || room.room_id; + roomNameSpan.style = ""; + roomContentSpan.style = ""; + if (room.avatar) { + roomCell.getElementsByClassName("roomavatar")[0].src = + mxcToUrl(syncv2ServerUrl, room.avatar) || "/client/placeholder.svg"; + } else { + roomCell.getElementsByClassName("roomavatar")[0].src = + "/client/placeholder.svg"; + } + if (isHighlighted) { + roomCell.style = "background: #d7d7f7"; + } + if (room.highlight_count > 0) { + // use the notification count instead to avoid counts dropping down. This matches ele-web + unreadCountSpan.textContent = room.notification_count + ""; + unreadCountSpan.classList.add("unreadcounthighlight"); + } else if (room.notification_count > 0) { + unreadCountSpan.textContent = room.notification_count + ""; + unreadCountSpan.classList.add("unreadcountnotify"); + } else { + unreadCountSpan.textContent = ""; + } - const body = textForEvent(mostRecentEvent); - if (mostRecentEvent.type === "m.room.member") { - roomContentSpan.textContent = ""; - roomSenderSpan.textContent = body; - } else { - roomContentSpan.textContent = body; - } - } else { + if (room.obsolete) { + roomContentSpan.textContent = ""; + roomSenderSpan.textContent = room.obsolete; + } else if (room.timeline && room.timeline.length > 0) { + const mostRecentEvent = room.timeline[room.timeline.length - 1]; + roomSenderSpan.textContent = mostRecentEvent.sender; + + roomTimestampSpan.textContent = formatTimestamp( + mostRecentEvent.origin_server_ts + ); + + const body = textForEvent(mostRecentEvent); + if (mostRecentEvent.type === "m.room.member") { roomContentSpan.textContent = ""; + roomSenderSpan.textContent = body; + } else { + roomContentSpan.textContent = body; } + } else { + roomContentSpan.textContent = ""; } };