Compare commits
96 commits
master
...
bwindels/c
Author | SHA1 | Date | |
---|---|---|---|
|
f61064c462 | ||
|
433dc957ee | ||
|
c7f7d24273 | ||
|
330f234b5a | ||
|
3198ca6a92 | ||
|
3767f6a420 | ||
|
6e1174e03d | ||
|
14dbe340c7 | ||
|
a52423856d | ||
|
22df062bbb | ||
|
8b16782270 | ||
|
39ecc6cc6d | ||
|
cdb2a79b62 | ||
|
ac60d1b61d | ||
|
baa884e9d0 | ||
|
10a6269147 | ||
|
55c6dcf613 | ||
|
99769eb84e | ||
|
82ffb557e5 | ||
|
4a8af83c8f | ||
|
c42292f1b0 | ||
|
382fba88bd | ||
|
468a0a9698 | ||
|
ea1c3a2b86 | ||
|
021b8cdcdc | ||
|
ff856d843c | ||
|
55097e4154 | ||
|
2d00d10161 | ||
|
bc118b5c0b | ||
|
2d4301fe5a | ||
|
36dc463d23 | ||
|
0e9307608b | ||
|
2635adb232 | ||
|
797cb23cc7 | ||
|
fd5b2aa7bb | ||
|
d734a61447 | ||
|
a710f394eb | ||
|
517e796e90 | ||
|
5cacdcfee0 | ||
|
c99fc2ad70 | ||
|
e0efbaeb4e | ||
|
387bad73b0 | ||
|
9be64730b6 | ||
|
b84c90891c | ||
|
c02e1de001 | ||
|
8e82aad86b | ||
|
8153060831 | ||
|
302d4bc02d | ||
|
1b0abebe8f | ||
|
156f5b78bf | ||
|
8a06663023 | ||
|
ad140d5af1 | ||
|
a78ae52a54 | ||
|
b133f58f7a | ||
|
bade40acc6 | ||
|
1dc46127c3 | ||
|
79411437cf | ||
|
6472800387 | ||
|
fe6e7b09b5 | ||
|
ad1cceac86 | ||
|
2852834ce3 | ||
|
1ad5db73a9 | ||
|
42b470b06b | ||
|
d7360e7741 | ||
|
c54ffd4fc3 | ||
|
ba45178e04 | ||
|
11a9177592 | ||
|
4bf171def9 | ||
|
eaf92b382b | ||
|
a0a07355d4 | ||
|
0a37fd561e | ||
|
9efd191f4e | ||
|
cad2aa760d | ||
|
4be82cd472 | ||
|
e760b8e556 | ||
|
e482e3aeef | ||
|
6daae797e5 | ||
|
07bc0a2376 | ||
|
1bccbbfa08 | ||
|
f674492685 | ||
|
3c160c8a37 | ||
|
b213a45c5c | ||
|
b2ac4bc291 | ||
|
6da4a4209c | ||
|
4bedd4737b | ||
|
60da85d641 | ||
|
6fe90e60db | ||
|
ecf7eab3ee | ||
|
25b0148073 | ||
|
98b77fc761 | ||
|
179c7e74b5 | ||
|
98e1dcf799 | ||
|
e5f44aecfb | ||
|
468841ecea | ||
|
b12bc52c4a | ||
|
46ebd55092 |
81 changed files with 4450 additions and 336 deletions
|
@ -46,7 +46,7 @@
|
|||
"postcss-value-parser": "^4.2.0",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"text-encoding": "^0.7.0",
|
||||
"typescript": "^4.3.5",
|
||||
"typescript": "^4.4",
|
||||
"vite": "^2.6.14",
|
||||
"xxhashjs": "^0.2.2"
|
||||
},
|
||||
|
|
|
@ -22,6 +22,7 @@ const main = document.querySelector("main");
|
|||
let selectedItemNode;
|
||||
let rootItem;
|
||||
let itemByRef;
|
||||
let itemsRefFrom;
|
||||
|
||||
const logLevels = [undefined, "All", "Debug", "Detail", "Info", "Warn", "Error", "Fatal", "Off"];
|
||||
|
||||
|
@ -49,6 +50,7 @@ window.addEventListener("hashchange", () => {
|
|||
const id = window.location.hash.substr(1);
|
||||
const itemNode = document.getElementById(id);
|
||||
if (itemNode && itemNode.closest("main")) {
|
||||
ensureParentsExpanded(itemNode);
|
||||
selectNode(itemNode);
|
||||
itemNode.scrollIntoView({behavior: "smooth", block: "nearest"});
|
||||
}
|
||||
|
@ -70,6 +72,14 @@ function selectNode(itemNode) {
|
|||
showItemDetails(item, parent, selectedItemNode);
|
||||
}
|
||||
|
||||
function ensureParentsExpanded(itemNode) {
|
||||
let li = itemNode.parentElement.parentElement;
|
||||
while (li.tagName === "LI") {
|
||||
li.classList.add("expanded");
|
||||
li = li.parentElement.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyItemValue(value) {
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return JSON.stringify(value, undefined, 2);
|
||||
|
@ -102,6 +112,11 @@ function showItemDetails(item, parent, itemNode) {
|
|||
} else {
|
||||
valueNode = `unknown ref ${value}`;
|
||||
}
|
||||
} else if (key === "refId") {
|
||||
const refSources = itemsRefFrom.get(value) ?? [];
|
||||
valueNode = t.div([t.p([`${value}`, t.br(),`Found these references:`]),t.ul(refSources.map(item => {
|
||||
return t.li(t.a({href: `#${item.id}`}, itemCaption(item)));
|
||||
}))]);
|
||||
} else {
|
||||
valueNode = stringifyItemValue(value);
|
||||
}
|
||||
|
@ -153,7 +168,8 @@ async function loadFile() {
|
|||
logs.items.sort((a, b) => itemStart(a) - itemStart(b));
|
||||
rootItem = {c: logs.items};
|
||||
itemByRef = new Map();
|
||||
preprocessRecursively(rootItem, null, itemByRef, []);
|
||||
itemsRefFrom = new Map();
|
||||
preprocessRecursively(rootItem, null, itemByRef, itemsRefFrom, []);
|
||||
|
||||
const fragment = logs.items.reduce((fragment, item, i, items) => {
|
||||
const prevItem = i === 0 ? null : items[i - 1];
|
||||
|
@ -167,18 +183,26 @@ async function loadFile() {
|
|||
}
|
||||
|
||||
// TODO: make this use processRecursively
|
||||
function preprocessRecursively(item, parentElement, refsMap, path) {
|
||||
function preprocessRecursively(item, parentElement, refsMap, refsFromMap, path) {
|
||||
item.s = (parentElement?.s || 0) + item.s;
|
||||
if (itemRefSource(item)) {
|
||||
refsMap.set(itemRefSource(item), item);
|
||||
}
|
||||
if (itemRef(item)) {
|
||||
let refs = refsFromMap.get(itemRef(item));
|
||||
if (!refs) {
|
||||
refs = [];
|
||||
refsFromMap.set(itemRef(item), refs);
|
||||
}
|
||||
refs.push(item);
|
||||
}
|
||||
if (itemChildren(item)) {
|
||||
for (let i = 0; i < itemChildren(item).length; i += 1) {
|
||||
// do it in advance for a child as we don't want to do it for the rootItem
|
||||
const child = itemChildren(item)[i];
|
||||
const childPath = path.concat(i);
|
||||
child.id = childPath.join("/");
|
||||
preprocessRecursively(child, item, refsMap, childPath);
|
||||
preprocessRecursively(child, item, refsMap, refsFromMap, childPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -395,4 +419,4 @@ document.getElementById("showAll").addEventListener("click", () => {
|
|||
for (const node of document.querySelectorAll(".hidden")) {
|
||||
node.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
23
src/domain/AvatarSource.ts
Normal file
23
src/domain/AvatarSource.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export interface AvatarSource {
|
||||
get avatarLetter(): string;
|
||||
get avatarColorNumber(): number;
|
||||
avatarUrl(size: number): string | undefined;
|
||||
get avatarTitle(): string;
|
||||
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {SortedArray} from "../observable/index.js";
|
||||
import {SortedArray} from "../observable/index";
|
||||
import {ViewModel} from "./ViewModel";
|
||||
import {avatarInitials, getIdentifierColorNumber} from "./avatar";
|
||||
|
||||
|
|
|
@ -51,10 +51,10 @@ export function getIdentifierColorNumber(id: string): number {
|
|||
return (hashCode(id) % 8) + 1;
|
||||
}
|
||||
|
||||
export function getAvatarHttpUrl(avatarUrl: string, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | null {
|
||||
export function getAvatarHttpUrl(avatarUrl: string | undefined, cssSize: number, platform: Platform, mediaRepository: MediaRepository): string | undefined {
|
||||
if (avatarUrl) {
|
||||
const imageSize = cssSize * platform.devicePixelRatio;
|
||||
return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop");
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
|
|
@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue, ObservableValue} from "../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
|
||||
|
||||
export class Navigation {
|
||||
constructor(allowsChild) {
|
||||
|
|
|
@ -186,7 +186,7 @@ export class RoomGridViewModel extends ViewModel {
|
|||
}
|
||||
|
||||
import {createNavigation} from "../navigation/index.js";
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||
|
||||
export function tests() {
|
||||
class RoomVMMock {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||
import {RoomStatus} from "../../matrix/room/common";
|
||||
|
||||
/**
|
||||
|
|
|
@ -99,6 +99,9 @@ export class SessionViewModel extends ViewModel {
|
|||
|
||||
start() {
|
||||
this._sessionStatusViewModel.start();
|
||||
this._client.session.callHandler.loadCalls("m.ring");
|
||||
// TODO: only do this when opening the room
|
||||
this._client.session.callHandler.loadCalls("m.prompt");
|
||||
}
|
||||
|
||||
get activeMiddleViewModel() {
|
||||
|
@ -174,7 +177,7 @@ export class SessionViewModel extends ViewModel {
|
|||
_createRoomViewModelInstance(roomId) {
|
||||
const room = this._client.session.rooms.get(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
||||
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
|
||||
roomVM.load();
|
||||
return roomVM;
|
||||
}
|
||||
|
@ -191,7 +194,7 @@ export class SessionViewModel extends ViewModel {
|
|||
async _createArchivedRoomViewModel(roomId) {
|
||||
const room = await this._client.session.loadArchivedRoom(roomId);
|
||||
if (room) {
|
||||
const roomVM = new RoomViewModel(this.childOptions({room}));
|
||||
const roomVM = new RoomViewModel(this.childOptions({room, session: this._client.session}));
|
||||
roomVM.load();
|
||||
return roomVM;
|
||||
}
|
||||
|
|
180
src/domain/session/room/CallViewModel.ts
Normal file
180
src/domain/session/room/CallViewModel.ts
Normal file
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
Copyright 2022 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 {AvatarSource} from "../../AvatarSource";
|
||||
import {ViewModel, Options as BaseOptions} from "../../ViewModel";
|
||||
import {getStreamVideoTrack, getStreamAudioTrack} from "../../../matrix/calls/common";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import type {GroupCall} from "../../../matrix/calls/group/GroupCall";
|
||||
import type {Member} from "../../../matrix/calls/group/Member";
|
||||
import type {BaseObservableList} from "../../../observable/list/BaseObservableList";
|
||||
import {EventObservableValue} from "../../../observable/value/EventObservableValue";
|
||||
import {ObservableValueMap} from "../../../observable/map/ObservableValueMap";
|
||||
import type {Stream} from "../../../platform/types/MediaDevices";
|
||||
import type {MediaRepository} from "../../../matrix/net/MediaRepository";
|
||||
|
||||
type Options = BaseOptions & {
|
||||
call: GroupCall,
|
||||
mediaRepository: MediaRepository
|
||||
};
|
||||
|
||||
export class CallViewModel extends ViewModel<Options> {
|
||||
public readonly memberViewModels: BaseObservableList<CallMemberViewModel>;
|
||||
|
||||
constructor(options: Options) {
|
||||
super(options);
|
||||
const ownMemberViewModelMap = new ObservableValueMap("self", new EventObservableValue(this.call, "change"))
|
||||
.mapValues(call => new OwnMemberViewModel(this.childOptions({call: this.call, mediaRepository: this.getOption("mediaRepository")})), () => {});
|
||||
this.memberViewModels = this.call.members
|
||||
.filterValues(member => member.isConnected)
|
||||
.mapValues(member => new CallMemberViewModel(this.childOptions({member, mediaRepository: this.getOption("mediaRepository")})))
|
||||
.join(ownMemberViewModelMap)
|
||||
.sortValues((a, b) => a.compare(b));
|
||||
}
|
||||
|
||||
private get call(): GroupCall {
|
||||
return this.getOption("call");
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.call.name;
|
||||
}
|
||||
|
||||
get id(): string {
|
||||
return this.call.id;
|
||||
}
|
||||
|
||||
get stream(): Stream | undefined {
|
||||
return this.call.localMedia?.userMedia;
|
||||
}
|
||||
|
||||
leave() {
|
||||
if (this.call.hasJoined) {
|
||||
this.call.leave();
|
||||
}
|
||||
}
|
||||
|
||||
get isCameraMuted(): boolean {
|
||||
return this.call.muteSettings.camera;
|
||||
}
|
||||
|
||||
get isMicrophoneMuted(): boolean {
|
||||
return this.call.muteSettings.microphone;
|
||||
}
|
||||
|
||||
async toggleVideo() {
|
||||
this.call.setMuted(this.call.muteSettings.toggleCamera());
|
||||
}
|
||||
}
|
||||
|
||||
type OwnMemberOptions = BaseOptions & {
|
||||
call: GroupCall,
|
||||
mediaRepository: MediaRepository
|
||||
}
|
||||
|
||||
class OwnMemberViewModel extends ViewModel<OwnMemberOptions> implements IStreamViewModel {
|
||||
get stream(): Stream | undefined {
|
||||
return this.call.localMedia?.userMedia;
|
||||
}
|
||||
|
||||
private get call(): GroupCall {
|
||||
return this.getOption("call");
|
||||
}
|
||||
|
||||
get isCameraMuted(): boolean {
|
||||
return this.call.muteSettings.camera ?? !!getStreamVideoTrack(this.stream);
|
||||
}
|
||||
|
||||
get isMicrophoneMuted(): boolean {
|
||||
return this.call.muteSettings.microphone ?? !!getStreamAudioTrack(this.stream);
|
||||
}
|
||||
|
||||
get avatarLetter(): string {
|
||||
return "I";
|
||||
}
|
||||
|
||||
get avatarColorNumber(): number {
|
||||
return 3;
|
||||
}
|
||||
|
||||
avatarUrl(size: number): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get avatarTitle(): string {
|
||||
return "ikke";
|
||||
}
|
||||
|
||||
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
type MemberOptions = BaseOptions & {member: Member, mediaRepository: MediaRepository};
|
||||
|
||||
export class CallMemberViewModel extends ViewModel<MemberOptions> implements IStreamViewModel {
|
||||
get stream(): Stream | undefined {
|
||||
return this.member.remoteMedia?.userMedia;
|
||||
}
|
||||
|
||||
private get member(): Member {
|
||||
return this.getOption("member");
|
||||
}
|
||||
|
||||
get isCameraMuted(): boolean {
|
||||
return this.member.remoteMuteSettings?.camera ?? !getStreamVideoTrack(this.stream);
|
||||
}
|
||||
|
||||
get isMicrophoneMuted(): boolean {
|
||||
return this.member.remoteMuteSettings?.microphone ?? !getStreamAudioTrack(this.stream);
|
||||
}
|
||||
|
||||
get avatarLetter(): string {
|
||||
return avatarInitials(this.member.member.name);
|
||||
}
|
||||
|
||||
get avatarColorNumber(): number {
|
||||
return getIdentifierColorNumber(this.member.userId);
|
||||
}
|
||||
|
||||
avatarUrl(size: number): string | undefined {
|
||||
const {avatarUrl} = this.member.member;
|
||||
const mediaRepository = this.getOption("mediaRepository");
|
||||
return getAvatarHttpUrl(avatarUrl, size, this.platform, mediaRepository);
|
||||
}
|
||||
|
||||
get avatarTitle(): string {
|
||||
return this.member.member.name;
|
||||
}
|
||||
|
||||
compare(other: OwnMemberViewModel | CallMemberViewModel): number {
|
||||
if (other instanceof OwnMemberViewModel) {
|
||||
return -other.compare(this);
|
||||
}
|
||||
const myUserId = this.member.member.userId;
|
||||
const otherUserId = other.member.member.userId;
|
||||
if(myUserId === otherUserId) {
|
||||
return 0;
|
||||
}
|
||||
return myUserId < otherUserId ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IStreamViewModel extends AvatarSource, ViewModel {
|
||||
get stream(): Stream | undefined;
|
||||
get isCameraMuted(): boolean;
|
||||
get isMicrophoneMuted(): boolean;
|
||||
}
|
|
@ -17,9 +17,12 @@ limitations under the License.
|
|||
|
||||
import {TimelineViewModel} from "./timeline/TimelineViewModel.js";
|
||||
import {ComposerViewModel} from "./ComposerViewModel.js"
|
||||
import {CallViewModel} from "./CallViewModel"
|
||||
import {PickMapObservableValue} from "../../../observable/value/PickMapObservableValue";
|
||||
import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar";
|
||||
import {ViewModel} from "../../ViewModel";
|
||||
import {imageToInfo} from "../common.js";
|
||||
import {LocalMedia} from "../../../matrix/calls/LocalMedia";
|
||||
// TODO: remove fallback so default isn't included in bundle for SDK users that have their custom tileClassForEntry
|
||||
// this is a breaking SDK change though to make this option mandatory
|
||||
import {tileClassForEntry as defaultTileClassForEntry} from "./timeline/tiles/index";
|
||||
|
@ -43,6 +46,30 @@ export class RoomViewModel extends ViewModel {
|
|||
}
|
||||
this._clearUnreadTimout = null;
|
||||
this._closeUrl = this.urlCreator.urlUntilSegment("session");
|
||||
this._setupCallViewModel();
|
||||
}
|
||||
|
||||
_setupCallViewModel() {
|
||||
// pick call for this room with lowest key
|
||||
const calls = this.getOption("session").callHandler.calls;
|
||||
this._callObservable = new PickMapObservableValue(calls.filterValues(c => {
|
||||
return c.roomId === this._room.id && c.hasJoined;
|
||||
}));
|
||||
this._callViewModel = undefined;
|
||||
this.track(this._callObservable.subscribe(call => {
|
||||
if (call && this._callViewModel && call.id === this._callViewModel.id) {
|
||||
return;
|
||||
}
|
||||
this._callViewModel = this.disposeTracked(this._callViewModel);
|
||||
if (call) {
|
||||
this._callViewModel = this.track(new CallViewModel(this.childOptions({call, mediaRepository: this._room.mediaRepository})));
|
||||
}
|
||||
this.emitChange("callViewModel");
|
||||
}));
|
||||
const call = this._callObservable.get();
|
||||
if (call) {
|
||||
this._callViewModel = new CallViewModel(this.childOptions({call}));
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
|
@ -50,6 +77,7 @@ export class RoomViewModel extends ViewModel {
|
|||
try {
|
||||
const timeline = await this._room.openTimeline();
|
||||
this._tileOptions = this.childOptions({
|
||||
session: this.getOption("session"),
|
||||
roomVM: this,
|
||||
timeline,
|
||||
tileClassForEntry: this._tileClassForEntry,
|
||||
|
@ -317,6 +345,10 @@ export class RoomViewModel extends ViewModel {
|
|||
return this._composerVM;
|
||||
}
|
||||
|
||||
get callViewModel() {
|
||||
return this._callViewModel;
|
||||
}
|
||||
|
||||
openDetailsPanel() {
|
||||
let path = this.navigation.path.until("room");
|
||||
path = path.with(this.navigation.segment("right-panel", true));
|
||||
|
@ -329,6 +361,19 @@ export class RoomViewModel extends ViewModel {
|
|||
this._composerVM.setReplyingTo(entry);
|
||||
}
|
||||
}
|
||||
|
||||
async startCall() {
|
||||
try {
|
||||
const session = this.getOption("session");
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||
// this will set the callViewModel above as a call will be added to callHandler.calls
|
||||
const call = await session.callHandler.createCall(this._room.id, "m.video", "A call " + Math.round(this.platform.random() * 100));
|
||||
await call.join(localMedia);
|
||||
} catch (err) {
|
||||
console.error(err.stack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function videoToInfo(video) {
|
||||
|
|
|
@ -189,7 +189,7 @@ import {HomeServer as MockHomeServer} from "../../../../mocks/HomeServer.js";
|
|||
// other imports
|
||||
import {BaseMessageTile} from "./tiles/BaseMessageTile.js";
|
||||
import {MappedList} from "../../../../observable/list/MappedList";
|
||||
import {ObservableValue} from "../../../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../../../observable/value/ObservableValue";
|
||||
import {PowerLevels} from "../../../../matrix/room/PowerLevels.js";
|
||||
|
||||
export function tests() {
|
||||
|
|
|
@ -49,14 +49,6 @@ export class BaseMessageTile extends SimpleTile {
|
|||
return `https://matrix.to/#/${encodeURIComponent(this.sender)}`;
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._entry.displayName || this.sender;
|
||||
}
|
||||
|
||||
get sender() {
|
||||
return this._entry.sender;
|
||||
}
|
||||
|
||||
get memberPanelLink() {
|
||||
return `${this.urlCreator.urlUntilSegment("room")}/member/${this.sender}`;
|
||||
}
|
||||
|
|
94
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
94
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
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 {SimpleTile} from "./SimpleTile.js";
|
||||
import {LocalMedia} from "../../../../../matrix/calls/LocalMedia";
|
||||
|
||||
// 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 ...
|
||||
|
||||
// alternatively, we could just subscribe to the GroupCall and spontanously emit an update when it updates
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get confId() {
|
||||
return this._entry.stateKey;
|
||||
}
|
||||
|
||||
get shape() {
|
||||
return "call";
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this._entry.content["m.name"];
|
||||
}
|
||||
|
||||
get canJoin() {
|
||||
return this._call && !this._call.hasJoined;
|
||||
}
|
||||
|
||||
get canLeave() {
|
||||
return this._call && this._call.hasJoined;
|
||||
}
|
||||
|
||||
get label() {
|
||||
if (this._call) {
|
||||
if (this._call.hasJoined) {
|
||||
return `Ongoing call (${this.name}, ${this.confId})`;
|
||||
} else {
|
||||
return `${this.displayName} started a call (${this.name}, ${this.confId})`;
|
||||
}
|
||||
} else {
|
||||
return `Call finished, started by ${this.displayName} (${this.name}, ${this.confId})`;
|
||||
}
|
||||
}
|
||||
|
||||
async join() {
|
||||
if (this.canJoin) {
|
||||
const stream = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||
const localMedia = new LocalMedia().withUserMedia(stream);
|
||||
await this._call.join(localMedia);
|
||||
}
|
||||
}
|
||||
|
||||
async leave() {
|
||||
if (this.canLeave) {
|
||||
this._call.leave();
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._callSubscription) {
|
||||
this._callSubscription = this._callSubscription();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -154,4 +154,12 @@ export class SimpleTile extends ViewModel {
|
|||
get _ownMember() {
|
||||
return this._options.timeline.me;
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._entry.displayName || this.sender;
|
||||
}
|
||||
|
||||
get sender() {
|
||||
return this._entry.sender;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,9 +26,11 @@ import {RoomMemberTile} from "./RoomMemberTile.js";
|
|||
import {EncryptedEventTile} from "./EncryptedEventTile.js";
|
||||
import {EncryptionEnabledTile} from "./EncryptionEnabledTile.js";
|
||||
import {MissingAttachmentTile} from "./MissingAttachmentTile.js";
|
||||
import {CallTile} from "./CallTile.js";
|
||||
|
||||
import type {SimpleTile} from "./SimpleTile.js";
|
||||
import type {Room} from "../../../../../matrix/room/Room";
|
||||
import type {Session} from "../../../../../matrix/Session";
|
||||
import type {Timeline} from "../../../../../matrix/room/timeline/Timeline";
|
||||
import type {FragmentBoundaryEntry} from "../../../../../matrix/room/timeline/entries/FragmentBoundaryEntry";
|
||||
import type {EventEntry} from "../../../../../matrix/room/timeline/entries/EventEntry";
|
||||
|
@ -38,6 +40,7 @@ import type {Options as ViewModelOptions} from "../../../../ViewModel";
|
|||
export type TimelineEntry = FragmentBoundaryEntry | EventEntry | PendingEventEntry;
|
||||
export type TileClassForEntryFn = (entry: TimelineEntry) => TileConstructor | undefined;
|
||||
export type Options = ViewModelOptions & {
|
||||
session: Session,
|
||||
room: Room,
|
||||
timeline: Timeline
|
||||
tileClassForEntry: TileClassForEntryFn;
|
||||
|
@ -86,6 +89,14 @@ export function tileClassForEntry(entry: TimelineEntry): TileConstructor | undef
|
|||
return EncryptedEventTile;
|
||||
case "m.room.encryption":
|
||||
return EncryptionEnabledTile;
|
||||
case "org.matrix.msc3401.call": {
|
||||
// if prevContent is present, it's an update to a call event, which we don't render
|
||||
// as the original event is updated through the call object which receive state event updates
|
||||
if (entry.stateKey && !entry.prevContent) {
|
||||
return CallTile;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
default:
|
||||
// unknown type not rendered
|
||||
return undefined;
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import {ViewModel} from "../../ViewModel";
|
||||
import {KeyType} from "../../../matrix/ssss/index";
|
||||
import {createEnum} from "../../../utils/enum";
|
||||
import {FlatMapObservableValue} from "../../../observable/value/FlatMapObservableValue";
|
||||
|
||||
export const Status = createEnum("Enabled", "SetupKey", "SetupPhrase", "Pending", "NewVersionAvailable");
|
||||
export const BackupWriteStatus = createEnum("Writing", "Stopped", "Done", "Pending");
|
||||
|
@ -29,8 +30,8 @@ export class KeyBackupViewModel extends ViewModel {
|
|||
this._isBusy = false;
|
||||
this._dehydratedDeviceId = undefined;
|
||||
this._status = undefined;
|
||||
this._backupOperation = this._session.keyBackup.flatMap(keyBackup => keyBackup.operationInProgress);
|
||||
this._progress = this._backupOperation.flatMap(op => op.progress);
|
||||
this._backupOperation = new FlatMapObservableValue(this._session.keyBackup, keyBackup => keyBackup.operationInProgress);
|
||||
this._progress = new FlatMapObservableValue(this._backupOperation, op => op.progress);
|
||||
this.track(this._backupOperation.subscribe(() => {
|
||||
// see if needsNewKey might be set
|
||||
this._reevaluateStatus();
|
||||
|
|
|
@ -71,6 +71,7 @@ export {AvatarView} from "./platform/web/ui/AvatarView.js";
|
|||
export {RoomType} from "./matrix/room/common";
|
||||
export {EventEmitter} from "./utils/EventEmitter";
|
||||
export {Disposables} from "./utils/Disposables";
|
||||
export {LocalMedia} from "./matrix/calls/LocalMedia";
|
||||
// these should eventually be moved to another library
|
||||
export {
|
||||
ObservableArray,
|
||||
|
@ -80,8 +81,6 @@ export {
|
|||
ConcatList,
|
||||
ObservableMap
|
||||
} from "./observable/index";
|
||||
export {
|
||||
BaseObservableValue,
|
||||
ObservableValue,
|
||||
RetainedObservableValue
|
||||
} from "./observable/ObservableValue";
|
||||
export {BaseObservableValue} from "./observable/value/BaseObservableValue";
|
||||
export {ObservableValue} from "./observable/value/ObservableValue";
|
||||
export {RetainedObservableValue} from "./observable/value/RetainedObservableValue";
|
||||
|
|
|
@ -36,6 +36,15 @@ export abstract class BaseLogger implements ILogger {
|
|||
this._persistItem(item, undefined, false);
|
||||
}
|
||||
|
||||
/** Prefer `run()` or `log()` above this method; only use it if you have a long-running operation
|
||||
* *without* a single call stack that should be logged into one sub-tree.
|
||||
* You need to call `finish()` on the returned item or it will stay open until the app unloads. */
|
||||
child(labelOrValues: LabelOrValues, logLevel: LogLevel = LogLevel.Info, filterCreator?: FilterCreator): ILogItem {
|
||||
const item = new DeferredPersistRootLogItem(labelOrValues, logLevel, this, filterCreator);
|
||||
this._openItems.add(item);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** if item is a log item, wrap the callback in a child of it, otherwise start a new root log item. */
|
||||
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T {
|
||||
if (item) {
|
||||
|
@ -127,7 +136,7 @@ export abstract class BaseLogger implements ILogger {
|
|||
|
||||
_finishOpenItems() {
|
||||
for (const openItem of this._openItems) {
|
||||
openItem.finish();
|
||||
openItem.forceFinish();
|
||||
try {
|
||||
// for now, serialize with an all-permitting filter
|
||||
// as the createFilter function would get a distorted image anyway
|
||||
|
@ -158,3 +167,15 @@ export abstract class BaseLogger implements ILogger {
|
|||
return Math.round(this._platform.random() * Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
}
|
||||
|
||||
class DeferredPersistRootLogItem extends LogItem {
|
||||
finish() {
|
||||
super.finish();
|
||||
(this._logger as BaseLogger)._persistItem(this, undefined, false);
|
||||
}
|
||||
|
||||
forceFinish() {
|
||||
super.finish();
|
||||
/// no need to persist when force-finishing as _finishOpenItems above will do it
|
||||
}
|
||||
}
|
||||
|
|
|
@ -25,6 +25,12 @@ export class ConsoleLogger extends BaseLogger {
|
|||
async export(): Promise<ILogExport | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
printOpenItems(): void {
|
||||
for (const item of this._openItems) {
|
||||
this._persistItem(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const excludedKeysFromTable = ["l", "id"];
|
||||
|
@ -39,7 +45,7 @@ function filterValues(values: LogItemValues): LogItemValues | null {
|
|||
}
|
||||
|
||||
function printToConsole(item: LogItem): void {
|
||||
const label = `${itemCaption(item)} (${item.duration}ms)`;
|
||||
const label = `${itemCaption(item)} (@${item.start}ms, duration: ${item.duration}ms)`;
|
||||
const filteredValues = filterValues(item.values);
|
||||
const shouldGroup = item.children || filteredValues;
|
||||
if (shouldGroup) {
|
||||
|
|
|
@ -25,7 +25,7 @@ export class LogItem implements ILogItem {
|
|||
public error?: Error;
|
||||
public end?: number;
|
||||
private _values: LogItemValues;
|
||||
private _logger: BaseLogger;
|
||||
protected _logger: BaseLogger;
|
||||
private _filterCreator?: FilterCreator;
|
||||
private _children?: Array<LogItem>;
|
||||
|
||||
|
@ -221,6 +221,11 @@ export class LogItem implements ILogItem {
|
|||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
forceFinish(): void {
|
||||
this.finish();
|
||||
}
|
||||
|
||||
// expose log level without needing import everywhere
|
||||
get level(): typeof LogLevel {
|
||||
return LogLevel;
|
||||
|
@ -235,7 +240,7 @@ export class LogItem implements ILogItem {
|
|||
|
||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): LogItem {
|
||||
if (this.end) {
|
||||
console.trace("log item is finished, additional logs will likely not be recorded");
|
||||
console.trace(`log item ${this.values.l} finished, additional log ${JSON.stringify(labelOrValues)} will likely not be recorded`);
|
||||
}
|
||||
if (!logLevel) {
|
||||
logLevel = this.logLevel || LogLevel.Info;
|
||||
|
|
|
@ -23,6 +23,10 @@ export class NullLogger implements ILogger {
|
|||
|
||||
log(): void {}
|
||||
|
||||
child(): ILogItem {
|
||||
return this.item;
|
||||
}
|
||||
|
||||
run<T>(_, callback: LogCallback<T>): T {
|
||||
return callback(this.item);
|
||||
}
|
||||
|
@ -50,13 +54,13 @@ export class NullLogger implements ILogger {
|
|||
}
|
||||
|
||||
export class NullLogItem implements ILogItem {
|
||||
public readonly logger: NullLogger;
|
||||
public readonly logger: ILogger;
|
||||
public readonly logLevel: LogLevel;
|
||||
public children?: Array<ILogItem>;
|
||||
public values: LogItemValues;
|
||||
public error?: Error;
|
||||
|
||||
constructor(logger: NullLogger) {
|
||||
constructor(logger: ILogger) {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
|
@ -99,6 +103,7 @@ export class NullLogItem implements ILogItem {
|
|||
}
|
||||
|
||||
finish(): void {}
|
||||
forceFinish(): void {}
|
||||
|
||||
serialize(): undefined {
|
||||
return undefined;
|
||||
|
|
|
@ -51,11 +51,24 @@ export interface ILogItem {
|
|||
catch(err: Error): Error;
|
||||
serialize(filter: LogFilter, parentStartTime: number | undefined, forced: boolean): ISerializedItem | undefined;
|
||||
finish(): void;
|
||||
forceFinish(): void;
|
||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||
}
|
||||
/*
|
||||
extend both ILogger and ILogItem from this interface, but need to rename ILogger.run => wrap then. Or both to `span`?
|
||||
|
||||
export interface ILogItemCreator {
|
||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||
refDetached(logItem: ILogItem, logLevel?: LogLevel): void;
|
||||
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): ILogItem;
|
||||
wrap<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||
get level(): typeof LogLevel;
|
||||
}
|
||||
*/
|
||||
|
||||
export interface ILogger {
|
||||
log(labelOrValues: LabelOrValues, logLevel?: LogLevel): void;
|
||||
child(labelOrValues: LabelOrValues, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||
wrapOrRun<T>(item: ILogItem | undefined, labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||
runDetached<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): ILogItem;
|
||||
run<T>(labelOrValues: LabelOrValues, callback: LogCallback<T>, logLevel?: LogLevel, filterCreator?: FilterCreator): T;
|
||||
|
|
|
@ -18,7 +18,7 @@ limitations under the License.
|
|||
import {createEnum} from "../utils/enum";
|
||||
import {lookupHomeserver} from "./well-known.js";
|
||||
import {AbortableOperation} from "../utils/AbortableOperation";
|
||||
import {ObservableValue} from "../observable/ObservableValue";
|
||||
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||
import {HomeServerApi} from "./net/HomeServerApi";
|
||||
import {Reconnector, ConnectionStatus} from "./net/Reconnector";
|
||||
import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay";
|
||||
|
|
|
@ -16,12 +16,15 @@ limitations under the License.
|
|||
|
||||
import {OLM_ALGORITHM} from "./e2ee/common.js";
|
||||
import {countBy, groupBy} from "../utils/groupBy";
|
||||
import {LRUCache} from "../utils/LRUCache";
|
||||
|
||||
export class DeviceMessageHandler {
|
||||
constructor({storage}) {
|
||||
constructor({storage, callHandler}) {
|
||||
this._storage = storage;
|
||||
this._olmDecryption = null;
|
||||
this._megolmDecryption = null;
|
||||
this._callHandler = callHandler;
|
||||
this._senderDeviceCache = new LRUCache(10, di => di.curve25519Key);
|
||||
}
|
||||
|
||||
enableEncryption({olmDecryption, megolmDecryption}) {
|
||||
|
@ -35,6 +38,7 @@ export class DeviceMessageHandler {
|
|||
|
||||
async prepareSync(toDeviceEvents, lock, txn, log) {
|
||||
log.set("messageTypes", countBy(toDeviceEvents, e => e.type));
|
||||
this._handleUnencryptedCallEvents(toDeviceEvents, log);
|
||||
const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
|
||||
if (!this._olmDecryption) {
|
||||
log.log("can't decrypt, encryption not enabled", log.level.Warn);
|
||||
|
@ -49,10 +53,38 @@ export class DeviceMessageHandler {
|
|||
log.child("decrypt_error").catch(err);
|
||||
}
|
||||
const newRoomKeys = this._megolmDecryption.roomKeysFromDeviceMessages(olmDecryptChanges.results, log);
|
||||
|
||||
// const callMessages = olmDecryptChanges.results.filter(dr => this._callHandler.handlesDeviceMessageEventType(dr.event?.type));
|
||||
// // load devices by sender key
|
||||
// await Promise.all(callMessages.map(async dr => {
|
||||
// dr.setDevice(await this._getDevice(dr.senderCurve25519Key, txn));
|
||||
// }));
|
||||
// // TODO: pass this in the prep and run it in afterSync or afterSyncComplete (as callHandler can send events as well)?
|
||||
// for (const dr of callMessages) {
|
||||
// if (dr.device) {
|
||||
// this._callHandler.handleDeviceMessage(dr.event, dr.device.userId, dr.device.deviceId, log);
|
||||
// } else {
|
||||
// console.error("could not deliver message because don't have device for sender key", dr.event);
|
||||
// }
|
||||
// }
|
||||
|
||||
// TODO: somehow include rooms that received a call to_device message in the sync state?
|
||||
// or have updates flow through event emitter?
|
||||
// well, we don't really need to update the room other then when a call starts or stops
|
||||
// any changes within the call will be emitted on the call object?
|
||||
return new SyncPreparation(olmDecryptChanges, newRoomKeys);
|
||||
}
|
||||
}
|
||||
|
||||
_handleUnencryptedCallEvents(toDeviceEvents, log) {
|
||||
const callMessages = toDeviceEvents.filter(e => this._callHandler.handlesDeviceMessageEventType(e.type));
|
||||
for (const event of callMessages) {
|
||||
const userId = event.sender;
|
||||
const deviceId = event.content.device_id;
|
||||
this._callHandler.handleDeviceMessage(event, userId, deviceId, log);
|
||||
}
|
||||
}
|
||||
|
||||
/** check that prep is not undefined before calling this */
|
||||
async writeSync(prep, txn) {
|
||||
// write olm changes
|
||||
|
@ -60,6 +92,18 @@ export class DeviceMessageHandler {
|
|||
const didWriteValues = await Promise.all(prep.newRoomKeys.map(key => this._megolmDecryption.writeRoomKey(key, txn)));
|
||||
return didWriteValues.some(didWrite => !!didWrite);
|
||||
}
|
||||
|
||||
|
||||
async _getDevice(senderKey, txn) {
|
||||
let device = this._senderDeviceCache.get(senderKey);
|
||||
if (!device) {
|
||||
device = await txn.deviceIdentities.getByCurve25519Key(senderKey);
|
||||
if (device) {
|
||||
this._senderDeviceCache.set(device);
|
||||
}
|
||||
}
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
class SyncPreparation {
|
||||
|
|
|
@ -21,7 +21,7 @@ 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";
|
||||
import { ObservableMap } from "../observable/index";
|
||||
import {User} from "./User.js";
|
||||
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
|
||||
import {Account as E2EEAccount} from "./e2ee/Account.js";
|
||||
|
@ -45,7 +45,9 @@ import {
|
|||
keyFromDehydratedDeviceKey as createSSSSKeyFromDehydratedDeviceKey
|
||||
} from "./ssss/index";
|
||||
import {SecretStorage} from "./ssss/SecretStorage";
|
||||
import {ObservableValue, RetainedObservableValue} from "../observable/ObservableValue";
|
||||
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||
import {RetainedObservableValue} from "../observable/value/RetainedObservableValue";
|
||||
import {CallHandler} from "./calls/CallHandler";
|
||||
|
||||
const PICKLE_KEY = "DEFAULT_KEY";
|
||||
const PUSHER_KEY = "pusher";
|
||||
|
@ -73,7 +75,33 @@ export class Session {
|
|||
};
|
||||
this._roomsBeingCreated = new ObservableMap();
|
||||
this._user = new User(sessionInfo.userId);
|
||||
this._deviceMessageHandler = new DeviceMessageHandler({storage});
|
||||
this._callHandler = new CallHandler({
|
||||
clock: this._platform.clock,
|
||||
hsApi: this._hsApi,
|
||||
encryptDeviceMessage: async (roomId, userId, message, log) => {
|
||||
if (!this._deviceTracker || !this._olmEncryption) {
|
||||
throw new Error("encryption is not enabled");
|
||||
}
|
||||
// TODO: just get the devices we're sending the message to, not all the room devices
|
||||
// although we probably already fetched all devices to send messages in the likely e2ee room
|
||||
const devices = await log.wrap("get device keys", async log => {
|
||||
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
|
||||
return this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
|
||||
});
|
||||
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
||||
return encryptedMessage;
|
||||
},
|
||||
storage: this._storage,
|
||||
webRTC: this._platform.webRTC,
|
||||
ownDeviceId: sessionInfo.deviceId,
|
||||
ownUserId: sessionInfo.userId,
|
||||
logger: this._platform.logger,
|
||||
turnServers: [{
|
||||
urls: ["stun:turn.matrix.org"],
|
||||
}],
|
||||
forceTURN: false,
|
||||
});
|
||||
this._deviceMessageHandler = new DeviceMessageHandler({storage, callHandler: this._callHandler});
|
||||
this._olm = olm;
|
||||
this._olmUtil = null;
|
||||
this._e2eeAccount = null;
|
||||
|
@ -118,6 +146,10 @@ export class Session {
|
|||
return this._sessionInfo.userId;
|
||||
}
|
||||
|
||||
get callHandler() {
|
||||
return this._callHandler;
|
||||
}
|
||||
|
||||
// called once this._e2eeAccount is assigned
|
||||
_setupEncryption() {
|
||||
// TODO: this should all go in a wrapper in e2ee/ that is bootstrapped by passing in the account
|
||||
|
@ -562,7 +594,8 @@ export class Session {
|
|||
pendingEvents,
|
||||
user: this._user,
|
||||
createRoomEncryption: this._createRoomEncryption,
|
||||
platform: this._platform
|
||||
platform: this._platform,
|
||||
callHandler: this._callHandler
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -983,9 +1016,18 @@ export function tests() {
|
|||
|
||||
return {
|
||||
"session data is not modified until after sync": async (assert) => {
|
||||
const session = new Session({storage: createStorageMock({
|
||||
const storage = createStorageMock({
|
||||
sync: {token: "a", filterId: 5}
|
||||
}), sessionInfo: {userId: ""}});
|
||||
});
|
||||
const session = new Session({
|
||||
storage,
|
||||
sessionInfo: {userId: ""},
|
||||
platform: {
|
||||
clock: {
|
||||
createTimeout: () => undefined
|
||||
}
|
||||
}
|
||||
});
|
||||
await session.load();
|
||||
let syncSet = false;
|
||||
const syncTxn = {
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ObservableValue} from "../observable/ObservableValue";
|
||||
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||
import {createEnum} from "../utils/enum";
|
||||
|
||||
const INCREMENTAL_TIMEOUT = 30000;
|
||||
|
@ -224,6 +224,7 @@ export class Sync {
|
|||
_openPrepareSyncTxn() {
|
||||
const storeNames = this._storage.storeNames;
|
||||
return this._storage.readTxn([
|
||||
storeNames.deviceIdentities, // to read device from olm messages
|
||||
storeNames.olmSessions,
|
||||
storeNames.inboundGroupSessions,
|
||||
// to read fragments when loading sync writer when rejoining archived room
|
||||
|
@ -343,6 +344,7 @@ export class Sync {
|
|||
// to decrypt and store new room keys
|
||||
storeNames.olmSessions,
|
||||
storeNames.inboundGroupSessions,
|
||||
storeNames.calls,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
228
src/matrix/calls/CallHandler.ts
Normal file
228
src/matrix/calls/CallHandler.ts
Normal file
|
@ -0,0 +1,228 @@
|
|||
/*
|
||||
Copyright 2022 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 {ObservableMap} from "../../observable/map/ObservableMap";
|
||||
import {WebRTC, PeerConnection} from "../../platform/types/WebRTC";
|
||||
import {MediaDevices, Track} from "../../platform/types/MediaDevices";
|
||||
import {handlesEventType} from "./PeerCall";
|
||||
import {EventType, CallIntent} from "./callEventTypes";
|
||||
import {GroupCall} from "./group/GroupCall";
|
||||
import {makeId} from "../common";
|
||||
|
||||
import type {LocalMedia} from "./LocalMedia";
|
||||
import type {Room} from "../room/Room";
|
||||
import type {MemberChange} from "../room/members/RoomMember";
|
||||
import type {StateEvent} from "../storage/types";
|
||||
import type {ILogItem, ILogger} from "../../logging/types";
|
||||
import type {Platform} from "../../platform/web/Platform";
|
||||
import type {BaseObservableMap} from "../../observable/map/BaseObservableMap";
|
||||
import type {SignallingMessage, MGroupCallBase} from "./callEventTypes";
|
||||
import type {Options as GroupCallOptions} from "./group/GroupCall";
|
||||
import type {Transaction} from "../storage/idb/Transaction";
|
||||
import type {CallEntry} from "../storage/idb/stores/CallStore";
|
||||
import type {Clock} from "../../platform/web/dom/Clock";
|
||||
|
||||
export type Options = Omit<GroupCallOptions, "emitUpdate" | "createTimeout"> & {
|
||||
logger: ILogger,
|
||||
clock: Clock
|
||||
};
|
||||
|
||||
export class CallHandler {
|
||||
// group calls by call id
|
||||
private readonly _calls: ObservableMap<string, GroupCall> = new ObservableMap<string, GroupCall>();
|
||||
// map of userId to set of conf_id's they are in
|
||||
private memberToCallIds: Map<string, Set<string>> = new Map();
|
||||
private groupCallOptions: GroupCallOptions;
|
||||
private sessionId = makeId("s");
|
||||
|
||||
constructor(private readonly options: Options) {
|
||||
this.groupCallOptions = Object.assign({}, this.options, {
|
||||
emitUpdate: (groupCall, params) => this._calls.update(groupCall.id, params),
|
||||
createTimeout: this.options.clock.createTimeout,
|
||||
sessionId: this.sessionId
|
||||
});
|
||||
}
|
||||
|
||||
async loadCalls(intent: CallIntent = CallIntent.Ring) {
|
||||
const txn = await this._getLoadTxn();
|
||||
const callEntries = await txn.calls.getByIntent(intent);
|
||||
this._loadCallEntries(callEntries, txn);
|
||||
}
|
||||
|
||||
async loadCallsForRoom(intent: CallIntent, roomId: string) {
|
||||
const txn = await this._getLoadTxn();
|
||||
const callEntries = await txn.calls.getByIntentAndRoom(intent, roomId);
|
||||
this._loadCallEntries(callEntries, txn);
|
||||
}
|
||||
|
||||
private async _getLoadTxn(): Promise<Transaction> {
|
||||
const names = this.options.storage.storeNames;
|
||||
const txn = await this.options.storage.readTxn([
|
||||
names.calls,
|
||||
names.roomState
|
||||
]);
|
||||
return txn;
|
||||
}
|
||||
|
||||
private async _loadCallEntries(callEntries: CallEntry[], txn: Transaction): Promise<void> {
|
||||
return this.options.logger.run("loading calls", async log => {
|
||||
log.set("entries", callEntries.length);
|
||||
await Promise.all(callEntries.map(async callEntry => {
|
||||
if (this._calls.get(callEntry.callId)) {
|
||||
return;
|
||||
}
|
||||
const event = await txn.roomState.get(callEntry.roomId, EventType.GroupCall, callEntry.callId);
|
||||
if (event) {
|
||||
const logItem = this.options.logger.child({l: "call", loaded: true});
|
||||
const call = new GroupCall(event.event.state_key, false, event.event.content, event.roomId, this.groupCallOptions, logItem);
|
||||
this._calls.set(call.id, call);
|
||||
}
|
||||
}));
|
||||
const roomIds = Array.from(new Set(callEntries.map(e => e.roomId)));
|
||||
await Promise.all(roomIds.map(async roomId => {
|
||||
// const ownCallsMemberEvent = await txn.roomState.get(roomId, EventType.GroupCallMember, this.options.ownUserId);
|
||||
// if (ownCallsMemberEvent) {
|
||||
// this.handleCallMemberEvent(ownCallsMemberEvent.event, log);
|
||||
// }
|
||||
const callsMemberEvents = await txn.roomState.getAllForType(roomId, EventType.GroupCallMember);
|
||||
for (const entry of callsMemberEvents) {
|
||||
this.handleCallMemberEvent(entry.event, log);
|
||||
}
|
||||
// TODO: we should be loading the other members as well at some point
|
||||
}));
|
||||
log.set("newSize", this._calls.size);
|
||||
});
|
||||
}
|
||||
|
||||
async createCall(roomId: string, type: "m.video" | "m.voice", name: string, intent: CallIntent = CallIntent.Ring): Promise<GroupCall> {
|
||||
const logItem = this.options.logger.child({l: "call", incoming: false});
|
||||
const call = new GroupCall(makeId("conf-"), true, {
|
||||
"m.name": name,
|
||||
"m.intent": intent
|
||||
}, roomId, this.groupCallOptions, logItem);
|
||||
this._calls.set(call.id, call);
|
||||
|
||||
try {
|
||||
await call.create(type);
|
||||
// store call info so it will ring again when reopening the app
|
||||
const txn = await this.options.storage.readWriteTxn([this.options.storage.storeNames.calls]);
|
||||
txn.calls.add({
|
||||
intent: call.intent,
|
||||
callId: call.id,
|
||||
timestamp: this.options.clock.now(),
|
||||
roomId: roomId
|
||||
});
|
||||
await txn.complete();
|
||||
} catch (err) {
|
||||
//if (err.name === "ConnectionError") {
|
||||
// if we're offline, give up and remove the call again
|
||||
call.dispose();
|
||||
this._calls.remove(call.id);
|
||||
//}
|
||||
throw err;
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
get calls(): BaseObservableMap<string, GroupCall> { return this._calls; }
|
||||
|
||||
// TODO: check and poll turn server credentials here
|
||||
|
||||
/** @internal */
|
||||
handleRoomState(room: Room, events: StateEvent[], txn: Transaction, log: ILogItem) {
|
||||
// first update call events
|
||||
for (const event of events) {
|
||||
if (event.type === EventType.GroupCall) {
|
||||
this.handleCallEvent(event, room.id, txn, log);
|
||||
}
|
||||
}
|
||||
// then update members
|
||||
for (const event of events) {
|
||||
if (event.type === EventType.GroupCallMember) {
|
||||
this.handleCallMemberEvent(event, log);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
updateRoomMembers(room: Room, memberChanges: Map<string, MemberChange>) {
|
||||
// TODO: also have map for roomId to calls, so we can easily update members
|
||||
// we will also need this to get the call for a room
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
handlesDeviceMessageEventType(eventType: string): boolean {
|
||||
return handlesEventType(eventType);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, log: ILogItem) {
|
||||
// TODO: buffer messages for calls we haven't received the state event for yet?
|
||||
const call = this._calls.get(message.content.conf_id);
|
||||
call?.handleDeviceMessage(message, userId, deviceId, log);
|
||||
}
|
||||
|
||||
private handleCallEvent(event: StateEvent, roomId: string, txn: Transaction, log: ILogItem) {
|
||||
const callId = event.state_key;
|
||||
let call = this._calls.get(callId);
|
||||
if (call) {
|
||||
call.updateCallEvent(event.content, log);
|
||||
if (call.isTerminated) {
|
||||
call.dispose();
|
||||
this._calls.remove(call.id);
|
||||
txn.calls.remove(call.intent, roomId, call.id);
|
||||
}
|
||||
} else {
|
||||
const logItem = this.options.logger.child({l: "call", incoming: true});
|
||||
call = new GroupCall(event.state_key, false, event.content, roomId, this.groupCallOptions, logItem);
|
||||
this._calls.set(call.id, call);
|
||||
txn.calls.add({
|
||||
intent: call.intent,
|
||||
callId: call.id,
|
||||
timestamp: event.origin_server_ts,
|
||||
roomId: roomId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleCallMemberEvent(event: StateEvent, log: ILogItem) {
|
||||
const userId = event.state_key;
|
||||
const calls = event.content["m.calls"] ?? [];
|
||||
for (const call of calls) {
|
||||
const callId = call["m.call_id"];
|
||||
const groupCall = this._calls.get(callId);
|
||||
// TODO: also check the member when receiving the m.call event
|
||||
groupCall?.updateMembership(userId, call, log);
|
||||
};
|
||||
const newCallIdsMemberOf = new Set<string>(calls.map(call => call["m.call_id"]));
|
||||
let previousCallIdsMemberOf = this.memberToCallIds.get(userId);
|
||||
// remove user as member of any calls not present anymore
|
||||
if (previousCallIdsMemberOf) {
|
||||
for (const previousCallId of previousCallIdsMemberOf) {
|
||||
if (!newCallIdsMemberOf.has(previousCallId)) {
|
||||
const groupCall = this._calls.get(previousCallId);
|
||||
groupCall?.removeMembership(userId, log);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newCallIdsMemberOf.size === 0) {
|
||||
this.memberToCallIds.delete(userId);
|
||||
} else {
|
||||
this.memberToCallIds.set(userId, newCallIdsMemberOf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
81
src/matrix/calls/LocalMedia.ts
Normal file
81
src/matrix/calls/LocalMedia.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
Copyright 2022 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 {SDPStreamMetadataPurpose} from "./callEventTypes";
|
||||
import {Stream} from "../../platform/types/MediaDevices";
|
||||
import {SDPStreamMetadata} from "./callEventTypes";
|
||||
import {getStreamVideoTrack, getStreamAudioTrack} from "./common";
|
||||
|
||||
export class LocalMedia {
|
||||
constructor(
|
||||
public readonly userMedia?: Stream,
|
||||
public readonly screenShare?: Stream,
|
||||
public readonly dataChannelOptions?: RTCDataChannelInit,
|
||||
) {}
|
||||
|
||||
withUserMedia(stream: Stream) {
|
||||
return new LocalMedia(stream, this.screenShare, this.dataChannelOptions);
|
||||
}
|
||||
|
||||
withScreenShare(stream: Stream) {
|
||||
return new LocalMedia(this.userMedia, stream, this.dataChannelOptions);
|
||||
}
|
||||
|
||||
withDataChannel(options: RTCDataChannelInit): LocalMedia {
|
||||
return new LocalMedia(this.userMedia, this.screenShare, options);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
replaceClone(oldClone: LocalMedia | undefined, oldOriginal: LocalMedia | undefined): LocalMedia {
|
||||
let userMedia;
|
||||
let screenShare;
|
||||
const cloneOrAdoptStream = (oldOriginalStream: Stream | undefined, oldCloneStream: Stream | undefined, newStream: Stream | undefined): Stream | undefined => {
|
||||
let stream;
|
||||
if (oldOriginalStream?.id === newStream?.id) {
|
||||
stream = oldCloneStream;
|
||||
} else {
|
||||
stream = newStream?.clone();
|
||||
getStreamAudioTrack(oldCloneStream)?.stop();
|
||||
getStreamVideoTrack(oldCloneStream)?.stop();
|
||||
}
|
||||
return stream;
|
||||
}
|
||||
return new LocalMedia(
|
||||
cloneOrAdoptStream(oldOriginal?.userMedia, oldClone?.userMedia, this.userMedia),
|
||||
cloneOrAdoptStream(oldOriginal?.screenShare, oldClone?.screenShare, this.screenShare),
|
||||
this.dataChannelOptions
|
||||
);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
clone(): LocalMedia {
|
||||
return new LocalMedia(this.userMedia?.clone(),this.screenShare?.clone(), this.dataChannelOptions);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.stopExcept(undefined);
|
||||
}
|
||||
|
||||
stopExcept(newMedia: LocalMedia | undefined) {
|
||||
if(newMedia?.userMedia?.id !== this.userMedia?.id) {
|
||||
getStreamAudioTrack(this.userMedia)?.stop();
|
||||
getStreamVideoTrack(this.userMedia)?.stop();
|
||||
}
|
||||
if(newMedia?.screenShare?.id !== this.screenShare?.id) {
|
||||
getStreamVideoTrack(this.screenShare)?.stop();
|
||||
}
|
||||
}
|
||||
}
|
1123
src/matrix/calls/PeerCall.ts
Normal file
1123
src/matrix/calls/PeerCall.ts
Normal file
File diff suppressed because it is too large
Load diff
189
src/matrix/calls/TODO.md
Normal file
189
src/matrix/calls/TODO.md
Normal file
|
@ -0,0 +1,189 @@
|
|||
- relevant MSCs next to spec:
|
||||
- https://github.com/matrix-org/matrix-doc/pull/2746 Improved Signalling for 1:1 VoIP
|
||||
- https://github.com/matrix-org/matrix-doc/pull/2747 Transferring VoIP Calls
|
||||
- https://github.com/matrix-org/matrix-doc/pull/3077 Support for multi-stream VoIP
|
||||
- https://github.com/matrix-org/matrix-doc/pull/3086 Asserted identity on VoIP calls
|
||||
- https://github.com/matrix-org/matrix-doc/pull/3291 Muting in VoIP calls
|
||||
- https://github.com/matrix-org/matrix-doc/pull/3401 Native Group VoIP Signalling
|
||||
|
||||
|
||||
## TODO
|
||||
- DONE: implement receiving hangup
|
||||
- DONE: implement cloning the localMedia so it works in safari?
|
||||
- DONE: implement 3 retries per peer
|
||||
- implement muting tracks with m.call.sdp_stream_metadata_changed
|
||||
- implement renegotiation
|
||||
- making logging better
|
||||
- finish session id support
|
||||
- call peers are essentially identified by (userid, deviceid, sessionid). If see a new session id, we first disconnect from the current member so we're ready to connect with a clean slate again (in a member event, also in to_device? no harm I suppose, given olm encryption ensures you can't spoof the deviceid).
|
||||
- implement to_device messages arriving before m.call(.member) state event
|
||||
- reeable crypto & implement fetching olm keys before sending encrypted signalling message
|
||||
- local echo for join/leave buttons?
|
||||
- make UI pretsy
|
||||
- figure out video layout
|
||||
- figure out nav structure
|
||||
- batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute)
|
||||
- don't load all members when loading calls to know whether they are ringing and joined by ourself
|
||||
- only load our own member once, then have a way to load additional members on a call.
|
||||
- see if we remove partyId entirely, it is only used for detecting remote echo which is not an issue for group calls? see https://github.com/matrix-org/matrix-spec-proposals/blob/dbkr/msc2746/proposals/2746-reliable-voip.md#add-party_id-to-all-voip-events
|
||||
## TODO (old)
|
||||
- PeerCall
|
||||
- send invite
|
||||
- implement terminate
|
||||
- implement waitForState
|
||||
|
||||
- find out if we need to do something different when renegotation is triggered (a subsequent onnegotiationneeded event) whether
|
||||
we sent the invite/offer or answer. e.g. do we always do createOffer/setLocalDescription and then send it over a matrix negotiation event? even if we before called createAnswer.
|
||||
- handle receiving offer and send anwser
|
||||
- handle sending ice candidates
|
||||
- handle ice candidates finished (iceGatheringState === 'complete')
|
||||
- handle receiving ice candidates
|
||||
- handle sending renegotiation
|
||||
- handle receiving renegotiation
|
||||
- reject call
|
||||
- hangup call
|
||||
- handle muting tracks
|
||||
- handle remote track being muted
|
||||
- handle adding/removing tracks to an ongoing call
|
||||
- handle sdp metadata
|
||||
- Participant
|
||||
- handle glare
|
||||
- encrypt to_device message with olm
|
||||
- batch outgoing to_device messages in one request to homeserver for operations that will send out an event to all participants (e.g. mute)
|
||||
- find out if we should start muted or not?
|
||||
|
||||
## Store ongoing calls
|
||||
|
||||
DONE: Add store with all ongoing calls so when we quit and start again, we don't have to go through all the past calls to know which ones might still be ongoing.
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
we send m.call as state event in room
|
||||
|
||||
we add m.call.participant for our own device
|
||||
|
||||
we wait for other participants to add their user and device (in the sources)
|
||||
|
||||
for each (userid, deviceid)
|
||||
- if userId < ourUserId
|
||||
- get local media
|
||||
- we setup a peer connection
|
||||
- add local tracks
|
||||
- we wait for negotation event to get sdp
|
||||
- peerConn.createOffer
|
||||
- peerConn.setLocalDescription
|
||||
- we send an m.call.invite
|
||||
- else
|
||||
- wait for invite from other side
|
||||
|
||||
on local ice candidate:
|
||||
- if we haven't ... sent invite yet? or received answer? buffer candidate
|
||||
- otherwise send candidate (without buffering?)
|
||||
|
||||
on incoming call:
|
||||
- ring, offer to answer
|
||||
|
||||
answering incoming call
|
||||
- get local media
|
||||
- peerConn.setRemoteDescription
|
||||
- add local tracks to peerConn
|
||||
- peerConn.createAnswer()
|
||||
- peerConn.setLocalDescription
|
||||
|
||||
in some cases, we will actually send the invite to all devices (e.g. SFU), so
|
||||
we probably still need to handle multiple anwsers?
|
||||
|
||||
so we would send an invite to multiple devices and pick the one for which we
|
||||
received the anwser first. between invite and anwser, we could already receive
|
||||
ice candidates that we need to buffer.
|
||||
|
||||
|
||||
|
||||
updating the metadata:
|
||||
|
||||
if we're renegotiating: use m.call.negotatie
|
||||
if just muting: use m.call.sdp_stream_metadata_changed
|
||||
|
||||
|
||||
party identification
|
||||
- for 1:1 calls, we identify with a party_id
|
||||
- for group calls, we identify with a device_id
|
||||
|
||||
|
||||
|
||||
|
||||
## TODO
|
||||
|
||||
Build basic version of PeerCall
|
||||
- add candidates code
|
||||
DONE: Build basic version of GroupCall
|
||||
- DONE: add state, block invalid actions
|
||||
DONE: Make it possible to olm encrypt the messages
|
||||
Do work needed for state events
|
||||
- DONEish: receiving (almost done?)
|
||||
- DONEish: sending
|
||||
logging
|
||||
DONE: Expose call objects
|
||||
expose volume events from audiotrack to group call
|
||||
DONE: Write view model
|
||||
DONE: write view
|
||||
- handle glare edge-cases (not yet sent): https://spec.matrix.org/latest/client-server-api/#glare
|
||||
|
||||
## Calls questions
|
||||
- how do we handle glare between group calls (e.g. different state events with different call ids?)
|
||||
- Split up DOM part into platform code? What abstractions to choose?
|
||||
Does it make sense to come up with our own API very similar to DOM api?
|
||||
- what code do we copy over vs what do we implement ourselves?
|
||||
- MatrixCall: perhaps we can copy it over and modify it to our needs? Seems to have a lot of edge cases implemented.
|
||||
- what is partyId about?
|
||||
- CallFeed: I need better understand where it is used. It's basically a wrapper around a MediaStream with volume detection. Could it make sense to put this in platform for example?
|
||||
|
||||
- which parts of MSC2746 are still relevant for group calls?
|
||||
- which parts of MSC2747 are still relevant for group calls? it seems mostly orthogonal?
|
||||
- SOLVED: how does switching channels work? This was only enabled by MSC 2746
|
||||
- you do getUserMedia()/getDisplayMedia() to get the stream(s)
|
||||
- you call removeTrack/addTrack on the peerConnection
|
||||
- you receive a negotiationneeded event
|
||||
- you call createOffer
|
||||
- you send m.call.negotiate
|
||||
- SOLVED: wrt to MSC2746, is the screen share track and the audio track (and video track) part of the same stream? or do screen share tracks need to go in a different stream? it sounds incompatible with the MSC2746 requirement.
|
||||
- SOLVED: how does muting work? MediaStreamTrack.enabled
|
||||
- SOLVED: so, what's the difference between the call_id and the conf_id in group call events?
|
||||
- call_id is the specific 1:1 call, conf_id is the thing in the m.call state event key
|
||||
- so a group call has a conf_id with MxN peer calls, each having their call_id.
|
||||
|
||||
I think we need to synchronize the negotiation needed because we don't use a CallState to guard it...
|
||||
|
||||
## Thursday 3-3 notes
|
||||
|
||||
we probably best keep the perfect negotiation flags, as they are needed for both starting the call AND renegotiation? if only for the former, it would make sense as it is a step in setting up the call, but if the call is ongoing, does it make sense to have a MakingOffer state? it actually looks like they are only needed for renegotiation! for call setup we compare the call_ids. What does that mean for these flags?
|
||||
|
||||
|
||||
## Peer call state transitions
|
||||
|
||||
FROM CALLER FROM CALLEE
|
||||
|
||||
Fledgling Fledgling
|
||||
V `call()` V `handleInvite()`: setRemoteDescription(event.offer), add buffered candidates
|
||||
V Ringing
|
||||
V V `answer()`
|
||||
CreateOffer V
|
||||
V add local tracks V
|
||||
V wait for negotionneeded events V add local tracks
|
||||
V setLocalDescription() CreateAnswer
|
||||
V send invite event V setLocalDescription(createAnswer())
|
||||
InviteSent |
|
||||
V receive anwser, setRemoteDescription() |
|
||||
\___________________________________________________/
|
||||
V
|
||||
Connecting
|
||||
V receive ice candidates and iceConnectionState becomes 'connected'
|
||||
Connected
|
||||
V `hangup()` or some terminate condition
|
||||
Ended
|
||||
|
||||
so if we don't want to bother with having two call objects, we can make the existing call hangup his old call_id? That way we keep the old peerConnection.
|
||||
|
||||
|
||||
when glare, won't we drop both calls? No: https://github.com/matrix-org/matrix-spec-proposals/pull/2746#discussion_r819388754
|
227
src/matrix/calls/callEventTypes.ts
Normal file
227
src/matrix/calls/callEventTypes.ts
Normal file
|
@ -0,0 +1,227 @@
|
|||
// allow non-camelcase as these are events type that go onto the wire
|
||||
/* eslint-disable camelcase */
|
||||
import type {StateEvent} from "../storage/types";
|
||||
import type {SessionDescription} from "../../platform/types/WebRTC";
|
||||
export enum EventType {
|
||||
GroupCall = "org.matrix.msc3401.call",
|
||||
GroupCallMember = "org.matrix.msc3401.call.member",
|
||||
Invite = "m.call.invite",
|
||||
Candidates = "m.call.candidates",
|
||||
Answer = "m.call.answer",
|
||||
Hangup = "m.call.hangup",
|
||||
Reject = "m.call.reject",
|
||||
SelectAnswer = "m.call.select_answer",
|
||||
Negotiate = "m.call.negotiate",
|
||||
SDPStreamMetadataChanged = "m.call.sdp_stream_metadata_changed",
|
||||
SDPStreamMetadataChangedPrefix = "org.matrix.call.sdp_stream_metadata_changed",
|
||||
Replaces = "m.call.replaces",
|
||||
AssertedIdentity = "m.call.asserted_identity",
|
||||
AssertedIdentityPrefix = "org.matrix.call.asserted_identity",
|
||||
}
|
||||
|
||||
// TODO: Change to "sdp_stream_metadata" when MSC3077 is merged
|
||||
export const SDPStreamMetadataKey = "org.matrix.msc3077.sdp_stream_metadata";
|
||||
|
||||
export interface CallDeviceMembership {
|
||||
device_id: string,
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface CallMembership {
|
||||
["m.call_id"]: string,
|
||||
["m.devices"]: CallDeviceMembership[]
|
||||
}
|
||||
|
||||
export interface CallMemberContent {
|
||||
["m.calls"]: CallMembership[];
|
||||
}
|
||||
|
||||
export enum SDPStreamMetadataPurpose {
|
||||
Usermedia = "m.usermedia",
|
||||
Screenshare = "m.screenshare",
|
||||
}
|
||||
|
||||
export interface SDPStreamMetadataObject {
|
||||
purpose: SDPStreamMetadataPurpose;
|
||||
audio_muted: boolean;
|
||||
video_muted: boolean;
|
||||
}
|
||||
|
||||
export interface SDPStreamMetadata {
|
||||
[key: string]: SDPStreamMetadataObject;
|
||||
}
|
||||
|
||||
export interface CallCapabilities {
|
||||
'm.call.transferee': boolean;
|
||||
'm.call.dtmf': boolean;
|
||||
}
|
||||
|
||||
export interface CallReplacesTarget {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
}
|
||||
|
||||
export type MCallBase = {
|
||||
call_id: string;
|
||||
version: string | number;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export type MGroupCallBase = MCallBase & {
|
||||
conf_id: string;
|
||||
device_id: string;
|
||||
sender_session_id: string;
|
||||
dest_session_id: string;
|
||||
party_id: string; // Should not need this?
|
||||
}
|
||||
|
||||
export type MCallAnswer<Base extends MCallBase> = Base & {
|
||||
answer: SessionDescription;
|
||||
capabilities?: CallCapabilities;
|
||||
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||
}
|
||||
|
||||
export type MCallSelectAnswer<Base extends MCallBase> = Base & {
|
||||
selected_party_id: string;
|
||||
}
|
||||
|
||||
export type MCallInvite<Base extends MCallBase> = Base & {
|
||||
offer: SessionDescription;
|
||||
lifetime: number;
|
||||
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||
}
|
||||
|
||||
export type MCallNegotiate<Base extends MCallBase> = Base & {
|
||||
description: SessionDescription;
|
||||
lifetime: number;
|
||||
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||
}
|
||||
|
||||
export type MCallSDPStreamMetadataChanged<Base extends MCallBase> = Base & {
|
||||
[SDPStreamMetadataKey]: SDPStreamMetadata;
|
||||
}
|
||||
|
||||
export type MCallReplacesEvent<Base extends MCallBase> = Base & {
|
||||
replacement_id: string;
|
||||
target_user: CallReplacesTarget;
|
||||
create_call: string;
|
||||
await_call: string;
|
||||
target_room: string;
|
||||
}
|
||||
|
||||
export type MCAllAssertedIdentity<Base extends MCallBase> = Base & {
|
||||
asserted_identity: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type MCallCandidates<Base extends MCallBase> = Base & {
|
||||
candidates: RTCIceCandidate[];
|
||||
}
|
||||
|
||||
export type MCallHangupReject<Base extends MCallBase> = Base & {
|
||||
reason?: CallErrorCode;
|
||||
}
|
||||
|
||||
export enum CallErrorCode {
|
||||
/** The user chose to end the call */
|
||||
UserHangup = 'user_hangup',
|
||||
|
||||
/** An error code when the local client failed to create an offer. */
|
||||
LocalOfferFailed = 'local_offer_failed',
|
||||
/**
|
||||
* An error code when there is no local mic/camera to use. This may be because
|
||||
* the hardware isn't plugged in, or the user has explicitly denied access.
|
||||
*/
|
||||
NoUserMedia = 'no_user_media',
|
||||
|
||||
/**
|
||||
* Error code used when a call event failed to send
|
||||
* because unknown devices were present in the room
|
||||
*/
|
||||
UnknownDevices = 'unknown_devices',
|
||||
|
||||
/**
|
||||
* Error code used when we fail to send the invite
|
||||
* for some reason other than there being unknown devices
|
||||
*/
|
||||
SendInvite = 'send_invite',
|
||||
|
||||
/**
|
||||
* An answer could not be created
|
||||
*/
|
||||
CreateAnswer = 'create_answer',
|
||||
|
||||
/**
|
||||
* Error code used when we fail to send the answer
|
||||
* for some reason other than there being unknown devices
|
||||
*/
|
||||
SendAnswer = 'send_answer',
|
||||
|
||||
/**
|
||||
* The session description from the other side could not be set
|
||||
*/
|
||||
SetRemoteDescription = 'set_remote_description',
|
||||
|
||||
/**
|
||||
* The session description from this side could not be set
|
||||
*/
|
||||
SetLocalDescription = 'set_local_description',
|
||||
|
||||
/**
|
||||
* A different device answered the call
|
||||
*/
|
||||
AnsweredElsewhere = 'answered_elsewhere',
|
||||
|
||||
/**
|
||||
* No media connection could be established to the other party
|
||||
*/
|
||||
IceFailed = 'ice_failed',
|
||||
|
||||
/**
|
||||
* The invite timed out whilst waiting for an answer
|
||||
*/
|
||||
InviteTimeout = 'invite_timeout',
|
||||
|
||||
/**
|
||||
* The call was replaced by another call
|
||||
*/
|
||||
Replaced = 'replaced',
|
||||
|
||||
/**
|
||||
* Signalling for the call could not be sent (other than the initial invite)
|
||||
*/
|
||||
SignallingFailed = 'signalling_timeout',
|
||||
|
||||
/**
|
||||
* The remote party is busy
|
||||
*/
|
||||
UserBusy = 'user_busy',
|
||||
|
||||
/**
|
||||
* We transferred the call off to somewhere else
|
||||
*/
|
||||
Transfered = 'transferred',
|
||||
|
||||
/**
|
||||
* A call from the same user was found with a new session id
|
||||
*/
|
||||
NewSession = 'new_session',
|
||||
}
|
||||
|
||||
export type SignallingMessage<Base extends MCallBase> =
|
||||
{type: EventType.Invite, content: MCallInvite<Base>} |
|
||||
{type: EventType.Negotiate, content: MCallNegotiate<Base>} |
|
||||
{type: EventType.Answer, content: MCallAnswer<Base>} |
|
||||
{type: EventType.SDPStreamMetadataChanged | EventType.SDPStreamMetadataChangedPrefix, content: MCallSDPStreamMetadataChanged<Base>} |
|
||||
{type: EventType.Candidates, content: MCallCandidates<Base>} |
|
||||
{type: EventType.Hangup | EventType.Reject, content: MCallHangupReject<Base>};
|
||||
|
||||
export enum CallIntent {
|
||||
Ring = "m.ring",
|
||||
Prompt = "m.prompt",
|
||||
Room = "m.room",
|
||||
};
|
37
src/matrix/calls/common.ts
Normal file
37
src/matrix/calls/common.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
Copyright 2022 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 {Track, Stream} from "../../platform/types/MediaDevices";
|
||||
|
||||
export function getStreamAudioTrack(stream: Stream | undefined): Track | undefined {
|
||||
return stream?.getAudioTracks()[0];
|
||||
}
|
||||
|
||||
export function getStreamVideoTrack(stream: Stream | undefined): Track | undefined {
|
||||
return stream?.getVideoTracks()[0];
|
||||
}
|
||||
|
||||
export class MuteSettings {
|
||||
constructor (public readonly microphone: boolean = false, public readonly camera: boolean = false) {}
|
||||
|
||||
toggleCamera(): MuteSettings {
|
||||
return new MuteSettings(this.microphone, !this.camera);
|
||||
}
|
||||
|
||||
toggleMicrophone(): MuteSettings {
|
||||
return new MuteSettings(!this.microphone, this.camera);
|
||||
}
|
||||
}
|
391
src/matrix/calls/group/GroupCall.ts
Normal file
391
src/matrix/calls/group/GroupCall.ts
Normal file
|
@ -0,0 +1,391 @@
|
|||
/*
|
||||
Copyright 2022 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 {ObservableMap} from "../../../observable/map/ObservableMap";
|
||||
import {Member} from "./Member";
|
||||
import {LocalMedia} from "../LocalMedia";
|
||||
import {MuteSettings} from "../common";
|
||||
import {RoomMember} from "../../room/members/RoomMember";
|
||||
import {EventEmitter} from "../../../utils/EventEmitter";
|
||||
import {EventType, CallIntent} from "../callEventTypes";
|
||||
|
||||
import type {Options as MemberOptions} from "./Member";
|
||||
import type {BaseObservableMap} from "../../../observable/map/BaseObservableMap";
|
||||
import type {Track} from "../../../platform/types/MediaDevices";
|
||||
import type {SignallingMessage, MGroupCallBase, CallMembership} from "../callEventTypes";
|
||||
import type {Room} from "../../room/Room";
|
||||
import type {StateEvent} from "../../storage/types";
|
||||
import type {Platform} from "../../../platform/web/Platform";
|
||||
import type {EncryptedMessage} from "../../e2ee/olm/Encryption";
|
||||
import type {ILogItem} from "../../../logging/types";
|
||||
import type {Storage} from "../../storage/idb/Storage";
|
||||
|
||||
export enum GroupCallState {
|
||||
Fledgling = "fledgling",
|
||||
Creating = "creating",
|
||||
Created = "created",
|
||||
Joining = "joining",
|
||||
Joined = "joined",
|
||||
}
|
||||
|
||||
function getMemberKey(userId: string, deviceId: string) {
|
||||
return JSON.stringify(userId)+`,`+JSON.stringify(deviceId);
|
||||
}
|
||||
|
||||
function memberKeyIsForUser(key: string, userId: string) {
|
||||
return key.startsWith(JSON.stringify(userId)+`,`);
|
||||
}
|
||||
|
||||
function getDeviceFromMemberKey(key: string): string {
|
||||
return JSON.parse(`[${key}]`)[1];
|
||||
}
|
||||
|
||||
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
||||
emitUpdate: (call: GroupCall, params?: any) => void;
|
||||
encryptDeviceMessage: (roomId: string, userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
||||
storage: Storage,
|
||||
};
|
||||
|
||||
export class GroupCall extends EventEmitter<{change: never}> {
|
||||
private readonly _members: ObservableMap<string, Member> = new ObservableMap();
|
||||
private _localMedia?: LocalMedia = undefined;
|
||||
private _memberOptions: MemberOptions;
|
||||
private _state: GroupCallState;
|
||||
private localMuteSettings: MuteSettings = new MuteSettings(false, false);
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
newCall: boolean,
|
||||
private callContent: Record<string, any>,
|
||||
public readonly roomId: string,
|
||||
private readonly options: Options,
|
||||
private readonly logItem: ILogItem,
|
||||
) {
|
||||
super();
|
||||
logItem.set("id", this.id);
|
||||
this._state = newCall ? GroupCallState.Fledgling : GroupCallState.Created;
|
||||
this._memberOptions = Object.assign({}, options, {
|
||||
confId: this.id,
|
||||
emitUpdate: member => this._members.update(getMemberKey(member.userId, member.deviceId), member),
|
||||
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log) => {
|
||||
return this.options.encryptDeviceMessage(this.roomId, userId, message, log);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get localMedia(): LocalMedia | undefined { return this._localMedia; }
|
||||
get members(): BaseObservableMap<string, Member> { return this._members; }
|
||||
|
||||
get isTerminated(): boolean {
|
||||
return this.callContent?.["m.terminated"] === true;
|
||||
}
|
||||
|
||||
get isRinging(): boolean {
|
||||
return this._state === GroupCallState.Created && this.intent === "m.ring" && !this.isMember(this.options.ownUserId);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this.callContent?.["m.name"];
|
||||
}
|
||||
|
||||
get intent(): CallIntent {
|
||||
return this.callContent?.["m.intent"];
|
||||
}
|
||||
|
||||
join(localMedia: LocalMedia): Promise<void> {
|
||||
return this.logItem.wrap("join", async log => {
|
||||
if (this._state !== GroupCallState.Created) {
|
||||
return;
|
||||
}
|
||||
this._state = GroupCallState.Joining;
|
||||
this._localMedia = localMedia;
|
||||
this.emitChange();
|
||||
const memberContent = await this._createJoinPayload();
|
||||
// send m.call.member state event
|
||||
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
|
||||
await request.response();
|
||||
this.emitChange();
|
||||
// send invite to all members that are < my userId
|
||||
for (const [,member] of this._members) {
|
||||
member.connect(this._localMedia!.clone(), this.localMuteSettings);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async setMedia(localMedia: LocalMedia): Promise<void> {
|
||||
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined && this._localMedia) {
|
||||
const oldMedia = this._localMedia!;
|
||||
this._localMedia = localMedia;
|
||||
await Promise.all(Array.from(this._members.values()).map(m => {
|
||||
return m.setMedia(localMedia, oldMedia);
|
||||
}));
|
||||
oldMedia?.stopExcept(localMedia);
|
||||
}
|
||||
}
|
||||
|
||||
setMuted(muteSettings: MuteSettings) {
|
||||
this.localMuteSettings = muteSettings;
|
||||
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) {
|
||||
for (const [,member] of this._members) {
|
||||
member.setMuted(this.localMuteSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get muteSettings(): MuteSettings {
|
||||
return this.localMuteSettings;
|
||||
}
|
||||
|
||||
get hasJoined() {
|
||||
return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined;
|
||||
}
|
||||
|
||||
leave(): Promise<void> {
|
||||
return this.logItem.wrap("leave", async log => {
|
||||
const memberContent = await this._leaveCallMemberContent();
|
||||
// send m.call.member state event
|
||||
if (memberContent) {
|
||||
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCallMember, this.options.ownUserId, memberContent, {log});
|
||||
await request.response();
|
||||
// our own user isn't included in members, so not in the count
|
||||
if (this.intent === CallIntent.Ring && this._members.size === 0) {
|
||||
await this.terminate();
|
||||
}
|
||||
} else {
|
||||
log.set("already_left", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): Promise<void> {
|
||||
return this.logItem.wrap("terminate", async log => {
|
||||
if (this._state === GroupCallState.Fledgling) {
|
||||
return;
|
||||
}
|
||||
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, Object.assign({}, this.callContent, {
|
||||
"m.terminated": true
|
||||
}), {log});
|
||||
await request.response();
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
create(type: "m.video" | "m.voice"): Promise<void> {
|
||||
return this.logItem.wrap("create", async log => {
|
||||
if (this._state !== GroupCallState.Fledgling) {
|
||||
return;
|
||||
}
|
||||
this._state = GroupCallState.Creating;
|
||||
this.emitChange();
|
||||
this.callContent = Object.assign({
|
||||
"m.type": type,
|
||||
}, this.callContent);
|
||||
const request = this.options.hsApi.sendState(this.roomId, EventType.GroupCall, this.id, this.callContent!, {log});
|
||||
await request.response();
|
||||
this._state = GroupCallState.Created;
|
||||
this.emitChange();
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
updateCallEvent(callContent: Record<string, any>, syncLog: ILogItem) {
|
||||
this.logItem.wrap("updateCallEvent", log => {
|
||||
syncLog.refDetached(log);
|
||||
this.callContent = callContent;
|
||||
if (this._state === GroupCallState.Creating) {
|
||||
this._state = GroupCallState.Created;
|
||||
}
|
||||
log.set("status", this._state);
|
||||
this.emitChange();
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
updateMembership(userId: string, callMembership: CallMembership, syncLog: ILogItem) {
|
||||
this.logItem.wrap({l: "updateMember", id: userId}, log => {
|
||||
syncLog.refDetached(log);
|
||||
const devices = callMembership["m.devices"];
|
||||
const previousDeviceIds = this.getDeviceIdsForUserId(userId);
|
||||
for (const device of devices) {
|
||||
const deviceId = device.device_id;
|
||||
const memberKey = getMemberKey(userId, deviceId);
|
||||
log.wrap({l: "update device member", id: memberKey}, log => {
|
||||
if (userId === this.options.ownUserId && deviceId === this.options.ownDeviceId) {
|
||||
if (this._state === GroupCallState.Joining) {
|
||||
log.set("update_own", true);
|
||||
this._state = GroupCallState.Joined;
|
||||
this.emitChange();
|
||||
}
|
||||
} else {
|
||||
let member = this._members.get(memberKey);
|
||||
if (member) {
|
||||
log.set("update", true);
|
||||
member!.updateCallInfo(device);
|
||||
} else {
|
||||
const logItem = this.logItem.child({l: "member", id: memberKey});
|
||||
log.set("add", true);
|
||||
log.refDetached(logItem);
|
||||
member = new Member(
|
||||
RoomMember.fromUserId(this.roomId, userId, "join"),
|
||||
device, this._memberOptions, logItem
|
||||
);
|
||||
this._members.add(memberKey, member);
|
||||
if (this._state === GroupCallState.Joining || this._state === GroupCallState.Joined) {
|
||||
// Safari can't send a MediaStream to multiple sources, so clone it
|
||||
member.connect(this._localMedia!.clone(), this.localMuteSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const newDeviceIds = new Set<string>(devices.map(call => call.device_id));
|
||||
// remove user as member of any calls not present anymore
|
||||
for (const previousDeviceId of previousDeviceIds) {
|
||||
if (!newDeviceIds.has(previousDeviceId)) {
|
||||
log.wrap({l: "remove device member", id: getMemberKey(userId, previousDeviceId)}, log => {
|
||||
this.removeMemberDevice(userId, previousDeviceId, log);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (userId === this.options.ownUserId && !newDeviceIds.has(this.options.ownDeviceId)) {
|
||||
this.removeOwnDevice(log);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
removeMembership(userId: string, syncLog: ILogItem) {
|
||||
const deviceIds = this.getDeviceIdsForUserId(userId);
|
||||
this.logItem.wrap("removeMember", log => {
|
||||
syncLog.refDetached(log);
|
||||
for (const deviceId of deviceIds) {
|
||||
this.removeMemberDevice(userId, deviceId, log);
|
||||
}
|
||||
if (userId === this.options.ownUserId) {
|
||||
this.removeOwnDevice(log);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getDeviceIdsForUserId(userId: string): string[] {
|
||||
return Array.from(this._members.keys())
|
||||
.filter(key => memberKeyIsForUser(key, userId))
|
||||
.map(key => getDeviceFromMemberKey(key));
|
||||
}
|
||||
|
||||
private isMember(userId: string): boolean {
|
||||
return Array.from(this._members.keys()).some(key => memberKeyIsForUser(key, userId));
|
||||
}
|
||||
|
||||
private removeOwnDevice(log: ILogItem) {
|
||||
if (this._state === GroupCallState.Joined) {
|
||||
log.set("leave_own", true);
|
||||
for (const [,member] of this._members) {
|
||||
member.disconnect(true);
|
||||
}
|
||||
this._localMedia?.dispose();
|
||||
this._localMedia = undefined;
|
||||
this._state = GroupCallState.Created;
|
||||
this.emitChange();
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
private removeMemberDevice(userId: string, deviceId: string, log: ILogItem) {
|
||||
const memberKey = getMemberKey(userId, deviceId);
|
||||
log.wrap({l: "removeMemberDevice", id: memberKey}, log => {
|
||||
const member = this._members.get(memberKey);
|
||||
if (member) {
|
||||
log.set("leave", true);
|
||||
this._members.remove(memberKey);
|
||||
member.disconnect(false);
|
||||
}
|
||||
this.emitChange();
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, syncLog: ILogItem) {
|
||||
// TODO: return if we are not membering to the call
|
||||
let member = this._members.get(getMemberKey(userId, deviceId));
|
||||
if (member) {
|
||||
member.handleDeviceMessage(message, deviceId, syncLog);
|
||||
} else {
|
||||
const item = this.logItem.log({l: "could not find member for signalling message", userId, deviceId});
|
||||
syncLog.refDetached(item);
|
||||
// we haven't received the m.call.member yet for this caller. buffer the device messages or create the member/call anyway?
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
dispose() {
|
||||
this.logItem.finish();
|
||||
}
|
||||
|
||||
private async _createJoinPayload() {
|
||||
const {storage} = this.options;
|
||||
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||
const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId);
|
||||
const stateContent = stateEvent?.event?.content ?? {
|
||||
["m.calls"]: []
|
||||
};
|
||||
const callsInfo = stateContent["m.calls"];
|
||||
let callInfo = callsInfo.find(c => c["m.call_id"] === this.id);
|
||||
if (!callInfo) {
|
||||
callInfo = {
|
||||
["m.call_id"]: this.id,
|
||||
["m.devices"]: []
|
||||
};
|
||||
callsInfo.push(callInfo);
|
||||
}
|
||||
const devicesInfo = callInfo["m.devices"];
|
||||
let deviceInfo = devicesInfo.find(d => d["device_id"] === this.options.ownDeviceId);
|
||||
if (!deviceInfo) {
|
||||
deviceInfo = {
|
||||
["device_id"]: this.options.ownDeviceId,
|
||||
["session_id"]: this.options.sessionId,
|
||||
feeds: [{purpose: "m.usermedia"}]
|
||||
};
|
||||
devicesInfo.push(deviceInfo);
|
||||
}
|
||||
return stateContent;
|
||||
}
|
||||
|
||||
private async _leaveCallMemberContent(): Promise<Record<string, any> | undefined> {
|
||||
const {storage} = this.options;
|
||||
const txn = await storage.readTxn([storage.storeNames.roomState]);
|
||||
const stateEvent = await txn.roomState.get(this.roomId, EventType.GroupCallMember, this.options.ownUserId);
|
||||
if (stateEvent) {
|
||||
const content = stateEvent.event.content;
|
||||
const callInfo = content["m.calls"]?.find(c => c["m.call_id"] === this.id);
|
||||
if (callInfo) {
|
||||
const devicesInfo = callInfo["m.devices"];
|
||||
const deviceIndex = devicesInfo.findIndex(d => d["device_id"] === this.options.ownDeviceId);
|
||||
if (deviceIndex !== -1) {
|
||||
devicesInfo.splice(deviceIndex, 1);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
protected emitChange() {
|
||||
this.emit("change");
|
||||
this.options.emitUpdate(this);
|
||||
}
|
||||
}
|
211
src/matrix/calls/group/Member.ts
Normal file
211
src/matrix/calls/group/Member.ts
Normal file
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
Copyright 2022 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 {PeerCall, CallState} from "../PeerCall";
|
||||
import {makeTxnId, makeId} from "../../common";
|
||||
import {EventType, CallErrorCode} from "../callEventTypes";
|
||||
import {formatToDeviceMessagesPayload} from "../../common";
|
||||
|
||||
import type {MuteSettings} from "../common";
|
||||
import type {Options as PeerCallOptions, RemoteMedia} from "../PeerCall";
|
||||
import type {LocalMedia} from "../LocalMedia";
|
||||
import type {HomeServerApi} from "../../net/HomeServerApi";
|
||||
import type {MCallBase, MGroupCallBase, SignallingMessage, CallDeviceMembership} from "../callEventTypes";
|
||||
import type {GroupCall} from "./GroupCall";
|
||||
import type {RoomMember} from "../../room/members/RoomMember";
|
||||
import type {EncryptedMessage} from "../../e2ee/olm/Encryption";
|
||||
import type {ILogItem} from "../../../logging/types";
|
||||
|
||||
export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessage"> & {
|
||||
confId: string,
|
||||
ownUserId: string,
|
||||
ownDeviceId: string,
|
||||
sessionId: string,
|
||||
hsApi: HomeServerApi,
|
||||
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
||||
emitUpdate: (participant: Member, params?: any) => void,
|
||||
}
|
||||
|
||||
const errorCodesWithoutRetry = [
|
||||
CallErrorCode.UserHangup,
|
||||
CallErrorCode.AnsweredElsewhere,
|
||||
CallErrorCode.Replaced,
|
||||
CallErrorCode.UserBusy,
|
||||
CallErrorCode.Transfered,
|
||||
CallErrorCode.NewSession
|
||||
];
|
||||
|
||||
export class Member {
|
||||
private peerCall?: PeerCall;
|
||||
private localMedia?: LocalMedia;
|
||||
private localMuteSettings?: MuteSettings;
|
||||
private retryCount: number = 0;
|
||||
|
||||
constructor(
|
||||
public readonly member: RoomMember,
|
||||
private callDeviceMembership: CallDeviceMembership,
|
||||
private readonly options: Options,
|
||||
private readonly logItem: ILogItem,
|
||||
) {}
|
||||
|
||||
get remoteMedia(): RemoteMedia | undefined {
|
||||
return this.peerCall?.remoteMedia;
|
||||
}
|
||||
|
||||
get remoteMuteSettings(): MuteSettings | undefined {
|
||||
return this.peerCall?.remoteMuteSettings;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.peerCall?.state === CallState.Connected;
|
||||
}
|
||||
|
||||
get userId(): string {
|
||||
return this.member.userId;
|
||||
}
|
||||
|
||||
get deviceId(): string {
|
||||
return this.callDeviceMembership.device_id;
|
||||
}
|
||||
|
||||
get dataChannel(): any | undefined {
|
||||
return this.peerCall?.dataChannel;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
connect(localMedia: LocalMedia, localMuteSettings: MuteSettings) {
|
||||
this.logItem.wrap("connect", () => {
|
||||
this.localMedia = localMedia;
|
||||
this.localMuteSettings = localMuteSettings;
|
||||
// otherwise wait for it to connect
|
||||
let shouldInitiateCall;
|
||||
// the lexicographically lower side initiates the call
|
||||
if (this.member.userId === this.options.ownUserId) {
|
||||
shouldInitiateCall = this.deviceId > this.options.ownDeviceId;
|
||||
} else {
|
||||
shouldInitiateCall = this.member.userId > this.options.ownUserId;
|
||||
}
|
||||
if (shouldInitiateCall) {
|
||||
this.peerCall = this._createPeerCall(makeId("c"));
|
||||
this.peerCall.call(localMedia, localMuteSettings);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
disconnect(hangup: boolean) {
|
||||
this.logItem.wrap("disconnect", log => {
|
||||
if (hangup) {
|
||||
this.peerCall?.hangup(CallErrorCode.UserHangup);
|
||||
} else {
|
||||
this.peerCall?.close(undefined, log);
|
||||
}
|
||||
this.peerCall?.dispose();
|
||||
this.peerCall = undefined;
|
||||
this.localMedia?.dispose();
|
||||
this.localMedia = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
updateCallInfo(callDeviceMembership: CallDeviceMembership) {
|
||||
this.callDeviceMembership = callDeviceMembership;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
emitUpdate = (peerCall: PeerCall, params: any) => {
|
||||
if (peerCall.state === CallState.Ringing) {
|
||||
peerCall.answer(this.localMedia!, this.localMuteSettings!);
|
||||
}
|
||||
else if (peerCall.state === CallState.Ended) {
|
||||
const hangupReason = peerCall.hangupReason;
|
||||
peerCall.dispose();
|
||||
this.peerCall = undefined;
|
||||
if (hangupReason && !errorCodesWithoutRetry.includes(hangupReason)) {
|
||||
this.retryCount += 1;
|
||||
if (this.retryCount <= 3) {
|
||||
this.connect(this.localMedia!, this.localMuteSettings!);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.options.emitUpdate(this, params);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem): Promise<void> => {
|
||||
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
||||
groupMessage.content.conf_id = this.options.confId;
|
||||
groupMessage.content.device_id = this.options.ownDeviceId;
|
||||
groupMessage.content.party_id = this.options.ownDeviceId;
|
||||
groupMessage.content.sender_session_id = this.options.sessionId;
|
||||
groupMessage.content.dest_session_id = this.callDeviceMembership.session_id;
|
||||
// const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
|
||||
// const payload = formatToDeviceMessagesPayload(encryptedMessages);
|
||||
const payload = {
|
||||
messages: {
|
||||
[this.member.userId]: {
|
||||
[this.deviceId]: groupMessage.content
|
||||
}
|
||||
}
|
||||
};
|
||||
// TODO: remove this for release
|
||||
log.set("payload", groupMessage.content);
|
||||
const request = this.options.hsApi.sendToDevice(
|
||||
message.type,
|
||||
//"m.room.encrypted",
|
||||
payload,
|
||||
makeTxnId(),
|
||||
{log}
|
||||
);
|
||||
await request.response();
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, deviceId: string, syncLog: ILogItem) {
|
||||
syncLog.refDetached(this.logItem);
|
||||
const destSessionId = message.content.dest_session_id;
|
||||
if (destSessionId !== this.options.sessionId) {
|
||||
this.logItem.log({l: "ignoring to_device event with wrong session_id", destSessionId, type: message.type});
|
||||
return;
|
||||
}
|
||||
if (message.type === EventType.Invite && !this.peerCall) {
|
||||
this.peerCall = this._createPeerCall(message.content.call_id);
|
||||
}
|
||||
if (this.peerCall) {
|
||||
this.peerCall.handleIncomingSignallingMessage(message, deviceId);
|
||||
} else {
|
||||
// TODO: need to buffer events until invite comes?
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
async setMedia(localMedia: LocalMedia, previousMedia: LocalMedia): Promise<void> {
|
||||
this.localMedia = localMedia.replaceClone(this.localMedia, previousMedia);
|
||||
await this.peerCall?.setMedia(this.localMedia);
|
||||
}
|
||||
|
||||
setMuted(muteSettings: MuteSettings) {
|
||||
this.localMuteSettings = muteSettings;
|
||||
this.peerCall?.setMuted(muteSettings);
|
||||
}
|
||||
|
||||
private _createPeerCall(callId: string): PeerCall {
|
||||
return new PeerCall(callId, Object.assign({}, this.options, {
|
||||
emitUpdate: this.emitUpdate,
|
||||
sendSignallingMessage: this.sendSignallingMessage
|
||||
}), this.logItem);
|
||||
}
|
||||
}
|
|
@ -15,16 +15,37 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {groupBy} from "../utils/groupBy";
|
||||
|
||||
|
||||
export function makeTxnId() {
|
||||
return makeId("t");
|
||||
}
|
||||
|
||||
export function makeId(prefix) {
|
||||
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
const str = n.toString(16);
|
||||
return "t" + "0".repeat(14 - str.length) + str;
|
||||
return prefix + "0".repeat(14 - str.length) + str;
|
||||
}
|
||||
|
||||
export function isTxnId(txnId) {
|
||||
return txnId.startsWith("t") && txnId.length === 15;
|
||||
}
|
||||
|
||||
export function formatToDeviceMessagesPayload(messages) {
|
||||
const messagesByUser = groupBy(messages, message => message.device.userId);
|
||||
const payload = {
|
||||
messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
|
||||
userMap[userId] = messages.reduce((deviceMap, message) => {
|
||||
deviceMap[message.device.deviceId] = message.content;
|
||||
return deviceMap;
|
||||
}, {});
|
||||
return userMap;
|
||||
}, {})
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"isTxnId succeeds on result of makeTxnId": assert => {
|
||||
|
|
|
@ -69,6 +69,14 @@ export class DecryptionResult {
|
|||
}
|
||||
}
|
||||
|
||||
get userId(): string | undefined {
|
||||
return this.device?.userId;
|
||||
}
|
||||
|
||||
get deviceId(): string | undefined {
|
||||
return this.device?.deviceId;
|
||||
}
|
||||
|
||||
get isVerificationUnknown(): boolean {
|
||||
// verification is unknown if we haven't yet fetched the devices for the room
|
||||
return !this.device && !this.roomTracked;
|
||||
|
|
|
@ -18,7 +18,7 @@ import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
|
|||
import {groupEventsBySession} from "./megolm/decryption/utils";
|
||||
import {mergeMap} from "../../utils/mergeMap";
|
||||
import {groupBy} from "../../utils/groupBy";
|
||||
import {makeTxnId} from "../common.js";
|
||||
import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js";
|
||||
|
||||
const ENCRYPTED_TYPE = "m.room.encrypted";
|
||||
// how often ensureMessageKeyIsShared can check if it needs to
|
||||
|
@ -386,6 +386,7 @@ export class RoomEncryption {
|
|||
await writeTxn.complete();
|
||||
}
|
||||
|
||||
// TODO: make this use _sendMessagesToDevices
|
||||
async _sendSharedMessageToDevices(type, message, devices, hsApi, log) {
|
||||
const devicesByUser = groupBy(devices, device => device.userId);
|
||||
const payload = {
|
||||
|
@ -403,16 +404,7 @@ export class RoomEncryption {
|
|||
|
||||
async _sendMessagesToDevices(type, messages, hsApi, log) {
|
||||
log.set("messages", messages.length);
|
||||
const messagesByUser = groupBy(messages, message => message.device.userId);
|
||||
const payload = {
|
||||
messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
|
||||
userMap[userId] = messages.reduce((deviceMap, message) => {
|
||||
deviceMap[message.device.deviceId] = message.content;
|
||||
return deviceMap;
|
||||
}, {});
|
||||
return userMap;
|
||||
}, {})
|
||||
};
|
||||
const payload = formatToDeviceMessagesPayload(messages);
|
||||
const txnId = makeTxnId();
|
||||
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ import {StoredRoomKey, keyFromBackup} from "../decryption/RoomKey";
|
|||
import {MEGOLM_ALGORITHM} from "../../common";
|
||||
import * as Curve25519 from "./Curve25519";
|
||||
import {AbortableOperation} from "../../../../utils/AbortableOperation";
|
||||
import {ObservableValue} from "../../../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../../../observable/value/ObservableValue";
|
||||
|
||||
import {SetAbortableFn} from "../../../../utils/AbortableOperation";
|
||||
import type {BackupInfo, SessionData, SessionKeyInfo, SessionInfo, KeyBackupPayload} from "./types";
|
||||
|
|
|
@ -311,7 +311,7 @@ class EncryptionTarget {
|
|||
}
|
||||
}
|
||||
|
||||
class EncryptedMessage {
|
||||
export class EncryptedMessage {
|
||||
constructor(
|
||||
public readonly content: OlmEncryptedMessageContent,
|
||||
public readonly device: DeviceIdentity
|
||||
|
|
|
@ -159,6 +159,10 @@ export class HomeServerApi {
|
|||
state(roomId: string, eventType: string, stateKey: string, options?: BaseRequestOptions): IHomeServerRequest {
|
||||
return this._get(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, undefined, options);
|
||||
}
|
||||
|
||||
sendState(roomId: string, eventType: string, stateKey: string, content: Record<string, any>, options?: BaseRequestOptions): IHomeServerRequest {
|
||||
return this._put(`/rooms/${encodeURIComponent(roomId)}/state/${encodeURIComponent(eventType)}/${encodeURIComponent(stateKey)}`, {}, content, options);
|
||||
}
|
||||
|
||||
getLoginFlows(): IHomeServerRequest {
|
||||
return this._unauthedRequest("GET", this._url("/login"));
|
||||
|
|
|
@ -29,32 +29,31 @@ export class MediaRepository {
|
|||
this._platform = platform;
|
||||
}
|
||||
|
||||
mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | null {
|
||||
mxcUrlThumbnail(url: string, width: number, height: number, method: "crop" | "scale"): string | undefined {
|
||||
const parts = this._parseMxcUrl(url);
|
||||
if (parts) {
|
||||
const [serverName, mediaId] = parts;
|
||||
const httpUrl = `${this._homeserver}/_matrix/media/r0/thumbnail/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||
return httpUrl + "?" + encodeQueryParams({width: Math.round(width), height: Math.round(height), method});
|
||||
}
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
mxcUrl(url: string): string | null {
|
||||
mxcUrl(url: string): string | undefined {
|
||||
const parts = this._parseMxcUrl(url);
|
||||
if (parts) {
|
||||
const [serverName, mediaId] = parts;
|
||||
return `${this._homeserver}/_matrix/media/r0/download/${encodeURIComponent(serverName)}/${encodeURIComponent(mediaId)}`;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _parseMxcUrl(url: string): string[] | null {
|
||||
private _parseMxcUrl(url: string): string[] | undefined {
|
||||
const prefix = "mxc://";
|
||||
if (url.startsWith(prefix)) {
|
||||
return url.substr(prefix.length).split("/", 2);
|
||||
} else {
|
||||
return null;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ObservableValue} from "../../observable/ObservableValue";
|
||||
import {ObservableValue} from "../../observable/value/ObservableValue";
|
||||
import type {ExponentialRetryDelay} from "./ExponentialRetryDelay";
|
||||
import type {TimeMeasure} from "../../platform/web/dom/Clock.js";
|
||||
import type {OnlineStatus} from "../../platform/web/dom/OnlineStatus.js";
|
||||
|
|
|
@ -29,7 +29,7 @@ import {ObservedEventMap} from "./ObservedEventMap.js";
|
|||
import {DecryptionSource} from "../e2ee/common.js";
|
||||
import {ensureLogItem} from "../../logging/utils";
|
||||
import {PowerLevels} from "./PowerLevels.js";
|
||||
import {RetainedObservableValue} from "../../observable/ObservableValue";
|
||||
import {RetainedObservableValue} from "../../observable/value/RetainedObservableValue";
|
||||
import {TimelineReader} from "./timeline/persistence/TimelineReader";
|
||||
|
||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue} from "../../observable/ObservableValue";
|
||||
import {BaseObservableValue} from "../../observable/value/BaseObservableValue";
|
||||
|
||||
export class ObservedEventMap {
|
||||
constructor(notifyEmpty) {
|
||||
|
|
|
@ -30,6 +30,7 @@ const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
|||
export class Room extends BaseRoom {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
this._callHandler = options.callHandler;
|
||||
// TODO: pass pendingEvents to start like pendingOperations?
|
||||
const {pendingEvents} = options;
|
||||
const relationWriter = new RelationWriter({
|
||||
|
@ -178,6 +179,7 @@ export class Room extends BaseRoom {
|
|||
removedPendingEvents = await this._sendQueue.removeRemoteEchos(roomResponse.timeline.events, txn, log);
|
||||
}
|
||||
const powerLevelsEvent = this._getPowerLevelsEvent(roomResponse);
|
||||
this._updateCallHandler(roomResponse, txn, log);
|
||||
return {
|
||||
summaryChanges,
|
||||
roomEncryption,
|
||||
|
@ -215,6 +217,9 @@ export class Room extends BaseRoom {
|
|||
if (this._memberList) {
|
||||
this._memberList.afterSync(memberChanges);
|
||||
}
|
||||
if (this._callHandler) {
|
||||
this._callHandler.updateRoomMembers(this, memberChanges);
|
||||
}
|
||||
if (this._observedMembers) {
|
||||
this._updateObservedMembers(memberChanges);
|
||||
}
|
||||
|
@ -442,6 +447,22 @@ export class Room extends BaseRoom {
|
|||
return this._sendQueue.pendingEvents;
|
||||
}
|
||||
|
||||
_updateCallHandler(roomResponse, txn, log) {
|
||||
if (this._callHandler) {
|
||||
const stateEvents = roomResponse.state?.events;
|
||||
if (stateEvents?.length) {
|
||||
this._callHandler.handleRoomState(this, stateEvents, txn, log);
|
||||
}
|
||||
let timelineEvents = roomResponse.timeline?.events;
|
||||
if (timelineEvents) {
|
||||
const timelineStateEvents = timelineEvents.filter(e => typeof e.state_key === "string");
|
||||
if (timelineEvents.length !== 0) {
|
||||
this._callHandler.handleRoomState(this, timelineStateEvents, txn, log);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @package */
|
||||
writeIsTrackingMembers(value, txn) {
|
||||
return this._summary.writeIsTrackingMembers(value, txn);
|
||||
|
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index.js";
|
||||
import {SortedArray, AsyncMappedList, ConcatList, ObservableArray} from "../../../observable/index";
|
||||
import {Disposables} from "../../../utils/Disposables";
|
||||
import {Direction} from "./Direction";
|
||||
import {TimelineReader} from "./persistence/TimelineReader.js";
|
||||
|
|
|
@ -33,6 +33,7 @@ export enum StoreNames {
|
|||
groupSessionDecryptions = "groupSessionDecryptions",
|
||||
operations = "operations",
|
||||
accountData = "accountData",
|
||||
calls = "calls"
|
||||
}
|
||||
|
||||
export const STORE_NAMES: Readonly<StoreNames[]> = Object.values(StoreNames);
|
||||
|
|
|
@ -36,6 +36,7 @@ import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore";
|
|||
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore";
|
||||
import {OperationStore} from "./stores/OperationStore";
|
||||
import {AccountDataStore} from "./stores/AccountDataStore";
|
||||
import {CallStore} from "./stores/CallStore";
|
||||
import type {ILogger, ILogItem} from "../../../logging/types";
|
||||
|
||||
export type IDBKey = IDBValidKey | IDBKeyRange;
|
||||
|
@ -167,6 +168,10 @@ export class Transaction {
|
|||
get accountData(): AccountDataStore {
|
||||
return this._store(StoreNames.accountData, idbStore => new AccountDataStore(idbStore));
|
||||
}
|
||||
|
||||
get calls(): CallStore {
|
||||
return this._store(StoreNames.calls, idbStore => new CallStore(idbStore));
|
||||
}
|
||||
|
||||
async complete(log?: ILogItem): Promise<void> {
|
||||
try {
|
||||
|
|
|
@ -34,7 +34,8 @@ export const schema: MigrationFunc[] = [
|
|||
backupAndRestoreE2EEAccountToLocalStorage,
|
||||
clearAllStores,
|
||||
addInboundSessionBackupIndex,
|
||||
migrateBackupStatus
|
||||
migrateBackupStatus,
|
||||
createCallStore
|
||||
];
|
||||
// TODO: how to deal with git merge conflicts of this array?
|
||||
|
||||
|
@ -309,3 +310,8 @@ async function migrateBackupStatus(db: IDBDatabase, txn: IDBTransaction, localSt
|
|||
log.set("countWithoutSession", countWithoutSession);
|
||||
log.set("countWithSession", countWithSession);
|
||||
}
|
||||
|
||||
//v17 create calls store
|
||||
function createCallStore(db: IDBDatabase) : void {
|
||||
db.createObjectStore("calls", {keyPath: "key"});
|
||||
}
|
||||
|
|
83
src/matrix/storage/idb/stores/CallStore.ts
Normal file
83
src/matrix/storage/idb/stores/CallStore.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
Copyright 2020 Bruno Windels <bruno@windels.cloud>
|
||||
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.
|
||||
*/
|
||||
|
||||
import {Store} from "../Store";
|
||||
import {StateEvent} from "../../types";
|
||||
import {MIN_UNICODE, MAX_UNICODE} from "./common";
|
||||
|
||||
function encodeKey(intent: string, roomId: string, callId: string) {
|
||||
return `${intent}|${roomId}|${callId}`;
|
||||
}
|
||||
|
||||
function decodeStorageEntry(storageEntry: CallStorageEntry): CallEntry {
|
||||
const [intent, roomId, callId] = storageEntry.key.split("|");
|
||||
return {intent, roomId, callId, timestamp: storageEntry.timestamp};
|
||||
}
|
||||
|
||||
export interface CallEntry {
|
||||
intent: string;
|
||||
roomId: string;
|
||||
callId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type CallStorageEntry = {
|
||||
key: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class CallStore {
|
||||
private _callStore: Store<CallStorageEntry>;
|
||||
|
||||
constructor(idbStore: Store<CallStorageEntry>) {
|
||||
this._callStore = idbStore;
|
||||
}
|
||||
|
||||
async getByIntent(intent: string): Promise<CallEntry[]> {
|
||||
const range = this._callStore.IDBKeyRange.bound(
|
||||
encodeKey(intent, MIN_UNICODE, MIN_UNICODE),
|
||||
encodeKey(intent, MAX_UNICODE, MAX_UNICODE),
|
||||
true,
|
||||
true
|
||||
);
|
||||
const storageEntries = await this._callStore.selectAll(range);
|
||||
return storageEntries.map(e => decodeStorageEntry(e));
|
||||
}
|
||||
|
||||
async getByIntentAndRoom(intent: string, roomId: string): Promise<CallEntry[]> {
|
||||
const range = this._callStore.IDBKeyRange.bound(
|
||||
encodeKey(intent, roomId, MIN_UNICODE),
|
||||
encodeKey(intent, roomId, MAX_UNICODE),
|
||||
true,
|
||||
true
|
||||
);
|
||||
const storageEntries = await this._callStore.selectAll(range);
|
||||
return storageEntries.map(e => decodeStorageEntry(e));
|
||||
}
|
||||
|
||||
add(entry: CallEntry) {
|
||||
const storageEntry: CallStorageEntry = {
|
||||
key: encodeKey(entry.intent, entry.roomId, entry.callId),
|
||||
timestamp: entry.timestamp
|
||||
};
|
||||
this._callStore.add(storageEntry);
|
||||
}
|
||||
|
||||
remove(intent: string, roomId: string, callId: string): void {
|
||||
this._callStore.delete(encodeKey(intent, roomId, callId));
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MAX_UNICODE} from "./common";
|
||||
import {MIN_UNICODE, MAX_UNICODE} from "./common";
|
||||
import {Store} from "../Store";
|
||||
import {StateEvent} from "../../types";
|
||||
|
||||
|
@ -41,6 +41,16 @@ export class RoomStateStore {
|
|||
return this._roomStateStore.get(key);
|
||||
}
|
||||
|
||||
getAllForType(roomId: string, type: string): Promise<RoomStateEntry[]> {
|
||||
const range = this._roomStateStore.IDBKeyRange.bound(
|
||||
encodeKey(roomId, type, MIN_UNICODE),
|
||||
encodeKey(roomId, type, MAX_UNICODE),
|
||||
true,
|
||||
true
|
||||
);
|
||||
return this._roomStateStore.selectAll(range);
|
||||
}
|
||||
|
||||
set(roomId: string, event: StateEvent): void {
|
||||
const key = encodeKey(roomId, event.type, event.state_key);
|
||||
const entry = {roomId, event, key};
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {ObservableValue} from "../observable/ObservableValue";
|
||||
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||
|
||||
class Timeout {
|
||||
constructor(elapsed, ms) {
|
||||
|
|
|
@ -1,248 +0,0 @@
|
|||
/*
|
||||
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 {AbortError} from "../utils/error";
|
||||
import {BaseObservable} from "./BaseObservable";
|
||||
import type {SubscriptionHandle} from "./BaseObservable";
|
||||
|
||||
// like an EventEmitter, but doesn't have an event type
|
||||
export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) => void> {
|
||||
emit(argument: T) {
|
||||
for (const h of this._handlers) {
|
||||
h(argument);
|
||||
}
|
||||
}
|
||||
|
||||
abstract get(): T;
|
||||
|
||||
waitFor(predicate: (value: T) => boolean): IWaitHandle<T> {
|
||||
if (predicate(this.get())) {
|
||||
return new ResolvedWaitForHandle(Promise.resolve(this.get()));
|
||||
} else {
|
||||
return new WaitForHandle(this, predicate);
|
||||
}
|
||||
}
|
||||
|
||||
flatMap<C>(mapper: (value: T) => (BaseObservableValue<C> | undefined)): BaseObservableValue<C | undefined> {
|
||||
return new FlatMapObservableValue<T, C>(this, mapper);
|
||||
}
|
||||
}
|
||||
|
||||
interface IWaitHandle<T> {
|
||||
promise: Promise<T>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
class WaitForHandle<T> implements IWaitHandle<T> {
|
||||
private _promise: Promise<T>
|
||||
private _reject: ((reason?: any) => void) | null;
|
||||
private _subscription: (() => void) | null;
|
||||
|
||||
constructor(observable: BaseObservableValue<T>, predicate: (value: T) => boolean) {
|
||||
this._promise = new Promise((resolve, reject) => {
|
||||
this._reject = reject;
|
||||
this._subscription = observable.subscribe(v => {
|
||||
if (predicate(v)) {
|
||||
this._reject = null;
|
||||
resolve(v);
|
||||
this.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get promise(): Promise<T> {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._subscription) {
|
||||
this._subscription();
|
||||
this._subscription = null;
|
||||
}
|
||||
if (this._reject) {
|
||||
this._reject(new AbortError());
|
||||
this._reject = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResolvedWaitForHandle<T> implements IWaitHandle<T> {
|
||||
constructor(public promise: Promise<T>) {}
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
export class ObservableValue<T> extends BaseObservableValue<T> {
|
||||
private _value: T;
|
||||
|
||||
constructor(initialValue: T) {
|
||||
super();
|
||||
this._value = initialValue;
|
||||
}
|
||||
|
||||
get(): T {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
if (value !== this._value) {
|
||||
this._value = value;
|
||||
this.emit(this._value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RetainedObservableValue<T> extends ObservableValue<T> {
|
||||
private _freeCallback: () => void;
|
||||
|
||||
constructor(initialValue: T, freeCallback: () => void) {
|
||||
super(initialValue);
|
||||
this._freeCallback = freeCallback;
|
||||
}
|
||||
|
||||
onUnsubscribeLast() {
|
||||
super.onUnsubscribeLast();
|
||||
this._freeCallback();
|
||||
}
|
||||
}
|
||||
|
||||
export class FlatMapObservableValue<P, C> extends BaseObservableValue<C | undefined> {
|
||||
private sourceSubscription?: SubscriptionHandle;
|
||||
private targetSubscription?: SubscriptionHandle;
|
||||
|
||||
constructor(
|
||||
private readonly source: BaseObservableValue<P>,
|
||||
private readonly mapper: (value: P) => (BaseObservableValue<C> | undefined)
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
onUnsubscribeLast() {
|
||||
super.onUnsubscribeLast();
|
||||
this.sourceSubscription = this.sourceSubscription!();
|
||||
if (this.targetSubscription) {
|
||||
this.targetSubscription = this.targetSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
onSubscribeFirst() {
|
||||
super.onSubscribeFirst();
|
||||
this.sourceSubscription = this.source.subscribe(() => {
|
||||
this.updateTargetSubscription();
|
||||
this.emit(this.get());
|
||||
});
|
||||
this.updateTargetSubscription();
|
||||
}
|
||||
|
||||
private updateTargetSubscription() {
|
||||
const sourceValue = this.source.get();
|
||||
if (sourceValue) {
|
||||
const target = this.mapper(sourceValue);
|
||||
if (target) {
|
||||
if (!this.targetSubscription) {
|
||||
this.targetSubscription = target.subscribe(() => this.emit(this.get()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if no sourceValue or target
|
||||
if (this.targetSubscription) {
|
||||
this.targetSubscription = this.targetSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
get(): C | undefined {
|
||||
const sourceValue = this.source.get();
|
||||
if (!sourceValue) {
|
||||
return undefined;
|
||||
}
|
||||
const mapped = this.mapper(sourceValue);
|
||||
return mapped?.get();
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"set emits an update": assert => {
|
||||
const a = new ObservableValue<number>(0);
|
||||
let fired = false;
|
||||
const subscription = a.subscribe(v => {
|
||||
fired = true;
|
||||
assert.strictEqual(v, 5);
|
||||
});
|
||||
a.set(5);
|
||||
assert(fired);
|
||||
subscription();
|
||||
},
|
||||
"set doesn't emit if value hasn't changed": assert => {
|
||||
const a = new ObservableValue(5);
|
||||
let fired = false;
|
||||
const subscription = a.subscribe(() => {
|
||||
fired = true;
|
||||
});
|
||||
a.set(5);
|
||||
a.set(5);
|
||||
assert(!fired);
|
||||
subscription();
|
||||
},
|
||||
"waitFor promise resolves on matching update": async assert => {
|
||||
const a = new ObservableValue(5);
|
||||
const handle = a.waitFor(v => v === 6);
|
||||
Promise.resolve().then(() => {
|
||||
a.set(6);
|
||||
});
|
||||
await handle.promise;
|
||||
assert.strictEqual(a.get(), 6);
|
||||
},
|
||||
"waitFor promise rejects when disposed": async assert => {
|
||||
const a = new ObservableValue<number>(0);
|
||||
const handle = a.waitFor(() => false);
|
||||
Promise.resolve().then(() => {
|
||||
handle.dispose();
|
||||
});
|
||||
await assert.rejects(handle.promise, AbortError);
|
||||
},
|
||||
"flatMap.get": assert => {
|
||||
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
|
||||
const countProxy = a.flatMap(a => a!.count);
|
||||
assert.strictEqual(countProxy.get(), undefined);
|
||||
const count = new ObservableValue<number>(0);
|
||||
a.set({count});
|
||||
assert.strictEqual(countProxy.get(), 0);
|
||||
},
|
||||
"flatMap update from source": assert => {
|
||||
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
|
||||
const updates: (number | undefined)[] = [];
|
||||
a.flatMap(a => a!.count).subscribe(count => {
|
||||
updates.push(count);
|
||||
});
|
||||
const count = new ObservableValue<number>(0);
|
||||
a.set({count});
|
||||
assert.deepEqual(updates, [0]);
|
||||
},
|
||||
"flatMap update from target": assert => {
|
||||
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
|
||||
const updates: (number | undefined)[] = [];
|
||||
a.flatMap(a => a!.count).subscribe(count => {
|
||||
updates.push(count);
|
||||
});
|
||||
const count = new ObservableValue<number>(0);
|
||||
a.set({count});
|
||||
count.set(5);
|
||||
assert.deepEqual(updates, [0, 5]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -46,3 +46,12 @@ Object.assign(BaseObservableMap.prototype, {
|
|||
return new JoinedMap([this].concat(otherMaps));
|
||||
}
|
||||
});
|
||||
|
||||
declare module "./map/BaseObservableMap" {
|
||||
interface BaseObservableMap<K, V> {
|
||||
sortValues(comparator: (a: V, b: V) => number): SortedMapList<V>;
|
||||
mapValues<M>(mapper: (V, emitSpontaneousUpdate: (params: any) => void) => M, updater: (mappedValue: M, params: any, value: V) => void): MappedMap<K, M>;
|
||||
filterValues(filter: (V, K) => boolean): FilteredMap<K, V>;
|
||||
join(...otherMaps: BaseObservableMap<K, V>[]): JoinedMap<K, V>;
|
||||
}
|
||||
}
|
|
@ -80,15 +80,15 @@ export class ObservableMap<K, V> extends BaseObservableMap<K, V> {
|
|||
return this._values.size;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<[K, V]> {
|
||||
[Symbol.iterator](): IterableIterator<[K, V]> {
|
||||
return this._values.entries();
|
||||
}
|
||||
|
||||
values(): Iterator<V> {
|
||||
values(): IterableIterator<V> {
|
||||
return this._values.values();
|
||||
}
|
||||
|
||||
keys(): Iterator<K> {
|
||||
keys(): IterableIterator<K> {
|
||||
return this._values.keys();
|
||||
}
|
||||
}
|
||||
|
|
53
src/observable/map/ObservableValueMap.ts
Normal file
53
src/observable/map/ObservableValueMap.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
Copyright 2022 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 {BaseObservableMap} from "./BaseObservableMap";
|
||||
import {BaseObservableValue} from "../value/BaseObservableValue";
|
||||
import {SubscriptionHandle} from "../BaseObservable";
|
||||
|
||||
export class ObservableValueMap<K, V> extends BaseObservableMap<K, V> {
|
||||
private subscription?: SubscriptionHandle;
|
||||
|
||||
constructor(private readonly key: K, private readonly observableValue: BaseObservableValue<V>) {
|
||||
super();
|
||||
}
|
||||
|
||||
onSubscribeFirst() {
|
||||
this.subscription = this.observableValue.subscribe(value => {
|
||||
this.emitUpdate(this.key, value, undefined);
|
||||
});
|
||||
super.onSubscribeFirst();
|
||||
}
|
||||
|
||||
onUnsubscribeLast() {
|
||||
this.subscription!();
|
||||
super.onUnsubscribeLast();
|
||||
}
|
||||
|
||||
*[Symbol.iterator](): Iterator<[K, V]> {
|
||||
yield [this.key, this.observableValue.get()];
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return 1;
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (key == this.key) {
|
||||
return this.observableValue.get();
|
||||
}
|
||||
}
|
||||
}
|
83
src/observable/value/BaseObservableValue.ts
Normal file
83
src/observable/value/BaseObservableValue.ts
Normal file
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
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 {AbortError} from "../../utils/error";
|
||||
import {BaseObservable} from "../BaseObservable";
|
||||
import type {SubscriptionHandle} from "../BaseObservable";
|
||||
import {FlatMapObservableValue} from "./FlatMapObservableValue";
|
||||
|
||||
// like an EventEmitter, but doesn't have an event type
|
||||
export abstract class BaseObservableValue<T> extends BaseObservable<(value: T) => void> {
|
||||
emit(argument: T) {
|
||||
for (const h of this._handlers) {
|
||||
h(argument);
|
||||
}
|
||||
}
|
||||
|
||||
abstract get(): T;
|
||||
|
||||
waitFor(predicate: (value: T) => boolean): IWaitHandle<T> {
|
||||
if (predicate(this.get())) {
|
||||
return new ResolvedWaitForHandle(Promise.resolve(this.get()));
|
||||
} else {
|
||||
return new WaitForHandle(this, predicate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface IWaitHandle<T> {
|
||||
promise: Promise<T>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
class WaitForHandle<T> implements IWaitHandle<T> {
|
||||
private _promise: Promise<T>
|
||||
private _reject: ((reason?: any) => void) | null;
|
||||
private _subscription: (() => void) | null;
|
||||
|
||||
constructor(observable: BaseObservableValue<T>, predicate: (value: T) => boolean) {
|
||||
this._promise = new Promise((resolve, reject) => {
|
||||
this._reject = reject;
|
||||
this._subscription = observable.subscribe(v => {
|
||||
if (predicate(v)) {
|
||||
this._reject = null;
|
||||
resolve(v);
|
||||
this.dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
get promise(): Promise<T> {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this._subscription) {
|
||||
this._subscription();
|
||||
this._subscription = null;
|
||||
}
|
||||
if (this._reject) {
|
||||
this._reject(new AbortError());
|
||||
this._reject = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResolvedWaitForHandle<T> implements IWaitHandle<T> {
|
||||
constructor(public promise: Promise<T>) {}
|
||||
dispose() {}
|
||||
}
|
45
src/observable/value/EventObservableValue.ts
Normal file
45
src/observable/value/EventObservableValue.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
Copyright 2022 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 {BaseObservableValue} from "./BaseObservableValue";
|
||||
import {EventEmitter} from "../../utils/EventEmitter";
|
||||
|
||||
export class EventObservableValue<T, V extends EventEmitter<T>> extends BaseObservableValue<V> {
|
||||
private eventSubscription: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly value: V,
|
||||
private readonly eventName: keyof T
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
onSubscribeFirst(): void {
|
||||
this.eventSubscription = this.value.disposableOn(this.eventName, () => {
|
||||
this.emit(this.value);
|
||||
});
|
||||
super.onSubscribeFirst();
|
||||
}
|
||||
|
||||
onUnsubscribeLast(): void {
|
||||
this.eventSubscription!();
|
||||
super.onUnsubscribeLast();
|
||||
}
|
||||
|
||||
get(): V {
|
||||
return this.value;
|
||||
}
|
||||
}
|
109
src/observable/value/FlatMapObservableValue.ts
Normal file
109
src/observable/value/FlatMapObservableValue.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
/*
|
||||
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 {BaseObservableValue} from "./BaseObservableValue";
|
||||
import {SubscriptionHandle} from "../BaseObservable";
|
||||
|
||||
export class FlatMapObservableValue<P, C> extends BaseObservableValue<C | undefined> {
|
||||
private sourceSubscription?: SubscriptionHandle;
|
||||
private targetSubscription?: SubscriptionHandle;
|
||||
|
||||
constructor(
|
||||
private readonly source: BaseObservableValue<P>,
|
||||
private readonly mapper: (value: P) => (BaseObservableValue<C> | undefined)
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
onUnsubscribeLast() {
|
||||
super.onUnsubscribeLast();
|
||||
this.sourceSubscription = this.sourceSubscription!();
|
||||
if (this.targetSubscription) {
|
||||
this.targetSubscription = this.targetSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
onSubscribeFirst() {
|
||||
super.onSubscribeFirst();
|
||||
this.sourceSubscription = this.source.subscribe(() => {
|
||||
this.updateTargetSubscription();
|
||||
this.emit(this.get());
|
||||
});
|
||||
this.updateTargetSubscription();
|
||||
}
|
||||
|
||||
private updateTargetSubscription() {
|
||||
const sourceValue = this.source.get();
|
||||
if (sourceValue) {
|
||||
const target = this.mapper(sourceValue);
|
||||
if (target) {
|
||||
if (!this.targetSubscription) {
|
||||
this.targetSubscription = target.subscribe(() => this.emit(this.get()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// if no sourceValue or target
|
||||
if (this.targetSubscription) {
|
||||
this.targetSubscription = this.targetSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
get(): C | undefined {
|
||||
const sourceValue = this.source.get();
|
||||
if (!sourceValue) {
|
||||
return undefined;
|
||||
}
|
||||
const mapped = this.mapper(sourceValue);
|
||||
return mapped?.get();
|
||||
}
|
||||
}
|
||||
|
||||
import {ObservableValue} from "./ObservableValue";
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"flatMap.get": assert => {
|
||||
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
|
||||
const countProxy = new FlatMapObservableValue(a, a => a!.count);
|
||||
assert.strictEqual(countProxy.get(), undefined);
|
||||
const count = new ObservableValue<number>(0);
|
||||
a.set({count});
|
||||
assert.strictEqual(countProxy.get(), 0);
|
||||
},
|
||||
"flatMap update from source": assert => {
|
||||
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
|
||||
const updates: (number | undefined)[] = [];
|
||||
new FlatMapObservableValue(a, a => a!.count).subscribe(count => {
|
||||
updates.push(count);
|
||||
});
|
||||
const count = new ObservableValue<number>(0);
|
||||
a.set({count});
|
||||
assert.deepEqual(updates, [0]);
|
||||
},
|
||||
"flatMap update from target": assert => {
|
||||
const a = new ObservableValue<undefined | {count: ObservableValue<number>}>(undefined);
|
||||
const updates: (number | undefined)[] = [];
|
||||
new FlatMapObservableValue(a, a => a!.count).subscribe(count => {
|
||||
updates.push(count);
|
||||
});
|
||||
const count = new ObservableValue<number>(0);
|
||||
a.set({count});
|
||||
count.set(5);
|
||||
assert.deepEqual(updates, [0, 5]);
|
||||
}
|
||||
}
|
||||
}
|
82
src/observable/value/ObservableValue.ts
Normal file
82
src/observable/value/ObservableValue.ts
Normal file
|
@ -0,0 +1,82 @@
|
|||
/*
|
||||
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 {AbortError} from "../../utils/error";
|
||||
import {BaseObservableValue} from "./BaseObservableValue";
|
||||
|
||||
export class ObservableValue<T> extends BaseObservableValue<T> {
|
||||
private _value: T;
|
||||
|
||||
constructor(initialValue: T) {
|
||||
super();
|
||||
this._value = initialValue;
|
||||
}
|
||||
|
||||
get(): T {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
set(value: T): void {
|
||||
if (value !== this._value) {
|
||||
this._value = value;
|
||||
this.emit(this._value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
return {
|
||||
"set emits an update": assert => {
|
||||
const a = new ObservableValue<number>(0);
|
||||
let fired = false;
|
||||
const subscription = a.subscribe(v => {
|
||||
fired = true;
|
||||
assert.strictEqual(v, 5);
|
||||
});
|
||||
a.set(5);
|
||||
assert(fired);
|
||||
subscription();
|
||||
},
|
||||
"set doesn't emit if value hasn't changed": assert => {
|
||||
const a = new ObservableValue(5);
|
||||
let fired = false;
|
||||
const subscription = a.subscribe(() => {
|
||||
fired = true;
|
||||
});
|
||||
a.set(5);
|
||||
a.set(5);
|
||||
assert(!fired);
|
||||
subscription();
|
||||
},
|
||||
"waitFor promise resolves on matching update": async assert => {
|
||||
const a = new ObservableValue(5);
|
||||
const handle = a.waitFor(v => v === 6);
|
||||
Promise.resolve().then(() => {
|
||||
a.set(6);
|
||||
});
|
||||
await handle.promise;
|
||||
assert.strictEqual(a.get(), 6);
|
||||
},
|
||||
"waitFor promise rejects when disposed": async assert => {
|
||||
const a = new ObservableValue<number>(0);
|
||||
const handle = a.waitFor(() => false);
|
||||
Promise.resolve().then(() => {
|
||||
handle.dispose();
|
||||
});
|
||||
await assert.rejects(handle.promise, AbortError);
|
||||
}
|
||||
}
|
||||
}
|
89
src/observable/value/PickMapObservableValue.ts
Normal file
89
src/observable/value/PickMapObservableValue.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
/*
|
||||
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 {BaseObservableValue} from "./BaseObservableValue";
|
||||
import {BaseObservableMap, IMapObserver} from "../map/BaseObservableMap";
|
||||
import {SubscriptionHandle} from "../BaseObservable";
|
||||
|
||||
function pickLowestKey<K>(currentKey: K, newKey: K): boolean {
|
||||
return newKey < currentKey;
|
||||
}
|
||||
|
||||
export class PickMapObservableValue<K, V> extends BaseObservableValue<V | undefined> implements IMapObserver<K, V>{
|
||||
|
||||
private key?: K;
|
||||
private mapSubscription?: SubscriptionHandle;
|
||||
|
||||
constructor(
|
||||
private readonly map: BaseObservableMap<K, V>,
|
||||
private readonly pickKey: (currentKey: K, newKey: K) => boolean = pickLowestKey
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private updateKey(newKey: K): boolean {
|
||||
if (this.key === undefined || this.pickKey(this.key, newKey)) {
|
||||
this.key = newKey;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
onReset(): void {
|
||||
this.key = undefined;
|
||||
this.emit(this.get());
|
||||
}
|
||||
|
||||
onAdd(key: K, value:V): void {
|
||||
if (this.updateKey(key)) {
|
||||
this.emit(this.get());
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate(key: K, value: V, params: any): void {
|
||||
this.emit(this.get());
|
||||
}
|
||||
|
||||
onRemove(key: K, value: V): void {
|
||||
if (key === this.key) {
|
||||
this.key = undefined;
|
||||
// try to see if there is another key that fullfills pickKey
|
||||
for (const [key] of this.map) {
|
||||
this.updateKey(key);
|
||||
}
|
||||
this.emit(this.get());
|
||||
}
|
||||
}
|
||||
|
||||
onSubscribeFirst(): void {
|
||||
this.mapSubscription = this.map.subscribe(this);
|
||||
for (const [key] of this.map) {
|
||||
this.updateKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
onUnsubscribeLast(): void {
|
||||
this.mapSubscription!();
|
||||
this.key = undefined;
|
||||
}
|
||||
|
||||
get(): V | undefined {
|
||||
if (this.key !== undefined) {
|
||||
return this.map.get(this.key);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
31
src/observable/value/RetainedObservableValue.ts
Normal file
31
src/observable/value/RetainedObservableValue.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
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 {ObservableValue} from "./ObservableValue";
|
||||
|
||||
export class RetainedObservableValue<T> extends ObservableValue<T> {
|
||||
private _freeCallback: () => void;
|
||||
|
||||
constructor(initialValue: T, freeCallback: () => void) {
|
||||
super(initialValue);
|
||||
this._freeCallback = freeCallback;
|
||||
}
|
||||
|
||||
onUnsubscribeLast() {
|
||||
super.onUnsubscribeLast();
|
||||
this._freeCallback();
|
||||
}
|
||||
}
|
79
src/platform/types/MediaDevices.ts
Normal file
79
src/platform/types/MediaDevices.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
Copyright 2022 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 interface Event {}
|
||||
|
||||
export interface MediaDevices {
|
||||
// filter out audiooutput
|
||||
enumerate(): Promise<MediaDeviceInfo[]>;
|
||||
// to assign to a video element, we downcast to WrappedTrack and use the stream property.
|
||||
getMediaTracks(audio: true | MediaDeviceInfo, video: boolean | MediaDeviceInfo): Promise<Stream>;
|
||||
getScreenShareTrack(): Promise<Stream | undefined>;
|
||||
createVolumeMeasurer(stream: Stream, callback: () => void): VolumeMeasurer;
|
||||
}
|
||||
|
||||
// Typescript definitions derived from https://github.com/microsoft/TypeScript/blob/main/lib/lib.dom.d.ts
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
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
|
||||
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
|
||||
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
|
||||
MERCHANTABLITY OR NON-INFRINGEMENT.
|
||||
See the Apache Version 2.0 License for specific language governing permissions
|
||||
and limitations under the License.
|
||||
***************************************************************************** */
|
||||
|
||||
export interface StreamTrackEvent extends Event {
|
||||
readonly track: Track;
|
||||
}
|
||||
|
||||
export interface StreamEventMap {
|
||||
"addtrack": StreamTrackEvent;
|
||||
"removetrack": StreamTrackEvent;
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
getTracks(): ReadonlyArray<Track>;
|
||||
getAudioTracks(): ReadonlyArray<Track>;
|
||||
getVideoTracks(): ReadonlyArray<Track>;
|
||||
readonly id: string;
|
||||
clone(): Stream;
|
||||
addEventListener<K extends keyof StreamEventMap>(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
removeEventListener<K extends keyof StreamEventMap>(type: K, listener: (this: Stream, ev: StreamEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
||||
}
|
||||
|
||||
export enum TrackKind {
|
||||
Video = "video",
|
||||
Audio = "audio"
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
readonly kind: TrackKind;
|
||||
readonly label: string;
|
||||
readonly id: string;
|
||||
enabled: boolean;
|
||||
// getSettings(): MediaTrackSettings;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface VolumeMeasurer {
|
||||
get isSpeaking(): boolean;
|
||||
setSpeakingThreshold(threshold: number): void;
|
||||
stop();
|
||||
}
|
168
src/platform/types/WebRTC.ts
Normal file
168
src/platform/types/WebRTC.ts
Normal file
|
@ -0,0 +1,168 @@
|
|||
/*
|
||||
Copyright 2022 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 {Track, Stream, Event} from "./MediaDevices";
|
||||
import {SDPStreamMetadataPurpose} from "../../matrix/calls/callEventTypes";
|
||||
|
||||
export interface WebRTC {
|
||||
createPeerConnection(forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize: number): PeerConnection;
|
||||
prepareSenderForPurpose(peerConnection: PeerConnection, sender: Sender, purpose: SDPStreamMetadataPurpose): void;
|
||||
}
|
||||
|
||||
// Typescript definitions derived from https://github.com/microsoft/TypeScript/blob/main/lib/lib.dom.d.ts
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
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
|
||||
THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED
|
||||
WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE,
|
||||
MERCHANTABLITY OR NON-INFRINGEMENT.
|
||||
See the Apache Version 2.0 License for specific language governing permissions
|
||||
and limitations under the License.
|
||||
***************************************************************************** */
|
||||
|
||||
export interface DataChannelEventMap {
|
||||
"bufferedamountlow": Event;
|
||||
"close": Event;
|
||||
"error": Event;
|
||||
"message": MessageEvent;
|
||||
"open": Event;
|
||||
}
|
||||
|
||||
export interface DataChannel {
|
||||
binaryType: BinaryType;
|
||||
readonly id: number | null;
|
||||
readonly label: string;
|
||||
readonly negotiated: boolean;
|
||||
readonly readyState: DataChannelState;
|
||||
close(): void;
|
||||
send(data: string): void;
|
||||
send(data: Blob): void;
|
||||
send(data: ArrayBuffer): void;
|
||||
send(data: ArrayBufferView): void;
|
||||
addEventListener<K extends keyof DataChannelEventMap>(type: K, listener: (this: DataChannel, ev: DataChannelEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
removeEventListener<K extends keyof DataChannelEventMap>(type: K, listener: (this: DataChannel, ev: DataChannelEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
||||
}
|
||||
|
||||
export interface DataChannelInit {
|
||||
id?: number;
|
||||
maxPacketLifeTime?: number;
|
||||
maxRetransmits?: number;
|
||||
negotiated?: boolean;
|
||||
ordered?: boolean;
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
export interface DataChannelEvent extends Event {
|
||||
readonly channel: DataChannel;
|
||||
}
|
||||
|
||||
export interface PeerConnectionIceEvent extends Event {
|
||||
readonly candidate: RTCIceCandidate | null;
|
||||
}
|
||||
|
||||
export interface TrackEvent extends Event {
|
||||
readonly receiver: Receiver;
|
||||
readonly streams: ReadonlyArray<Stream>;
|
||||
readonly track: Track;
|
||||
readonly transceiver: Transceiver;
|
||||
}
|
||||
|
||||
export interface PeerConnectionEventMap {
|
||||
"connectionstatechange": Event;
|
||||
"datachannel": DataChannelEvent;
|
||||
"icecandidate": PeerConnectionIceEvent;
|
||||
"iceconnectionstatechange": Event;
|
||||
"icegatheringstatechange": Event;
|
||||
"negotiationneeded": Event;
|
||||
"signalingstatechange": Event;
|
||||
"track": TrackEvent;
|
||||
}
|
||||
|
||||
export type DataChannelState = "closed" | "closing" | "connecting" | "open";
|
||||
export type IceConnectionState = "checking" | "closed" | "completed" | "connected" | "disconnected" | "failed" | "new";
|
||||
export type PeerConnectionState = "closed" | "connected" | "connecting" | "disconnected" | "failed" | "new";
|
||||
export type SignalingState = "closed" | "have-local-offer" | "have-local-pranswer" | "have-remote-offer" | "have-remote-pranswer" | "stable";
|
||||
export type IceGatheringState = "complete" | "gathering" | "new";
|
||||
export type SdpType = "answer" | "offer" | "pranswer" | "rollback";
|
||||
export type TransceiverDirection = "inactive" | "recvonly" | "sendonly" | "sendrecv" | "stopped";
|
||||
export interface SessionDescription {
|
||||
readonly sdp: string;
|
||||
readonly type: SdpType;
|
||||
}
|
||||
|
||||
export interface AnswerOptions {}
|
||||
|
||||
export interface OfferOptions {
|
||||
iceRestart?: boolean;
|
||||
offerToReceiveAudio?: boolean;
|
||||
offerToReceiveVideo?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionDescriptionInit {
|
||||
sdp?: string;
|
||||
type: SdpType;
|
||||
}
|
||||
|
||||
export interface LocalSessionDescriptionInit {
|
||||
sdp?: string;
|
||||
type?: SdpType;
|
||||
}
|
||||
|
||||
/** A WebRTC connection between the local computer and a remote peer. It provides methods to connect to a remote peer, maintain and monitor the connection, and close the connection once it's no longer needed. */
|
||||
export interface PeerConnection {
|
||||
readonly connectionState: PeerConnectionState;
|
||||
readonly iceConnectionState: IceConnectionState;
|
||||
readonly iceGatheringState: IceGatheringState;
|
||||
readonly localDescription: SessionDescription | null;
|
||||
readonly remoteDescription: SessionDescription | null;
|
||||
readonly signalingState: SignalingState;
|
||||
addIceCandidate(candidate?: RTCIceCandidateInit): Promise<void>;
|
||||
addTrack(track: Track, ...streams: Stream[]): Sender;
|
||||
close(): void;
|
||||
createAnswer(options?: AnswerOptions): Promise<SessionDescriptionInit>;
|
||||
createDataChannel(label: string, dataChannelDict?: DataChannelInit): DataChannel;
|
||||
createOffer(options?: OfferOptions): Promise<SessionDescriptionInit>;
|
||||
getReceivers(): Receiver[];
|
||||
getSenders(): Sender[];
|
||||
getTransceivers(): Transceiver[];
|
||||
removeTrack(sender: Sender): void;
|
||||
restartIce(): void;
|
||||
setLocalDescription(description?: LocalSessionDescriptionInit): Promise<void>;
|
||||
setRemoteDescription(description: SessionDescriptionInit): Promise<void>;
|
||||
addEventListener<K extends keyof PeerConnectionEventMap>(type: K, listener: (this: PeerConnection, ev: PeerConnectionEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
|
||||
removeEventListener<K extends keyof PeerConnectionEventMap>(type: K, listener: (this: PeerConnection, ev: PeerConnectionEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
|
||||
}
|
||||
|
||||
export interface Receiver {
|
||||
readonly track: Track;
|
||||
}
|
||||
|
||||
export interface Sender {
|
||||
readonly track: Track | null;
|
||||
replaceTrack(withTrack: Track | null): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Transceiver {
|
||||
readonly currentDirection: TransceiverDirection | null;
|
||||
direction: TransceiverDirection;
|
||||
readonly mid: string | null;
|
||||
readonly receiver: Receiver;
|
||||
readonly sender: Sender;
|
||||
stop(): void;
|
||||
}
|
|
@ -43,3 +43,11 @@ export type File = {
|
|||
readonly name: string;
|
||||
readonly blob: IBlobHandle;
|
||||
}
|
||||
|
||||
export interface Timeout {
|
||||
elapsed(): Promise<void>;
|
||||
abort(): void;
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export type TimeoutCreator = (timeout: number) => Timeout;
|
||||
|
|
|
@ -38,6 +38,8 @@ import {downloadInIframe} from "./dom/download.js";
|
|||
import {Disposables} from "../../utils/Disposables";
|
||||
import {parseHTML} from "./parsehtml.js";
|
||||
import {handleAvatarError} from "./ui/avatar";
|
||||
import {MediaDevicesWrapper} from "./dom/MediaDevices";
|
||||
import {DOMWebRTC} from "./dom/WebRTC";
|
||||
|
||||
function addScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
|
@ -164,6 +166,8 @@ export class Platform {
|
|||
this._disposables = new Disposables();
|
||||
this._olmPromise = undefined;
|
||||
this._workerPromise = undefined;
|
||||
this.mediaDevices = new MediaDevicesWrapper(navigator.mediaDevices);
|
||||
this.webRTC = new DOMWebRTC();
|
||||
}
|
||||
|
||||
async init() {
|
||||
|
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue} from "../../../observable/ObservableValue";
|
||||
import {BaseObservableValue} from "../../../observable/value/BaseObservableValue";
|
||||
|
||||
export class History extends BaseObservableValue {
|
||||
handleEvent(event) {
|
||||
|
|
181
src/platform/web/dom/MediaDevices.ts
Normal file
181
src/platform/web/dom/MediaDevices.ts
Normal file
|
@ -0,0 +1,181 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
Copyright 2022 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 {MediaDevices as IMediaDevices, Stream, Track, TrackKind, VolumeMeasurer} from "../../types/MediaDevices";
|
||||
|
||||
const POLLING_INTERVAL = 200; // ms
|
||||
export const SPEAKING_THRESHOLD = -60; // dB
|
||||
const SPEAKING_SAMPLE_COUNT = 8; // samples
|
||||
|
||||
export class MediaDevicesWrapper implements IMediaDevices {
|
||||
constructor(private readonly mediaDevices: MediaDevices) {}
|
||||
|
||||
enumerate(): Promise<MediaDeviceInfo[]> {
|
||||
return this.mediaDevices.enumerateDevices();
|
||||
}
|
||||
|
||||
async getMediaTracks(audio: true | MediaDeviceInfo, video: boolean | MediaDeviceInfo): Promise<Stream> {
|
||||
const stream = await this.mediaDevices.getUserMedia(this.getUserMediaContraints(audio, video));
|
||||
return stream as Stream;
|
||||
}
|
||||
|
||||
async getScreenShareTrack(): Promise<Stream | undefined> {
|
||||
const stream = await this.mediaDevices.getDisplayMedia(this.getScreenshareContraints());
|
||||
return stream as Stream;
|
||||
}
|
||||
|
||||
private getUserMediaContraints(audio: boolean | MediaDeviceInfo, video: boolean | MediaDeviceInfo): MediaStreamConstraints {
|
||||
const isWebkit = !!navigator["webkitGetUserMedia"];
|
||||
|
||||
return {
|
||||
audio: audio
|
||||
? {
|
||||
deviceId: typeof audio !== "boolean" ? { ideal: audio.deviceId } : undefined,
|
||||
}
|
||||
: false,
|
||||
video: video
|
||||
? {
|
||||
deviceId: typeof video !== "boolean" ? { ideal: video.deviceId } : undefined,
|
||||
/* We want 640x360. Chrome will give it only if we ask exactly,
|
||||
FF refuses entirely if we ask exactly, so have to ask for ideal
|
||||
instead
|
||||
XXX: Is this still true?
|
||||
*/
|
||||
width: isWebkit ? { exact: 640 } : { ideal: 640 },
|
||||
height: isWebkit ? { exact: 360 } : { ideal: 360 },
|
||||
}
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
private getScreenshareContraints(): DisplayMediaStreamConstraints {
|
||||
return {
|
||||
audio: false,
|
||||
video: true,
|
||||
};
|
||||
}
|
||||
|
||||
createVolumeMeasurer(stream: Stream, callback: () => void): VolumeMeasurer {
|
||||
return new WebAudioVolumeMeasurer(stream as MediaStream, callback);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebAudioVolumeMeasurer implements VolumeMeasurer {
|
||||
private measuringVolumeActivity = false;
|
||||
private audioContext?: AudioContext;
|
||||
private analyser: AnalyserNode;
|
||||
private frequencyBinCount: Float32Array;
|
||||
private speakingThreshold = SPEAKING_THRESHOLD;
|
||||
private speaking = false;
|
||||
private volumeLooperTimeout: number;
|
||||
private speakingVolumeSamples: number[];
|
||||
private callback: () => void;
|
||||
private stream: MediaStream;
|
||||
|
||||
constructor(stream: MediaStream, callback: () => void) {
|
||||
this.stream = stream;
|
||||
this.callback = callback;
|
||||
this.speakingVolumeSamples = new Array(SPEAKING_SAMPLE_COUNT).fill(-Infinity);
|
||||
this.initVolumeMeasuring();
|
||||
this.measureVolumeActivity(true);
|
||||
}
|
||||
|
||||
get isSpeaking(): boolean { return this.speaking; }
|
||||
/**
|
||||
* Starts emitting volume_changed events where the emitter value is in decibels
|
||||
* @param enabled emit volume changes
|
||||
*/
|
||||
private measureVolumeActivity(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
if (!this.audioContext || !this.analyser || !this.frequencyBinCount) return;
|
||||
|
||||
this.measuringVolumeActivity = true;
|
||||
this.volumeLooper();
|
||||
} else {
|
||||
this.measuringVolumeActivity = false;
|
||||
this.speakingVolumeSamples.fill(-Infinity);
|
||||
this.callback();
|
||||
// this.emit(CallFeedEvent.VolumeChanged, -Infinity);
|
||||
}
|
||||
}
|
||||
|
||||
private initVolumeMeasuring(): void {
|
||||
const AudioContext = window.AudioContext || window["webkitAudioContext"] as undefined | typeof window.AudioContext;
|
||||
if (!AudioContext) return;
|
||||
|
||||
this.audioContext = new AudioContext();
|
||||
|
||||
this.analyser = this.audioContext.createAnalyser();
|
||||
this.analyser.fftSize = 512;
|
||||
this.analyser.smoothingTimeConstant = 0.1;
|
||||
|
||||
const mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.stream);
|
||||
mediaStreamAudioSourceNode.connect(this.analyser);
|
||||
|
||||
this.frequencyBinCount = new Float32Array(this.analyser.frequencyBinCount);
|
||||
}
|
||||
|
||||
public setSpeakingThreshold(threshold: number) {
|
||||
this.speakingThreshold = threshold;
|
||||
}
|
||||
|
||||
private volumeLooper = () => {
|
||||
if (!this.analyser) return;
|
||||
|
||||
if (!this.measuringVolumeActivity) return;
|
||||
|
||||
this.analyser.getFloatFrequencyData(this.frequencyBinCount);
|
||||
|
||||
let maxVolume = -Infinity;
|
||||
for (let i = 0; i < this.frequencyBinCount.length; i++) {
|
||||
if (this.frequencyBinCount[i] > maxVolume) {
|
||||
maxVolume = this.frequencyBinCount[i];
|
||||
}
|
||||
}
|
||||
|
||||
this.speakingVolumeSamples.shift();
|
||||
this.speakingVolumeSamples.push(maxVolume);
|
||||
|
||||
this.callback();
|
||||
// this.emit(CallFeedEvent.VolumeChanged, maxVolume);
|
||||
|
||||
let newSpeaking = false;
|
||||
|
||||
for (let i = 0; i < this.speakingVolumeSamples.length; i++) {
|
||||
const volume = this.speakingVolumeSamples[i];
|
||||
|
||||
if (volume > this.speakingThreshold) {
|
||||
newSpeaking = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.speaking !== newSpeaking) {
|
||||
this.speaking = newSpeaking;
|
||||
this.callback();
|
||||
// this.emit(CallFeedEvent.Speaking, this.speaking);
|
||||
}
|
||||
|
||||
this.volumeLooperTimeout = setTimeout(this.volumeLooper, POLLING_INTERVAL) as unknown as number;
|
||||
};
|
||||
|
||||
public stop(): void {
|
||||
clearTimeout(this.volumeLooperTimeout);
|
||||
this.analyser.disconnect();
|
||||
this.audioContext?.close();
|
||||
}
|
||||
}
|
|
@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue} from "../../../observable/ObservableValue";
|
||||
import {BaseObservableValue} from "../../../observable/value/BaseObservableValue";
|
||||
|
||||
export class OnlineStatus extends BaseObservableValue {
|
||||
constructor() {
|
||||
|
|
64
src/platform/web/dom/WebRTC.ts
Normal file
64
src/platform/web/dom/WebRTC.ts
Normal file
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
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 {Stream, Track, TrackKind} from "../../types/MediaDevices";
|
||||
import {WebRTC, Sender, PeerConnection} from "../../types/WebRTC";
|
||||
import {SDPStreamMetadataPurpose} from "../../../matrix/calls/callEventTypes";
|
||||
|
||||
const POLLING_INTERVAL = 200; // ms
|
||||
export const SPEAKING_THRESHOLD = -60; // dB
|
||||
const SPEAKING_SAMPLE_COUNT = 8; // samples
|
||||
|
||||
export class DOMWebRTC implements WebRTC {
|
||||
createPeerConnection(forceTURN: boolean, turnServers: RTCIceServer[], iceCandidatePoolSize): PeerConnection {
|
||||
return new RTCPeerConnection({
|
||||
iceTransportPolicy: forceTURN ? 'relay' : undefined,
|
||||
iceServers: turnServers,
|
||||
iceCandidatePoolSize: iceCandidatePoolSize,
|
||||
}) as PeerConnection;
|
||||
}
|
||||
|
||||
prepareSenderForPurpose(peerConnection: PeerConnection, sender: Sender, purpose: SDPStreamMetadataPurpose): void {
|
||||
if (purpose === SDPStreamMetadataPurpose.Screenshare) {
|
||||
this.getRidOfRTXCodecs(peerConnection as RTCPeerConnection, sender as RTCRtpSender);
|
||||
}
|
||||
}
|
||||
|
||||
private getRidOfRTXCodecs(peerConnection: RTCPeerConnection, sender: RTCRtpSender): void {
|
||||
// RTCRtpReceiver.getCapabilities and RTCRtpSender.getCapabilities don't seem to be supported on FF
|
||||
if (!RTCRtpReceiver.getCapabilities || !RTCRtpSender.getCapabilities) return;
|
||||
|
||||
const recvCodecs = RTCRtpReceiver.getCapabilities("video")?.codecs ?? [];
|
||||
const sendCodecs = RTCRtpSender.getCapabilities("video")?.codecs ?? [];
|
||||
const codecs = [...sendCodecs, ...recvCodecs];
|
||||
|
||||
for (const codec of codecs) {
|
||||
if (codec.mimeType === "video/rtx") {
|
||||
const rtxCodecIndex = codecs.indexOf(codec);
|
||||
codecs.splice(rtxCodecIndex, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const transceiver = peerConnection.getTransceivers().find(t => t.sender === sender);
|
||||
if (transceiver && (
|
||||
transceiver.sender.track?.kind === "video" ||
|
||||
transceiver.receiver.track?.kind === "video"
|
||||
)
|
||||
) {
|
||||
transceiver.setCodecPreferences(codecs);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1155,3 +1155,55 @@ button.RoomDetailsView_row::after {
|
|||
background-position: center;
|
||||
background-size: 36px;
|
||||
}
|
||||
|
||||
.CallView {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.CallView ul {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.StreamView {
|
||||
width: 360px;
|
||||
min-height: 200px;
|
||||
border: 2px var(--accent-color) solid;
|
||||
display: grid;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
.StreamView > * {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.StreamView video {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.StreamView_avatar {
|
||||
align-self: center;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.StreamView_muteStatus {
|
||||
align-self: end;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.StreamView_muteStatus.microphoneMuted::before {
|
||||
content: "mic muted";
|
||||
}
|
||||
|
||||
.StreamView_muteStatus.cameraMuted::before {
|
||||
content: "cam muted";
|
||||
}
|
||||
|
||||
|
|
|
@ -181,7 +181,7 @@ export class TemplateBuilder<T extends IObservableValue> {
|
|||
this._templateView._addEventListener(node, name, fn, useCapture);
|
||||
}
|
||||
|
||||
_addAttributeBinding(node: Element, name: string, fn: (value: T) => boolean | string): void {
|
||||
_addAttributeBinding(node: Element, name: string, fn: AttributeBinding<T>): void {
|
||||
let prevValue: string | boolean | undefined = undefined;
|
||||
const binding = () => {
|
||||
const newValue = fn(this._value);
|
||||
|
@ -337,7 +337,7 @@ export class TemplateBuilder<T extends IObservableValue> {
|
|||
// Special case of mapView for a TemplateView.
|
||||
// Always creates a TemplateView, if this is optional depending
|
||||
// on mappedValue, use `if` or `mapView`
|
||||
map<R>(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder<T>, vm: T) => ViewNode): ViewNode {
|
||||
map<R>(mapFn: (value: T) => R, renderFn: (mapped: R, t: Builder<T>, vm: T) => ViewNode | undefined): ViewNode {
|
||||
return this.mapView(mapFn, mappedValue => {
|
||||
return new InlineTemplateView(this._value, (t, vm) => {
|
||||
const rootNode = renderFn(mappedValue, t, vm);
|
||||
|
@ -371,17 +371,17 @@ export class TemplateBuilder<T extends IObservableValue> {
|
|||
event handlers, ...
|
||||
You should not call the TemplateBuilder (e.g. `t.xxx()`) at all from the side effect,
|
||||
instead use tags from html.ts to help you construct any DOM you need. */
|
||||
mapSideEffect<R>(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined) => void) {
|
||||
mapSideEffect<R>(mapFn: (value: T) => R, sideEffect: (newV: R, oldV: R | undefined, value: T) => void) {
|
||||
let prevValue = mapFn(this._value);
|
||||
const binding = () => {
|
||||
const newValue = mapFn(this._value);
|
||||
if (prevValue !== newValue) {
|
||||
sideEffect(newValue, prevValue);
|
||||
sideEffect(newValue, prevValue, this._value);
|
||||
prevValue = newValue;
|
||||
}
|
||||
};
|
||||
this._addBinding(binding);
|
||||
sideEffect(prevValue, undefined);
|
||||
sideEffect(prevValue, undefined, this._value);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
63
src/platform/web/ui/session/room/CallView.ts
Normal file
63
src/platform/web/ui/session/room/CallView.ts
Normal file
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
Copyright 2022 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, Builder} from "../../general/TemplateView";
|
||||
import {AvatarView} from "../../AvatarView";
|
||||
import {ListView} from "../../general/ListView";
|
||||
import {Stream} from "../../../../types/MediaDevices";
|
||||
import type {CallViewModel, CallMemberViewModel, IStreamViewModel} from "../../../../../domain/session/room/CallViewModel";
|
||||
|
||||
export class CallView extends TemplateView<CallViewModel> {
|
||||
render(t: Builder<CallViewModel>, vm: CallViewModel): Element {
|
||||
return t.div({class: "CallView"}, [
|
||||
t.p(vm => `Call ${vm.name} (${vm.id})`),
|
||||
t.view(new ListView({list: vm.memberViewModels}, vm => new StreamView(vm))),
|
||||
t.div({class: "buttons"}, [
|
||||
t.button({onClick: () => vm.leave()}, "Leave"),
|
||||
t.button({onClick: () => vm.toggleVideo()}, "Toggle video"),
|
||||
])
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
class StreamView extends TemplateView<IStreamViewModel> {
|
||||
render(t: Builder<IStreamViewModel>, vm: IStreamViewModel): Element {
|
||||
const video = t.video({
|
||||
autoplay: true,
|
||||
className: {
|
||||
hidden: vm => vm.isCameraMuted
|
||||
}
|
||||
}) as HTMLVideoElement;
|
||||
t.mapSideEffect(vm => vm.stream, stream => {
|
||||
video.srcObject = stream as MediaStream;
|
||||
});
|
||||
return t.div({className: "StreamView"}, [
|
||||
video,
|
||||
t.div({className: {
|
||||
StreamView_avatar: true,
|
||||
hidden: vm => !vm.isCameraMuted
|
||||
}}, t.view(new AvatarView(vm, 64), {parentProvidesUpdates: true})),
|
||||
t.div({
|
||||
className: {
|
||||
StreamView_muteStatus: true,
|
||||
hidden: vm => !vm.isCameraMuted && !vm.isMicrophoneMuted,
|
||||
microphoneMuted: vm => vm.isMicrophoneMuted && !vm.isCameraMuted,
|
||||
cameraMuted: vm => vm.isCameraMuted,
|
||||
}
|
||||
})
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import {TimelineLoadingView} from "./TimelineLoadingView.js";
|
|||
import {MessageComposer} from "./MessageComposer.js";
|
||||
import {RoomArchivedView} from "./RoomArchivedView.js";
|
||||
import {AvatarView} from "../../AvatarView.js";
|
||||
import {CallView} from "./CallView";
|
||||
|
||||
export class RoomView extends TemplateView {
|
||||
constructor(vm, viewClassForTile) {
|
||||
|
@ -53,6 +54,7 @@ export class RoomView extends TemplateView {
|
|||
]),
|
||||
t.div({className: "RoomView_body"}, [
|
||||
t.div({className: "RoomView_error"}, vm => vm.error),
|
||||
t.mapView(vm => vm.callViewModel, callViewModel => callViewModel ? new CallView(callViewModel) : null),
|
||||
t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
|
||||
return timelineViewModel ?
|
||||
new TimelineView(timelineViewModel, this._viewClassForTile) :
|
||||
|
@ -70,6 +72,7 @@ export class RoomView extends TemplateView {
|
|||
const vm = this.value;
|
||||
const options = [];
|
||||
options.push(Menu.option(vm.i18n`Room details`, () => vm.openDetailsPanel()))
|
||||
options.push(Menu.option(vm.i18n`Start call`, () => vm.startCall()))
|
||||
if (vm.canLeave) {
|
||||
options.push(Menu.option(vm.i18n`Leave room`, () => this._confirmToLeaveRoom()).setDestructive());
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
|||
import {RedactedView} from "./timeline/RedactedView.js";
|
||||
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
||||
import {GapView} from "./timeline/GapView.js";
|
||||
import {CallTileView} from "./timeline/CallTileView";
|
||||
import type {TileViewConstructor, ViewClassForEntryFn} from "./TimelineView";
|
||||
|
||||
export function viewClassForTile(vm: SimpleTile): TileViewConstructor {
|
||||
|
@ -47,6 +48,8 @@ export function viewClassForTile(vm: SimpleTile): TileViewConstructor {
|
|||
return MissingAttachmentView;
|
||||
case "redacted":
|
||||
return RedactedView;
|
||||
case "call":
|
||||
return CallTileView;
|
||||
default:
|
||||
throw new Error(`Tiles of shape "${vm.shape}" are not supported, check the tileClassForEntry function in the view model`);
|
||||
}
|
||||
|
|
40
src/platform/web/ui/session/room/timeline/CallTileView.ts
Normal file
40
src/platform/web/ui/session/room/timeline/CallTileView.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
Copyright 2022 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 type {CallTile} from "../../../../../../domain/session/room/timeline/tiles/CallTile";
|
||||
|
||||
export class CallTileView extends TemplateView<CallTile> {
|
||||
render(t, vm) {
|
||||
return t.li(
|
||||
{className: "AnnouncementView"},
|
||||
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")
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
/* This is called by the parent ListView, which just has 1 listener for the whole list */
|
||||
onClick(evt) {
|
||||
if (evt.target.className === "CallTileView_join") {
|
||||
this.value.join();
|
||||
} else if (evt.target.className === "CallTileView_leave") {
|
||||
this.value.leave();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,7 +14,8 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseObservableValue, ObservableValue} from "../observable/ObservableValue";
|
||||
import {BaseObservableValue} from "../observable/value/BaseObservableValue";
|
||||
import {ObservableValue} from "../observable/value/ObservableValue";
|
||||
|
||||
export interface IAbortable {
|
||||
abort();
|
||||
|
|
|
@ -71,7 +71,7 @@ export class BaseLRUCache<T> {
|
|||
export class LRUCache<T, K> extends BaseLRUCache<T> {
|
||||
private _keyFn: (T) => K;
|
||||
|
||||
constructor(limit, keyFn: (T) => K) {
|
||||
constructor(limit: number, keyFn: (T) => K) {
|
||||
super(limit);
|
||||
this._keyFn = keyFn;
|
||||
}
|
||||
|
|
39
src/utils/recursivelyAssign.ts
Normal file
39
src/utils/recursivelyAssign.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2019 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.
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* This function is similar to Object.assign() but it assigns recursively and
|
||||
* allows you to ignore nullish values from the source
|
||||
*
|
||||
* @param {Object} target
|
||||
* @param {Object} source
|
||||
* @returns the target object
|
||||
*/
|
||||
export function recursivelyAssign(target: Object, source: Object, ignoreNullish = false): any {
|
||||
for (const [sourceKey, sourceValue] of Object.entries(source)) {
|
||||
if (target[sourceKey] instanceof Object && sourceValue) {
|
||||
recursivelyAssign(target[sourceKey], sourceValue);
|
||||
continue;
|
||||
}
|
||||
if ((sourceValue !== null && sourceValue !== undefined) || !ignoreNullish) {
|
||||
target[sourceKey] = sourceValue;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
|
@ -1485,10 +1485,10 @@ type-fest@^0.20.2:
|
|||
resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
|
||||
integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
|
||||
|
||||
typescript@^4.3.5:
|
||||
version "4.3.5"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4"
|
||||
integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA==
|
||||
typescript@^4.4:
|
||||
version "4.6.3"
|
||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.3.tgz#eefeafa6afdd31d725584c67a0eaba80f6fc6c6c"
|
||||
integrity sha512-yNIatDa5iaofVozS/uQJEl3JRWLKKGJKh6Yaiv0GLGSuhpFJe7P3SbHZ8/yjAHRQwKRoA6YZqlfjXWmVzoVSMw==
|
||||
|
||||
typeson-registry@^1.0.0-alpha.20:
|
||||
version "1.0.0-alpha.39"
|
||||
|
|
Reference in a new issue