Merge pull request #5 from bwindels/bwindels/sending

Send text messages
This commit is contained in:
Bruno Windels 2019-07-29 18:28:29 +00:00 committed by GitHub
commit 378eea8ceb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
49 changed files with 1011 additions and 81 deletions

View file

@ -3,3 +3,6 @@ goal:
write client that works on lumia 950 phone, so I can use matrix on my phone. write client that works on lumia 950 phone, so I can use matrix on my phone.
try approach offline to indexeddb. go low-memory, and test the performance of storing every event individually in indexeddb. try approach offline to indexeddb. go low-memory, and test the performance of storing every event individually in indexeddb.
try to use little bandwidth, mainly by being an offline application and storing all requested data in indexeddb.
be as functional as possible while offline

View file

@ -1,3 +1,11 @@
# Remaining stuffs
- don't swallow send errors, they should probably appear in the room error?
- not sure it makes sense to show them where the composer is,
because they might get sent a long time after you enter them in brawl,
so you don't neccessarily have the context of the composer anymore
- local echo
takes care of rate limiting, takes care of rate limiting,
and sending events from different rooms in parallel, and sending events from different rooms in parallel,
NO: txnIds are created inside room. ~~making txnIds? ... it's rooms though that will receive the event in their sync response~~ NO: txnIds are created inside room. ~~making txnIds? ... it's rooms though that will receive the event in their sync response~~
@ -76,8 +84,36 @@ steps of sending
// sender is the thing that is shared across rooms to handle rate limiting. // sender is the thing that is shared across rooms to handle rate limiting.
const sendQueue = new SendQueue({roomId, hsApi, sender, storage}); const sendQueue = new SendQueue({roomId, hsApi, sender, storage});
await sendQueue.load(); //loads the queue? await sendQueue.load(); //loads the queue?
//might need to load members for e2e rooms //might need to load members for e2e rooms ...
//events should be encrypted before storing them though ...
// terminology ...?
// task: to let us wait for it to be our turn
// given rate limiting
class Sender {
acquireSlot() {
return new SendSlot();
}
}
// terminology ...?
// task: after waiting for it to be our turn given rate-limiting,
// send the actual thing we want to send.
// this should be used for all rate-limited apis... ?
class SendSlot {
sendContent(content) {
}
sendRedaction() {
}
uploadMedia() {
}
}
class SendQueue { class SendQueue {
// when trying to send // when trying to send
enqueueEvent(pendingEvent) { enqueueEvent(pendingEvent) {
@ -93,11 +129,13 @@ steps of sending
while (let pendingEvent = await findNextPendingEvent()) { while (let pendingEvent = await findNextPendingEvent()) {
pendingEvent.status = QUEUED; pendingEvent.status = QUEUED;
try { try {
await this.sender.sendEvent(() => { const mediaSlot = await this.sender.acquireSlot();
// callback gets called const mxcUrl = await mediaSlot.uploadMedia(pendingEvent.blob);
pendingEvent.status = SENDING; pendingEvent.content.url = mxcUrl;
return pendingEvent; const contentSlot = await this.sender.acquireSlot();
}); contentSlot.sendContent(pendingEvent.content);
pendingEvent.status = SENDING;
await slot.sendContent(...);
} catch (err) { } catch (err) {
//offline //offline
} }

View file

@ -1,5 +1,5 @@
// #ifdef PLATFORM_GNOME //#ifdef PLATFORM_GNOME
// export {default} from "./ui/gnome/GnomePlatform.js"; //##export {default} from "./ui/gnome/GnomePlatform.js";
// #else //#else
export {default} from "./ui/web/WebPlatform.js"; export {default} from "./ui/web/WebPlatform.js";
// #endif //#endif

View file

@ -45,7 +45,7 @@ export default class SessionViewModel extends EventEmitter {
} }
this._currentRoomViewModel = new RoomViewModel({ this._currentRoomViewModel = new RoomViewModel({
room, room,
ownUserId: this._session.userId, ownUserId: this._session.user.id,
closeCallback: () => this._closeCurrentRoom(), closeCallback: () => this._closeCurrentRoom(),
}); });
this._currentRoomViewModel.load(); this._currentRoomViewModel.load();

View file

@ -63,4 +63,10 @@ export default class RoomViewModel extends EventEmitter {
get avatarInitials() { get avatarInitials() {
return avatarInitials(this._room.name); return avatarInitials(this._room.name);
} }
sendMessage(message) {
if (message) {
this._room.sendEvent("m.room.message", {msgtype: "m.text", body: message});
}
}
} }

View file

@ -4,8 +4,8 @@ export default class MessageTile extends SimpleTile {
constructor(options) { constructor(options) {
super(options); super(options);
this._isOwn = this._entry.event.sender === options.ownUserId; this._isOwn = this._entry.sender === options.ownUserId;
this._date = new Date(this._entry.event.origin_server_ts); this._date = new Date(this._entry.timestamp);
this._isContinuation = false; this._isContinuation = false;
} }
@ -14,7 +14,7 @@ export default class MessageTile extends SimpleTile {
} }
get sender() { get sender() {
return this._entry.event.sender; return this._entry.sender;
} }
get date() { get date() {
@ -34,8 +34,7 @@ export default class MessageTile extends SimpleTile {
} }
_getContent() { _getContent() {
const event = this._entry.event; return this._entry.content;
return event && event.content;
} }
updatePreviousSibling(prev) { updatePreviousSibling(prev) {

View file

@ -7,21 +7,20 @@ export default class RoomNameTile extends SimpleTile {
} }
get announcement() { get announcement() {
const event = this._entry.event; const {sender, content, stateKey} = this._entry;
const content = event.content;
switch (content.membership) { switch (content.membership) {
case "invite": return `${event.state_key} was invited to the room by ${event.sender}`; case "invite": return `${stateKey} was invited to the room by ${sender}`;
case "join": return `${event.state_key} joined the room`; case "join": return `${stateKey} joined the room`;
case "leave": { case "leave": {
if (event.state_key === event.sender) { if (stateKey === sender) {
return `${event.state_key} left the room`; return `${stateKey} left the room`;
} else { } else {
const reason = content.reason; const reason = content.reason;
return `${event.state_key} was kicked from the room by ${event.sender}${reason ? `: ${reason}` : ""}`; return `${stateKey} was kicked from the room by ${sender}${reason ? `: ${reason}` : ""}`;
} }
} }
case "ban": return `${event.state_key} was banned from the room by ${event.sender}`; case "ban": return `${stateKey} was banned from the room by ${sender}`;
default: return `${event.sender} membership changed to ${content.membership}`; default: return `${sender} membership changed to ${content.membership}`;
} }
} }
} }

View file

@ -7,8 +7,7 @@ export default class RoomNameTile extends SimpleTile {
} }
get announcement() { get announcement() {
const event = this._entry.event; const content = this._entry.content;
const content = event.content; return `${this._entry.sender} named the room "${content.name}"`
return `${event.sender} named the room "${content.name}"`
} }
} }

View file

@ -69,4 +69,8 @@ export default class SimpleTile {
get internalId() { get internalId() {
return this._entry.asEventKey().toString(); return this._entry.asEventKey().toString();
} }
get isPending() {
return this._entry.isPending;
}
} }

