This commit is contained in:
Bruno Windels 2018-12-21 14:35:24 +01:00
commit 0cf9e84bdd
10 changed files with 496 additions and 0 deletions

5
GOAL.md Normal file
View file

@ -0,0 +1,5 @@
goal:
to write a minimal matrix client that should you all your rooms, allows you to pick one and read and write messages in it.
on the technical side, the goal is to go low-memory, and test the performance of storing every event individually in indexeddb.

95
matrix.mjs Normal file
View file

@ -0,0 +1,95 @@
/*
idb stores:
all in one database per stored session:
- session
- device id
- last sync token
- access token
- home server
- user id
- user name
- avatar
- filter(s)?
- room_summaries
- room_id
- heroes
- room_name
- room_avatar (just the url)
- tags (account_data?)
- is_direct
- unread_message_count ?
- unread_message_with_mention ?
- roomstate_{room_id}
how about every state event gets a revision number
for each state event, we store the min and max revision number where they form part of the room state
then we "just" do a where revision_range includes revision, and every state event event/gap in the timeline we store the revision number, and we have an index on it? so we can easily look for the nearest one
it's like every state event we know about has a range where it is relevant
we want the intersection of a revision with all ranges
1 2 3 * 4 5 6
| topic | oth*er topic |
| power levels * |
| member a'1 | membe*r a'2 |
*-------- get intersection for all or some type & state_keys for revision 3 (forward) or 4 (backwards)
tricky to do a > && < in indexeddb
we'll need to do either > or < for min or max revision and iterate through the cursor and apply the rest of the conditions in code ...
all current state for last event would have max revision of some special value to indicate it hasn't been replaced yet.
the idea is that we can easily load just the state for a given event in the timeline,
can be the last synced event, or a permalink event
- members_{room_id}
historical?
- timeline_{room_id}
- search?
where to store avatars?
we could cache the requested ones in a table ...
or service worker, but won't work on my phone
*/
class Credentials {
accessToken,
deviceId
}
class LoginFlow {
constructor(network) {
}
//differentiate between next stage and Credentials?
async next(stage) {}
static async attemptPasswordLogin(username, password) {
}
}
class LoginStage {
get type() {}
serialize() {} //called by LoginFlow::next
}
class PasswordStage extends LoginStage {
set password() {
}
set username() {
}
serialize() {
return {
identifier: {
type: "m.id.user",
user: this._username
},
password: this._password
};
}
}

20
room/room.js Normal file
View file

@ -0,0 +1,20 @@
class Room {
constructor(roomId, storage) {
this._roomId = roomId;
this._storage = storage;
this._summary = new RoomSummary(this._roomId, this._storage);
}
async applyInitialSync(roomResponse, membership) {
}
async applyIncrementalSync(roomResponse, membership) {
}
async loadFromStorage() {
}
}

185
room/summary.js Normal file
View file

