diff --git a/doc/impl-thoughts/E2EE.md b/doc/impl-thoughts/E2EE.md
index 66ef31d5..41f56f6e 100644
--- a/doc/impl-thoughts/E2EE.md
+++ b/doc/impl-thoughts/E2EE.md
@@ -2,7 +2,10 @@
## Olm
- implement MemberList as ObservableMap
- make sure we have all members (as we're using lazy loading members), and store these somehow
+ - keep in mind that the server might not support lazy loading? E.g. we should store in a memberlist all the membership events passed by sync, perhaps with a flag if we already attempted to fetch all. We could also check if the server announces lazy loading support in the version response (I think r0.6.0).
- do we need to update /members on every limited sync response or did we find a way around this?
+ - I don't think we need to ... we get all state events that were sent during the gap in `room.state`
+ - I tested this with riot and synapse, and indeed, we get membership events from the gap on a limited sync. This could be clearer in the spec though.
- fields:
- user id
- room id
@@ -118,7 +121,8 @@ we'll need to pass an implementation of EventSender or something to SendQueue th
- use AES-CTR from https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto
## Notes
- - libolm api docs (also for js api) would be great
+ - libolm api docs (also for js api) would be great. Found something that could work:
+ https://gitlab.matrix.org/matrix-org/olm/-/blob/master/javascript/index.d.ts
## OO Design
diff --git a/doc/impl-thoughts/MEMBERS.md b/doc/impl-thoughts/MEMBERS.md
index d2b1db3e..0103b879 100644
--- a/doc/impl-thoughts/MEMBERS.md
+++ b/doc/impl-thoughts/MEMBERS.md
@@ -1,3 +1,11 @@
+# TODO
+
+## Member list
+
+ - support migrations in StorageFactory
+ - migrate all stores from key to key_path
+ - how to deal with members coming from backfill? do we even need to store them?
+
# How to store members?
All of this is assuming we'll use lazy loading of members.
diff --git a/doc/impl-thoughts/timeline-member.md b/doc/impl-thoughts/timeline-member.md
new file mode 100644
index 00000000..a0c4eccb
--- /dev/null
+++ b/doc/impl-thoughts/timeline-member.md
@@ -0,0 +1,7 @@
+## Get member for timeline event
+
+so when writing sync, we persist the display name and avatar
+
+the server might or might not support lazy loading
+
+if it is a room we just joined
\ No newline at end of file
diff --git a/lib/olm.js b/lib/olm.js
new file mode 120000
index 00000000..9f16c77b
--- /dev/null
+++ b/lib/olm.js
@@ -0,0 +1 @@
+../node_modules/olm/olm.js
\ No newline at end of file
diff --git a/lib/olm.wasm b/lib/olm.wasm
new file mode 120000
index 00000000..8d848e89
--- /dev/null
+++ b/lib/olm.wasm
@@ -0,0 +1 @@
+../node_modules/olm/olm.wasm
\ No newline at end of file
diff --git a/prototypes/olmtest.html b/prototypes/olmtest.html
new file mode 100644
index 00000000..9a17a189
--- /dev/null
+++ b/prototypes/olmtest.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js
index dc2faedb..7b225afb 100644
--- a/src/domain/SessionLoadViewModel.js
+++ b/src/domain/SessionLoadViewModel.js
@@ -67,6 +67,7 @@ export class SessionLoadViewModel extends ViewModel {
this._error = err;
} finally {
this._loading = false;
+ // loadLabel in case of sc.loadError also gets updated through this
this.emitChange("loading");
}
}
diff --git a/src/domain/SessionPickerViewModel.js b/src/domain/SessionPickerViewModel.js
index 37fbf767..6e33883b 100644
--- a/src/domain/SessionPickerViewModel.js
+++ b/src/domain/SessionPickerViewModel.js
@@ -184,13 +184,18 @@ export class SessionPickerViewModel extends ViewModel {
}
async import(json) {
- const data = JSON.parse(json);
- const {sessionInfo} = data;
- sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
- sessionInfo.id = this._createSessionContainer().createNewSessionId();
- await this._storageFactory.import(sessionInfo.id, data.stores);
- await this._sessionInfoStorage.add(sessionInfo);
- this._sessions.set(new SessionItemViewModel(sessionInfo, this));
+ try {
+ const data = JSON.parse(json);
+ const {sessionInfo} = data;
+ sessionInfo.comment = `Imported on ${new Date().toLocaleString()} from id ${sessionInfo.id}.`;
+ sessionInfo.id = this._createSessionContainer().createNewSessionId();
+ await this._storageFactory.import(sessionInfo.id, data.stores);
+ await this._sessionInfoStorage.add(sessionInfo);
+ this._sessions.set(new SessionItemViewModel(sessionInfo, this));
+ } catch (err) {
+ alert(err.message);
+ console.error(err);
+ }
}
async delete(id) {
diff --git a/src/domain/session/room/timeline/tiles/GapTile.js b/src/domain/session/room/timeline/tiles/GapTile.js
index 0cfbb491..98d197b9 100644
--- a/src/domain/session/room/timeline/tiles/GapTile.js
+++ b/src/domain/session/room/timeline/tiles/GapTile.js
@@ -36,6 +36,9 @@ export class GapTile extends SimpleTile {
console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`);
this._error = err;
this.emitChange("error");
+ // rethrow so caller of this method
+ // knows not to keep calling this for now
+ throw err;
} finally {
this._loading = false;
this.emitChange("isLoading");
diff --git a/src/domain/session/room/timeline/tiles/ImageTile.js b/src/domain/session/room/timeline/tiles/ImageTile.js
index 8bdaa514..e72d28e4 100644
--- a/src/domain/session/room/timeline/tiles/ImageTile.js
+++ b/src/domain/session/room/timeline/tiles/ImageTile.js
@@ -20,15 +20,10 @@ const MAX_HEIGHT = 300;
const MAX_WIDTH = 400;
export class ImageTile extends MessageTile {
- constructor(options, room) {
- super(options);
- this._room = room;
- }
-
get thumbnailUrl() {
const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") {
- return this._room.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
+ return this._mediaRepository.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
}
return null;
}
@@ -36,7 +31,7 @@ export class ImageTile extends MessageTile {
get url() {
const mxcUrl = this._getContent()?.url;
if (typeof mxcUrl === "string") {
- return this._room.mxcUrl(mxcUrl);
+ return this._mediaRepository.mxcUrl(mxcUrl);
}
return null;
}
diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js
index 8ad0b22e..794ba1a9 100644
--- a/src/domain/session/room/timeline/tiles/MessageTile.js
+++ b/src/domain/session/room/timeline/tiles/MessageTile.js
@@ -15,11 +15,12 @@ limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
-import {getIdentifierColorNumber} from "../../../../avatar.js";
+import {getIdentifierColorNumber, avatarInitials} from "../../../../avatar.js";
export class MessageTile extends SimpleTile {
constructor(options) {
super(options);
+ this._mediaRepository = options.mediaRepository;
this._clock = options.clock;
this._isOwn = this._entry.sender === options.ownUserId;
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
@@ -31,13 +32,24 @@ export class MessageTile extends SimpleTile {
}
get sender() {
- return this._entry.sender;
+ return this._entry.displayName || this._entry.sender;
}
- get senderColorNumber() {
+ get avatarColorNumber() {
return getIdentifierColorNumber(this._entry.sender);
}
+ get avatarUrl() {
+ if (this._entry.avatarUrl) {
+ return this._mediaRepository.mxcUrlThumbnail(this._entry.avatarUrl, 30, 30, "crop");
+ }
+ return null;
+ }
+
+ get avatarLetter() {
+ return avatarInitials(this.sender);
+ }
+
get date() {
return this._date && this._date.toLocaleDateString({}, {month: "numeric", day: "numeric"});
}
diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js
index 0abc0766..f1f790e2 100644
--- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js
+++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js
@@ -23,35 +23,36 @@ export class RoomMemberTile extends SimpleTile {
}
get announcement() {
- const {sender, content, prevContent, stateKey} = this._entry;
+ const {sender, content, prevContent} = this._entry;
+ const name = this._entry.displayName || sender;
const membership = content && content.membership;
const prevMembership = prevContent && prevContent.membership;
if (prevMembership === "join" && membership === "join") {
if (content.avatar_url !== prevContent.avatar_url) {
- return `${stateKey} changed their avatar`;
+ return `${name} changed their avatar`;
} else if (content.displayname !== prevContent.displayname) {
- return `${stateKey} changed their name to ${content.displayname}`;
+ return `${name} changed their name to ${content.displayname}`;
}
} else if (membership === "join") {
- return `${stateKey} joined the room`;
+ return `${name} joined the room`;
} else if (membership === "invite") {
- return `${stateKey} was invited to the room by ${sender}`;
+ return `${name} was invited to the room by ${sender}`;
} else if (prevMembership === "invite") {
if (membership === "join") {
- return `${stateKey} accepted the invitation to join the room`;
+ return `${name} accepted the invitation to join the room`;
} else if (membership === "leave") {
- return `${stateKey} declined the invitation to join the room`;
+ return `${name} declined the invitation to join the room`;
}
} else if (membership === "leave") {
- if (stateKey === sender) {
- return `${stateKey} left the room`;
+ if (name === sender) {
+ return `${name} left the room`;
} else {
const reason = content.reason;
- return `${stateKey} was kicked from the room by ${sender}${reason ? `: ${reason}` : ""}`;
+ return `${name} was kicked from the room by ${sender}${reason ? `: ${reason}` : ""}`;
}
} else if (membership === "ban") {
- return `${stateKey} was banned from the room by ${sender}`;
+ return `${name} was banned from the room by ${sender}`;
}
return `${sender} membership changed to ${content.membership}`;
diff --git a/src/domain/session/room/timeline/tiles/RoomNameTile.js b/src/domain/session/room/timeline/tiles/RoomNameTile.js
index cf5705dd..a7a785d0 100644
--- a/src/domain/session/room/timeline/tiles/RoomNameTile.js
+++ b/src/domain/session/room/timeline/tiles/RoomNameTile.js
@@ -24,6 +24,6 @@ export class RoomNameTile extends SimpleTile {
get announcement() {
const content = this._entry.content;
- return `${this._entry.sender} named the room "${content.name}"`
+ return `${this._entry.displayName || this._entry.sender} named the room "${content.name}"`
}
}
diff --git a/src/domain/session/room/timeline/tilesCreator.js b/src/domain/session/room/timeline/tilesCreator.js
index 1ae17bdc..567e9e7d 100644
--- a/src/domain/session/room/timeline/tilesCreator.js
+++ b/src/domain/session/room/timeline/tilesCreator.js
@@ -24,7 +24,8 @@ import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
export function tilesCreator({room, ownUserId, clock}) {
return function tilesCreator(entry, emitUpdate) {
- const options = {entry, emitUpdate, ownUserId, clock};
+ const options = {entry, emitUpdate, ownUserId, clock,
+ mediaRepository: room.mediaRepository};
if (entry.isGap) {
return new GapTile(options, room);
} else if (entry.eventType) {
@@ -38,7 +39,7 @@ export function tilesCreator({room, ownUserId, clock}) {
case "m.emote":
return new TextTile(options);
case "m.image":
- return new ImageTile(options, room);
+ return new ImageTile(options);
case "m.location":
return new LocationTile(options);
default:
diff --git a/src/matrix/ServerFeatures.js b/src/matrix/ServerFeatures.js
new file mode 100644
index 00000000..c2875942
--- /dev/null
+++ b/src/matrix/ServerFeatures.js
@@ -0,0 +1,19 @@
+const R0_5_0 = "r0.5.0";
+
+export class ServerFeatures {
+ constructor(versionResponse) {
+ this._versionResponse = versionResponse;
+ }
+
+ _supportsVersion(version) {
+ if (!this._versionResponse) {
+ return false;
+ }
+ const {versions} = this._versionResponse;
+ return Array.isArray(versions) && versions.includes(version);
+ }
+
+ get lazyLoadMembers() {
+ return this._supportsVersion(R0_5_0);
+ }
+}
diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js
index 05de8fd5..74184a0a 100644
--- a/src/matrix/SessionContainer.js
+++ b/src/matrix/SessionContainer.js
@@ -110,7 +110,7 @@ export class SessionContainer {
this._status.set(LoadStatus.LoginFailed);
} else if (err instanceof ConnectionError) {
this._loginFailure = LoginFailure.Connection;
- this._status.set(LoadStatus.LoginFailure);
+ this._status.set(LoadStatus.LoginFailed);
} else {
this._status.set(LoadStatus.Error);
}
@@ -191,9 +191,14 @@ export class SessionContainer {
}
}
// only transition into Ready once the first sync has succeeded
- this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing);
+ this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing || s === SyncStatus.Stopped);
try {
await this._waitForFirstSyncHandle.promise;
+ if (this._sync.status.get() === SyncStatus.Stopped) {
+ if (this._sync.error) {
+ throw this._sync.error;
+ }
+ }
} catch (err) {
// if dispose is called from stop, bail out
if (err instanceof AbortError) {
diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js
index 73eeb7c1..4b4acd9d 100644
--- a/src/matrix/Sync.js
+++ b/src/matrix/Sync.js
@@ -119,6 +119,7 @@ export class Sync {
storeNames.session,
storeNames.roomSummary,
storeNames.roomState,
+ storeNames.roomMembers,
storeNames.timelineEvents,
storeNames.timelineFragments,
storeNames.pendingEvents,
@@ -148,6 +149,7 @@ export class Sync {
}
} catch(err) {
console.warn("aborting syncTxn because of error");
+ console.error(err);
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js
index c71ef0a5..ba4adb7f 100644
--- a/src/matrix/net/HomeServerApi.js
+++ b/src/matrix/net/HomeServerApi.js
@@ -45,6 +45,18 @@ class RequestWrapper {
}
}
+function encodeQueryParams(queryParams) {
+ return Object.entries(queryParams || {})
+ .filter(([, value]) => value !== undefined)
+ .map(([name, value]) => {
+ if (typeof value === "object") {
+ value = JSON.stringify(value);
+ }
+ return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
+ })
+ .join("&");
+}
+
export class HomeServerApi {
constructor({homeServer, accessToken, request, createTimeout, reconnector}) {
// store these both in a closure somehow so it's harder to get at in case of XSS?
@@ -54,26 +66,15 @@ export class HomeServerApi {
this._requestFn = request;
this._createTimeout = createTimeout;
this._reconnector = reconnector;
+ this._mediaRepository = new MediaRepository(homeServer);
}
_url(csPath) {
return `${this._homeserver}/_matrix/client/r0${csPath}`;
}
- _encodeQueryParams(queryParams) {
- return Object.entries(queryParams || {})
- .filter(([, value]) => value !== undefined)
- .map(([name, value]) => {
- if (typeof value === "object") {
- value = JSON.stringify(value);
- }
- return `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
- })
- .join("&");
- }
-
_request(method, url, queryParams, body, options) {
- const queryString = this._encodeQueryParams(queryParams);
+ const queryString = encodeQueryParams(queryParams);
url = `${url}?${queryString}`;
let bodyString;
const headers = new Map();
@@ -126,6 +127,11 @@ export class HomeServerApi {
return this._get(`/rooms/${encodeURIComponent(roomId)}/messages`, params, null, options);
}
+ // params is at, membership and not_membership
+ members(roomId, params, options = null) {
+ return this._get(`/rooms/${encodeURIComponent(roomId)}/members`, params, null, options);
+ }
+
send(roomId, eventType, txnId, content, options = null) {
return this._put(`/rooms/${encodeURIComponent(roomId)}/send/${encodeURIComponent(eventType)}/${encodeURIComponent(txnId)}`, {}, content, options);
}
@@ -149,13 +155,14 @@ export class HomeServerApi {
return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
}
- _parseMxcUrl(url) {
- const prefix = "mxc://";
- if (url.startsWith(prefix)) {
- return url.substr(prefix.length).split("/", 2);
- } else {
- return null;
- }
+ get mediaRepository() {
+ return this._mediaRepository;
+ }
+}
+
+class MediaRepository {
+ constructor(homeserver) {
+ this._homeserver = homeserver;
}
mxcUrlThumbnail(url, width, height, method) {
@@ -163,7 +170,7 @@ export class HomeServerApi {
if (parts) {
const [serverName, mediaId] = parts;
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
- return httpUrl + "?" + this._encodeQueryParams({width, height, method});
+ return httpUrl + "?" + encodeQueryParams({width, height, method});
}
return null;
}
@@ -177,6 +184,15 @@ export class HomeServerApi {
return null;
}
}
+
+ _parseMxcUrl(url) {
+ const prefix = "mxc://";
+ if (url.startsWith(prefix)) {
+ return url.substr(prefix.length).split("/", 2);
+ } else {
+ return null;
+ }
+ }
}
export function tests() {
diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js
index c922aea4..7bea8362 100644
--- a/src/matrix/room/Room.js
+++ b/src/matrix/room/Room.js
@@ -22,6 +22,8 @@ import {Timeline} from "./timeline/Timeline.js";
import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js";
import {SendQueue} from "./sending/SendQueue.js";
import {WrappedError} from "../error.js"
+import {fetchOrLoadMembers} from "./members/load.js";
+import {MemberList} from "./members/MemberList.js";
export class Room extends EventEmitter {
constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) {
@@ -36,22 +38,35 @@ export class Room extends EventEmitter {
this._sendQueue = new SendQueue({roomId, storage, sendScheduler, pendingEvents});
this._timeline = null;
this._user = user;
+ this._changedMembersDuringSync = null;
}
+ /** @package */
async writeSync(roomResponse, membership, txn) {
const summaryChanges = this._summary.writeSync(roomResponse, membership, txn);
- const {entries, newLiveKey} = await this._syncWriter.writeSync(roomResponse, txn);
+ const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn);
let removedPendingEvents;
if (roomResponse.timeline && roomResponse.timeline.events) {
removedPendingEvents = this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn);
}
- return {summaryChanges, newTimelineEntries: entries, newLiveKey, removedPendingEvents};
+ return {summaryChanges, newTimelineEntries: entries, newLiveKey, removedPendingEvents, changedMembers};
}
- afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents}) {
+ /** @package */
+ afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, changedMembers}) {
this._syncWriter.afterSync(newLiveKey);
+ if (changedMembers.length) {
+ if (this._changedMembersDuringSync) {
+ for (const member of changedMembers) {
+ this._changedMembersDuringSync.set(member.userId, member);
+ }
+ }
+ if (this._memberList) {
+ this._memberList.afterSync(changedMembers);
+ }
+ }
if (summaryChanges) {
- this._summary.afterSync(summaryChanges);
+ this._summary.applyChanges(summaryChanges);
this.emit("change");
this._emitCollectionChange(this);
}
@@ -63,10 +78,12 @@ export class Room extends EventEmitter {
}
}
+ /** @package */
resumeSending() {
this._sendQueue.resumeSending();
}
+ /** @package */
load(summary, txn) {
try {
this._summary.load(summary);
@@ -76,13 +93,36 @@ export class Room extends EventEmitter {
}
}
+ /** @public */
sendEvent(eventType, content) {
return this._sendQueue.enqueueEvent(eventType, content);
}
+ /** @public */
+ async loadMemberList() {
+ if (this._memberList) {
+ this._memberList.retain();
+ return this._memberList;
+ } else {
+ const members = await fetchOrLoadMembers({
+ summary: this._summary,
+ roomId: this._roomId,
+ hsApi: this._hsApi,
+ storage: this._storage,
+ // to handle race between /members and /sync
+ setChangedMembersMap: map => this._changedMembersDuringSync = map,
+ });
+ this._memberList = new MemberList({
+ members,
+ closeCallback: () => { this._memberList = null; }
+ });
+ return this._memberList;
+ }
+ }
/** @public */
async fillGap(fragmentEntry, amount) {
+ // TODO move some/all of this out of Room
if (fragmentEntry.edgeReached) {
return;
}
@@ -90,7 +130,10 @@ export class Room extends EventEmitter {
from: fragmentEntry.token,
dir: fragmentEntry.direction.asApiString(),
limit: amount,
- filter: {lazy_load_members: true}
+ filter: {
+ lazy_load_members: true,
+ include_redundant_members: true,
+ }
}).response();
const txn = await this._storage.readWriteTxn([
@@ -127,14 +170,17 @@ export class Room extends EventEmitter {
}
}
+ /** @public */
get name() {
return this._summary.name;
}
+ /** @public */
get id() {
return this._roomId;
}
+ /** @public */
async openTimeline() {
if (this._timeline) {
throw new Error("not dealing with load race here for now");
@@ -155,12 +201,8 @@ export class Room extends EventEmitter {
return this._timeline;
}
- mxcUrlThumbnail(url, width, height, method) {
- return this._hsApi.mxcUrlThumbnail(url, width, height, method);
- }
-
- mxcUrl(url) {
- return this._hsApi.mxcUrl(url);
+ get mediaRepository() {
+ return this._hsApi.mediaRepository;
}
}
diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js
index 3ad6909a..dd443be3 100644
--- a/src/matrix/room/RoomSummary.js
+++ b/src/matrix/room/RoomSummary.js
@@ -27,7 +27,12 @@ function applySyncResponse(data, roomResponse, membership) {
data = roomResponse.state.events.reduce(processEvent, data);
}
if (roomResponse.timeline) {
- data = roomResponse.timeline.events.reduce(processEvent, data);
+ const {timeline} = roomResponse;
+ if (timeline.prev_batch) {
+ data = data.cloneIfNeeded();
+ data.lastPaginationToken = timeline.prev_batch;
+ }
+ data = timeline.events.reduce(processEvent, data);
}
return data;
@@ -98,6 +103,8 @@ class SummaryData {
this.heroes = copy ? copy.heroes : null;
this.canonicalAlias = copy ? copy.canonicalAlias : null;
this.altAliases = copy ? copy.altAliases : null;
+ this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false;
+ this.lastPaginationToken = copy ? copy.lastPaginationToken : null;
this.cloned = copy ? true : false;
}
@@ -148,6 +155,21 @@ export class RoomSummary {
return this._data.joinCount;
}
+ get hasFetchedMembers() {
+ return this._data.hasFetchedMembers;
+ }
+
+ get lastPaginationToken() {
+ return this._data.lastPaginationToken;
+ }
+
+ writeHasFetchedMembers(value, txn) {
+ const data = new SummaryData(this._data);
+ data.hasFetchedMembers = value;
+ txn.roomSummary.set(data.serialize());
+ return data;
+ }
+
writeSync(roomResponse, membership, txn) {
// clear cloned flag, so cloneIfNeeded makes a copy and
// this._data is not modified if any field is changed.
@@ -165,7 +187,7 @@ export class RoomSummary {
}
}
- afterSync(data) {
+ applyChanges(data) {
this._data = data;
}
diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js
new file mode 100644
index 00000000..f428ed6c
--- /dev/null
+++ b/src/matrix/room/members/MemberList.js
@@ -0,0 +1,49 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {ObservableMap} from "../../../observable/map/ObservableMap.js";
+
+export class MemberList {
+ constructor({members, closeCallback}) {
+ this._members = new ObservableMap();
+ for (const member of members) {
+ this._members.add(member.userId, member);
+ }
+ this._closeCallback = closeCallback;
+ this._retentionCount = 1;
+ }
+
+ afterSync(updatedMembers) {
+ for (const member of updatedMembers) {
+ this._members.add(member.userId, member);
+ }
+ }
+
+ get members() {
+ return this._members;
+ }
+
+ retain() {
+ this._retentionCount += 1;
+ }
+
+ release() {
+ this._retentionCount -= 1;
+ if (this._retentionCount === 0) {
+ this._closeCallback();
+ }
+ }
+}
diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js
new file mode 100644
index 00000000..4c38f66b
--- /dev/null
+++ b/src/matrix/room/members/RoomMember.js
@@ -0,0 +1,68 @@
+/*
+Copyright 2020 Bruno Windels
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export const EVENT_TYPE = "m.room.member";
+
+export class RoomMember {
+ constructor(data) {
+ this._data = data;
+ }
+
+ static fromMemberEvent(roomId, memberEvent) {
+ const userId = memberEvent && memberEvent.state_key;
+ if (typeof userId !== "string") {
+ return;
+ }
+ return this._fromMemberEventContent(roomId, userId, memberEvent.content);
+ }
+
+ static fromReplacingMemberEvent(roomId, memberEvent) {
+ const userId = memberEvent && memberEvent.state_key;
+ if (typeof userId !== "string") {
+ return;
+ }
+ return this._fromMemberEventContent(roomId, userId, memberEvent.prev_content);
+ }
+
+ static _fromMemberEventContent(roomId, userId, content) {
+ const membership = content?.membership;
+ const avatarUrl = content?.avatar_url;
+ const displayName = content?.displayname;
+ if (typeof membership !== "string") {
+ return;
+ }
+ return new RoomMember({
+ roomId,
+ userId,
+ membership,
+ avatarUrl,
+ displayName,
+ });
+ }
+
+ get roomId() {
+ return this._data.roomId;
+ }
+
+ get userId() {
+ return this._data.userId;
+ }
+
+ serialize() {
+ return this._data;
+ }
+}
diff --git a/src/matrix/room/members/load.js b/src/matrix/room/members/load.js
new file mode 100644
index 00000000..54d7c3dc
--- /dev/null
+++ b/src/matrix/room/members/load.js
@@ -0,0 +1,90 @@
+/*
+Copyright 2020 Bruno Windels
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import {RoomMember} from "./RoomMember.js";
+
+async function loadMembers({roomId, storage}) {
+ const txn = await storage.readTxn([
+ storage.storeNames.roomMembers,
+ ]);
+ const memberDatas = await txn.roomMembers.getAll(roomId);
+ return memberDatas.map(d => new RoomMember(d));
+}
+
+async function fetchMembers({summary, roomId, hsApi, storage, setChangedMembersMap}) {
+ // if any members are changed by sync while we're fetching members,
+ // they will end up here, so we check not to override them
+ const changedMembersDuringSync = new Map();
+ setChangedMembersMap(changedMembersDuringSync);
+
+ const memberResponse = await hsApi.members(roomId, {at: summary.lastPaginationToken}).response;
+
+ const txn = await storage.readWriteTxn([
+ storage.storeNames.roomSummary,
+ storage.storeNames.roomMembers,
+ ]);
+
+ let summaryChanges;
+ let members;
+
+ try {
+ summaryChanges = summary.writeHasFetchedMembers(true, txn);
+ const {roomMembers} = txn;
+ const memberEvents = memberResponse.chunk;
+ if (!Array.isArray(memberEvents)) {
+ throw new Error("malformed");
+ }
+ members = await Promise.all(memberEvents.map(async memberEvent => {
+ const userId = memberEvent?.state_key;
+ if (!userId) {
+ throw new Error("malformed");
+ }
+ // this member was changed during a sync that happened while calling /members
+ // and thus is more recent, so don't overwrite
+ const changedMember = changedMembersDuringSync.get(userId);
+ if (changedMember) {
+ return changedMember;
+ } else {
+ const member = RoomMember.fromMemberEvent(roomId, memberEvent);
+ if (member) {
+ roomMembers.set(member.serialize());
+ }
+ return member;
+ }
+ }));
+ } catch (err) {
+ // abort txn on any error
+ txn.abort();
+ throw err;
+ } finally {
+ // important this gets cleared
+ // or otherwise Room remains in "fetching-members" mode
+ setChangedMembersMap(null);
+ }
+ await txn.complete();
+ summary.applyChanges(summaryChanges);
+ return members;
+}
+
+export async function fetchOrLoadMembers(options) {
+ const {summary} = options;
+ if (!summary.hasFetchedMembers) {
+ return fetchMembers(options);
+ } else {
+ return loadMembers(options);
+ }
+}
diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js
index ead383fa..d1d5b64c 100644
--- a/src/matrix/room/timeline/entries/EventEntry.js
+++ b/src/matrix/room/timeline/entries/EventEntry.js
@@ -50,6 +50,14 @@ export class EventEntry extends BaseEntry {
return this._eventEntry.event.sender;
}
+ get displayName() {
+ return this._eventEntry.displayName;
+ }
+
+ get avatarUrl() {
+ return this._eventEntry.avatarUrl;
+ }
+
get timestamp() {
return this._eventEntry.event.origin_server_ts;
}
diff --git a/src/matrix/room/timeline/persistence/GapWriter.js b/src/matrix/room/timeline/persistence/GapWriter.js
index 11b774d3..834239f7 100644
--- a/src/matrix/room/timeline/persistence/GapWriter.js
+++ b/src/matrix/room/timeline/persistence/GapWriter.js
@@ -17,6 +17,7 @@ limitations under the License.
import {EventKey} from "../EventKey.js";
import {EventEntry} from "../entries/EventEntry.js";
import {createEventEntry, directionalAppend} from "./common.js";
+import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
export class GapWriter {
constructor({roomId, storage, fragmentIdComparer}) {
@@ -98,14 +99,20 @@ export class GapWriter {
}
}
- _storeEvents(events, startKey, direction, txn) {
+ _storeEvents(events, startKey, direction, state, txn) {
const entries = [];
// events is in reverse chronological order for backwards pagination,
// e.g. order is moving away from the `from` point.
let key = startKey;
- for(let event of events) {
+ for (let i = 0; i < events.length; ++i) {
+ const event = events[i];
key = key.nextKeyForDirection(direction);
const eventStorageEntry = createEventEntry(key, this._roomId, event);
+ const memberData = this._findMemberData(event.sender, state, events, i, direction);
+ if (memberData) {
+ eventStorageEntry.displayName = memberData?.displayName;
+ eventStorageEntry.avatarUrl = memberData?.avatarUrl;
+ }
txn.timelineEvents.insert(eventStorageEntry);
const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer);
directionalAppend(entries, eventEntry, direction);
@@ -113,6 +120,35 @@ export class GapWriter {
return entries;
}
+ _findMemberData(userId, state, events, index, direction) {
+ function isOurUser(event) {
+ return event.type === MEMBER_EVENT_TYPE && event.state_key === userId;
+ }
+ // older messages are at a higher index in the array when going backwards
+ const inc = direction.isBackward ? 1 : -1;
+ for (let i = index + inc; i >= 0 && i < events.length; i += inc) {
+ const event = events[i];
+ if (isOurUser(event)) {
+ return RoomMember.fromMemberEvent(this._roomId, event)?.serialize();
+ }
+ }
+ // look into newer events, but using prev_content if found.
+ // We do this before looking into `state` because it is not well specified
+ // in the spec whether the events in there represent state before or after `chunk`.
+ // So we look both directions first in chunk to make sure it doesn't matter.
+ for (let i = index; i >= 0 && i < events.length; i -= inc) {
+ const event = events[i];
+ if (isOurUser(event)) {
+ return RoomMember.fromReplacingMemberEvent(this._roomId, event)?.serialize();
+ }
+ }
+ // assuming the member hasn't changed within the chunk, just take it from state if it's there
+ const stateMemberEvent = state.find(isOurUser);
+ if (stateMemberEvent) {
+ return RoomMember.fromMemberEvent(this._roomId, stateMemberEvent)?.serialize();
+ }
+ }
+
async _updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn) {
const {direction} = fragmentEntry;
const changedFragments = [];
@@ -158,7 +194,7 @@ export class GapWriter {
async writeFragmentFill(fragmentEntry, response, txn) {
const {fragmentId, direction} = fragmentEntry;
// chunk is in reverse-chronological order when backwards
- const {chunk, start, end} = response;
+ const {chunk, start, end, state} = response;
let entries;
if (!Array.isArray(chunk)) {
@@ -195,7 +231,7 @@ export class GapWriter {
} = await this._findOverlappingEvents(fragmentEntry, chunk, txn);
// create entries for all events in chunk, add them to entries
- entries = this._storeEvents(nonOverlappingEvents, lastKey, direction, txn);
+ entries = this._storeEvents(nonOverlappingEvents, lastKey, direction, state, txn);
const fragments = await this._updateFragments(fragmentEntry, neighbourFragmentEntry, end, entries, txn);
return {entries, fragments};
diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js
index 7f168d03..5cbd6779 100644
--- a/src/matrix/room/timeline/persistence/SyncWriter.js
+++ b/src/matrix/room/timeline/persistence/SyncWriter.js
@@ -18,6 +18,7 @@ import {EventKey} from "../EventKey.js";
import {EventEntry} from "../entries/EventEntry.js";
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
import {createEventEntry} from "./common.js";
+import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
// Synapse bug? where the m.room.create event appears twice in sync response
// when first syncing the room
@@ -97,9 +98,87 @@ export class SyncWriter {
return {oldFragment, newFragment};
}
+ _writeStateEvent(event, txn) {
+ if (event.type === MEMBER_EVENT_TYPE) {
+ const userId = event.state_key;
+ if (userId) {
+ const member = RoomMember.fromMemberEvent(this._roomId, event);
+ if (member) {
+ // as this is sync, we can just replace the member
+ // if it is there already
+ txn.roomMembers.set(member.serialize());
+ }
+ return member;
+ }
+ } else {
+ txn.roomState.set(this._roomId, event);
+ }
+ }
+
+ _writeStateEvents(roomResponse, txn) {
+ const changedMembers = [];
+ // persist state
+ const {state} = roomResponse;
+ if (state.events) {
+ for (const event of state.events) {
+ const member = this._writeStateEvent(event, txn);
+ if (member) {
+ changedMembers.push(member);
+ }
+ }
+ }
+ return changedMembers;
+ }
+
+ async _writeTimeline(entries, timeline, currentKey, txn) {
+ const changedMembers = [];
+ if (timeline.events) {
+ const events = deduplicateEvents(timeline.events);
+ for(const event of events) {
+ // store event in timeline
+ currentKey = currentKey.nextKey();
+ const entry = createEventEntry(currentKey, this._roomId, event);
+ let memberData = await this._findMemberData(event.sender, events, txn);
+ if (memberData) {
+ entry.displayName = memberData.displayName;
+ entry.avatarUrl = memberData.avatarUrl;
+ }
+ txn.timelineEvents.insert(entry);
+ entries.push(new EventEntry(entry, this._fragmentIdComparer));
+
+ // process live state events first, so new member info is available
+ if (typeof event.state_key === "string") {
+ const member = this._writeStateEvent(event, txn);
+ if (member) {
+ changedMembers.push(member);
+ }
+ }
+ }
+ }
+ return {currentKey, changedMembers};
+ }
+
+ async _findMemberData(userId, events, txn) {
+ // TODO: perhaps add a small cache here?
+ const memberData = await txn.roomMembers.get(this._roomId, userId);
+ if (memberData) {
+ return memberData;
+ } else {
+ // sometimes the member event isn't included in state, but rather in the timeline,
+ // even if it is not the first event in the timeline. In this case, go look for the
+ // first occurence
+ const memberEvent = events.find(e => {
+ return e.type === MEMBER_EVENT_TYPE && e.state_key === userId;
+ });
+ if (memberEvent) {
+ return RoomMember.fromMemberEvent(this._roomId, memberEvent)?.serialize();
+ }
+ }
+ }
+
async writeSync(roomResponse, txn) {
const entries = [];
- const timeline = roomResponse.timeline;
+ const {timeline} = roomResponse;
let currentKey = this._lastLiveKey;
if (!currentKey) {
// means we haven't synced this room yet (just joined or did initial sync)
@@ -117,32 +196,14 @@ export class SyncWriter {
entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer));
entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer));
}
- if (timeline.events) {
- const events = deduplicateEvents(timeline.events);
- for(const event of events) {
- currentKey = currentKey.nextKey();
- const entry = createEventEntry(currentKey, this._roomId, event);
- txn.timelineEvents.insert(entry);
- entries.push(new EventEntry(entry, this._fragmentIdComparer));
- }
- }
- // persist state
- const state = roomResponse.state;
- if (state.events) {
- for (const event of state.events) {
- txn.roomState.setStateEvent(this._roomId, event);
- }
- }
- // persist live state events in timeline
- if (timeline.events) {
- for (const event of timeline.events) {
- if (typeof event.state_key === "string") {
- txn.roomState.setStateEvent(this._roomId, event);
- }
- }
- }
+ // important this happens before _writeTimeline so
+ // members are available in the transaction
+ const changedMembers = this._writeStateEvents(roomResponse, txn);
+ const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn);
+ currentKey = timelineResult.currentKey;
+ changedMembers.push(...timelineResult.changedMembers);
- return {entries, newLiveKey: currentKey};
+ return {entries, newLiveKey: currentKey, changedMembers};
}
afterSync(newLiveKey) {
diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js
index 87c81e17..0cf5b9b0 100644
--- a/src/matrix/storage/common.js
+++ b/src/matrix/storage/common.js
@@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([
"session",
"roomState",
"roomSummary",
+ "roomMembers",
"timelineEvents",
"timelineFragments",
"pendingEvents",
@@ -37,7 +38,7 @@ export class StorageError extends Error {
fullMessage += `(name: ${cause.name}) `;
}
if (typeof cause.code === "number") {
- fullMessage += `(code: ${cause.name}) `;
+ fullMessage += `(code: ${cause.code}) `;
}
}
if (value) {
diff --git a/src/matrix/storage/idb/QueryTarget.js b/src/matrix/storage/idb/QueryTarget.js
index 9b6f3036..0738df60 100644
--- a/src/matrix/storage/idb/QueryTarget.js
+++ b/src/matrix/storage/idb/QueryTarget.js
@@ -151,17 +151,31 @@ export class QueryTarget {
}
_selectLimit(range, amount, direction) {
- return this._selectWhile(range, (results) => {
+ return this._selectUntil(range, (results) => {
return results.length === amount;
}, direction);
}
- async _selectWhile(range, predicate, direction) {
+ async _selectUntil(range, predicate, direction) {
const cursor = this._openCursor(range, direction);
const results = [];
await iterateCursor(cursor, (value) => {
results.push(value);
- return {done: predicate(results)};
+ return {done: predicate(results, value)};
+ });
+ return results;
+ }
+
+ // allows you to fetch one too much that won't get added when the predicate fails
+ async _selectWhile(range, predicate, direction) {
+ const cursor = this._openCursor(range, direction);
+ const results = [];
+ await iterateCursor(cursor, (value) => {
+ const passesPredicate = predicate(value);
+ if (passesPredicate) {
+ results.push(value);
+ }
+ return {done: !passesPredicate};
});
return results;
}
diff --git a/src/matrix/storage/idb/StorageFactory.js b/src/matrix/storage/idb/StorageFactory.js
index f5e252dc..0b58d5dc 100644
--- a/src/matrix/storage/idb/StorageFactory.js
+++ b/src/matrix/storage/idb/StorageFactory.js
@@ -17,9 +17,10 @@ limitations under the License.
import {Storage} from "./Storage.js";
import { openDatabase, reqAsPromise } from "./utils.js";
import { exportSession, importSession } from "./export.js";
+import { schema } from "./schema.js";
const sessionName = sessionId => `brawl_session_${sessionId}`;
-const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, 1);
+const openDatabaseWithSessionId = sessionId => openDatabase(sessionName(sessionId), createStores, schema.length);
export class StorageFactory {
async create(sessionId) {
@@ -44,26 +45,10 @@ export class StorageFactory {
}
}
-function createStores(db) {
- db.createObjectStore("session", {keyPath: "key"});
- // any way to make keys unique here? (just use put?)
- db.createObjectStore("roomSummary", {keyPath: "roomId"});
+async function createStores(db, txn, oldVersion, version) {
+ const startIdx = oldVersion || 0;
- // need index to find live fragment? prooobably ok without for now
- //key = room_id | fragment_id
- db.createObjectStore("timelineFragments", {keyPath: "key"});
- //key = room_id | fragment_id | event_index
- const timelineEvents = db.createObjectStore("timelineEvents", {keyPath: "key"});
- //eventIdKey = room_id | event_id
- timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true});
- //key = room_id | event.type | event.state_key,
- db.createObjectStore("roomState", {keyPath: "key"});
- db.createObjectStore("pendingEvents", {keyPath: "key"});
-
- // const roomMembers = db.createObjectStore("roomMembers", {keyPath: [
- // "event.room_id",
- // "event.content.membership",
- // "event.state_key"
- // ]});
- // roomMembers.createIndex("byName", ["room_id", "content.name"]);
+ for(let i = startIdx; i < version; ++i) {
+ await schema[i](db, txn);
+ }
}
diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js
index 90b4dd63..4f5e3af5 100644
--- a/src/matrix/storage/idb/Transaction.js
+++ b/src/matrix/storage/idb/Transaction.js
@@ -21,6 +21,7 @@ import {SessionStore} from "./stores/SessionStore.js";
import {RoomSummaryStore} from "./stores/RoomSummaryStore.js";
import {TimelineEventStore} from "./stores/TimelineEventStore.js";
import {RoomStateStore} from "./stores/RoomStateStore.js";
+import {RoomMemberStore} from "./stores/RoomMemberStore.js";
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
import {PendingEventStore} from "./stores/PendingEventStore.js";
@@ -72,6 +73,10 @@ export class Transaction {
return this._store("roomState", idbStore => new RoomStateStore(idbStore));
}
+ get roomMembers() {
+ return this._store("roomMembers", idbStore => new RoomMemberStore(idbStore));
+ }
+
get pendingEvents() {
return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore));
}
diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js
new file mode 100644
index 00000000..21a108c8
--- /dev/null
+++ b/src/matrix/storage/idb/schema.js
@@ -0,0 +1,46 @@
+import {iterateCursor} from "./utils.js";
+import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../room/members/RoomMember.js";
+import {RoomMemberStore} from "./stores/RoomMemberStore.js";
+
+// FUNCTIONS SHOULD ONLY BE APPENDED!!
+// the index in the array is the database version
+export const schema = [
+ createInitialStores,
+ createMemberStore,
+];
+// TODO: how to deal with git merge conflicts of this array?
+
+
+// how do we deal with schema updates vs existing data migration in a way that
+//v1
+function createInitialStores(db) {
+ db.createObjectStore("session", {keyPath: "key"});
+ // any way to make keys unique here? (just use put?)
+ db.createObjectStore("roomSummary", {keyPath: "roomId"});
+
+ // need index to find live fragment? prooobably ok without for now
+ //key = room_id | fragment_id
+ db.createObjectStore("timelineFragments", {keyPath: "key"});
+ //key = room_id | fragment_id | event_index
+ const timelineEvents = db.createObjectStore("timelineEvents", {keyPath: "key"});
+ //eventIdKey = room_id | event_id
+ timelineEvents.createIndex("byEventId", "eventIdKey", {unique: true});
+ //key = room_id | event.type | event.state_key,
+ db.createObjectStore("roomState", {keyPath: "key"});
+ db.createObjectStore("pendingEvents", {keyPath: "key"});
+}
+//v2
+async function createMemberStore(db, txn) {
+ const roomMembers = new RoomMemberStore(db.createObjectStore("roomMembers", {keyPath: "key"}));
+ // migrate existing member state events over
+ const roomState = txn.objectStore("roomState");
+ await iterateCursor(roomState.openCursor(), entry => {
+ if (entry.event.type === MEMBER_EVENT_TYPE) {
+ roomState.delete(entry.key);
+ const member = RoomMember.fromMemberEvent(entry.roomId, entry.event);
+ if (member) {
+ roomMembers.set(member.serialize());
+ }
+ }
+ });
+}
diff --git a/src/matrix/storage/idb/stores/MemberStore.js b/src/matrix/storage/idb/stores/MemberStore.js
deleted file mode 100644
index 27bb6b84..00000000
--- a/src/matrix/storage/idb/stores/MemberStore.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
-Copyright 2020 Bruno Windels
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-// no historical members for now
-class MemberStore {
- async getMember(roomId, userId) {
-
- }
-
- /* async getMemberAtSortKey(roomId, userId, sortKey) {
-
- } */
- // multiple members here? does it happen at same sort key?
- async setMembers(roomId, members) {
-
- }
-
- async getSortedMembers(roomId, offset, amount) {
-
- }
-}
diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.js b/src/matrix/storage/idb/stores/RoomMemberStore.js
new file mode 100644
index 00000000..aa979056
--- /dev/null
+++ b/src/matrix/storage/idb/stores/RoomMemberStore.js
@@ -0,0 +1,43 @@
+/*
+Copyright 2020 Bruno Windels
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+function encodeKey(roomId, userId) {
+ return `${roomId}|${userId}`;
+}
+
+// no historical members
+export class RoomMemberStore {
+ constructor(roomMembersStore) {
+ this._roomMembersStore = roomMembersStore;
+ }
+
+ get(roomId, userId) {
+ return this._roomMembersStore.get(encodeKey(roomId, userId));
+ }
+
+ async set(member) {
+ member.key = encodeKey(member.roomId, member.userId);
+ return this._roomMembersStore.put(member);
+ }
+
+ getAll(roomId) {
+ const range = IDBKeyRange.lowerBound(encodeKey(roomId, ""));
+ return this._roomMembersStore.selectWhile(range, member => {
+ return member.roomId === roomId;
+ });
+ }
+}
diff --git a/src/matrix/storage/idb/stores/RoomStateStore.js b/src/matrix/storage/idb/stores/RoomStateStore.js
index a216ee28..0cec87bc 100644
--- a/src/matrix/storage/idb/stores/RoomStateStore.js
+++ b/src/matrix/storage/idb/stores/RoomStateStore.js
@@ -19,15 +19,15 @@ export class RoomStateStore {
this._roomStateStore = idbStore;
}
- async getEvents(type) {
+ async getAllForType(type) {
}
- async getEventsForKey(type, stateKey) {
-
+ async get(type, stateKey) {
+
}
- async setStateEvent(roomId, event) {
+ async set(roomId, event) {
const key = `${roomId}|${event.type}|${event.state_key}`;
const entry = {roomId, event, key};
return this._roomStateStore.put(entry);
diff --git a/src/matrix/storage/idb/utils.js b/src/matrix/storage/idb/utils.js
index f9d98d4a..7cdc30fd 100644
--- a/src/matrix/storage/idb/utils.js
+++ b/src/matrix/storage/idb/utils.js
@@ -41,8 +41,9 @@ export function openDatabase(name, createObjectStore, version) {
const req = window.indexedDB.open(name, version);
req.onupgradeneeded = (ev) => {
const db = ev.target.result;
+ const txn = ev.target.transaction;
const oldVersion = ev.oldVersion;
- createObjectStore(db, oldVersion, version);
+ createObjectStore(db, txn, oldVersion, version);
};
return reqAsPromise(req);
}
@@ -74,7 +75,10 @@ export function iterateCursor(cursorRequest, processValue) {
resolve(false);
return; // end of results
}
- const {done, jumpTo} = processValue(cursor.value, cursor.key);
+ const result = processValue(cursor.value, cursor.key);
+ const done = result?.done;
+ const jumpTo = result?.jumpTo;
+
if (done) {
resolve(true);
} else if(jumpTo) {
diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css
index 53ccb2f8..82754140 100644
--- a/src/ui/web/css/themes/element/theme.css
+++ b/src/ui/web/css/themes/element/theme.css
@@ -297,15 +297,25 @@ ul.Timeline > li:not(.continuation) {
margin-top: 7px;
}
-ul.Timeline > li.continuation .sender {
+ul.Timeline > li.continuation .profile {
display: none;
}
+
.message-container {
padding: 1px 10px 0px 10px;
margin: 5px 10px 0 10px;
}
+.message-container .profile {
+ display: flex;
+ align-items: center;
+}
+
+.message-container .avatar {
+ --avatar-size: 25px;
+}
+
.TextMessageView.continuation .message-container {
margin-top: 0;
margin-bottom: 0;
@@ -313,6 +323,7 @@ ul.Timeline > li.continuation .sender {
.message-container .sender {
margin: 6px 0;
+ margin-left: 6px;
font-weight: bold;
line-height: 1.7rem;
}
diff --git a/src/ui/web/css/timeline.css b/src/ui/web/css/timeline.css
index 14b60b26..469e117f 100644
--- a/src/ui/web/css/timeline.css
+++ b/src/ui/web/css/timeline.css
@@ -45,7 +45,7 @@ limitations under the License.
replace with css aspect-ratio once supported */
}
-.message-container img {
+.message-container img.picture {
display: block;
position: absolute;
top: 0;
diff --git a/src/ui/web/session/room/TimelineList.js b/src/ui/web/session/room/TimelineList.js
index 8838963c..b43fcc27 100644
--- a/src/ui/web/session/room/TimelineList.js
+++ b/src/ui/web/session/room/TimelineList.js
@@ -48,8 +48,8 @@ export class TimelineList extends ListView {
while (predicate()) {
// fill, not enough content to fill timeline
this._topLoadingPromise = this._viewModel.loadAtTop();
- const startReached = await this._topLoadingPromise;
- if (startReached) {
+ const shouldStop = await this._topLoadingPromise;
+ if (shouldStop) {
break;
}
}
diff --git a/src/ui/web/session/room/timeline/ImageView.js b/src/ui/web/session/room/timeline/ImageView.js
index 4770510c..69360b75 100644
--- a/src/ui/web/session/room/timeline/ImageView.js
+++ b/src/ui/web/session/room/timeline/ImageView.js
@@ -22,6 +22,7 @@ export class ImageView extends TemplateView {
// replace with css aspect-ratio once supported
const heightRatioPercent = (vm.thumbnailHeight / vm.thumbnailWidth) * 100;
const image = t.img({
+ className: "picture",
src: vm.thumbnailUrl,
width: vm.thumbnailWidth,
height: vm.thumbnailHeight,
diff --git a/src/ui/web/session/room/timeline/common.js b/src/ui/web/session/room/timeline/common.js
index 18bf0be0..b7965905 100644
--- a/src/ui/web/session/room/timeline/common.js
+++ b/src/ui/web/session/room/timeline/common.js
@@ -22,8 +22,20 @@ export function renderMessage(t, vm, children) {
pending: vm.isPending,
continuation: vm => vm.isContinuation,
};
- const sender = t.div({className: `sender usercolor${vm.senderColorNumber}`}, vm.sender);
- children = [sender].concat(children);
+
+ const hasAvatar = !!vm.avatarUrl;
+ const avatarClasses = {
+ avatar: true,
+ [`usercolor${vm.avatarColorNumber}`]: !hasAvatar,
+ };
+ const avatarContent = hasAvatar ?
+ t.img({src: vm.avatarUrl, width: "30", height: "30", title: vm.sender}) :
+ vm.avatarLetter;
+ const profile = t.div({className: "profile"}, [
+ t.div({className: avatarClasses}, [avatarContent]),
+ t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.sender)
+ ]);
+ children = [profile].concat(children);
return t.li(
{className: classes},
t.div({className: "message-container"}, children)