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)