From fa5cb684b0917949cb40fae9267a6e980cd86393 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Tue, 24 Jan 2023 11:53:49 +0530 Subject: [PATCH] WIP --- src/domain/avatar.ts | 8 ++ src/domain/session/SessionViewModel.js | 9 ++ .../toast/BaseToastNotificationViewModel.ts | 34 ++++++ .../toast/CallToastNotificationViewModel.ts | 97 +++++++++++++++++ .../session/toast/ToastCollectionViewModel.ts | 88 +++++++++++++++ src/matrix/calls/CallHandler.ts | 6 +- src/matrix/calls/group/GroupCall.ts | 1 + .../web/ui/css/themes/element/theme.css | 100 ++++++++++++++++++ src/platform/web/ui/session/SessionView.js | 2 + .../toast/CallToastNotificationView.ts | 51 +++++++++ .../ui/session/toast/ToastCollectionView.ts | 33 ++++++ 11 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 src/domain/session/toast/BaseToastNotificationViewModel.ts create mode 100644 src/domain/session/toast/CallToastNotificationViewModel.ts create mode 100644 src/domain/session/toast/ToastCollectionViewModel.ts create mode 100644 src/platform/web/ui/session/toast/CallToastNotificationView.ts create mode 100644 src/platform/web/ui/session/toast/ToastCollectionView.ts diff --git a/src/domain/avatar.ts b/src/domain/avatar.ts index ecaccebe..aaf700ef 100644 --- a/src/domain/avatar.ts +++ b/src/domain/avatar.ts @@ -58,3 +58,11 @@ export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, } return undefined; } + +// move to AvatarView.js when converting to typescript +export interface IAvatarContract { + avatarLetter: string; + avatarColorNumber: number; + avatarUrl: (size: number) => string | undefined; + avatarTitle: string; +} diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index eb371ca8..1247f5d7 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -30,6 +30,7 @@ import {ViewModel} from "../ViewModel"; import {RoomViewModelObservable} from "./RoomViewModelObservable.js"; import {RightPanelViewModel} from "./rightpanel/RightPanelViewModel.js"; import {SyncStatus} from "../../matrix/Sync.js"; +import {ToastCollectionViewModel} from "./toast/ToastCollectionViewModel"; export class SessionViewModel extends ViewModel { constructor(options) { @@ -47,6 +48,10 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = null; this._createRoomViewModel = null; this._joinRoomViewModel = null; + this._toastCollectionViewModel = this.track(new ToastCollectionViewModel(this.childOptions({ + callHandler: this._client.session.callHandler, + session: this._client.session, + }))); this._setupNavigation(); this._setupForcedLogoutOnAccessTokenInvalidation(); } @@ -173,6 +178,10 @@ export class SessionViewModel extends ViewModel { return this._joinRoomViewModel; } + get toastCollectionViewModel() { + return this._toastCollectionViewModel; + } + _updateGrid(roomIds) { const changed = !(this._gridViewModel && roomIds); const currentRoomId = this.navigation.path.get("room"); diff --git a/src/domain/session/toast/BaseToastNotificationViewModel.ts b/src/domain/session/toast/BaseToastNotificationViewModel.ts new file mode 100644 index 00000000..70fefea7 --- /dev/null +++ b/src/domain/session/toast/BaseToastNotificationViewModel.ts @@ -0,0 +1,34 @@ +/* +Copyright 2023 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 { ErrorReportViewModel } from "../../ErrorReportViewModel"; +import {Options as BaseOptions} from "../../ViewModel"; +import type {Session} from "../../../matrix/Session.js"; + +export type BaseClassOptions = { + dismiss: () => void; + session: Session; +} & BaseOptions; + +export abstract class BaseToastNotificationViewModel extends ErrorReportViewModel { + constructor(options: T) { + super(options); + } + + dismiss() { + this.getOption("dismiss")(); + } +} diff --git a/src/domain/session/toast/CallToastNotificationViewModel.ts b/src/domain/session/toast/CallToastNotificationViewModel.ts new file mode 100644 index 00000000..0e72c3f9 --- /dev/null +++ b/src/domain/session/toast/CallToastNotificationViewModel.ts @@ -0,0 +1,97 @@ +/* +Copyright 2023 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 type {GroupCall} from "../../../matrix/calls/group/GroupCall"; +import type {Room} from "../../../matrix/room/Room.js"; +import {IAvatarContract, avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar"; +import {LocalMedia} from "../../../matrix/calls/LocalMedia"; +import {BaseClassOptions, BaseToastNotificationViewModel} from "./BaseToastNotificationViewModel"; + +type Options = { + call: GroupCall; + room: Room; +} & BaseClassOptions; + + +export class CallToastNotificationViewModel extends BaseToastNotificationViewModel implements IAvatarContract { + constructor(options: Options) { + super(options); + this.track( + this.call.members.subscribe({ + onAdd: (_, __) => { + this.emitChange("memberCount"); + }, + onUpdate: (_, __) => { + this.emitChange("memberCount"); + }, + onRemove: (_, __) => { + this.emitChange("memberCount"); + }, + onReset: () => { /** noop */ }, + }) + ); + // Dismiss the toast if the room is opened manually + this.track( + this.navigation.observe("room").subscribe(roomId => { + if (roomId === this.call.roomId) { + this.dismiss(); + } + })); + } + + async join() { + await this.logAndCatch("CallToastNotificationViewModel.join", async (log) => { + const stream = await this.platform.mediaDevices.getMediaTracks(false, true); + const localMedia = new LocalMedia().withUserMedia(stream); + await this.call.join(localMedia, log); + const url = this.urlCreator.openRoomActionUrl(this.call.roomId); + this.urlCreator.pushUrl(url); + }); + } + + get call(): GroupCall { + return this.getOption("call"); + } + + private get room(): Room { + return this.getOption("room"); + } + + get roomName(): string { + return this.room.name; + } + + get memberCount(): number { + return this.call.members.size; + } + + get avatarLetter() { + return avatarInitials(this.roomName); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this.room.avatarColorId); + } + + avatarUrl(size: number) { + return getAvatarHttpUrl(this.room.avatarUrl, size, this.platform, this.room.mediaRepository); + } + + get avatarTitle() { + return this.roomName; + } +} + + diff --git a/src/domain/session/toast/ToastCollectionViewModel.ts b/src/domain/session/toast/ToastCollectionViewModel.ts new file mode 100644 index 00000000..f2fe65e3 --- /dev/null +++ b/src/domain/session/toast/ToastCollectionViewModel.ts @@ -0,0 +1,88 @@ +/* +Copyright 2023 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 {ObservableArray, ObservableMap} from "../../../observable"; +import {ViewModel, Options as BaseOptions} from "../../ViewModel"; +import type {GroupCall} from "../../../matrix/calls/group/GroupCall"; +import type {Room} from "../../../matrix/room/Room.js"; +import type {CallHandler} from "../../../matrix/calls/CallHandler"; +import type {Session} from "../../../matrix/Session.js"; +import {CallToastNotificationViewModel} from "./CallToastNotificationViewModel"; + +type Options = { + callHandler: CallHandler; + session: Session; +} & BaseOptions; + +export class ToastCollectionViewModel extends ViewModel { + public readonly toastViewModels: ObservableArray = new ObservableArray(); + + constructor(options: Options) { + super(options); + const callsObservableMap = this.getOption("callHandler").calls; + this.track(callsObservableMap.subscribe(this)); + } + + onAdd(_, call) { + if (this.shouldShowNotification(call)) { + const room = this._findRoomForCall(call); + const dismiss = () => { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + }; + this.toastViewModels.append( + new CallToastNotificationViewModel(this.childOptions({ call, room, dismiss })) + ); + } + } + + onRemove(_, call) { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + this.toastViewModels.remove(idx); + } + } + + onUpdate(_, call) { + const idx = this.toastViewModels.array.findIndex(vm => vm.call === call); + if (idx !== -1) { + // todo: is this correct? + this.toastViewModels.update(idx, this.toastViewModels.at(idx)!); + } + } + + onReset() { + for (let i = 0; i < this.toastViewModels.length; ++i) { + this.toastViewModels.remove(i); + } + } + + private _findRoomForCall(call: GroupCall): Room { + const id = call.roomId; + const rooms = this.getOption("session").rooms; + return rooms.get(id); + } + + private shouldShowNotification(call: GroupCall): boolean { + const currentlyOpenedRoomId = this.navigation.path.get("room")?.value; + if (!call.isLoadedFromStorage && call.roomId !== currentlyOpenedRoomId) { + return true; + } + return false; + } +} diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index 6be6b193..e153c761 100644 --- a/src/matrix/calls/CallHandler.ts +++ b/src/matrix/calls/CallHandler.ts @@ -104,7 +104,7 @@ export class CallHandler implements RoomStateHandler { } const event = await txn.roomState.get(callEntry.roomId, EventType.GroupCall, callEntry.callId); if (event) { - const call = new GroupCall(event.event.state_key, false, event.event.content, event.roomId, this.groupCallOptions); + const call = new GroupCall(event.event.state_key, true, false, event.event.content, event.roomId, this.groupCallOptions); this._calls.set(call.id, call); } })); @@ -135,7 +135,7 @@ export class CallHandler implements RoomStateHandler { if (!intent) { intent = CallIntent.Ring; } - const call = new GroupCall(makeId("conf-"), true, { + const call = new GroupCall(makeId("conf-"), false, true, { "m.name": name, "m.intent": intent }, roomId, this.groupCallOptions); @@ -217,7 +217,7 @@ export class CallHandler implements RoomStateHandler { txn.calls.remove(call.intent, roomId, call.id); } } else { - call = new GroupCall(event.state_key, false, event.content, roomId, this.groupCallOptions); + call = new GroupCall(event.state_key, false, false, event.content, roomId, this.groupCallOptions); this._calls.set(call.id, call); txn.calls.add({ intent: call.intent, diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 0b8c7db5..108e1fb9 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -104,6 +104,7 @@ export class GroupCall extends EventEmitter<{change: never}> { constructor( public readonly id: string, + public readonly isLoadedFromStorage: boolean, newCall: boolean, private callContent: Record, public readonly roomId: string, diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 1c9a233b..3aec2a8a 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -1224,3 +1224,103 @@ button.RoomDetailsView_row::after { .JoinRoomView_status .spinner { margin-right: 5px; } + +/* Toast */ +.ToastCollectionView { + display: flex; + position: fixed; + flex-direction: column; + z-index: 1000; + left: 44px; + top: 52px; +} + +.ToastCollectionView ul { + margin: 0; + padding: 0; +} + +.CallToastNotificationView:not(:first-child) { + margin-top: 12px; +} + +.CallToastNotificationView { + display: grid; + grid-template-rows: 40px 1fr 1fr 48px; + row-gap: 4px; + width: 260px; + background-color: var(--background-color-secondary); + border-radius: 8px; + color: var(--text-color); + box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); +} + +.CallToastNotificationView__top { + display: grid; + grid-template-columns: auto 176px auto; + align-items: center; + justify-items: center; +} +.CallToastNotificationView__dismiss-btn { + background: center var(--background-color-secondary--darker-5) url("./icons/dismiss.svg?primary=text-color") no-repeat; + border-radius: 100%; + height: 15px; + width: 15px; +} + +.CallToastNotificationView__name { + font-weight: 600; + width: 100%; +} + +.CallToastNotificationView__description { + margin-left: 42px; +} + +.CallToastNotificationView__call-type::before { + content: url("./icons/video-call.svg?primary=light-text-color"); + display: flex; + width: 20px; + height: 20px; + padding-right: 5px; +} + +.CallToastNotificationView__call-type::after { + content: ""; + width: 4px; + height: 4px; + background-color: var(--text-color); + border-radius: 100%; + align-self: center; + margin: 5px; +} + +.CallToastNotificationView__member-count::before { + content: url("./icons/room-members.svg?primary=light-text-color"); + display: flex; + width: 20px; + height: 20px; + padding-right: 5px; +} + +.CallToastNotificationView__member-count, +.CallToastNotificationView__call-type { + display: flex; + align-items: center; +} + +.CallToastNotificationView__info { + display: flex; + margin-left: 42px; +} + +.CallToastNotificationView__action { + display: flex; + justify-content: end; + margin-right: 10px; +} + +.CallToastNotificationView__action .button-action { + width: 100px; + height: 40px; +} diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index 7bcd8c0f..9f84e872 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -30,6 +30,7 @@ import {CreateRoomView} from "./CreateRoomView.js"; import {RightPanelView} from "./rightpanel/RightPanelView.js"; import {viewClassForTile} from "./room/common"; import {JoinRoomView} from "./JoinRoomView"; +import {ToastCollectionView} from "./toast/ToastCollectionView"; export class SessionView extends TemplateView { render(t, vm) { @@ -40,6 +41,7 @@ export class SessionView extends TemplateView { "right-shown": vm => !!vm.rightPanelViewModel }, }, [ + t.view(new ToastCollectionView(vm.toastCollectionViewModel)), t.view(new SessionStatusView(vm.sessionStatusViewModel)), t.view(new LeftPanelView(vm.leftPanelViewModel)), t.mapView(vm => vm.activeMiddleViewModel, () => { diff --git a/src/platform/web/ui/session/toast/CallToastNotificationView.ts b/src/platform/web/ui/session/toast/CallToastNotificationView.ts new file mode 100644 index 00000000..50adcc7b --- /dev/null +++ b/src/platform/web/ui/session/toast/CallToastNotificationView.ts @@ -0,0 +1,51 @@ +/* +Copyright 2023 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 {AvatarView} from "../../AvatarView.js"; +import {ErrorView} from "../../general/ErrorView"; +import {TemplateView, Builder} from "../../general/TemplateView"; +import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/CallToastNotificationViewModel"; + +export class CallToastNotificationView extends TemplateView { + render(t: Builder, vm: CallToastNotificationViewModel) { + return t.div({ className: "CallToastNotificationView" }, [ + t.div({ className: "CallToastNotificationView__top" }, [ + t.view(new AvatarView(vm, 24)), + t.span({ className: "CallToastNotificationView__name" }, (vm) => vm.roomName), + t.button({ + className: "button-action CallToastNotificationView__dismiss-btn", + onClick: () => vm.dismiss(), + }), + ]), + t.div({ className: "CallToastNotificationView__description" }, [ + t.span(vm.i18n`Video call started`) + ]), + t.div({ className: "CallToastNotificationView__info" }, [ + t.span({className: "CallToastNotificationView__call-type"}, vm.i18n`Video`), + t.span({className: "CallToastNotificationView__member-count"}, (vm) => vm.memberCount), + ]), + t.div({ className: "CallToastNotificationView__action" }, [ + t.button({ + className: "button-action primary", + onClick: () => vm.join(), + }, vm.i18n`Join`), + ]), + t.if(vm => !!vm.errorViewModel, t => { + return t.div({className: "CallView_error"}, t.view(new ErrorView(vm.errorViewModel!))); + }), + ]); + } +} diff --git a/src/platform/web/ui/session/toast/ToastCollectionView.ts b/src/platform/web/ui/session/toast/ToastCollectionView.ts new file mode 100644 index 00000000..3dc99c77 --- /dev/null +++ b/src/platform/web/ui/session/toast/ToastCollectionView.ts @@ -0,0 +1,33 @@ +/* +Copyright 2023 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 {CallToastNotificationView} from "./CallToastNotificationView"; +import {ListView} from "../../general/ListView"; +import {TemplateView, Builder} from "../../general/TemplateView"; +import type {CallToastNotificationViewModel} from "../../../../../domain/session/toast/CallToastNotificationViewModel"; +import type {ToastCollectionViewModel} from "../../../../../domain/session/toast/ToastCollectionViewModel"; + +export class ToastCollectionView extends TemplateView { + render(t: Builder, vm: ToastCollectionViewModel) { + const view = new ListView({ + list: vm.toastViewModels, + parentProvidesUpdates: false, + }, (vm: CallToastNotificationViewModel) => new CallToastNotificationView(vm)); + return t.div({ className: "ToastCollectionView" }, [ + t.view(view), + ]); + } +}