diff --git a/src/domain/session/CreateRoomViewModel.js b/src/domain/session/CreateRoomViewModel.js new file mode 100644 index 00000000..47656128 --- /dev/null +++ b/src/domain/session/CreateRoomViewModel.js @@ -0,0 +1,126 @@ +/* +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/create"; + +export class CreateRoomViewModel extends ViewModel { + constructor(options) { + super(options); + const {session} = options; + this._session = session; + this._name = ""; + this._topic = ""; + this._isPublic = false; + this._isEncrypted = true; + this._avatarScaledBlob = undefined; + this._avatarFileName = undefined; + this._avatarInfo = undefined; + } + + setName(name) { + this._name = name; + this.emitChange("name"); + } + + get name() { return this._name; } + + setTopic(topic) { + this._topic = topic; + this.emitChange("topic"); + } + + get topic() { return this._topic; } + + setPublic(isPublic) { + this._isPublic = isPublic; + this.emitChange("isPublic"); + } + + get isPublic() { return this._isPublic; } + + setEncrypted(isEncrypted) { + this._isEncrypted = isEncrypted; + this.emitChange("isEncrypted"); + } + + get isEncrypted() { return this._isEncrypted; } + + get canCreate() { + return !!this.name; + } + + 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, + alias: this.isPublic ? this.roomAlias : undefined, + avatar, + invites: ["@bwindels:matrix.org"] + }); + this.navigation.push("room", roomBeingCreated.localId); + } + + + avatarUrl() { return this._avatarScaledBlob.url; } + get avatarTitle() { return this.name; } + get avatarLetter() { return ""; } + get avatarColorNumber() { return 0; } + get hasAvatar() { return !!this._avatarScaledBlob; } + get error() { return ""; } + + 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"); + } +} diff --git a/src/domain/session/common.js b/src/domain/session/common.js new file mode 100644 index 00000000..1ab275bb --- /dev/null +++ b/src/domain/session/common.js @@ -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 + }; +} diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index c81c47ef..2d10c7ca 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -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) { @@ -273,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, @@ -319,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; diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 143ea899..2ee9ca0c 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -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 */ diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 2b3a04ae..a0f42b01 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -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 diff --git a/src/platform/web/ui/css/themes/element/icons/plus.svg b/src/platform/web/ui/css/themes/element/icons/plus.svg new file mode 100644 index 00000000..ea197223 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index eae02ee8..3a0d34ba 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -171,6 +171,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'); } @@ -1089,3 +1093,28 @@ button.RoomDetailsView_row::after { display: flex; gap: 12px; } + +.CreateRoomView { + padding: 0 12px; + justify-self: center; + max-width: 400px; + 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; +} diff --git a/src/platform/web/ui/session/CreateRoomView.js b/src/platform/web/ui/session/CreateRoomView.js new file mode 100644 index 00000000..6bdc32ca --- /dev/null +++ b/src/platform/web/ui/session/CreateRoomView.js @@ -0,0 +1,105 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {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: "CreateRoomView middle"}, [ + 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` + }, vm => vm.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 + `}), + ]), + 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; + } + } + + onSubmit(evt) { + evt.preventDefault(); + this.value.create(); + } +}