forked from mystiq/hydrogen-web
just enough view code to join a call
This commit is contained in:
parent
9efd191f4e
commit
0a37fd561e
13 changed files with 141 additions and 28 deletions
|
@ -48,7 +48,8 @@ export class RoomViewModel extends ViewModel {
|
||||||
|
|
||||||
_setupCallViewModel() {
|
_setupCallViewModel() {
|
||||||
// pick call for this room with lowest key
|
// pick call for this room with lowest key
|
||||||
this._callObservable = new PickMapObservableValue(this.getOption("session").callHandler.calls.filterValues(c => c.roomId === this._room.id));
|
const calls = this.getOption("session").callHandler.calls;
|
||||||
|
this._callObservable = new PickMapObservableValue(calls.filterValues(c => c.roomId === this._room.id && c.hasJoined));
|
||||||
this._callViewModel = undefined;
|
this._callViewModel = undefined;
|
||||||
this.track(this._callObservable.subscribe(call => {
|
this.track(this._callObservable.subscribe(call => {
|
||||||
this._callViewModel = this.disposeTracked(this._callViewModel);
|
this._callViewModel = this.disposeTracked(this._callViewModel);
|
||||||
|
@ -68,6 +69,7 @@ export class RoomViewModel extends ViewModel {
|
||||||
try {
|
try {
|
||||||
const timeline = await this._room.openTimeline();
|
const timeline = await this._room.openTimeline();
|
||||||
this._tilesCreator = tilesCreator(this.childOptions({
|
this._tilesCreator = tilesCreator(this.childOptions({
|
||||||
|
session: this.getOption("session"),
|
||||||
roomVM: this,
|
roomVM: this,
|
||||||
timeline,
|
timeline,
|
||||||
}));
|
}));
|
||||||
|
@ -349,9 +351,8 @@ export class RoomViewModel extends ViewModel {
|
||||||
async startCall() {
|
async startCall() {
|
||||||
try {
|
try {
|
||||||
const session = this.getOption("session");
|
const session = this.getOption("session");
|
||||||
const mediaTracks = await this.platform.mediaDevices.getMediaTracks(true, true);
|
const mediaTracks = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||||
const localMedia = new LocalMedia().withTracks(mediaTracks);
|
const localMedia = new LocalMedia().withTracks(mediaTracks);
|
||||||
console.log("localMedia", localMedia.tracks);
|
|
||||||
// this will set the callViewModel above as a call will be added to callHandler.calls
|
// this will set the callViewModel above as a call will be added to callHandler.calls
|
||||||
await session.callHandler.createCall(this._room.id, localMedia, "A call " + Math.round(this.platform.random() * 100));
|
await session.callHandler.createCall(this._room.id, localMedia, "A call " + Math.round(this.platform.random() * 100));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
48
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
48
src/domain/session/room/timeline/tiles/CallTile.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
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 {
|
||||||
|
|
||||||
|
get shape() {
|
||||||
|
return "call";
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return this._entry.content["m.name"];
|
||||||
|
}
|
||||||
|
|
||||||
|
get _call() {
|
||||||
|
const calls = this.getOption("session").callHandler.calls;
|
||||||
|
return calls.get(this._entry.stateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
async join() {
|
||||||
|
const call = this._call;
|
||||||
|
if (call) {
|
||||||
|
const mediaTracks = await this.platform.mediaDevices.getMediaTracks(false, true);
|
||||||
|
const localMedia = new LocalMedia().withTracks(mediaTracks);
|
||||||
|
await call.join(localMedia);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ import {RoomMemberTile} from "./tiles/RoomMemberTile.js";
|
||||||
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
import {EncryptedEventTile} from "./tiles/EncryptedEventTile.js";
|
||||||
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
import {EncryptionEnabledTile} from "./tiles/EncryptionEnabledTile.js";
|
||||||
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
|
import {MissingAttachmentTile} from "./tiles/MissingAttachmentTile.js";
|
||||||
|
import {CallTile} from "./tiles/CallTile.js";
|
||||||
|
|
||||||
export function tilesCreator(baseOptions) {
|
export function tilesCreator(baseOptions) {
|
||||||
const tilesCreator = function tilesCreator(entry, emitUpdate) {
|
const tilesCreator = function tilesCreator(entry, emitUpdate) {
|
||||||
|
@ -71,6 +72,8 @@ export function tilesCreator(baseOptions) {
|
||||||
return new EncryptedEventTile(options);
|
return new EncryptedEventTile(options);
|
||||||
case "m.room.encryption":
|
case "m.room.encryption":
|
||||||
return new EncryptionEnabledTile(options);
|
return new EncryptionEnabledTile(options);
|
||||||
|
case "m.call":
|
||||||
|
return entry.stateKey ? new CallTile(options) : null;
|
||||||
default:
|
default:
|
||||||
// unknown type not rendered
|
// unknown type not rendered
|
||||||
return null;
|
return null;
|
||||||
|
|
|
@ -86,6 +86,7 @@ export class DeviceMessageHandler {
|
||||||
this._senderDeviceCache.set(device);
|
this._senderDeviceCache.set(device);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log("incoming device message", senderKey, device, this._senderDeviceCache);
|
||||||
return device;
|
return device;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -78,14 +78,15 @@ export class Session {
|
||||||
this._callHandler = new CallHandler({
|
this._callHandler = new CallHandler({
|
||||||
createTimeout: this._platform.clock.createTimeout,
|
createTimeout: this._platform.clock.createTimeout,
|
||||||
hsApi: this._hsApi,
|
hsApi: this._hsApi,
|
||||||
encryptDeviceMessage: async (roomId, message, log) => {
|
encryptDeviceMessage: async (roomId, userId, message, log) => {
|
||||||
if (!this._deviceTracker || !this._olmEncryption) {
|
if (!this._deviceTracker || !this._olmEncryption) {
|
||||||
throw new Error("encryption is not enabled");
|
throw new Error("encryption is not enabled");
|
||||||
}
|
}
|
||||||
// TODO: just get the devices we're sending the message to, not all the room devices
|
// 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
|
// although we probably already fetched all devices to send messages in the likely e2ee room
|
||||||
await this._deviceTracker.trackRoom(roomId, log);
|
await this._deviceTracker.trackRoom(this.rooms.get(roomId), log);
|
||||||
const devices = await this._deviceTracker.devicesForTrackedRoom(roomId, this._hsApi, log);
|
const devices = await this._deviceTracker.devicesForRoomMembers(roomId, [userId], this._hsApi, log);
|
||||||
|
console.log("devices", devices);
|
||||||
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
const encryptedMessage = await this._olmEncryption.encrypt(message.type, message.content, devices, this._hsApi, log);
|
||||||
return encryptedMessage;
|
return encryptedMessage;
|
||||||
},
|
},
|
||||||
|
|
|
@ -224,6 +224,7 @@ export class Sync {
|
||||||
_openPrepareSyncTxn() {
|
_openPrepareSyncTxn() {
|
||||||
const storeNames = this._storage.storeNames;
|
const storeNames = this._storage.storeNames;
|
||||||
return this._storage.readTxn([
|
return this._storage.readTxn([
|
||||||
|
storeNames.deviceIdentities, // to read device from olm messages
|
||||||
storeNames.olmSessions,
|
storeNames.olmSessions,
|
||||||
storeNames.inboundGroupSessions,
|
storeNames.inboundGroupSessions,
|
||||||
// to read fragments when loading sync writer when rejoining archived room
|
// to read fragments when loading sync writer when rejoining archived room
|
||||||
|
|
|
@ -114,6 +114,7 @@ export class PeerCall implements IDisposable {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.logger = {
|
this.logger = {
|
||||||
|
info(...args) { console.info.apply(console, ["WebRTC debug:", ...args])},
|
||||||
debug(...args) { console.log.apply(console, ["WebRTC debug:", ...args])},
|
debug(...args) { console.log.apply(console, ["WebRTC debug:", ...args])},
|
||||||
log(...args) { console.log.apply(console, ["WebRTC log:", ...args])},
|
log(...args) { console.log.apply(console, ["WebRTC log:", ...args])},
|
||||||
warn(...args) { console.log.apply(console, ["WebRTC warn:", ...args])},
|
warn(...args) { console.log.apply(console, ["WebRTC warn:", ...args])},
|
||||||
|
|
|
@ -41,7 +41,7 @@ export enum GroupCallState {
|
||||||
|
|
||||||
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
export type Options = Omit<MemberOptions, "emitUpdate" | "confId" | "encryptDeviceMessage"> & {
|
||||||
emitUpdate: (call: GroupCall, params?: any) => void;
|
emitUpdate: (call: GroupCall, params?: any) => void;
|
||||||
encryptDeviceMessage: (roomId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
encryptDeviceMessage: (roomId: string, userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
||||||
storage: Storage,
|
storage: Storage,
|
||||||
ownDeviceId: string
|
ownDeviceId: string
|
||||||
};
|
};
|
||||||
|
@ -61,13 +61,13 @@ export class GroupCall {
|
||||||
) {
|
) {
|
||||||
this.id = id ?? makeId("conf-");
|
this.id = id ?? makeId("conf-");
|
||||||
this._state = id ? GroupCallState.Created : GroupCallState.Fledgling;
|
this._state = id ? GroupCallState.Created : GroupCallState.Fledgling;
|
||||||
this._memberOptions = Object.assign({
|
this._memberOptions = Object.assign({}, options, {
|
||||||
confId: this.id,
|
confId: this.id,
|
||||||
emitUpdate: member => this._members.update(member.member.userId, member),
|
emitUpdate: member => this._members.update(member.member.userId, member),
|
||||||
encryptDeviceMessage: (message: SignallingMessage<MGroupCallBase>, log) => {
|
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log) => {
|
||||||
return this.options.encryptDeviceMessage(this.roomId, message, log);
|
return this.options.encryptDeviceMessage(this.roomId, userId, message, log);
|
||||||
}
|
}
|
||||||
}, options);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get localMedia(): LocalMedia | undefined { return this._localMedia; }
|
get localMedia(): LocalMedia | undefined { return this._localMedia; }
|
||||||
|
@ -99,6 +99,10 @@ export class GroupCall {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasJoined() {
|
||||||
|
return this._state === GroupCallState.Joining || this._state === GroupCallState.Joined;
|
||||||
|
}
|
||||||
|
|
||||||
async leave() {
|
async leave() {
|
||||||
const memberContent = await this._leaveCallMemberContent();
|
const memberContent = await this._leaveCallMemberContent();
|
||||||
// send m.call.member state event
|
// send m.call.member state event
|
||||||
|
@ -165,6 +169,7 @@ export class GroupCall {
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, log: ILogItem) {
|
handleDeviceMessage(message: SignallingMessage<MGroupCallBase>, userId: string, deviceId: string, log: ILogItem) {
|
||||||
|
console.log("incoming to_device call signalling message from", userId, deviceId, message);
|
||||||
// TODO: return if we are not membering to the call
|
// TODO: return if we are not membering to the call
|
||||||
let member = this._members.get(userId);
|
let member = this._members.get(userId);
|
||||||
if (member) {
|
if (member) {
|
||||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
||||||
import {PeerCall, CallState} from "../PeerCall";
|
import {PeerCall, CallState} from "../PeerCall";
|
||||||
import {makeTxnId, makeId} from "../../common";
|
import {makeTxnId, makeId} from "../../common";
|
||||||
import {EventType} from "../callEventTypes";
|
import {EventType} from "../callEventTypes";
|
||||||
|
import {formatToDeviceMessagesPayload} from "../../common";
|
||||||
|
|
||||||
import type {Options as PeerCallOptions} from "../PeerCall";
|
import type {Options as PeerCallOptions} from "../PeerCall";
|
||||||
import type {LocalMedia} from "../LocalMedia";
|
import type {LocalMedia} from "../LocalMedia";
|
||||||
|
@ -32,7 +33,7 @@ export type Options = Omit<PeerCallOptions, "emitUpdate" | "sendSignallingMessag
|
||||||
confId: string,
|
confId: string,
|
||||||
ownUserId: string,
|
ownUserId: string,
|
||||||
hsApi: HomeServerApi,
|
hsApi: HomeServerApi,
|
||||||
encryptDeviceMessage: (message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
encryptDeviceMessage: (userId: string, message: SignallingMessage<MGroupCallBase>, log: ILogItem) => Promise<EncryptedMessage>,
|
||||||
emitUpdate: (participant: Member, params?: any) => void,
|
emitUpdate: (participant: Member, params?: any) => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,13 +82,14 @@ export class Member {
|
||||||
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem) => {
|
sendSignallingMessage = async (message: SignallingMessage<MCallBase>, log: ILogItem) => {
|
||||||
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
const groupMessage = message as SignallingMessage<MGroupCallBase>;
|
||||||
groupMessage.content.conf_id = this.options.confId;
|
groupMessage.content.conf_id = this.options.confId;
|
||||||
const encryptedMessage = await this.options.encryptDeviceMessage(groupMessage, log);
|
const encryptedMessages = await this.options.encryptDeviceMessage(this.member.userId, groupMessage, log);
|
||||||
|
const payload = formatToDeviceMessagesPayload(encryptedMessages);
|
||||||
const request = this.options.hsApi.sendToDevice(
|
const request = this.options.hsApi.sendToDevice(
|
||||||
"m.room.encrypted",
|
"m.room.encrypted",
|
||||||
{[this.member.userId]: {
|
payload,
|
||||||
["*"]: encryptedMessage.content
|
makeTxnId(),
|
||||||
}
|
{log}
|
||||||
}, makeTxnId(), {log});
|
);
|
||||||
await request.response();
|
await request.response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,9 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {groupBy} from "../utils/groupBy";
|
||||||
|
|
||||||
|
|
||||||
export function makeTxnId() {
|
export function makeTxnId() {
|
||||||
return makeId("t");
|
return makeId("t");
|
||||||
}
|
}
|
||||||
|
@ -29,6 +32,20 @@ export function isTxnId(txnId) {
|
||||||
return txnId.startsWith("t") && txnId.length === 15;
|
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() {
|
export function tests() {
|
||||||
return {
|
return {
|
||||||
"isTxnId succeeds on result of makeTxnId": assert => {
|
"isTxnId succeeds on result of makeTxnId": assert => {
|
||||||
|
|
|
@ -18,7 +18,7 @@ import {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
|
||||||
import {groupEventsBySession} from "./megolm/decryption/utils";
|
import {groupEventsBySession} from "./megolm/decryption/utils";
|
||||||
import {mergeMap} from "../../utils/mergeMap";
|
import {mergeMap} from "../../utils/mergeMap";
|
||||||
import {groupBy} from "../../utils/groupBy";
|
import {groupBy} from "../../utils/groupBy";
|
||||||
import {makeTxnId} from "../common.js";
|
import {makeTxnId, formatToDeviceMessagesPayload} from "../common.js";
|
||||||
|
|
||||||
const ENCRYPTED_TYPE = "m.room.encrypted";
|
const ENCRYPTED_TYPE = "m.room.encrypted";
|
||||||
// how often ensureMessageKeyIsShared can check if it needs to
|
// how often ensureMessageKeyIsShared can check if it needs to
|
||||||
|
@ -386,6 +386,7 @@ export class RoomEncryption {
|
||||||
await writeTxn.complete();
|
await writeTxn.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: make this use _sendMessagesToDevices
|
||||||
async _sendSharedMessageToDevices(type, message, devices, hsApi, log) {
|
async _sendSharedMessageToDevices(type, message, devices, hsApi, log) {
|
||||||
const devicesByUser = groupBy(devices, device => device.userId);
|
const devicesByUser = groupBy(devices, device => device.userId);
|
||||||
const payload = {
|
const payload = {
|
||||||
|
@ -403,16 +404,7 @@ export class RoomEncryption {
|
||||||
|
|
||||||
async _sendMessagesToDevices(type, messages, hsApi, log) {
|
async _sendMessagesToDevices(type, messages, hsApi, log) {
|
||||||
log.set("messages", messages.length);
|
log.set("messages", messages.length);
|
||||||
const messagesByUser = groupBy(messages, message => message.device.userId);
|
const payload = formatToDeviceMessagesPayload(messages);
|
||||||
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 txnId = makeTxnId();
|
const txnId = makeTxnId();
|
||||||
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
await hsApi.sendToDevice(type, payload, txnId, {log}).response();
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,7 @@ import {AnnouncementView} from "./timeline/AnnouncementView.js";
|
||||||
import {RedactedView} from "./timeline/RedactedView.js";
|
import {RedactedView} from "./timeline/RedactedView.js";
|
||||||
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
import {SimpleTile} from "../../../../../domain/session/room/timeline/tiles/SimpleTile.js";
|
||||||
import {GapView} from "./timeline/GapView.js";
|
import {GapView} from "./timeline/GapView.js";
|
||||||
|
import {CallTileView} from "./timeline/CallTileView";
|
||||||
|
|
||||||
export type TileView = GapView | AnnouncementView | TextMessageView |
|
export type TileView = GapView | AnnouncementView | TextMessageView |
|
||||||
ImageView | VideoView | FileView | LocationView | MissingAttachmentView | RedactedView;
|
ImageView | VideoView | FileView | LocationView | MissingAttachmentView | RedactedView;
|
||||||
|
@ -51,5 +52,7 @@ export function viewClassForEntry(entry: SimpleTile): TileViewConstructor | unde
|
||||||
return MissingAttachmentView;
|
return MissingAttachmentView;
|
||||||
case "redacted":
|
case "redacted":
|
||||||
return RedactedView;
|
return RedactedView;
|
||||||
|
case "call":
|
||||||
|
return CallTileView;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
38
src/platform/web/ui/session/room/timeline/CallTileView.ts
Normal file
38
src/platform/web/ui/session/room/timeline/CallTileView.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
/*
|
||||||
|
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([
|
||||||
|
"Call ",
|
||||||
|
vm => vm.name,
|
||||||
|
t.button({className: "CallTileView_join"}, "Join")
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue