Merge pull request #59 from vector-im/bwindels/memberlist

Add avatars and display names to the timeline
This commit is contained in:
Bruno Windels 2020-08-20 14:58:47 +00:00 committed by GitHub
commit 2241add672
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 773 additions and 170 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

1
lib/olm.js Symbolic link
View file

@ -0,0 +1 @@
../node_modules/olm/olm.js

1
lib/olm.wasm Symbolic link
View file

@ -0,0 +1 @@
../node_modules/olm/olm.wasm

58
prototypes/olmtest.html Normal file
View file

@ -0,0 +1,58 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
pre {
font-family: monospace;
display: block;
white-space: pre;
font-size: 2em;
}
</style>
</head>
<body>
<script type="text/javascript" src="../lib/olm.js"></script>
<script type="module">
async function main() {
const Olm = window.Olm;
await Olm.init({
locateFile: () => "../lib/olm.wasm",
});
const alice = new Olm.Account();
alice.create();
console.log("alice", alice.identity_keys());
const bob = new Olm.Account();
bob.unpickle("secret", "EWfA87or4GgQ+wqVkyuFiW9gUk3FI6QSXgp8E2dS5RFLvXgy4oFvxwQ1gVnbMkdJz2Hy9ex9UmJ/ZyuRU0aRt0IwXpw/SUNq4IQeVJ7J/miXW7rV4Ep+4RSEf945KbDrokDCS2CoL5PIfv/NYyey32gA0hMi8wWIfIlOxFBV4SBJYSC+Qd54VjprwCg0Sn9vjQouKVrM/+5jzsv9+JK5OpWW0Vrb3qrXwyAOEAQ4WlOQcqZHAyPQIw");
console.log("bob", bob.identity_keys());
// generate OTK on receiver side
bob.generate_one_time_keys(1);
const bobOneTimeKeys = JSON.parse(bob.one_time_keys());
const bobOneTimeKey = Object.values(bobOneTimeKeys.curve25519)[0];
// encrypt
const aliceSession = new Olm.Session();
aliceSession.create_outbound(
alice,
JSON.parse(bob.identity_keys()).curve25519,
bobOneTimeKey,
);
const message = aliceSession.encrypt("hello secret world");
console.log("message", message);
// decrypt
const bobSession = new Olm.Session();
bobSession.create_inbound(bob, message.body);
const plaintext = bobSession.decrypt(message.type, message.body);
console.log("plaintext", plaintext);
// remove Bob's OTK as it was used to start an olm session
console.log("bob OTK before removing", bob.one_time_keys());
bob.remove_one_time_keys(bobSession);
console.log("bob OTK after removing", bob.one_time_keys());
}
main();
</script>
</body>
</html>

View file

@ -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");
}
}

View file

@ -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) {

View file

@ -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");

View file

@ -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;
}

View file

@ -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"});
}

View file

@ -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}`;

View file

@ -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}"`
}
}

View file

@ -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:

View file

@ -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);
}
}

View file

@ -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) {

View file

@ -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

View file

@ -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() {

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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();
}
}
}

View file

@ -0,0 +1,68 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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;
}
}

View file

@ -0,0 +1,90 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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);
}
}

View file

@ -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;
}

View file

@ -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};

View file

@ -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) {

View file

@ -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) {

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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));
}

View file

@ -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());
}
}
});
}

View file

@ -1,34 +0,0 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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) {
}
}

View file

@ -0,0 +1,43 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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;
});
}
}

View file

@ -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);

View file

@ -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) {

View file

@ -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;
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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)