Merge branch 'master' of github.com:vector-im/hydrogen-web into rxl881/ems-guest-sync

This commit is contained in:
Richard Lewis 2021-07-07 13:47:45 +01:00
commit 250054059c
83 changed files with 2984 additions and 472 deletions

View file

@ -12,6 +12,6 @@ module.exports = {
"no-console": "off",
"no-empty": "off",
"no-prototype-builtins": "off",
"no-unused-vars": "warn",
"no-unused-vars": "warn"
}
};

View file

@ -1,5 +1,15 @@
# FAQ
## What browsers are supported?
Internet Explorer 11, Chrome [1], Firefox [1] (not in a private window), Edge [1], Safari [1] and any mobile versions of these. It will probably also work on any derivatives of these.
1: Because of https://github.com/vector-im/hydrogen-web/issues/230, only [more recent versions](https://caniuse.com/mdn-javascript_operators_optional_chaining) are supported.
TorBrowser ships a crippled IndexedDB implementation and will not work. At some point we should support a memory store as a fallback, but that will still give a sub-par experience with end-to-end encryption.
It used work in pre-webkit Edge, to have it work on Windows Phone, but that support has probably bit-rotted as it isn't tested anymore.
## Is there a way to run the app as a desktop app?
You can install Hydrogen as a PWA using Chrome/Chromium on any platform or Edge on Windows. Gnome Web/Ephiphany also allows to "Install site as web application". There is no Electron build of Hydrogen, and there will likely be none in the near future, as Electron complicates the release process considerably. Once Hydrogen is more mature and feature complete, we might reconsider and use [Tauri](https://tauri.studio) if there are compelling use cases not possible with PWAs. For now though, we want to keep development and releasing fast and nimble ;)
@ -12,10 +22,6 @@ If you can't find an easy way to locate the feature you are looking for, then th
That's not yet a feature, as hydrogen just uses a single line text box for message input for now.
### Hmm does Hydrogen not support leaving rooms? I left some rooms via Element and they moved to "Historical" but nothing happened on this end.
Indeed :) [Joining](https://github.com/vector-im/hydrogen-web/issues/28) and [leaving](https://github.com/vector-im/hydrogen-web/issues/147) isn't implemented yet, just haven't gotten around to it.
## How can I verify my session from Element?
You can only verify by comparing keys manually currently. In Element, go to your own profile in the right panel, click on the Hydrogen device and select Manually Verify by Text. The session key displayed should be the same as in the Hydrogen settings. You can't yet mark your Element session as trusted from Hydrogen.
@ -26,4 +32,8 @@ There are no published builds at this point. You need to checkout the version yo
## I want to embed Hydrogen in my website, how should I do that?
There are no npm modules yet published for Hydrogen. The easiest is probably to setup your website project, do yarn/npm init if you haven't yet, then add the hydrogen repo as a git http dependency, and import the files/classes you want to use from Hydrogen. Feel free to ask which classes you need as the documentation is lacking somewhat still. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet.
There are no npm modules yet published for Hydrogen. The easiest is probably to setup your website project, do yarn/npm init if you haven't yet, then add the hydrogen repo as a git http dependency, and import the files/classes you want to use from Hydrogen.
For example, for a single room chat, you could create an instance of `Platform`, you create a new `SessionContainer` with it, call `startWithLogin` on it, observe `sessionContainer.loadStatus` to know when initial sync is done, then do `sessionContainer.session.rooms.get('roomid')` and you create a `RoomViewModel` with it and pass that to a `RoomView`. Then you call `document.appendChild(roomView.mount())` and you should see a syncing room.
Feel free to ask for pointers in #hydrogen:matrix.org as the documentation is still lacking considerably. Note that at this early, pre 1.0 stage of the project, there is no promise of API stability yet.

View file

@ -9,13 +9,12 @@ SyncWriter will need to resolve the related remote id to a [fragmentId, eventInd
sourceEventId:
targetEventId:
rel_type:
type:
roomId:
}
`{"key": "!bEWtlqtDwCLFIAKAcv:matrix.org|$apmyieZOI5vm4DzjEFzjbRiZW9oeQQR21adM6A6eRwM|m.annotation|m.reaction|$jSisozR3is5XUuDZXD5cyaVMOQ5_BtFS3jKfcP89MOM"}`
or actually stored like `roomId|targetEventId|rel_type|source_event_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event
or actually stored like `roomId|targetEventId|rel_type|sourceEventId`. How can we get the last edit? They are sorted by origin_server_ts IIRC? Should this be part of the key? Solved: we store the event id of a replacement on the target event
We should look into what part of the relationships will be present on the event once it is received from the server (e.g. m.replace might be evident, but not all the reaction events?). If not, we could add a object store with missing relation targets.

View file

@ -1,6 +1,6 @@
{
"name": "hydrogen-web",
"version": "0.1.56",
"version": "0.2.0",
"description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB",
"main": "index.js",
"directories": {
@ -57,7 +57,7 @@
"base64-arraybuffer": "^0.2.0",
"bs58": "^4.0.1",
"es6-promise": "https://github.com/bwindels/es6-promise.git#bwindels/expose-flush",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"text-encoding": "^0.7.0"
}
}

View file

@ -70,7 +70,7 @@ async function populateLib() {
const libDir = path.join(projectDir, "lib/");
await removeDirIfExists(libDir);
await fs.mkdir(libDir);
const olmSrcDir = path.dirname(require.resolve("olm"));
const olmSrcDir = path.dirname(require.resolve("@matrix-org/olm"));
const olmDstDir = path.join(libDir, "olm/");
await fs.mkdir(olmDstDir);
for (const file of ["olm.js", "olm.wasm", "olm_legacy.js"]) {

View file

@ -37,7 +37,7 @@ function allowsChild(parent, child) {
// downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile";
case "room":
return type === "lightbox";
return type === "lightbox" || type === "details";
default:
return false;
}
@ -113,6 +113,9 @@ export function parseUrlPath(urlPath, currentNavPath, defaultSessionId) {
segments.push(roomsSegmentWithRoom(rooms, roomId, currentNavPath));
}
segments.push(new Segment("room", roomId));
if (currentNavPath.get("details")?.value) {
segments.push(new Segment("details"));
}
} else if (type === "last-session") {
let sessionSegment = currentNavPath.get("session");
if (typeof sessionSegment?.value !== "string" && defaultSessionId) {
@ -254,6 +257,25 @@ export function tests() {
assert.equal(segments[2].type, "room");
assert.equal(segments[2].value, "a");
},
"parse open-room action changing focus to an existing room with details open": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b"),
new Segment("details", true)
]);
const segments = parseUrlPath("/session/1/open-room/a", path);
assert.equal(segments.length, 4);
assert.equal(segments[0].type, "session");
assert.equal(segments[0].value, "1");
assert.equal(segments[1].type, "rooms");
assert.deepEqual(segments[1].value, ["a", "b", "c"]);
assert.equal(segments[2].type, "room");
assert.equal(segments[2].value, "a");
assert.equal(segments[3].type, "details");
assert.equal(segments[3].value, true);
},
"parse open-room action setting a room in an empty tile": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([

View file

@ -78,13 +78,23 @@ export class RoomGridViewModel extends ViewModel {
return this._height;
}
_switchToRoom(roomId) {
const detailsShown = !!this.navigation.path.get("details")?.value;
let path = this.navigation.path.until("rooms");
path = path.with(this.navigation.segment("room", roomId));
if (detailsShown) {
path = path.with(this.navigation.segment("details", true));
}
this.navigation.applyPath(path);
}
focusTile(index) {
if (index === this._selectedIndex) {
return;
}
const vmo = this._viewModelsObservables[index];
if (vmo) {
this.navigation.push("room", vmo.id);
this._switchToRoom(vmo.id);
} else {
this.navigation.push("empty-grid-tile", index);
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js";
import {RoomDetailsViewModel} from "./rightpanel/RoomDetailsViewModel.js";
import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js";
@ -62,6 +63,7 @@ export class SessionViewModel extends ViewModel {
if (!this._gridViewModel) {
this._updateRoom(roomId);
}
this._updateRoomDetails();
}));
if (!this._gridViewModel) {
this._updateRoom(currentRoomId.get());
@ -78,6 +80,10 @@ export class SessionViewModel extends ViewModel {
this._updateLightbox(eventId);
}));
this._updateLightbox(lightbox.get());
const details = this.navigation.observe("details");
this.track(details.subscribe(() => this._updateRoomDetails()));
this._updateRoomDetails();
}
get id() {
@ -112,6 +118,10 @@ export class SessionViewModel extends ViewModel {
return this._roomViewModelObservable?.get();
}
get roomDetailsViewModel() {
return this._roomDetailsViewModel;
}
_updateGrid(roomIds) {
const changed = !(this._gridViewModel && roomIds);
const currentRoomId = this.navigation.path.get("room");
@ -230,8 +240,7 @@ export class SessionViewModel extends ViewModel {
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);
}
if (eventId) {
const roomId = this.navigation.path.get("room").value;
const room = this._sessionContainer.session.rooms.get(roomId);
const room = this._roomFromNavigation();
this._lightboxViewModel = this.track(new LightboxViewModel(this.childOptions({eventId, room})));
}
this.emitChange("lightboxViewModel");
@ -240,4 +249,22 @@ export class SessionViewModel extends ViewModel {
get lightboxViewModel() {
return this._lightboxViewModel;
}
_roomFromNavigation() {
const roomId = this.navigation.path.get("room")?.value;
const room = this._sessionContainer.session.rooms.get(roomId);
return room;
}
_updateRoomDetails() {
this._roomDetailsViewModel = this.disposeTracked(this._roomDetailsViewModel);
const enable = !!this.navigation.path.get("details")?.value;
if (enable) {
const room = this._roomFromNavigation();
if (!room) { return; }
this._roomDetailsViewModel = this.track(new RoomDetailsViewModel(this.childOptions({room})));
}
this.emitChange("roomDetailsViewModel");
}
}

View file

@ -69,7 +69,7 @@ export class BaseTileViewModel extends ViewModel {
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._avatarSource.id);
return getIdentifierColorNumber(this._avatarSource.avatarColorId);
}
avatarUrl(size) {

View file

@ -92,26 +92,30 @@ export class LeftPanelViewModel extends ViewModel {
}
}
_pathForDetails(path) {
const details = this.navigation.path.get("details");
return details?.value ? path.with(details) : path;
}
toggleGrid() {
if (this.gridEnabled) {
let path = this.navigation.path.until("session");
const room = this.navigation.path.get("room");
let path = this.navigation.path.until("session");
if (this.gridEnabled) {
if (room) {
path = path.with(room);
path = this._pathForDetails(path);
}
this.navigation.applyPath(path);
} else {
let path = this.navigation.path.until("session");
const room = this.navigation.path.get("room");
if (room) {
path = path.with(this.navigation.segment("rooms", [room.value]));
path = path.with(room);
path = this._pathForDetails(path);
} else {
path = path.with(this.navigation.segment("rooms", []));
path = path.with(this.navigation.segment("empty-grid-tile", 0));
}
this.navigation.applyPath(path);
}
this.navigation.applyPath(path);
}
get tileViewModels() {

View file

@ -0,0 +1,61 @@
import {ViewModel} from "../../ViewModel.js";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
export class RoomDetailsViewModel extends ViewModel {
constructor(options) {
super(options);
this._room = options.room;
this._onRoomChange = this._onRoomChange.bind(this);
this._room.on("change", this._onRoomChange);
}
get roomId() {
return this._room.id;
}
get canonicalAlias() {
return this._room.canonicalAlias;
}
get name() {
return this._room.name;
}
get isEncrypted() {
return !!this._room.isEncrypted;
}
get memberCount() {
return this._room.joinedMemberCount;
}
get avatarLetter() {
return avatarInitials(this.name);
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._room.avatarColorId)
}
avatarUrl(size) {
return getAvatarHttpUrl(this._room.avatarUrl, size, this.platform, this._room.mediaRepository);
}
get avatarTitle() {
return this.name;
}
_onRoomChange() {
this.emitChange();
}
closePanel() {
const path = this.navigation.path.until("room");
this.navigation.applyPath(path);
}
dispose() {
super.dispose();
this._room.off("change", this._onRoomChange);
}
}

View file

@ -56,7 +56,7 @@ export class InviteViewModel extends ViewModel {
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._invite.id)
return getIdentifierColorNumber(this._invite.avatarColorId)
}
avatarUrl(size) {

View file

@ -119,7 +119,7 @@ export class RoomViewModel extends ViewModel {
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._room.id)
return getIdentifierColorNumber(this._room.avatarColorId)
}
avatarUrl(size) {
@ -287,6 +287,12 @@ export class RoomViewModel extends ViewModel {
get composerViewModel() {
return this._composerVM;
}
openDetailsPanel() {
let path = this.navigation.path.until("room");
path = path.with(this.navigation.segment("details", true));
this.navigation.applyPath(path);
}
}
class ComposerViewModel extends ViewModel {

View file

@ -0,0 +1,363 @@
/*
Copyright 2021 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 ReactionsViewModel {
constructor(parentTile) {
this._parentTile = parentTile;
this._map = new ObservableMap();
this._reactions = this._map.sortValues((a, b) => a._compare(b));
}
/** @package */
update(annotations, pendingAnnotations) {
if (annotations) {
for (const key in annotations) {
if (annotations.hasOwnProperty(key)) {
const annotation = annotations[key];
const reaction = this._map.get(key);
if (reaction) {
if (reaction._tryUpdate(annotation)) {
this._map.update(key);
}
} else {
this._map.add(key, new ReactionViewModel(key, annotation, null, this._parentTile));
}
}
}
}
if (pendingAnnotations) {
for (const [key, annotation] of pendingAnnotations.entries()) {
const reaction = this._map.get(key);
if (reaction) {
reaction._tryUpdatePending(annotation);
this._map.update(key);
} else {
this._map.add(key, new ReactionViewModel(key, null, annotation, this._parentTile));
}
}
}
for (const existingKey of this._map.keys()) {
const hasPending = pendingAnnotations?.has(existingKey);
const hasRemote = annotations?.hasOwnProperty(existingKey);
if (!hasRemote && !hasPending) {
this._map.remove(existingKey);
} else if (!hasRemote) {
if (this._map.get(existingKey)._tryUpdate(null)) {
this._map.update(existingKey);
}
} else if (!hasPending) {
if (this._map.get(existingKey)._tryUpdatePending(null)) {
this._map.update(existingKey);
}
}
}
}
get reactions() {
return this._reactions;
}
getReaction(key) {
return this._map.get(key);
}
}
class ReactionViewModel {
constructor(key, annotation, pending, parentTile) {
this._key = key;
this._annotation = annotation;
this._pending = pending;
this._parentTile = parentTile;
this._isToggling = false;
}
_tryUpdate(annotation) {
const oneSetAndOtherNot = !!this._annotation !== !!annotation;
const bothSet = this._annotation && annotation;
const areDifferent = bothSet && (
annotation.me !== this._annotation.me ||
annotation.count !== this._annotation.count ||
annotation.firstTimestamp !== this._annotation.firstTimestamp
);
if (oneSetAndOtherNot || areDifferent) {
this._annotation = annotation;
return true;
}
return false;
}
_tryUpdatePending(pending) {
if (!pending && !this._pending) {
return false;
}
this._pending = pending;
return true;
}
get key() {
return this._key;
}
get count() {
return (this._pending?.count || 0) + (this._annotation?.count || 0);
}
get isPending() {
return this._pending !== null;
}
/** @returns {boolean} true if the user has a (pending) reaction
* already for this key, or they have a pending redaction for
* the reaction, false if there is nothing pending and
* the user has not reacted yet. */
get isActive() {
return this._annotation?.me || this.isPending;
}
get firstTimestamp() {
let ts = Number.MAX_SAFE_INTEGER;
if (this._annotation) {
ts = Math.min(ts, this._annotation.firstTimestamp);
}
if (this._pending) {
ts = Math.min(ts, this._pending.firstTimestamp);
}
return ts;
}
_compare(other) {
// the comparator is also used to test for equality by sortValues, if the comparison returns 0
// given that the firstTimestamp isn't set anymore when the last reaction is removed,
// the remove event wouldn't be able to find the correct index anymore. So special case equality.
if (other === this) {
return 0;
}
if (this.count !== other.count) {
return other.count - this.count;
} else {
const cmp = this.firstTimestamp - other.firstTimestamp;
if (cmp === 0) {
return this.key < other.key ? -1 : 1;
}
return cmp;
}
}
async toggle(log = null) {
if (this._isToggling) {
console.log("busy toggling reaction already");
return;
}
this._isToggling = true;
try {
await this._parentTile.toggleReaction(this.key, log);
} finally {
this._isToggling = false;
}
}
}
// matrix classes uses in the integration test below
import {User} from "../../../../matrix/User.js";
import {SendQueue} from "../../../../matrix/room/sending/SendQueue.js";
import {Timeline} from "../../../../matrix/room/timeline/Timeline.js";
import {EventEntry} from "../../../../matrix/room/timeline/entries/EventEntry.js";
import {RelationWriter} from "../../../../matrix/room/timeline/persistence/RelationWriter.js";
import {FragmentIdComparer} from "../../../../matrix/room/timeline/FragmentIdComparer.js";
import {createAnnotation} from "../../../../matrix/room/timeline/relations.js";
// mocks
import {Clock as MockClock} from "../../../../mocks/Clock.js";
import {createMockStorage} from "../../../../mocks/Storage.js";
import {ListObserver} from "../../../../mocks/ListObserver.js";
import {createEvent, withTextBody, withContent} from "../../../../mocks/event.js";
import {NullLogItem, NullLogger} from "../../../../logging/NullLogger.js";
import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
// other imports
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
import {MappedList} from "../../../../observable/list/MappedList.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
const logger = new NullLogger();
function findInIterarable(it, predicate) {
let i = 0;
for (const item of it) {
if (predicate(item, i)) {
return item;
}
i += 1;
}
throw new Error("not found");
}
function mapMessageEntriesToBaseMessageTile(timeline, queue) {
const room = {
id: roomId,
sendEvent(eventType, content, attachments, log) {
return queue.enqueueEvent(eventType, content, attachments, log);
},
sendRedaction(eventIdOrTxnId, reason, log) {
return queue.enqueueRedaction(eventIdOrTxnId, reason, log);
}
};
const tiles = new MappedList(timeline.entries, entry => {
if (entry.eventType === "m.room.message") {
return new BaseMessageTile({entry, room, timeline, platform: {logger}});
}
return null;
}, (tile, params, entry) => tile?.updateEntry(entry, params));
return tiles;
}
return {
// these are more an integration test than unit tests,
// but fully test the local echo when toggling and
// the correct send queue modifications happen
"toggling reaction with own remote reaction": async assert => {
// 1. put message and reaction in storage
const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const myReactionEvent = withContent(createAnnotation(messageEvent.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
myReactionEvent.origin_server_ts = 5;
const myReactionEntry = new EventEntry({event: myReactionEvent, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([
storage.storeNames.timelineEvents,
storage.storeNames.timelineRelations,
storage.storeNames.timelineFragments
]);
txn.timelineFragments.add({id: 1, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event: messageEvent, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 3, event: myReactionEvent, roomId});
await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem());
await txn.complete();
// 2. setup queue & timeline
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api});
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
clock: new MockClock(), pendingEvents: queue.pendingEvents});
// 3. load the timeline, which will load the message with the reaction
await timeline.load(new User(alice), "join", new NullLogItem());
const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue);
// 4. subscribe to the queue to observe, and the tiles (so we can safely iterate)
const queueObserver = new ListObserver();
queue.pendingEvents.subscribe(queueObserver);
tiles.subscribe(new ListObserver());
const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null
const reactionVM = messageTile.reactions.getReaction("🐶");
// 5. test toggling
// make sure the preexisting reaction is counted
assert.equal(reactionVM.count, 1);
// 5.1. unset reaction, should redact the pre-existing reaction
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 0);
const {value: redaction, type} = await queueObserver.next();
assert.equal("add", type);
assert.equal(redaction.eventType, "m.room.redaction");
assert.equal(redaction.relatedEventId, myReactionEntry.id);
// SendQueue puts redaction in sending status, as it is first in the queue
assert.equal("update", (await queueObserver.next()).type);
}
// 5.2. set reaction, should send a new reaction as the redaction is already sending
await reactionVM.toggle();
let reactionIndex;
{
assert.equal(reactionVM.count, 1);
const {value: reaction, type, index} = await queueObserver.next();
assert.equal("add", type);
assert.equal(reaction.eventType, "m.reaction");
assert.equal(reaction.relatedEventId, messageEvent.event_id);
reactionIndex = index;
}
// 5.3. unset reaction, should abort the previous pending reaction as it hasn't started sending yet
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 0);
const {index, type} = await queueObserver.next();
assert.equal("remove", type);
assert.equal(reactionIndex, index);
}
},
"toggling reaction without own remote reaction": async assert => {
// 1. put message in storage
const messageEvent = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([
storage.storeNames.timelineEvents,
storage.storeNames.timelineFragments
]);
txn.timelineFragments.add({id: 1, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event: messageEvent, roomId});
await txn.complete();
// 2. setup queue & timeline
const queue = new SendQueue({roomId, storage, hsApi: new MockHomeServer().api});
const timeline = new Timeline({roomId, storage, fragmentIdComparer,
clock: new MockClock(), pendingEvents: queue.pendingEvents});
// 3. load the timeline, which will load the message with the reaction
await timeline.load(new User(alice), "join", new NullLogItem());
const tiles = mapMessageEntriesToBaseMessageTile(timeline, queue);
// 4. subscribe to the queue to observe, and the tiles (so we can safely iterate)
const queueObserver = new ListObserver();
queue.pendingEvents.subscribe(queueObserver);
tiles.subscribe(new ListObserver());
const messageTile = findInIterarable(tiles, e => !!e); // the other entries are mapped to null
// 5. test toggling
assert.equal(messageTile.reactions, null);
// 5.1. set reaction, should send a new reaction as there is none yet
await messageTile.react("🐶");
// now there should be a reactions view model
const reactionVM = messageTile.reactions.getReaction("🐶");
let reactionTxnId;
{
assert.equal(reactionVM.count, 1);
const {value: reaction, type} = await queueObserver.next();
assert.equal("add", type);
assert.equal(reaction.eventType, "m.reaction");
assert.equal(reaction.relatedEventId, messageEvent.event_id);
// SendQueue puts reaction in sending status, as it is first in the queue
assert.equal("update", (await queueObserver.next()).type);
reactionTxnId = reaction.txnId;
}
// 5.2. unset reaction, should redact the previous pending reaction as it has started sending already
let redactionIndex;
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 0);
const {value: redaction, type, index} = await queueObserver.next();
assert.equal("add", type);
assert.equal(redaction.eventType, "m.room.redaction");
assert.equal(redaction.relatedTxnId, reactionTxnId);
redactionIndex = index;
}
// 5.3. set reaction, should abort the previous pending redaction as it hasn't started sending yet
await reactionVM.toggle();
{
assert.equal(reactionVM.count, 1);
const {index, type} = await queueObserver.next();
assert.equal("remove", type);
assert.equal(redactionIndex, index);
redactionIndex = index;
}
},
}
}

View file

@ -52,7 +52,7 @@ export class TimelineViewModel extends ViewModel {
return true;
}
const firstTile = this._tiles.getFirst();
if (firstTile.shape === "gap") {
if (firstTile?.shape === "gap") {
return await firstTile.fill();
} else {
const topReached = await this._timeline.loadAtTop(10);

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import {SimpleTile} from "./SimpleTile.js";
import {ReactionsViewModel} from "../ReactionsViewModel.js";
import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js";
export class BaseMessageTile extends SimpleTile {
@ -22,10 +23,10 @@ export class BaseMessageTile extends SimpleTile {
super(options);
this._date = this._entry.timestamp ? new Date(this._entry.timestamp) : null;
this._isContinuation = false;
this._reactions = null;
if (this._entry.annotations || this._entry.pendingAnnotations) {
this._updateReactions();
}
get _room() {
return this.getOption("room");
}
get _mediaRepository() {
@ -97,6 +98,14 @@ export class BaseMessageTile extends SimpleTile {
}
}
updateEntry(entry, param) {
const action = super.updateEntry(entry, param);
if (action.shouldUpdate) {
this._updateReactions();
}
return action;
}
redact(reason, log) {
return this._room.sendRedaction(this._entry.id, reason, log);
}
@ -104,4 +113,81 @@ export class BaseMessageTile extends SimpleTile {
get canRedact() {
return this._powerLevels.canRedactFromSender(this._entry.sender);
}
get reactions() {
if (this.shape !== "redacted") {
return this._reactions;
}
return null;
}
get canReact() {
return this._powerLevels.canSendType("m.reaction");
}
react(key, log = null) {
return this.logger.wrapOrRun(log, "react", async log => {
if (!this.canReact) {
log.set("powerlevel_lacking", true);
return;
}
if (this._entry.haveAnnotation(key)) {
log.set("already_reacted", true);
return;
}
const redaction = this._entry.pendingAnnotations?.get(key)?.redactionEntry;
if (redaction && !redaction.pendingEvent.hasStartedSending) {
log.set("abort_redaction", true);
await redaction.pendingEvent.abort();
} else {
await this._room.sendEvent("m.reaction", this._entry.annotate(key), null, log);
}
});
}
redactReaction(key, log = null) {
return this.logger.wrapOrRun(log, "redactReaction", async log => {
if (!this._powerLevels.canRedactFromSender(this._ownMember.userId)) {
log.set("powerlevel_lacking", true);
return;
}
if (!this._entry.haveAnnotation(key)) {
log.set("not_yet_reacted", true);
return;
}
let entry = this._entry.pendingAnnotations?.get(key)?.annotationEntry;
if (!entry) {
entry = await this._timeline.getOwnAnnotationEntry(this._entry.id, key);
}
if (entry) {
await this._room.sendRedaction(entry.id, null, log);
} else {
log.set("no_reaction", true);
}
});
}
toggleReaction(key, log = null) {
return this.logger.wrapOrRun(log, "toggleReaction", async log => {
if (this._entry.haveAnnotation(key)) {
await this.redactReaction(key, log);
} else {
await this.react(key, log);
}
});
}
_updateReactions() {
const {annotations, pendingAnnotations} = this._entry;
if (!annotations && !pendingAnnotations) {
if (this._reactions) {
this._reactions = null;
}
} else {
if (!this._reactions) {
this._reactions = new ReactionsViewModel(this);
}
this._reactions.update(annotations, pendingAnnotations);
}
}
}

View file

@ -33,7 +33,10 @@ export class RoomMemberTile extends SimpleTile {
if (content.avatar_url !== prevContent.avatar_url) {
return `${senderName} changed their avatar`;
} else if (content.displayname !== prevContent.displayname) {
return `${prevContent.displayname} changed their name to ${content.displayname}`;
if (!content.displayname) {
return `${stateKey} removed their name (${prevContent.displayname})`;
}
return `${prevContent.displayname ?? stateKey} changed their name to ${content.displayname}`;
}
} else if (membership === "join") {
return `${targetName} joined the room`;
@ -59,3 +62,28 @@ export class RoomMemberTile extends SimpleTile {
return `${sender} membership changed to ${content.membership}`;
}
}
export function tests() {
return {
"user removes display name": (assert) => {
const tile = new RoomMemberTile({
entry: {
prevContent: {displayname: "foo", membership: "join"},
content: {membership: "join"},
stateKey: "foo@bar.com",
},
});
assert.strictEqual(tile.announcement, "foo@bar.com removed their name (foo)");
},
"user without display name sets a new display name": (assert) => {
const tile = new RoomMemberTile({
entry: {
prevContent: {membership: "join"},
content: {displayname: "foo", membership: "join" },
stateKey: "foo@bar.com",
},
});
assert.strictEqual(tile.announcement, "foo@bar.com changed their name to foo");
},
};
}

View file

@ -54,8 +54,7 @@ export class SimpleTile extends ViewModel {
get canAbortSending() {
return this._entry.isPending &&
this._entry.pendingEvent.status !== SendStatus.Sending &&
this._entry.pendingEvent.status !== SendStatus.Sent;
!this._entry.pendingEvent.hasStartedSending;
}
abortSending() {
@ -130,8 +129,12 @@ export class SimpleTile extends ViewModel {
return this._options.room;
}
get _timeline() {
return this._options.timeline;
}
get _powerLevels() {
return this._options.timeline.powerLevels;
return this._timeline.powerLevels;
}
get _ownMember() {

View file

@ -32,13 +32,14 @@ export function tilesCreator(baseOptions) {
const options = Object.assign({entry, emitUpdate}, baseOptions);
if (entry.isGap) {
return new GapTile(options);
} else if (entry.isRedacted) {
return new RedactedTile(options);
} else if (entry.isPending && entry.pendingEvent.isMissingAttachments) {
return new MissingAttachmentTile(options);
} else if (entry.eventType) {
switch (entry.eventType) {
case "m.room.message": {
if (entry.isRedacted) {
return new RedactedTile(options);
}
const content = entry.content;
const msgtype = content && content.msgtype;
switch (msgtype) {

View file

@ -31,9 +31,9 @@ export class NullLogger {
wrapOrRun(item, _, callback) {
if (item) {
item.wrap(null, callback);
return item.wrap(null, callback);
} else {
this.run(null, callback);
return this.run(null, callback);
}
}

View file

@ -333,6 +333,7 @@ export class Sync {
storeNames.roomState,
storeNames.roomMembers,
storeNames.timelineEvents,
storeNames.timelineRelations,
storeNames.timelineFragments,
storeNames.pendingEvents,
storeNames.userIdentities,

View file

@ -17,6 +17,7 @@ limitations under the License.
import {EventEmitter} from "../../utils/EventEmitter.js";
import {RoomSummary} from "./RoomSummary.js";
import {GapWriter} from "./timeline/persistence/GapWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js";
import {Timeline} from "./timeline/Timeline.js";
import {FragmentIdComparer} from "./timeline/FragmentIdComparer.js";
import {WrappedError} from "../error.js"
@ -258,6 +259,7 @@ export class BaseRoom extends EventEmitter {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.pendingEvents,
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineRelations,
this._storage.storeNames.timelineFragments,
]);
let extraGapFillChanges;
@ -266,10 +268,16 @@ export class BaseRoom extends EventEmitter {
// detect remote echos of pending messages in the gap
extraGapFillChanges = await this._writeGapFill(response.chunk, txn, log);
// write new events into gap
const relationWriter = new RelationWriter({
roomId: this._roomId,
fragmentIdComparer: this._fragmentIdComparer,
ownUserId: this._user.id,
});
const gapWriter = new GapWriter({
roomId: this._roomId,
storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer,
relationWriter
});
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn, log);
} catch (err) {
@ -291,7 +299,7 @@ export class BaseRoom extends EventEmitter {
if (this._timeline) {
// these should not be added if not already there
this._timeline.replaceEntries(gapResult.updatedEntries);
this._timeline.addOrReplaceEntries(gapResult.entries);
this._timeline.addEntries(gapResult.entries);
}
});
}
@ -333,6 +341,16 @@ export class BaseRoom extends EventEmitter {
return null;
}
/**
* Retrieve the identifier that should be used to color
* this room's avatar. By default this is the room's
* ID, but DM rooms should be the same color as their
* user's avatar.
*/
get avatarColorId() {
return this._roomId;
}
get lastMessageTimestamp() {
return this._summary.data.lastMessageTimestamp;
}
@ -354,6 +372,14 @@ export class BaseRoom extends EventEmitter {
return this.membership === "leave";
}
get canonicalAlias() {
return this._summary.data.canonicalAlias;
}
get joinedMemberCount() {
return this._summary.data.joinCount;
}
get mediaRepository() {
return this._mediaRepository;
}

View file

@ -56,6 +56,11 @@ export class Invite extends EventEmitter {
return this._inviteData.avatarUrl;
}
/** @see BaseRoom.avatarColorId */
get avatarColorId() {
return this._inviteData.avatarColorId;
}
get timestamp() {
return this._inviteData.timestamp;
}
@ -175,6 +180,7 @@ export class Invite extends EventEmitter {
_createData(inviteState, myInvite, inviter, summaryData, heroes) {
const name = heroes ? heroes.roomName : summaryData.name;
const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl;
const avatarColorId = heroes ? heroes.roomAvatarColorId : this.id;
return {
roomId: this.id,
isEncrypted: !!summaryData.encryption,
@ -182,6 +188,7 @@ export class Invite extends EventEmitter {
// type:
name,
avatarUrl,
avatarColorId,
canonicalAlias: summaryData.canonicalAlias,
timestamp: this._platform.clock.now(),
joinRule: this._getJoinRule(inviteState),

View file

@ -16,6 +16,8 @@ limitations under the License.
import {BaseRoom} from "./BaseRoom.js";
import {SyncWriter} from "./timeline/persistence/SyncWriter.js";
import {MemberWriter} from "./timeline/persistence/MemberWriter.js";
import {RelationWriter} from "./timeline/persistence/RelationWriter.js";
import {SendQueue} from "./sending/SendQueue.js";
import {WrappedError} from "../error.js"
import {Heroes} from "./members/Heroes.js";
@ -28,7 +30,17 @@ export class Room extends BaseRoom {
constructor(options) {
super(options);
const {pendingEvents} = options;
this._syncWriter = new SyncWriter({roomId: this.id, fragmentIdComparer: this._fragmentIdComparer});
const relationWriter = new RelationWriter({
roomId: this.id,
fragmentIdComparer: this._fragmentIdComparer,
ownUserId: this._user.id
});
this._syncWriter = new SyncWriter({
roomId: this.id,
fragmentIdComparer: this._fragmentIdComparer,
relationWriter,
memberWriter: new MemberWriter(this.id)
});
this._sendQueue = new SendQueue({roomId: this.id, storage: this._storage, hsApi: this._hsApi, pendingEvents});
}
@ -227,7 +239,7 @@ export class Room extends BaseRoom {
if (this._timeline) {
// these should not be added if not already there
this._timeline.replaceEntries(updatedEntries);
this._timeline.addOrReplaceEntries(newEntries);
this._timeline.addEntries(newEntries);
}
if (this._observedEvents) {
this._observedEvents.updateEvents(updatedEntries);
@ -291,7 +303,7 @@ export class Room extends BaseRoom {
/** @public */
sendEvent(eventType, content, attachments, log = null) {
this._platform.logger.wrapOrRun(log, "send", log => {
return this._platform.logger.wrapOrRun(log, "send", log => {
log.set("id", this.id);
return this._sendQueue.enqueueEvent(eventType, content, attachments, log);
});
@ -299,7 +311,7 @@ export class Room extends BaseRoom {
/** @public */
sendRedaction(eventIdOrTxnId, reason, log = null) {
this._platform.logger.wrapOrRun(log, "redact", log => {
return this._platform.logger.wrapOrRun(log, "redact", log => {
log.set("id", this.id);
return this._sendQueue.enqueueRedaction(eventIdOrTxnId, reason, log);
});
@ -316,6 +328,10 @@ export class Room extends BaseRoom {
});
}
get avatarColorId() {
return this._heroes?.roomAvatarColorId || this._roomId;
}
get isUnread() {
return this._summary.data.isUnread;
}

View file

@ -21,3 +21,7 @@ export function getPrevContentFromStateEvent(event) {
}
export const REDACTION_TYPE = "m.room.redaction";
export function isRedacted(event) {
return !!event?.unsigned?.redacted_because;
}

View file

@ -97,4 +97,21 @@ export class Heroes {
}
return null;
}
/**
* In DM rooms, we want the room's color to be
* the same as the other user's color. Thus, if the room
* only has one hero, we use their ID, instead
* of the room's, to get the avatar color.
*
* @returns {?string} the ID of the single hero.
*/
get roomAvatarColorId() {
if (this._members.size === 1) {
for (const member of this._members.keys()) {
return member
}
}
return null;
}
}

View file

@ -16,6 +16,7 @@ limitations under the License.
import {createEnum} from "../../../utils/enum.js";
import {AbortError} from "../../../utils/error.js";
import {REDACTION_TYPE} from "../common.js";
import {getRelationFromContent} from "../timeline/relations.js";
export const SendStatus = createEnum(
"Waiting",
@ -49,11 +50,24 @@ export class PendingEvent {
get remoteId() { return this._data.remoteId; }
get content() { return this._data.content; }
get relatedTxnId() { return this._data.relatedTxnId; }
get relatedEventId() { return this._data.relatedEventId; }
get relatedEventId() {
const relation = getRelationFromContent(this.content);
if (relation) {
// may be null when target is not sent yet, is intended
return relation.event_id;
} else {
return this._data.relatedEventId;
}
}
setRelatedEventId(eventId) {
const relation = getRelationFromContent(this.content);
if (relation) {
relation.event_id = eventId;
} else {
this._data.relatedEventId = eventId;
}
}
get data() { return this._data; }
@ -102,6 +116,10 @@ export class PendingEvent {
get status() { return this._status; }
get error() { return this._error; }
get hasStartedSending() {
return this._status === SendStatus.Sending || this._status === SendStatus.Sent;
}
get attachmentsTotalBytes() {
return this._attachmentsTotalBytes;
}

View file

@ -19,6 +19,7 @@ import {ConnectionError} from "../../error.js";
import {PendingEvent, SendStatus} from "./PendingEvent.js";
import {makeTxnId, isTxnId} from "../../common.js";
import {REDACTION_TYPE} from "../common.js";
import {getRelationFromContent, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js";
export class SendQueue {
constructor({roomId, storage, hsApi, pendingEvents}) {
@ -38,7 +39,7 @@ export class SendQueue {
const pendingEvent = new PendingEvent({
data,
remove: () => this._removeEvent(pendingEvent),
emitUpdate: () => this._pendingEvents.update(pendingEvent),
emitUpdate: params => this._pendingEvents.update(pendingEvent, params),
attachments
});
return pendingEvent;
@ -156,8 +157,8 @@ export class SendQueue {
}
async _removeEvent(pendingEvent) {
const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) {
let hasEvent = this._pendingEvents.array.indexOf(pendingEvent) !== -1;
if (hasEvent) {
const txn = await this._storage.readWriteTxn([this._storage.storeNames.pendingEvents]);
try {
txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex);
@ -165,8 +166,13 @@ export class SendQueue {
txn.abort();
}
await txn.complete();
// lookup index after async txn is complete,
// to make sure we're not racing with anything
const idx = this._pendingEvents.array.indexOf(pendingEvent);
if (idx !== -1) {
this._pendingEvents.remove(idx);
}
}
pendingEvent.dispose();
}
@ -197,7 +203,26 @@ export class SendQueue {
}
async enqueueEvent(eventType, content, attachments, log) {
await this._enqueueEvent(eventType, content, attachments, null, null, log);
const relation = getRelationFromContent(content);
let relatedTxnId = null;
if (relation) {
if (isTxnId(relation.event_id)) {
relatedTxnId = relation.event_id;
relation.event_id = null;
}
if (relation.rel_type === ANNOTATION_RELATION_TYPE) {
const isAlreadyAnnotating = this._pendingEvents.array.some(pe => {
const r = getRelationFromContent(pe.content);
return pe.eventType === eventType && r && r.key === relation.key &&
(pe.relatedTxnId === relatedTxnId || r.event_id === relation.event_id);
});
if (isAlreadyAnnotating) {
log.set("already_annotating", true);
return;
}
}
}
await this._enqueueEvent(eventType, content, attachments, relatedTxnId, null, log);
}
async _enqueueEvent(eventType, content, attachments, relatedTxnId, relatedEventId, log) {
@ -214,6 +239,14 @@ export class SendQueue {
}
async enqueueRedaction(eventIdOrTxnId, reason, log) {
const isAlreadyRedacting = this._pendingEvents.array.some(pe => {
return pe.eventType === REDACTION_TYPE &&
(pe.relatedTxnId === eventIdOrTxnId || pe.relatedEventId === eventIdOrTxnId);
});
if (isAlreadyRedacting) {
log.set("already_redacting", true);
return;
}
let relatedTxnId;
let relatedEventId;
if (isTxnId(eventIdOrTxnId)) {
@ -284,7 +317,9 @@ export class SendQueue {
// wouldn't be able to detect the remote echo already arrived and end up overwriting the new event
const maxQueueIndex = Math.max(maxStorageQueueIndex, this._currentQueueIndex);
const queueIndex = maxQueueIndex + 1;
const needsEncryption = eventType !== REDACTION_TYPE && !!this._roomEncryption;
const needsEncryption = eventType !== REDACTION_TYPE &&
eventType !== REACTION_TYPE &&
!!this._roomEncryption;
pendingEvent = this._createPendingEvent({
roomId: this._roomId,
queueIndex,
@ -314,9 +349,11 @@ export class SendQueue {
import {HomeServer as MockHomeServer} from "../../../mocks/HomeServer.js";
import {createMockStorage} from "../../../mocks/Storage.js";
import {NullLogger} from "../../../logging/NullLogger.js";
import {ListObserver} from "../../../mocks/ListObserver.js";
import {NullLogger, NullLogItem} from "../../../logging/NullLogger.js";
import {createEvent, withTextBody, withTxnId} from "../../../mocks/event.js";
import {poll} from "../../../mocks/poll.js";
import {createAnnotation} from "../timeline/relations.js";
export function tests() {
const logger = new NullLogger();
@ -350,6 +387,61 @@ export function tests() {
const sendRequest2 = await poll(() => hs.requests.send[1]);
sendRequest2.respond({event_id: event2.event_id});
await poll(() => !queue._isSending);
}
},
"redaction of pending event that hasn't started sending yet aborts it": async assert => {
const queue = new SendQueue({
roomId: "!abc",
storage: await createMockStorage(),
hsApi: new MockHomeServer().api
});
// first, enqueue a message that will be attempted to send, but we don't respond
await queue.enqueueEvent("m.room.message", {body: "hello!"}, null, new NullLogItem());
const observer = new ListObserver();
queue.pendingEvents.subscribe(observer);
await queue.enqueueEvent("m.room.message", {body: "...world"}, null, new NullLogItem());
let txnId;
{
const {type, index, value} = await observer.next();
assert.equal(type, "add");
assert.equal(index, 1);
assert.equal(typeof value.txnId, "string");
txnId = value.txnId;
}
await queue.enqueueRedaction(txnId, null, new NullLogItem());
{
const {type, value, index} = await observer.next();
assert.equal(type, "remove");
assert.equal(index, 1);
assert.equal(txnId, value.txnId);
}
},
"duplicate redaction gets dropped": async assert => {
const queue = new SendQueue({
roomId: "!abc",
storage: await createMockStorage(),
hsApi: new MockHomeServer().api
});
assert.equal(queue.pendingEvents.length, 0);
await queue.enqueueRedaction("!event", null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 1);
await queue.enqueueRedaction("!event", null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 1);
},
"duplicate reaction gets dropped": async assert => {
const queue = new SendQueue({
roomId: "!abc",
storage: await createMockStorage(),
hsApi: new MockHomeServer().api
});
assert.equal(queue.pendingEvents.length, 0);
await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 1);
await queue.enqueueEvent("m.reaction", createAnnotation("!target", "👋"), null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 2);
await queue.enqueueEvent("m.reaction", createAnnotation("!target", "🚀"), null, new NullLogItem());
assert.equal(queue.pendingEvents.length, 2);
},
}
}

View file

@ -0,0 +1,76 @@
/*
Copyright 2021 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 class PendingAnnotation {
constructor() {
// TODO: use simple member for reaction and redaction as we can't/shouldn't really have more than 2 entries
// this contains both pending annotation entries, and pending redactions of remote annotation entries
this._entries = [];
}
get firstTimestamp() {
return this._entries.reduce((ts, e) => {
if (e.isRedaction) {
return ts;
}
return Math.min(e.timestamp, ts);
}, Number.MAX_SAFE_INTEGER);
}
get annotationEntry() {
return this._entries.find(e => !e.isRedaction);
}
get redactionEntry() {
return this._entries.find(e => e.isRedaction);
}
get count() {
return this._entries.reduce((count, e) => {
return count + (e.isRedaction ? -1 : 1);
}, 0);
}
add(entry) {
this._entries.push(entry);
}
remove(entry) {
const idx = this._entries.indexOf(entry);
if (idx === -1) {
return false;
}
this._entries.splice(idx, 1);
return true;
}
get willAnnotate() {
const lastEntry = this._entries.reduce((lastEntry, e) => {
if (!lastEntry || e.pendingEvent.queueIndex > lastEntry.pendingEvent.queueIndex) {
return e;
}
return lastEntry;
}, null);
if (lastEntry) {
return !lastEntry.isRedaction;
}
return false;
}
get isEmpty() {
return this._entries.length === 0;
}
}

View file

@ -15,22 +15,34 @@ limitations under the License.
*/
export class PowerLevels {
constructor({powerLevelEvent, createEvent, ownUserId}) {
constructor({powerLevelEvent, createEvent, ownUserId, membership}) {
this._plEvent = powerLevelEvent;
this._createEvent = createEvent;
this._ownUserId = ownUserId;
this._membership = membership;
}
canRedactFromSender(userId) {
if (userId === this._ownUserId) {
if (userId === this._ownUserId && this._membership === "join") {
return true;
} else {
return this.canRedact;
}
}
canSendType(eventType) {
return this._myLevel >= this._getEventTypeLevel(eventType);
}
get canRedact() {
return this._getUserLevel(this._ownUserId) >= this._getActionLevel("redact");
return this._myLevel >= this._getActionLevel("redact");
}
get _myLevel() {
if (this._membership !== "join") {
return Number.MIN_SAFE_INTEGER;
}
return this._getUserLevel(this._ownUserId);
}
_getUserLevel(userId) {
@ -59,37 +71,88 @@ export class PowerLevels {
return 50;
}
}
_getEventTypeLevel(eventType) {
const level = this._plEvent?.content.events?.[eventType];
if (typeof level === "number") {
return level;
} else {
const level = this._plEvent?.content.events_default;
if (typeof level === "number") {
return level;
} else {
return 0;
}
}
}
}
export function tests() {
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
const charly = "@charly:hs.tld";
const createEvent = {content: {creator: alice}};
const powerLevelEvent = {content: {
const redactPowerLevelEvent = {content: {
redact: 50,
users: {
[alice]: 50
},
users_default: 0
}};
const eventsPowerLevelEvent = {content: {
events_default: 5,
events: {
"m.room.message": 45,
"m.room.topic": 50,
},
users: {
[alice]: 50,
[bob]: 10
},
users_default: 0
}};
return {
"redact somebody else event with power level event": assert => {
const pl1 = new PowerLevels({powerLevelEvent, ownUserId: alice});
const pl1 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: alice, membership: "join"});
assert.equal(pl1.canRedact, true);
const pl2 = new PowerLevels({powerLevelEvent, ownUserId: bob});
const pl2 = new PowerLevels({powerLevelEvent: redactPowerLevelEvent, ownUserId: bob, membership: "join"});
assert.equal(pl2.canRedact, false);
},
"redact somebody else event with create event": assert => {
const pl1 = new PowerLevels({createEvent, ownUserId: alice});
const pl1 = new PowerLevels({createEvent, ownUserId: alice, membership: "join"});
assert.equal(pl1.canRedact, true);
const pl2 = new PowerLevels({createEvent, ownUserId: bob});
const pl2 = new PowerLevels({createEvent, ownUserId: bob, membership: "join"});
assert.equal(pl2.canRedact, false);
},
"redact own event": assert => {
const pl = new PowerLevels({ownUserId: alice});
const pl = new PowerLevels({ownUserId: alice, membership: "join"});
assert.equal(pl.canRedactFromSender(alice), true);
assert.equal(pl.canRedactFromSender(bob), false);
},
"can send event without power levels": assert => {
const pl = new PowerLevels({createEvent, ownUserId: charly, membership: "join"});
assert.equal(pl.canSendType("m.room.message"), true);
},
"can't send any event below events_default": assert => {
const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: charly, membership: "join"});
assert.equal(pl.canSendType("m.foo"), false);
},
"can't send event below events[type]": assert => {
const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: bob, membership: "join"});
assert.equal(pl.canSendType("m.foo"), true);
assert.equal(pl.canSendType("m.room.message"), false);
},
"can send event above or at events[type]": assert => {
const pl = new PowerLevels({powerLevelEvent: eventsPowerLevelEvent, ownUserId: alice, membership: "join"});
assert.equal(pl.canSendType("m.room.message"), true);
assert.equal(pl.canSendType("m.room.topic"), true);
},
"can't redact or send in non-joined room'": assert => {
const pl = new PowerLevels({createEvent, ownUserId: alice, membership: "leave"});
assert.equal(pl.canRedact, false);
assert.equal(pl.canRedactFromSender(alice), false);
assert.equal(pl.canSendType("m.room.message"), false);
},
}
}

View file

@ -15,13 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {SortedArray, MappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
import {Disposables} from "../../../utils/Disposables.js";
import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {RoomMember} from "../members/RoomMember.js";
import {PowerLevels} from "./PowerLevels.js";
import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js";
import {REDACTION_TYPE} from "../common.js";
export class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, clock}) {
@ -32,7 +34,8 @@ export class Timeline {
this._disposables = new Disposables();
this._pendingEvents = pendingEvents;
this._clock = clock;
this._remoteEntries = null;
// constructing this early avoid some problem while sync and openTimeline race
this._remoteEntries = new SortedArray((a, b) => a.compare(b));
this._ownMember = null;
this._timelineReader = new TimelineReader({
roomId: this._roomId,
@ -63,7 +66,7 @@ export class Timeline {
// as they should only populate once the view subscribes to it
// if they are populated already, the sender profile would be empty
this._powerLevels = await this._loadPowerLevels(txn);
this._powerLevels = await this._loadPowerLevels(membership, txn);
// 30 seems to be a good amount to fill the entire screen
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30, txn, log));
try {
@ -75,79 +78,115 @@ export class Timeline {
// txn should be assumed to have finished here, as decryption will close it.
}
async _loadPowerLevels(txn) {
async _loadPowerLevels(membership, txn) {
// TODO: update power levels as state is updated
const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", "");
if (powerLevelsState) {
return new PowerLevels({
powerLevelEvent: powerLevelsState.event,
ownUserId: this._ownMember.userId
ownUserId: this._ownMember.userId,
membership
});
}
const createState = await txn.roomState.get(this._roomId, "m.room.create", "");
if (createState) {
return new PowerLevels({
createEvent: createState.event,
ownUserId: this._ownMember.userId
ownUserId: this._ownMember.userId,
membership
});
} else {
return new PowerLevels({ownUserId: this._ownMember.userId});
return new PowerLevels({ownUserId: this._ownMember.userId, membership});
}
}
_setupEntries(timelineEntries) {
this._remoteEntries = new SortedArray((a, b) => a.compare(b));
this._remoteEntries.setManySorted(timelineEntries);
if (this._pendingEvents) {
this._localEntries = new MappedList(this._pendingEvents, pe => {
const pee = new PendingEventEntry({pendingEvent: pe, member: this._ownMember, clock: this._clock});
this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => target.addLocalRelation(pee));
return pee;
}, (pee, params) => {
this._localEntries = new AsyncMappedList(this._pendingEvents,
pe => this._mapPendingEventToEntry(pe),
(pee, params) => {
// is sending but redacted, who do we detect that here to remove the relation?
pee.notifyUpdate(params);
}, pee => {
this._applyAndEmitLocalRelationChange(pee.pendingEvent, target => target.removeLocalRelation(pee));
});
},
pee => this._applyAndEmitLocalRelationChange(pee, target => target.removeLocalRelation(pee))
);
} else {
this._localEntries = new ObservableArray();
}
this._allEntries = new ConcatList(this._remoteEntries, this._localEntries);
}
_applyAndEmitLocalRelationChange(pe, updater) {
async _mapPendingEventToEntry(pe) {
// we load the redaction target for pending events,
// so if we are redacting a relation, we can pass the redaction
// to the relation target and the removal of the relation can
// be taken into account for local echo.
let redactingEntry;
if (pe.eventType === REDACTION_TYPE) {
redactingEntry = await this._getOrLoadEntry(pe.relatedTxnId, pe.relatedEventId);
}
const pee = new PendingEventEntry({
pendingEvent: pe, member: this._ownMember,
clock: this._clock, redactingEntry
});
this._applyAndEmitLocalRelationChange(pee, target => target.addLocalRelation(pee));
return pee;
}
_applyAndEmitLocalRelationChange(pee, updater) {
// this is the contract of findAndUpdate, used in _findAndUpdateRelatedEntry
const updateOrFalse = e => {
const params = updater(e);
return params ? params : false;
};
this._findAndUpdateRelatedEntry(pee.pendingEvent.relatedTxnId, pee.relatedEventId, updateOrFalse);
// also look for a relation target to update with this redaction
if (pee.redactingEntry) {
// redactingEntry might be a PendingEventEntry or an EventEntry, so don't assume pendingEvent
const relatedTxnId = pee.redactingEntry.pendingEvent?.relatedTxnId;
this._findAndUpdateRelatedEntry(relatedTxnId, pee.redactingEntry.relatedEventId, updateOrFalse);
}
}
_findAndUpdateRelatedEntry(relatedTxnId, relatedEventId, updateOrFalse) {
let found = false;
// first, look in local entries based on txn id
if (pe.relatedTxnId) {
const found = this._localEntries.findAndUpdate(
e => e.id === pe.relatedTxnId,
if (relatedTxnId) {
found = this._localEntries.findAndUpdate(
e => e.id === relatedTxnId,
updateOrFalse,
);
if (found) {
return;
}
}
// now look in remote entries based on event id
if (pe.relatedEventId) {
// if not found here, look in remote entries based on event id
if (!found && relatedEventId) {
this._remoteEntries.findAndUpdate(
e => e.id === pe.relatedEventId,
e => e.id === relatedEventId,
updateOrFalse
);
}
}
updateOwnMember(member) {
this._ownMember = member;
async getOwnAnnotationEntry(targetId, key) {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineRelations,
]);
const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE);
for (const relation of relations) {
const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId);
if (annotation && annotation.event.sender === this._ownMember.userId && getRelation(annotation.event).key === key) {
const eventEntry = new EventEntry(annotation, this._fragmentIdComparer);
this._addLocalRelationsToNewRemoteEntries([eventEntry]);
return eventEntry;
}
}
return null;
}
replaceEntries(entries) {
this._addLocalRelationsToNewRemoteEntries(entries);
for (const entry of entries) {
this._remoteEntries.update(entry);
}
/** @package */
updateOwnMember(member) {
this._ownMember = member;
}
_addLocalRelationsToNewRemoteEntries(entries) {
@ -159,7 +198,9 @@ export class Timeline {
// Once the subscription is setup, MappedList will set up the local
// relations as needed with _applyAndEmitLocalRelationChange,
// so we're not missing anything by bailing out.
if (!this._localEntries.hasSubscriptions) {
//
// _localEntries can also not yet exist
if (!this._localEntries?.hasSubscriptions) {
return;
}
// find any local relations to this new remote event
@ -170,11 +211,30 @@ export class Timeline {
// no need to emit here as this entry is about to be added
relationTarget?.addLocalRelation(pee);
}
if (pee.redactingEntry) {
const eventId = pee.redactingEntry.relatedEventId;
const relationTarget = entries.find(e => e.id === eventId);
relationTarget?.addLocalRelation(pee);
}
}
}
// used in replaceEntries
static _entryUpdater(existingEntry, entry) {
entry.updateFrom(existingEntry);
return entry;
}
/** @package */
replaceEntries(entries) {
this._addLocalRelationsToNewRemoteEntries(entries);
for (const entry of entries) {
this._remoteEntries.getAndUpdate(entry, Timeline._entryUpdater);
}
}
/** @package */
addOrReplaceEntries(newEntries) {
addEntries(newEntries) {
this._addLocalRelationsToNewRemoteEntries(newEntries);
this._remoteEntries.setManySorted(newEntries);
}
@ -201,13 +261,39 @@ export class Timeline {
));
try {
const entries = await readerRequest.complete();
this.addOrReplaceEntries(entries);
this.addEntries(entries);
return entries.length < amount;
} finally {
this._disposables.disposeTracked(readerRequest);
}
}
async _getOrLoadEntry(txnId, eventId) {
if (txnId) {
// also look for redacting relation in pending events, in case the target is already being sent
for (const p of this._localEntries) {
if (p.id === txnId) {
return p;
}
}
}
if (eventId) {
const loadedEntry = this.getByEventId(eventId);
if (loadedEntry) {
return loadedEntry;
} else {
const txn = await this._storage.readWriteTxn([
this._storage.storeNames.timelineEvents,
]);
const redactionTargetEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
if (redactionTargetEntry) {
return new EventEntry(redactionTargetEntry, this._fragmentIdComparer);
}
}
}
return null;
}
getByEventId(eventId) {
for (let i = 0; i < this._remoteEntries.length; i += 1) {
const entry = this._remoteEntries.get(i);
@ -255,37 +341,48 @@ export class Timeline {
}
import {FragmentIdComparer} from "./FragmentIdComparer.js";
import {poll} from "../../../mocks/poll.js";
import {Clock as MockClock} from "../../../mocks/Clock.js";
import {createMockStorage} from "../../../mocks/Storage.js";
import {createEvent, withTextBody, withSender} from "../../../mocks/event.js";
import {ListObserver} from "../../../mocks/ListObserver.js";
import {createEvent, withTextBody, withContent, withSender} from "../../../mocks/event.js";
import {NullLogItem} from "../../../logging/NullLogger.js";
import {EventEntry} from "./entries/EventEntry.js";
import {User} from "../../User.js";
import {PendingEvent} from "../sending/PendingEvent.js";
import {createAnnotation} from "./relations.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
function getIndexFromIterable(it, n) {
let i = 0;
for (const item of it) {
if (i === n) {
return item;
}
i += 1;
}
throw new Error("not enough items in iterable");
}
return {
"adding or replacing entries before subscribing to entries does not loose local relations": async assert => {
"adding or replacing entries before subscribing to entries does not lose local relations": async assert => {
const pendingEvents = new ObservableArray();
const timeline = new Timeline({
roomId,
storage: await createMockStorage(),
closeCallback: () => {},
fragmentIdComparer,
pendingEvents,
clock: new MockClock(),
});
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
// 1. load timeline
await timeline.load(new User("@alice:hs.tld"), "join", new NullLogItem());
// 2. test replaceEntries and addOrReplaceEntries don't fail
const event1 = withTextBody("hi!", withSender("@bob:hs.tld", createEvent("m.room.message", "!abc")));
await timeline.load(new User(alice), "join", new NullLogItem());
// 2. test replaceEntries and addEntries don't fail
const event1 = withTextBody("hi!", withSender(bob, createEvent("m.room.message", "!abc")));
const entry1 = new EventEntry({event: event1, fragmentId: 1, eventIndex: 1}, fragmentIdComparer);
timeline.replaceEntries([entry1]);
const event2 = withTextBody("hi bob!", withSender("@alice:hs.tld", createEvent("m.room.message", "!def")));
const event2 = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!def")));
const entry2 = new EventEntry({event: event2, fragmentId: 1, eventIndex: 2}, fragmentIdComparer);
timeline.addOrReplaceEntries([entry2]);
timeline.addEntries([entry2]);
// 3. add local relation (redaction)
pendingEvents.append(new PendingEvent({data: {
roomId,
@ -296,9 +393,213 @@ export function tests() {
relatedEventId: event2.event_id
}}));
// 4. subscribe (it's now safe to iterate timeline.entries)
timeline.entries.subscribe({});
timeline.entries.subscribe(new ListObserver());
// 5. check the local relation got correctly aggregated
assert.equal(Array.from(timeline.entries)[0].isRedacting, true);
}
const locallyRedacted = await poll(() => Array.from(timeline.entries)[0].isRedacting);
assert.equal(locallyRedacted, true);
},
"add and remove local reaction, and cancel again": async assert => {
// 1. setup timeline with message
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
const event = withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc")));
timeline.addEntries([new EventEntry({event, fragmentId: 1, eventIndex: 2}, fragmentIdComparer)]);
let entry = getIndexFromIterable(timeline.entries, 0);
// 2. add local reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.reaction",
txnId: "t123",
content: entry.annotate("👋"),
relatedEventId: entry.id
}}));
await poll(() => timeline.entries.length === 2);
assert.equal(entry.pendingAnnotations.get("👋").count, 1);
const reactionEntry = getIndexFromIterable(timeline.entries, 1);
// 3. add redaction to timeline
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 2,
eventType: "m.room.redaction",
txnId: "t456",
content: {},
relatedTxnId: reactionEntry.id
}}));
// TODO: await nextUpdate here with ListObserver, to ensure entry emits an update when pendingAnnotations changes
await poll(() => timeline.entries.length === 3);
assert.equal(entry.pendingAnnotations.get("👋").count, 0);
// 4. cancel redaction
pendingEvents.remove(1);
await poll(() => timeline.entries.length === 2);
assert.equal(entry.pendingAnnotations.get("👋").count, 1);
// 5. cancel reaction
pendingEvents.remove(0);
await poll(() => timeline.entries.length === 1);
assert(!entry.pendingAnnotations);
},
"getOwnAnnotationEntry": async assert => {
const messageId = "!abc";
const reactionId = "!def";
// 1. put event and reaction into storage
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({
event: withContent(createAnnotation(messageId, "👋"), createEvent("m.reaction", reactionId, bob)),
fragmentId: 1, eventIndex: 1, roomId
});
txn.timelineRelations.add(roomId, messageId, ANNOTATION_RELATION_TYPE, reactionId);
await txn.complete();
// 2. setup the timeline
const timeline = new Timeline({roomId, storage, closeCallback: () => {},
fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
// 3. get the own annotation out
const reactionEntry = await timeline.getOwnAnnotationEntry(messageId, "👋");
assert.equal(reactionEntry.id, reactionId);
assert.equal(reactionEntry.relation.key, "👋");
},
"remote reaction": async assert => {
const messageEntry = new EventEntry({
event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)),
fragmentId: 1, eventIndex: 2, roomId,
annotations: { // aggregated like RelationWriter would
"👋": {count: 1, me: true, firstTimestamp: 0}
},
}, fragmentIdComparer);
// 2. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 3. add message to timeline
timeline.addEntries([messageEntry]);
const entry = getIndexFromIterable(timeline.entries, 0);
assert.equal(entry, messageEntry);
assert.equal(entry.annotations["👋"].count, 1);
},
"remove remote reaction": async assert => {
// 1. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 2. add message and reaction to timeline
const messageEntry = new EventEntry({
event: withTextBody("hi bob!", createEvent("m.room.message", "!abc", alice)),
fragmentId: 1, eventIndex: 2, roomId,
}, fragmentIdComparer);
const reactionEntry = new EventEntry({
event: withContent(createAnnotation(messageEntry.id, "👋"), createEvent("m.reaction", "!def", bob)),
fragmentId: 1, eventIndex: 3, roomId
}, fragmentIdComparer);
timeline.addEntries([messageEntry, reactionEntry]);
// 3. redact reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.room.redaction",
txnId: "t123",
content: {},
relatedEventId: reactionEntry.id
}}));
await poll(() => timeline.entries.length >= 3);
assert.equal(messageEntry.pendingAnnotations.get("👋").count, -1);
},
"local reaction gets applied after remote echo is added to timeline": async assert => {
const messageEntry = new EventEntry({event: withTextBody("hi bob!", withSender(alice, createEvent("m.room.message", "!abc"))),
fragmentId: 1, eventIndex: 2}, fragmentIdComparer);
// 1. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage: await createMockStorage(),
closeCallback: () => {}, fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 2. add local reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.reaction",
txnId: "t123",
content: messageEntry.annotate("👋"),
relatedEventId: messageEntry.id
}}));
await poll(() => timeline.entries.length === 1);
// 3. add remote reaction target
timeline.addEntries([messageEntry]);
await poll(() => timeline.entries.length === 2);
const entry = getIndexFromIterable(timeline.entries, 0);
assert.equal(entry, messageEntry);
assert.equal(entry.pendingAnnotations.get("👋").count, 1);
},
"local reaction removal gets applied after remote echo is added to timeline with reaction not loaded": async assert => {
const messageId = "!abc";
const reactionId = "!def";
// 1. put reaction in storage
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({
event: withContent(createAnnotation(messageId, "👋"), createEvent("m.reaction", reactionId, bob)),
fragmentId: 1, eventIndex: 3, roomId
});
await txn.complete();
// 2. setup timeline
const pendingEvents = new ObservableArray();
const timeline = new Timeline({roomId, storage, closeCallback: () => {},
fragmentIdComparer, pendingEvents, clock: new MockClock()});
await timeline.load(new User(bob), "join", new NullLogItem());
timeline.entries.subscribe(new ListObserver());
// 3. add local redaction for reaction
pendingEvents.append(new PendingEvent({data: {
roomId,
queueIndex: 1,
eventType: "m.room.redaction",
txnId: "t123",
content: {},
relatedEventId: reactionId
}}));
await poll(() => timeline.entries.length === 1);
// 4. add reaction target
timeline.addEntries([new EventEntry({
event: withTextBody("hi bob!", createEvent("m.room.message", messageId, alice)),
fragmentId: 1, eventIndex: 2}, fragmentIdComparer)
]);
await poll(() => timeline.entries.length === 2);
// 5. check that redaction was linked to reaction target
const entry = getIndexFromIterable(timeline.entries, 0);
assert.equal(entry.pendingAnnotations.get("👋").count, -1);
},
"decrypted entry preserves content when receiving other update without decryption": async assert => {
// 1. create encrypted and decrypted entry
const encryptedEntry = new EventEntry({
event: withContent({ciphertext: "abc"}, createEvent("m.room.encrypted", "!abc", alice)),
fragmentId: 1, eventIndex: 1, roomId
}, fragmentIdComparer);
const decryptedEntry = encryptedEntry.clone();
decryptedEntry.setDecryptionResult({
event: withTextBody("hi bob!", createEvent("m.room.message", encryptedEntry.id, encryptedEntry.sender))
});
// 2. setup the timeline
const timeline = new Timeline({roomId, storage: await createMockStorage(), closeCallback: () => {},
fragmentIdComparer, pendingEvents: new ObservableArray(), clock: new MockClock()});
await timeline.load(new User(alice), "join", new NullLogItem());
timeline.addEntries([decryptedEntry]);
const observer = new ListObserver();
timeline.entries.subscribe(observer);
// 3. replace the entry with one that is not decrypted
// (as would happen when receiving a reaction,
// as it does not rerun the decryption)
// and check that the decrypted content is preserved
timeline.replaceEntries([encryptedEntry]);
const {value, type} = await observer.next();
assert.equal(type, "update");
assert.equal(value.eventType, "m.room.message");
assert.equal(value.content.body, "hi bob!");
}
};
}

View file

@ -47,4 +47,6 @@ export class BaseEntry {
asEventKey() {
return new EventKey(this.fragmentId, this.entryIndex);
}
updateFrom() {}
}

View file

@ -16,11 +16,16 @@ limitations under the License.
import {BaseEntry} from "./BaseEntry.js";
import {REDACTION_TYPE} from "../../common.js";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js";
import {PendingAnnotation} from "../PendingAnnotation.js";
/** Deals mainly with local echo for relations and redactions,
* so it is shared between PendingEventEntry and EventEntry */
export class BaseEventEntry extends BaseEntry {
constructor(fragmentIdComparer) {
super(fragmentIdComparer);
this._pendingRedactions = null;
this._pendingAnnotations = null;
}
get isRedacting() {
@ -31,6 +36,10 @@ export class BaseEventEntry extends BaseEntry {
return this.isRedacting;
}
get isRedaction() {
return this.eventType === REDACTION_TYPE;
}
get redactionReason() {
if (this._pendingRedactions) {
return this._pendingRedactions[0].content?.reason;
@ -39,11 +48,11 @@ export class BaseEventEntry extends BaseEntry {
}
/**
aggregates local relation.
aggregates local relation or local redaction of remote relation.
@return [string] returns the name of the field that has changed, if any
*/
addLocalRelation(entry) {
if (entry.eventType === REDACTION_TYPE) {
if (entry.eventType === REDACTION_TYPE && entry.isRelatedToId(this.id)) {
if (!this._pendingRedactions) {
this._pendingRedactions = [];
}
@ -51,15 +60,24 @@ export class BaseEventEntry extends BaseEntry {
if (this._pendingRedactions.length === 1) {
return "isRedacted";
}
} else {
const relationEntry = entry.redactingEntry || entry;
if (relationEntry.isRelatedToId(this.id)) {
if (relationEntry.relation.rel_type === ANNOTATION_RELATION_TYPE) {
if (this._addPendingAnnotation(entry)) {
return "pendingAnnotations";
}
}
}
}
}
/**
deaggregates local relation.
deaggregates local relation or a local redaction of a remote relation.
@return [string] returns the name of the field that has changed, if any
*/
removeLocalRelation(entry) {
if (entry.eventType === REDACTION_TYPE && this._pendingRedactions) {
if (entry.eventType === REDACTION_TYPE && entry.isRelatedToId(this.id) && this._pendingRedactions) {
const countBefore = this._pendingRedactions.length;
this._pendingRedactions = this._pendingRedactions.filter(e => e !== entry);
if (this._pendingRedactions.length === 0) {
@ -68,8 +86,49 @@ export class BaseEventEntry extends BaseEntry {
return "isRedacted";
}
}
} else {
const relationEntry = entry.redactingEntry || entry;
if (relationEntry.isRelatedToId(this.id)) {
if (relationEntry.relation?.rel_type === ANNOTATION_RELATION_TYPE && this._pendingAnnotations) {
if (this._removePendingAnnotation(entry)) {
return "pendingAnnotations";
}
}
}
}
}
_addPendingAnnotation(entry) {
if (!this._pendingAnnotations) {
this._pendingAnnotations = new Map();
}
const {key} = (entry.redactingEntry || entry).relation;
if (key) {
let annotation = this._pendingAnnotations.get(key);
if (!annotation) {
annotation = new PendingAnnotation();
this._pendingAnnotations.set(key, annotation);
}
annotation.add(entry);
return true;
}
return false;
}
_removePendingAnnotation(entry) {
const {key} = (entry.redactingEntry || entry).relation;
if (key) {
let annotation = this._pendingAnnotations.get(key);
if (annotation.remove(entry) && annotation.isEmpty) {
this._pendingAnnotations.delete(key);
}
if (this._pendingAnnotations.size === 0) {
this._pendingAnnotations = null;
}
return true;
}
return false;
}
async abortPendingRedaction() {
if (this._pendingRedactions) {
@ -80,4 +139,46 @@ export class BaseEventEntry extends BaseEntry {
// so don't clear _pendingRedactions here
}
}
get pendingRedaction() {
if (this._pendingRedactions) {
return this._pendingRedactions[0];
}
return null;
}
annotate(key) {
return createAnnotation(this.id, key);
}
/** takes both remote event id and local txn id into account, see overriding in PendingEventEntry */
isRelatedToId(id) {
return id && this.relatedEventId === id;
}
haveAnnotation(key) {
const haveRemoteReaction = this.annotations?.[key]?.me || false;
const pendingAnnotation = this.pendingAnnotations?.get(key);
const willAnnotate = pendingAnnotation?.willAnnotate || false;
/*
We have an annotation in these case:
- remote annotation with me, no pending
- remote annotation with me, pending redaction and then annotation
- pending annotation without redaction after it
*/
return (haveRemoteReaction && (!pendingAnnotation || willAnnotate)) ||
(!haveRemoteReaction && willAnnotate);
}
get relation() {
return getRelationFromContent(this.content);
}
get pendingAnnotations() {
return this._pendingAnnotations;
}
get annotations() {
return null; //overwritten in EventEntry
}
}

View file

@ -15,7 +15,8 @@ limitations under the License.
*/
import {BaseEventEntry} from "./BaseEventEntry.js";
import {getPrevContentFromStateEvent} from "../../common.js";
import {getPrevContentFromStateEvent, isRedacted} from "../../common.js";
import {getRelatedEventId} from "../relations.js";
export class EventEntry extends BaseEventEntry {
constructor(eventEntry, fragmentIdComparer) {
@ -27,11 +28,20 @@ export class EventEntry extends BaseEventEntry {
clone() {
const clone = new EventEntry(this._eventEntry, this._fragmentIdComparer);
clone._decryptionResult = this._decryptionResult;
clone._decryptionError = this._decryptionError;
clone.updateFrom(this);
return clone;
}
updateFrom(other) {
super.updateFrom(other);
if (other._decryptionResult && !this._decryptionResult) {
this._decryptionResult = other._decryptionResult;
}
if (other._decryptionError && !this._decryptionError) {
this._decryptionError = other._decryptionError;
}
}
get event() {
return this._eventEntry.event;
}
@ -110,11 +120,11 @@ export class EventEntry extends BaseEventEntry {
}
get relatedEventId() {
return this._eventEntry.event.redacts;
return getRelatedEventId(this.event);
}
get isRedacted() {
return super.isRedacted || !!this._eventEntry.event.unsigned?.redacted_because;
return super.isRedacted || isRedacted(this._eventEntry.event);
}
get redactionReason() {
@ -122,6 +132,96 @@ export class EventEntry extends BaseEventEntry {
if (redactionEvent) {
return redactionEvent.content?.reason;
}
// fall back to local echo reason
return super.redactionReason;
}
get annotations() {
return this._eventEntry.annotations;
}
}
import {withTextBody, withContent, createEvent} from "../../../../mocks/event.js";
import {Clock as MockClock} from "../../../../mocks/Clock.js";
import {PendingEventEntry} from "./PendingEventEntry.js";
import {PendingEvent} from "../../sending/PendingEvent.js";
import {createAnnotation} from "../relations.js";
export function tests() {
let queueIndex = 0;
const clock = new MockClock();
function addPendingReaction(target, key) {
queueIndex += 1;
target.addLocalRelation(new PendingEventEntry({
pendingEvent: new PendingEvent({data: {
eventType: "m.reaction",
content: createAnnotation(target.id, key),
queueIndex,
txnId: `t${queueIndex}`
}}),
clock
}));
return target;
}
function addPendingRedaction(target, key) {
const pendingReaction = target.pendingAnnotations?.get(key)?.annotationEntry;
let redactingEntry = pendingReaction;
// make up a remote entry if we don't have a pending reaction and have an aggregated remote entry
if (!pendingReaction && target.annotations[key].me) {
redactingEntry = new EventEntry({
event: withContent(createAnnotation(target.id, key), createEvent("m.reaction", "!def"))
});
}
queueIndex += 1;
target.addLocalRelation(new PendingEventEntry({
pendingEvent: new PendingEvent({data: {
eventType: "m.room.redaction",
relatedTxnId: pendingReaction ? pendingReaction.id : null,
relatedEventId: pendingReaction ? null : redactingEntry.id,
queueIndex,
txnId: `t${queueIndex}`
}}),
redactingEntry,
clock
}));
return target;
}
function remoteAnnotation(key, me, count, obj = {}) {
obj[key] = {me, count};
return obj;
}
return {
// testing it here because parent class always assumes annotations is null
"haveAnnotation": assert => {
const msgEvent = withTextBody("hi!", createEvent("m.room.message", "!abc"));
const e1 = new EventEntry({event: msgEvent});
assert.equal(false, e1.haveAnnotation("🚀"));
const e2 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", false, 1)});
assert.equal(false, e2.haveAnnotation("🚀"));
const e3 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)});
assert.equal(true, e3.haveAnnotation("🚀"));
const e4 = new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 2)});
assert.equal(true, e4.haveAnnotation("🚀"));
const e5 = addPendingReaction(new EventEntry({event: msgEvent}), "🚀");
assert.equal(true, e5.haveAnnotation("🚀"));
const e6 = addPendingRedaction(new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}), "🚀");
assert.equal(false, e6.haveAnnotation("🚀"));
const e7 = addPendingReaction(
addPendingRedaction(
new EventEntry({event: msgEvent, annotations: remoteAnnotation("🚀", true, 1)}),
"🚀"),
"🚀");
assert.equal(true, e7.haveAnnotation("🚀"));
const e8 = addPendingRedaction(
addPendingReaction(
new EventEntry({event: msgEvent}),
"🚀"),
"🚀");
assert.equal(false, e8.haveAnnotation("🚀"));
}
}
}

View file

@ -18,12 +18,16 @@ import {PENDING_FRAGMENT_ID} from "./BaseEntry.js";
import {BaseEventEntry} from "./BaseEventEntry.js";
export class PendingEventEntry extends BaseEventEntry {
constructor({pendingEvent, member, clock}) {
constructor({pendingEvent, member, clock, redactingEntry}) {
super(null);
this._pendingEvent = pendingEvent;
/** @type {RoomMember} */
this._member = member;
this._clock = clock;
// try to come up with a timestamp that is around construction time and
// will be roughly sorted by queueIndex, so it can be used to as a secondary
// sorting dimension for reactions
this._timestamp = clock.now() - (100 - pendingEvent.queueIndex);
this._redactingEntry = redactingEntry;
}
get fragmentId() {
@ -63,7 +67,7 @@ export class PendingEventEntry extends BaseEventEntry {
}
get timestamp() {
return this._clock.now();
return this._timestamp;
}
get isPending() {
@ -82,7 +86,18 @@ export class PendingEventEntry extends BaseEventEntry {
}
isRelatedToId(id) {
if (id && id === this._pendingEvent.relatedTxnId) {
return true;
}
return super.isRelatedToId(id);
}
get relatedEventId() {
return this._pendingEvent.relatedEventId;
}
get redactingEntry() {
return this._redactingEntry;
}
}

View file

@ -14,18 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {RelationWriter} from "./RelationWriter.js";
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}) {
constructor({roomId, storage, fragmentIdComparer, relationWriter}) {
this._roomId = roomId;
this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer;
this._relationWriter = new RelationWriter(roomId, fragmentIdComparer);
this._relationWriter = relationWriter;
}
// events is in reverse-chronological order (last event comes at index 0) if backwards
async _findOverlappingEvents(fragmentEntry, events, txn, log) {
@ -120,13 +119,14 @@ export class GapWriter {
eventStorageEntry.displayName = member.displayName;
eventStorageEntry.avatarUrl = member.avatarUrl;
}
// this will modify eventStorageEntry if it is a relation target
const updatedRelationTargetEntries = await this._relationWriter.writeGapRelation(eventStorageEntry, direction, txn, log);
if (updatedRelationTargetEntries) {
updatedEntries.push(...updatedRelationTargetEntries);
}
txn.timelineEvents.insert(eventStorageEntry);
const eventEntry = new EventEntry(eventStorageEntry, this._fragmentIdComparer);
directionalAppend(entries, eventEntry, direction);
const updatedRelationTargetEntry = await this._relationWriter.writeRelation(eventEntry, txn, log);
if (updatedRelationTargetEntry) {
updatedEntries.push(updatedRelationTargetEntry);
}
}
return {entries, updatedEntries};
}

View file

@ -15,59 +15,211 @@ limitations under the License.
*/
import {EventEntry} from "../entries/EventEntry.js";
import {REDACTION_TYPE} from "../../common.js";
import {REDACTION_TYPE, isRedacted} from "../../common.js";
import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js";
export class RelationWriter {
constructor(roomId, fragmentIdComparer) {
constructor({roomId, ownUserId, fragmentIdComparer}) {
this._roomId = roomId;
this._ownUserId = ownUserId;
this._fragmentIdComparer = fragmentIdComparer;
}
// this needs to happen again after decryption too for edits
async writeRelation(sourceEntry, txn, log) {
if (sourceEntry.relatedEventId) {
const target = await txn.timelineEvents.getByEventId(this._roomId, sourceEntry.relatedEventId);
const {relatedEventId} = sourceEntry;
if (relatedEventId) {
const relation = getRelation(sourceEntry.event);
if (relation) {
txn.timelineRelations.add(this._roomId, relation.event_id, relation.rel_type, sourceEntry.id);
}
const target = await txn.timelineEvents.getByEventId(this._roomId, relatedEventId);
if (target) {
if (this._applyRelation(sourceEntry, target, log)) {
txn.timelineEvents.update(target);
return new EventEntry(target, this._fragmentIdComparer);
const updatedStorageEntries = await this._applyRelation(sourceEntry, target, txn, log);
if (updatedStorageEntries) {
return updatedStorageEntries.map(e => {
txn.timelineEvents.update(e);
return new EventEntry(e, this._fragmentIdComparer);
});
}
}
}
return;
return null;
}
_applyRelation(sourceEntry, targetEntry, log) {
/**
* @param {Object} storageEntry the event object, as it will be stored in storage.
* Will be modified (but not written to storage) in case this event is
* a relation target for which we've previously received relations.
* @param {Direction} direction of the gap fill
* */
async writeGapRelation(storageEntry, direction, txn, log) {
const sourceEntry = new EventEntry(storageEntry, this._fragmentIdComparer);
const result = await this.writeRelation(sourceEntry, txn, log);
// when back-paginating, it can also happen that we've received relations
// for this event before, which now upon receiving the target need to be aggregated.
if (direction.isBackward && !isRedacted(storageEntry.event)) {
const relations = await txn.timelineRelations.getAllForTarget(this._roomId, sourceEntry.id);
if (relations.length) {
for (const r of relations) {
const relationStorageEntry = await txn.timelineEvents.getByEventId(this._roomId, r.sourceEventId);
if (relationStorageEntry) {
const relationEntry = new EventEntry(relationStorageEntry, this._fragmentIdComparer);
await this._applyRelation(relationEntry, storageEntry, txn, log);
}
}
}
}
return result;
}
/**
* @param {EventEntry} sourceEntry
* @param {Object} targetStorageEntry event entry as stored in the timelineEvents store
* @return {[Object]} array of event storage entries that have been updated
* */
async _applyRelation(sourceEntry, targetStorageEntry, txn, log) {
if (sourceEntry.eventType === REDACTION_TYPE) {
return log.wrap("redact", log => this._applyRedaction(sourceEntry.event, targetEntry.event, log));
} else {
return false;
return log.wrap("redact", async log => {
const redactedEvent = targetStorageEntry.event;
const relation = getRelation(redactedEvent); // get this before redacting
const redacted = this._applyRedaction(sourceEntry.event, targetStorageEntry, txn, log);
if (redacted) {
const updated = [targetStorageEntry];
if (relation) {
const relationTargetStorageEntry = await this._reaggregateRelation(redactedEvent, relation, txn, log);
if (relationTargetStorageEntry) {
updated.push(relationTargetStorageEntry);
}
}
return updated;
}
return null;
});
} else {
const relation = getRelation(sourceEntry.event);
if (relation && !isRedacted(targetStorageEntry.event)) {
const relType = relation.rel_type;
if (relType === ANNOTATION_RELATION_TYPE) {
const aggregated = log.wrap("react", log => {
return this._aggregateAnnotation(sourceEntry.event, targetStorageEntry, log);
});
if (aggregated) {
return [targetStorageEntry];
}
}
}
}
return null;
}
_applyRedaction(redactionEvent, targetEvent, log) {
_applyRedaction(redactionEvent, redactedStorageEntry, txn, log) {
const redactedEvent = redactedStorageEntry.event;
log.set("redactionId", redactionEvent.event_id);
log.set("id", targetEvent.event_id);
// TODO: should we make efforts to preserve the decrypted event type?
// probably ok not to, as we'll show whatever is deleted as "deleted message"
// reactions are the only thing that comes to mind, but we don't encrypt those (for now)
for (const key of Object.keys(targetEvent)) {
log.set("id", redactedEvent.event_id);
const relation = getRelation(redactedEvent);
if (relation) {
txn.timelineRelations.remove(this._roomId, relation.event_id, relation.rel_type, redactedEvent.event_id);
}
// check if we're the target of a relation and remove all relations then as well
txn.timelineRelations.removeAllForTarget(this._roomId, redactedEvent.event_id);
for (const key of Object.keys(redactedEvent)) {
if (!_REDACT_KEEP_KEY_MAP[key]) {
delete targetEvent[key];
delete redactedEvent[key];
}
}
const {content} = targetEvent;
const keepMap = _REDACT_KEEP_CONTENT_MAP[targetEvent.type];
const {content} = redactedEvent;
const keepMap = _REDACT_KEEP_CONTENT_MAP[redactedEvent.type];
for (const key of Object.keys(content)) {
if (!keepMap?.[key]) {
delete content[key];
}
}
targetEvent.unsigned = targetEvent.unsigned || {};
targetEvent.unsigned.redacted_because = redactionEvent;
redactedEvent.unsigned = redactedEvent.unsigned || {};
redactedEvent.unsigned.redacted_because = redactionEvent;
delete redactedStorageEntry.annotations;
return true;
}
_aggregateAnnotation(annotationEvent, targetStorageEntry, log) {
// TODO: do we want to verify it is a m.reaction event somehow?
const relation = getRelation(annotationEvent);
if (!relation) {
return false;
}
let {annotations} = targetStorageEntry;
if (!annotations) {
targetStorageEntry.annotations = annotations = {};
}
let annotation = annotations[relation.key];
if (!annotation) {
annotations[relation.key] = annotation = {
count: 0,
me: false,
firstTimestamp: Number.MAX_SAFE_INTEGER
};
}
const sentByMe = annotationEvent.sender === this._ownUserId;
annotation.me = annotation.me || sentByMe;
annotation.count += 1;
annotation.firstTimestamp = Math.min(
annotation.firstTimestamp,
annotationEvent.origin_server_ts
);
return true;
}
async _reaggregateRelation(redactedRelationEvent, redactedRelation, txn, log) {
if (redactedRelation.rel_type === ANNOTATION_RELATION_TYPE) {
return log.wrap("reaggregate annotations", log => this._reaggregateAnnotation(
redactedRelation.event_id,
redactedRelation.key,
txn, log
));
}
return null;
}
async _reaggregateAnnotation(targetId, key, txn, log) {
const target = await txn.timelineEvents.getByEventId(this._roomId, targetId);
if (!target) {
return null;
}
log.set("id", targetId);
const relations = await txn.timelineRelations.getForTargetAndType(this._roomId, targetId, ANNOTATION_RELATION_TYPE);
log.set("relations", relations.length);
delete target.annotations[key];
if (isObjectEmpty(target.annotations)) {
delete target.annotations;
}
await Promise.all(relations.map(async relation => {
const annotation = await txn.timelineEvents.getByEventId(this._roomId, relation.sourceEventId);
if (!annotation) {
log.log({l: "missing annotation", id: relation.sourceEventId});
}
if (getRelation(annotation.event).key === key) {
this._aggregateAnnotation(annotation.event, target, log);
}
}));
return target;
}
}
function isObjectEmpty(obj) {
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
return false;
}
}
return true;
}
// copied over from matrix-js-sdk, copyright 2016 OpenMarket Ltd
@ -99,3 +251,136 @@ const _REDACT_KEEP_CONTENT_MAP = {
'm.room.aliases': {'aliases': 1},
};
// end of matrix-js-sdk code
import {createMockStorage} from "../../../../mocks/Storage.js";
import {createEvent, withTextBody, withRedacts, withContent} from "../../../../mocks/event.js";
import {createAnnotation} from "../relations.js";
import {FragmentIdComparer} from "../FragmentIdComparer.js";
import {NullLogItem} from "../../../../logging/NullLogger.js";
export function tests() {
const fragmentIdComparer = new FragmentIdComparer([]);
const roomId = "$abc";
const alice = "@alice:hs.tld";
const bob = "@bob:hs.tld";
return {
"apply redaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const reason = "nonsense, cats are the best!";
const redaction = withRedacts(event.event_id, reason, createEvent("m.room.redaction", "!def", alice));
const redactionEntry = new EventEntry({fragmentId: 1, eventIndex: 3, event: redaction, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: bob, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
const updatedEntries = await relationWriter.writeRelation(redactionEntry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 1);
const redactedMessage = updatedEntries[0];
assert.equal(redactedMessage.id, "!abc");
assert.equal(redactedMessage.content.body, undefined);
assert.equal(redactedMessage.redactionReason, reason);
const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
await readTxn.complete();
assert.equal(storedMessage.event.content.body, undefined);
assert.equal(storedMessage.event.unsigned.redacted_because.content.reason, reason);
},
"aggregate reaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const reaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
reaction.origin_server_ts = 5;
const reactionEntry = new EventEntry({event: reaction, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
const updatedEntries = await relationWriter.writeRelation(reactionEntry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 1);
const reactedMessage = updatedEntries[0];
assert.equal(reactedMessage.id, "!abc");
const annotation = reactedMessage.annotations["🐶"];
assert.equal(annotation.me, true);
assert.equal(annotation.count, 1);
assert.equal(annotation.firstTimestamp, 5);
const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
await readTxn.complete();
assert(storedMessage.annotations["🐶"]);
},
"aggregate second reaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const reaction1 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
reaction1.origin_server_ts = 5;
const reaction1Entry = new EventEntry({event: reaction1, roomId}, fragmentIdComparer);
const reaction2 = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob));
reaction2.origin_server_ts = 10;
const reaction2Entry = new EventEntry({event: reaction2, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
await relationWriter.writeRelation(reaction1Entry, txn, new NullLogItem());
const updatedEntries = await relationWriter.writeRelation(reaction2Entry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 1);
const reactedMessage = updatedEntries[0];
assert.equal(reactedMessage.id, "!abc");
const annotation = reactedMessage.annotations["🐶"];
assert.equal(annotation.me, true);
assert.equal(annotation.count, 2);
assert.equal(annotation.firstTimestamp, 5);
},
"redact second reaction": async assert => {
const event = withTextBody("Dogs > Cats", createEvent("m.room.message", "!abc", bob));
const myReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!def", alice));
myReaction.origin_server_ts = 5;
const bobReaction = withContent(createAnnotation(event.event_id, "🐶"), createEvent("m.reaction", "!hij", bob));
bobReaction.origin_server_ts = 10;
const myReactionRedaction = withRedacts(myReaction.event_id, "", createEvent("m.room.redaction", "!pol", alice));
const myReactionEntry = new EventEntry({event: myReaction, roomId}, fragmentIdComparer);
const bobReactionEntry = new EventEntry({event: bobReaction, roomId}, fragmentIdComparer);
const myReactionRedactionEntry = new EventEntry({event: myReactionRedaction, roomId}, fragmentIdComparer);
const relationWriter = new RelationWriter({roomId, ownUserId: alice, fragmentIdComparer});
const storage = await createMockStorage();
const txn = await storage.readWriteTxn([storage.storeNames.timelineEvents, storage.storeNames.timelineRelations]);
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 2, event, roomId});
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 3, event: myReaction, roomId});
await relationWriter.writeRelation(myReactionEntry, txn, new NullLogItem());
txn.timelineEvents.insert({fragmentId: 1, eventIndex: 4, event: bobReaction, roomId});
await relationWriter.writeRelation(bobReactionEntry, txn, new NullLogItem());
const updatedEntries = await relationWriter.writeRelation(myReactionRedactionEntry, txn, new NullLogItem());
await txn.complete();
assert.equal(updatedEntries.length, 2);
const redactedReaction = updatedEntries[0];
assert.equal(redactedReaction.id, "!def");
const reaggregatedMessage = updatedEntries[1];
assert.equal(reaggregatedMessage.id, "!abc");
const annotation = reaggregatedMessage.annotations["🐶"];
assert.equal(annotation.me, false);
assert.equal(annotation.count, 1);
assert.equal(annotation.firstTimestamp, 10);
const readTxn = await storage.readTxn([storage.storeNames.timelineEvents]);
const storedMessage = await readTxn.timelineEvents.getByEventId(roomId, "!abc");
await readTxn.complete();
assert.equal(storedMessage.annotations["🐶"].count, 1);
},
}
}

View file

@ -20,8 +20,6 @@ import {EventEntry} from "../entries/EventEntry.js";
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
import {createEventEntry} from "./common.js";
import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
import {MemberWriter} from "./MemberWriter.js";
import {RelationWriter} from "./RelationWriter.js";
// Synapse bug? where the m.room.create event appears twice in sync response
// when first syncing the room
@ -38,10 +36,10 @@ function deduplicateEvents(events) {
}
export class SyncWriter {
constructor({roomId, fragmentIdComparer}) {
constructor({roomId, fragmentIdComparer, memberWriter, relationWriter}) {
this._roomId = roomId;
this._memberWriter = new MemberWriter(roomId);
this._relationWriter = new RelationWriter(roomId, fragmentIdComparer);
this._memberWriter = memberWriter;
this._relationWriter = relationWriter;
this._fragmentIdComparer = fragmentIdComparer;
this._lastLiveKey = null;
}
@ -174,9 +172,9 @@ export class SyncWriter {
txn.timelineEvents.insert(storageEntry);
const entry = new EventEntry(storageEntry, this._fragmentIdComparer);
entries.push(entry);
const updatedRelationTargetEntry = await this._relationWriter.writeRelation(entry, txn, log);
if (updatedRelationTargetEntry) {
updatedEntries.push(updatedRelationTargetEntry);
const updatedRelationTargetEntries = await this._relationWriter.writeRelation(entry, txn, log);
if (updatedRelationTargetEntries) {
updatedEntries.push(...updatedRelationTargetEntries);
}
// update state events after writing event, so for a member event,
// we only update the member info after having written the member event

View file

@ -0,0 +1,51 @@
/*
Copyright 2021 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 {REDACTION_TYPE} from "../common.js";
export const REACTION_TYPE = "m.reaction";
export const ANNOTATION_RELATION_TYPE = "m.annotation";
export function createAnnotation(targetId, key) {
return {
"m.relates_to": {
"event_id": targetId,
key,
"rel_type": ANNOTATION_RELATION_TYPE
}
};
}
export function getRelatedEventId(event) {
if (event.type === REDACTION_TYPE) {
return event.redacts;
} else {
const relation = getRelation(event);
if (relation) {
return relation.event_id;
}
}
return null;
}
export function getRelationFromContent(content) {
return content?.["m.relates_to"];
}
export function getRelation(event) {
return getRelationFromContent(event.content);
}

View file

@ -22,6 +22,7 @@ export const STORE_NAMES = Object.freeze([
"invites",
"roomMembers",
"timelineEvents",
"timelineRelations",
"timelineFragments",
"pendingEvents",
"userIdentities",

View file

@ -21,6 +21,7 @@ import {SessionStore} from "./stores/SessionStore.js";
import {RoomSummaryStore} from "./stores/RoomSummaryStore.js";
import {InviteStore} from "./stores/InviteStore.js";
import {TimelineEventStore} from "./stores/TimelineEventStore.js";
import {TimelineRelationStore} from "./stores/TimelineRelationStore.js";
import {RoomStateStore} from "./stores/RoomStateStore.js";
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
@ -82,6 +83,10 @@ export class Transaction {
return this._store("timelineEvents", idbStore => new TimelineEventStore(idbStore));
}
get timelineRelations() {
return this._store("timelineRelations", idbStore => new TimelineRelationStore(idbStore));
}
get roomState() {
return this._store("roomState", idbStore => new RoomStateStore(idbStore));
}

View file

@ -16,6 +16,7 @@ export const schema = [
createInviteStore,
createArchivedRoomSummaryStore,
migrateOperationScopeIndex,
createTimelineRelationsStore,
];
// TODO: how to deal with git merge conflicts of this array?
@ -136,3 +137,8 @@ async function migrateOperationScopeIndex(db, txn) {
console.error("could not migrate operations", err.stack);
}
}
//v10
function createTimelineRelationsStore(db) {
db.createObjectStore("timelineRelations", {keyPath: "key"});
}

View file

@ -26,10 +26,6 @@ export class RoomStateStore {
this._roomStateStore = idbStore;
}
getAllForType(roomId, type) {
throw new Error("unimplemented");
}
get(roomId, type, stateKey) {
const key = encodeKey(roomId, type, stateKey);
return this._roomStateStore.get(key);

View file

@ -0,0 +1,75 @@
/*
Copyright 2021 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 {MIN_UNICODE, MAX_UNICODE} from "./common.js";
function encodeKey(roomId, targetEventId, relType, sourceEventId) {
return `${roomId}|${targetEventId}|${relType}|${sourceEventId}`;
}
function decodeKey(key) {
const [roomId, targetEventId, relType, sourceEventId] = key.split("|");
return {roomId, targetEventId, relType, sourceEventId};
}
export class TimelineRelationStore {
constructor(store) {
this._store = store;
}
add(roomId, targetEventId, relType, sourceEventId) {
return this._store.add({key: encodeKey(roomId, targetEventId, relType, sourceEventId)});
}
remove(roomId, targetEventId, relType, sourceEventId) {
return this._store.delete(encodeKey(roomId, targetEventId, relType, sourceEventId));
}
removeAllForTarget(roomId, targetId) {
const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
true,
true
);
return this._store.delete(range);
}
async getForTargetAndType(roomId, targetId, relType) {
// exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max
const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, relType, MIN_UNICODE),
encodeKey(roomId, targetId, relType, MAX_UNICODE),
true,
true
);
const items = await this._store.selectAll(range);
return items.map(i => decodeKey(i.key));
}
async getAllForTarget(roomId, targetId) {
// exclude both keys as they are theoretical min and max,
// but we should't have a match for just the room id, or room id with max
const range = this._store.IDBKeyRange.bound(
encodeKey(roomId, targetId, MIN_UNICODE, MIN_UNICODE),
encodeKey(roomId, targetId, MAX_UNICODE, MAX_UNICODE),
true,
true
);
const items = await this._store.selectAll(range);
return items.map(i => decodeKey(i.key));
}
}

61
src/mocks/ListObserver.js Normal file
View file

@ -0,0 +1,61 @@
/*
Copyright 2021 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 class ListObserver {
constructor() {
this._queue = [];
this._backlog = [];
}
next() {
if (this._backlog.length) {
return Promise.resolve(this._backlog.shift());
} else {
return new Promise(resolve => {
this._queue.push(resolve);
});
}
}
_fullfillNext(value) {
if (this._queue.length) {
const resolve = this._queue.shift();
resolve(value);
} else {
this._backlog.push(value);
}
}
onReset() {
this._fullfillNext({type: "reset"});
}
onAdd(index, value) {
this._fullfillNext({type: "add", index, value});
}
onUpdate(index, value, params) {
this._fullfillNext({type: "update", index, value, params});
}
onRemove(index, value) {
this._fullfillNext({type: "remove", index, value});
}
onMove(fromIdx, toIdx, value) {
this._fullfillNext({type: "move", fromIdx, toIdx, value});
}
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export function createEvent(type, id = null) {
return {type, event_id: id};
export function createEvent(type, id = null, sender = null) {
return {type, event_id: id, sender};
}
export function withContent(content, event) {
@ -33,3 +33,7 @@ export function withTextBody(body, event) {
export function withTxnId(txnId, event) {
return Object.assign({}, event, {unsigned: {transaction_id: txnId}});
}
export function withRedacts(redacts, reason, event) {
return Object.assign({redacts, content: {reason}}, event);
}

View file

@ -15,7 +15,6 @@ limitations under the License.
*/
export async function poll(fn) {
let result;
do {
const result = fn();
if (result) {

View file

@ -23,6 +23,7 @@ import {BaseObservableMap} from "./map/BaseObservableMap.js";
export { ObservableArray } from "./list/ObservableArray.js";
export { SortedArray } from "./list/SortedArray.js";
export { MappedList } from "./list/MappedList.js";
export { AsyncMappedList } from "./list/AsyncMappedList.js";
export { ConcatList } from "./list/ConcatList.js";
export { ObservableMap } from "./map/ObservableMap.js";

View file

@ -0,0 +1,197 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 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 {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js";
export class AsyncMappedList extends BaseMappedList {
constructor(sourceList, mapper, updater, removeCallback) {
super(sourceList, mapper, updater, removeCallback);
this._eventQueue = null;
}
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._eventQueue = [];
this._mappedValues = [];
let idx = 0;
for (const item of this._sourceList) {
this._eventQueue.push(new AddEvent(idx, item));
idx += 1;
}
this._flush();
}
async _flush() {
if (this._flushing) {
return;
}
this._flushing = true;
try {
while (this._eventQueue.length) {
const event = this._eventQueue.shift();
await event.run(this);
}
} finally {
this._flushing = false;
}
}
onReset() {
if (this._eventQueue) {
this._eventQueue.push(new ResetEvent());
this._flush();
}
}
onAdd(index, value) {
if (this._eventQueue) {
this._eventQueue.push(new AddEvent(index, value));
this._flush();
}
}
onUpdate(index, value, params) {
if (this._eventQueue) {
this._eventQueue.push(new UpdateEvent(index, value, params));
this._flush();
}
}
onRemove(index) {
if (this._eventQueue) {
this._eventQueue.push(new RemoveEvent(index));
this._flush();
}
}
onMove(fromIdx, toIdx) {
if (this._eventQueue) {
this._eventQueue.push(new MoveEvent(fromIdx, toIdx));
this._flush();
}
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
this._eventQueue = null;
this._mappedValues = null;
}
}
class AddEvent {
constructor(index, value) {
this.index = index;
this.value = value;
}
async run(list) {
const mappedValue = await list._mapper(this.value);
runAdd(list, this.index, mappedValue);
}
}
class UpdateEvent {
constructor(index, value, params) {
this.index = index;
this.value = value;
this.params = params;
}
async run(list) {
runUpdate(list, this.index, this.value, this.params);
}
}
class RemoveEvent {
constructor(index) {
this.index = index;
}
async run(list) {
runRemove(list, this.index);
}
}
class MoveEvent {
constructor(fromIdx, toIdx) {
this.fromIdx = fromIdx;
this.toIdx = toIdx;
}
async run(list) {
runMove(list, this.fromIdx, this.toIdx);
}
}
class ResetEvent {
async run(list) {
runReset(list);
}
}
import {ObservableArray} from "./ObservableArray.js";
import {ListObserver} from "../../mocks/ListObserver.js";
export function tests() {
return {
"events are emitted in order": async assert => {
const double = n => n * n;
const source = new ObservableArray();
const mapper = new AsyncMappedList(source, async n => {
await new Promise(r => setTimeout(r, n));
return {n: double(n)};
}, (o, params, n) => {
o.n = double(n);
});
const observer = new ListObserver();
mapper.subscribe(observer);
source.append(2); // will sleep this amount, so second append would take less time
source.append(1);
source.update(0, 7, "lucky seven")
source.remove(0);
{
const {type, index, value} = await observer.next();
assert.equal(mapper.length, 1);
assert.equal(type, "add");
assert.equal(index, 0);
assert.equal(value.n, 4);
}
{
const {type, index, value} = await observer.next();
assert.equal(mapper.length, 2);
assert.equal(type, "add");
assert.equal(index, 1);
assert.equal(value.n, 1);
}
{
const {type, index, value, params} = await observer.next();
assert.equal(mapper.length, 2);
assert.equal(type, "update");
assert.equal(index, 0);
assert.equal(value.n, 49);
assert.equal(params, "lucky seven");
}
{
const {type, index, value} = await observer.next();
assert.equal(mapper.length, 1);
assert.equal(type, "remove");
assert.equal(index, 0);
assert.equal(value.n, 49);
}
}
}
}

View file

@ -0,0 +1,77 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2021 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 {BaseObservableList} from "./BaseObservableList.js";
import {findAndUpdateInArray} from "./common.js";
export class BaseMappedList extends BaseObservableList {
constructor(sourceList, mapper, updater, removeCallback) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._removeCallback = removeCallback;
this._mappedValues = null;
this._sourceUnsubscribe = null;
}
findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._mappedValues, this, updater);
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
export function runAdd(list, index, mappedValue) {
list._mappedValues.splice(index, 0, mappedValue);
list.emitAdd(index, mappedValue);
}
export function runUpdate(list, index, value, params) {
const mappedValue = list._mappedValues[index];
if (list._updater) {
list._updater(mappedValue, params, value);
}
list.emitUpdate(index, mappedValue, params);
}
export function runRemove(list, index) {
const mappedValue = list._mappedValues[index];
list._mappedValues.splice(index, 1);
if (list._removeCallback) {
list._removeCallback(mappedValue);
}
list.emitRemove(index, mappedValue);
}
export function runMove(list, fromIdx, toIdx) {
const mappedValue = list._mappedValues[fromIdx];
list._mappedValues.splice(fromIdx, 1);
list._mappedValues.splice(toIdx, 0, mappedValue);
list.emitMove(fromIdx, toIdx, mappedValue);
}
export function runReset(list) {
list._mappedValues = [];
list.emitReset();
}

View file

@ -15,20 +15,9 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {BaseObservableList} from "./BaseObservableList.js";
import {findAndUpdateInArray} from "./common.js";
export class MappedList extends BaseObservableList {
constructor(sourceList, mapper, updater, removeCallback) {
super();
this._sourceList = sourceList;
this._mapper = mapper;
this._updater = updater;
this._removeCallback = removeCallback;
this._sourceUnsubscribe = null;
this._mappedValues = null;
}
import {BaseMappedList, runAdd, runUpdate, runRemove, runMove, runReset} from "./BaseMappedList.js";
export class MappedList extends BaseMappedList {
onSubscribeFirst() {
this._sourceUnsubscribe = this._sourceList.subscribe(this);
this._mappedValues = [];
@ -38,14 +27,12 @@ export class MappedList extends BaseObservableList {
}
onReset() {
this._mappedValues = [];
this.emitReset();
runReset(this);
}
onAdd(index, value) {
const mappedValue = this._mapper(value);
this._mappedValues.splice(index, 0, mappedValue);
this.emitAdd(index, mappedValue);
runAdd(this, index, mappedValue);
}
onUpdate(index, value, params) {
@ -53,47 +40,24 @@ export class MappedList extends BaseObservableList {
if (!this._mappedValues) {
return;
}
const mappedValue = this._mappedValues[index];
if (this._updater) {
this._updater(mappedValue, params, value);
}
this.emitUpdate(index, mappedValue, params);
runUpdate(this, index, value, params);
}
onRemove(index) {
const mappedValue = this._mappedValues[index];
this._mappedValues.splice(index, 1);
if (this._removeCallback) {
this._removeCallback(mappedValue);
}
this.emitRemove(index, mappedValue);
runRemove(this, index);
}
onMove(fromIdx, toIdx) {
const mappedValue = this._mappedValues[fromIdx];
this._mappedValues.splice(fromIdx, 1);
this._mappedValues.splice(toIdx, 0, mappedValue);
this.emitMove(fromIdx, toIdx, mappedValue);
runMove(this, fromIdx, toIdx);
}
onUnsubscribeLast() {
this._sourceUnsubscribe();
}
findAndUpdate(predicate, updater) {
return findAndUpdateInArray(predicate, this._mappedValues, this, updater);
}
get length() {
return this._mappedValues.length;
}
[Symbol.iterator]() {
return this._mappedValues.values();
}
}
import {ObservableArray} from "./ObservableArray.js";
import {BaseObservableList} from "./BaseObservableList.js";
export async function tests() {
class MockList extends BaseObservableList {

View file

@ -44,6 +44,13 @@ export class ObservableArray extends BaseObservableList {
this.emitAdd(idx, item);
}
update(idx, item, params = null) {
if (idx < this._items.length) {
this._items[idx] = item;
this.emitUpdate(idx, item, params);
}
}
get array() {
return this._items;
}

View file

@ -46,6 +46,16 @@ export class SortedArray extends BaseObservableList {
return findAndUpdateInArray(predicate, this._items, this, updater);
}
getAndUpdate(item, updater, updateParams = null) {
const idx = this.indexOf(item);
if (idx !== -1) {
const existingItem = this._items[idx];
const newItem = updater(existingItem, item);
this._items[idx] = newItem;
this.emitUpdate(idx, newItem, updateParams);
}
}
update(item, updateParams = null) {
const idx = this.indexOf(item);
if (idx !== -1) {

View file

@ -74,6 +74,10 @@ export class ObservableMap extends BaseObservableMap {
values() {
return this._values.values();
}
keys() {
return this._values.keys();
}
}
export function tests() {

View file

@ -36,6 +36,7 @@ import {BlobHandle} from "./dom/BlobHandle.js";
import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandle.js";
import {downloadInIframe} from "./dom/download.js";
import {Disposables} from "../../utils/Disposables.js";
import {handleAvatarError} from "./ui/avatar.js";
function addScript(src) {
return new Promise(function (resolve, reject) {
@ -189,6 +190,8 @@ export class Platform {
this._disposables.track(disposable);
}
}
this._container.addEventListener("error", handleAvatarError, true);
this._disposables.track(() => this._container.removeEventListener("error", handleAvatarError, true));
window.__hydrogenViewModel = vm;
const view = new RootView(vm);
this._container.appendChild(view.mount());

View file

@ -0,0 +1,86 @@
import {BaseUpdateView} from "./general/BaseUpdateView.js";
import {renderStaticAvatar, renderImg} from "./avatar.js";
import {text} from "./general/html.js";
/*
optimization to not use a sub view when changing between img and text
because there can be many many instances of this view
*/
export class AvatarView extends BaseUpdateView {
/**
* @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter}
* @param {Number} size
*/
constructor(value, size) {
super(value);
this._root = null;
this._avatarUrl = null;
this._avatarTitle = null;
this._avatarLetter = null;
this._size = size;
}
_avatarUrlChanged() {
if (this.value.avatarUrl(this._size) !== this._avatarUrl) {
this._avatarUrl = this.value.avatarUrl(this._size);
return true;
}
return false;
}
_avatarTitleChanged() {
if (this.value.avatarTitle !== this._avatarTitle) {
this._avatarTitle = this.value.avatarTitle;
return true;
}
return false;
}
_avatarLetterChanged() {
if (this.value.avatarLetter !== this._avatarLetter) {
this._avatarLetter = this.value.avatarLetter;
return true;
}
return false;
}
mount(options) {
this._avatarUrlChanged();
this._avatarLetterChanged();
this._avatarTitleChanged();
this._root = renderStaticAvatar(this.value, this._size);
// takes care of update being called when needed
super.mount(options);
return this._root;
}
root() {
return this._root;
}
update(vm) {
// important to always call _...changed for every prop
if (this._avatarUrlChanged()) {
// avatarColorNumber won't change, it's based on room/user id
const bgColorClass = `usercolor${vm.avatarColorNumber}`;
if (vm.avatarUrl(this._size)) {
this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild);
this._root.classList.remove(bgColorClass);
} else {
this._root.textContent = vm.avatarLetter;
this._root.classList.add(bgColorClass);
}
}
const hasAvatar = !!vm.avatarUrl(this._size);
if (this._avatarTitleChanged() && hasAvatar) {
const element = this._root.firstChild;
if (element.tagName === "IMG") {
element.setAttribute("title", vm.avatarTitle);
}
}
if (this._avatarLetterChanged() && !hasAvatar) {
this._root.textContent = vm.avatarLetter;
}
}
}

View file

@ -14,90 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {tag, text, classNames} from "./general/html.js";
import {BaseUpdateView} from "./general/BaseUpdateView.js";
/*
optimization to not use a sub view when changing between img and text
because there can be many many instances of this view
*/
export class AvatarView extends BaseUpdateView {
/**
* @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter}
* @param {Number} size
*/
constructor(value, size) {
super(value);
this._root = null;
this._avatarUrl = null;
this._avatarTitle = null;
this._avatarLetter = null;
this._size = size;
}
_avatarUrlChanged() {
if (this.value.avatarUrl(this._size) !== this._avatarUrl) {
this._avatarUrl = this.value.avatarUrl(this._size);
return true;
}
return false;
}
_avatarTitleChanged() {
if (this.value.avatarTitle !== this._avatarTitle) {
this._avatarTitle = this.value.avatarTitle;
return true;
}
return false;
}
_avatarLetterChanged() {
if (this.value.avatarLetter !== this._avatarLetter) {
this._avatarLetter = this.value.avatarLetter;
return true;
}
return false;
}
mount(options) {
this._avatarUrlChanged();
this._avatarLetterChanged();
this._avatarTitleChanged();
this._root = renderStaticAvatar(this.value, this._size);
// takes care of update being called when needed
super.mount(options);
return this._root;
}
root() {
return this._root;
}
update(vm) {
// important to always call _...changed for every prop
if (this._avatarUrlChanged()) {
// avatarColorNumber won't change, it's based on room/user id
const bgColorClass = `usercolor${vm.avatarColorNumber}`;
if (vm.avatarUrl(this._size)) {
this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild);
this._root.classList.remove(bgColorClass);
} else {
this._root.replaceChild(text(vm.avatarLetter), this._root.firstChild);
this._root.classList.add(bgColorClass);
}
}
const hasAvatar = !!vm.avatarUrl(this._size);
if (this._avatarTitleChanged() && hasAvatar) {
const img = this._root.firstChild;
img.setAttribute("title", vm.avatarTitle);
}
if (this._avatarLetterChanged() && !hasAvatar) {
this._root.firstChild.textContent = vm.avatarLetter;
}
}
}
import {tag, text, classNames, setAttribute} from "./general/html.js";
/**
* @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter}
* @param {Number} size
@ -108,16 +25,36 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) {
let avatarClasses = classNames({
avatar: true,
[`size-${size}`]: true,
[`usercolor${vm.avatarColorNumber}`]: !hasAvatar,
[`usercolor${vm.avatarColorNumber}`]: !hasAvatar
});
if (extraClasses) {
avatarClasses += ` ${extraClasses}`;
}
const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter);
return tag.div({className: avatarClasses}, [avatarContent]);
const avatar = tag.div({className: avatarClasses}, [avatarContent]);
if (hasAvatar) {
setAttribute(avatar, "data-avatar-letter", vm.avatarLetter);
setAttribute(avatar, "data-avatar-color", vm.avatarColorNumber);
}
return avatar;
}
function renderImg(vm, size) {
export function renderImg(vm, size) {
const sizeStr = size.toString();
return tag.img({src: vm.avatarUrl(size), width: sizeStr, height: sizeStr, title: vm.avatarTitle});
}
function isAvatarEvent(e) {
const element = e.target;
const parent = element.parentElement;
return element.tagName === "IMG" && parent.classList.contains("avatar");
}
export function handleAvatarError(e) {
if (!isAvatarEvent(e)) { return; }
const parent = e.target.parentElement;
const avatarColorNumber = parent.getAttribute("data-avatar-color");
parent.classList.add(`usercolor${avatarColorNumber}`);
const avatarLetter = parent.getAttribute("data-avatar-letter");
parent.textContent = avatarLetter;
}

View file

@ -14,10 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
const container = document.querySelector(".hydrogen");
let container;
export function spinner(t, extraClasses = undefined) {
if (container.classList.contains("legacy")) {
if (container === undefined) {
container = document.querySelector(".hydrogen");
}
if (container?.classList.contains("legacy")) {
return t.div({className: "spinner"}, [
t.div(),
t.div(),

View file

@ -53,6 +53,14 @@ limitations under the License.
font-size: calc(var(--avatar-size) * 0.6);
}
.hydrogen .avatar.size-52 {
--avatar-size: 52px;
width: var(--avatar-size);
height: var(--avatar-size);
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) * 0.6);
}
.hydrogen .avatar.size-30 {
--avatar-size: 30px;
width: var(--avatar-size);

View file

@ -6,6 +6,8 @@
local('Segoe UI Emoji'),
local('Segoe UI Symbol'),
local('Noto Color Emoji'),
local('Twemoji'),
local('Twemoji Mozilla'),
local('Android Emoji'),
local('EmojiSymbols'),
local('Symbola');

View file

@ -54,6 +54,13 @@ main {
min-width: 0;
}
.right-shown{
grid-template:
"status status status" auto
"left middle right" 1fr /
300px 1fr 300px;
}
/* resize and reposition session view to account for mobile Safari which shifts
the layout viewport up without resizing it when the keyboard shows */
.hydrogen.ios .SessionView {
@ -65,7 +72,7 @@ the layout viewport up without resizing it when the keyboard shows */
.middle .close-middle { display: none; }
/* mobile layout */
@media screen and (max-width: 800px) {
.SessionView:not(.middle-shown) {
.SessionView:not(.middle-shown):not(.right-shown) {
grid-template:
"status" auto
"left" 1fr /
@ -79,8 +86,16 @@ the layout viewport up without resizing it when the keyboard shows */
1fr;
}
.SessionView:not(.middle-shown) .room-placeholder { display: none; }
.SessionView.right-shown{
grid-template:
"status" auto
"right" 1fr /
1fr;
}
.SessionView:not(.middle-shown):not(.right-shown) .room-placeholder { display: none; }
.SessionView.middle-shown .LeftPanel { display: none; }
.SessionView.right-shown .middle, .SessionView.right-shown .LeftPanel { display: none; }
/* show back button */
.middle .close-middle { display: block !important; }
@ -179,6 +194,11 @@ the layout viewport up without resizing it when the keyboard shows */
z-index: 2;
}
.menu .menu-item{
box-sizing: border-box;
width: 100%;
}
.Settings {
display: flex;
flex-direction: column;

View file

@ -18,6 +18,7 @@ limitations under the License.
@import url('layout.css');
@import url('login.css');
@import url('left-panel.css');
@import url('right-panel.css');
@import url('room.css');
@import url('timeline.css');
@import url('avatar.css');

View file

@ -0,0 +1,31 @@
.RoomDetailsView {
grid-area: right;
flex-direction: column;
}
.RoomDetailsView_avatar {
display: flex;
}
.RoomDetailsView_name h2 {
text-align: center;
}
.RoomDetailsView_row {
justify-content: space-between;
}
.RoomDetailsView_label, .RoomDetailsView_row, .RoomDetailsView, .EncryptionIconView {
display: flex;
align-items: center;
}
.EncryptionIconView {
justify-content: center;
}
.RoomDetailsView_buttons {
display: flex;
justify-content: flex-end;
width: 100%;
}

View file

@ -0,0 +1,5 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.38 12.27C15.76 11.42 16 10.43 16 9.27V3.05L8.99997 1L5.21997 2.11L15.38 12.27Z" fill="white"/>
<path d="M2.21 2.98999L2 3.04999V9.26999C2 15.63 9 17 9 17C9 17 11.71 16.47 13.76 14.53L2.21 2.98999Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.46967 0.46967C0.762563 0.176777 1.23744 0.176777 1.53033 0.46967L16.7203 15.6597C17.0132 15.9526 17.0132 16.4274 16.7203 16.7203C16.4274 17.0132 15.9526 17.0132 15.6597 16.7203L0.46967 1.53033C0.176777 1.23744 0.176777 0.762563 0.46967 0.46967Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 642 B

View file

@ -0,0 +1,3 @@
<svg viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 9.27V3.05L9 1L16 3.05V9.27C16 15.63 9 17 9 17C9 17 2 15.63 2 9.27Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 179 B

View file

@ -0,0 +1,3 @@
<svg width="25" height="24" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 19C9 19 18 15.2 18 9.50002V2.85001L9 1.52588e-05L0 2.85001L0 9.50002C0 15.2 9 19 9 19Z" fill="#C1C6CD"/>
</svg>

After

Width:  |  Height:  |  Size: 260 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 10C12.8284 10 13.5 9.32843 13.5 8.5C13.5 7.67157 12.8284 7 12 7C11.1716 7 10.5 7.67157 10.5 8.5C10.5 9.32843 11.1716 10 12 10ZM11 13C10.4477 13 10 12.5523 10 12C10 11.4477 10.4477 11 11 11H12C12.5523 11 13 11.4477 13 12V15.5H13.5C14.0523 15.5 14.5 15.9477 14.5 16.5C14.5 17.0523 14.0523 17.5 13.5 17.5H12C11.4477 17.5 11 17.0523 11 16.5L11 13Z" fill="#8d99a5"/>
</svg>

After

Width:  |  Height:  |  Size: 518 B

View file

@ -0,0 +1,7 @@
<svg width="25" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="path-1-inside-1" fill="white">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1502 21.1214C16.3946 22.3074 14.2782 23 12 23C9.52367 23 7.23845 22.1817 5.4 20.8008C2.72821 18.794 1 15.5988 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 15.797 21.0762 19.1446 18.1502 21.1214ZM12 12.55C13.8225 12.55 15.3 10.9494 15.3 8.975C15.3 7.00058 13.8225 5.4 12 5.4C10.1775 5.4 8.7 7.00058 8.7 8.975C8.7 10.9494 10.1775 12.55 12 12.55ZM12 20.8C14.3782 20.8 16.536 19.8566 18.1197 18.3237C17.1403 15.9056 14.7693 14.2 12 14.2C9.23066 14.2 6.85969 15.9056 5.88028 18.3237C7.46399 19.8566 9.62183 20.8 12 20.8Z"/>
</mask>
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.1502 21.1214C16.3946 22.3074 14.2782 23 12 23C9.52367 23 7.23845 22.1817 5.4 20.8008C2.72821 18.794 1 15.5988 1 12C1 5.92487 5.92487 1 12 1C18.0751 1 23 5.92487 23 12C23 15.797 21.0762 19.1446 18.1502 21.1214ZM12 12.55C13.8225 12.55 15.3 10.9494 15.3 8.975C15.3 7.00058 13.8225 5.4 12 5.4C10.1775 5.4 8.7 7.00058 8.7 8.975C8.7 10.9494 10.1775 12.55 12 12.55ZM12 20.8C14.3782 20.8 16.536 19.8566 18.1197 18.3237C17.1403 15.9056 14.7693 14.2 12 14.2C9.23066 14.2 6.85969 15.9056 5.88028 18.3237C7.46399 19.8566 9.62183 20.8 12 20.8Z" fill="#C1C6CD"/>
<path d="M18.1502 21.1214L18.9339 22.2814L18.1502 21.1214ZM5.4 20.8008L4.55919 21.9202H4.55919L5.4 20.8008ZM18.1197 18.3237L19.0934 19.3296L19.7717 18.6731L19.4173 17.7981L18.1197 18.3237ZM5.88028 18.3237L4.58268 17.7981L4.22829 18.6731L4.90659 19.3296L5.88028 18.3237ZM12 24.4C14.5662 24.4 16.9541 23.619 18.9339 22.2814L17.3665 19.9613C15.835 20.9959 13.9902 21.6 12 21.6V24.4ZM4.55919 21.9202C6.63176 23.477 9.21011 24.4 12 24.4V21.6C9.83723 21.6 7.84514 20.8865 6.24081 19.6814L4.55919 21.9202ZM-0.399998 12C-0.399998 16.0577 1.55052 19.6603 4.55919 21.9202L6.24081 19.6814C3.90591 17.9276 2.4 15.1399 2.4 12H-0.399998ZM12 -0.399998C5.15167 -0.399998 -0.399998 5.15167 -0.399998 12H2.4C2.4 6.69807 6.69807 2.4 12 2.4V-0.399998ZM24.4 12C24.4 5.15167 18.8483 -0.399998 12 -0.399998V2.4C17.3019 2.4 21.6 6.69807 21.6 12H24.4ZM18.9339 22.2814C22.2288 20.0554 24.4 16.2815 24.4 12H21.6C21.6 15.3124 19.9236 18.2337 17.3665 19.9613L18.9339 22.2814ZM13.9 8.975C13.9 10.2838 12.9459 11.15 12 11.15V13.95C14.6991 13.95 16.7 11.615 16.7 8.975H13.9ZM12 6.8C12.9459 6.8 13.9 7.66616 13.9 8.975H16.7C16.7 6.335 14.6991 4 12 4V6.8ZM10.1 8.975C10.1 7.66616 11.0541 6.8 12 6.8V4C9.30086 4 7.3 6.335 7.3 8.975H10.1ZM12 11.15C11.0541 11.15 10.1 10.2838 10.1 8.975H7.3C7.3 11.615 9.30086 13.95 12 13.95V11.15ZM17.146 17.3178C15.8129 18.6081 14.0004 19.4 12 19.4V22.2C14.756 22.2 17.2591 21.1051 19.0934 19.3296L17.146 17.3178ZM12 15.6C14.1797 15.6 16.0494 16.9415 16.8221 18.8493L19.4173 17.7981C18.2312 14.8697 15.359 12.8 12 12.8V15.6ZM7.17788 18.8493C7.95058 16.9415 9.8203 15.6 12 15.6V12.8C8.64102 12.8 5.7688 14.8697 4.58268 17.7981L7.17788 18.8493ZM12 19.4C9.99963 19.4 8.18709 18.6081 6.85397 17.3178L4.90659 19.3296C6.74088 21.1051 9.24402 22.2 12 22.2V19.4Z" fill="#C1C6CD" mask="url(#path-1-inside-1)"/>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -22,7 +22,6 @@ limitations under the License.
font-size: 10px;
}
.hydrogen {
font-family: 'Inter', sans-serif, 'emoji';
background-color: white;
@ -332,7 +331,6 @@ a {
align-items: center;
}
.SessionStatusView button.link {
color: currentcolor;
text-align: left;
@ -456,6 +454,10 @@ a {
background-image: url("./icons/vertical-ellipsis.svg");
}
.RoomHeader .room-info {
background-image: url("./icons/info.svg");
}
.RoomView_error {
color: red;
}
@ -660,20 +662,35 @@ button.link {
margin: 0;
}
.menu li{
margin-bottom: 10px;
}
.menu button {
border-radius: 4px;
display: block;
border: none;
width: 100%;
background-color: transparent;
text-align: left;
padding: 8px 32px 8px 8px;
font-size: 1.5rem;
height: 24px;
cursor: pointer;
}
.menu .destructive button {
color: #FF4B55;
}
.menu .quick-reactions {
display: flex;
padding: 8px 32px 8px 8px;
}
.menu .quick-reactions button {
padding: 2px 4px;
text-align: center;
}
.InviteView_body {
display: flex;
justify-content: space-around;
@ -769,3 +786,82 @@ button.link {
max-width: 200px;
width: 100%;
}
/* Right Panel */
.RoomDetailsView {
background: rgba(245, 245, 245, 0.90);
padding: 16px;
}
.RoomDetailsView_id {
color: #737D8C;
font-size: 12px;
}
.RoomDetailsView_rows{
margin-top: 36px;
width: 100%;
}
.RoomDetailsView_name h2 {
margin-bottom: 4px;
font-size: 1.8rem;
}
.RoomDetailsView_row {
margin-bottom: 20px;
font-weight: 500;
font-size: 15px;
}
.RoomDetailsView_label::before {
padding-right: 16px;
height: 24px;
width: 20px;
}
.RoomDetailsView_value {
color: #737D8C;
}
.MemberCount::before {
content: url("./icons/room-members.svg");
}
.EncryptionStatus::before {
content: url("./icons/encryption-status.svg");
}
/* Encryption icon next to avatar */
.EncryptionIconView {
width: 52px;
height: 52px;
border-radius: 100%;
background: #737D8C;
border: 3px solid #F2F5F8;
margin-left: -16px;
}
.EncryptionIconView_encrypted, .EncryptionIconView_unencrypted {
height: 24px;
width: 24px;
}
.EncryptionIconView_encrypted {
content: url("./icons/e2ee-normal.svg");
}
.EncryptionIconView_unencrypted {
content: url("./icons/e2ee-disabled.svg");
}
.RoomDetailsView .button-utility {
width: 24px;
height: 24px;
}
.RoomDetailsView .close {
background-image: url("./icons/clear.svg");
}

View file

@ -20,7 +20,8 @@ limitations under the License.
grid-template:
"avatar sender" auto
"avatar body" auto
"time body" 1fr /
"time body" 1fr
"time reactions" auto /
30px 1fr;
column-gap: 8px;
padding: 4px;
@ -39,7 +40,8 @@ limitations under the License.
grid-template:
"avatar sender" auto
"body body" 1fr
"time time" auto /
"time time" auto
"reactions reactions" auto /
30px 1fr;
}
@ -57,6 +59,7 @@ limitations under the License.
.Timeline_message:hover > .Timeline_messageOptions,
.Timeline_message.menuOpen > .Timeline_messageOptions {
display: block;
user-select: none;
}
.Timeline_messageAvatar {
@ -104,6 +107,7 @@ limitations under the License.
.Timeline_messageBody time {
padding: 2px 0 0px 10px;
user-select: none;
}
.Timeline_messageBody time, .Timeline_messageTime {
@ -133,6 +137,9 @@ limitations under the License.
.hydrogen .Timeline_messageSender.usercolor7 { color: var(--usercolor7); }
.hydrogen .Timeline_messageSender.usercolor8 { color: var(--usercolor8); }
.Timeline_messageBody a {
word-break: break-all;
}
.Timeline_messageBody .media {
display: grid;
@ -211,6 +218,42 @@ only loads when the top comes into view*/
color: #ff4b55;
}
.Timeline_messageReactions {
grid-area: reactions;
margin-top: 6px;
}
.Timeline_messageReactions button {
display: inline-flex;
line-height: 2.0rem;
margin-right: 6px;
padding: 1px 6px;
border: 1px solid #e9edf1;
border-radius: 10px;
background-color: #f3f8fd;
cursor: pointer;
user-select: none;
vertical-align: middle;
}
.Timeline_messageReactions button.active {
background-color: #e9fff9;
border-color: #0DBD8B;
}
@keyframes glow-reaction-border {
0% { border-color: #e9edf1; }
100% { border-color: #0DBD8B; }
}
.Timeline_messageReactions button.active.pending {
animation-name: glow-reaction-border;
animation-duration: 0.5s;
animation-direction: alternate;
animation-iteration-count: infinite;
animation-timing-function: linear;
}
.AnnouncementView {
margin: 5px 0;
padding: 5px 10%;
@ -227,7 +270,3 @@ only loads when the top comes into view*/
.GapView > :not(:first-child) {
margin-left: 12px;
}
.Timeline_messageBody a {
word-break: break-all;
}

View file

@ -14,8 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {tag} from "./html.js";
import {errorToDOM} from "./error.js";
import {el} from "./html.js";
import {mountView} from "./utils.js";
function insertAt(parentNode, idx, childNode) {
const isLast = idx === parentNode.childElementCount;
@ -28,10 +28,11 @@ function insertAt(parentNode, idx, childNode) {
}
export class ListView {
constructor({list, onItemClick, className, parentProvidesUpdates = true}, childCreator) {
constructor({list, onItemClick, className, tagName = "ul", parentProvidesUpdates = true}, childCreator) {
this._onItemClick = onItemClick;
this._list = list;
this._className = className;
this._tagName = tagName;
this._root = null;
this._subscription = null;
this._childCreator = childCreator;
@ -62,7 +63,7 @@ export class ListView {
if (this._className) {
attr.className = this._className;
}
this._root = tag.ul(attr);
this._root = el(this._tagName, attr);
this.loadList();
if (this._onItemClick) {
this._root.addEventListener("click", this._onClick);
@ -107,12 +108,7 @@ export class ListView {
for (let item of this._list) {
const child = this._childCreator(item);
this._childInstances.push(child);
try {
const childDomNode = child.mount(this._mountArgs);
fragment.appendChild(childDomNode);
} catch (err) {
fragment.appendChild(errorToDOM(err));
}
fragment.appendChild(mountView(child, this._mountArgs));
}
this._root.appendChild(fragment);
}
@ -121,7 +117,7 @@ export class ListView {
this.onBeforeListChanged();
const child = this._childCreator(value);
this._childInstances.splice(idx, 0, child);
insertAt(this._root, idx, child.mount(this._mountArgs));
insertAt(this._root, idx, mountView(child, this._mountArgs));
this.onListChanged();
}

View file

@ -27,18 +27,7 @@ export class Menu extends TemplateView {
}
render(t) {
return t.ul({className: "menu", role: "menu"}, this._options.map(o => {
const className = {
destructive: o.destructive,
};
if (o.icon) {
className.icon = true;
className[o.icon] = true;
}
return t.li({
className,
}, t.button({onClick: o.callback}, o.label));
}));
return t.ul({className: "menu", role: "menu"}, this._options.map(o => o.toDOM(t)));
}
}
@ -59,4 +48,17 @@ class MenuOption {
this.destructive = true;
return this;
}
toDOM(t) {
const className = {
destructive: this.destructive,
};
if (this.icon) {
className.icon = true;
className[this.icon] = true;
}
return t.li({
className,
}, t.button({className:"menu-item", onClick: this.callback}, this.label));
}
}

View file

@ -1,94 +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.
*/
import {errorToDOM} from "./error.js";
export class SwitchView {
constructor(defaultView) {
this._childView = defaultView;
}
mount() {
return this._childView.mount();
}
unmount() {
return this._childView.unmount();
}
root() {
return this._childView.root();
}
update() {
return this._childView.update();
}
switch(newView) {
const oldRoot = this.root();
this._childView.unmount();
this._childView = newView;
let newRoot;
try {
newRoot = this._childView.mount();
} catch (err) {
newRoot = errorToDOM(err);
}
const parent = oldRoot.parentNode;
if (parent) {
parent.replaceChild(newRoot, oldRoot);
}
}
get childView() {
return this._childView;
}
}
/*
// SessionLoadView
// should this be the new switch view?
// and the other one be the BasicSwitchView?
new BoundSwitchView(vm, vm => vm.isLoading, (loading, vm) => {
if (loading) {
return new InlineTemplateView(vm, t => {
return t.div({className: "loading"}, [
t.span({className: "spinner"}),
t.span(vm => vm.loadingText)
]);
});
} else {
return new SessionView(vm.sessionViewModel);
}
});
*/
export class BoundSwitchView extends SwitchView {
constructor(value, mapper, viewCreator) {
super(viewCreator(mapper(value), value));
this._mapper = mapper;
this._viewCreator = viewCreator;
this._mappedValue = mapper(value);
}
update(value) {
const mappedValue = this._mapper(value);
if (mappedValue !== this._mappedValue) {
this._mappedValue = mappedValue;
this.switch(this._viewCreator(this._mappedValue, value));
} else {
super.update(value);
}
}
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js";
import {errorToDOM} from "./error.js";
import {mountView} from "./utils.js";
import {BaseUpdateView} from "./BaseUpdateView.js";
function objHasFns(obj) {
@ -282,17 +282,11 @@ class TemplateBuilder {
return node;
}
// this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template
// this inserts a view, and is not a view factory for `if`, so returns the root element to insert in the template
// you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree).
view(view, mountOptions = undefined) {
let root;
try {
root = view.mount(mountOptions);
} catch (err) {
return errorToDOM(err);
}
this._templateView.addSubView(view);
return root;
return mountView(view, mountOptions);
}
// map a value to a view, every time the value changes

View file

@ -0,0 +1,27 @@
/*
Copyright 2021 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 {errorToDOM} from "./error.js";
export function mountView(view, mountArgs = undefined) {
let node;
try {
node = view.mount(mountArgs);
} catch (err) {
node = errorToDOM(err);
}
return node;
}

View file

@ -25,13 +25,15 @@ import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js";
import {SettingsView} from "./settings/SettingsView.js";
import {RoomDetailsView} from "./rightpanel/RoomDetailsView.js";
export class SessionView extends TemplateView {
render(t, vm) {
return t.div({
className: {
"SessionView": true,
"middle-shown": vm => !!vm.activeMiddleViewModel
"middle-shown": vm => !!vm.activeMiddleViewModel,
"right-shown": vm => !!vm.roomDetailsViewModel
},
}, [
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
@ -53,6 +55,7 @@ export class SessionView extends TemplateView {
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
}
}),
t.mapView(vm => vm.roomDetailsViewModel, roomDetailsViewModel => roomDetailsViewModel ? new RoomDetailsView(roomDetailsViewModel) : null),
t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null)
]);
}

View file

@ -16,7 +16,7 @@ limitations under the License.
*/
import {TemplateView} from "../../general/TemplateView.js";
import {AvatarView} from "../../avatar.js";
import {AvatarView} from "../../AvatarView.js";
export class RoomTileView extends TemplateView {
render(t, vm) {

View file

@ -0,0 +1,51 @@
import {TemplateView} from "../../general/TemplateView.js";
import {classNames, tag} from "../../general/html.js";
import {AvatarView} from "../../AvatarView.js";
export class RoomDetailsView extends TemplateView {
render(t, vm) {
const encryptionString = () => vm.isEncrypted ? vm.i18n`On` : vm.i18n`Off`;
return t.div({className: "RoomDetailsView"}, [
this._createButton(t, vm),
t.div({className: "RoomDetailsView_avatar"},
[
t.view(new AvatarView(vm, 52)),
t.mapView(vm => vm.isEncrypted, isEncrypted => new EncryptionIconView(isEncrypted))
]),
t.div({className: "RoomDetailsView_name"}, [t.h2(vm => vm.name)]),
this._createRoomAliasDisplay(vm),
t.div({className: "RoomDetailsView_rows"},
[
this._createRightPanelRow(t, vm.i18n`People`, {MemberCount: true}, vm => vm.memberCount),
this._createRightPanelRow(t, vm.i18n`Encryption`, {EncryptionStatus: true}, encryptionString)
])
]);
}
_createRoomAliasDisplay(vm) {
return vm.canonicalAlias ? tag.div({className: "RoomDetailsView_id"}, [vm.canonicalAlias]) :
"";
}
_createRightPanelRow(t, label, labelClass, value) {
const labelClassString = classNames({RoomDetailsView_label: true, ...labelClass});
return t.div({className: "RoomDetailsView_row"}, [
t.div({className: labelClassString}, [label]),
t.div({className: "RoomDetailsView_value"}, value)
]);
}
_createButton(t, vm) {
return t.div({className: "RoomDetailsView_buttons"},
[
t.button({className: "close button-utility", onClick: () => vm.closePanel()})
]);
}
}
class EncryptionIconView extends TemplateView {
render(t, isEncrypted) {
return t.div({className: "EncryptionIconView"},
[t.div({className: isEncrypted ? "EncryptionIconView_encrypted" : "EncryptionIconView_unencrypted"})]);
}
}

View file

@ -22,7 +22,7 @@ import {TimelineList} from "./TimelineList.js";
import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js";
import {RoomArchivedView} from "./RoomArchivedView.js";
import {AvatarView} from "../../avatar.js";
import {AvatarView} from "../../AvatarView.js";
export class RoomView extends TemplateView {
constructor(options) {
@ -68,6 +68,7 @@ export class RoomView extends TemplateView {
} else {
const vm = this.value;
const options = [];
options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel()))
if (vm.canLeave) {
options.push(Menu.option(vm.i18n`Leave room`, () => vm.leaveRoom()).setDestructive());
}

View file

@ -74,6 +74,7 @@ export class TimelineList extends ListView {
}
}
catch (err) {
console.error(err);
//ignore error, as it is handled in the VM
}
finally {

View file

@ -17,9 +17,11 @@ limitations under the License.
import {renderStaticAvatar} from "../../../avatar.js";
import {tag} from "../../../general/html.js";
import {mountView} from "../../../general/utils.js";
import {TemplateView} from "../../../general/TemplateView.js";
import {Popup} from "../../../general/Popup.js";
import {Menu} from "../../../general/Menu.js";
import {ReactionsView} from "./ReactionsView.js";
export class BaseMessageView extends TemplateView {
constructor(value) {
@ -35,6 +37,7 @@ export class BaseMessageView extends TemplateView {
unverified: vm.isUnverified,
continuation: vm => vm.isContinuation,
}}, [
// dynamically added and removed nodes are handled below
this.renderMessageBody(t, vm),
// should be after body as it is overlayed on top
t.button({className: "Timeline_messageOptions"}, "⋯"),
@ -53,6 +56,21 @@ export class BaseMessageView extends TemplateView {
li.insertBefore(tag.div({className: `Timeline_messageSender usercolor${vm.avatarColorNumber}`}, vm.displayName), li.firstChild);
}
});
// similarly, we could do this with a simple ifView,
// but that adds a comment node to all messages without reactions
let reactionsView = null;
t.mapSideEffect(vm => vm.reactions, reactions => {
if (reactions && !reactionsView) {
reactionsView = new ReactionsView(vm.reactions);
this.addSubView(reactionsView);
li.appendChild(mountView(reactionsView));
} else if (!reactions && reactionsView) {
li.removeChild(reactionsView.root());
reactionsView.unmount();
this.removeSubView(reactionsView);
reactionsView = null;
}
});
return li;
}
@ -92,6 +110,9 @@ export class BaseMessageView extends TemplateView {
createMenuOptions(vm) {
const options = [];
if (vm.canReact && vm.shape !== "redacted") {
options.push(new QuickReactionsMenuOption(vm));
}
if (vm.canAbortSending) {
options.push(Menu.option(vm.i18n`Cancel`, () => vm.abortSending()));
} else if (vm.canRedact) {
@ -102,3 +123,21 @@ export class BaseMessageView extends TemplateView {
renderMessageBody() {}
}
class QuickReactionsMenuOption {
constructor(vm) {
this._vm = vm;
}
toDOM(t) {
const emojiButtons = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"].map(emoji => {
return t.button({onClick: () => this._vm.react(emoji)}, emoji);
});
const customButton = t.button({onClick: () => {
const key = prompt("Enter your reaction (emoji)");
if (key) {
this._vm.react(key);
}
}}, "…");
return t.li({className: "quick-reactions"}, [...emojiButtons, customButton]);
}
}

View file

@ -0,0 +1,45 @@
/*
Copyright 2021 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 {ListView} from "../../../general/ListView.js";
import {TemplateView} from "../../../general/TemplateView.js";
export class ReactionsView extends ListView {
constructor(reactionsViewModel) {
const options = {
className: "Timeline_messageReactions",
tagName: "div",
list: reactionsViewModel.reactions,
onItemClick: reactionView => reactionView.onClick(),
}
super(options, reactionVM => new ReactionView(reactionVM));
}
}
class ReactionView extends TemplateView {
render(t, vm) {
return t.button({
className: {
active: vm => vm.isActive,
pending: vm => vm.isPending
},
}, [vm.key, " ", vm => `${vm.count}`]);
}
onClick() {
this.value.toggle();
}
}

View file

@ -861,6 +861,10 @@
minimatch "^3.0.4"
strip-json-comments "^3.1.1"
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz":
version "3.2.3"
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz#cc332fdd25c08ef0e40f4d33fc3f822a0f98b6f4"
"@rollup/plugin-babel@^5.1.0":
version "5.2.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.1.tgz#20fc8f8864dc0eaa1c5578408459606808f72924"
@ -2069,10 +2073,6 @@ object.assign@^4.1.0:
has-symbols "^1.0.0"
object-keys "^1.0.11"
"olm@https://packages.matrix.org/npm/olm/olm-3.1.4.tgz":
version "3.1.4"
resolved "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz#0f03128b7d3b2f614d2216409a1dfccca765fdb3"
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"