Merge pull request #654 from vector-im/bwindels/create-room

Create rooms
This commit is contained in:
Bruno Windels 2022-02-11 16:56:24 +01:00 committed by GitHub
commit a184ad528f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
61 changed files with 1384 additions and 277 deletions

View file

@ -32,7 +32,7 @@ function allowsChild(parent, child) {
// allowed root segments // allowed root segments
return type === "login" || type === "session" || type === "sso" || type === "logout"; return type === "login" || type === "session" || type === "sso" || type === "logout";
case "session": case "session":
return type === "room" || type === "rooms" || type === "settings"; return type === "room" || type === "rooms" || type === "settings" || type === "create-room";
case "rooms": case "rooms":
// downside of the approach: both of these will control which tile is selected // downside of the approach: both of these will control which tile is selected
return type === "room" || type === "empty-grid-tile"; return type === "room" || type === "empty-grid-tile";

View file

@ -0,0 +1,144 @@
/*
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 {ViewModel} from "../ViewModel.js";
import {imageToInfo} from "./common.js";
import {RoomType} from "../../matrix/room/common";
export class CreateRoomViewModel extends ViewModel {
constructor(options) {
super(options);
const {session} = options;
this._session = session;
this._name = undefined;
this._topic = undefined;
this._roomAlias = undefined;
this._isPublic = false;
this._isEncrypted = true;
this._isAdvancedShown = false;
this._isFederationDisabled = false;
this._avatarScaledBlob = undefined;
this._avatarFileName = undefined;
this._avatarInfo = undefined;
}
get isPublic() { return this._isPublic; }
get isEncrypted() { return this._isEncrypted; }
get canCreate() { return !!this._name; }
avatarUrl() { return this._avatarScaledBlob.url; }
get avatarTitle() { return this._name; }
get avatarLetter() { return ""; }
get avatarColorNumber() { return 0; }
get hasAvatar() { return !!this._avatarScaledBlob; }
get isFederationDisabled() { return this._isFederationDisabled; }
get isAdvancedShown() { return this._isAdvancedShown; }
setName(name) {
this._name = name;
this.emitChange("canCreate");
}
setRoomAlias(roomAlias) {
this._roomAlias = roomAlias;
}
setTopic(topic) {
this._topic = topic;
}
setPublic(isPublic) {
this._isPublic = isPublic;
this.emitChange("isPublic");
}
setEncrypted(isEncrypted) {
this._isEncrypted = isEncrypted;
this.emitChange("isEncrypted");
}
setFederationDisabled(disable) {
this._isFederationDisabled = disable;
this.emitChange("isFederationDisabled");
}
toggleAdvancedShown() {
this._isAdvancedShown = !this._isAdvancedShown;
this.emitChange("isAdvancedShown");
}
create() {
let avatar;
if (this._avatarScaledBlob) {
avatar = {
info: this._avatarInfo,
name: this._avatarFileName,
blob: this._avatarScaledBlob
}
}
const roomBeingCreated = this._session.createRoom({
type: this.isPublic ? RoomType.Public : RoomType.Private,
name: this._name ?? undefined,
topic: this._topic ?? undefined,
isEncrypted: !this.isPublic && this._isEncrypted,
isFederationDisabled: this._isFederationDisabled,
alias: this.isPublic ? ensureAliasIsLocalPart(this._roomAlias) : undefined,
avatar,
});
this.navigation.push("room", roomBeingCreated.id);
}
async selectAvatar() {
if (!this.platform.hasReadPixelPermission()) {
alert("Please allow canvas image data access, so we can scale your images down.");
return;
}
if (this._avatarScaledBlob) {
this._avatarScaledBlob.dispose();
}
this._avatarScaledBlob = undefined;
this._avatarFileName = undefined;
this._avatarInfo = undefined;
const file = await this.platform.openFile("image/*");
if (!file || !file.blob.mimeType.startsWith("image/")) {
// allow to clear the avatar by not selecting an image
this.emitChange("hasAvatar");
return;
}
let image = await this.platform.loadImage(file.blob);
const limit = 800;
if (image.maxDimension > limit) {
const scaledImage = await image.scale(limit);
image.dispose();
image = scaledImage;
}
this._avatarScaledBlob = image.blob;
this._avatarInfo = imageToInfo(image);
this._avatarFileName = file.name;
this.emitChange("hasAvatar");
}
}
function ensureAliasIsLocalPart(roomAliasLocalPart) {
if (roomAliasLocalPart.startsWith("#")) {
roomAliasLocalPart = roomAliasLocalPart.substr(1);
}
const colonIdx = roomAliasLocalPart.indexOf(":");
if (colonIdx !== -1) {
roomAliasLocalPart = roomAliasLocalPart.substr(0, colonIdx);
}
return roomAliasLocalPart;
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {ObservableValue} from "../../observable/ObservableValue"; import {ObservableValue} from "../../observable/ObservableValue";
import {RoomStatus} from "../../matrix/room/common";
/** /**
Depending on the status of a room (invited, joined, archived, or none), Depending on the status of a room (invited, joined, archived, or none),
@ -34,11 +35,11 @@ the now transferred child view model.
This is also why there is an explicit initialize method, see comment there. This is also why there is an explicit initialize method, see comment there.
*/ */
export class RoomViewModelObservable extends ObservableValue { export class RoomViewModelObservable extends ObservableValue {
constructor(sessionViewModel, roomId) { constructor(sessionViewModel, roomIdOrLocalId) {
super(null); super(null);
this._statusSubscription = null; this._statusSubscription = null;
this._sessionViewModel = sessionViewModel; this._sessionViewModel = sessionViewModel;
this.id = roomId; this.id = roomIdOrLocalId;
} }
/** /**
@ -59,11 +60,21 @@ export class RoomViewModelObservable extends ObservableValue {
} }
async _statusToViewModel(status) { async _statusToViewModel(status) {
if (status.invited) { if (status & RoomStatus.Replaced) {
if (status & RoomStatus.BeingCreated) {
const {session} = this._sessionViewModel._client;
const roomBeingCreated = session.roomsBeingCreated.get(this.id);
this._sessionViewModel.notifyRoomReplaced(roomBeingCreated.id, roomBeingCreated.roomId);
} else {
throw new Error("Don't know how to replace a room with this status: " + (status ^ RoomStatus.Replaced));
}
} else if (status & RoomStatus.BeingCreated) {
return this._sessionViewModel._createRoomBeingCreatedViewModel(this.id);
} else if (status & RoomStatus.Invited) {
return this._sessionViewModel._createInviteViewModel(this.id); return this._sessionViewModel._createInviteViewModel(this.id);
} else if (status.joined) { } else if (status & RoomStatus.Joined) {
return this._sessionViewModel._createRoomViewModel(this.id); return this._sessionViewModel._createRoomViewModelInstance(this.id);
} else if (status.archived) { } else if (status & RoomStatus.Archived) {
return await this._sessionViewModel._createArchivedRoomViewModel(this.id); return await this._sessionViewModel._createArchivedRoomViewModel(this.id);
} else { } else {
return this._sessionViewModel._createUnknownRoomViewModel(this.id); return this._sessionViewModel._createUnknownRoomViewModel(this.id);

View file

@ -19,10 +19,12 @@ import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js";
import {RoomViewModel} from "./room/RoomViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js";
import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js"; import {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js"; import {InviteViewModel} from "./room/InviteViewModel.js";
import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js"; import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {CreateRoomViewModel} from "./CreateRoomViewModel.js";
import {ViewModel} from "../ViewModel.js"; import {ViewModel} from "../ViewModel.js";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
@ -37,13 +39,11 @@ export class SessionViewModel extends ViewModel {
reconnector: client.reconnector, reconnector: client.reconnector,
session: client.session, session: client.session,
}))); })));
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({ this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({session: this._client.session})));
invites: this._client.session.invites,
rooms: this._client.session.rooms
})));
this._settingsViewModel = null; this._settingsViewModel = null;
this._roomViewModelObservable = null; this._roomViewModelObservable = null;
this._gridViewModel = null; this._gridViewModel = null;
this._createRoomViewModel = null;
this._setupNavigation(); this._setupNavigation();
} }
@ -75,6 +75,12 @@ export class SessionViewModel extends ViewModel {
})); }));
this._updateSettings(settings.get()); this._updateSettings(settings.get());
const createRoom = this.navigation.observe("create-room");
this.track(createRoom.subscribe(createRoomOpen => {
this._updateCreateRoom(createRoomOpen);
}));
this._updateCreateRoom(createRoom.get());
const lightbox = this.navigation.observe("lightbox"); const lightbox = this.navigation.observe("lightbox");
this.track(lightbox.subscribe(eventId => { this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId); this._updateLightbox(eventId);
@ -96,7 +102,7 @@ export class SessionViewModel extends ViewModel {
} }
get activeMiddleViewModel() { get activeMiddleViewModel() {
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel; return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel;
} }
get roomGridViewModel() { get roomGridViewModel() {
@ -119,11 +125,14 @@ export class SessionViewModel extends ViewModel {
return this._roomViewModelObservable?.get(); return this._roomViewModelObservable?.get();
} }
get rightPanelViewModel() { get rightPanelViewModel() {
return this._rightPanelViewModel; return this._rightPanelViewModel;
} }
get createRoomViewModel() {
return this._createRoomViewModel;
}
_updateGrid(roomIds) { _updateGrid(roomIds) {
const changed = !(this._gridViewModel && roomIds); const changed = !(this._gridViewModel && roomIds);
const currentRoomId = this.navigation.path.get("room"); const currentRoomId = this.navigation.path.get("room");
@ -162,7 +171,7 @@ export class SessionViewModel extends ViewModel {
} }
} }
_createRoomViewModel(roomId) { _createRoomViewModelInstance(roomId) {
const room = this._client.session.rooms.get(roomId); const room = this._client.session.rooms.get(roomId);
if (room) { if (room) {
const roomVM = new RoomViewModel(this.childOptions({room})); const roomVM = new RoomViewModel(this.childOptions({room}));
@ -200,6 +209,17 @@ export class SessionViewModel extends ViewModel {
return null; return null;
} }
_createRoomBeingCreatedViewModel(localId) {
const roomBeingCreated = this._client.session.roomsBeingCreated.get(localId);
if (roomBeingCreated) {
return new RoomBeingCreatedViewModel(this.childOptions({
roomBeingCreated,
mediaRepository: this._client.session.mediaRepository,
}));
}
return null;
}
_updateRoom(roomId) { _updateRoom(roomId) {
// opening a room and already open? // opening a room and already open?
if (this._roomViewModelObservable?.id === roomId) { if (this._roomViewModelObservable?.id === roomId) {
@ -237,6 +257,16 @@ export class SessionViewModel extends ViewModel {
this.emitChange("activeMiddleViewModel"); this.emitChange("activeMiddleViewModel");
} }
_updateCreateRoom(createRoomOpen) {
if (this._createRoomViewModel) {
this._createRoomViewModel = this.disposeTracked(this._createRoomViewModel);
}
if (createRoomOpen) {
this._createRoomViewModel = this.track(new CreateRoomViewModel(this.childOptions({session: this._client.session})));
}
this.emitChange("activeMiddleViewModel");
}
_updateLightbox(eventId) { _updateLightbox(eventId) {
if (this._lightboxViewModel) { if (this._lightboxViewModel) {
this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel); this._lightboxViewModel = this.disposeTracked(this._lightboxViewModel);
@ -263,9 +293,12 @@ export class SessionViewModel extends ViewModel {
const enable = !!this.navigation.path.get("right-panel")?.value; const enable = !!this.navigation.path.get("right-panel")?.value;
if (enable) { if (enable) {
const room = this._roomFromNavigation(); const room = this._roomFromNavigation();
this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room}))); this._rightPanelViewModel = this.track(new RightPanelViewModel(this.childOptions({room, session: this._client.session})));
} }
this.emitChange("rightPanelViewModel"); this.emitChange("rightPanelViewModel");
} }
notifyRoomReplaced(oldId, newId) {
this.navigation.push("room", newId);
}
} }

View file

@ -0,0 +1,24 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export function imageToInfo(image) {
return {
w: image.width,
h: image.height,
mimetype: image.blob.mimeType,
size: image.blob.size
};
}

View file

@ -18,7 +18,7 @@ limitations under the License.
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
const KIND_ORDER = ["invite", "room"]; const KIND_ORDER = ["roomBeingCreated", "invite", "room"];
export class BaseTileViewModel extends ViewModel { export class BaseTileViewModel extends ViewModel {
constructor(options) { constructor(options) {

View file

@ -1,5 +1,4 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020, 2021 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"); Licensed under the Apache License, Version 2.0 (the "License");
@ -16,6 +15,7 @@ limitations under the License.
*/ */
import {BaseTileViewModel} from "./BaseTileViewModel.js"; import {BaseTileViewModel} from "./BaseTileViewModel.js";
import {comparePrimitive} from "./common";
export class InviteTileViewModel extends BaseTileViewModel { export class InviteTileViewModel extends BaseTileViewModel {
constructor(options) { constructor(options) {
@ -25,31 +25,40 @@ export class InviteTileViewModel extends BaseTileViewModel {
this._url = this.urlCreator.openRoomActionUrl(this._invite.id); this._url = this.urlCreator.openRoomActionUrl(this._invite.id);
} }
get busy() { get busy() { return this._invite.accepting || this._invite.rejecting; }
return this._invite.accepting || this._invite.rejecting; get kind() { return "invite"; }
} get url() { return this._url; }
get name() { return this._invite.name; }
get kind() { get isHighlighted() { return true; }
return "invite"; get isUnread() { return true; }
} get badgeCount() { return this.i18n`!`; }
get _avatarSource() { return this._invite; }
get url() {
return this._url;
}
/** very important that sorting order is stable and that comparing
* to itself always returns 0, otherwise SortedMapList will
* remove the wrong children, etc ... */
compare(other) { compare(other) {
const parentComparison = super.compare(other); const parentComparison = super.compare(other);
if (parentComparison !== 0) { if (parentComparison !== 0) {
return parentComparison; return parentComparison;
} }
return other._invite.timestamp - this._invite.timestamp; const timeDiff = other._invite.timestamp - this._invite.timestamp;
if (timeDiff !== 0) {
return timeDiff;
} }
return comparePrimitive(this._invite.id, other._invite.id);
get name() { }
return this._invite.name; }
}
export function tests() {
get _avatarSource() { return {
return this._invite; "test compare with timestamp": assert => {
const urlCreator = {openRoomActionUrl() { return "";}}
const vm1 = new InviteTileViewModel({invite: {timestamp: 500, id: "1"}, urlCreator});
const vm2 = new InviteTileViewModel({invite: {timestamp: 250, id: "2"}, urlCreator});
assert(vm1.compare(vm2) < 0);
assert(vm2.compare(vm1) > 0);
assert.equal(vm1.compare(vm1), 0);
},
} }
} }

View file

@ -18,6 +18,7 @@ limitations under the License.
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
import {RoomTileViewModel} from "./RoomTileViewModel.js"; import {RoomTileViewModel} from "./RoomTileViewModel.js";
import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js"; import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js";
import {addPanelIfNeeded} from "../../navigation/index.js"; import {addPanelIfNeeded} from "../../navigation/index.js";
@ -25,32 +26,36 @@ import {addPanelIfNeeded} from "../../navigation/index.js";
export class LeftPanelViewModel extends ViewModel { export class LeftPanelViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {rooms, invites} = options; const {session} = options;
this._tileViewModelsMap = this._mapTileViewModels(rooms, invites); this._tileViewModelsMap = this._mapTileViewModels(session.roomsBeingCreated, session.invites, session.rooms);
this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap); this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap);
this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b)); this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b));
this._currentTileVM = null; this._currentTileVM = null;
this._setupNavigation(); this._setupNavigation();
this._closeUrl = this.urlCreator.urlForSegment("session"); this._closeUrl = this.urlCreator.urlForSegment("session");
this._settingsUrl = this.urlCreator.urlForSegment("settings"); this._settingsUrl = this.urlCreator.urlForSegment("settings");
this._createRoomUrl = this.urlCreator.urlForSegment("create-room");
} }
_mapTileViewModels(rooms, invites) { _mapTileViewModels(roomsBeingCreated, invites, rooms) {
// join is not commutative, invites will take precedence over rooms // join is not commutative, invites will take precedence over rooms
return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { const allTiles = invites.join(roomsBeingCreated, rooms).mapValues((item, emitChange) => {
let vm; let vm;
if (roomOrInvite.isInvite) { if (item.isBeingCreated) {
vm = new InviteTileViewModel(this.childOptions({invite: roomOrInvite, emitChange})); vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange}));
} else if (item.isInvite) {
vm = new InviteTileViewModel(this.childOptions({invite: item, emitChange}));
} else { } else {
vm = new RoomTileViewModel(this.childOptions({room: roomOrInvite, emitChange})); vm = new RoomTileViewModel(this.childOptions({room: item, emitChange}));
} }
const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; const isOpen = this.navigation.path.get("room")?.value === item.id;
if (isOpen) { if (isOpen) {
vm.open(); vm.open();
this._updateCurrentVM(vm); this._updateCurrentVM(vm);
} }
return vm; return vm;
}); });
return allTiles;
} }
_updateCurrentVM(vm) { _updateCurrentVM(vm) {
@ -69,6 +74,8 @@ export class LeftPanelViewModel extends ViewModel {
return this._settingsUrl; return this._settingsUrl;
} }
get createRoomUrl() { return this._createRoomUrl; }
_setupNavigation() { _setupNavigation() {
const roomObservable = this.navigation.observe("room"); const roomObservable = this.navigation.observe("room");
this.track(roomObservable.subscribe(roomId => this._open(roomId))); this.track(roomObservable.subscribe(roomId => this._open(roomId)));

View file

@ -0,0 +1,70 @@
/*
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";
import {comparePrimitive} from "./common";
export class RoomBeingCreatedTileViewModel extends BaseTileViewModel {
constructor(options) {
super(options);
const {roomBeingCreated} = options;
this._roomBeingCreated = roomBeingCreated;
this._url = this.urlCreator.openRoomActionUrl(this._roomBeingCreated.id);
}
get busy() { return !this._roomBeingCreated.error; }
get kind() { return "roomBeingCreated"; }
get isHighlighted() { return !this.busy; }
get badgeCount() { return !this.busy && this.i18n`Failed`; }
get url() { return this._url; }
get name() { return this._roomBeingCreated.name; }
get _avatarSource() { return this._roomBeingCreated; }
/** very important that sorting order is stable and that comparing
* to itself always returns 0, otherwise SortedMapList will
* remove the wrong children, etc ... */
compare(other) {
const parentCmp = super.compare(other);
if (parentCmp !== 0) {
return parentCmp;
}
const nameCmp = comparePrimitive(this.name, other.name);
if (nameCmp === 0) {
return comparePrimitive(this._roomBeingCreated.id, other._roomBeingCreated.id);
} else {
return nameCmp;
}
}
avatarUrl(size) {
// allow blob url which doesn't need mxc => http resolution
return this._roomBeingCreated.avatarBlobUrl ?? super.avatarUrl(size);
}
}
export function tests() {
return {
"test compare with names": assert => {
const urlCreator = {openRoomActionUrl() { return "";}}
const vm1 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "A", id: "1"}, urlCreator});
const vm2 = new RoomBeingCreatedTileViewModel({roomBeingCreated: {name: "B", id: "2"}, urlCreator});
assert(vm1.compare(vm2) < 0);
assert(vm2.compare(vm1) > 0);
assert.equal(vm1.compare(vm1), 0);
},
}
}

