Merge pull request #328 from vector-im/bwindels/invites

Invites
This commit is contained in:
Bruno Windels 2021-04-28 10:14:56 +02:00 committed by GitHub
commit fe9f0f7692
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 2128 additions and 277 deletions

View file

@ -3,4 +3,118 @@
- invite_state doesn't update over /sync
- can we reuse room summary? need to clear when joining
- rely on filter operator to split membership=join from membership=invite?
-
- invite_state comes once, and then not again
- only state (no heroes for example, but we do get the members)
- wants:
- different class to represent invited room, with accept or reject method?
- make it somewhat easy to render just joined rooms (rely on filter and still put them all in the same observable map)
- make the transition from invite to joined smooth
- reuse room summary logic?
InvitedRoom
isDM
isEncrypted
name
timestamp
accept()
reject()
JoiningRoom
to store intent of room you joined through directory, invite, or just /join roomid
also joining is retried when coming back online
forget()
Room
so, also taking into account that other types of room we might not want to expose through session.rooms will have invites,
perhaps it is best to expose invites through a different observable collection. You can always join/concat them to show in
the same list.
How do we handle a smooth UI transition when accepting an invite though?
For looking at the room itself:
- we would attach to the Invite event emitter, and we can have a property "joined" that we would update. Then you know you can go look for the room (or even allow to access the room through a property?)
- so this way the view model can know when to switch and signal the view
For the room list:
- the new Room will be added at exactly the same moment the Invite is removed,
so it should already be fairly smooth whether they are rendered in the same list or not.
How will we locate the Invite/Room during sync when we go from invite => join?
- have both adhere to sync target api (e.g. prepareSync, ...) and look in invite map
if room id is not found in room map in session.getroom.
- how do we remove the invite when join?
- we ca
Where to store?
- room summaries?
- do we have an interest in keeping the raw events?
- room versions will add another layer of indirection to the room summaries (or will it? once you've upgraded the room, we don't care too much anymore about the details of the old room? hmmm, we do care about whether it is encrypted or not... we need everything to be able to show the timeline in any case)
Invite => accept() => Room (ends up in session.rooms)
(.type) => Space (ends up in session.spaces)
Invite:
- isEncrypted
- isDM
- type
- id
- name
- avatarUrl
- timestamp
- joinRule (to say wheter you cannot join this room again if you reject)
new "memberships":
joining (when we want to join/are joining but haven't received remote echo yet)
leaving (needed?)
maybe it's slightly overkill to persist the intent of joining or leaving a room,
but I do want a way to local echo joining a room,
so that it immediately appears in the room list when clicking join in the room directory / from a url ... how would we sort these rooms though? we can always add another collection, but I'm not sure invites should be treated the same, they can already local echo on the invite object itself.
since invites don't update, we could, in sync when processing a new join just set a flag on the roomsyncstate if a room is newly created and in writeSync/afterSync check if there is a `session.invites.get(id)` and call `writeSync/afterSync` on it as well. We need to handle leave => invite as well. So don't check for invites only if it is a new room, but also if membership is leave
transitions are:
invite => join
invite => leave
invite => ban
join => left
join => ban
leave => invite
leave => join
leave => ban
ban => leave
none => invite
none => join
none => ban
kick should keep the room & timeline visible (even in room list, until you archive?)
leave should close the room. So explicit archive() step on room ?
Room => leave() => ArchivedRoom (just a Room loaded from archived_room_summaries) => .forget()
=> .forget()
Room receives leave membership
- if sender === state_key, we left, and we archive the room (remove it from the room list, but keep it in storage)
- if sender !== state_key, we got kicked, and we write the membership but don't archive so it stays in the room list until you call archive/forget on the room
when calling room.leave(), do you have to call archive() or forget() after as well? or rather param of leave and stored intent? sounds like non-atomical operation to me ...
we should be able to archive or forget before leave remote echo arrives
if two stores, this could mean we could have both an invite and a room with kicked state for a given room id?
we should avoid key collisions between `session.invites` and `session.rooms` (also `session.archivedRooms` once supported?) in any case,
because if we join them to display in one list, things get complicated.
avoiding key collisions can happen both with 1 or multiple stores for different room states and is just a matter
of carefully removing one state representation before adding another one.
so a kicked or left room would disappear from session.rooms when an invite is synced?
this would prevent you from seeing the old timeline for example, and if you reject, the old state would come back?
# Decisions
- we expose session.invites separate from session.rooms because they are of a different type.
This way, you only have methods on the object that make sense (accept on Room does not make sense, like Invite.openTimeline doesn't make sense)
- we store invites (and likely also archived rooms) in a different store, so that we don't have to clear/add properties where they both differ when transitioning. Also, this gives us the possibility to show the timeline on a room that you have previously joined, as the room summary and invite can exist at the same time. (need to resolve key collision question though for this)
- we want to keep kicked rooms in the room list until explicitly archived
- room id collisions between invites and rooms, can we implement a strategy to prefer invites in the join operator?

View file

@ -147,6 +147,22 @@ class Path {
return this._segments.find(s => s.type === type);
}
replace(segment) {
const index = this._segments.findIndex(s => s.type === segment.type);
if (index !== -1) {
const parent = this._segments[index - 1];
if (this._allowsChild(parent, segment)) {
const child = this._segments[index + 1];
if (!child || this._allowsChild(segment, child)) {
const newSegments = this._segments.slice();
newSegments[index] = segment;
return new Path(newSegments, this._allowsChild);
}
}
}
return null;
}
get segments() {
return this._segments;
}
@ -229,6 +245,17 @@ export function tests() {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
assert.equal(path.get("foo").value, 5);
assert.equal(path.get("bar").value, 6);
},
"path.replace success": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("foo", 1));
assert.equal(newPath.get("foo").value, 1);
assert.equal(newPath.get("bar").value, 6);
},
"path.replace not found": assert => {
const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true);
const newPath = path.replace(new Segment("baz", 1));
assert.equal(newPath, null);
}
};
}

View file

@ -43,6 +43,30 @@ function allowsChild(parent, child) {
}
}
export function removeRoomFromPath(path, roomId) {
const rooms = path.get("rooms");
let roomIdGridIndex = -1;
// first delete from rooms segment
if (rooms) {
roomIdGridIndex = rooms.value.indexOf(roomId);
if (roomIdGridIndex !== -1) {
const idsWithoutRoom = rooms.value.slice();
idsWithoutRoom[roomIdGridIndex] = "";
path = path.replace(new Segment("rooms", idsWithoutRoom));
}
}
const room = path.get("room");
// then from room (which occurs with or without rooms)
if (room && room.value === roomId) {
if (roomIdGridIndex !== -1) {
path = path.with(new Segment("empty-grid-tile", roomIdGridIndex));
} else {
path = path.until("session");
}
}
return path;
}
function roomsSegmentWithRoom(rooms, roomId, path) {
if(!rooms.value.includes(roomId)) {
const emptyGridTile = path.get("empty-grid-tile");
@ -243,6 +267,79 @@ export function tests() {
assert.equal(segments.length, 1);
assert.equal(segments[0].type, "session");
assert.strictEqual(segments[0].value, true);
}
},
"remove active room from grid path turns it into empty tile": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]);
assert.equal(newPath.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 1);
},
"remove inactive room from grid path": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", "c"]),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 3);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]);
assert.equal(newPath.segments[2].type, "room");
assert.equal(newPath.segments[2].value, "b");
},
"remove inactive room from grid path with empty tile": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("rooms", ["a", "b", ""]),
new Segment("empty-grid-tile", 3)
]);
const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 3);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "rooms");
assert.deepEqual(newPath.segments[1].value, ["a", "", ""]);
assert.equal(newPath.segments[2].type, "empty-grid-tile");
assert.equal(newPath.segments[2].value, 3);
},
"remove active room": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "b");
assert.equal(newPath.segments.length, 1);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
},
"remove inactive room doesn't do anything": assert => {
const nav = new Navigation(allowsChild);
const path = nav.pathFrom([
new Segment("session", 1),
new Segment("room", "b")
]);
const newPath = removeRoomFromPath(path, "a");
assert.equal(newPath.segments.length, 2);
assert.equal(newPath.segments[0].type, "session");
assert.equal(newPath.segments[0].value, 1);
assert.equal(newPath.segments[1].type, "room");
assert.equal(newPath.segments[1].value, "b");
},
}
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import {ViewModel} from "../ViewModel.js";
import {removeRoomFromPath} from "../navigation/index.js";
function dedupeSparse(roomIds) {
return roomIds.map((id, idx) => {
@ -33,9 +34,9 @@ export class RoomGridViewModel extends ViewModel {
this._width = options.width;
this._height = options.height;
this._createRoomViewModel = options.createRoomViewModel;
this._selectedIndex = 0;
this._viewModels = [];
this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this);
this._setupNavigation();
}
@ -63,6 +64,27 @@ export class RoomGridViewModel extends ViewModel {
// initial focus for a room is set by initializeRoomIdsAndTransferVM
}
_refreshRoomViewModel(roomId) {
const index = this._viewModels.findIndex(vm => vm?.id === roomId);
if (index === -1) {
return;
}
this._viewModels[index] = this.disposeTracked(this._viewModels[index]);
// this will create a RoomViewModel because the invite is already
// removed from the collection (see Invite.afterSync)
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel);
if (roomVM) {
this._viewModels[index] = this.track(roomVM);
if (this.focusIndex === index) {
roomVM.focus();
}
} else {
// close room id
this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId));
}
this.emitChange();
}
roomViewModelAt(i) {
return this._viewModels[i];
}
@ -128,7 +150,7 @@ export class RoomGridViewModel extends ViewModel {
this._viewModels[i] = this.disposeTracked(vm);
}
if (newId) {
const newVM = this._createRoomViewModel(newId);
const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel);
if (newVM) {
this._viewModels[i] = this.track(newVM);
}

View file

