mirror of
https://github.com/matrix-org/sliding-sync.git
synced 2025-03-10 13:37:11 +00:00
418 lines
15 KiB
JavaScript
418 lines
15 KiB
JavaScript
/*
|
|
* This file contains the entry point for the client, as well as DOM interactions.
|
|
*/
|
|
|
|
import {
|
|
SlidingList,
|
|
SlidingSyncConnection,
|
|
SlidingSync,
|
|
LifecycleSyncComplete,
|
|
LifecycleSyncRequestFinished,
|
|
} from "./sync.js";
|
|
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;
|
|
};
|
|
|
|
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
|
|
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;
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
// 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];
|
|
renderRoomTimeline(rooms.roomIdToRoom[slidingSync.roomSubscription], true);
|
|
// get the highlight on the room
|
|
renderLists();
|
|
// interrupt the sync to get extra state events
|
|
syncConnection.abort();
|
|
};
|
|
|
|
const renderRoomTimeline = (room, refresh) => {
|
|
if (!room) {
|
|
console.error(
|
|
"renderRoomTimeline: cannot render room timeline: unknown active room ID ",
|
|
slidingSync.roomSubscription
|
|
);
|
|
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");
|
|
const lists = {};
|
|
activeLists.forEach((al, i) => {
|
|
lists[""+i] = al;
|
|
})
|
|
slidingSync = new SlidingSync(lists, syncConnection);
|
|
slidingSync.addLifecycleListener((state, resp, err) => {
|
|
switch (state) {
|
|
// The sync has been processed and we can now re-render the UI.
|
|
case LifecycleSyncComplete:
|
|
// 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
|
|
);
|
|
break;
|
|
// 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;
|
|
}
|
|
});
|
|
|
|
slidingSync.addRoomDataListener((roomId, roomData) => {
|
|
accumulateRoomData(roomId, roomData);
|
|
// 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);
|
|
});
|
|
|
|
// begin the sliding sync loop
|
|
slidingSync.start(accessToken);
|
|
};
|
|
|
|
// Main entry point to the client is here
|
|
window.addEventListener("load", async (event) => {
|
|
// 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 /
|
|
|
|
// 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";
|
|
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);
|
|
});
|
|
|
|
// Load any stored access token.
|
|
const storedAccessToken = window.localStorage.getItem("accessToken");
|
|
if (storedAccessToken) {
|
|
document.getElementById("accessToken").value = storedAccessToken;
|
|
}
|
|
|
|
// hook up the sync button to start the sync loop
|
|
document.getElementById("syncButton").onclick = () => {
|
|
const accessToken = document.getElementById("accessToken").value;
|
|
window.localStorage.setItem("accessToken", accessToken); // remember token over refreshes
|
|
doSyncLoop(accessToken);
|
|
};
|
|
|
|
// 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++) {
|
|
// apply the room name filter to all lists
|
|
const filters = activeLists[i].getFilters();
|
|
filters.room_name_like = roomNameFilter;
|
|
activeLists[i].setFilters(filters);
|
|
}
|
|
// 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();
|
|
}
|
|
});
|
|
});
|