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
return type === "login" || type === "session" || type === "sso" || type === "logout";
case "session":
return type === "room" || type === "rooms" || type === "settings";
return type === "room" || type === "rooms" || type === "settings" || type === "create-room";
case "rooms":
// downside of the approach: both of these will control which tile is selected
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 {RoomStatus} from "../../matrix/room/common";
/**
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.
*/
export class RoomViewModelObservable extends ObservableValue {
constructor(sessionViewModel, roomId) {
constructor(sessionViewModel, roomIdOrLocalId) {
super(null);
this._statusSubscription = null;
this._sessionViewModel = sessionViewModel;
this.id = roomId;
this.id = roomIdOrLocalId;
}
/**
@ -59,11 +60,21 @@ export class RoomViewModelObservable extends ObservableValue {
}
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);
} else if (status.joined) {
return this._sessionViewModel._createRoomViewModel(this.id);
} else if (status.archived) {
} else if (status & RoomStatus.Joined) {
return this._sessionViewModel._createRoomViewModelInstance(this.id);
} else if (status & RoomStatus.Archived) {
return await this._sessionViewModel._createArchivedRoomViewModel(this.id);
} else {
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 {UnknownRoomViewModel} from "./room/UnknownRoomViewModel.js";
import {InviteViewModel} from "./room/InviteViewModel.js";
import {RoomBeingCreatedViewModel} from "./room/RoomBeingCreatedViewModel.js";
import {LightboxViewModel} from "./room/LightboxViewModel.js";
import {SessionStatusViewModel} from "./SessionStatusViewModel.js";
import {RoomGridViewModel} from "./RoomGridViewModel.js";
import {SettingsViewModel} from "./settings/SettingsViewModel.js";
import {CreateRoomViewModel} from "./CreateRoomViewModel.js";
import {ViewModel} from "../ViewModel.js";
import {RoomViewModelObservable} from "./RoomViewModelObservable.js";
import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js";
@ -37,13 +39,11 @@ export class SessionViewModel extends ViewModel {
reconnector: client.reconnector,
session: client.session,
})));
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({
invites: this._client.session.invites,
rooms: this._client.session.rooms
})));
this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({session: this._client.session})));
this._settingsViewModel = null;
this._roomViewModelObservable = null;
this._gridViewModel = null;
this._createRoomViewModel = null;
this._setupNavigation();
}
@ -75,6 +75,12 @@ export class SessionViewModel extends ViewModel {
}));
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");
this.track(lightbox.subscribe(eventId => {
this._updateLightbox(eventId);
@ -96,7 +102,7 @@ export class SessionViewModel extends ViewModel {
}
get activeMiddleViewModel() {
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel;
return this._roomViewModelObservable?.get() || this._gridViewModel || this._settingsViewModel || this._createRoomViewModel;
}
get roomGridViewModel() {
@ -119,11 +125,14 @@ export class SessionViewModel extends ViewModel {
return this._roomViewModelObservable?.get();
}
get rightPanelViewModel() {
return this._rightPanelViewModel;
}
get createRoomViewModel() {
return this._createRoomViewModel;
}
_updateGrid(roomIds) {
const changed = !(this._gridViewModel && roomIds);
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);
if (room) {
const roomVM = new RoomViewModel(this.childOptions({room}));
@ -200,6 +209,17 @@ export class SessionViewModel extends ViewModel {
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) {
// opening a room and already open?
if (this._roomViewModelObservable?.id === roomId) {
@ -237,6 +257,16 @@ export class SessionViewModel extends ViewModel {
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) {
if (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;
if (enable) {
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");
}
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 {ViewModel} from "../../ViewModel.js";
const KIND_ORDER = ["invite", "room"];
const KIND_ORDER = ["roomBeingCreated", "invite", "room"];
export class BaseTileViewModel extends ViewModel {
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.
Licensed under the Apache License, Version 2.0 (the "License");
@ -16,6 +15,7 @@ limitations under the License.
*/
import {BaseTileViewModel} from "./BaseTileViewModel.js";
import {comparePrimitive} from "./common";
export class InviteTileViewModel extends BaseTileViewModel {
constructor(options) {
@ -25,31 +25,40 @@ export class InviteTileViewModel extends BaseTileViewModel {
this._url = this.urlCreator.openRoomActionUrl(this._invite.id);
}
get busy() {
return this._invite.accepting || this._invite.rejecting;
}
get kind() {
return "invite";
}
get url() {
return this._url;
}
get busy() { return this._invite.accepting || this._invite.rejecting; }
get kind() { return "invite"; }
get url() { return this._url; }
get name() { return this._invite.name; }
get isHighlighted() { return true; }
get isUnread() { return true; }
get badgeCount() { return this.i18n`!`; }
get _avatarSource() { return this._invite; }
/** 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 parentComparison = super.compare(other);
if (parentComparison !== 0) {
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;
}
get _avatarSource() {
return this._invite;
export function tests() {
return {
"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 {RoomTileViewModel} from "./RoomTileViewModel.js";
import {InviteTileViewModel} from "./InviteTileViewModel.js";
import {RoomBeingCreatedTileViewModel} from "./RoomBeingCreatedTileViewModel.js";
import {RoomFilter} from "./RoomFilter.js";
import {ApplyMap} from "../../../observable/map/ApplyMap.js";
import {addPanelIfNeeded} from "../../navigation/index.js";
@ -25,32 +26,36 @@ import {addPanelIfNeeded} from "../../navigation/index.js";
export class LeftPanelViewModel extends ViewModel {
constructor(options) {
super(options);
const {rooms, invites} = options;
this._tileViewModelsMap = this._mapTileViewModels(rooms, invites);
const {session} = options;
this._tileViewModelsMap = this._mapTileViewModels(session.roomsBeingCreated, session.invites, session.rooms);
this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap);
this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b));
this._currentTileVM = null;
this._setupNavigation();
this._closeUrl = this.urlCreator.urlForSegment("session");
this._settingsUrl = this.urlCreator.urlForSegment("settings");
this._createRoomUrl = this.urlCreator.urlForSegment("create-room");
}
_mapTileViewModels(rooms, invites) {
_mapTileViewModels(roomsBeingCreated, invites, 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;
if (roomOrInvite.isInvite) {
vm = new InviteTileViewModel(this.childOptions({invite: roomOrInvite, emitChange}));
if (item.isBeingCreated) {
vm = new RoomBeingCreatedTileViewModel(this.childOptions({roomBeingCreated: item, emitChange}));
} else if (item.isInvite) {
vm = new InviteTileViewModel(this.childOptions({invite: item, emitChange}));
} 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) {
vm.open();
this._updateCurrentVM(vm);
}
return vm;
});
return allTiles;
}
_updateCurrentVM(vm) {
@ -69,6 +74,8 @@ export class LeftPanelViewModel extends ViewModel {
return this._settingsUrl;
}
get createRoomUrl() { return this._createRoomUrl; }
_setupNavigation() {
const roomObservable = this.navigation.observe("room");
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;
}
/** 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 parentComparison = super.compare(other);
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 {RoomType} from "../../../matrix/room/common";
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js";
export class MemberDetailsViewModel extends ViewModel {
@ -25,6 +26,7 @@ export class MemberDetailsViewModel extends ViewModel {
this._member = this._observableMember.get();
this._isEncrypted = options.isEncrypted;
this._powerLevelsObservable = options.powerLevelsObservable;
this._session = options.session;
this.track(this._powerLevelsObservable.subscribe(() => this._onPowerLevelsChange()));
this.track(this._observableMember.subscribe( () => this._onMemberChange()));
}
@ -77,6 +79,19 @@ export class MemberDetailsViewModel extends ViewModel {
}
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) {
super(options);
this._room = options.room;
this._session = options.session;
this._members = null;
this._setupNavigation();
}
@ -48,7 +49,13 @@ export class RightPanelViewModel extends ViewModel {
}
const isEncrypted = this._room.isEncrypted;
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() {

View file

@ -1,9 +1,17 @@
# "Room" view models
InviteViewModel and RoomViewModel are interchangebly used as "room view model":
- SessionViewModel.roomViewModel can be an instance of either
- RoomGridViewModel.roomViewModelAt(i) can return an instance of either
InviteViewModel, RoomViewModel and RoomBeingCreatedViewModel are interchangebly used as "room view model":
- SessionViewModel.roomViewModel can be an instance of any
- 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.
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 {tilesCreator} from "./timeline/tilesCreator.js";
import {ViewModel} from "../../ViewModel.js";
import {imageToInfo} from "../common.js";
export class RoomViewModel extends ViewModel {
constructor(options) {
@ -97,9 +98,8 @@ export class RoomViewModel extends ViewModel {
// room doesn't tell us yet which fields changed,
// so emit all fields originating from summary
_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.emitChange();
}
@ -274,7 +274,9 @@ export class RoomViewModel extends ViewModel {
let image = await this.platform.loadImage(file.blob);
const limit = await this.platform.settingsStorage.getInt("sentImageSizeLimit");
if (limit && image.maxDimension > limit) {
image = await image.scale(limit);
const scaledImage = await image.scale(limit);
image.dispose();
image = scaledImage;
}
const content = {
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) {
const info = imageToInfo(video);
info.duration = video.duration;

View file

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

View file

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

View file

@ -43,7 +43,7 @@ export interface ILogItem {
readonly values: LogItemValues;
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
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;
wrapDetached(labelOrValues: LabelOrValues, callback: LogCallback<unknown>, logLevel?: LogLevel, filterCreator?: FilterCreator): void;
refDetached(logItem: ILogItem, logLevel?: LogLevel): void;

View file

@ -17,7 +17,8 @@ limitations under the License.
import {Room} from "./room/Room.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 {Pusher} from "./push/Pusher";
import { ObservableMap } from "../observable/index.js";
@ -63,6 +64,14 @@ export class Session {
this._activeArchivedRooms = new Map();
this._invites = new ObservableMap();
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._deviceMessageHandler = new DeviceMessageHandler({storage});
this._olm = olm;
@ -421,7 +430,7 @@ export class Session {
// load rooms
const rooms = await txn.roomSummary.getAll();
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));
this._rooms.add(room.id, room);
}));
@ -529,8 +538,21 @@ export class Session {
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 */
createRoom(roomId, pendingEvents) {
createJoinedRoom(roomId, pendingEvents) {
return new Room({
roomId,
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) {
const toDeviceEvents = syncResponse.to_device?.events;
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
for (const rs of roomStates) {
if (rs.shouldAdd) {
this._rooms.add(rs.id, rs.room);
this._tryReplaceRoomBeingCreated(rs.id, log);
} else if (rs.shouldRemove) {
this._rooms.remove(rs.id);
}
@ -684,21 +754,23 @@ export class Session {
if (this._observedRoomStatus.size !== 0) {
for (const ars of archivedRoomStates) {
if (ars.shouldAdd) {
this._observedRoomStatus.get(ars.id)?.set(RoomStatus.archived);
this._observedRoomStatus.get(ars.id)?.set(RoomStatus.Archived);
}
}
for (const rs of roomStates) {
if (rs.shouldAdd) {
this._observedRoomStatus.get(rs.id)?.set(RoomStatus.joined);
this._observedRoomStatus.get(rs.id)?.set(RoomStatus.Joined);
}
}
for (const is of inviteStates) {
const statusObservable = this._observedRoomStatus.get(is.id);
if (statusObservable) {
const withInvited = statusObservable.get() | RoomStatus.Invited;
if (is.shouldAdd) {
statusObservable.set(statusObservable.get().withInvited());
statusObservable.set(withInvited);
} 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) {
const statusObservable = this._observedRoomStatus.get(roomId);
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) {
const isBeingCreated = !!this._roomsBeingCreated.get(roomId);
if (isBeingCreated) {
return RoomStatus.BeingCreated;
}
const isJoined = !!this._rooms.get(roomId);
if (isJoined) {
return RoomStatus.joined;
return RoomStatus.Joined;
} else {
const isInvited = !!this._invites.get(roomId);
const txn = await this._storage.readTxn([this._storage.storeNames.archivedRoomSummary]);
const isArchived = await txn.archivedRoomSummary.has(roomId);
if (isInvited && isArchived) {
return RoomStatus.invitedAndArchived;
return RoomStatus.Invited | RoomStatus.Archived;
} else if (isInvited) {
return RoomStatus.invited;
return RoomStatus.Invited;
} else if (isArchived) {
return RoomStatus.archived;
return RoomStatus.Archived;
} else {
return RoomStatus.none;
return RoomStatus.None;
}
}
}
@ -823,6 +899,7 @@ export class Session {
observable = new RetainedObservableValue(status, () => {
this._observedRoomStatus.delete(roomId);
});
this._observedRoomStatus.set(roomId, observable);
}
return observable;

View file

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

View file

@ -57,3 +57,15 @@ export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Ke
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);
}
getDehydratedDevice(options: BaseRequestOptions): IHomeServerRequest {
getDehydratedDevice(options: BaseRequestOptions = {}): IHomeServerRequest {
options.prefix = DEHYDRATION_PREFIX;
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;
return this._put(`/dehydrated_device`, {}, payload, options);
}
claimDehydratedDevice(deviceId: string, options: BaseRequestOptions): IHomeServerRequest {
claimDehydratedDevice(deviceId: string, options: BaseRequestOptions = {}): IHomeServerRequest {
options.prefix = DEHYDRATION_PREFIX;
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";

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

View file

@ -420,6 +420,21 @@ export class BaseRoom extends EventEmitter {
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() {
const txn = await this._storage.readTxn([this._storage.storeNames.roomState]);
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;
}
isDirectMessageForUserId(userId) {
return this.isDirectMessage && this._inviter.userId === userId;
}
get isPublic() {
return this._inviteData.joinRule === "public";
}
@ -184,7 +188,7 @@ export class Invite extends EventEmitter {
return {
roomId: this.id,
isEncrypted: !!summaryData.encryption,
isDirectMessage: this._isDirectMessage(myInvite),
isDirectMessage: summaryData.isDirectMessage,
// type:
name,
avatarUrl,
@ -196,12 +200,8 @@ export class Invite extends EventEmitter {
};
}
_isDirectMessage(myInvite) {
return !!(myInvite?.content?.is_direct);
}
_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) {

View file

@ -54,15 +54,12 @@ export class Room extends BaseRoom {
return false;
}
async prepareSync(roomResponse, membership, invite, newKeys, txn, log) {
async prepareSync(roomResponse, membership, newKeys, txn, log) {
log.set("id", this.id);
if (newKeys) {
log.set("newKeys", newKeys.length);
}
let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership);
if (membership === "join" && invite) {
summaryChanges = summaryChanges.applyInvite(invite);
}
let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership, this._user.id);
let roomEncryption = this._roomEncryption;
// encryption is enabled in this sync
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;
}
function applySyncResponse(data, roomResponse, membership) {
function applySyncResponse(data, roomResponse, membership, ownUserId) {
if (roomResponse.summary) {
data = updateSummary(data, roomResponse.summary);
}
@ -60,7 +60,7 @@ function applySyncResponse(data, roomResponse, membership) {
// process state events in state and in timeline.
// non-state events are handled by applyTimelineEntries
// 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;
if (unreadNotifications) {
data = processNotificationCounts(data, unreadNotifications);
@ -95,8 +95,11 @@ function processRoomAccountData(data, event) {
return data;
}
export function processStateEvent(data, event) {
if (event.type === "m.room.encryption") {
export function processStateEvent(data, event, ownUserId) {
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;
if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
data = data.cloneIfNeeded();
@ -118,6 +121,25 @@ export function processStateEvent(data, event) {
const content = event.content;
data = data.cloneIfNeeded();
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;
}
@ -158,19 +180,6 @@ function updateSummary(data, summary) {
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 {
constructor(copy, roomId) {
this.roomId = copy ? copy.roomId : roomId;
@ -227,12 +236,8 @@ export class SummaryData {
return applyTimelineEntries(this, timelineEntries, isInitialSync, canMarkUnread, ownUserId);
}
applySyncResponse(roomResponse, membership) {
return applySyncResponse(this, roomResponse, membership);
}
applyInvite(invite) {
return applyInvite(this, invite);
applySyncResponse(roomResponse, membership, ownUserId) {
return applySyncResponse(this, roomResponse, membership, ownUserId);
}
get needsHeroes() {

View file

@ -25,3 +25,18 @@ export const REDACTION_TYPE = "m.room.redaction";
export function isRedacted(event) {
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";
function calculateRoomName(sortedMembers, summaryData, log) {
export function calculateRoomName(sortedMembers, summaryData, log) {
const countWithoutMe = summaryData.joinCount + summaryData.inviteCount - 1;
if (sortedMembers.length >= countWithoutMe) {
if (sortedMembers.length > 1) {

View file

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

View file

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

View file

@ -18,7 +18,7 @@ import {SortedArray} from "../../../observable/list/SortedArray";
import {ConnectionError} from "../../error.js";
import {PendingEvent, SendStatus} from "./PendingEvent.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";
export class SendQueue {

View file

@ -22,7 +22,7 @@ import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js";
import {RoomMember} from "../members/RoomMember.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 {DecryptionSource} from "../../e2ee/common.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 {REDACTION_TYPE} from "../../common.js";
import {REDACTION_TYPE} from "../../common";
import {createAnnotation, ANNOTATION_RELATION_TYPE, getRelationFromContent} from "../relations.js";
import {PendingAnnotation} from "../PendingAnnotation.js";
import {createReplyContent} from "./reply.js"

View file

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

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
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 {redactEvent} from "../common.js";

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import {REDACTION_TYPE} from "../common.js";
import {REDACTION_TYPE} from "../common";
export const REACTION_TYPE = "m.reaction";
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 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 {
width: 100%;
height: 100%;
object-fit: cover;
}
/* 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.
*/
.form input {
.form-row.text > input, .form-row.text > textarea {
display: block;
width: 100%;
min-width: 0;

View file

@ -49,7 +49,7 @@ main {
grid-template:
"status status" auto
"left middle" 1fr /
300px 1fr;
320px 1fr;
min-height: 0;
min-width: 0;
}
@ -58,7 +58,7 @@ main {
grid-template:
"status status status" auto
"left middle right" 1fr /
300px 1fr 300px;
320px 1fr 300px;
}
/* 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;
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;
}
.form-row.text textarea {
font-family: "Inter", sans-serif;
}
.form-group {
margin: 24px 0;
}
.form-row {
margin: 12px 0;
}
.form-row input {
.form-row.text > input, .form-row.text > textarea {
padding: 12px;
border: 1px solid rgba(141, 151, 165, 0.15);
border-radius: 8px;
margin-top: 5px;
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;
}
.form-row .form-row-description {
font-size: 1rem;
color: #777;
margin: 8px 0 0 0;
}
.button-action {
cursor: pointer;
}
@ -157,6 +178,10 @@ a.button-action {
background-image: url('icons/settings.svg');
}
.button-utility.create {
background-image: url('icons/plus.svg');
}
.button-utility.grid.on {
background-image: url('icons/disable-grid.svg');
}
@ -1049,10 +1074,14 @@ button.RoomDetailsView_row::after {
flex-direction: column;
}
.MemberDetailsView_options a{
.MemberDetailsView_options a, .MemberDetailsView_options button {
color: #0dbd8b;
text-decoration: none;
margin-bottom: 3px;
margin: 0 0 3px 0;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.LazyListParent {
@ -1075,3 +1104,35 @@ button.RoomDetailsView_row::after {
display: flex;
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.
*/
import {TemplateView} from "./TemplateView";
import {StaticView} from "./StaticView";
import {spinner} from "../common.js";
export class LoadingView extends TemplateView {
render(t) {
return t.div({ className: "LoadingView" }, [spinner(t), "Loading"]);
export class LoadingView extends StaticView {
constructor(label = "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.h1([vm.i18n`Sign In`]),
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.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.div({ className: "form-row" }, [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: "username" }, vm.i18n`Username`), username]),
t.div({ className: "form-row text" }, [t.label({ for: "password" }, vm.i18n`Password`), password]),
t.div({ className: "button-row" }, [
t.button({
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 {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js";
import {InviteView} from "./room/InviteView.js";
import {TemplateView} from "../general/TemplateView";
import {StaticView} from "../general/StaticView.js";
@ -33,7 +34,9 @@ export class RoomGridView extends TemplateView {
},
}, t.mapView(vm => vm.roomViewModelAt(i), roomVM => {
if (roomVM) {
if (roomVM.kind === "invite") {
if (roomVM.kind === "roomBeingCreated") {
return new RoomBeingCreatedView(roomVM);
} else if (roomVM.kind === "invite") {
return new InviteView(roomVM);
} else {
return new RoomView(roomVM);

View file

@ -18,6 +18,7 @@ limitations under the License.
import {LeftPanelView} from "./leftpanel/LeftPanelView.js";
import {RoomView} from "./room/RoomView.js";
import {UnknownRoomView} from "./room/UnknownRoomView.js";
import {RoomBeingCreatedView} from "./room/RoomBeingCreatedView.js";
import {InviteView} from "./room/InviteView.js";
import {LightboxView} from "./room/LightboxView.js";
import {TemplateView} from "../general/TemplateView";
@ -25,6 +26,7 @@ import {StaticView} from "../general/StaticView.js";
import {SessionStatusView} from "./SessionStatusView.js";
import {RoomGridView} from "./RoomGridView.js";
import {SettingsView} from "./settings/SettingsView.js";
import {CreateRoomView} from "./CreateRoomView.js";
import {RightPanelView} from "./rightpanel/RightPanelView.js";
export class SessionView extends TemplateView {
@ -43,11 +45,15 @@ export class SessionView extends TemplateView {
return new RoomGridView(vm.roomGridViewModel);
} else if (vm.settingsViewModel) {
return new SettingsView(vm.settingsViewModel);
} else if (vm.createRoomViewModel) {
return new CreateRoomView(vm.createRoomViewModel);
} else if (vm.currentRoomViewModel) {
if (vm.currentRoomViewModel.kind === "invite") {
return new InviteView(vm.currentRoomViewModel);
} else if (vm.currentRoomViewModel.kind === "room") {
return new RoomView(vm.currentRoomViewModel);
} else if (vm.currentRoomViewModel.kind === "roomBeingCreated") {
return new RoomBeingCreatedView(vm.currentRoomViewModel);
} else {
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 {TemplateView} from "../../general/TemplateView";
import {RoomTileView} from "./RoomTileView.js";
import {InviteTileView} from "./InviteTileView.js";
class FilterField extends TemplateView {
render(t, options) {
@ -63,13 +62,7 @@ export class LeftPanelView extends TemplateView {
className: "RoomList",
list: vm.tileViewModels,
},
tileVM => {
if (tileVM.kind === "invite") {
return new InviteTileView(tileVM);
} else {
return new RoomTileView(tileVM);
}
}
tileVM => new RoomTileView(tileVM)
));
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`}),
@ -97,6 +90,7 @@ export class LeftPanelView extends TemplateView {
"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 create", href: vm.createRoomUrl, "aria-label": vm.i18n`Create room`, title: vm.i18n`Create room`}),
]);
return t.div({className: "LeftPanel"}, [

View file

@ -17,6 +17,7 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView";
import {AvatarView} from "../../AvatarView.js";
import {spinner} from "../../common.js";
export class RoomTileView extends TemplateView {
render(t, vm) {
@ -29,13 +30,19 @@ export class RoomTileView extends TemplateView {
t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}),
t.div({className: "description"}, [
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: {
badge: true,
highlighted: vm => vm.isHighlighted,
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_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();
}
},
placeholder: vm.isEncrypted ? "Send an encrypted message…" : "Send a message…",
placeholder: vm => vm.isEncrypted ? "Send an encrypted message…" : "Send a message…",
rows: "1"
});
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`))
]);
}
}