@ -0,0 +1,185 @@
const SUMMARY_NAME_COUNT = 3;
function disambiguateMember(name, userId) {
return `${name} (${userId})`;
}
export class RoomSummary {
constructor(roomId, storage) {
this._storage = storage;
this._members = new SummaryMembers();
this._roomId = roomId;
this._inviteCount = 0;
this._joinCount = 0;
this._calculatedName = null;
this._nameFromEvent = null;
this._lastMessageBody = null;
}
get name() {
return this._nameFromEvent || this._calculatedName;
}
get lastMessage() {
return this._lastMessageBody;
}
get inviteCount() {
return this._inviteCount;
}
get joinCount() {
return this._joinCount;
}
async applySync(roomResponse) {
const changed = this._processSyncResponse(roomResponse);
if (changed) {
await this._persist();
}
return changed;
}
async loadFromStorage() {
const summary = await storage.getSummary(this._roomId);
this._roomId = summary.roomId;
this._inviteCount = summary.inviteCount;
this._joinCount = summary.joinCount;
this._calculatedName = summary.calculatedName;
this._nameFromEvent = summary.nameFromEvent;
this._lastMessageBody = summary.lastMessageBody;
this._members = new SummaryMembers(summary.members);
}
_persist() {
const summary = {
roomId: this._roomId,
heroes: this._heroes,
inviteCount: this._inviteCount,
joinCount: this._joinCount,
calculatedName: this._calculatedName,
nameFromEvent: this._nameFromEvent,
lastMessageBody: this._lastMessageBody,
members: this._members.asArray()
};
return this.storage.saveSummary(this.room_id, summary);
}
_processSyncResponse(roomResponse) {
// lets not do lazy loading for now
// if (roomResponse.summary) {
// this._updateSummary(roomResponse.summary);
// }
let changed = false;
if (roomResponse.limited) {
changed = roomResponse.state_events.events.reduce((changed, e) => {
return this._processEvent(e) || changed;
}, changed);
}
changed = roomResponse.timeline.events.reduce((changed, e) => {
return this._processEvent(e) || changed;
}, changed);
return changed;
}
_processEvent(event) {
if (event.type === "m.room.name") {
const newName = event.content && event.content.name;
if (newName !== this._nameFromEvent) {
this._nameFromEvent = newName;
return true;
}
} else if (event.type === "m.room.member") {
return this._processMembership(event);
} else if (event.type === "m.room.message") {
const content = event.content;
const body = content && content.body;
const msgtype = content && content.msgtype;
if (msgtype === "m.text") {
this._lastMessageBody = body;
return true;
}
}
return false;
}
_processMembership(event) {
let changed = false;
const prevMembership = event.prev_content && event.prev_content.membership;
const membership = event.content && event.content.membership;
// danger of a replayed event getting the count out of sync
// but summary api will solve this.
// otherwise we'd have to store all the member ids in here
if (membership !== prevMembership) {
switch (prevMembership) {
case "invite": --this._inviteCount;
case "join": --this._joinCount;
}
switch (membership) {
case "invite": ++this._inviteCount;
case "join": ++this._joinCount;
}
changed = true;
}
if (membership === "join" && content.name) {
// TODO: avatar_url
changed = this._members.applyMember(content.name, content.state_key) || changed;
}
return changed;
}
_updateSummary(summary) {
const heroes = summary["m.heroes"];
const inviteCount = summary["m.joined_member_count"];
const joinCount = summary["m.invited_member_count"];
if (heroes) {
this._heroes = heroes;
}
if (Number.isInteger(inviteCount)) {
this._inviteCount = inviteCount;
}
if (Number.isInteger(joinCount)) {
this._joinCount = joinCount;
}
// this._recaculateNameIfNoneSet();
}
}
class SummaryMembers {
constructor(initialMembers = []) {
this._alphabeticalNames = initialMembers.map(m => m.name);
}
applyMember(name, userId) {
let insertionIndex = 0;
for (var i = this._alphabeticalNames.length - 1; i >= 0; i--) {
const cmp = this._alphabeticalNames[i].localeCompare(name);
// name is already in the list, disambiguate
if (cmp === 0) {
name = disambiguateMember(name, userId);
}
// name should come after already present name, stop
if (cmp >= 0) {
insertionIndex = i + 1;
break;
}
}
// don't append names if list is full already
if (insertionIndex < SUMMARY_NAME_COUNT) {
this._alphabeticalNames.splice(insertionIndex, 0, name);
}
if (this._alphabeticalNames > SUMMARY_NAME_COUNT) {
this._alphabeticalNames = this._alphabeticalNames.slice(0, SUMMARY_NAME_COUNT);
}
}
get names() {
return this._alphabeticalNames;
}
asArray() {
return this._alphabeticalNames.map(n => {name: n});
}
}

6
src/error.js Normal file
View file

@ -0,0 +1,6 @@
export class HomeServerError extends Error {
constructor(body) {
super(body.error);
this.errcode = body.errcode;
}
}

58
src/network.js Normal file
View file