View file

@ -33,6 +33,9 @@ export class RoomTileViewModel extends BaseTileViewModel {
return this._url; return this._url;
} }
/** very important that sorting order is stable and that comparing
* to itself always returns 0, otherwise SortedMapList will
* remove the wrong children, etc ... */
compare(other) { compare(other) {
const parentComparison = super.compare(other); const parentComparison = super.compare(other);
if (parentComparison !== 0) { if (parentComparison !== 0) {

View file

@ -0,0 +1,23 @@
/*
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.
*/
export function comparePrimitive(a, b) {
if (a === b) {
return 0;
} else {
return a < b ? -1 : 1;
}
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
import {RoomType} from "../../../matrix/room/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
export class MemberDetailsViewModel extends ViewModel { export class MemberDetailsViewModel extends ViewModel {
@ -25,6 +26,7 @@ export class MemberDetailsViewModel extends ViewModel {
this._member = this._observableMember.get(); this._member = this._observableMember.get();
this._isEncrypted = options.isEncrypted; this._isEncrypted = options.isEncrypted;
this._powerLevelsObservable = options.powerLevelsObservable; this._powerLevelsObservable = options.powerLevelsObservable;
this._session = options.session;
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange())); this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
this.track(this._observableMember.subscribe( () => this._onMemberChange())); this.track(this._observableMember.subscribe( () => this._onMemberChange()));
} }
@ -77,6 +79,19 @@ export class MemberDetailsViewModel extends ViewModel {
} }
get linkToUser() { get linkToUser() {
return `https://matrix.to/#/${this._member.userId}`; return `https://matrix.to/#/${encodeURIComponent(this._member.userId)}`;
}
async openDirectMessage() {
const room = this._session.findDirectMessageForUserId(this.userId);
let roomId = room?.id;
if (!roomId) {
const roomBeingCreated = await this._session.createRoom({
type: RoomType.DirectMessage,
invites: [this.userId]
});
roomId = roomBeingCreated.id;
}
this.navigation.push("room", roomId);
} }
} }

View file

@ -23,6 +23,7 @@ export class RightPanelViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this._room = options.room; this._room = options.room;
this._session = options.session;
this._members = null; this._members = null;
this._setupNavigation(); this._setupNavigation();
} }
@ -48,7 +49,13 @@ export class RightPanelViewModel extends ViewModel {
} }
const isEncrypted = this._room.isEncrypted; const isEncrypted = this._room.isEncrypted;
const powerLevelsObservable = await this._room.observePowerLevels(); const powerLevelsObservable = await this._room.observePowerLevels();
return {observableMember, isEncrypted, powerLevelsObservable, mediaRepository: this._room.mediaRepository}; return {
observableMember,
isEncrypted,
powerLevelsObservable,
mediaRepository: this._room.mediaRepository,
session: this._session
};
} }
_setupNavigation() { _setupNavigation() {

View file

@ -1,9 +1,17 @@
# "Room" view models # "Room" view models
InviteViewModel and RoomViewModel are interchangebly used as "room view model": InviteViewModel, RoomViewModel and RoomBeingCreatedViewModel are interchangebly used as "room view model":
- SessionViewModel.roomViewModel can be an instance of either - SessionViewModel.roomViewModel can be an instance of any
- RoomGridViewModel.roomViewModelAt(i) can return an instance of either - RoomGridViewModel.roomViewModelAt(i) can return an instance of any
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. 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. They share an `id` and `kind` property, the latter can be used to differentiate them from the view, and a `focus` method.
Once we convert this folder to typescript, we should use this interface for all the view models:
```ts
interface IGridItemViewModel {
id: string;
kind: string;
focus();
}
```

View file

@ -0,0 +1,75 @@
/*
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, getAvatarHttpUrl} from "../../avatar.js";
import {ViewModel} from "../../ViewModel.js";
export class RoomBeingCreatedViewModel extends ViewModel {
constructor(options) {
super(options);
const {roomBeingCreated, mediaRepository} = options;
this._roomBeingCreated = roomBeingCreated;
this._mediaRepository = mediaRepository;
this._onRoomChange = this._onRoomChange.bind(this);
this._closeUrl = this.urlCreator.urlUntilSegment("session");
this._roomBeingCreated.on("change", this._onRoomChange);
}
get kind() { return "roomBeingCreated"; }
get closeUrl() { return this._closeUrl; }
get name() { return this._roomBeingCreated.name; }
get id() { return this._roomBeingCreated.id; }
get isEncrypted() { return this._roomBeingCreated.isEncrypted; }
get error() {
const {error} = this._roomBeingCreated;
if (error) {
if (error.name === "ConnectionError") {
return this.i18n`You seem to be offline`;
} else {
return error.message;
}
}
return "";
}
get avatarLetter() { return avatarInitials(this.name); }
get avatarColorNumber() { return getIdentifierColorNumber(this._roomBeingCreated.avatarColorId); }
get avatarTitle() { return this.name; }
avatarUrl(size) {
// allow blob url which doesn't need mxc => http resolution
return this._roomBeingCreated.avatarBlobUrl ??
getAvatarHttpUrl(this._roomBeingCreated.avatarUrl, size, this.platform, this._mediaRepository);
}
focus() {}
_onRoomChange() {
this.emitChange();
}
cancel() {
this._roomBeingCreated.cancel();
// navigate away from the room
this.navigation.applyPath(this.navigation.path.until("session"));
}
dispose() {
super.dispose();
this._roomBeingCreated.off("change", this._onRoomChange);
}
}

View file

@ -20,6 +20,7 @@ import {ComposerViewModel} from "./ComposerViewModel.js"
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
import {tilesCreator} from "./timeline/tilesCreator.js"; import {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel.js"; import {ViewModel} from "../../ViewModel.js";
import {imageToInfo} from "../common.js";
export class RoomViewModel extends ViewModel { export class RoomViewModel extends ViewModel {
constructor(options) { constructor(options) {
@ -97,9 +98,8 @@ export class RoomViewModel extends ViewModel {
// room doesn't tell us yet which fields changed, // room doesn't tell us yet which fields changed,
// so emit all fields originating from summary // so emit all fields originating from summary
_onRoomChange() { _onRoomChange() {
if (this._room.isArchived) { // propagate the update to the child view models so it's bindings can update based on room changes
this._composerVM.emitChange(); this._composerVM.emitChange();
}
this.emitChange(); this.emitChange();
} }
@ -274,7 +274,9 @@ export class RoomViewModel extends ViewModel {
let image = await this.platform.loadImage(file.blob); let image = await this.platform.loadImage(file.blob);
const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
if (limit && image.maxDimension > limit) { if (limit && image.maxDimension > limit) {
image = await image.scale(limit); const scaledImage = await image.scale(limit);
image.dispose();
image = scaledImage;
} }
const content = { const content = {
body: file.name, body: file.name,
@ -320,15 +322,6 @@ export class RoomViewModel extends ViewModel {
} }
} }
function imageToInfo(image) {
return {
w: image.width,
h: image.height,
mimetype: image.blob.mimeType,
size: image.blob.size
};
}
function videoToInfo(video) { function videoToInfo(video) {
const info = imageToInfo(video); const info = imageToInfo(video);
info.duration = video.duration; info.duration = video.duration;

View file

@ -108,13 +108,14 @@ export class LogItem implements ILogItem {
return item; return item;
} }
set(key: string | object, value?: unknown): void { set(key: string | object, value?: unknown): ILogItem {
if(typeof key === "object") { if(typeof key === "object") {
const values = key; const values = key;
Object.assign(this._values, values); Object.assign(this._values, values);
} else { } else {
this._values[key] = value; this._values[key] = value;
} }
return this;
} }
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined { serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined {

View file

@ -67,7 +67,7 @@ export class NullLogItem implements ILogItem {
log(): ILogItem { log(): ILogItem {
return this; return this;
} }
set(): void {} set(): ILogItem { return this; }
runDetached(_: LabelOrValues, callback: LogCallback<unknown>): ILogItem { runDetached(_: LabelOrValues, callback: LogCallback<unknown>): ILogItem {
new Promise(r => r(callback(this))).then(noop, noop); new Promise(r => r(callback(this))).then(noop, noop);

View file

@ -43,7 +43,7 @@ export interface ILogItem {
readonly values: LogItemValues; readonly values: LogItemValues;
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T; wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem; log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
set(key: string | object, value: unknown): void; set(key: string | object, value: unknown): ILogItem;
runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem; runDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): void; wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): void;
refDetached(logItem: ILogItem, logLevel?: LogLevel): void; refDetached(logItem: ILogItem, logLevel?: LogLevel): void;

View file

@ -17,7 +17,8 @@ limitations under the License.
import {Room} from "./room/Room.js"; import {Room} from "./room/Room.js";
import {ArchivedRoom} from "./room/ArchivedRoom.js"; import {ArchivedRoom} from "./room/ArchivedRoom.js";
import {RoomStatus} from "./room/RoomStatus.js"; import {RoomStatus} from "./room/common";
import {RoomBeingCreated} from "./room/RoomBeingCreated";
import {Invite} from "./room/Invite.js"; import {Invite} from "./room/Invite.js";
import {Pusher} from "./push/Pusher"; import {Pusher} from "./push/Pusher";
import { ObservableMap } from "../observable/index.js"; import { ObservableMap } from "../observable/index.js";
@ -63,6 +64,14 @@ export class Session {
this._activeArchivedRooms = new Map(); this._activeArchivedRooms = new Map();
this._invites = new ObservableMap(); this._invites = new ObservableMap();
this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params);
this._roomsBeingCreatedUpdateCallback = (rbc, params) => {
if (rbc.isCancelled) {
this._roomsBeingCreated.remove(rbc.id);
} else {
this._roomsBeingCreated.update(rbc.id, params)
}
};
this._roomsBeingCreated = new ObservableMap();
this._user = new User(sessionInfo.userId); this._user = new User(sessionInfo.userId);
this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._deviceMessageHandler = new DeviceMessageHandler({storage});
this._olm = olm; this._olm = olm;
@ -421,7 +430,7 @@ export class Session {
// load rooms // load rooms
const rooms = await txn.roomSummary.getAll(); const rooms = await txn.roomSummary.getAll();
const roomLoadPromise = Promise.all(rooms.map(async summary => { const roomLoadPromise = Promise.all(rooms.map(async summary => {
const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); const room = this.createJoinedRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId));
await 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); this._rooms.add(room.id, room);
})); }));
@ -529,8 +538,21 @@ export class Session {
return this._rooms; return this._rooms;
} }
findDirectMessageForUserId(userId) {
for (const [,room] of this._rooms) {
if (room.isDirectMessageForUserId(userId)) {
return room;
}
}
for (const [,invite] of this._invites) {
if (invite.isDirectMessageForUserId(userId)) {
return invite;
}
}
}
/** @internal */ /** @internal */
createRoom(roomId, pendingEvents) { createJoinedRoom(roomId, pendingEvents) {
return new Room({ return new Room({
roomId, roomId,
getSyncToken: this._getSyncToken, getSyncToken: this._getSyncToken,
@ -580,6 +602,36 @@ export class Session {
}); });
} }
get roomsBeingCreated() {
return this._roomsBeingCreated;
}
createRoom(options) {
let roomBeingCreated;
this._platform.logger.runDetached("create room", async log => {
const id = `local-${Math.floor(this._platform.random() * Number.MAX_SAFE_INTEGER)}`;
roomBeingCreated = new RoomBeingCreated(
id, options, this._roomsBeingCreatedUpdateCallback,
this._mediaRepository, this._platform, log);
this._roomsBeingCreated.set(id, roomBeingCreated);
const promises = [roomBeingCreated.create(this._hsApi, log)];
const loadProfiles = options.loadProfiles !== false; // default to true
if (loadProfiles) {
promises.push(roomBeingCreated.loadProfiles(this._hsApi, log));
}
await Promise.all(promises);
// we should now know the roomId, check if the room was synced before we received
// the room id. Replace the room being created with the synced room.
if (roomBeingCreated.roomId) {
if (this.rooms.get(roomBeingCreated.roomId)) {
this._tryReplaceRoomBeingCreated(roomBeingCreated.roomId, log);
}
await roomBeingCreated.adjustDirectMessageMapIfNeeded(this._user, this._storage, this._hsApi, log);
}
});
return roomBeingCreated;
}
async obtainSyncLock(syncResponse) { async obtainSyncLock(syncResponse) {
const toDeviceEvents = syncResponse.to_device?.events; const toDeviceEvents = syncResponse.to_device?.events;
if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) { if (Array.isArray(toDeviceEvents) && toDeviceEvents.length) {
@ -662,11 +714,29 @@ export class Session {
} }
} }
applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates) { _tryReplaceRoomBeingCreated(roomId, log) {
for (const [,roomBeingCreated] of this._roomsBeingCreated) {
if (roomBeingCreated.roomId === roomId) {
const observableStatus = this._observedRoomStatus.get(roomBeingCreated.id);
if (observableStatus) {
log.log(`replacing room being created`)
.set("localId", roomBeingCreated.id)
.set("roomId", roomBeingCreated.roomId);
observableStatus.set(observableStatus.get() | RoomStatus.Replaced);
}
roomBeingCreated.dispose();
this._roomsBeingCreated.remove(roomBeingCreated.id);
return;
}
}
}
applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates, log) {
// update the collections after sync // update the collections after sync
for (const rs of roomStates) { for (const rs of roomStates) {
if (rs.shouldAdd) { if (rs.shouldAdd) {
this._rooms.add(rs.id, rs.room); this._rooms.add(rs.id, rs.room);
this._tryReplaceRoomBeingCreated(rs.id, log);
} else if (rs.shouldRemove) { } else if (rs.shouldRemove) {
this._rooms.remove(rs.id); this._rooms.remove(rs.id);
} }
@ -684,21 +754,23 @@ export class Session {
if (this._observedRoomStatus.size !== 0) { if (this._observedRoomStatus.size !== 0) {
for (const ars of archivedRoomStates) { for (const ars of archivedRoomStates) {
if (ars.shouldAdd) { if (ars.shouldAdd) {
this._observedRoomStatus.get(ars.id)?.set(RoomStatus.archived); this._observedRoomStatus.get(ars.id)?.set(RoomStatus.Archived);
} }
} }
for (const rs of roomStates) { for (const rs of roomStates) {
if (rs.shouldAdd) { if (rs.shouldAdd) {
this._observedRoomStatus.get(rs.id)?.set(RoomStatus.joined); this._observedRoomStatus.get(rs.id)?.set(RoomStatus.Joined);
} }
} }
for (const is of inviteStates) { for (const is of inviteStates) {
const statusObservable = this._observedRoomStatus.get(is.id); const statusObservable = this._observedRoomStatus.get(is.id);
if (statusObservable) { if (statusObservable) {
const withInvited = statusObservable.get() | RoomStatus.Invited;
if (is.shouldAdd) { if (is.shouldAdd) {
statusObservable.set(statusObservable.get().withInvited()); statusObservable.set(withInvited);
} else if (is.shouldRemove) { } else if (is.shouldRemove) {
statusObservable.set(statusObservable.get().withoutInvited()); const withoutInvited = withInvited ^ RoomStatus.Invited;
statusObservable.set(withoutInvited);
} }
} }
} }
@ -708,7 +780,7 @@ export class Session {
_forgetArchivedRoom(roomId) { _forgetArchivedRoom(roomId) {
const statusObservable = this._observedRoomStatus.get(roomId); const statusObservable = this._observedRoomStatus.get(roomId);
if (statusObservable) { if (statusObservable) {
statusObservable.set(statusObservable.get().withoutArchived()); statusObservable.set((statusObservable.get() | RoomStatus.Archived) ^ RoomStatus.Archived);
} }
} }
@ -797,21 +869,25 @@ export class Session {
} }
async getRoomStatus(roomId) { async getRoomStatus(roomId) {
const isBeingCreated = !!this._roomsBeingCreated.get(roomId);
if (isBeingCreated) {
return RoomStatus.BeingCreated;
}
const isJoined = !!this._rooms.get(roomId); const isJoined = !!this._rooms.get(roomId);
if (isJoined) { if (isJoined) {
return RoomStatus.joined; return RoomStatus.Joined;
} else { } else {
const isInvited = !!this._invites.get(roomId); const isInvited = !!this._invites.get(roomId);
const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]); const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]);
const isArchived = await txn.archivedRoomSummary.has(roomId); const isArchived = await txn.archivedRoomSummary.has(roomId);
if (isInvited && isArchived) { if (isInvited && isArchived) {
return RoomStatus.invitedAndArchived; return RoomStatus.Invited | RoomStatus.Archived;
} else if (isInvited) { } else if (isInvited) {
return RoomStatus.invited; return RoomStatus.Invited;
} else if (isArchived) { } else if (isArchived) {
return RoomStatus.archived; return RoomStatus.Archived;
} else { } else {
return RoomStatus.none; return RoomStatus.None;
} }
} }
} }
@ -823,6 +899,7 @@ export class Session {
observable = new RetainedObservableValue(status, () => { observable = new RetainedObservableValue(status, () => {
this._observedRoomStatus.delete(roomId); this._observedRoomStatus.delete(roomId);
}); });
this._observedRoomStatus.set(roomId, observable); this._observedRoomStatus.set(roomId, observable);
} }
return observable; return observable;

View file

@ -249,7 +249,7 @@ export class Sync {
if (!isRoomInResponse) { if (!isRoomInResponse) {
let room = this._session.rooms.get(roomId); let room = this._session.rooms.get(roomId);
if (room) { if (room) {
roomStates.push(new RoomSyncProcessState(room, false, null, {}, room.membership)); roomStates.push(new RoomSyncProcessState(room, false, {}, room.membership));
} }
} }
} }
@ -264,7 +264,7 @@ export class Sync {
await rs.room.load(null, prepareTxn, log); await rs.room.load(null, prepareTxn, log);
} }
return rs.room.prepareSync( return rs.room.prepareSync(
rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log) rs.roomResponse, rs.membership, newKeys, prepareTxn, log)
}, log.level.Detail); }, log.level.Detail);
})); }));
@ -316,7 +316,7 @@ export class Sync {
for(let is of inviteStates) { for(let is of inviteStates) {
log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail); log.wrap("invite", log => is.invite.afterSync(is.changes, log), log.level.Detail);
} }
this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates); this._session.applyRoomCollectionChangesAfterSync(inviteStates, roomStates, archivedRoomStates, log);
} }
_openSyncTxn() { _openSyncTxn() {
@ -366,7 +366,7 @@ export class Sync {
if (invite) { if (invite) {
inviteStates.push(new InviteSyncProcessState(invite, false, null, membership)); inviteStates.push(new InviteSyncProcessState(invite, false, null, membership));
} }
const roomState = this._createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync); const roomState = this._createRoomSyncState(roomId, roomResponse, membership, isInitialSync);
if (roomState) { if (roomState) {
roomStates.push(roomState); roomStates.push(roomState);
} }
@ -381,7 +381,7 @@ export class Sync {
return {roomStates, archivedRoomStates}; return {roomStates, archivedRoomStates};
} }
_createRoomSyncState(roomId, invite, roomResponse, membership, isInitialSync) { _createRoomSyncState(roomId, roomResponse, membership, isInitialSync) {
let isNewRoom = false; let isNewRoom = false;
let room = this._session.rooms.get(roomId); let room = this._session.rooms.get(roomId);
// create room only either on new join, // create room only either on new join,
@ -392,12 +392,12 @@ export class Sync {
// we receive also gets written. // we receive also gets written.
// In any case, don't create a room for a rejected invite // In any case, don't create a room for a rejected invite
if (!room && (membership === "join" || (isInitialSync && membership === "leave"))) { if (!room && (membership === "join" || (isInitialSync && membership === "leave"))) {
room = this._session.createRoom(roomId); room = this._session.createJoinedRoom(roomId);
isNewRoom = true; isNewRoom = true;
} }
if (room) { if (room) {
return new RoomSyncProcessState( return new RoomSyncProcessState(
room, isNewRoom, invite, roomResponse, membership); room, isNewRoom, roomResponse, membership);
} }
} }
@ -468,10 +468,9 @@ class SessionSyncProcessState {
} }
class RoomSyncProcessState { class RoomSyncProcessState {
constructor(room, isNewRoom, invite, roomResponse, membership) { constructor(room, isNewRoom, roomResponse, membership) {
this.room = room; this.room = room;
this.isNewRoom = isNewRoom; this.isNewRoom = isNewRoom;
this.invite = invite;
this.roomResponse = roomResponse; this.roomResponse = roomResponse;
this.membership = membership; this.membership = membership;
this.preparation = null; this.preparation = null;

View file

@ -57,3 +57,15 @@ export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Ke
return false; return false;
} }
} }
export function createRoomEncryptionEvent() {
return {
"type": "m.room.encryption",
"state_key": "",
"content": {
"algorithm": MEGOLM_ALGORITHM,
"rotation_period_ms": 604800000,
"rotation_period_msgs": 100
}
}
}

View file

@ -279,20 +279,32 @@ export class HomeServerApi {
return this._post(`/logout`, {}, {}, options); return this._post(`/logout`, {}, {}, options);
} }
getDehydratedDevice(options: BaseRequestOptions): IHomeServerRequest { getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest {
options.prefix = DEHYDRATION_PREFIX; options.prefix = DEHYDRATION_PREFIX;
return this._get(`/dehydrated_device`, undefined, undefined, options); return this._get(`/dehydrated_device`, undefined, undefined, options);
} }
createDehydratedDevice(payload: Record<string, any>, options: BaseRequestOptions): IHomeServerRequest { createDehydratedDevice(payload: Record<string, any>, options: BaseRequestOptions = {}): IHomeServerRequest {
options.prefix = DEHYDRATION_PREFIX; options.prefix = DEHYDRATION_PREFIX;
return this._put(`/dehydrated_device`, {}, payload, options); return this._put(`/dehydrated_device`, {}, payload, options);
} }
claimDehydratedDevice(deviceId: string, options: BaseRequestOptions): IHomeServerRequest { claimDehydratedDevice(deviceId: string, options: BaseRequestOptions = {}): IHomeServerRequest {
options.prefix = DEHYDRATION_PREFIX; options.prefix = DEHYDRATION_PREFIX;
return this._post(`/dehydrated_device/claim`, {}, {device_id: deviceId}, options); return this._post(`/dehydrated_device/claim`, {}, {device_id: deviceId}, options);
} }
profile(userId: string, options?: BaseRequestOptions): IHomeServerRequest {
return this._get(`/profile/${encodeURIComponent(userId)}`);
}
createRoom(payload: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._post(`/createRoom`, {}, payload, options);
}
setAccountData(ownUserId: string, type: string, content: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
return this._put(`/user/${encodeURIComponent(ownUserId)}/account_data/${encodeURIComponent(type)}`, {}, content, options);
}
} }
import {Request as MockRequest} from "../../mocks/Request.js"; import {Request as MockRequest} from "../../mocks/Request.js";

