Merge pull request #1003 from vector-im/bwindels/calltile-ui

Improve CallTile UI
This commit is contained in:
Bruno Windels 2023-01-26 14:44:51 +01:00 committed by GitHub
commit 38d5a4412b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 260 additions and 47 deletions

View File

@ -15,7 +15,11 @@ limitations under the License.
*/ */
import {SimpleTile} from "./SimpleTile.js"; import {SimpleTile} from "./SimpleTile.js";
import {ViewModel} from "../../../../ViewModel";
import {LocalMedia} from "../../../../../matrix/calls/LocalMedia"; 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 // 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 ... // should also update previous entries in the timeline, so we can update the name of the call, whether it is terminated, etc ...
@ -25,30 +29,64 @@ export class CallTile extends SimpleTile {
constructor(entry, options) { constructor(entry, options) {
super(entry, options); super(entry, options);
const calls = this.getOption("session").callHandler.calls; const calls = this.getOption("session").callHandler.calls;
this._call = calls.get(this._entry.stateKey);
this._callSubscription = undefined; this._callSubscription = undefined;
if (this._call) { const call = calls.get(this._entry.stateKey);
this._callSubscription = this._call.disposableOn("change", () => { if (call && !call.isTerminated) {
// unsubscribe when terminated this._call = call;
if (this._call.isTerminated) { this.memberViewModels = this._setupMembersList(this._call);
this._callSubscription = this._callSubscription(); this._callSubscription = this.track(this._call.disposableOn("change", () => {
this._call = undefined; this._onCallUpdate();
} }));
this.emitChange(); 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() { get confId() {
return this._entry.stateKey; return this._entry.stateKey;
} }
get shape() { get duration() {
return "call"; if (this._call && this._call.duration) {
return this.timeFormatter.formatDuration(this._call.duration);
} else {
return "";
}
} }
get name() { get shape() {
return this._entry.content["m.name"]; return "call";
} }
get canJoin() { get canJoin() {
@ -59,18 +97,34 @@ export class CallTile extends SimpleTile {
return this._call && this._call.hasJoined; return this._call && this._call.hasJoined;
} }
get label() { get title() {
if (this._call) { if (this._call) {
if (this._call.hasJoined) { if (this.type === CallType.Video) {
return `Ongoing call (${this.name}, ${this.confId})`; return `${this.displayName} started a video call`;
} else { } else {
return `${this.displayName} started a call (${this.name}, ${this.confId})`; return `${this.displayName} started a voice call`;
} }
} else { } 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() { async join() {
await this.logAndCatch("CallTile.join", async log => { await this.logAndCatch("CallTile.join", async log => {
if (this.canJoin) { if (this.canJoin) {
@ -88,10 +142,32 @@ export class CallTile extends SimpleTile {
} }
}); });
} }
}
dispose() { class MemberAvatarViewModel extends ViewModel {
if (this._callSubscription) { get _member() {
this._callSubscription = this._callSubscription(); 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;
} }
} }

View File

@ -18,7 +18,7 @@ import {ObservableMap} from "../../observable/map";
import {WebRTC, PeerConnection} from "../../platform/types/WebRTC"; import {WebRTC, PeerConnection} from "../../platform/types/WebRTC";
import {MediaDevices, Track} from "../../platform/types/MediaDevices"; import {MediaDevices, Track} from "../../platform/types/MediaDevices";
import {handlesEventType} from "./PeerCall"; import {handlesEventType} from "./PeerCall";
import {EventType, CallIntent} from "./callEventTypes"; import {EventType, CallIntent, CallType} from "./callEventTypes";
import {GroupCall} from "./group/GroupCall"; import {GroupCall} from "./group/GroupCall";
import {makeId} from "../common"; import {makeId} from "../common";
import {CALL_LOG_TYPE} 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); const event = await txn.roomState.get(callEntry.roomId, EventType.GroupCall, callEntry.callId);
if (event) { 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); this._calls.set(call.id, call);
} }
})); }));
@ -130,15 +138,20 @@ export class CallHandler implements RoomStateHandler {
log.set("newSize", this._calls.size); log.set("newSize", this._calls.size);
} }
createCall(roomId: string, type: "m.video" | "m.voice", name: string, intent?: CallIntent, log?: ILogItem): Promise<GroupCall> { createCall(roomId: string, type: CallType, name: string, intent?: CallIntent, log?: ILogItem): Promise<GroupCall> {
return this.options.logger.wrapOrRun(log, "CallHandler.createCall", async log => { return this.options.logger.wrapOrRun(log, "CallHandler.createCall", async log => {
if (!intent) { if (!intent) {
intent = CallIntent.Ring; intent = CallIntent.Ring;
} }
const call = new GroupCall(makeId("conf-"), false, true, { const call = new GroupCall(
"m.name": name, makeId("conf-"), // id
"m.intent": intent false, // isLoadedFromStorage
}, roomId, this.groupCallOptions); true, // newCall
undefined, // startTime
{"m.name": name, "m.intent": intent}, // callContent
roomId, // roomId
this.groupCallOptions // options
);
this._calls.set(call.id, call); this._calls.set(call.id, call);
try { try {
@ -210,14 +223,22 @@ export class CallHandler implements RoomStateHandler {
const callId = event.state_key; const callId = event.state_key;
let call = this._calls.get(callId); let call = this._calls.get(callId);
if (call) { if (call) {
call.updateCallEvent(event.content, log); call.updateCallEvent(event, log);
if (call.isTerminated) { if (call.isTerminated) {
call.disconnect(log); call.disconnect(log);
this._calls.remove(call.id); this._calls.remove(call.id);
txn.calls.remove(call.intent, roomId, call.id); txn.calls.remove(call.intent, roomId, call.id);
} }
} else { } 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); this._calls.set(call.id, call);
txn.calls.add({ txn.calls.add({
intent: call.intent, intent: call.intent,

View File

@ -227,3 +227,8 @@ export enum CallIntent {
Prompt = "m.prompt", Prompt = "m.prompt",
Room = "m.room", Room = "m.room",
}; };
export enum CallType {
Video = "m.video",
Voice = "m.voice",
}

View File

@ -20,7 +20,7 @@ import {LocalMedia} from "../LocalMedia";
import {MuteSettings, CALL_LOG_TYPE, CALL_MEMBER_VALIDITY_PERIOD_MS, mute} from "../common"; import {MuteSettings, CALL_LOG_TYPE, CALL_MEMBER_VALIDITY_PERIOD_MS, mute} from "../common";
import {MemberChange, RoomMember} from "../../room/members/RoomMember"; import {MemberChange, RoomMember} from "../../room/members/RoomMember";
import {EventEmitter} from "../../../utils/EventEmitter"; import {EventEmitter} from "../../../utils/EventEmitter";
import {EventType, CallIntent} from "../callEventTypes"; import {EventType, CallIntent, CallType} from "../callEventTypes";
import { ErrorBoundary } from "../../../utils/ErrorBoundary"; import { ErrorBoundary } from "../../../utils/ErrorBoundary";
import type {Options as MemberOptions} from "./Member"; import type {Options as MemberOptions} from "./Member";
@ -106,6 +106,7 @@ export class GroupCall extends EventEmitter<{change: never}> {
public readonly id: string, public readonly id: string,
public readonly isLoadedFromStorage: boolean, public readonly isLoadedFromStorage: boolean,
newCall: boolean, newCall: boolean,
private startTime: number | undefined,
private callContent: Record<string, any>, private callContent: Record<string, any>,
public readonly roomId: string, public readonly roomId: string,
private readonly options: Options, private readonly options: Options,
@ -144,6 +145,12 @@ export class GroupCall extends EventEmitter<{change: never}> {
return !!this.callContent?.["m.terminated"]; return !!this.callContent?.["m.terminated"];
} }
get duration(): number | undefined {
if (typeof this.startTime === "number") {
return (this.options.clock.now() - this.startTime);
}
}
get isRinging(): boolean { get isRinging(): boolean {
return this._state === GroupCallState.Created && this.intent === "m.ring" && !this.isMember(this.options.ownUserId); 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"]; return this.callContent?.["m.intent"];
} }
get type(): CallType {
return this.callContent?.["m.type"];
}
/** /**
* Gives access the log item for this call while joined. * Gives access the log item for this call while joined.
* Can be used for call diagnostics while in the call. * Can be used for call diagnostics while in the call.
@ -319,7 +330,7 @@ export class GroupCall extends EventEmitter<{change: never}> {
} }
/** @internal */ /** @internal */
create(type: "m.video" | "m.voice", log: ILogItem): Promise<void> { create(type: CallType, log: ILogItem): Promise<void> {
return log.wrap({l: "create call", t: CALL_LOG_TYPE}, async log => { return log.wrap({l: "create call", t: CALL_LOG_TYPE}, async log => {
if (this._state !== GroupCallState.Fledgling) { if (this._state !== GroupCallState.Fledgling) {
return; return;
@ -337,10 +348,14 @@ export class GroupCall extends EventEmitter<{change: never}> {
} }
/** @internal */ /** @internal */
updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) { updateCallEvent(event: StateEvent, syncLog: ILogItem) {
this.errorBoundary.try(() => { this.errorBoundary.try(() => {
syncLog.wrap({l: "update call", t: CALL_LOG_TYPE, id: this.id}, log => { 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) { if (this._state === GroupCallState.Creating) {
this._state = GroupCallState.Created; this._state = GroupCallState.Created;
} }

View File

@ -55,4 +55,5 @@ export interface ITimeFormatter {
formatTime(date: Date): string; formatTime(date: Date): string;
formatRelativeDate(date: Date): string; formatRelativeDate(date: Date): string;
formatMachineReadableDate(date: Date): string; formatMachineReadableDate(date: Date): string;
formatDuration(milliseconds: number): string;
} }

View File

@ -16,11 +16,7 @@ limitations under the License.
import type { ITimeFormatter } from "../../types/types"; import type { ITimeFormatter } from "../../types/types";
import {Clock} from "./Clock"; import {Clock} from "./Clock";
import {formatDuration, TimeScope} from "../../../utils/timeFormatting";
enum TimeScope {
Minute = 60 * 1000,
Day = 24 * 60 * 60 * 1000,
}
export class TimeFormatter implements ITimeFormatter { export class TimeFormatter implements ITimeFormatter {
@ -75,6 +71,10 @@ export class TimeFormatter implements ITimeFormatter {
return this.otherYearFormatter.format(date); return this.otherYearFormatter.format(date);
} }
} }
formatDuration(milliseconds: number): string {
return formatDuration(milliseconds);
}
} }
function capitalizeFirstLetter(str: string) { function capitalizeFirstLetter(str: string) {

View File

@ -440,4 +440,35 @@ only loads when the top comes into view*/
background-color: var(--background-color-primary); background-color: var(--background-color-primary);
border-radius: 8px; border-radius: 8px;
text-align: center; 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;
}

View File

@ -17,6 +17,8 @@ limitations under the License.
import {Builder, TemplateView} from "../../../general/TemplateView"; import {Builder, TemplateView} from "../../../general/TemplateView";
import type {CallTile} from "../../../../../../domain/session/room/timeline/tiles/CallTile"; import type {CallTile} from "../../../../../../domain/session/room/timeline/tiles/CallTile";
import {ErrorView} from "../../../general/ErrorView"; import {ErrorView} from "../../../general/ErrorView";
import {ListView} from "../../../general/ListView";
import {AvatarView} from "../../../AvatarView";
export class CallTileView extends TemplateView<CallTile> { export class CallTileView extends TemplateView<CallTile> {
render(t: Builder<CallTile>, vm: CallTile) { render(t: Builder<CallTile>, vm: CallTile) {
@ -28,9 +30,19 @@ export class CallTileView extends TemplateView<CallTile> {
return t.div({className: "CallTileView_error"}, t.view(new ErrorView(vm.errorViewModel, {inline: true}))); return t.div({className: "CallTileView_error"}, t.view(new ErrorView(vm.errorViewModel, {inline: true})));
}), }),
t.div([ t.div([
vm => vm.label, t.div({className: "CallTileView_title"}, vm => vm.title),
t.button({className: "CallTileView_join", hidden: vm => !vm.canJoin}, "Join"), t.div({className: "CallTileView_subtitle"}, [
t.button({className: "CallTileView_leave", hidden: vm => !vm.canLeave}, "Leave") 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<CallTile> {
/* This is called by the parent ListView, which just has 1 listener for the whole list */ /* This is called by the parent ListView, which just has 1 listener for the whole list */
onClick(evt) { onClick(evt) {
if (evt.target.className === "CallTileView_join") { if (evt.target.classList.contains("CallTileView_join")) {
this.value.join(); this.value.join();
} else if (evt.target.className === "CallTileView_leave") { } else if (evt.target.classList.contains("CallTileView_leave")) {
this.value.leave(); this.value.leave();
} }
} }

View File

@ -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;
}