@ -0,0 +1,58 @@
class Request {
constructor(promise, controller) {
this._promise = promise;
this._controller = controller;
}
abort() {
this._controller.abort();
}
response() {
return this._promise;
}
}
export class Network {
constructor(homeserver, accessToken) {
this._homeserver = homeserver;
this._accessToken = accessToken;
}
_url(csPath) {
return `${this._homeserver}/_matrix/client/r0/${csPath}`;
}
_request(method, csPath, queryParams = {}) {
const queryString = Object.entries(queryParams)
.filter(([name, value]) => value !== undefined)
.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`);
.join("&");
const url = this._url(`${csPath}?${queryString}`);
const request = new Request(url);
const headers = request.headers;
headers.append("Authorization", `Bearer ${this._accessToken}`);
headers.append("Accept", "application/json");
if (false/* body */) {
headers.append("Content-Type", "application/json");
}
const controller = new AbortController();
// TODO: set authenticated headers with second arguments, cache them
let promise = fetch(request, {signal: controller.signal});
promise = promise.then(response => {
if (response.ok) {
return response.json();
} else {
switch (response.status) {
default:
throw new HomeServerError(response.json())
}
}
});
return new Request(promise, controller);
}
sync(timeout = 0, since = null) {
return this._request("GET", "/sync", {since, timeout});
}
}

34
src/session.js Normal file
View file

@ -0,0 +1,34 @@
class Session {
// sessionData has device_id and access_token
constructor(sessionData) {
this._sessionData = sessionData;
}
loadFromStorage() {
// what is the PK for a session [user_id, device_id], a uuid?
}
start() {
if (!this._syncToken) {
do initial sync
}
do incremental sync
}
stop() {
if (this._initialSync) {
this._initialSync.abort();
}
if (this._incrementalSync) {
this._incrementalSync.stop();
}
}
getRoom(roomId) {
return this._rooms[roomId];
}
applySync(newRooms, syncToken, accountData) {
}
}

10
src/sync/common.js Normal file
View file

@ -0,0 +1,10 @@
export function parseRooms(responseSections, roomMapper) {
return ["join", "invite", "leave"].map(membership => {
const membershipSection = responseSections[membership];
const results = Object.entries(membershipSection).map(([roomId, roomResponse]) => {
const room = roomMapper(roomId, membership);
return room.processInitialSync(roomResponse);
});
return results;
}).reduce((allResults, sectionResults) => allResults.concat(sectionResults), []);
}

64
src/sync/incremental.js Normal file
View file

@ -0,0 +1,64 @@
import {parseRooms} from "./common";
import {RequestAbortError} from "../network";
import {HomeServerError} from "../error";
const TIMEOUT = 30;
export class IncrementalSync {
constructor(network, session, roomCreator) {
this._network = network;
this._session = session;
this._roomCreator = roomCreator;
this._isSyncing = false;
this._currentRequest = null;
}
start() {
if (this._isSyncing) {
return;
}
this._isSyncing = true;
try {
this._syncLoop(session.syncToken);
} catch(err) {
//expected when stop is called
if (err instanceof RequestAbortError) {
} else if (err instanceof HomeServerError) {
} else {
// something threw something
}
}
}
async _syncLoop(syncToken) {
while(this._isSyncing) {
this._currentRequest = this._network.sync(TIMEOUT, syncToken);
const response = await this._currentRequest.response();
syncToken = response.next_batch;
const sessionPromise = session.applySync(syncToken, response.account_data);
// to_device
// presence
const roomPromises = parseRooms(response.rooms, async (roomId, roomResponse, membership) => {
let room = session.getRoom(roomId);
if (!room) {
room = await session.createRoom(roomId);
}
return room.applyIncrementalSync(roomResponse, membership);
});
await Promise.all(roomPromises.concat(sessionPromise));
}
}
stop() {
if (!this._isSyncing) {
return;
}
this._isSyncing = false;
if (this._currentRequest) {
this._currentRequest.abort();
this._currentRequest = null;
}
}
}

19
src/sync/initial.js Normal file
View file

@ -0,0 +1,19 @@
import {parseRooms} from "./common";
// TODO make abortable
export async function initialSync(network, session) {
const response = await network.sync().response();
const rooms = await createRooms(response.rooms, session);
const sessionData = {syncToken: response.next_batch};
const accountData = response.account_data;
await session.applySync(rooms, response.next_batch, response.account_data);
}
function createRooms(responseSections, session) {
const roomPromises = parseRooms(responseSections, (roomId, roomResponse, membership) => {
const room = await session.createRoom(roomId);
await room.initialSync(roomResponse, membership);
return room;
});
return Promise.all(roomPromises);
}