@ -15,8 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {removeRoomFromPath} from "../navigation/index.js";
import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js";
@ -34,11 +36,14 @@ export class SessionViewModel extends ViewModel {
session: sessionContainer.session,
})));
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({
invites: this._sessionContainer.session.invites,
rooms: this._sessionContainer.session.rooms
})));
this._settingsViewModel = null;
this._currentRoomViewModel = null;
this._gridViewModel = null;
this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this);
this._createRoomViewModel = this._createRoomViewModel.bind(this);
this._setupNavigation();
}
@ -84,15 +89,8 @@ export class SessionViewModel extends ViewModel {
this._sessionStatusViewModel.start();
}
get activeSection() {
if (this._currentRoomViewModel) {
return this._currentRoomViewModel.id;
} else if (this._gridViewModel) {
return "roomgrid";
} else if (this._settingsViewModel) {
return "settings";
}
return "placeholder";
get activeMiddleViewModel() {
return this._currentRoomViewModel || this._gridViewModel || this._settingsViewModel;
}
get roomGridViewModel() {
@ -111,10 +109,6 @@ export class SessionViewModel extends ViewModel {
return this._settingsViewModel;
}
get roomList() {
return this._roomList;
}
get currentRoomViewModel() {
return this._currentRoomViewModel;
}
@ -127,7 +121,7 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({
width: 3,
height: 2,
createRoomViewModel: roomId => this._createRoomViewModel(roomId),
createRoomViewModel: this._createRoomViewModel,
})));
if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) {
this._currentRoomViewModel = this.untrack(this._currentRoomViewModel);
@ -138,12 +132,13 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel.setRoomIds(roomIds);
}
} else if (this._gridViewModel && !roomIds) {
// closing grid, try to show focused room in grid
if (currentRoomId) {
const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value);
if (vm) {
this._currentRoomViewModel = this.track(vm);
} else {
const newVM = this._createRoomViewModel(currentRoomId.value);
const newVM = this._createRoomViewModel(currentRoomId.value, this._refreshRoomViewModel);
if (newVM) {
this._currentRoomViewModel = this.track(newVM);
}
@ -152,41 +147,67 @@ export class SessionViewModel extends ViewModel {
this._gridViewModel = this.disposeTracked(this._gridViewModel);
}
if (changed) {
this.emitChange("activeSection");
this.emitChange("activeMiddleViewModel");
}
}
_createRoomViewModel(roomId) {
const room = this._sessionContainer.session.rooms.get(roomId);
if (!room) {
return null;
/**
* @param {string} roomId
* @param {function} refreshRoomViewModel passed in as an argument, because the grid needs a different impl of this
* @return {RoomViewModel | InviteViewModel}
*/
_createRoomViewModel(roomId, refreshRoomViewModel) {
const invite = this._sessionContainer.session.invites.get(roomId);
if (invite) {
return new InviteViewModel(this.childOptions({
invite,
mediaRepository: this._sessionContainer.session.mediaRepository,
refreshRoomViewModel,
}));
} else {
const room = this._sessionContainer.session.rooms.get(roomId);
if (room) {
const roomVM = new RoomViewModel(this.childOptions({
room,
ownUserId: this._sessionContainer.session.user.id,
refreshRoomViewModel
}));
roomVM.load();
return roomVM;
}
}
const roomVM = new RoomViewModel(this.childOptions({
room,
ownUserId: this._sessionContainer.session.user.id,
}));
roomVM.load();
return roomVM;
return null;
}
/** refresh the room view model after an internal change that needs
to change between invite, room or none state */
_refreshRoomViewModel(roomId) {
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel);
if (roomVM) {
this._currentRoomViewModel = this.track(roomVM);
} else {
// close room id
this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId));
}
this.emitChange("activeMiddleViewModel");
}
_updateRoom(roomId) {
if (!roomId) {
if (this._currentRoomViewModel) {
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
this.emitChange("currentRoom");
}
return;
}
// already open?
// opening a room and already open?
if (this._currentRoomViewModel?.id === roomId) {
return;
}
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
const roomVM = this._createRoomViewModel(roomId);
// close if needed
if (this._currentRoomViewModel) {
this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel);
}
// and try opening again
const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel);
if (roomVM) {
this._currentRoomViewModel = this.track(roomVM);
}
this.emitChange("currentRoom");
this.emitChange("activeMiddleViewModel");
}
_updateSettings(settingsOpen) {
@ -199,7 +220,7 @@ export class SessionViewModel extends ViewModel {
})));
this._settingsViewModel.load();
}
this.emitChange("activeSection");
this.emitChange("activeMiddleViewModel");
}
_updateLightbox(eventId) {

View file

@ -0,0 +1,89 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 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 {avatarInitials, getIdentifierColorNumber} from "../../avatar.js";
import {ViewModel} from "../../ViewModel.js";
const KIND_ORDER = ["invite", "room"];
export class BaseTileViewModel extends ViewModel {
constructor(options) {
super(options);
this._isOpen = false;
this._hidden = false;
if (options.isOpen) {
this.open();
}
}
get hidden() {
return this._hidden;
}
set hidden(value) {
if (value !== this._hidden) {
this._hidden = value;
this.emitChange("hidden");
}
}
close() {
if (this._isOpen) {
this._isOpen = false;
this.emitChange("isOpen");
}
}
open() {
if (!this._isOpen) {
this._isOpen = true;
this.emitChange("isOpen");
}
}
get isOpen() {
return this._isOpen;
}
compare(other) {
if (other.kind !== this.kind) {
return KIND_ORDER.indexOf(this.kind) - KIND_ORDER.indexOf(other.kind);
}
return 0;
}
// Avatar view model contract
get avatarLetter() {
return avatarInitials(this.name);
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._avatarSource.id);
}
get avatarUrl() {
if (this._avatarSource.avatarUrl) {
const size = 32 * this.platform.devicePixelRatio;
return this._avatarSource.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop");
}
return null;
}
get avatarTitle() {
return this.name;
}
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 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 {BaseTileViewModel} from "./BaseTileViewModel.js";
export class InviteTileViewModel extends BaseTileViewModel {
constructor(options) {
super(options);
const {invite} = options;
this._invite = invite;
this._url = this.urlCreator.openRoomActionUrl(this._invite.id);
}
get busy() {
return this._invite.accepting || this._invite.rejecting;
}
get kind() {
return "invite";
}
get url() {
return this._url;
}
compare(other) {
const parentComparison = super.compare(other);
if (parentComparison !== 0) {
return parentComparison;
}
return other._invite.timestamp - this._invite.timestamp;
}
get name() {
return this._invite.name;
}
get _avatarSource() {
return this._invite;
}
}

View file

@ -17,37 +17,49 @@ limitations under the License.
import {ViewModel} from "../../ViewModel.js";
import {RoomTileViewModel} from "./RoomTileViewModel.js";
import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js";
export class LeftPanelViewModel extends ViewModel {
constructor(options) {
super(options);
const {rooms} = options;
this._roomTileViewModels = rooms.mapValues((room, emitChange) => {
const isOpen = this.navigation.path.get("room")?.value === room.id;
const vm = new RoomTileViewModel(this.childOptions({
isOpen,
room,
emitChange
}));
// need to also update the current vm here as
// we can't call `_open` from the ctor as the map
// is only populated when the view subscribes.
if (isOpen) {
this._currentTileVM?.close();
this._currentTileVM = vm;
}
return vm;
});
this._roomListFilterMap = new ApplyMap(this._roomTileViewModels);
this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b));
const {rooms, invites} = options;
this._tileViewModelsMap = this._mapTileViewModels(rooms, invites);
this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap);
this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b));
this._currentTileVM = null;
this._setupNavigation();
this._closeUrl = this.urlCreator.urlForSegment("session");
this._settingsUrl = this.urlCreator.urlForSegment("settings");
}
_mapTileViewModels(rooms, invites) {
const joinedRooms = rooms.filterValues(room => room.membership === "join");
// join is not commutative, invites will take precedence over rooms
return invites.join(joinedRooms).mapValues((roomOrInvite, emitChange) => {
const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id;
let vm;
if (roomOrInvite.isInvite) {
vm = new InviteTileViewModel(this.childOptions({isOpen, invite: roomOrInvite, emitChange}));
} else {
vm = new RoomTileViewModel(this.childOptions({isOpen, room: roomOrInvite, emitChange}));
}
if (isOpen) {
this._updateCurrentVM(vm);
}
return vm;
});
}
_updateCurrentVM(vm) {
// need to also update the current vm here as
// we can't call `_open` from the ctor as the map
// is only populated when the view subscribes.
this._currentTileVM?.close();
this._currentTileVM = vm;
}
get closeUrl() {
return this._closeUrl;
}
@ -75,7 +87,7 @@ export class LeftPanelViewModel extends ViewModel {
this._currentTileVM?.close();
this._currentTileVM = null;
if (roomId) {
this._currentTileVM = this._roomTileViewModels.get(roomId);
this._currentTileVM = this._tileViewModelsMap.get(roomId);
this._currentTileVM?.open();
}
}
@ -102,13 +114,13 @@ export class LeftPanelViewModel extends ViewModel {
}
}
get roomList() {
return this._roomList;
get tileViewModels() {
return this._tileViewModels;
}
clearFilter() {
this._roomListFilterMap.setApply(null);
this._roomListFilterMap.applyOnce((roomId, vm) => vm.hidden = false);
this._tileViewModelsFilterMap.setApply(null);
this._tileViewModelsFilterMap.applyOnce((roomId, vm) => vm.hidden = false);
}
setFilter(query) {
@ -117,7 +129,7 @@ export class LeftPanelViewModel extends ViewModel {
this.clearFilter();
} else {
const filter = new RoomFilter(query);
this._roomListFilterMap.setApply((roomId, vm) => {
this._tileViewModelsFilterMap.setApply((roomId, vm) => {
vm.hidden = !filter.matches(vm);
});
}

View file

@ -15,51 +15,23 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js";
import {ViewModel} from "../../ViewModel.js";
import {BaseTileViewModel} from "./BaseTileViewModel.js";
function isSortedAsUnread(vm) {
return vm.isUnread || (vm.isOpen && vm._wasUnreadWhenOpening);
}
export class RoomTileViewModel extends ViewModel {
export class RoomTileViewModel extends BaseTileViewModel {
constructor(options) {
super(options);
const {room} = options;
this._room = room;
this._isOpen = false;
this._wasUnreadWhenOpening = false;
this._hidden = false;
this._url = this.urlCreator.openRoomActionUrl(this._room.id);
if (options.isOpen) {
this.open();
}
}
get hidden() {
return this._hidden;
}
set hidden(value) {
if (value !== this._hidden) {
this._hidden = value;
this.emitChange("hidden");
}
}
close() {
if (this._isOpen) {
this._isOpen = false;
this.emitChange("isOpen");
}
}
open() {
if (!this._isOpen) {
this._isOpen = true;
this._wasUnreadWhenOpening = this._room.isUnread;
this.emitChange("isOpen");
}
get kind() {
return "room";
}
get url() {
@ -67,6 +39,10 @@ export class RoomTileViewModel extends ViewModel {
}
compare(other) {
const parentComparison = super.compare(other);
if (parentComparison !== 0) {
return parentComparison;
}
/*
put unread rooms first
then put rooms with a timestamp first, and sort by name
@ -110,10 +86,6 @@ export class RoomTileViewModel extends ViewModel {
return timeDiff;
}
get isOpen() {
return this._isOpen;
}
get isUnread() {
return this._room.isUnread;
}
@ -122,27 +94,6 @@ export class RoomTileViewModel extends ViewModel {
return this._room.name || this.i18n`Empty Room`;
}
// Avatar view model contract
get avatarLetter() {
return avatarInitials(this.name);
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._room.id)
}
get avatarUrl() {
if (this._room.avatarUrl) {
const size = 32 * this.platform.devicePixelRatio;
return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop");
}
return null;
}
get avatarTitle() {
return this.name;
}
get badgeCount() {
return this._room.notificationCount;
}
@ -150,4 +101,8 @@ export class RoomTileViewModel extends ViewModel {
get isHighlighted() {
return this._room.highlightCount !== 0;
}
get _avatarSource() {
return this._room;
}
}

View file

@ -0,0 +1,167 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 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 {avatarInitials, getIdentifierColorNumber} from "../../avatar.js";
import {ViewModel} from "../../ViewModel.js";
export class InviteViewModel extends ViewModel {
constructor(options) {
super(options);
const {invite, mediaRepository, refreshRoomViewModel} = options;
this._invite = invite;
this._mediaRepository = mediaRepository;
this._refreshRoomViewModel = refreshRoomViewModel;
this._onInviteChange = this._onInviteChange.bind(this);
this._error = null;
this._closeUrl = this.urlCreator.urlUntilSegment("session");
this._invite.on("change", this._onInviteChange);
this._inviter = null;
if (this._invite.inviter) {
this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform);
}
this._roomDescription = this._createRoomDescription();
}
get kind() { return "invite"; }
get closeUrl() { return this._closeUrl; }
get name() { return this._invite.name; }
get id() { return this._invite.id; }
get isEncrypted() { return this._invite.isEncrypted; }
get isDirectMessage() { return this._invite.isDirectMessage; }
get inviter() { return this._inviter; }
get busy() { return this._invite.accepting || this._invite.rejecting; }
get error() {
if (this._error) {
return `Something went wrong: ${this._error.message}`;
}
return "";
}
get avatarLetter() {
return avatarInitials(this.name);
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._invite.id)
}
get avatarUrl() {
if (this._invite.avatarUrl) {
const size = 128 * this.platform.devicePixelRatio;
return this._mediaRepository.mxcUrlThumbnail(this._invite.avatarUrl, size, size, "crop");
}
return null;
}
_createRoomDescription() {
const parts = [];
if (this._invite.isPublic) {
parts.push("Public room");
} else {
parts.push("Private room");
}
if (this._invite.canonicalAlias) {
parts.push(this._invite.canonicalAlias);
}
return parts.join(" • ")
}
get roomDescription() {
return this._roomDescription;
}
get avatarTitle() {
return this.name;
}
focus() {}
async accept() {
try {
await this._invite.accept();
} catch (err) {
this._error = err;
this.emitChange("error");
}
}
async reject() {
try {
await this._invite.reject();
} catch (err) {
this._error = err;
this.emitChange("error");
}
}
_onInviteChange() {
if (this._invite.accepted || this._invite.rejected) {
// close invite if rejected, or open room if accepted.
// Done with a callback rather than manipulating the nav,
// as closing the invite changes the nav path depending whether
// we're in a grid view, and opening the room doesn't change
// the nav path because the url is the same for an
// invite and the room.
this._refreshRoomViewModel(this.id);
} else {
this.emitChange();
}
}
dispose() {
super.dispose();
this._invite.off("change", this._onInviteChange);
}
}
class RoomMemberViewModel {
constructor(member, mediaRepository, platform) {
this._member = member;
this._mediaRepository = mediaRepository;
this._platform = platform;
}
get id() {
return this._member.userId;
}
get name() {
return this._member.name;
}
get avatarLetter() {
return avatarInitials(this.name);
}
get avatarColorNumber() {
return getIdentifierColorNumber(this._member.userId);
}
get avatarUrl() {
if (this._member.avatarUrl) {
const size = 24 * this._platform.devicePixelRatio;
return this._mediaRepository.mxcUrlThumbnail(this._member.avatarUrl, size, size, "crop");
}
return null;
}
get avatarTitle() {
return this.name;
}
}

View file

@ -0,0 +1,9 @@
# "Room" view models
InviteViewModel and RoomViewModel are interchangebly used as "room view model":
- SessionViewModel.roomViewModel can be an instance of either
- RoomGridViewModel.roomViewModelAt(i) can return an instance of either
This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel.
They share an `id` and `kind` property, the latter can be used to differentiate them from the view, and a `focus` method.

View file

@ -22,9 +22,10 @@ import {ViewModel} from "../../ViewModel.js";
export class RoomViewModel extends ViewModel {
constructor(options) {
super(options);
const {room, ownUserId} = options;
const {room, ownUserId, refreshRoomViewModel} = options;
this._room = room;
this._ownUserId = ownUserId;
this._refreshRoomViewModel = refreshRoomViewModel;
this._timelineVM = null;
this._onRoomChange = this._onRoomChange.bind(this);
this._timelineError = null;
@ -34,10 +35,6 @@ export class RoomViewModel extends ViewModel {
this._closeUrl = this.urlCreator.urlUntilSegment("session");
}
get closeUrl() {
return this._closeUrl;
}
async load() {
this._room.on("change", this._onRoomChange);
try {
@ -69,7 +66,7 @@ export class RoomViewModel extends ViewModel {
} catch (err) {
if (err.name !== "AbortError") {
throw err;
}
}
}
}
@ -86,33 +83,24 @@ export class RoomViewModel extends ViewModel {
}
}
// called from view to close room
// parent vm will dispose this vm
close() {
this._closeCallback();
}
// room doesn't tell us yet which fields changed,
// so emit all fields originating from summary
_onRoomChange() {
this.emitChange("name");
// if there is now an invite on this (left) room,
// show the invite view by refreshing the view model
if (this._room.invite) {
this._refreshRoomViewModel(this.id);
} else {
this.emitChange("name");
}
}
get name() {
return this._room.name || this.i18n`Empty Room`;
}
get id() {
return this._room.id;
}
get timelineViewModel() {
return this._timelineVM;
}
get isEncrypted() {
return this._room.isEncrypted;
}
get kind() { return "room"; }
get closeUrl() { return this._closeUrl; }
get name() { return this._room.name || this.i18n`Empty Room`; }
get id() { return this._room.id; }
get timelineViewModel() { return this._timelineVM; }
get isEncrypted() { return this._room.isEncrypted; }
get error() {
if (this._timelineError) {

View file

@ -0,0 +1,52 @@
const inviteFixture = {
"invite_state": {
"events": [
{
"type": "m.room.create",
"state_key": "",
"content": {
"creator": "@alice:hs.tld",
},
"sender": "@alice:hs.tld"
},
{
"type": "m.room.encryption",
"state_key": "",
"content": {
"algorithm": "m.megolm.v1.aes-sha2"
},
"sender": "@alice:hs.tld"
},
{
"type": "m.room.join_rules",
"state_key": "",
"content": {
"join_rule": "invite"
},
"sender": "@alice:hs.tld"
},
{
"type": "m.room.member",
"state_key": "@alice:hs.tld",
"content": {
"avatar_url": "mxc://hs.tld/def456",
"displayname": "Alice",
"membership": "join"
},
"sender": "@alice:hs.tld"
},
{
"content": {
"avatar_url": "mxc://hs.tld/abc123",
"displayname": "Bob",
"is_direct": true,
"membership": "invite"
},
"sender": "@alice:hs.tld",
"state_key": "@bob:hs.tld",
"type": "m.room.member",
}
]
}
};
export default inviteFixture;

View file

@ -0,0 +1,59 @@
const inviteFixture = {
"invite_state": {
"events": [
{
"type": "m.room.create",
"state_key": "",
"content": {
"creator": "@alice:hs.tld",
},
"sender": "@alice:hs.tld"
},
{
"type": "m.room.join_rules",
"state_key": "",
"content": {
"join_rule": "invite"
},
"sender": "@alice:hs.tld"
},
{
"type": "m.room.member",
"state_key": "@alice:hs.tld",
"content": {
"avatar_url": "mxc://hs.tld/def456",
"displayname": "Alice",
"membership": "join"
},
"sender": "@alice:hs.tld"
},
{
"type": "m.room.name",
"state_key": "",
"content": {
"name": "Invite example"
},
"sender": "@alice:hs.tld"
},
{
"content": {
"avatar_url": "mxc://hs.tld/abc123",
"displayname": "Bob",
"membership": "invite"
},
"sender": "@alice:hs.tld",
"state_key": "@bob:hs.tld",
"type": "m.room.member",
},
{
"content": {
"url": "mxc://hs.tld/roomavatar"
},
"sender": "@alice:hs.tld",
"state_key": "",
"type": "m.room.avatar",
}
]
}
};
export default inviteFixture;

View file

@ -50,7 +50,7 @@ export class NullLogger {
}
}
class NullLogItem {
export class NullLogItem {
wrap(_, callback) {
return callback(this);
}

View file

@ -1,5 +1,6 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 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.
@ -15,6 +16,7 @@ limitations under the License.
*/
import {Room} from "./room/Room.js";
import {Invite} from "./room/Invite.js";
import {Pusher} from "./push/Pusher.js";
import { ObservableMap } from "../observable/index.js";
import {User} from "./User.js";
@ -52,6 +54,9 @@ export class Session {
this._sessionInfo = sessionInfo;
this._rooms = new ObservableMap();
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
this._invites = new ObservableMap();
this._inviteRemoveCallback = invite => this._invites.remove(invite.id);
this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params);
this._user = new User(sessionInfo.userId);
this._deviceMessageHandler = new DeviceMessageHandler({storage});
this._olm = olm;
@ -254,6 +259,7 @@ export class Session {
const txn = await this._storage.readTxn([
this._storage.storeNames.session,
this._storage.storeNames.roomSummary,
this._storage.storeNames.invites,
this._storage.storeNames.roomMembers,
this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments,
@ -278,12 +284,28 @@ export class Session {
}
}
const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn);
// load invites
const invites = await txn.invites.getAll();
const inviteLoadPromise = Promise.all(invites.map(async inviteData => {
const invite = this.createInvite(inviteData.roomId);
log.wrap("invite", log => invite.load(inviteData, log));
this._invites.add(invite.id, invite);
}));
// load rooms
const rooms = await txn.roomSummary.getAll();
await Promise.all(rooms.map(summary => {
const roomLoadPromise = Promise.all(rooms.map(async summary => {
const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId));
return log.wrap("room", log => room.load(summary, txn, log));
await log.wrap("room", log => room.load(summary, txn, log));
this._rooms.add(room.id, room);
}));
// load invites and rooms in parallel
await Promise.all([inviteLoadPromise, roomLoadPromise]);
for (const [roomId, invite] of this.invites) {
const room = this.rooms.get(roomId);
if (room) {
room.setInvite(invite);
}
}
}
dispose() {
@ -360,7 +382,7 @@ export class Session {
/** @internal */
createRoom(roomId, pendingEvents) {
const room = new Room({
return new Room({
roomId,
getSyncToken: this._getSyncToken,
storage: this._storage,
@ -372,8 +394,33 @@ export class Session {
createRoomEncryption: this._createRoomEncryption,
platform: this._platform
});
this._rooms.add(roomId, room);
return room;
}
/** @internal */
addRoomAfterSync(room) {
this._rooms.add(room.id, room);
}
get invites() {
return this._invites;
}
/** @internal */
createInvite(roomId) {
return new Invite({
roomId,
hsApi: this._hsApi,
emitCollectionRemove: this._inviteRemoveCallback,
emitCollectionUpdate: this._inviteUpdateCallback,
mediaRepository: this._mediaRepository,
user: this._user,
platform: this._platform,
});
}
/** @internal */
addInviteAfterSync(invite) {
this._invites.add(invite.id, invite);
}
async obtainSyncLock(syncResponse) {
@ -469,6 +516,10 @@ export class Session {
return this._user;
}
get mediaRepository() {
return this._mediaRepository;
}
enablePushNotifications(enable) {
if (enable) {
return this._enablePush();
@ -555,6 +606,11 @@ export function tests() {
getAll() {
return Promise.resolve([]);
}
},
invites: {
getAll() {
return Promise.resolve([]);
}
}
};
},

View file

@ -1,6 +1,6 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020, 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.
@ -191,48 +191,23 @@ export class Sync {
const isInitialSync = !syncToken;
const sessionState = new SessionSyncProcessState();
const roomStates = this._parseRoomsResponse(response.rooms, isInitialSync);
const inviteStates = this._parseInvites(response.rooms);
const roomStates = this._parseRoomsResponse(response.rooms, inviteStates, isInitialSync);
try {
// take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing
sessionState.lock = await log.wrap("obtainSyncLock", () => this._session.obtainSyncLock(response));
await log.wrap("prepare", log => this._prepareSessionAndRooms(sessionState, roomStates, response, log));
await log.wrap("prepare", log => this._prepareSync(sessionState, roomStates, response, log));
await log.wrap("afterPrepareSync", log => Promise.all(roomStates.map(rs => {
return rs.room.afterPrepareSync(rs.preparation, log);
})));
await log.wrap("write", async log => {
const syncTxn = await this._openSyncTxn();
try {
sessionState.changes = await log.wrap("session", log => this._session.writeSync(
response, syncFilterId, sessionState.preparation, syncTxn, log));
await Promise.all(roomStates.map(async rs => {
rs.changes = await log.wrap("room", log => rs.room.writeSync(
rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log));
}));
} catch(err) {
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
try {
syncTxn.abort();
} catch (abortErr) {
log.set("couldNotAbortTxn", true);
}
throw err;
}
await syncTxn.complete();
});
await log.wrap("write", async log => this._writeSync(
sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log));
} finally {
sessionState.dispose();
}
log.wrap("after", log => {
log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail);
// emit room related events after txn has been closed
for(let rs of roomStates) {
log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail);
}
});
// sync txn comitted, emit updates and apply changes to in-memory state
log.wrap("after", log => this._afterSync(sessionState, inviteStates, roomStates, log));
const toDeviceEvents = response.to_device?.events;
return {
@ -252,7 +227,7 @@ export class Sync {
]);
}
async _prepareSessionAndRooms(sessionState, roomStates, response, log) {
async _prepareSync(sessionState, roomStates, response, log) {
const prepareTxn = await this._openPrepareSyncTxn();
sessionState.preparation = await log.wrap("session", log => this._session.prepareSync(
response, sessionState.lock, prepareTxn, log));
@ -267,7 +242,7 @@ export class Sync {
if (!isRoomInResponse) {
let room = this._session.rooms.get(roomId);
if (room) {
roomStates.push(new RoomSyncProcessState(room, {}, room.membership));
roomStates.push(new RoomSyncProcessState(room, false, null, {}, room.membership));
}
}
}
@ -276,18 +251,72 @@ export class Sync {
await Promise.all(roomStates.map(async rs => {
const newKeys = newKeysByRoom?.get(rs.room.id);
rs.preparation = await log.wrap("room", log => rs.room.prepareSync(
rs.roomResponse, rs.membership, newKeys, prepareTxn, log), log.level.Detail);
rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log), log.level.Detail);
}));
// This is needed for safari to not throw TransactionInactiveErrors on the syncTxn. See docs/INDEXEDDB.md
await prepareTxn.complete();
}
async _writeSync(sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log) {
const syncTxn = await this._openSyncTxn();
try {
sessionState.changes = await log.wrap("session", log => this._session.writeSync(
response, syncFilterId, sessionState.preparation, syncTxn, log));
await Promise.all(inviteStates.map(async is => {
is.changes = await log.wrap("invite", log => is.invite.writeSync(
is.membership, is.roomResponse, syncTxn, log));
}));
await Promise.all(roomStates.map(async rs => {
rs.changes = await log.wrap("room", log => rs.room.writeSync(
rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log));
}));
} catch(err) {
// avoid corrupting state by only
// storing the sync up till the point
// the exception occurred
try {
syncTxn.abort();
} catch (abortErr) {
log.set("couldNotAbortTxn", true);
}
throw err;
}
await syncTxn.complete();
}
_afterSync(sessionState, inviteStates, roomStates, log) {
log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail);
// emit room related events after txn has been closed
for(let rs of roomStates) {
log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail);
if (rs.isNewRoom) {
// important to add the room before removing the invite,
// so the room will be found if looking for it when the invite
// is removed
this._session.addRoomAfterSync(rs.room);
}
}
// emit invite related events after txn has been closed
for(let is of inviteStates) {
log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail);
if (is.isNewInvite) {
this._session.addInviteAfterSync(is.invite);
}
// if we haven't archived or forgotten the (left) room yet,
// notify there is an invite now, so we can update the UI
if (is.room) {
is.room.setInvite(is.invite);
}
}
}
_openSyncTxn() {
const storeNames = this._storage.storeNames;
return this._storage.readWriteTxn([
storeNames.session,
storeNames.roomSummary,
storeNames.invites,
storeNames.roomState,
storeNames.roomMembers,
storeNames.timelineEvents,
@ -307,11 +336,10 @@ export class Sync {
]);
}
_parseRoomsResponse(roomsSection, isInitialSync) {
_parseRoomsResponse(roomsSection, inviteStates, isInitialSync) {
const roomStates = [];
if (roomsSection) {
// don't do "invite", "leave" for now
const allMemberships = ["join"];
const allMemberships = ["join", "leave"];
for(const membership of allMemberships) {
const membershipSection = roomsSection[membership];
if (membershipSection) {
@ -321,11 +349,23 @@ export class Sync {
if (isInitialSync && timelineIsEmpty(roomResponse)) {
continue;
}
let isNewRoom = false;
let room = this._session.rooms.get(roomId);
if (!room) {
// don't create a room for a rejected invite
if (!room && membership === "join") {
room = this._session.createRoom(roomId);
isNewRoom = true;
}
const invite = this._session.invites.get(roomId);
// if there is an existing invite, add a process state for it
// so its writeSync and afterSync will run and remove the invite
if (invite) {
inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null));
}
if (room) {
roomStates.push(new RoomSyncProcessState(
room, isNewRoom, invite, roomResponse, membership));
}
roomStates.push(new RoomSyncProcessState(room, roomResponse, membership));
}
}
}
@ -333,6 +373,22 @@ export class Sync {
return roomStates;
}
_parseInvites(roomsSection) {
const inviteStates = [];
if (roomsSection.invite) {
for (const [roomId, roomResponse] of Object.entries(roomsSection.invite)) {
let invite = this._session.invites.get(roomId);
let isNewInvite = false;
if (!invite) {
invite = this._session.createInvite(roomId);
isNewInvite = true;
}
const room = this._session.rooms.get(roomId);
inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse));
}
}
return inviteStates;
}
stop() {
if (this._status.get() === SyncStatus.Stopped) {
@ -360,11 +416,24 @@ class SessionSyncProcessState {
}
class RoomSyncProcessState {
constructor(room, roomResponse, membership) {
constructor(room, isNewRoom, invite, roomResponse, membership) {
this.room = room;
this.isNewRoom = isNewRoom;
this.invite = invite;
this.roomResponse = roomResponse;
this.membership = membership;
this.preparation = null;
this.changes = null;
}
}
class InviteSyncProcessState {
constructor(invite, isNewInvite, room, membership, roomResponse) {
this.invite = invite;
this.isNewInvite = isNewInvite;
this.room = room;
this.membership = membership;
this.roomResponse = roomResponse;
this.changes = null;
}
}

View file

@ -185,6 +185,14 @@ export class HomeServerApi {
getPushers(options = null) {
return this._get("/pushers", null, null, options);
}
join(roomId, options = null) {
return this._post(`/rooms/${encodeURIComponent(roomId)}/join`, null, null, options);
}
leave(roomId, options = null) {
return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, null, null, options);
}
}
import {Request as MockRequest} from "../../mocks/Request.js";

356
src/matrix/room/Invite.js Normal file
View file

@ -0,0 +1,356 @@
/*
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 {EventEmitter} from "../../utils/EventEmitter.js";
import {SummaryData, processStateEvent} from "./RoomSummary.js";
import {Heroes} from "./members/Heroes.js";
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js";
export class Invite extends EventEmitter {
constructor({roomId, user, hsApi, mediaRepository, emitCollectionRemove, emitCollectionUpdate, platform}) {
super();
this._roomId = roomId;
this._user = user;
this._hsApi = hsApi;
this._emitCollectionRemove = emitCollectionRemove;
this._emitCollectionUpdate = emitCollectionUpdate;
this._mediaRepository = mediaRepository;
this._platform = platform;
this._inviteData = null;
this._accepting = false;
this._rejecting = false;
this._accepted = false;
this._rejected = false;
}
get isInvite() {
return true;
}
get id() {
return this._roomId;
}
get name() {
return this._inviteData.name || this._inviteData.canonicalAlias;
}
get isDirectMessage() {
return this._inviteData.isDirectMessage;
}
get avatarUrl() {
return this._inviteData.avatarUrl;
}
get timestamp() {
return this._inviteData.timestamp;
}
get isEncrypted() {
return this._inviteData.isEncrypted;
}
get inviter() {
return this._inviter;
}
get isPublic() {
return this._inviteData.joinRule === "public";
}
get canonicalAlias() {
return this._inviteData.canonicalAlias;
}
async accept(log = null) {
await this._platform.logger.wrapOrRun(log, "acceptInvite", async log => {
this._accepting = true;
this._emitChange("accepting");
await this._hsApi.join(this._roomId, {log}).response();
});
}
async reject(log = null) {
await this._platform.logger.wrapOrRun(log, "rejectInvite", async log => {
this._rejecting = true;
this._emitChange("rejecting");
await this._hsApi.leave(this._roomId, {log}).response();
});
}
get accepting() {
return this._accepting;
}
get accepted() {
return this._accepted;
}
get rejecting() {
return this._rejecting;
}
get rejected() {
return this._rejected;
}
get mediaRepository() {
return this._mediaRepository;
}
_emitChange(params) {
this.emit("change");
this._emitCollectionUpdate(this, params);
}
load(inviteData, log) {
log.set("id", this.id);
this._inviteData = inviteData;
this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null;
}
async writeSync(membership, roomResponse, txn, log) {
if (membership === "invite") {
log.set("id", this.id);
log.set("add", true);
const inviteState = roomResponse["invite_state"]?.events;
if (!Array.isArray(inviteState)) {
return null;
}
const summaryData = this._createSummaryData(inviteState);
let heroes;
if (!summaryData.name && !summaryData.canonicalAlias) {
heroes = await this._createHeroes(inviteState);
}
const myInvite = this._getMyInvite(inviteState);
if (!myInvite) {
return null;
}
const inviter = this._getInviter(myInvite, inviteState);
const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes);
txn.invites.set(inviteData);
return {inviteData, inviter};
} else {
log.set("id", this.id);
log.set("membership", membership);
txn.invites.remove(this.id);
return {removed: true, membership};
}
}
afterSync(changes) {
if (changes) {
if (changes.removed) {
this._accepting = false;
this._rejecting = false;
if (changes.membership === "join") {
this._accepted = true;
} else {
this._rejected = true;
}
// important to remove before emitting change
// so code checking session.invites.get(id) won't
// find the invite anymore on update
this._emitCollectionRemove(this);
this.emit("change");
} else {
this._inviteData = changes.inviteData;
this._inviter = changes.inviter;
// sync will add the invite to the collection by
// calling session.addInviteAfterSync
}
}
}
_createData(inviteState, myInvite, inviter, summaryData, heroes) {
const name = heroes ? heroes.roomName : summaryData.name;
const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl;
return {
roomId: this.id,
isEncrypted: !!summaryData.encryption,
isDirectMessage: this._isDirectMessage(myInvite),
// type:
name,
avatarUrl,
canonicalAlias: summaryData.canonicalAlias,
timestamp: this._platform.clock.now(),
joinRule: this._getJoinRule(inviteState),
inviter: inviter?.serialize(),
};
}
_isDirectMessage(myInvite) {
return !!(myInvite?.content?.is_direct);
}
_createSummaryData(inviteState) {
return inviteState.reduce(processStateEvent, new SummaryData(null, this.id));
}
async _createHeroes(inviteState) {
const members = inviteState.filter(e => e.type === MEMBER_EVENT_TYPE);
const otherMembers = members.filter(e => e.state_key !== this._user.id);
const memberChanges = otherMembers.reduce((map, e) => {
const member = RoomMember.fromMemberEvent(this.id, e);
map.set(member.userId, new MemberChange(member, null));
return map;
}, new Map());
const otherUserIds = otherMembers.map(e => e.state_key);
const heroes = new Heroes(this.id);
const changes = await heroes.calculateChanges(otherUserIds, memberChanges, null);
// we don't get an actual lazy-loading m.heroes summary on invites,
// so just count the members by hand
const countSummary = new SummaryData(null, this.id);
countSummary.joinCount = members.reduce((sum, e) => sum + (e.content?.membership === "join" ? 1 : 0), 0);
countSummary.inviteCount = members.reduce((sum, e) => sum + (e.content?.membership === "invite" ? 1 : 0), 0);
heroes.applyChanges(changes, countSummary);
return heroes;
}
_getMyInvite(inviteState) {
return inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === this._user.id);
}
_getInviter(myInvite, inviteState) {
const inviterMemberEvent = inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === myInvite.sender);
if (inviterMemberEvent) {
return RoomMember.fromMemberEvent(this.id, inviterMemberEvent);
}
}
_getJoinRule(inviteState) {
const event = inviteState.find(e => e.type === "m.room.join_rules");
if (event) {
return event.content?.join_rule;
}
return null;
}
}
import {NullLogItem} from "../../logging/NullLogger.js";
import {Clock as MockClock} from "../../mocks/Clock.js";
import {default as roomInviteFixture} from "../../fixtures/matrix/invites/room.js";
import {default as dmInviteFixture} from "../../fixtures/matrix/invites/dm.js";
export function tests() {
function createStorage() {
const invitesMap = new Map();
return {
invitesMap,
invites: {
set(invite) {
invitesMap.set(invite.roomId, invite);
},
remove(roomId) {
invitesMap.delete(roomId);
}
}
}
}
const roomId = "!123:hs.tld";
const aliceAvatarUrl = "mxc://hs.tld/def456";
const roomAvatarUrl = "mxc://hs.tld/roomavatar";
return {
"invite for room has correct fields": async assert => {
const invite = new Invite({
roomId,
platform: {clock: new MockClock(1001)},
user: {id: "@bob:hs.tld"}
});
const txn = createStorage();
const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem());
assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
invite.afterSync(changes);
assert.equal(invite.name, "Invite example");
assert.equal(invite.avatarUrl, roomAvatarUrl);
assert.equal(invite.isPublic, false);
assert.equal(invite.timestamp, 1001);
assert.equal(invite.isEncrypted, false);
assert.equal(invite.isDirectMessage, false);
assert(invite.inviter);
assert.equal(invite.inviter.userId, "@alice:hs.tld");
assert.equal(invite.inviter.displayName, "Alice");
assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl);
},
"invite for encrypted DM has correct fields": async assert => {
const invite = new Invite({
roomId,
platform: {clock: new MockClock(1003)},
user: {id: "@bob:hs.tld"}
});
const txn = createStorage();
const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
invite.afterSync(changes);
assert.equal(invite.name, "Alice");
assert.equal(invite.avatarUrl, aliceAvatarUrl);
assert.equal(invite.timestamp, 1003);
assert.equal(invite.isEncrypted, true);
assert.equal(invite.isDirectMessage, true);
assert(invite.inviter);
assert.equal(invite.inviter.userId, "@alice:hs.tld");
assert.equal(invite.inviter.displayName, "Alice");
assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl);
},
"load persisted invite has correct fields": async assert => {
const writeInvite = new Invite({
roomId,
platform: {clock: new MockClock(1003)},
user: {id: "@bob:hs.tld"}
});
const txn = createStorage();
await writeInvite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
const invite = new Invite({roomId});
invite.load(txn.invitesMap.get(roomId), new NullLogItem());
assert.equal(invite.name, "Alice");
assert.equal(invite.avatarUrl, aliceAvatarUrl);
assert.equal(invite.timestamp, 1003);
assert.equal(invite.isEncrypted, true);
assert.equal(invite.isDirectMessage, true);
assert(invite.inviter);
assert.equal(invite.inviter.userId, "@alice:hs.tld");
assert.equal(invite.inviter.displayName, "Alice");
assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl);
},
"syncing with membership from invite removes the invite": async assert => {
let removedEmitted = false;
const invite = new Invite({
roomId,
platform: {clock: new MockClock(1003)},
user: {id: "@bob:hs.tld"},
emitCollectionRemove: emittingInvite => {
assert.equal(emittingInvite, invite);
removedEmitted = true;
}
});
const txn = createStorage();
const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem());
assert.equal(txn.invitesMap.get(roomId).roomId, roomId);
invite.afterSync(changes);
const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem());
assert(!removedEmitted);
invite.afterSync(joinChanges);
assert.equal(txn.invitesMap.get(roomId), undefined);
assert.equal(invite.rejected, false);
assert.equal(invite.accepted, true);
assert(removedEmitted);
}
}
}

View file

@ -54,6 +54,7 @@ export class Room extends EventEmitter {
this._getSyncToken = getSyncToken;
this._platform = platform;
this._observedEvents = null;
this._invite = null;
}
async _eventIdsToEntries(eventIds, txn) {
@ -189,12 +190,15 @@ export class Room extends EventEmitter {
return retryEntries;
}
async prepareSync(roomResponse, membership, newKeys, txn, log) {
async prepareSync(roomResponse, membership, invite, newKeys, txn, log) {
log.set("id", this.id);
if (newKeys) {
log.set("newKeys", newKeys.length);
}
const summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership)
let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership);
if (membership === "join" && invite) {
summaryChanges = summaryChanges.applyInvite(invite);
}
let roomEncryption = this._roomEncryption;
// encryption is enabled in this sync
if (!roomEncryption && summaryChanges.encryption) {
@ -245,8 +249,9 @@ export class Room extends EventEmitter {
/** @package */
async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) {
log.set("id", this.id);
const isRejoin = summaryChanges.membership === "join" && this._summary.data.membership === "leave";
const {entries: newEntries, newLiveKey, memberChanges} =
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, txn, log), log.level.Detail);
await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail);
let allEntries = newEntries;
if (decryptChanges) {
const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log));
@ -340,6 +345,10 @@ export class Room extends EventEmitter {
}
let emitChange = false;
if (summaryChanges) {
// if we joined the room, we can't have an invite anymore
if (summaryChanges.membership === "join" && this._summary.data.membership !== "join") {
this._invite = null;
}
this._summary.applyChanges(summaryChanges);
if (!this._summary.data.needsHeroes) {
this._heroes = null;
@ -423,6 +432,14 @@ export class Room extends EventEmitter {
}
}
/** @internal */
setInvite(invite) {
// called when an invite comes in for this room
// (e.g. when we're in membership leave and haven't been archived or forgotten yet)
this._invite = invite;
this._emitUpdate();
}
/** @public */
sendEvent(eventType, content, attachments, log = null) {
this._platform.logger.wrapOrRun(log, "send", log => {
@ -585,6 +602,17 @@ export class Room extends EventEmitter {
return this._summary.data.membership;
}
/**
* The invite for this room, if any.
* This will only be set if you've left a room, and
* don't archive or forget it, and then receive an invite
* for it again
* @return {Invite?}
*/
get invite() {
return this._invite;
}
enableSessionBackup(sessionBackup) {
this._roomEncryption?.enableSessionBackup(sessionBackup);
// TODO: do we really want to do this every time you open the app?

View file

@ -85,7 +85,7 @@ function processRoomAccountData(data, event) {
return data;
}
function processStateEvent(data, event) {
export function processStateEvent(data, event) {
if (event.type === "m.room.encryption") {
const algorithm = event.content?.algorithm;
if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
@ -148,7 +148,19 @@ function updateSummary(data, summary) {
return data;
}
class SummaryData {
function applyInvite(data, invite) {
if (data.isDirectMessage !== invite.isDirectMessage) {
data = data.cloneIfNeeded();
data.isDirectMessage = invite.isDirectMessage;
}
if (data.dmUserId !== invite.inviter?.userId) {
data = data.cloneIfNeeded();
data.dmUserId = invite.inviter?.userId;
}
return data;
}
export class SummaryData {
constructor(copy, roomId) {
this.roomId = copy ? copy.roomId : roomId;
this.name = copy ? copy.name : null;
@ -166,6 +178,8 @@ class SummaryData {
this.notificationCount = copy ? copy.notificationCount : 0;
this.highlightCount = copy ? copy.highlightCount : 0;
this.tags = copy ? copy.tags : null;
this.isDirectMessage = copy ? copy.isDirectMessage : false;
this.dmUserId = copy ? copy.dmUserId : null;
this.cloned = copy ? true : false;
}
@ -202,6 +216,10 @@ class SummaryData {
return applySyncResponse(this, roomResponse, membership);
}
applyInvite(invite) {
return applyInvite(this, invite);
}
get needsHeroes() {
return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0;
}

View file

@ -190,6 +190,26 @@ export class SyncWriter {
return currentKey;
}
async _handleRejoinOverlap(timeline, txn, log) {
if (this._lastLiveKey) {
const {fragmentId} = this._lastLiveKey;
const [lastEvent] = await txn.timelineEvents.lastEvents(this._roomId, fragmentId, 1);
if (lastEvent) {
const lastEventId = lastEvent.event.event_id;
const {events} = timeline;
const index = events.findIndex(event => event.event_id === lastEventId);
if (index !== -1) {
log.set("overlap_event_id", lastEventId);
return {
limited: false,
events: events.slice(index + 1)
};
}
}
}
return timeline;
}
/**
* @type {SyncWriterResult}
* @property {Array<BaseEntry>} entries new timeline entries written
@ -197,12 +217,19 @@ export class SyncWriter {
* @property {Map<string, MemberChange>} memberChanges member changes in the processed sync ny user id
*
* @param {Object} roomResponse [description]
* @param {boolean} isRejoin whether the room was rejoined in the sync being processed
* @param {Transaction} txn
* @return {SyncWriterResult}
*/
async writeSync(roomResponse, txn, log) {
async writeSync(roomResponse, isRejoin, txn, log) {
const entries = [];
const {timeline} = roomResponse;
let {timeline} = roomResponse;
// we have rejoined the room after having synced it before,
// check for overlap with the last synced event
log.set("isRejoin", isRejoin);
if (isRejoin) {
timeline = await this._handleRejoinOverlap(timeline, txn, log);
}
const memberChanges = new Map();
// important this happens before _writeTimeline so
// members are available in the transaction

View file

@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([
"session",
"roomState",
"roomSummary",
"invites",
"roomMembers",
"timelineEvents",
"timelineFragments",

View file

@ -19,6 +19,7 @@ import {StorageError} from "../common.js";
import {Store} from "./Store.js";
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 {RoomStateStore} from "./stores/RoomStateStore.js";
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
@ -64,6 +65,10 @@ export class Transaction {
return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore));
}
get invites() {
return this._store("invites", idbStore => new InviteStore(idbStore));
}
get timelineFragments() {
return this._store("timelineFragments", idbStore => new TimelineFragmentStore(idbStore));
}

View file

@ -11,7 +11,8 @@ export const schema = [
migrateSession,
createE2EEStores,
migrateEncryptionFlag,
createAccountDataStore
createAccountDataStore,
createInviteStore
];
// TODO: how to deal with git merge conflicts of this array?
@ -103,3 +104,8 @@ async function migrateEncryptionFlag(db, txn) {
function createAccountDataStore(db) {
db.createObjectStore("accountData", {keyPath: "type"});
}
// v7
function createInviteStore(db) {
db.createObjectStore("invites", {keyPath: "roomId"});
}

View file

@ -0,0 +1,33 @@
/*
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 InviteStore {
constructor(inviteStore) {
this._inviteStore = inviteStore;
}
getAll() {
return this._inviteStore.selectAll();
}
set(invite) {
return this._inviteStore.put(invite);
}
remove(roomId) {
this._inviteStore.delete(roomId);
}
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import {SortedMapList} from "./list/SortedMapList.js";
import {FilteredMap} from "./map/FilteredMap.js";
import {MappedMap} from "./map/MappedMap.js";
import {JoinedMap} from "./map/JoinedMap.js";
import {BaseObservableMap} from "./map/BaseObservableMap.js";
// re-export "root" (of chain) collections
export { ObservableArray } from "./list/ObservableArray.js";
@ -38,5 +39,9 @@ Object.assign(BaseObservableMap.prototype, {
filterValues(filter) {
return new FilteredMap(this, filter);
},
join(...otherMaps) {
return new JoinedMap([this].concat(otherMaps));
}
});

View file

@ -82,4 +82,8 @@ export class ApplyMap extends BaseObservableMap {
get size() {
return this._source.size;
}
get(key) {
return this._source.get(key);
}
}

View file

@ -49,4 +49,9 @@ export class BaseObservableMap extends BaseObservable {
get size() {
throw new Error("unimplemented");
}
// eslint-disable-next-line no-unused-vars
get(key) {
throw new Error("unimplemented");
}
}

View file

@ -28,26 +28,29 @@ export class FilteredMap extends BaseObservableMap {
setFilter(filter) {
this._filter = filter;
this.update();
if (this._subscription) {
this._reapplyFilter();
}
}
/**
* reapply the filter
*/
update() {
// TODO: need to check if we have a subscriber already? If not, we really should not iterate the source?
_reapplyFilter(silent = false) {
if (this._filter) {
const hadFilterBefore = !!this._included;
const oldIncluded = this._included;
this._included = this._included || new Map();
for (const [key, value] of this._source) {
const isIncluded = this._filter(value, key);
const wasIncluded = hadFilterBefore ? this._included.get(key) : true;
this._included.set(key, isIncluded);
this._emitForUpdate(wasIncluded, isIncluded, key, value);
if (!silent) {
const wasIncluded = oldIncluded ? oldIncluded.get(key) : true;
this._emitForUpdate(wasIncluded, isIncluded, key, value);
}
}
} else { // no filter
// did we have a filter before?
if (this._included) {
if (this._included && !silent) {
// add any non-included items again
for (const [key, value] of this._source) {
if (!this._included.get(key)) {
@ -100,7 +103,7 @@ export class FilteredMap extends BaseObservableMap {
onSubscribeFirst() {
this._subscription = this._source.subscribe(this);
this.update();
this._reapplyFilter(true);
super.onSubscribeFirst();
}
@ -111,7 +114,7 @@ export class FilteredMap extends BaseObservableMap {
}
onReset() {
this.update();
this._reapplyFilter();
this.emitReset();
}
@ -128,12 +131,19 @@ export class FilteredMap extends BaseObservableMap {
});
return count;
}
get(key) {
const value = this._source.get(key);
if (value && this._filter(value, key)) {
return value;
}
}
}
class FilterIterator {
constructor(map, _included) {
this._included = _included;
this._sourceIterator = map.entries();
this._sourceIterator = map[Symbol.iterator]();
}
next() {
@ -143,7 +153,7 @@ class FilterIterator {
if (sourceResult.done) {
return sourceResult;
}
const key = sourceResult.value[1];
const key = sourceResult.value[0];
if (this._included.get(key)) {
return sourceResult;
}
@ -151,26 +161,31 @@ class FilterIterator {
}
}
// import {ObservableMap} from "./ObservableMap.js";
// export function tests() {
// return {
// "filter preloaded list": assert => {
// const source = new ObservableMap();
// source.add("one", 1);
// source.add("two", 2);
// source.add("three", 3);
// const odds = Array.from(new FilteredMap(source, x => x % 2 !== 0));
// assert.equal(odds.length, 2);
import {ObservableMap} from "./ObservableMap.js";
export function tests() {
return {
"filter preloaded list": assert => {
const source = new ObservableMap();
source.add("one", 1);
source.add("two", 2);
source.add("three", 3);
const oddNumbers = new FilteredMap(source, x => x % 2 !== 0);
// can only iterate after subscribing
oddNumbers.subscribe({});
assert.equal(oddNumbers.size, 2);
const it = oddNumbers[Symbol.iterator]();
assert.deepEqual(it.next().value, ["one", 1]);
assert.deepEqual(it.next().value, ["three", 3]);
assert.equal(it.next().done, true);
},
// "filter added values": assert => {
// },
// "filter added values": assert => {
// },
// "filter removed values": assert => {
// },
// "filter removed values": assert => {
// },
// "filter changed values": assert => {
// },
// "filter changed values": assert => {
// },
// }
// }
// },
}
}

View file

@ -0,0 +1,281 @@
/*
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 {BaseObservableMap} from "./BaseObservableMap.js";
export class JoinedMap extends BaseObservableMap {
constructor(sources) {
super();
this._sources = sources;
this._subscriptions = null;
}
onAdd(source, key, value) {
if (!this._isKeyAtSourceOccluded(source, key)) {
const occludingValue = this._getValueFromOccludedSources(source, key);
if (occludingValue !== undefined) {
// adding a value that will occlude another one should
// first emit a remove
this.emitRemove(key, occludingValue);
}
this.emitAdd(key, value);
}
}
onRemove(source, key, value) {
if (!this._isKeyAtSourceOccluded(source, key)) {
this.emitRemove(key, value);
const occludedValue = this._getValueFromOccludedSources(source, key);
if (occludedValue !== undefined) {
// removing a value that so far occluded another one should
// emit an add for the occluded value after the removal
this.emitAdd(key, occludedValue);
}
}
}
onUpdate(source, key, value, params) {
if (!this._isKeyAtSourceOccluded(source, key)) {
this.emitUpdate(key, value, params);
}
}
onReset() {
this.emitReset();
}
onSubscribeFirst() {
this._subscriptions = this._sources.map(source => new SourceSubscriptionHandler(source, this).subscribe());
super.onSubscribeFirst();
}
_isKeyAtSourceOccluded(source, key) {
// sources that come first in the sources array can
// hide the keys in later sources, to prevent events
// being emitted for the same key and different values,
// so check the key is not present in earlier sources
const index = this._sources.indexOf(source);
for (let i = 0; i < index; i += 1) {
if (this._sources[i].get(key) !== undefined) {
return true;
}
}
return false;
}
// get the value that the given source and key occlude, if any
_getValueFromOccludedSources(source, key) {
// sources that come first in the sources array can
// hide the keys in later sources, to prevent events
// being emitted for the same key and different values,
// so check the key is not present in earlier sources
const index = this._sources.indexOf(source);
for (let i = index + 1; i < this._sources.length; i += 1) {
const source = this._sources[i];
const occludedValue = source.get(key);
if (occludedValue !== undefined) {
return occludedValue;
}
}
return undefined;
}
onUnsubscribeLast() {
super.onUnsubscribeLast();
for (const s of this._subscriptions) {
s.dispose();
}
}
[Symbol.iterator]() {
return new JoinedIterator(this._sources);
}
get size() {
return this._sources.reduce((sum, s) => sum + s.size, 0);
}
get(key) {
for (const s of this._sources) {
const value = s.get(key);
if (value) {
return value;
}
}
return null;
}
}
class JoinedIterator {
constructor(sources) {
this._sources = sources;
this._sourceIndex = -1;
this._currentIterator = null;
this._encounteredKeys = new Set();
}
next() {
let result;
while (!result) {
if (!this._currentIterator) {
this._sourceIndex += 1;
if (this._sources.length <= this._sourceIndex) {
return {done: true};
}
this._currentIterator = this._sources[this._sourceIndex][Symbol.iterator]();
}
const sourceResult = this._currentIterator.next();
if (sourceResult.done) {
this._currentIterator = null;
continue;
} else {
const key = sourceResult.value[0];
if (!this._encounteredKeys.has(key)) {
this._encounteredKeys.add(key);
result = sourceResult;
}
}
}
return result;
}
}
class SourceSubscriptionHandler {
constructor(source, joinedMap) {
this._source = source;
this._joinedMap = joinedMap;
this._subscription = null;
}
subscribe() {
this._subscription = this._source.subscribe(this);
return this;
}
dispose() {
this._subscription = this._subscription();
}
onAdd(key, value) {
this._joinedMap.onAdd(this._source, key, value);
}
onRemove(key, value) {
this._joinedMap.onRemove(this._source, key, value);
}
onUpdate(key, value, params) {
this._joinedMap.onUpdate(this._source, key, value, params);
}
onReset() {
this._joinedMap.onReset(this._source);
}
}
import { ObservableMap } from "./ObservableMap.js";
export function tests() {
function observeMap(map) {
const events = [];
map.subscribe({
onAdd(key, value) { events.push({type: "add", key, value}); },
onRemove(key, value) { events.push({type: "remove", key, value}); },
onUpdate(key, value, params) { events.push({type: "update", key, value, params}); }
});
return events;
}
return {
"joined iterator": assert => {
const firstKV = ["a", 1];
const secondKV = ["b", 2];
const thirdKV = ["c", 3];
const it = new JoinedIterator([[firstKV, secondKV], [thirdKV]]);
assert.equal(it.next().value, firstKV);
assert.equal(it.next().value, secondKV);
assert.equal(it.next().value, thirdKV);
assert.equal(it.next().done, true);
},
"prevent key collision during iteration": assert => {
const first = new ObservableMap();
const second = new ObservableMap();
const join = new JoinedMap([first, second]);
second.add("a", 2);
second.add("b", 3);
first.add("a", 1);
const it = join[Symbol.iterator]();
assert.deepEqual(it.next().value, ["a", 1]);
assert.deepEqual(it.next().value, ["b", 3]);
assert.equal(it.next().done, true);
},
"adding occluded key doesn't emit add": assert => {
const first = new ObservableMap();
const second = new ObservableMap();
const join = new JoinedMap([first, second]);
const events = observeMap(join);
first.add("a", 1);
second.add("a", 2);
assert.equal(events.length, 1);
assert.equal(events[0].type, "add");
assert.equal(events[0].key, "a");
assert.equal(events[0].value, 1);
},
"updating occluded key doesn't emit update": assert => {
const first = new ObservableMap();
const second = new ObservableMap();
const join = new JoinedMap([first, second]);
first.add("a", 1);
second.add("a", 2);
const events = observeMap(join);
second.update("a", 3);
assert.equal(events.length, 0);
},
"removal of occluding key emits add after remove": assert => {
const first = new ObservableMap();
const second = new ObservableMap();
const join = new JoinedMap([first, second]);
first.add("a", 1);
second.add("a", 2);
const events = observeMap(join);
first.remove("a");
assert.equal(events.length, 2);
assert.equal(events[0].type, "remove");
assert.equal(events[0].key, "a");
assert.equal(events[0].value, 1);
assert.equal(events[1].type, "add");
assert.equal(events[1].key, "a");
assert.equal(events[1].value, 2);
},
"adding occluding key emits remove first": assert => {
const first = new ObservableMap();
const second = new ObservableMap();
const join = new JoinedMap([first, second]);
second.add("a", 2);
const events = observeMap(join);
first.add("a", 1);
assert.equal(events.length, 2);
assert.equal(events[0].type, "remove");
assert.equal(events[0].key, "a");
assert.equal(events[0].value, 2);
assert.equal(events[1].type, "add");
assert.equal(events[1].key, "a");
assert.equal(events[1].value, 1);
}
};
}

View file

@ -103,12 +103,15 @@ export class AvatarView extends BaseUpdateView {
* @param {Number} size
* @return {Element}
*/
export function renderStaticAvatar(vm, size) {
export function renderStaticAvatar(vm, size, extraClasses = undefined) {
const hasAvatar = !!vm.avatarUrl;
const avatarClasses = classNames({
let avatarClasses = classNames({
avatar: true,
[`usercolor${vm.avatarColorNumber}`]: !hasAvatar,
});
if (extraClasses) {
avatarClasses += ` ${extraClasses}`;
}
const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter);
return tag.div({className: avatarClasses}, [avatarContent]);
}

View file

@ -16,7 +16,6 @@ limitations under the License.
*/
.avatar {
--avatar-size: 32px;
width: var(--avatar-size);
height: var(--avatar-size);
overflow: hidden;
@ -25,7 +24,6 @@ limitations under the License.
line-height: var(--avatar-size);
font-size: calc(var(--avatar-size) * 0.6);
text-align: center;
letter-spacing: calc(var(--avatar-size) * -0.05);
speak: none;
}

View file

@ -100,10 +100,9 @@ main {
position: relative;
}
.RoomView {
min-width: 0;
min-height: 0;
.middle {
display: flex;
flex-direction: column;
}
.SessionStatusView {
@ -122,8 +121,8 @@ main {
height: 100%;
}
.TimelinePanel {
flex: 3;
.RoomView_body {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
@ -131,7 +130,7 @@ main {
height: 100%;
}
.TimelinePanel .Timeline, .TimelinePanel .TimelineLoadingView {
.RoomView_body .Timeline, .RoomView_body .TimelineLoadingView {
flex: 1 0 0;
}

View file

@ -92,12 +92,17 @@ limitations under the License.
display: block;
}
.button-action {
cursor: pointer;
}
a.button-action {
text-decoration: none;
text-align: center;
display: block;
}
.button-action.secondary {
color: #03B381;
}
@ -106,6 +111,11 @@ a.button-action {
background-color: #03B381;
border-radius: 8px;
color: white;
font-weight: bold;
}
.button-action.primary:disabled {
color: #fffa;
}
.button-action.primary.destructive {
@ -245,6 +255,7 @@ a.button-action {
/* make scrollbar hit right edge of parent */
padding-right: 8px;
margin-right: -8px;
--avatar-size: 32px;
}
.RoomList > li {
@ -276,7 +287,7 @@ a.button-action {
}
.RoomList .description {
align-items: baseline;
align-items: center;
}
.RoomList .name.unread {
@ -406,7 +417,7 @@ a {
.middle-header {
box-sizing: border-box;
height: 58px; /* 12 + 36 + 12 to align with filter field + margin */
flex: 0 0 56px; /* 12 + 32 + 12 to align with filter field + margin */
background: white;
padding: 0 16px;
border-bottom: 1px solid rgba(245, 245, 245, 0.90);
@ -429,6 +440,10 @@ a {
background-position-x: 10px;
}
.RoomHeader {
--avatar-size: 32px;
}
.RoomHeader .topic {
font-size: 14rem;
}
@ -822,3 +837,75 @@ button.link {
background-color: #03B381;
color: white;
}
.InviteView_body {
display: flex;
justify-content: space-around;
align-items: center;
flex: 1;
overflow: auto;
}
.InviteView_invite {
display: flex;
width: 100%;
max-width: 400px;
flex-direction: column;
padding: 0 24px;
}
.InviteView_roomProfile {
display: grid;
gap: 4px;
grid-template:
"avatar name" auto
"avatar description" 1fr /
72px 1fr;
align-self: center;
margin-bottom: 24px;
}
.InviteView_roomProfile h3 {
grid-area: name;
margin: 0;
}
.InviteView_roomDescription {
grid-area: description;
font-size: 1.2rem;
margin: 0;
color: #777;
}
.InviteView_roomAvatar {
grid-area: avatar;
--avatar-size: 64px;
}
.InviteView_dmAvatar {
align-self: center;
--avatar-size: 128px;
}
.InviteView_inviter {
text-align: center;
margin: 24px 0px;
}
.InviteView_inviter .avatar {
--avatar-size: 24px;
display: inline-block;
vertical-align: middle;
margin-right: 4px;
}
.InviteView_buttonRow {
margin: 10px auto;
max-width: 200px;
width: 100%;
}
.InviteView_buttonRow button {
display: block;
width: 100%;
}

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
.TimelinePanel ul {
.RoomView_body ul {
overflow-y: auto;
overscroll-behavior: contain;
list-style: none;
@ -23,9 +23,6 @@ limitations under the License.
margin: 0;
}
.TimelinePanel li {
}
.message-container {
flex: 0 1 auto;
/* first try break-all, then break-word, which isn't supported everywhere */

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import {RoomView} from "./room/RoomView.js";
import {InviteView} from "./room/InviteView.js";
import {TemplateView} from "../general/TemplateView.js";
import {StaticView} from "../general/StaticView.js";
@ -30,9 +31,13 @@ export class RoomGridView extends TemplateView {
[`tile${i}`]: true,
"focused": vm => vm.focusIndex === i
},
},t.mapView(vm => vm.roomViewModelAt(i), roomVM => {
}, t.mapView(vm => vm.roomViewModelAt(i), roomVM => {
if (roomVM) {
return new RoomView(roomVM);
if (roomVM.kind === "invite") {
return new InviteView(roomVM);
} else {
return new RoomView(roomVM);
}
} else {
return new StaticView(t => t.div({className: "room-placeholder"}, [
t.h2({className: "focused"}, vm.i18n`Select a room on the left`),

View file

@ -17,6 +17,7 @@ limitations under the License.
import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
import {RoomView} from "./room/RoomView.js";
import {InviteView} from "./room/InviteView.js";
import {LightboxView} from "./room/LightboxView.js";
import {TemplateView} from "../general/TemplateView.js";
import {StaticView} from "../general/StaticView.js";
@ -29,21 +30,24 @@ export class SessionView extends TemplateView {
return t.div({
className: {
"SessionView": true,
"middle-shown": vm => vm.activeSection !== "placeholder"
"middle-shown": vm => !!vm.activeMiddleViewModel
},
}, [
t.view(new SessionStatusView(vm.sessionStatusViewModel)),
t.view(new LeftPanelView(vm.leftPanelViewModel)),
t.mapView(vm => vm.activeSection, activeSection => {
switch (activeSection) {
case "roomgrid":
return new RoomGridView(vm.roomGridViewModel);
case "placeholder":
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
case "settings":
return new SettingsView(vm.settingsViewModel);
default: //room id
t.mapView(vm => vm.activeMiddleViewModel, () => {
if (vm.roomGridViewModel) {
return new RoomGridView(vm.roomGridViewModel);
} else if (vm.settingsViewModel) {
return new SettingsView(vm.settingsViewModel);
} else if (vm.currentRoomViewModel) {
if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel);
} else {
return new RoomView(vm.currentRoomViewModel);
}
} else {
return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`)));
}
}),
t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null)

View file

@ -0,0 +1,44 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 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 {TemplateView} from "../../general/TemplateView.js";
import {renderStaticAvatar} from "../../avatar.js";
import {spinner} from "../../common.js";
export class InviteTileView extends TemplateView {
render(t, vm) {
const classes = {
"active": vm => vm.isOpen,
"hidden": vm => vm.hidden
};
return t.li({"className": classes}, [
t.a({href: vm.url}, [
renderStaticAvatar(vm, 32),
t.div({className: "description"}, [
t.div({className: "name"}, vm.name),
t.map(vm => vm.busy, busy => {
if (busy) {
return spinner(t);
} else {
return t.div({className: "badge highlighted"}, "!");
}
})
])
])
]);
}
}

View file

@ -17,6 +17,7 @@ limitations under the License.
import {ListView} from "../../general/ListView.js";
import {TemplateView} from "../../general/TemplateView.js";
import {RoomTileView} from "./RoomTileView.js";
import {InviteTileView} from "./InviteTileView.js";
class FilterField extends TemplateView {
render(t, options) {
@ -84,9 +85,15 @@ export class LeftPanelView extends TemplateView {
t.view(new ListView(
{
className: "RoomList",
list: vm.roomList,
list: vm.tileViewModels,
},
roomTileVM => new RoomTileView(roomTileVM)
tileVM => {
if (tileVM.kind === "invite") {
return new InviteTileView(tileVM);
} else {
return new RoomTileView(tileVM);
}
}
))
]);
}

View file

@ -0,0 +1,74 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {TemplateView} from "../../general/TemplateView.js";
import {renderStaticAvatar} from "../../avatar.js";
export class InviteView extends TemplateView {
render(t, vm) {
let inviteNodes = [];
if (vm.isDirectMessage) {
inviteNodes.push(renderStaticAvatar(vm, 128, "InviteView_dmAvatar"));
}
let inviterNodes;
if (vm.isDirectMessage) {
inviterNodes = [t.strong(vm.name), ` (${vm.inviter?.id}) wants to chat with you.`];
} else if (vm.inviter) {
inviterNodes = [renderStaticAvatar(vm.inviter, 24), t.strong(vm.inviter.name), ` (${vm.inviter.id}) invited you.`];
} else {
inviterNodes = `You were invited to join.`;
}
inviteNodes.push(t.p({className: "InviteView_inviter"}, inviterNodes));
if (!vm.isDirectMessage) {
inviteNodes.push(t.div({className: "InviteView_roomProfile"}, [
renderStaticAvatar(vm, 64, "InviteView_roomAvatar"),
t.h3(vm.name),
t.p({className: "InviteView_roomDescription"}, vm.roomDescription)
]));
}
return t.main({className: "InviteView middle"}, [
t.div({className: "RoomHeader middle-header"}, [
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}),
renderStaticAvatar(vm, 32),
t.div({className: "room-description"}, [
t.h2(vm => vm.name),
]),
]),
t.if(vm => vm.error, t => t.div({className: "RoomView_error"}, vm => vm.error)),
t.div({className: "InviteView_body"}, [
t.div({className: "InviteView_invite"}, [
...inviteNodes,
t.div({className: "InviteView_buttonRow"},
t.button({
className: "button-action primary",
disabled: vm => vm.busy,
onClick: () => vm.accept()
}, vm.i18n`Accept`)
),
t.div({className: "InviteView_buttonRow"},
t.button({
className: "button-action primary destructive",
disabled: vm => vm.busy,
onClick: () => vm.reject()
}, vm.i18n`Reject`)
),
])
])
]);
}
}

View file

@ -24,21 +24,21 @@ import {AvatarView} from "../../avatar.js";
export class RoomView extends TemplateView {
render(t, vm) {
return t.main({className: "RoomView middle"}, [
t.div({className: "TimelinePanel"}, [
t.div({className: "RoomHeader middle-header"}, [
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}),
t.view(new AvatarView(vm, 32)),
t.div({className: "room-description"}, [
t.h2(vm => vm.name),
]),
t.div({className: "RoomHeader middle-header"}, [
t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}),
t.view(new AvatarView(vm, 32)),
t.div({className: "room-description"}, [
t.h2(vm => vm.name),
]),
]),
t.div({className: "RoomView_body"}, [
t.div({className: "RoomView_error"}, vm => vm.error),
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ?
new TimelineList(timelineViewModel) :
new TimelineLoadingView(vm); // vm is just needed for i18n
}),
t.view(new MessageComposer(this.value.composerViewModel)),
t.view(new MessageComposer(vm.composerViewModel)),
])
]);
}

View file

@ -74,5 +74,54 @@
}));
document.getElementById("session-loading").appendChild(view.mount());
</script>
<h2 name="invite-dm-view">Invite DM view</h2>
<div id="invite-dm-view" style="height: 600px" class="hydrogen"></div>
<script id="main" type="module">
import {InviteView} from "./session/room/InviteView.js";
const view = new InviteView(vm({
busy: false,
name: "Alice",
avatarTitle: "Alice",
avatarColorNumber: 5,
avatarLetter: "A",
error: "",
inviter: {
id: "@alice:hs.tld",
displayName: "Alice",
name: "Alice",
avatarTitle: "Alice",
avatarColorNumber: 5,
avatarLetter: "A",
},
isDirectMessage: true,
showDMProfile: true,
}));
document.getElementById("invite-dm-view").appendChild(view.mount());
</script>
<h2 name="invite-room-view">Invite Room view</h2>
<div id="invite-room-view" style="height: 600px" class="hydrogen"></div>
<script id="main" type="module">
import {InviteView} from "./session/room/InviteView.js";
const view = new InviteView(vm({
busy: false,
name: "Some Room",
avatarTitle: "Some Room",
avatarColorNumber: 2,
avatarLetter: "S",
error: "",
inviter: {
id: "@alice:hs.tld",
displayName: "Alice",
name: "Alice",
avatarTitle: "Alice",
avatarColorNumber: 5,
avatarLetter: "A",
},
roomDescription: "#some-room:hs.tld - public room",
isDirectMessage: false,
showDMProfile: false,
}));
document.getElementById("invite-room-view").appendChild(view.mount());
</script>
</body>
</html>