51
src/matrix/profile.ts Normal file
View file

@ -0,0 +1,51 @@
/*
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 type {HomeServerApi} from "./net/HomeServerApi";
import type {ILogItem} from "../logging/types";
export async function loadProfiles(userIds: string[], hsApi: HomeServerApi, log: ILogItem): Promise<Profile[]> {
const profiles = await Promise.all(userIds.map(async userId => {
const response = await hsApi.profile(userId, {log}).response();
return new Profile(userId, response.displayname as string, response.avatar_url as string);
}));
profiles.sort((a, b) => a.name.localeCompare(b.name));
return profiles;
}
export interface IProfile {
get userId(): string;
get displayName(): string | undefined;
get avatarUrl(): string | undefined;
get name(): string;
}
export class Profile implements IProfile {
constructor(
public readonly userId: string,
public readonly displayName: string,
public readonly avatarUrl: string | undefined
) {}
get name() { return this.displayName || this.userId; }
}
export class UserIdProfile implements IProfile {
constructor(public readonly userId: string) {}
get displayName() { return undefined; }
get name() { return this.userId; }
get avatarUrl() { return undefined; }
}

View file

@ -40,17 +40,15 @@ export class AttachmentUpload {
return this._sentBytes; return this._sentBytes;
} }
/** @public */
abort() { abort() {
this._uploadRequest?.abort(); this._uploadRequest?.abort();
} }
/** @public */
get localPreview() { get localPreview() {
return this._unencryptedBlob; return this._unencryptedBlob;
} }
/** @package */ /** @internal */
async encrypt() { async encrypt() {
if (this._encryptionInfo) { if (this._encryptionInfo) {
throw new Error("already encrypted"); throw new Error("already encrypted");
@ -60,7 +58,7 @@ export class AttachmentUpload {
this._encryptionInfo = info; this._encryptionInfo = info;
} }
/** @package */ /** @internal */
async upload(hsApi, progressCallback, log) { async upload(hsApi, progressCallback, log) {
this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, { this._uploadRequest = hsApi.uploadAttachment(this._transferredBlob, this._filename, {
uploadProgress: sentBytes => { uploadProgress: sentBytes => {
@ -73,7 +71,7 @@ export class AttachmentUpload {
this._mxcUrl = content_uri; this._mxcUrl = content_uri;
} }
/** @package */ /** @internal */
applyToContent(urlPath, content) { applyToContent(urlPath, content) {
if (!this._mxcUrl) { if (!this._mxcUrl) {
throw new Error("upload has not finished"); throw new Error("upload has not finished");

View file

@ -420,6 +420,21 @@ export class BaseRoom extends EventEmitter {
return this._summary.data.membership; return this._summary.data.membership;
} }
isDirectMessageForUserId(userId) {
if (this._summary.data.dmUserId === userId) {
return true;
} else {
// fall back to considering any room a DM containing heroes (e.g. no name) and 2 members,
// on of which the userId we're looking for.
// We need this because we're not yet processing m.direct account data correctly.
const {heroes, joinCount, inviteCount} = this._summary.data;
if (heroes && heroes.includes(userId) && (joinCount + inviteCount) === 2) {
return true;
}
}
return false;
}
async _loadPowerLevels() { async _loadPowerLevels() {
const txn = await this._storage.readTxn([this._storage.storeNames.roomState]); const txn = await this._storage.readTxn([this._storage.storeNames.roomState]);
const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", ""); const powerLevelsState = await txn.roomState.get(this._roomId, "m.room.power_levels", "");

View file

@ -73,6 +73,10 @@ export class Invite extends EventEmitter {
return this._inviter; return this._inviter;
} }
isDirectMessageForUserId(userId) {
return this.isDirectMessage && this._inviter.userId === userId;
}
get isPublic() { get isPublic() {
return this._inviteData.joinRule === "public"; return this._inviteData.joinRule === "public";
} }
@ -184,7 +188,7 @@ export class Invite extends EventEmitter {
return { return {
roomId: this.id, roomId: this.id,
isEncrypted: !!summaryData.encryption, isEncrypted: !!summaryData.encryption,
isDirectMessage: this._isDirectMessage(myInvite), isDirectMessage: summaryData.isDirectMessage,
// type: // type:
name, name,
avatarUrl, avatarUrl,
@ -196,12 +200,8 @@ export class Invite extends EventEmitter {
}; };
} }
_isDirectMessage(myInvite) {
return !!(myInvite?.content?.is_direct);
}
_createSummaryData(inviteState) { _createSummaryData(inviteState) {
return inviteState.reduce(processStateEvent, new SummaryData(null, this.id)); return inviteState.reduce((data, event) => processStateEvent(data, event, this._user.id), new SummaryData(null, this.id));
} }
async _createHeroes(inviteState, log) { async _createHeroes(inviteState, log) {

View file

@ -54,15 +54,12 @@ export class Room extends BaseRoom {
return false; return false;
} }
async prepareSync(roomResponse, membership, invite, newKeys, txn, log) { async prepareSync(roomResponse, membership, newKeys, txn, log) {
log.set("id", this.id); log.set("id", this.id);
if (newKeys) { if (newKeys) {
log.set("newKeys", newKeys.length); log.set("newKeys", newKeys.length);
} }
let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership, this._user.id);
if (membership === "join" && invite) {
summaryChanges = summaryChanges.applyInvite(invite);
}
let roomEncryption = this._roomEncryption; let roomEncryption = this._roomEncryption;
// encryption is enabled in this sync // encryption is enabled in this sync
if (!roomEncryption && summaryChanges.encryption) { if (!roomEncryption && summaryChanges.encryption) {

View file

@ -0,0 +1,258 @@
/*
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 {calculateRoomName} from "./members/Heroes";
import {createRoomEncryptionEvent} from "../e2ee/common";
import {MediaRepository} from "../net/MediaRepository";
import {EventEmitter} from "../../utils/EventEmitter";
import {AttachmentUpload} from "./AttachmentUpload";
import {loadProfiles, Profile, UserIdProfile} from "../profile";
import {RoomType} from "./common";
import type {HomeServerApi} from "../net/HomeServerApi";
import type {ILogItem} from "../../logging/types";
import type {Platform} from "../../platform/web/Platform";
import type {IBlobHandle} from "../../platform/types/types";
import type {User} from "../User";
import type {Storage} from "../storage/idb/Storage";
type CreateRoomPayload = {
is_direct?: boolean;
preset?: string;
name?: string;
topic?: string;
invite?: string[];
room_alias_name?: string;
creation_content?: {"m.federate": boolean};
initial_state: {type: string; state_key: string; content: Record<string, any>}[]
}
type ImageInfo = {
w: number;
h: number;
mimetype: string;
size: number;
}
type Avatar = {
info: ImageInfo;
blob: IBlobHandle;
name: string;
}
type Options = {
type: RoomType;
isEncrypted?: boolean;
isFederationDisabled?: boolean;
name?: string;
topic?: string;
invites?: string[];
avatar?: Avatar;
alias?: string;
}
function defaultE2EEStatusForType(type: RoomType): boolean {
switch (type) {
case RoomType.DirectMessage:
case RoomType.Private:
return true;
case RoomType.Public:
return false;
}
}
function presetForType(type: RoomType): string {
switch (type) {
case RoomType.DirectMessage:
return "trusted_private_chat";
case RoomType.Private:
return "private_chat";
case RoomType.Public:
return "public_chat";
}
}
export class RoomBeingCreated extends EventEmitter<{change: never}> {
private _roomId?: string;
private profiles: Profile[] = [];
public readonly isEncrypted: boolean;
private _calculatedName: string;
private _error?: Error;
private _isCancelled = false;
constructor(
public readonly id: string,
private readonly options: Options,
private readonly updateCallback: (self: RoomBeingCreated, params: string | undefined) => void,
public readonly mediaRepository: MediaRepository,
public readonly platform: Platform,
log: ILogItem
) {
super();
this.isEncrypted = options.isEncrypted === undefined ? defaultE2EEStatusForType(options.type) : options.isEncrypted;
if (options.name) {
this._calculatedName = options.name;
} else {
const summaryData = {
joinCount: 1, // ourselves
inviteCount: (options.invites?.length || 0)
};
const userIdProfiles = (options.invites || []).map(userId => new UserIdProfile(userId));
this._calculatedName = calculateRoomName(userIdProfiles, summaryData, log);
}
}
/** @internal */
async create(hsApi: HomeServerApi, log: ILogItem): Promise<void> {
try {
let avatarEventContent;
if (this.options.avatar) {
const {avatar} = this.options;
const attachment = new AttachmentUpload({filename: avatar.name, blob: avatar.blob, platform: this.platform});
await attachment.upload(hsApi, () => {}, log);
avatarEventContent = {
info: avatar.info
};
attachment.applyToContent("url", avatarEventContent);
}
const createOptions: CreateRoomPayload = {
is_direct: this.options.type === RoomType.DirectMessage,
preset: presetForType(this.options.type),
initial_state: []
};
if (this.options.name) {
createOptions.name = this.options.name;
}
if (this.options.topic) {
createOptions.topic = this.options.topic;
}
if (this.options.invites) {
createOptions.invite = this.options.invites;
}
if (this.options.alias) {
createOptions.room_alias_name = this.options.alias;
}
if (this.options.isFederationDisabled === true) {
createOptions.creation_content = {
"m.federate": false
};
}
if (this.isEncrypted) {
createOptions.initial_state.push(createRoomEncryptionEvent());
}
if (avatarEventContent) {
createOptions.initial_state.push({
type: "m.room.avatar",
state_key: "",
content: avatarEventContent
});
}
const response = await hsApi.createRoom(createOptions, {log}).response();
this._roomId = response["room_id"];
} catch (err) {
this._error = err;
}
this.emitChange();
}
/** requests the profiles of the invitees if needed to give an accurate
* estimated room name in case an explicit room name is not set.
* The room is being created in the background whether this is called
* or not, and this just gives a more accurate name while that request
* is running. */
/** @internal */
async loadProfiles(hsApi: HomeServerApi, log: ILogItem): Promise<void> {
try {
// only load profiles if we need it for the room name and avatar
if (!this.options.name && this.options.invites) {
this.profiles = await loadProfiles(this.options.invites, hsApi, log);
const summaryData = {
joinCount: 1, // ourselves
inviteCount: this.options.invites.length
};
this._calculatedName = calculateRoomName(this.profiles, summaryData, log);
this.emitChange();
}
} catch (err) {} // swallow error, loading profiles is not essential
}
private emitChange(params?: string) {
this.updateCallback(this, params);
this.emit("change");
}
get avatarColorId(): string { return this.options.invites?.[0] ?? this._roomId ?? this.id; }
get avatarUrl(): string | undefined { return this.profiles?.[0]?.avatarUrl; }
get avatarBlobUrl(): string | undefined { return this.options.avatar?.blob?.url; }
get roomId(): string | undefined { return this._roomId; }
get name() { return this._calculatedName; }
get isBeingCreated(): boolean { return true; }
get error(): Error | undefined { return this._error; }
cancel() {
if (!this._isCancelled) {
this.dispose();
this._isCancelled = true;
this.emitChange("isCancelled");
}
}
// called from Session when updateCallback is invoked to remove it from the collection
get isCancelled() { return this._isCancelled; }
/** @internal */
dispose() {
if (this.options.avatar) {
this.options.avatar.blob.dispose();
}
}
async adjustDirectMessageMapIfNeeded(user: User, storage: Storage, hsApi: HomeServerApi, log: ILogItem): Promise<void> {
if (!this.options.invites || this.options.type !== RoomType.DirectMessage) {
return;
}
const userId = this.options.invites[0];
const DM_MAP_TYPE = "m.direct";
await log.wrap("set " + DM_MAP_TYPE, async log => {
try {
const txn = await storage.readWriteTxn([storage.storeNames.accountData]);
let mapEntry;
try {
mapEntry = await txn.accountData.get(DM_MAP_TYPE);
if (!mapEntry) {
mapEntry = {type: DM_MAP_TYPE, content: {}};
}
const map = mapEntry.content;
let userRooms = map[userId];
if (!userRooms) {
map[userId] = userRooms = [];
}
// this is a new room id so no need to check if it's already there
userRooms.push(this._roomId);
txn.accountData.set(mapEntry);
await txn.complete();
} catch (err) {
txn.abort();
throw err;
}
await hsApi.setAccountData(user.id, DM_MAP_TYPE, mapEntry.content, {log}).response();
} catch (err) {
// we can't really do anything else but logging here
log.catch(err);
}
});
}
}

View file

@ -1,61 +0,0 @@
/*
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 RoomStatus {
constructor(joined, invited, archived) {
this.joined = joined;
this.invited = invited;
this.archived = archived;
}
withInvited() {
if (this.invited) {
return this;
} else if (this.archived) {
return RoomStatus.invitedAndArchived;
} else {
return RoomStatus.invited;
}
}
withoutInvited() {
if (!this.invited) {
return this;
} else if (this.joined) {
return RoomStatus.joined;
} else if (this.archived) {
return RoomStatus.archived;
} else {
return RoomStatus.none;
}
}
withoutArchived() {
if (!this.archived) {
return this;
} else if (this.invited) {
return RoomStatus.invited;
} else {
return RoomStatus.none;
}
}
}
RoomStatus.joined = new RoomStatus(true, false, false);
RoomStatus.archived = new RoomStatus(false, false, true);
RoomStatus.invited = new RoomStatus(false, true, false);
RoomStatus.invitedAndArchived = new RoomStatus(false, true, true);
RoomStatus.none = new RoomStatus(false, false, false);

View file

@ -46,7 +46,7 @@ export function reduceStateEvents(roomResponse, callback, value) {
return value; return value;
} }
function applySyncResponse(data, roomResponse, membership) { function applySyncResponse(data, roomResponse, membership, ownUserId) {
if (roomResponse.summary) { if (roomResponse.summary) {
data = updateSummary(data, roomResponse.summary); data = updateSummary(data, roomResponse.summary);
} }
@ -60,7 +60,7 @@ function applySyncResponse(data, roomResponse, membership) {
// process state events in state and in timeline. // process state events in state and in timeline.
// non-state events are handled by applyTimelineEntries // non-state events are handled by applyTimelineEntries
// so decryption is handled properly // so decryption is handled properly
data = reduceStateEvents(roomResponse, processStateEvent, data); data = reduceStateEvents(roomResponse, (data, event) => processStateEvent(data, event, ownUserId), data);
const unreadNotifications = roomResponse.unread_notifications; const unreadNotifications = roomResponse.unread_notifications;
if (unreadNotifications) { if (unreadNotifications) {
data = processNotificationCounts(data, unreadNotifications); data = processNotificationCounts(data, unreadNotifications);
@ -95,8 +95,11 @@ function processRoomAccountData(data, event) {
return data; return data;
} }
export function processStateEvent(data, event) { export function processStateEvent(data, event, ownUserId) {
if (event.type === "m.room.encryption") { if (event.type === "m.room.create") {
data = data.cloneIfNeeded();
data.lastMessageTimestamp = event.origin_server_ts;
} else if (event.type === "m.room.encryption") {
const algorithm = event.content?.algorithm; const algorithm = event.content?.algorithm;
if (!data.encryption && algorithm === MEGOLM_ALGORITHM) { if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
@ -118,6 +121,25 @@ export function processStateEvent(data, event) {
const content = event.content; const content = event.content;
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.canonicalAlias = content.alias; data.canonicalAlias = content.alias;
} else if (event.type === "m.room.member") {
const content = event.content;
if (content.is_direct === true && content.membership === "invite" && !data.isDirectMessage) {
let other;
if (event.sender === ownUserId) {
other = event.state_key;
} else if (event.state_key === ownUserId) {
other = event.sender;
}
if (other) {
data = data.cloneIfNeeded();
data.isDirectMessage = true;
data.dmUserId = other;
}
} else if (content.membership === "leave" && data.isDirectMessage && data.dmUserId === event.state_key) {
data = data.cloneIfNeeded();
data.isDirectMessage = false;
data.dmUserId = null;
}
} }
return data; return data;
} }
@ -158,19 +180,6 @@ function updateSummary(data, summary) {
return data; return data;
} }
function applyInvite(data, invite) {
if (data.isDirectMessage !== invite.isDirectMessage) {
data = data.cloneIfNeeded();
data.isDirectMessage = invite.isDirectMessage;
if (invite.isDirectMessage) {
data.dmUserId = invite.inviter?.userId;
} else {
data.dmUserId = null;
}
}
return data;
}
export class SummaryData { export class SummaryData {
constructor(copy, roomId) { constructor(copy, roomId) {
this.roomId = copy ? copy.roomId : roomId; this.roomId = copy ? copy.roomId : roomId;
@ -227,12 +236,8 @@ export class SummaryData {
return applyTimelineEntries(this, timelineEntries, isInitialSync, canMarkUnread, ownUserId); return applyTimelineEntries(this, timelineEntries, isInitialSync, canMarkUnread, ownUserId);
} }
applySyncResponse(roomResponse, membership) { applySyncResponse(roomResponse, membership, ownUserId) {
return applySyncResponse(this, roomResponse, membership); return applySyncResponse(this, roomResponse, membership, ownUserId);
}
applyInvite(invite) {
return applyInvite(this, invite);
} }
get needsHeroes() { get needsHeroes() {

View file

@ -25,3 +25,18 @@ export const REDACTION_TYPE = "m.room.redaction";
export function isRedacted(event) { export function isRedacted(event) {
return !!event?.unsigned?.redacted_because; return !!event?.unsigned?.redacted_because;
} }
export enum RoomStatus {
None = 1 << 0,
BeingCreated = 1 << 1,
Invited = 1 << 2,
Joined = 1 << 3,
Replaced = 1 << 4,
Archived = 1 << 5,
}
export enum RoomType {
DirectMessage,
Private,
Public
}

View file

@ -16,7 +16,7 @@ limitations under the License.
import {RoomMember} from "./RoomMember.js"; import {RoomMember} from "./RoomMember.js";
function calculateRoomName(sortedMembers, summaryData, log) { export function calculateRoomName(sortedMembers, summaryData, log) {
const countWithoutMe = summaryData.joinCount + summaryData.inviteCount - 1; const countWithoutMe = summaryData.joinCount + summaryData.inviteCount - 1;
if (sortedMembers.length >= countWithoutMe) { if (sortedMembers.length >= countWithoutMe) {
if (sortedMembers.length > 1) { if (sortedMembers.length > 1) {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {getPrevContentFromStateEvent} from "../common.js"; import {getPrevContentFromStateEvent} from "../common";
export const EVENT_TYPE = "m.room.member"; export const EVENT_TYPE = "m.room.member";

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {createEnum} from "../../../utils/enum"; import {createEnum} from "../../../utils/enum";
import {AbortError} from "../../../utils/error"; import {AbortError} from "../../../utils/error";
import {REDACTION_TYPE} from "../common.js"; import {REDACTION_TYPE} from "../common";
import {getRelationFromContent, getRelationTarget, setRelationTarget} from "../timeline/relations.js"; import {getRelationFromContent, getRelationTarget, setRelationTarget} from "../timeline/relations.js";
export const SendStatus = createEnum( export const SendStatus = createEnum(

View file

@ -18,7 +18,7 @@ import {SortedArray} from "../../../observable/list/SortedArray";
import {ConnectionError} from "../../error.js"; import {ConnectionError} from "../../error.js";
import {PendingEvent, SendStatus} from "./PendingEvent.js"; import {PendingEvent, SendStatus} from "./PendingEvent.js";
import {makeTxnId, isTxnId} from "../../common.js"; import {makeTxnId, isTxnId} from "../../common.js";
import {REDACTION_TYPE} from "../common.js"; import {REDACTION_TYPE} from "../common";
import {getRelationFromContent, getRelationTarget, setRelationTarget, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js"; import {getRelationFromContent, getRelationTarget, setRelationTarget, REACTION_TYPE, ANNOTATION_RELATION_TYPE} from "../timeline/relations.js";
export class SendQueue { export class SendQueue {

View file

@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {RoomMember} from "../members/RoomMember.js"; import {RoomMember} from "../members/RoomMember.js";
import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js"; import {getRelation, ANNOTATION_RELATION_TYPE} from "./relations.js";
import {REDACTION_TYPE} from "../common.js"; import {REDACTION_TYPE} from "../common";
import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js"; import {NonPersistedEventEntry} from "./entries/NonPersistedEventEntry.js";
import {DecryptionSource} from "../../e2ee/common.js"; import {DecryptionSource} from "../../e2ee/common.js";
import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js"; import {EVENT_TYPE as MEMBER_EVENT_TYPE} from "../members/RoomMember.js";

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {BaseEntry} from "./BaseEntry"; import {BaseEntry} from "./BaseEntry";
import {REDACTION_TYPE} from "../../common.js"; import {REDACTION_TYPE} from "../../common";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js"; import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js";
import {PendingAnnotation} from "../PendingAnnotation.js"; import {PendingAnnotation} from "../PendingAnnotation.js";
import {createReplyContent} from "./reply.js" import {createReplyContent} from "./reply.js"

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {BaseEventEntry} from "./BaseEventEntry.js"; import {BaseEventEntry} from "./BaseEventEntry.js";
import {getPrevContentFromStateEvent, isRedacted} from "../../common.js"; import {getPrevContentFromStateEvent, isRedacted} from "../../common";
import {getRelationFromContent, getRelatedEventId} from "../relations.js"; import {getRelationFromContent, getRelatedEventId} from "../relations.js";
export class EventEntry extends BaseEventEntry { export class EventEntry extends BaseEventEntry {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {EventEntry} from "../entries/EventEntry.js"; import {EventEntry} from "../entries/EventEntry.js";
import {REDACTION_TYPE, isRedacted} from "../../common.js"; import {REDACTION_TYPE, isRedacted} from "../../common";
import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js"; import {ANNOTATION_RELATION_TYPE, getRelation} from "../relations.js";
import {redactEvent} from "../common.js"; import {redactEvent} from "../common.js";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {REDACTION_TYPE} from "../common.js"; import {REDACTION_TYPE} from "../common";
export const REACTION_TYPE = "m.reaction"; export const REACTION_TYPE = "m.reaction";
export const ANNOTATION_RELATION_TYPE = "m.annotation"; export const ANNOTATION_RELATION_TYPE = "m.annotation";

View file

@ -0,0 +1,70 @@
/*
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 {BaseObservableMap} from "./BaseObservableMap.js";
export class LogMap extends BaseObservableMap {
constructor(source, log) {
super();
this._source = source;
this.log = log;
this._subscription = null;
}
onAdd(key, value) {
this.log("add", key, value);
this.emitAdd(key, value);
}
onRemove(key, value) {
this.log("remove", key, value);
this.emitRemove(key, value);
}
onUpdate(key, value, params) {
this.log("update", key, value, params);
this.emitUpdate(key, value, params);
}
onSubscribeFirst() {
this.log("subscribeFirst");
this._subscription = this._source.subscribe(this);
super.onSubscribeFirst();
}
onUnsubscribeLast() {
super.onUnsubscribeLast();
this._subscription = this._subscription();
this.log("unsubscribeLast");
}
onReset() {
this.log("reset");
this.emitReset();
}
[Symbol.iterator]() {
return this._source[Symbol.iterator]();
}
get size() {
return this._source.size;
}
get(key) {
return this._source.get(key);
}
}

View file

@ -29,3 +29,17 @@ export interface IRequestOptions {
} }
export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult; export type RequestFunction = (url: string, options: IRequestOptions) => RequestResult;
export interface IBlobHandle {
nativeBlob: any;
url: string;
size: number;
mimeType: string;
readAsBuffer(): BufferSource;
dispose()
}
export type File = {
readonly name: string;
readonly blob: IBlobHandle;
}

View file

@ -34,6 +34,7 @@ limitations under the License.
.hydrogen .avatar img { .hydrogen .avatar img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
} }
/* work around postcss-css-variables limitations and repeat variable usage */ /* work around postcss-css-variables limitations and repeat variable usage */

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.form input { .form-row.text > input, .form-row.text > textarea {
display: block; display: block;
width: 100%; width: 100%;
min-width: 0; min-width: 0;

View file

@ -49,7 +49,7 @@ main {
grid-template: grid-template:
"status status" auto "status status" auto
"left middle" 1fr / "left middle" 1fr /
300px 1fr; 320px 1fr;
min-height: 0; min-height: 0;
min-width: 0; min-width: 0;
} }
@ -58,7 +58,7 @@ main {
grid-template: grid-template:
"status status status" auto "status status status" auto
"left middle right" 1fr / "left middle right" 1fr /
300px 1fr 300px; 320px 1fr 300px;
} }
/* resize and reposition session view to account for mobile Safari which shifts /* resize and reposition session view to account for mobile Safari which shifts
@ -216,3 +216,14 @@ the layout viewport up without resizing it when the keyboard shows */
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.vertical-layout {
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.vertical-layout > .stretch {
flex: 1 0 0;
}

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.74986 3.55554C8.74986 3.14133 8.41408 2.80554 7.99986 2.80554C7.58565 2.80554 7.24986 3.14133 7.24986 3.55554V7.24999L3.55542 7.24999C3.14121 7.24999 2.80542 7.58577 2.80542 7.99999C2.80542 8.4142 3.14121 8.74999 3.55542 8.74999L7.24987 8.74999V12.4444C7.24987 12.8586 7.58565 13.1944 7.99987 13.1944C8.41408 13.1944 8.74987 12.8586 8.74987 12.4444V8.74999L12.4443 8.74999C12.8585 8.74999 13.1943 8.4142 13.1943 7.99999C13.1943 7.58577 12.8585 7.24999 12.4443 7.24999L8.74986 7.24999V3.55554Z" fill="#8E99A4"/>
</svg>

After

Width:  |  Height:  |  Size: 670 B

View file

@ -80,22 +80,43 @@ limitations under the License.
flex: 1 0 auto; flex: 1 0 auto;
} }
.form-row.text textarea {
font-family: "Inter", sans-serif;
}
.form-group {
margin: 24px 0;
}
.form-row { .form-row {
margin: 12px 0; margin: 12px 0;
} }
.form-row input { .form-row.text > input, .form-row.text > textarea {
padding: 12px; padding: 12px;
border: 1px solid rgba(141, 151, 165, 0.15); border: 1px solid rgba(141, 151, 165, 0.15);
border-radius: 8px; border-radius: 8px;
margin-top: 5px; margin-top: 5px;
font-size: 1em; font-size: 1em;
resize: vertical;
} }
.form-row label, .form-row input { .form-row.check {
display: flex;
align-items: baseline;
gap: 4px;
}
.form-row.text > label, .form-row.text > input {
display: block; display: block;
} }
.form-row .form-row-description {
font-size: 1rem;
color: #777;
margin: 8px 0 0 0;
}
.button-action { .button-action {
cursor: pointer; cursor: pointer;
} }
@ -157,6 +178,10 @@ a.button-action {
background-image: url('icons/settings.svg'); background-image: url('icons/settings.svg');
} }
.button-utility.create {
background-image: url('icons/plus.svg');
}
.button-utility.grid.on { .button-utility.grid.on {
background-image: url('icons/disable-grid.svg'); background-image: url('icons/disable-grid.svg');
} }
@ -1049,10 +1074,14 @@ button.RoomDetailsView_row::after {
flex-direction: column; flex-direction: column;
} }
.MemberDetailsView_options a{ .MemberDetailsView_options a, .MemberDetailsView_options button {
color: #0dbd8b; color: #0dbd8b;
text-decoration: none; text-decoration: none;
margin-bottom: 3px; margin: 0 0 3px 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
} }
.LazyListParent { .LazyListParent {
@ -1075,3 +1104,35 @@ button.RoomDetailsView_row::after {
display: flex; display: flex;
gap: 12px; gap: 12px;
} }
.CreateRoomView, .RoomBeingCreated_error {
max-width: 400px;
}
.RoomBeingCreated_error {
margin-top: 48px;
}
.centered-column {
padding: 0 12px;
align-self: center;
width: 100%;
box-sizing: border-box;
}
.CreateRoomView_selectAvatar {
border: none;
background: none;
cursor: pointer;
}
.CreateRoomView_selectAvatarPlaceholder {
width: 64px;
height: 64px;
border-radius: 100%;
background-color: #e1e3e6;
background-image: url('icons/plus.svg');
background-repeat: no-repeat;
background-position: center;
background-size: 36px;
}

View file

@ -14,11 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {TemplateView} from "./TemplateView"; import {StaticView} from "./StaticView";
import {spinner} from "../common.js"; import {spinner} from "../common.js";
export class LoadingView extends TemplateView { export class LoadingView extends StaticView {
render(t) { constructor(label = "Loading") {
return t.div({ className: "LoadingView" }, [spinner(t), "Loading"]); super(label, (t, label) => {
return t.div({ className: "LoadingView" }, [spinner(t), label]);
});
} }
} }

View file

@ -34,7 +34,7 @@ export class LoginView extends TemplateView {
t.div({className: "logo"}), t.div({className: "logo"}),
t.h1([vm.i18n`Sign In`]), t.h1([vm.i18n`Sign In`]),
t.mapView(vm => vm.completeSSOLoginViewModel, vm => vm ? new CompleteSSOView(vm) : null), t.mapView(vm => vm.completeSSOLoginViewModel, vm => vm ? new CompleteSSOView(vm) : null),
t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form form-row" }, t.if(vm => vm.showHomeserver, (t, vm) => t.div({ className: "LoginView_sso form-row text" },
[ [
t.label({for: "homeserver"}, vm.i18n`Homeserver`), t.label({for: "homeserver"}, vm.i18n`Homeserver`),
t.input({ t.input({

View file

@ -41,8 +41,8 @@ export class PasswordLoginView extends TemplateView {
} }
}, [ }, [
t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))), t.if(vm => vm.errorMessage, (t, vm) => t.p({className: "error"}, vm.i18n(vm.errorMessage))),
t.div({ className: "form-row" }, [t.label({ for: "username" }, vm.i18n`Username`), username]), t.div({ className: "form-row text" }, [t.label({ for: "username" }, vm.i18n`Username`), username]),
t.div({ className: "form-row" }, [t.label({ for: "password" }, vm.i18n`Password`), password]), t.div({ className: "form-row text" }, [t.label({ for: "password" }, vm.i18n`Password`), password]),
t.div({ className: "button-row" }, [ t.div({ className: "button-row" }, [
t.button({ t.button({
className: "button-action primary", className: "button-action primary",

View file

@ -0,0 +1,120 @@
/*
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";
import {AvatarView} from "../AvatarView";
import {StaticView} from "../general/StaticView";
export class CreateRoomView extends TemplateView {
render(t, vm) {
return t.main({className: "middle"},
t.div({className: "CreateRoomView centered-column"}, [
t.h2("Create room"),
//t.div({className: "RoomView_error"}, vm => vm.error),
t.form({className: "CreateRoomView_detailsForm form", onChange: evt => this.onFormChange(evt), onSubmit: evt => this.onSubmit(evt)}, [
t.div({className: "vertical-layout"}, [
t.button({type: "button", className: "CreateRoomView_selectAvatar", onClick: () => vm.selectAvatar()},
t.mapView(vm => vm.hasAvatar, hasAvatar => {
if (hasAvatar) {
return new AvatarView(vm, 64);
} else {
return new StaticView(undefined, t => {
return t.div({className: "CreateRoomView_selectAvatarPlaceholder"})
});
}
})
),
t.div({className: "stretch form-row text"}, [
t.label({for: "name"}, vm.i18n`Room name`),
t.input({
onInput: evt => vm.setName(evt.target.value),
type: "text", name: "name", id: "name",
placeholder: vm.i18n`Enter a room name`
}),
]),
]),
t.div({className: "form-row text"}, [
t.label({for: "topic"}, vm.i18n`Topic (optional)`),
t.textarea({
onInput: evt => vm.setTopic(evt.target.value),
name: "topic", id: "topic",
placeholder: vm.i18n`Topic`
}),
]),
t.div({className: "form-group"}, [
t.div({className: "form-row check"}, [
t.input({type: "radio", name: "isPublic", id: "isPrivate", value: "false", checked: !vm.isPublic}),
t.label({for: "isPrivate"}, vm.i18n`Private room, only upon invitation.`)
]),
t.div({className: "form-row check"}, [
t.input({type: "radio", name: "isPublic", id: "isPublic", value: "true", checked: vm.isPublic}),
t.label({for: "isPublic"}, vm.i18n`Public room, anyone can join`)
]),
]),
t.div({className: {"form-row check": true, hidden: vm => vm.isPublic}}, [
t.input({type: "checkbox", name: "isEncrypted", id: "isEncrypted", checked: vm.isEncrypted}),
t.label({for: "isEncrypted"}, vm.i18n`Enable end-to-end encryption`)
]),
t.div({className: {"form-row text": true, hidden: vm => !vm.isPublic}}, [
t.label({for: "roomAlias"}, vm.i18n`Room alias`),
t.input({
onInput: evt => vm.setRoomAlias(evt.target.value),
type: "text", name: "roomAlias", id: "roomAlias",
placeholder: vm.i18n`Room alias (<alias>, or #<alias> or #<alias>:hs.tld`}),
]),
t.div({className: "form-group"}, [
t.div(t.button({className: "link", type: "button", onClick: () => vm.toggleAdvancedShown()},
vm => vm.isAdvancedShown ? vm.i18n`Hide advanced settings` : vm.i18n`Show advanced settings`)),
t.div({className: {"form-row check": true, hidden: vm => !vm.isAdvancedShown}}, [
t.input({type: "checkbox", name: "isFederationDisabled", id: "isFederationDisabled", checked: vm.isFederationDisabled}),
t.label({for: "isFederationDisabled"}, [
vm.i18n`Disable federation`,
t.p({className: "form-row-description"}, vm.i18n`Can't be changed later. This will prevent people on other homeservers from joining the room. This is typically used when only people from your own organisation (if applicable) should be allowed in the room, and is otherwise not needed.`)
]),
]),
]),
t.div({className: "button-row"}, [
t.button({
className: "button-action primary",
type: "submit",
disabled: vm => !vm.canCreate
}, vm.i18n`Create room`),
]),
])
])
);
}
onFormChange(evt) {
switch (evt.target.name) {
case "isEncrypted":
this.value.setEncrypted(evt.target.checked);
break;
case "isPublic":
this.value.setPublic(evt.currentTarget.isPublic.value === "true");
break;
case "isFederationDisabled":
this.value.setFederationDisabled(evt.target.checked);
break;
}
}
onSubmit(evt) {
evt.preventDefault();
this.value.create();
}
}

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {RoomView} from "./room/RoomView.js"; import {RoomView} from "./room/RoomView.js";
import {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js";
import {InviteView} from "./room/InviteView.js"; import {InviteView} from "./room/InviteView.js";
import {TemplateView} from "../general/TemplateView"; import {TemplateView} from "../general/TemplateView";
import {StaticView} from "../general/StaticView.js"; import {StaticView} from "../general/StaticView.js";
@ -33,7 +34,9 @@ export class RoomGridView extends TemplateView {
}, },
}, t.mapView(vm => vm.roomViewModelAt(i), roomVM => { }, t.mapView(vm => vm.roomViewModelAt(i), roomVM => {
if (roomVM) { if (roomVM) {
if (roomVM.kind === "invite") { if (roomVM.kind === "roomBeingCreated") {
return new RoomBeingCreatedView(roomVM);
} else if (roomVM.kind === "invite") {
return new InviteView(roomVM); return new InviteView(roomVM);
} else { } else {
return new RoomView(roomVM); return new RoomView(roomVM);

View file

@ -18,6 +18,7 @@ limitations under the License.
import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
import {RoomView} from "./room/RoomView.js"; import {RoomView} from "./room/RoomView.js";
import {UnknownRoomView} from "./room/UnknownRoomView.js"; import {UnknownRoomView} from "./room/UnknownRoomView.js";
import {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js";
import {InviteView} from "./room/InviteView.js"; import {InviteView} from "./room/InviteView.js";
import {LightboxView} from "./room/LightboxView.js"; import {LightboxView} from "./room/LightboxView.js";
import {TemplateView} from "../general/TemplateView"; import {TemplateView} from "../general/TemplateView";
@ -25,6 +26,7 @@ import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js"; import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js"; import {RoomGridView} from "./RoomGridView.js";
import {SettingsView} from "./settings/SettingsView.js"; import {SettingsView} from "./settings/SettingsView.js";
import {CreateRoomView} from "./CreateRoomView.js";
import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js";
export class SessionView extends TemplateView { export class SessionView extends TemplateView {
@ -43,11 +45,15 @@ export class SessionView extends TemplateView {
return new RoomGridView(vm.roomGridViewModel); return new RoomGridView(vm.roomGridViewModel);
} else if (vm.settingsViewModel) { } else if (vm.settingsViewModel) {
return new SettingsView(vm.settingsViewModel); return new SettingsView(vm.settingsViewModel);
} else if (vm.createRoomViewModel) {
return new CreateRoomView(vm.createRoomViewModel);
} else if (vm.currentRoomViewModel) { } else if (vm.currentRoomViewModel) {
if (vm.currentRoomViewModel.kind === "invite") { if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel); return new InviteView(vm.currentRoomViewModel);
} else if (vm.currentRoomViewModel.kind === "room") { } else if (vm.currentRoomViewModel.kind === "room") {
return new RoomView(vm.currentRoomViewModel); return new RoomView(vm.currentRoomViewModel);
} else if (vm.currentRoomViewModel.kind === "roomBeingCreated") {
return new RoomBeingCreatedView(vm.currentRoomViewModel);
} else { } else {
return new UnknownRoomView(vm.currentRoomViewModel); return new UnknownRoomView(vm.currentRoomViewModel);
} }

View file

@ -1,44 +0,0 @@
/*
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";
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,7 +17,6 @@ limitations under the License.
import {ListView} from "../../general/ListView"; import {ListView} from "../../general/ListView";
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {RoomTileView} from "./RoomTileView.js"; import {RoomTileView} from "./RoomTileView.js";
import {InviteTileView} from "./InviteTileView.js";
class FilterField extends TemplateView { class FilterField extends TemplateView {
render(t, options) { render(t, options) {
@ -63,13 +62,7 @@ export class LeftPanelView extends TemplateView {
className: "RoomList", className: "RoomList",
list: vm.tileViewModels, list: vm.tileViewModels,
}, },
tileVM => { tileVM => new RoomTileView(tileVM)
if (tileVM.kind === "invite") {
return new InviteTileView(tileVM);
} else {
return new RoomTileView(tileVM);
}
}
)); ));
const utilitiesRow = t.div({className: "utilities"}, [ const utilitiesRow = t.div({className: "utilities"}, [
t.a({className: "button-utility close-session", href: vm.closeUrl, "aria-label": vm.i18n`Back to account list`, title: vm.i18n`Back to account list`}), t.a({className: "button-utility close-session", href: vm.closeUrl, "aria-label": vm.i18n`Back to account list`, title: vm.i18n`Back to account list`}),
@ -97,6 +90,7 @@ export class LeftPanelView extends TemplateView {
"aria-label": gridButtonLabel "aria-label": gridButtonLabel
}), }),
t.a({className: "button-utility settings", href: vm.settingsUrl, "aria-label": vm.i18n`Settings`, title: vm.i18n`Settings`}), t.a({className: "button-utility settings", href: vm.settingsUrl, "aria-label": vm.i18n`Settings`, title: vm.i18n`Settings`}),
t.a({className: "button-utility create", href: vm.createRoomUrl, "aria-label": vm.i18n`Create room`, title: vm.i18n`Create room`}),
]); ]);
return t.div({className: "LeftPanel"}, [ return t.div({className: "LeftPanel"}, [

View file

@ -17,6 +17,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView"; import {TemplateView} from "../../general/TemplateView";
import {AvatarView} from "../../AvatarView.js"; import {AvatarView} from "../../AvatarView.js";
import {spinner} from "../../common.js";
export class RoomTileView extends TemplateView { export class RoomTileView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -29,13 +30,19 @@ export class RoomTileView extends TemplateView {
t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}), t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}),
t.div({className: "description"}, [ t.div({className: "description"}, [
t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name),
t.div({ t.map(vm => vm.busy, busy => {
if (busy) {
return spinner(t);
} else {
return t.div({
className: { className: {
badge: true, badge: true,
highlighted: vm => vm.isHighlighted, highlighted: vm => vm.isHighlighted,
hidden: vm => !vm.badgeCount hidden: vm => !vm.badgeCount
} }
}, vm => vm.badgeCount), }, vm => vm.badgeCount);
}
})
]) ])
]) ])
]); ]);

View file

@ -46,7 +46,8 @@ export class MemberDetailsView extends TemplateView {
t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`), t.div({className: "MemberDetailsView_label"}, vm.i18n`Options`),
t.div({className: "MemberDetailsView_options"}, t.div({className: "MemberDetailsView_options"},
[ [
t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`) t.a({href: vm.linkToUser, target: "_blank", rel: "noopener"}, vm.i18n`Open Link to User`),
t.button({className: "text", onClick: () => vm.openDirectMessage()}, vm.i18n`Open direct message`)
]) ])
]); ]);
} }

View file

@ -39,7 +39,7 @@ export class MessageComposer extends TemplateView {
this._clearHeight(); this._clearHeight();
} }
}, },
placeholder: vm.isEncrypted ? "Send an encrypted message…" : "Send a message…", placeholder: vm => vm.isEncrypted ? "Send an encrypted message…" : "Send a message…",
rows: "1" rows: "1"
}); });
this._focusInput = () => this._input.focus(); this._focusInput = () => this._input.focus();

View file

@ -0,0 +1,57 @@
/*
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";
import {LoadingView} from "../../general/LoadingView";
import {AvatarView} from "../../AvatarView";
export class RoomBeingCreatedView extends TemplateView {
render(t, vm) {
return t.main({className: "RoomView middle"}, [
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.mapView(vm => vm.error, error => {
if (error) {
return new ErrorView(vm);
} else {
return new LoadingView(vm.i18n`Setting up the room…`);
}
})
])
]);
}
}
class ErrorView extends TemplateView {
render(t,vm) {
return t.div({className: "RoomBeingCreated_error centered-column"}, [
t.h3(vm.i18n`Could not create the room, something went wrong:`),
t.div({className: "RoomView_error form-group"}, vm.error),
t.div({className: "button-row"},
t.button({
className: "button-action primary destructive",
onClick: () => vm.cancel()
}, vm.i18n`Cancel`))
]);
}
}