diff --git a/src/domain/session/room/timeline/tiles/CallTile.js b/src/domain/session/room/timeline/tiles/CallTile.js index c792496e..4583de49 100644 --- a/src/domain/session/room/timeline/tiles/CallTile.js +++ b/src/domain/session/room/timeline/tiles/CallTile.js @@ -15,7 +15,11 @@ limitations under the License. */ import {SimpleTile} from "./SimpleTile.js"; +import {ViewModel} from "../../../../ViewModel"; import {LocalMedia} from "../../../../../matrix/calls/LocalMedia"; +import {CallType} from "../../../../../matrix/calls/callEventTypes"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../../../avatar"; + // TODO: timeline entries for state events with the same state key and type // should also update previous entries in the timeline, so we can update the name of the call, whether it is terminated, etc ... @@ -25,32 +29,66 @@ export class CallTile extends SimpleTile { constructor(entry, options) { super(entry, options); const calls = this.getOption("session").callHandler.calls; - this._call = calls.get(this._entry.stateKey); this._callSubscription = undefined; - if (this._call) { - this._callSubscription = this._call.disposableOn("change", () => { - // unsubscribe when terminated - if (this._call.isTerminated) { - this._callSubscription = this._callSubscription(); - this._call = undefined; - } - this.emitChange(); - }); + const call = calls.get(this._entry.stateKey); + if (call && !call.isTerminated) { + this._call = call; + this.memberViewModels = this._setupMembersList(this._call); + this._callSubscription = this.track(this._call.disposableOn("change", () => { + this._onCallUpdate(); + })); + this._onCallUpdate(); } } + _onCallUpdate() { + // unsubscribe when terminated + if (this._call.isTerminated) { + this._durationInterval = this.disposeTracked(this._durationInterval); + this._callSubscription = this.disposeTracked(this._callSubscription); + this._call = undefined; + } else if (!this._durationInterval) { + this._durationInterval = this.track(this.platform.clock.createInterval(() => { + this.emitChange("duration"); + }, 1000)); + } + this.emitChange(); + } + + _setupMembersList(call) { + return call.members.mapValues( + (member, emitChange) => new MemberAvatarViewModel(this.childOptions({ + member, + emitChange, + mediaRepository: this.getOption("room").mediaRepository + })), + ).sortValues((a, b) => a.userId.localeCompare(b.userId)); + } + + get memberCount() { + // TODO: emit updates for this property + if (this._call) { + return this._call.members.size; + } + return 0; + } + get confId() { return this._entry.stateKey; } + + get duration() { + if (this._call && this._call.duration) { + return this.timeFormatter.formatDuration(this._call.duration); + } else { + return ""; + } + } get shape() { return "call"; } - get name() { - return this._entry.content["m.name"]; - } - get canJoin() { return this._call && !this._call.hasJoined; } @@ -59,18 +97,34 @@ export class CallTile extends SimpleTile { return this._call && this._call.hasJoined; } - get label() { + get title() { if (this._call) { - if (this._call.hasJoined) { - return `Ongoing call (${this.name}, ${this.confId})`; + if (this.type === CallType.Video) { + return `${this.displayName} started a video call`; } else { - return `${this.displayName} started a call (${this.name}, ${this.confId})`; + return `${this.displayName} started a voice call`; } } else { - return `Call finished, started by ${this.displayName} (${this.name}, ${this.confId})`; + if (this.type === CallType.Video) { + return `Video call ended`; + } else { + return `Voice call ended`; + } } } + get typeLabel() { + if (this.type === CallType.Video) { + return `Video call`; + } else { + return `Voice call`; + } + } + + get type() { + return this._entry.event.content["m.type"]; + } + async join() { await this.logAndCatch("CallTile.join", async log => { if (this.canJoin) { @@ -88,10 +142,32 @@ export class CallTile extends SimpleTile { } }); } +} - dispose() { - if (this._callSubscription) { - this._callSubscription = this._callSubscription(); - } +class MemberAvatarViewModel extends ViewModel { + get _member() { + return this.getOption("member"); + } + + get userId() { + return this._member.userId; + } + + get avatarLetter() { + return avatarInitials(this._member.member.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._member.userId); + } + + avatarUrl(size) { + const {avatarUrl} = this._member.member; + const mediaRepository = this.getOption("mediaRepository"); + return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository); + } + + get avatarTitle() { + return this._member.member.name; } } diff --git a/src/matrix/calls/CallHandler.ts b/src/matrix/calls/CallHandler.ts index e153c761..af01beef 100644 --- a/src/matrix/calls/CallHandler.ts +++ b/src/matrix/calls/CallHandler.ts @@ -18,7 +18,7 @@ import {ObservableMap} from "../../observable/map"; import {WebRTC, PeerConnection} from "../../platform/types/WebRTC"; import {MediaDevices, Track} from "../../platform/types/MediaDevices"; import {handlesEventType} from "./PeerCall"; -import {EventType, CallIntent} from "./callEventTypes"; +import {EventType, CallIntent, CallType} from "./callEventTypes"; import {GroupCall} from "./group/GroupCall"; import {makeId} from "../common"; import {CALL_LOG_TYPE} from "./common"; @@ -104,7 +104,15 @@ 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, true, false, event.event.content, event.roomId, this.groupCallOptions); + const call = new GroupCall( + event.event.state_key, // id + true, // isLoadedFromStorage + false, // newCall + callEntry.timestamp, // startTime + event.event.content, // callContent + event.roomId, // roomId + this.groupCallOptions // options + ); this._calls.set(call.id, call); } })); @@ -130,15 +138,20 @@ export class CallHandler implements RoomStateHandler { log.set("newSize", this._calls.size); } - createCall(roomId: string, type: "m.video" | "m.voice", name: string, intent?: CallIntent, log?: ILogItem): Promise { + createCall(roomId: string, type: CallType, name: string, intent?: CallIntent, log?: ILogItem): Promise { return this.options.logger.wrapOrRun(log, "CallHandler.createCall", async log => { if (!intent) { intent = CallIntent.Ring; } - const call = new GroupCall(makeId("conf-"), false, true, { - "m.name": name, - "m.intent": intent - }, roomId, this.groupCallOptions); + const call = new GroupCall( + makeId("conf-"), // id + false, // isLoadedFromStorage + true, // newCall + undefined, // startTime + {"m.name": name, "m.intent": intent}, // callContent + roomId, // roomId + this.groupCallOptions // options + ); this._calls.set(call.id, call); try { @@ -210,14 +223,22 @@ export class CallHandler implements RoomStateHandler { const callId = event.state_key; let call = this._calls.get(callId); if (call) { - call.updateCallEvent(event.content, log); + call.updateCallEvent(event, log); if (call.isTerminated) { call.disconnect(log); this._calls.remove(call.id); txn.calls.remove(call.intent, roomId, call.id); } } else { - call = new GroupCall(event.state_key, false, false, event.content, roomId, this.groupCallOptions); + call = new GroupCall( + event.state_key, // id + false, // isLoadedFromStorage + false, // newCall + event.origin_server_ts, // startTime + event.content, // callContent + roomId, // roomId + this.groupCallOptions // options + ); this._calls.set(call.id, call); txn.calls.add({ intent: call.intent, diff --git a/src/matrix/calls/callEventTypes.ts b/src/matrix/calls/callEventTypes.ts index 8fd7b23d..a0eb986c 100644 --- a/src/matrix/calls/callEventTypes.ts +++ b/src/matrix/calls/callEventTypes.ts @@ -227,3 +227,8 @@ export enum CallIntent { Prompt = "m.prompt", Room = "m.room", }; + +export enum CallType { + Video = "m.video", + Voice = "m.voice", +} diff --git a/src/matrix/calls/group/GroupCall.ts b/src/matrix/calls/group/GroupCall.ts index 04b20ad3..7f9ba96e 100644 --- a/src/matrix/calls/group/GroupCall.ts +++ b/src/matrix/calls/group/GroupCall.ts @@ -20,7 +20,7 @@ import {LocalMedia} from "../LocalMedia"; import {MuteSettings, CALL_LOG_TYPE, CALL_MEMBER_VALIDITY_PERIOD_MS, mute} from "../common"; import {MemberChange, RoomMember} from "../../room/members/RoomMember"; import {EventEmitter} from "../../../utils/EventEmitter"; -import {EventType, CallIntent} from "../callEventTypes"; +import {EventType, CallIntent, CallType} from "../callEventTypes"; import { ErrorBoundary } from "../../../utils/ErrorBoundary"; import type {Options as MemberOptions} from "./Member"; @@ -106,6 +106,7 @@ export class GroupCall extends EventEmitter<{change: never}> { public readonly id: string, public readonly isLoadedFromStorage: boolean, newCall: boolean, + private startTime: number | undefined, private callContent: Record, public readonly roomId: string, private readonly options: Options, @@ -144,6 +145,12 @@ export class GroupCall extends EventEmitter<{change: never}> { return !!this.callContent?.["m.terminated"]; } + get duration(): number | undefined { + if (typeof this.startTime === "number") { + return (this.options.clock.now() - this.startTime); + } + } + get isRinging(): boolean { return this._state === GroupCallState.Created && this.intent === "m.ring" && !this.isMember(this.options.ownUserId); } @@ -156,6 +163,10 @@ export class GroupCall extends EventEmitter<{change: never}> { return this.callContent?.["m.intent"]; } + get type(): CallType { + return this.callContent?.["m.type"]; + } + /** * Gives access the log item for this call while joined. * Can be used for call diagnostics while in the call. @@ -319,7 +330,7 @@ export class GroupCall extends EventEmitter<{change: never}> { } /** @internal */ - create(type: "m.video" | "m.voice", log: ILogItem): Promise { + create(type: CallType, log: ILogItem): Promise { return log.wrap({l: "create call", t: CALL_LOG_TYPE}, async log => { if (this._state !== GroupCallState.Fledgling) { return; @@ -337,10 +348,14 @@ export class GroupCall extends EventEmitter<{change: never}> { } /** @internal */ - updateCallEvent(callContent: Record, syncLog: ILogItem) { + updateCallEvent(event: StateEvent, syncLog: ILogItem) { this.errorBoundary.try(() => { syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => { - this.callContent = callContent; + + if (typeof this.startTime !== "number") { + this.startTime = event.origin_server_ts; + } + this.callContent = event.content; if (this._state === GroupCallState.Creating) { this._state = GroupCallState.Created; } diff --git a/src/platform/types/types.ts b/src/platform/types/types.ts index 77953c4a..0e2f536e 100644 --- a/src/platform/types/types.ts +++ b/src/platform/types/types.ts @@ -55,4 +55,5 @@ export interface ITimeFormatter { formatTime(date: Date): string; formatRelativeDate(date: Date): string; formatMachineReadableDate(date: Date): string; + formatDuration(milliseconds: number): string; } diff --git a/src/platform/web/dom/TimeFormatter.ts b/src/platform/web/dom/TimeFormatter.ts index 2a98a716..c25e902b 100644 --- a/src/platform/web/dom/TimeFormatter.ts +++ b/src/platform/web/dom/TimeFormatter.ts @@ -16,11 +16,7 @@ limitations under the License. import type { ITimeFormatter } from "../../types/types"; import {Clock} from "./Clock"; - -enum TimeScope { - Minute = 60 * 1000, - Day = 24 * 60 * 60 * 1000, -} +import {formatDuration, TimeScope} from "../../../utils/timeFormatting"; export class TimeFormatter implements ITimeFormatter { @@ -75,6 +71,10 @@ export class TimeFormatter implements ITimeFormatter { return this.otherYearFormatter.format(date); } } + + formatDuration(milliseconds: number): string { + return formatDuration(milliseconds); + } } function capitalizeFirstLetter(str: string) { diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 7ff35eb1..16c758a3 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -440,4 +440,35 @@ only loads when the top comes into view*/ background-color: var(--background-color-primary); border-radius: 8px; text-align: center; - } +} + +.CallTileView > div > div { + display: flex; + flex-direction: column; + gap: 4px; +} + +.CallTileView_members > * { + margin-right: -16px; +} + +.CallTileView_members { + display: flex; +} + +.CallTileView_title { + font-weight: bold; +} + +.CallTileView_subtitle { + font-size: 12px; +} + +.CallTileView_memberCount::before { + content: url('./icons/room-members.svg?primary=text-color'); + width: 16px; + height: 16px; + display: inline-flex; + vertical-align: bottom; + margin-right: 4px; +} diff --git a/src/platform/web/ui/session/room/timeline/CallTileView.ts b/src/platform/web/ui/session/room/timeline/CallTileView.ts index 5bede510..a9a872b3 100644 --- a/src/platform/web/ui/session/room/timeline/CallTileView.ts +++ b/src/platform/web/ui/session/room/timeline/CallTileView.ts @@ -17,6 +17,8 @@ limitations under the License. import {Builder, TemplateView} from "../../../general/TemplateView"; import type {CallTile} from "../../../../../../domain/session/room/timeline/tiles/CallTile"; import {ErrorView} from "../../../general/ErrorView"; +import {ListView} from "../../../general/ListView"; +import {AvatarView} from "../../../AvatarView"; export class CallTileView extends TemplateView { render(t: Builder, vm: CallTile) { @@ -28,9 +30,19 @@ export class CallTileView extends TemplateView { return t.div({className: "CallTileView_error"}, t.view(new ErrorView(vm.errorViewModel, {inline: true}))); }), t.div([ - vm => vm.label, - t.button({className: "CallTileView_join", hidden: vm => !vm.canJoin}, "Join"), - t.button({className: "CallTileView_leave", hidden: vm => !vm.canLeave}, "Leave") + t.div({className: "CallTileView_title"}, vm => vm.title), + t.div({className: "CallTileView_subtitle"}, [ + vm.typeLabel, " • ", + t.span({className: "CallTileView_memberCount"}, vm => vm.memberCount) + ]), + t.view(new ListView({className: "CallTileView_members", tagName: "div", list: vm.memberViewModels}, vm => { + return new AvatarView(vm, 24); + })), + t.div(vm => vm.duration), + t.div([ + t.button({className: "CallTileView_join button-action primary", hidden: vm => !vm.canJoin}, "Join"), + t.button({className: "CallTileView_leave button-action primary destructive", hidden: vm => !vm.canLeave}, "Leave") + ]) ]) ]) ); @@ -38,9 +50,9 @@ export class CallTileView extends TemplateView { /* This is called by the parent ListView, which just has 1 listener for the whole list */ onClick(evt) { - if (evt.target.className === "CallTileView_join") { + if (evt.target.classList.contains("CallTileView_join")) { this.value.join(); - } else if (evt.target.className === "CallTileView_leave") { + } else if (evt.target.classList.contains("CallTileView_leave")) { this.value.leave(); } } diff --git a/src/utils/timeFormatting.ts b/src/utils/timeFormatting.ts new file mode 100644 index 00000000..2f2ae8c0 --- /dev/null +++ b/src/utils/timeFormatting.ts @@ -0,0 +1,52 @@ +/* +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. +*/ + +export enum TimeScope { + Minute = 60 * 1000, + Hours = 60 * TimeScope.Minute, + Day = 24 * TimeScope.Hours, +} + +export function formatDuration(milliseconds: number): string { + let days = 0; + let hours = 0; + let minutes = 0; + if (milliseconds >= TimeScope.Day) { + days = Math.floor(milliseconds / TimeScope.Day); + milliseconds -= days * TimeScope.Day; + } + if (milliseconds >= TimeScope.Hours) { + hours = Math.floor(milliseconds / TimeScope.Hours); + milliseconds -= hours * TimeScope.Hours; + } + if (milliseconds >= TimeScope.Minute) { + minutes = Math.floor(milliseconds / TimeScope.Minute); + milliseconds -= minutes * TimeScope.Minute; + } + const seconds = Math.floor(milliseconds / 1000); + let result = ""; + if (days) { + result = `${days}d `; + } + if (hours || days) { + result += `${hours}h `; + } + if (minutes || hours || days) { + result += `${minutes}m `; + } + result += `${seconds}s`; + return result; +}