View file

@ -4,9 +4,8 @@ export default class TextTile extends MessageTile {
get text() { get text() {
const content = this._getContent(); const content = this._getContent();
const body = content && content.body; const body = content && content.body;
const sender = this._entry.event.sender; if (content.msgtype === "m.emote") {
if (this._entry.type === "m.emote") { return `* ${this._entry.sender} ${body}`;
return `* ${sender} ${body}`;
} else { } else {
return body; return body;
} }

View file

@ -1,6 +1,5 @@
import GapTile from "./tiles/GapTile.js"; import GapTile from "./tiles/GapTile.js";
import TextTile from "./tiles/TextTile.js"; import TextTile from "./tiles/TextTile.js";
import ImageTile from "./tiles/ImageTile.js";
import LocationTile from "./tiles/LocationTile.js"; import LocationTile from "./tiles/LocationTile.js";
import RoomNameTile from "./tiles/RoomNameTile.js"; import RoomNameTile from "./tiles/RoomNameTile.js";
import RoomMemberTile from "./tiles/RoomMemberTile.js"; import RoomMemberTile from "./tiles/RoomMemberTile.js";
@ -10,11 +9,10 @@ export default function ({timeline, ownUserId}) {
const options = {entry, emitUpdate, ownUserId}; const options = {entry, emitUpdate, ownUserId};
if (entry.isGap) { if (entry.isGap) {
return new GapTile(options, timeline); return new GapTile(options, timeline);
} else if (entry.event) { } else if (entry.eventType) {
const event = entry.event; switch (entry.eventType) {
switch (event.type) {
case "m.room.message": { case "m.room.message": {
const content = event.content; const content = entry.content;
const msgtype = content && content.msgtype; const msgtype = content && content.msgtype;
switch (msgtype) { switch (msgtype) {
case "m.text": case "m.text":

View file

@ -5,7 +5,7 @@ import Sync from "./matrix/sync.js";
import SessionView from "./ui/web/session/SessionView.js"; import SessionView from "./ui/web/session/SessionView.js";
import SessionViewModel from "./domain/session/SessionViewModel.js"; import SessionViewModel from "./domain/session/SessionViewModel.js";
const HOST = "192.168.2.108"; const HOST = "127.0.0.1";
const HOMESERVER = `http://${HOST}:8008`; const HOMESERVER = `http://${HOST}:8008`;
const USERNAME = "bruno1"; const USERNAME = "bruno1";
const USER_ID = `@${USERNAME}:localhost`; const USER_ID = `@${USERNAME}:localhost`;
@ -76,6 +76,8 @@ export default async function main(container) {
if (needsInitialSync) { if (needsInitialSync) {
showSession(container, session, sync); showSession(container, session, sync);
} }
// this will start sending unsent messages
session.notifyNetworkAvailable();
} catch(err) { } catch(err) {
console.error(`${err.message}:\n${err.stack}`); console.error(`${err.message}:\n${err.stack}`);
} }

121
src/matrix/SendScheduler.js Normal file
View file

@ -0,0 +1,121 @@
import Platform from "../Platform.js";
import {HomeServerError, NetworkError} from "./error.js";
export class RateLimitingBackoff {
constructor() {
this._remainingRateLimitedRequest = 0;
}
async waitAfterLimitExceeded(retryAfterMs) {
// this._remainingRateLimitedRequest = 5;
// if (typeof retryAfterMs !== "number") {
// } else {
// }
if (!retryAfterMs) {
retryAfterMs = 5000;
}
await Platform.delay(retryAfterMs);
}
// do we have to know about succeeding requests?
// we can just
async waitForNextSend() {
// this._remainingRateLimitedRequest = Math.max(0, this._remainingRateLimitedRequest - 1);
}
}
/*
this represents a slot to do one rate limited api call.
because rate-limiting is handled here, it should only
try to do one call, so the SendScheduler can safely
retry if the call ends up being rate limited.
This is also why we have this abstraction it hsApi is not
passed straight to SendQueue when it is its turn to send.
e.g. we wouldn't want to repeat the callback in SendQueue that could
have other side-effects before the call to hsApi that we wouldn't want
repeated (setting up progress handlers for file uploads,
... a UI update to say it started sending?
... updating storage would probably only happen once the call succeeded
... doing multiple hsApi calls for e.g. a file upload before sending a image message (they should individually be retried)
) maybe it is a bit overengineering, but lets stick with it for now.
At least the above is a clear definition why we have this class
*/
//class SendSlot -- obsolete
export class SendScheduler {
constructor({hsApi, backoff}) {
this._hsApi = hsApi;
this._sendRequests = [];
this._sendScheduled = false;
this._offline = false;
this._waitTime = 0;
this._backoff = backoff;
/*
we should have some sort of flag here that we enable
after all the rooms have been notified that they can resume
sending, so that from session, we can say scheduler.enable();
this way, when we have better scheduling, it won't be first come,
first serve, when there are a lot of events in different rooms to send,
but we can apply some priorization of who should go first
*/
// this._enabled;
}
// this should really be per roomId to avoid head-of-line blocking
//
// takes a callback instead of returning a promise with the slot
// to make sure the scheduler doesn't get blocked by a slot that is not consumed
request(sendCallback) {
let request;
const promise = new Promise((resolve, reject) => request = {resolve, reject, sendCallback});
this._sendRequests.push(request);
if (!this._sendScheduled && !this._offline) {
this._sendLoop();
}
return promise;
}
async _sendLoop() {
while (this._sendRequests.length) {
const request = this._sendRequests.shift();
let result;
try {
// this can throw!
result = await this._doSend(request.sendCallback);
} catch (err) {
if (err instanceof NetworkError) {
// we're offline, everybody will have
// to re-request slots when we come back online
this._offline = true;
for (const r of this._sendRequests) {
r.reject(err);
}
this._sendRequests = [];
}
console.error("error for request", request);
request.reject(err);
break;
}
request.resolve(result);
}
// do next here instead of in _doSend
}
async _doSend(sendCallback) {
this._sendScheduled = false;
await this._backoff.waitForNextSend();
// loop is left by return or throw
while (true) { // eslint-disable-line no-constant-condition
try {
return await sendCallback(this._hsApi);
} catch (err) {
if (err instanceof HomeServerError && err.errcode === "M_LIMIT_EXCEEDED") {
await this._backoff.waitAfterLimitExceeded(err.retry_after_ms);
} else {
throw err;
}
}
}
}
}

9
src/matrix/User.js Normal file
View file

@ -0,0 +1,9 @@
export default class User {
constructor(userId) {
this._userId = userId;
}
get id() {
return this._userId;
}
}

View file

@ -2,6 +2,13 @@ export class HomeServerError extends Error {
constructor(method, url, body) { constructor(method, url, body) {
super(`${body.error} on ${method} ${url}`); super(`${body.error} on ${method} ${url}`);
this.errcode = body.errcode; this.errcode = body.errcode;
this.retry_after_ms = body.retry_after_ms;
}
get isFatal() {
switch (this.errcode) {
}
} }
} }

View file

@ -32,6 +32,7 @@ class RequestWrapper {
} }
} }
// todo: everywhere here, encode params in the url that could have slashes ... mainly event ids?
export default class HomeServerApi { export default class HomeServerApi {
constructor(homeserver, accessToken) { constructor(homeserver, accessToken) {
// store these both in a closure somehow so it's harder to get at in case of XSS? // store these both in a closure somehow so it's harder to get at in case of XSS?
@ -98,6 +99,10 @@ export default class HomeServerApi {
return this._request("POST", csPath, queryParams, body); return this._request("POST", csPath, queryParams, body);
} }
_put(csPath, queryParams, body) {
return this._request("PUT", csPath, queryParams, body);
}
_get(csPath, queryParams, body) { _get(csPath, queryParams, body) {
return this._request("GET", csPath, queryParams, body); return this._request("GET", csPath, queryParams, body);
} }
@ -111,6 +116,10 @@ export default class HomeServerApi {
return this._get(`/rooms/${roomId}/messages`, params); return this._get(`/rooms/${roomId}/messages`, params);
} }
send(roomId, eventType, txnId, content) {
return this._put(`/rooms/${roomId}/send/${eventType}/${txnId}`, {}, content);
}
passwordLogin(username, password) { passwordLogin(username, password) {
return this._post("/login", undefined, { return this._post("/login", undefined, {
"type": "m.login.password", "type": "m.login.password",

View file

@ -3,9 +3,10 @@ import RoomSummary from "./summary.js";
import SyncWriter from "./timeline/persistence/SyncWriter.js"; import SyncWriter from "./timeline/persistence/SyncWriter.js";
import Timeline from "./timeline/Timeline.js"; import Timeline from "./timeline/Timeline.js";
import FragmentIdComparer from "./timeline/FragmentIdComparer.js"; import FragmentIdComparer from "./timeline/FragmentIdComparer.js";
import SendQueue from "./sending/SendQueue.js";
export default class Room extends EventEmitter { export default class Room extends EventEmitter {
constructor({roomId, storage, hsApi, emitCollectionChange}) { constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) {
super(); super();
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
@ -14,16 +15,22 @@ export default class Room extends EventEmitter {
this._fragmentIdComparer = new FragmentIdComparer([]); this._fragmentIdComparer = new FragmentIdComparer([]);
this._syncWriter = new SyncWriter({roomId, storage, fragmentIdComparer: this._fragmentIdComparer}); this._syncWriter = new SyncWriter({roomId, storage, fragmentIdComparer: this._fragmentIdComparer});
this._emitCollectionChange = emitCollectionChange; this._emitCollectionChange = emitCollectionChange;
this._sendQueue = new SendQueue({roomId, storage, sendScheduler, pendingEvents});
this._timeline = null; this._timeline = null;
this._user = user;
} }
async persistSync(roomResponse, membership, txn) { async persistSync(roomResponse, membership, txn) {
const summaryChanged = this._summary.applySync(roomResponse, membership, txn); const summaryChanged = this._summary.applySync(roomResponse, membership, txn);
const newTimelineEntries = await this._syncWriter.writeSync(roomResponse, txn); const newTimelineEntries = await this._syncWriter.writeSync(roomResponse, txn);
return {summaryChanged, newTimelineEntries}; let removedPendingEvents;
if (roomResponse.timeline && roomResponse.timeline.events) {
removedPendingEvents = this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn);
}
return {summaryChanged, newTimelineEntries, removedPendingEvents};
} }
emitSync({summaryChanged, newTimelineEntries}) { emitSync({summaryChanged, newTimelineEntries, removedPendingEvents}) {
if (summaryChanged) { if (summaryChanged) {
this.emit("change"); this.emit("change");
this._emitCollectionChange(this); this._emitCollectionChange(this);
@ -31,13 +38,24 @@ export default class Room extends EventEmitter {
if (this._timeline) { if (this._timeline) {
this._timeline.appendLiveEntries(newTimelineEntries); this._timeline.appendLiveEntries(newTimelineEntries);
} }
if (removedPendingEvents) {
this._sendQueue.emitRemovals(removedPendingEvents);
}
} }
resumeSending() {
this._sendQueue.resumeSending();
}
load(summary, txn) { load(summary, txn) {
this._summary.load(summary); this._summary.load(summary);
return this._syncWriter.load(txn); return this._syncWriter.load(txn);
} }
sendEvent(eventType, content) {
this._sendQueue.enqueueEvent(eventType, content);
}
get name() { get name() {
return this._summary.name; return this._summary.name;
} }
@ -55,7 +73,9 @@ export default class Room extends EventEmitter {
storage: this._storage, storage: this._storage,
hsApi: this._hsApi, hsApi: this._hsApi,
fragmentIdComparer: this._fragmentIdComparer, fragmentIdComparer: this._fragmentIdComparer,
pendingEvents: this._sendQueue.pendingEvents,
closeCallback: () => this._timeline = null, closeCallback: () => this._timeline = null,
user: this._user,
}); });
await this._timeline.load(); await this._timeline.load();
return this._timeline; return this._timeline;

View file

@ -0,0 +1,14 @@
export default class PendingEvent {
constructor(data) {
this._data = data;
}
get roomId() { return this._data.roomId; }
get queueIndex() { return this._data.queueIndex; }
get eventType() { return this._data.eventType; }
get txnId() { return this._data.txnId; }
get remoteId() { return this._data.remoteId; }
set remoteId(value) { this._data.remoteId = value; }
get content() { return this._data.content; }
get data() { return this._data; }
}

View file

@ -0,0 +1,151 @@
import SortedArray from "../../../observable/list/SortedArray.js";
import {NetworkError} from "../../error.js";
import PendingEvent from "./PendingEvent.js";
function makeTxnId() {
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
const str = n.toString(16);
return "t" + "0".repeat(14 - str.length) + str;
}
export default class SendQueue {
constructor({roomId, storage, sendScheduler, pendingEvents}) {
pendingEvents = pendingEvents || [];
this._roomId = roomId;
this._storage = storage;
this._sendScheduler = sendScheduler;
this._pendingEvents = new SortedArray((a, b) => a.queueIndex - b.queueIndex);
if (pendingEvents.length) {
console.info(`SendQueue for room ${roomId} has ${pendingEvents.length} pending events`, pendingEvents);
}
this._pendingEvents.setManyUnsorted(pendingEvents.map(data => new PendingEvent(data)));
this._isSending = false;
this._offline = false;
this._amountSent = 0;
}
async _sendLoop() {
this._isSending = true;
try {
console.log("start sending", this._amountSent, "<", this._pendingEvents.length);
while (this._amountSent < this._pendingEvents.length) {
const pendingEvent = this._pendingEvents.get(this._amountSent);
console.log("trying to send", pendingEvent.content.body);
this._amountSent += 1;
if (pendingEvent.remoteId) {
continue;
}
console.log("really sending now");
const response = await this._sendScheduler.request(hsApi => {
console.log("got sendScheduler slot");
return hsApi.send(
pendingEvent.roomId,
pendingEvent.eventType,
pendingEvent.txnId,
pendingEvent.content
);
});
pendingEvent.remoteId = response.event_id;
//
console.log("writing remoteId now");
await this._tryUpdateEvent(pendingEvent);
console.log("keep sending?", this._amountSent, "<", this._pendingEvents.length);
}
} catch(err) {
if (err instanceof NetworkError) {
this._offline = true;
}
} finally {
this._isSending = false;
}
}
removeRemoteEchos(events, txn) {
const removed = [];
for (const event of events) {
const txnId = event.unsigned && event.unsigned.transaction_id;
if (txnId) {
const idx = this._pendingEvents.array.findIndex(pe => pe.txnId === txnId);
if (idx !== -1) {
const pendingEvent = this._pendingEvents.get(idx);
txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex);
removed.push(pendingEvent);
}
}
}
return removed;
}
emitRemovals(pendingEvents) {
for (const pendingEvent of pendingEvents) {
const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) {
this._amountSent -= 1;
this._pendingEvents.remove(idx);
}
}
}
resumeSending() {
this._offline = false;
if (!this._isSending) {
this._sendLoop();
}
}
async enqueueEvent(eventType, content) {
const pendingEvent = await this._createAndStoreEvent(eventType, content);
this._pendingEvents.set(pendingEvent);
if (!this._isSending && !this._offline) {
this._sendLoop();
}
}
get pendingEvents() {
return this._pendingEvents;
}
async _tryUpdateEvent(pendingEvent) {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
console.log("_tryUpdateEvent: got txn");
try {
// pendingEvent might have been removed already here
// by a racing remote echo, so check first so we don't recreate it
console.log("_tryUpdateEvent: before exists");
if (await txn.pendingEvents.exists(pendingEvent.roomId, pendingEvent.queueIndex)) {
console.log("_tryUpdateEvent: inside if exists");
txn.pendingEvents.update(pendingEvent.data);
}
console.log("_tryUpdateEvent: after exists");
} catch (err) {
txn.abort();
console.log("_tryUpdateEvent: error", err);
throw err;
}
console.log("_tryUpdateEvent: try complete");
await txn.complete();
}
async _createAndStoreEvent(eventType, content) {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
let pendingEvent;
try {
const pendingEventsStore = txn.pendingEvents;
const maxQueueIndex = await pendingEventsStore.getMaxQueueIndex(this._roomId) || 0;
const queueIndex = maxQueueIndex + 1;
pendingEvent = new PendingEvent({
roomId: this._roomId,
queueIndex,
eventType,
content,
txnId: makeTxnId()
});
pendingEventsStore.add(pendingEvent.data);
} catch (err) {
txn.abort();
throw err;
}
await txn.complete();
return pendingEvent;
}
}

View file

@ -1,32 +1,39 @@
import { SortedArray } from "../../../observable/index.js"; import { SortedArray, MappedList, ConcatList } from "../../../observable/index.js";
import Direction from "./Direction.js"; import Direction from "./Direction.js";
import GapWriter from "./persistence/GapWriter.js"; import GapWriter from "./persistence/GapWriter.js";
import TimelineReader from "./persistence/TimelineReader.js"; import TimelineReader from "./persistence/TimelineReader.js";
import PendingEventEntry from "./entries/PendingEventEntry.js";
export default class Timeline { export default class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, hsApi}) { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user, hsApi}) {
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
this._closeCallback = closeCallback; this._closeCallback = closeCallback;
this._fragmentIdComparer = fragmentIdComparer; this._fragmentIdComparer = fragmentIdComparer;
this._hsApi = hsApi; this._hsApi = hsApi;
this._entriesList = new SortedArray((a, b) => a.compare(b)); this._remoteEntries = new SortedArray((a, b) => a.compare(b));
this._timelineReader = new TimelineReader({ this._timelineReader = new TimelineReader({
roomId: this._roomId, roomId: this._roomId,
storage: this._storage, storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer
}); });
const localEntries = new MappedList(pendingEvents, pe => {
return new PendingEventEntry({pendingEvent: pe, user});
}, (pee, params) => {
pee.notifyUpdate(params);
});
this._allEntries = new ConcatList(this._remoteEntries, localEntries);
} }
/** @package */ /** @package */
async load() { async load() {
const entries = await this._timelineReader.readFromEnd(50); const entries = await this._timelineReader.readFromEnd(50);
this._entriesList.setManySorted(entries); this._remoteEntries.setManySorted(entries);
} }
/** @package */ /** @package */
appendLiveEntries(newEntries) { appendLiveEntries(newEntries) {
this._entriesList.setManySorted(newEntries); this._remoteEntries.setManySorted(newEntries);
} }
/** @public */ /** @public */
@ -42,12 +49,12 @@ export default class Timeline {
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer
}); });
const newEntries = await gapWriter.writeFragmentFill(fragmentEntry, response); const newEntries = await gapWriter.writeFragmentFill(fragmentEntry, response);
this._entriesList.setManySorted(newEntries); this._remoteEntries.setManySorted(newEntries);
} }
// tries to prepend `amount` entries to the `entries` list. // tries to prepend `amount` entries to the `entries` list.
async loadAtTop(amount) { async loadAtTop(amount) {
const firstEventEntry = this._entriesList.array.find(e => !!e.event); const firstEventEntry = this._remoteEntries.array.find(e => !!e.eventType);
if (!firstEventEntry) { if (!firstEventEntry) {
return; return;
} }
@ -56,12 +63,12 @@ export default class Timeline {
Direction.Backward, Direction.Backward,
amount amount
); );
this._entriesList.setManySorted(entries); this._remoteEntries.setManySorted(entries);
} }
/** @public */ /** @public */
get entries() { get entries() {
return this._entriesList; return this._allEntries;
} }
/** @public */ /** @public */

View file

@ -1,5 +1,6 @@
//entries can be sorted, first by fragment, then by entry index. //entries can be sorted, first by fragment, then by entry index.
import EventKey from "../EventKey.js"; import EventKey from "../EventKey.js";
export const PENDING_FRAGMENT_ID = Number.MAX_SAFE_INTEGER;
export default class BaseEntry { export default class BaseEntry {
constructor(fragmentIdComparer) { constructor(fragmentIdComparer) {
@ -17,6 +18,10 @@ export default class BaseEntry {
compare(otherEntry) { compare(otherEntry) {
if (this.fragmentId === otherEntry.fragmentId) { if (this.fragmentId === otherEntry.fragmentId) {
return this.entryIndex - otherEntry.entryIndex; return this.entryIndex - otherEntry.entryIndex;
} else if (this.fragmentId === PENDING_FRAGMENT_ID) {
return 1;
} else if (otherEntry.fragmentId === PENDING_FRAGMENT_ID) {
return -1;
} else { } else {
// This might throw if the relation of two fragments is unknown. // This might throw if the relation of two fragments is unknown.
return this._fragmentIdComparer.compare(this.fragmentId, otherEntry.fragmentId); return this._fragmentIdComparer.compare(this.fragmentId, otherEntry.fragmentId);

View file

@ -18,12 +18,20 @@ export default class EventEntry extends BaseEntry {
return this._eventEntry.event.content; return this._eventEntry.event.content;
} }
get event() { get eventType() {
return this._eventEntry.event; return this._eventEntry.event.type;
} }
get type() { get stateKey() {
return this._eventEntry.event.type; return this._eventEntry.event.state_key;
}
get sender() {
return this._eventEntry.event.sender;
}
get timestamp() {
return this._eventEntry.event.origin_server_ts;
} }
get id() { get id() {

View file

@ -0,0 +1,53 @@
import BaseEntry, {PENDING_FRAGMENT_ID} from "./BaseEntry.js";
export default class PendingEventEntry extends BaseEntry {
constructor({pendingEvent, user}) {
super(null);
this._pendingEvent = pendingEvent;
this._user = user;
}
get fragmentId() {
return PENDING_FRAGMENT_ID;
}
get entryIndex() {
return this._pendingEvent.queueIndex;
}
get content() {
return this._pendingEvent.content;
}
get event() {
return null;
}
get eventType() {
return this._pendingEvent.eventType;
}
get stateKey() {
return null;
}
get sender() {
return this._user.id;
}
get timestamp() {
return null;
}
get isPending() {
return true;
}
get id() {
return this._pendingEvent.txnId;
}
notifyUpdate() {
}
}

View file

@ -1,5 +1,7 @@
import Room from "./room/room.js"; import Room from "./room/room.js";
import { ObservableMap } from "../observable/index.js"; import { ObservableMap } from "../observable/index.js";
import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
import User from "./User.js";
export default class Session { export default class Session {
// sessionInfo contains deviceId, userId and homeServer // sessionInfo contains deviceId, userId and homeServer
@ -9,7 +11,9 @@ export default class Session {
this._session = null; this._session = null;
this._sessionInfo = sessionInfo; this._sessionInfo = sessionInfo;
this._rooms = new ObservableMap(); this._rooms = new ObservableMap();
this._sendScheduler = new SendScheduler({hsApi, backoff: new RateLimitingBackoff()});
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
this._user = new User(sessionInfo.userId);
} }
async load() { async load() {
@ -19,6 +23,7 @@ export default class Session {
this._storage.storeNames.roomState, this._storage.storeNames.roomState,
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineFragments,
this._storage.storeNames.pendingEvents,
]); ]);
// restore session object // restore session object
this._session = await txn.session.get(); this._session = await txn.session.get();
@ -26,24 +31,47 @@ export default class Session {
this._session = {}; this._session = {};
return; return;
} }
const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn);
// load rooms // load rooms
const rooms = await txn.roomSummary.getAll(); const rooms = await txn.roomSummary.getAll();
await Promise.all(rooms.map(summary => { await Promise.all(rooms.map(summary => {
const room = this.createRoom(summary.roomId); const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId));
return room.load(summary, txn); return room.load(summary, txn);
})); }));
} }
notifyNetworkAvailable() {
for (const [, room] of this._rooms) {
room.resumeSending();
}
}
async _getPendingEventsByRoom(txn) {
const pendingEvents = await txn.pendingEvents.getAll();
return pendingEvents.reduce((groups, pe) => {
const group = groups.get(pe.roomId);
if (group) {
group.push(pe);
} else {
groups.set(pe.roomId, [pe]);
}
return groups;
}, new Map());
}
get rooms() { get rooms() {
return this._rooms; return this._rooms;
} }
createRoom(roomId) { createRoom(roomId, pendingEvents) {
const room = new Room({ const room = new Room({
roomId, roomId,
storage: this._storage, storage: this._storage,
emitCollectionChange: this._roomUpdateCallback, emitCollectionChange: this._roomUpdateCallback,
hsApi: this._hsApi, hsApi: this._hsApi,
sendScheduler: this._sendScheduler,
pendingEvents,
user: this._user,
}); });
this._rooms.add(roomId, room); this._rooms.add(roomId, room);
return room; return room;
@ -60,7 +88,7 @@ export default class Session {
return this._session.syncToken; return this._session.syncToken;
} }
get userId() { get user() {
return this._sessionInfo.userId; return this._user;
} }
} }

View file

@ -1,4 +1,11 @@
export const STORE_NAMES = Object.freeze(["session", "roomState", "roomSummary", "timelineEvents", "timelineFragments"]); export const STORE_NAMES = Object.freeze([
"session",
"roomState",
"roomSummary",
"timelineEvents",
"timelineFragments",
"pendingEvents",
]);
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => { export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
nameMap[name] = name; nameMap[name] = name;
@ -16,5 +23,8 @@ export class StorageError extends Error {
fullMessage += cause.message; fullMessage += cause.message;
} }
super(fullMessage); super(fullMessage);
if (cause) {
this.errcode = cause.name;
}
} }
} }

View file

@ -20,6 +20,7 @@ function createStores(db) {
timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true}); timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true});
//key = room_id | event.type | event.state_key, //key = room_id | event.type | event.state_key,
db.createObjectStore("roomState", {keyPath: "key"}); db.createObjectStore("roomState", {keyPath: "key"});
db.createObjectStore("pendingEvents", {keyPath: "key"});
// const roomMembers = db.createObjectStore("roomMembers", {keyPath: [ // const roomMembers = db.createObjectStore("roomMembers", {keyPath: [
// "event.room_id", // "event.room_id",

View file

@ -21,6 +21,10 @@ export default class QueryTarget {
return reqAsPromise(this._target.get(key)); return reqAsPromise(this._target.get(key));
} }
getKey(key) {
return reqAsPromise(this._target.getKey(key));
}
reduce(range, reducer, initialValue) { reduce(range, reducer, initialValue) {
return this._reduce(range, reducer, initialValue, "next"); return this._reduce(range, reducer, initialValue, "next");
} }
@ -71,6 +75,16 @@ export default class QueryTarget {
return this._find(range, predicate, "prev"); return this._find(range, predicate, "prev");
} }
async findMaxKey(range) {
const cursor = this._target.openKeyCursor(range, "prev");
let maxKey;
await iterateCursor(cursor, (_, key) => {
maxKey = key;
return {done: true};
});
return maxKey;
}
/** /**
* Checks if a given set of keys exist. * Checks if a given set of keys exist.
* Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true). * Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true).

View file

@ -46,6 +46,22 @@ class QueryTargetWrapper {
throw new StorageError("get failed", err); throw new StorageError("get failed", err);
} }
} }
getKey(...params) {
try {
return this._qt.getKey(...params);
} catch(err) {
throw new StorageError("getKey failed", err);
}
}
delete(...params) {
try {
return this._qt.delete(...params);
} catch(err) {
throw new StorageError("delete failed", err);
}
}
index(...params) { index(...params) {
try { try {
@ -76,4 +92,8 @@ export default class Store extends QueryTarget {
add(value) { add(value) {
return reqAsPromise(this._idbStore.add(value)); return reqAsPromise(this._idbStore.add(value));
} }
delete(keyOrKeyRange) {
return reqAsPromise(this._idbStore.delete(keyOrKeyRange));
}
} }

View file

@ -0,0 +1,55 @@
import { encodeUint32, decodeUint32 } from "../utils.js";
import Platform from "../../../../Platform.js";
function encodeKey(roomId, queueIndex) {
return `${roomId}|${encodeUint32(queueIndex)}`;
}
function decodeKey(key) {
const [roomId, encodedQueueIndex] = key.split("|");
const queueIndex = decodeUint32(encodedQueueIndex);
return {roomId, queueIndex};
}
export default class PendingEventStore {
constructor(eventStore) {
this._eventStore = eventStore;
}
async getMaxQueueIndex(roomId) {
const range = IDBKeyRange.bound(
encodeKey(roomId, Platform.minStorageKey),
encodeKey(roomId, Platform.maxStorageKey),
false,
false,
);
const maxKey = await this._eventStore.findMaxKey(range);
if (maxKey) {
return decodeKey(maxKey).queueIndex;
}
}
remove(roomId, queueIndex) {
const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex));
this._eventStore.delete(keyRange);
}
async exists(roomId, queueIndex) {
const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex));
const key = await this._eventStore.getKey(keyRange);
return !!key;
}
add(pendingEvent) {
pendingEvent.key = encodeKey(pendingEvent.roomId, pendingEvent.queueIndex);
return this._eventStore.add(pendingEvent);
}
update(pendingEvent) {
return this._eventStore.put(pendingEvent);
}
getAll() {
return this._eventStore.selectAll();
}
}

View file

@ -1,13 +1,8 @@
import EventKey from "../../../room/timeline/EventKey.js"; import EventKey from "../../../room/timeline/EventKey.js";
import { StorageError } from "../../common.js"; import { StorageError } from "../../common.js";
import { encodeUint32 } from "../utils.js";
import Platform from "../../../../Platform.js"; import Platform from "../../../../Platform.js";
// storage keys are defined to be unsigned 32bit numbers in WebPlatform.js, which is assumed by idb
function encodeUint32(n) {
const hex = n.toString(16);
return "0".repeat(8 - hex.length) + hex;
}
function encodeKey(roomId, fragmentId, eventIndex) { function encodeKey(roomId, fragmentId, eventIndex) {
return `${roomId}|${encodeUint32(fragmentId)}|${encodeUint32(eventIndex)}`; return `${roomId}|${encodeUint32(fragmentId)}|${encodeUint32(eventIndex)}`;
} }

View file

@ -1,10 +1,9 @@
import { StorageError } from "../../common.js"; import { StorageError } from "../../common.js";
import Platform from "../../../../Platform.js"; import Platform from "../../../../Platform.js";
import { encodeUint32 } from "../utils.js";
function encodeKey(roomId, fragmentId) { function encodeKey(roomId, fragmentId) {
let fragmentIdHex = fragmentId.toString(16); return `${roomId}|${encodeUint32(fragmentId)}`;
fragmentIdHex = "0".repeat(8 - fragmentIdHex.length) + fragmentIdHex;
return `${roomId}|${fragmentIdHex}`;
} }
export default class RoomFragmentStore { export default class RoomFragmentStore {

View file

@ -6,6 +6,7 @@ import RoomSummaryStore from "./stores/RoomSummaryStore.js";
import TimelineEventStore from "./stores/TimelineEventStore.js"; import TimelineEventStore from "./stores/TimelineEventStore.js";
import RoomStateStore from "./stores/RoomStateStore.js"; import RoomStateStore from "./stores/RoomStateStore.js";
import TimelineFragmentStore from "./stores/TimelineFragmentStore.js"; import TimelineFragmentStore from "./stores/TimelineFragmentStore.js";
import PendingEventStore from "./stores/PendingEventStore.js";
export default class Transaction { export default class Transaction {
constructor(txn, allowedStoreNames) { constructor(txn, allowedStoreNames) {
@ -55,6 +56,10 @@ export default class Transaction {
return this._store("roomState", idbStore => new RoomStateStore(idbStore)); return this._store("roomState", idbStore => new RoomStateStore(idbStore));
} }
get pendingEvents() {
return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore));
}
complete() { complete() {
return txnAsPromise(this._txn); return txnAsPromise(this._txn);
} }

View file

@ -1,5 +1,16 @@
import { StorageError } from "../common.js"; import { StorageError } from "../common.js";
// storage keys are defined to be unsigned 32bit numbers in WebPlatform.js, which is assumed by idb
export function encodeUint32(n) {
const hex = n.toString(16);
return "0".repeat(8 - hex.length) + hex;
}
export function decodeUint32(str) {
return parseInt(str, 16);
}
export function openDatabase(name, createObjectStore, version) { export function openDatabase(name, createObjectStore, version) {
const req = window.indexedDB.open(name, version); const req = window.indexedDB.open(name, version);
req.onupgradeneeded = (ev) => { req.onupgradeneeded = (ev) => {

View file

@ -77,6 +77,7 @@ export default class Sync extends EventEmitter {
storeNames.roomState, storeNames.roomState,
storeNames.timelineEvents, storeNames.timelineEvents,
storeNames.timelineFragments, storeNames.timelineFragments,
storeNames.pendingEvents,
]); ]);
const roomChanges = []; const roomChanges = [];
try { try {

View file

@ -18,7 +18,7 @@ export default class BaseObservableCollection {
} }
return () => { return () => {
if (handler) { if (handler) {
this._handlers.delete(this._handler); this._handlers.delete(handler);
if (this._handlers.size === 0) { if (this._handlers.size === 0) {
this.onUnsubscribeLast(); this.onUnsubscribeLast();
} }
@ -30,3 +30,25 @@ export default class BaseObservableCollection {
// Add iterator over handlers here // Add iterator over handlers here
} }
export function tests() {
class Collection extends BaseObservableCollection {
constructor() {
super();
this.firstSubscribeCalls = 0;
this.firstUnsubscribeCalls = 0;
}
onSubscribeFirst() { this.firstSubscribeCalls += 1; }
onUnsubscribeLast() { this.firstUnsubscribeCalls += 1; }
}
return {
test_unsubscribe(assert) {
const c = new Collection();
const unsubscribe = c.subscribe({});
unsubscribe();
assert.equal(c.firstSubscribeCalls, 1);
assert.equal(c.firstUnsubscribeCalls, 1);
}
}
}

View file

@ -5,6 +5,8 @@ import BaseObservableMap from "./map/BaseObservableMap.js";
// re-export "root" (of chain) collections // re-export "root" (of chain) collections
export { default as ObservableArray } from "./list/ObservableArray.js"; export { default as ObservableArray } from "./list/ObservableArray.js";
export { default as SortedArray } from "./list/SortedArray.js"; export { default as SortedArray } from "./list/SortedArray.js";
export { default as MappedList } from "./list/MappedList.js";
export { default as ConcatList } from "./list/ConcatList.js";
export { default as ObservableMap } from "./map/ObservableMap.js"; export { default as ObservableMap } from "./map/ObservableMap.js";
// avoid circular dependency between these classes // avoid circular dependency between these classes

View file

@ -3,26 +3,26 @@ import BaseObservableCollection from "../BaseObservableCollection.js";
export default class BaseObservableList extends BaseObservableCollection { export default class BaseObservableList extends BaseObservableCollection {
emitReset() { emitReset() {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onReset(); h.onReset(this);
} }
} }
// we need batch events, mostly on index based collection though? // we need batch events, mostly on index based collection though?
// maybe we should get started without? // maybe we should get started without?
emitAdd(index, value) { emitAdd(index, value) {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onAdd(index, value); h.onAdd(index, value, this);
} }
} }
emitUpdate(index, value, params) { emitUpdate(index, value, params) {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onUpdate(index, value, params); h.onUpdate(index, value, params, this);
} }
} }
emitRemove(index, value) { emitRemove(index, value) {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onRemove(index, value); h.onRemove(index, value, this);
} }
} }
@ -30,7 +30,7 @@ export default class BaseObservableList extends BaseObservableCollection {
// been removed from its fromIdx // been removed from its fromIdx
emitMove(fromIdx, toIdx, value) { emitMove(fromIdx, toIdx, value) {
for(let h of this._handlers) { for(let h of this._handlers) {
h.onMove(fromIdx, toIdx, value); h.onMove(fromIdx, toIdx, value, this);
} }
} }

View file

@ -0,0 +1,144 @@
import BaseObservableList from "./BaseObservableList.js";
export default class ConcatList extends BaseObservableList {
constructor(...sourceLists) {
super();
this._sourceLists = sourceLists;
this._sourceUnsubscribes = null;
}
_offsetForSource(sourceList) {
const listIdx = this._sourceLists.indexOf(sourceList);
let offset = 0;
for (let i = 0; i < listIdx; ++i) {
offset += this._sourceLists[i].length;
}
return offset;
}
onSubscribeFirst() {
this._sourceUnsubscribes = [];
for (const sourceList of this._sourceLists) {
this._sourceUnsubscribes.push(sourceList.subscribe(this));
}
}
onUnsubscribeLast() {
for (const sourceUnsubscribe of this._sourceUnsubscribes) {
sourceUnsubscribe();
}
}
onReset() {
// TODO: not ideal if other source lists are large
// but working impl for now
// reset, and
this.emitReset();
let idx = 0;
for(const item of this) {
this.emitAdd(idx, item);
idx += 1;
}
}
onAdd(index, value, sourceList) {
this.emitAdd(this._offsetForSource(sourceList) + index, value);
}
onUpdate(index, value, params, sourceList) {
this.emitUpdate(this._offsetForSource(sourceList) + index, value, params);
}
onRemove(index, value, sourceList) {
this.emitRemove(this._offsetForSource(sourceList) + index, value);
}
onMove(fromIdx, toIdx, value, sourceList) {
const offset = this._offsetForSource(sourceList);
this.emitMove(offset + fromIdx, offset + toIdx, value);
}
get length() {
let len = 0;
for (let i = 0; i < this._sourceLists.length; ++i) {
len += this._sourceLists[i].length;
}
return len;
}
[Symbol.iterator]() {
let sourceListIdx = 0;
let it = this._sourceLists[0][Symbol.iterator]();
return {
next: () => {
let result = it.next();
while (result.done) {
sourceListIdx += 1;
if (sourceListIdx >= this._sourceLists.length) {
return result; //done
}
it = this._sourceLists[sourceListIdx][Symbol.iterator]();
result = it.next();
}
return result;
}
}
}
}
import ObservableArray from "./ObservableArray.js";
export async function tests() {
return {
test_length(assert) {
const all = new ConcatList(
new ObservableArray([1, 2, 3]),
new ObservableArray([11, 12, 13])
);
assert.equal(all.length, 6);
},
test_iterator(assert) {
const all = new ConcatList(
new ObservableArray([1, 2, 3]),
new ObservableArray([11, 12, 13])
);
const it = all[Symbol.iterator]();
assert.equal(it.next().value, 1);
assert.equal(it.next().value, 2);
assert.equal(it.next().value, 3);
assert.equal(it.next().value, 11);
assert.equal(it.next().value, 12);
assert.equal(it.next().value, 13);
assert(it.next().done);
},
test_add(assert) {
const list1 = new ObservableArray([1, 2, 3]);
const list2 = new ObservableArray([11, 12, 13]);
const all = new ConcatList(list1, list2);
let fired = false;
all.subscribe({
onAdd(index, value) {
fired = true;
assert.equal(index, 4);
assert.equal(value, 11.5);
}
});
list2.insert(1, 11.5);
assert(fired);
},
test_update(assert) {
const list1 = new ObservableArray([1, 2, 3]);
const list2 = new ObservableArray([11, 12, 13]);
const all = new ConcatList(list1, list2);
let fired = false;
all.subscribe({
onUpdate(index, value) {
fired = true;
assert.equal(index, 4);
assert.equal(value, 10);
}
});
list2.emitUpdate(1, 10);
assert(fired);
},
};
}

View file

@ -0,0 +1,115 @@
import BaseObservableList from "./BaseObservableList.js";
export default class MappedList extends BaseObservableList {
constructor(sourceList, mapper, updater) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._sourceUnsubscribe = null;
this._mappedValues = null;
}
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._mappedValues = [];
for (const item of this._sourceList) {
this._mappedValues.push(this._mapper(item));
}
}
onReset() {
this._mappedValues = [];
this.emitReset();
}
onAdd(index, value) {
const mappedValue = this._mapper(value);
this._mappedValues.splice(index, 0, mappedValue);
this.emitAdd(index, mappedValue);
}
onUpdate(index, value, params) {
const mappedValue = this._mappedValues[index];
if (this._updater) {
this._updater(mappedValue, params, value);
}
this.emitUpdate(index, mappedValue, params);
}
onRemove(index) {
const mappedValue = this._mappedValues[index];
this._mappedValues.splice(index, 1);
this.emitRemove(index, mappedValue);
}
onMove(fromIdx, toIdx) {
const mappedValue = this._mappedValues[fromIdx];
this._mappedValues.splice(fromIdx, 1);
this._mappedValues.splice(toIdx, 0, mappedValue);
this.emitMove(fromIdx, toIdx, mappedValue);
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
export async function tests() {
class MockList extends BaseObservableList {
get length() {
return 0;
}
[Symbol.iterator]() {
return [].values();
}
}
return {
test_add(assert) {
const source = new MockList();
const mapped = new MappedList(source, n => {return {n: n*n};});
let fired = false;
const unsubscribe = mapped.subscribe({
onAdd(idx, value) {
fired = true;
assert.equal(idx, 0);
assert.equal(value.n, 36);
}
});
source.emitAdd(0, 6);
assert(fired);
unsubscribe();
},
test_update(assert) {
const source = new MockList();
const mapped = new MappedList(
source,
n => {return {n: n*n};},
(o, p, n) => o.m = n*n
);
let fired = false;
const unsubscribe = mapped.subscribe({
onAdd() {},
onUpdate(idx, value) {
fired = true;
assert.equal(idx, 0);
assert.equal(value.n, 36);
assert.equal(value.m, 49);
}
});
source.emitAdd(0, 6);
source.emitUpdate(0, 7);
assert(fired);
unsubscribe();
}
};
}

View file

@ -1,9 +1,9 @@
import BaseObservableList from "./BaseObservableList.js"; import BaseObservableList from "./BaseObservableList.js";
export default class ObservableArray extends BaseObservableList { export default class ObservableArray extends BaseObservableList {
constructor() { constructor(initialValues = []) {
super(); super();
this._items = []; this._items = initialValues;
} }
append(item) { append(item) {

View file

@ -8,6 +8,10 @@ export default class SortedArray extends BaseObservableList {
this._items = []; this._items = [];
} }
setManyUnsorted(items) {
this.setManySorted(items);
}
setManySorted(items) { setManySorted(items) {
// TODO: we can make this way faster by only looking up the first and last key, // TODO: we can make this way faster by only looking up the first and last key,
// and merging whatever is inbetween with items // and merging whatever is inbetween with items
@ -32,8 +36,14 @@ export default class SortedArray extends BaseObservableList {
} }
} }
remove(item) { get(idx) {
throw new Error("unimplemented"); return this._items[idx];
}
remove(idx) {
const item = this._items[idx];
this._items.splice(idx, 1);
this.emitRemove(idx, item);
} }
get array() { get array() {

View file

@ -13,4 +13,8 @@ export default {
// for indexeddb, we use unsigned 32 bit integers as keys // for indexeddb, we use unsigned 32 bit integers as keys
return 0xFFFFFFFF; return 0xFFFFFFFF;
}, },
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
} }

View file

@ -50,6 +50,11 @@ body {
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
}
.TimelinePanel ul {
flex: 1 0 0;
} }
.RoomHeader { .RoomHeader {

View file

@ -69,3 +69,11 @@
.RoomView_error { .RoomView_error {
color: red; color: red;
} }
.MessageComposer > input {
display: block;
width: 100%;
box-sizing: border-box;
padding: 0.8em;
border: none;
}

View file

@ -1,6 +1,5 @@
.TimelinePanel ul { .TimelinePanel ul {
flex: 1;
overflow-y: auto; overflow-y: auto;
list-style: none; list-style: none;
padding: 0; padding: 0;
@ -54,6 +53,10 @@
background-color: darkgreen; background-color: darkgreen;
} }
.TextMessageView.pending .message-container {
background-color: #333;
}
.message-container p { .message-container p {
margin: 5px 0; margin: 5px 0;
} }

View file

@ -70,7 +70,7 @@ export function text(str) {
export const TAG_NAMES = [ export const TAG_NAMES = [
"ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "li", "div", "h1", "h2", "h3", "h4", "h5", "h6",
"p", "strong", "em", "span", "img", "section", "main", "article", "aside", "p", "strong", "em", "span", "img", "section", "main", "article", "aside",
"pre", "button", "time"]; "pre", "button", "time", "input", "textarea"];
export const tag = {}; export const tag = {};

View file

@ -0,0 +1,23 @@
import TemplateView from "../../general/TemplateView.js";
export default class MessageComposer extends TemplateView {
constructor(viewModel) {
super(viewModel);
this._input = null;
}
render(t) {
this._input = t.input({
placeholder: "Send a message ...",
onKeydown: e => this._onKeyDown(e)
});
return t.div({className: "MessageComposer"}, [this._input]);
}
_onKeyDown(event) {
if (event.key === "Enter") {
this.viewModel.sendMessage(this._input.value);
this._input.value = "";
}
}
}

View file

@ -1,5 +1,6 @@
import TemplateView from "../../general/TemplateView.js"; import TemplateView from "../../general/TemplateView.js";
import TimelineList from "./TimelineList.js"; import TimelineList from "./TimelineList.js";
import MessageComposer from "./MessageComposer.js";
export default class RoomView extends TemplateView { export default class RoomView extends TemplateView {
constructor(viewModel) { constructor(viewModel) {
@ -18,17 +19,20 @@ export default class RoomView extends TemplateView {
]), ]),
]), ]),
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, vm => vm.error),
this._timelineList.mount() this._timelineList.mount(),
this._composer.mount(),
]) ])
]); ]);
} }
mount() { mount() {
this._composer = new MessageComposer(this.viewModel);
this._timelineList = new TimelineList(); this._timelineList = new TimelineList();
return super.mount(); return super.mount();
} }
unmount() { unmount() {
this._composer.unmount();
this._timelineList.unmount(); this._timelineList.unmount();
super.unmount(); super.unmount();
} }

View file

@ -4,7 +4,7 @@ export default class TextMessageView extends TemplateView {
render(t, vm) { render(t, vm) {
// no bindings ... should this be a template view? // no bindings ... should this be a template view?
return t.li( return t.li(
{className: {"TextMessageView": true, own: vm.isOwn}}, {className: {"TextMessageView": true, own: vm.isOwn, pending: vm.isPending}},
t.div({className: "message-container"}, [ t.div({className: "message-container"}, [
t.div({className: "sender"}, vm => vm.isContinuation ? "" : vm.sender), t.div({className: "sender"}, vm => vm.isContinuation ? "" : vm.sender),
t.p([vm.text, t.time(vm.date + " " + vm.time)]), t.p([vm.text, t.time(vm.date + " " + vm.time)]),