sliding-sync/client/index.js

418 lines
15 KiB
JavaScript
Raw Normal View History

/*
* This file contains the entry point for the client, as well as DOM interactions.
*/
import {
SlidingList,
SlidingSyncConnection,
SlidingSync,
LifecycleSyncComplete,
LifecycleSyncRequestFinished,
} from "./sync.js";
2022-02-23 19:26:38 +00:00
import * as render from "./render.js";
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;
};
2022-02-24 14:28:05 +00:00
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();
// 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,
}),
new SlidingList("Group Chats", {
is_dm: false,
}),
];
let roomDomLists = activeLists.map((al, index) => {
// 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;
end =
end + bufferRange >= al.joinedCount
? al.joinedCount - 1
: end + bufferRange;
// we don't need to request rooms between 0,20 as we always have a filter for this
if (end <= 20) {
return;
}
// ensure we don't overlap with the 0,20 range
2022-12-15 10:51:29 +00:00
if (start <= 20) {
start = 21;
}
// update the data model
al.activeRanges[1] = [start, end];
// interrupt the sync connection to send up new ranges
syncConnection.abort();
});
});
// this is the main data structure the client uses to remember and render rooms. Attach it to
// the window to allow easy introspection.
let rooms = {
// this map is never deleted and forms the persistent storage of the client
roomIdToRoom: {},
};
window.rooms = rooms;
window.activeLists = activeLists;
2022-02-24 14:28:05 +00:00
/**
* Set top-level properties on the response which corresponds to state fields the UI requires.
* The normal response returns whole state events in an array which isn't useful when rendering as we
* often just need a single key within the content, so pre-emptively find the fields we want and set them.
* Modifies the input object.
* @param {object} r The room response JSON
*/
const setRoomStateFields = (r) => {
if (!r.required_state) {
return;
}
for (let i = 0; i < r.required_state.length; i++) {
const ev = r.required_state[i];
switch (ev.type) {
case "m.room.avatar":
r.avatar = ev.content.url;
break;
case "m.room.topic":
r.topic = ev.content.topic;
break;
case "m.room.tombstone":
r.obsolete = ev.content.body || "m.room.tombstone";
break;
2021-10-07 16:58:03 +01:00
}
}
};
/**
* Accumulate room data for this room. This is done by inspecting the 'initial' flag on the response.
* If it is set, the entire room is replaced with this data. If it is false, the room data is
* merged together with whatever is there in the store.
* @param {string} roomId The room ID for this room
* @param {object} r The room response JSON
*/
const accumulateRoomData = (roomId, r) => {
r.room_id = roomId;
setRoomStateFields(r);
if (r.initial) {
rooms.roomIdToRoom[r.room_id] = r;
return;
2021-10-07 17:21:25 +01:00
}
// use what we already have, if any
let existingRoom = rooms.roomIdToRoom[r.room_id];
if (existingRoom) {
if (r.name) {
existingRoom.name = r.name;
}
if (r.highlight_count !== undefined) {
existingRoom.highlight_count = r.highlight_count;
}
if (r.notification_count !== undefined) {
existingRoom.notification_count = r.notification_count;
}
if (r.timeline) {
r.timeline.forEach((e) => {
existingRoom.timeline.push(e);
});
}
} else {
// we don't know this room but apparently we should. Whine about it and use what we have.
console.error(
"room initial flag not set but we have no memory of this room",
r
);
existingRoom = r;
}
rooms.roomIdToRoom[existingRoom.room_id] = existingRoom;
};
const onRoomClick = (e) => {
let listIndex = -1;
let index = -1;
// walk up the pointer event path until we find a room-##-## id=
const path = e.composedPath();
for (let i = 0; i < path.length; i++) {
if (path[i].id && path[i].id.startsWith("room-")) {
const indexes = path[i].id.substr("room-".length).split("-");
listIndex = Number(indexes[0]);
index = Number(indexes[1]);
break;
}
}
if (index === -1) {
console.log("failed to find room for onclick");
return;
}
// assign room subscription
slidingSync.roomSubscription =
activeLists[listIndex].roomIndexToRoomId[index];
2022-02-24 14:28:05 +00:00
renderRoomTimeline(rooms.roomIdToRoom[slidingSync.roomSubscription], true);
// get the highlight on the room
renderLists();
2021-10-07 16:58:03 +01:00
// interrupt the sync to get extra state events
syncConnection.abort();
};
2022-02-24 14:28:05 +00:00
const renderRoomTimeline = (room, refresh) => {
if (!room) {
2022-02-23 19:26:38 +00:00
console.error(
2022-02-24 14:28:05 +00:00
"renderRoomTimeline: cannot render room timeline: unknown active room ID ",
slidingSync.roomSubscription
2022-02-23 19:26:38 +00:00
);
return;
}
const container = document.getElementById("messages");
if (refresh) {
// wipe all message entries
while (container.hasChildNodes()) {
container.removeChild(container.firstChild);
}
}
render.renderRoomHeader(room, syncv2ServerUrl);
// insert timeline messages
(room.timeline || []).forEach((ev) => {
const eventIdKey = "msg" + ev.event_id;
const msgCell = render.renderEvent(eventIdKey, ev);
container.appendChild(msgCell);
});
if (container.lastChild) {
container.lastChild.scrollIntoView();
}
};
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
);
}
}
};
const doSyncLoop = async (accessToken) => {
if (slidingSync) {
console.log("Terminating old loop");
slidingSync.stop();
}
console.log("Starting sync loop");
2023-02-13 17:13:42 +00:00
const lists = {};
activeLists.forEach((al, i) => {
lists[""+i] = al;
})
slidingSync = new SlidingSync(lists, syncConnection);
slidingSync.addLifecycleListener((state, resp, err) => {
switch (state) {
2022-02-24 14:28:05 +00:00
// The sync has been processed and we can now re-render the UI.
case LifecycleSyncComplete:
2022-02-24 15:03:10 +00:00
// this list matches the list in activeLists
renderLists();
// check for duplicates and rooms outside tracked ranges which should never happen but can if there's a bug
activeLists.forEach((list, listIndex) => {
let roomIdToPositions = {};
let dupeRoomIds = new Set();
let indexesOutsideRanges = new Set();
Object.keys(list.roomIndexToRoomId).forEach((i) => {
let rid = list.roomIndexToRoomId[i];
if (!rid) {
return;
}
let positions = roomIdToPositions[rid] || [];
positions.push(i);
roomIdToPositions[rid] = positions;
if (positions.length > 1) {
dupeRoomIds.add(rid);
}
let isInsideRange = false;
list.activeRanges.forEach((r) => {
if (i >= r[0] && i <= r[1]) {
isInsideRange = true;
}
});
if (!isInsideRange) {
indexesOutsideRanges.add(i);
}
});
dupeRoomIds.forEach((rid) => {
console.log(
rid,
"in list",
listIndex,
"has duplicate indexes:",
roomIdToPositions[rid]
);
});
if (indexesOutsideRanges.size > 0) {
console.log(
"list",
listIndex,
"tracking indexes outside of tracked ranges:",
JSON.stringify([...indexesOutsideRanges])
);
}
});
devtools.svgify(
document.getElementById("listgraph"),
activeLists,
resp
2022-02-23 19:26:38 +00:00
);
break;
2022-02-24 14:28:05 +00:00
// A sync request has been finished, possibly with an error.
case LifecycleSyncRequestFinished:
if (err) {
console.error("/sync failed:", err);
document.getElementById("errorMsg").textContent = err;
} else {
document.getElementById("errorMsg").textContent = "";
}
break;
}
});
2022-02-24 14:28:05 +00:00
slidingSync.addRoomDataListener((roomId, roomData) => {
accumulateRoomData(roomId, roomData);
2022-02-24 14:28:05 +00:00
// render the right-hand side section with the room timeline if we are viewing it.
if (roomId !== slidingSync.roomSubscription) {
return;
}
let room = rooms.roomIdToRoom[slidingSync.roomSubscription];
renderRoomTimeline(room, roomData.initial);
});
2022-02-24 15:03:10 +00:00
// begin the sliding sync loop
slidingSync.start(accessToken);
};
2022-02-22 18:24:35 +00:00
2022-02-24 14:28:05 +00:00
// Main entry point to the client is here
window.addEventListener("load", async (event) => {
2022-02-24 14:28:05 +00:00
// Download the base CS API server URL from the sliding sync proxy.
// We need to know the base URL for media requests, sending events, etc.
const v2ServerResp = await fetch("./server.json");
const syncv2ServerJson = await v2ServerResp.json();
if (!syncv2ServerJson || !syncv2ServerJson.server) {
console.error("failed to fetch v2 server url: ", v2ServerResp);
return;
}
syncv2ServerUrl = syncv2ServerJson.server.replace(/\/$/, ""); // remove trailing /
2022-02-24 14:28:05 +00:00
// Dynamically create the room lists based on the `activeLists` variable.
// This exists to allow developers to experiment with different lists and filters.
const container = document.getElementById("roomlistcontainer");
activeLists.forEach((list) => {
const roomList = document.createElement("div");
roomList.className = "roomlist";
2021-11-11 13:00:32 +00:00
const roomListName = document.createElement("div");
roomListName.className = "roomlistname";
roomListName.textContent = list.name;
const roomListWrapper = document.createElement("div");
roomListWrapper.className = "roomlistwrapper";
roomListWrapper.appendChild(roomListName);
roomListWrapper.appendChild(roomList);
container.appendChild(roomListWrapper);
});
2022-02-24 14:28:05 +00:00
// Load any stored access token.
const storedAccessToken = window.localStorage.getItem("accessToken");
if (storedAccessToken) {
document.getElementById("accessToken").value = storedAccessToken;
}
2022-02-24 14:28:05 +00:00
// hook up the sync button to start the sync loop
document.getElementById("syncButton").onclick = () => {
const accessToken = document.getElementById("accessToken").value;
2022-02-24 14:28:05 +00:00
window.localStorage.setItem("accessToken", accessToken); // remember token over refreshes
doSyncLoop(accessToken);
2021-10-22 15:00:57 +01:00
};
2022-02-24 14:28:05 +00:00
// hook up the room filter so it filters as the user types
document.getElementById("roomfilter").addEventListener("input", (ev) => {
const roomNameFilter = ev.target.value;
for (let i = 0; i < activeLists.length; i++) {
2022-02-24 14:28:05 +00:00
// apply the room name filter to all lists
const filters = activeLists[i].getFilters();
filters.room_name_like = roomNameFilter;
activeLists[i].setFilters(filters);
}
2022-02-24 14:28:05 +00:00
// bump to the start of the room list again. We need to do this to ensure the UI displays correctly.
const lists = document.getElementsByClassName("roomlist");
for (let i = 0; i < lists.length; i++) {
if (lists[i].firstChild) {
lists[i].firstChild.scrollIntoView(true);
}
}
// interrupt the sync request to send up new filters
syncConnection.abort();
});
// hook up the send message input
document
.getElementById("sendmessageinput")
.addEventListener("keydown", async (ev) => {
if (ev.key == "Enter") {
ev.target.setAttribute("disabled", "");
const msg = ev.target.value;
try {
await matrix.sendMessage(
syncv2ServerUrl,
document.getElementById("accessToken").value,
slidingSync.roomSubscription,
msg
);
ev.target.value = "";
} catch (err) {
document.getElementById("errorMsg").textContent =
"Error sending message: " + err;
}
ev.target.removeAttribute("disabled");
ev.target.focus();
}
});
});