diff --git a/res/css/_components.scss b/res/css/_components.scss index 7df45b857e1..bc7d4fc85f6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -162,6 +162,7 @@ @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; @import "./views/messages/_EventTileBubble.scss"; +@import "./views/messages/_CallEvent.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; diff --git a/res/css/views/elements/_InfoTooltip.scss b/res/css/views/elements/_InfoTooltip.scss index 5858a60629c..5329e7f1f84 100644 --- a/res/css/views/elements/_InfoTooltip.scss +++ b/res/css/views/elements/_InfoTooltip.scss @@ -30,5 +30,12 @@ limitations under the License. mask-position: center; content: ''; vertical-align: middle; +} + +.mx_InfoTooltip_icon_info::before { mask-image: url('$(res)/img/element-icons/info.svg'); } + +.mx_InfoTooltip_icon_warning::before { + mask-image: url('$(res)/img/element-icons/warning.svg'); +} diff --git a/res/css/views/messages/_CallEvent.scss b/res/css/views/messages/_CallEvent.scss new file mode 100644 index 00000000000..54c7df3e0bc --- /dev/null +++ b/res/css/views/messages/_CallEvent.scss @@ -0,0 +1,154 @@ +/* +Copyright 2021 Šimon Brandner + +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. +*/ + +.mx_CallEvent { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + + background-color: $dark-panel-bg-color; + border-radius: 8px; + margin: 10px auto; + max-width: 75%; + box-sizing: border-box; + height: 60px; + + &.mx_CallEvent_voice { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); + } + } + + &.mx_CallEvent_video { + .mx_CallEvent_type_icon::before, + .mx_CallEvent_content_button_callBack span::before, + .mx_CallEvent_content_button_answer span::before { + mask-image: url('$(res)/img/element-icons/call/video-call.svg'); + } + } + + .mx_CallEvent_info { + display: flex; + flex-direction: row; + align-items: center; + margin-left: 12px; + + .mx_CallEvent_info_basic { + display: flex; + flex-direction: column; + margin-left: 10px; // To match mx_CallEvent + + .mx_CallEvent_sender { + font-weight: 600; + font-size: 1.5rem; + line-height: 1.8rem; + margin-bottom: 3px; + } + + .mx_CallEvent_type { + font-weight: 400; + color: $secondary-fg-color; + font-size: 1.2rem; + line-height: $font-13px; + display: flex; + align-items: center; + + .mx_CallEvent_type_icon { + height: 13px; + width: 13px; + margin-right: 5px; + + &::before { + content: ''; + position: absolute; + height: 13px; + width: 13px; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + } + } + } + } + + .mx_CallEvent_content { + display: flex; + flex-direction: row; + align-items: center; + color: $secondary-fg-color; + margin-right: 16px; + + .mx_CallEvent_content_button { + height: 24px; + padding: 0px 12px; + margin-left: 8px; + + span { + padding: 8px 0; + display: flex; + align-items: center; + + &::before { + content: ''; + display: inline-block; + background-color: $button-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 16px; + width: 16px; + height: 16px; + margin-right: 8px; + } + } + } + + .mx_CallEvent_content_button_reject span::before { + mask-image: url('$(res)/img/element-icons/call/hangup.svg'); + } + + .mx_CallEvent_content_tooltip { + margin-right: 5px; + } + + .mx_CallEvent_iconButton { + display: inline-flex; + margin-right: 8px; + + &::before { + content: ''; + + height: 16px; + width: 16px; + background-color: $tertiary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } + + .mx_CallEvent_silence::before { + mask-image: url('$(res)/img/voip/silence.svg'); + } + + .mx_CallEvent_unSilence::before { + mask-image: url('$(res)/img/voip/un-silence.svg'); + } + } +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index d6ad37f6bb6..72328fab77e 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -333,6 +333,13 @@ $hover-select-border: 4px; .mx_EventTile_msgOption { grid-column: 2; } + + &:hover { + .mx_EventTile_line { + // To avoid bubble events being highlighted + background-color: inherit !important; + } + } } .mx_EventTile_readAvatars { diff --git a/res/img/element-icons/warning.svg b/res/img/element-icons/warning.svg new file mode 100644 index 00000000000..eef51931408 --- /dev/null +++ b/res/img/element-icons/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 489d28e26b9..e7ba1aa9fb2 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -99,7 +99,7 @@ const CHECK_PROTOCOLS_ATTEMPTS = 3; // (and store the ID of their native room) export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room'; -export enum AudioID { +enum AudioID { Ring = 'ringAudio', Ringback = 'ringbackAudio', CallEnd = 'callendAudio', @@ -142,6 +142,7 @@ export enum PlaceCallType { export enum CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", + SilencedCallsChanged = "silenced_calls_changed", } export default class CallHandler extends EventEmitter { @@ -164,6 +165,8 @@ export default class CallHandler extends EventEmitter { // do the async lookup when we get new information and then store these mappings here private assertedIdentityNativeUsers = new Map(); + private silencedCalls = new Set(); // callIds + static sharedInstance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler(); @@ -224,6 +227,33 @@ export default class CallHandler extends EventEmitter { } } + public silenceCall(callId: string) { + this.silencedCalls.add(callId); + this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); + + // Don't pause audio if we have calls which are still ringing + if (this.areAnyCallsUnsilenced()) return; + this.pause(AudioID.Ring); + } + + public unSilenceCall(callId: string) { + this.silencedCalls.delete(callId); + this.emit(CallHandlerEvent.SilencedCallsChanged, this.silencedCalls); + this.play(AudioID.Ring); + } + + public isCallSilenced(callId: string): boolean { + return this.silencedCalls.has(callId); + } + + /** + * Returns true if there is at least one unsilenced call + * @returns {boolean} + */ + private areAnyCallsUnsilenced(): boolean { + return this.calls.size > this.silencedCalls.size; + } + private async checkProtocols(maxTries) { try { const protocols = await MatrixClientPeg.get().getThirdpartyProtocols(); @@ -301,6 +331,13 @@ export default class CallHandler extends EventEmitter { }, true); }; + public getCallById(callId: string): MatrixCall { + for (const call of this.calls.values()) { + if (call.callId === callId) return call; + } + return null; + } + getCallForRoom(roomId: string): MatrixCall { return this.calls.get(roomId) || null; } @@ -441,6 +478,10 @@ export default class CallHandler extends EventEmitter { break; } + if (newState !== CallState.Ringing) { + this.silencedCalls.delete(call.callId); + } + switch (newState) { case CallState.Ringing: this.play(AudioID.Ring); diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 0056a37c851..7bad8eb50e4 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import { MatrixClientPeg } from './MatrixClientPeg'; import { _t } from './languageHandler'; import * as Roles from './Roles'; import { isValid3pidInvite } from "./RoomInvite"; @@ -318,90 +317,6 @@ function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null { }); } -function textForCallAnswerEvent(event: MatrixEvent): () => string | null { - return () => { - const senderName = event.sender ? event.sender.name : _t('Someone'); - const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); - return _t('%(senderName)s answered the call.', { senderName }) + ' ' + supported; - }; -} - -function textForCallHangupEvent(event: MatrixEvent): () => string | null { - const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); - const eventContent = event.getContent(); - let getReason = () => ""; - if (!MatrixClientPeg.get().supportsVoip()) { - getReason = () => _t('(not supported by this browser)'); - } else if (eventContent.reason) { - if (eventContent.reason === "ice_failed") { - // We couldn't establish a connection at all - getReason = () => _t('(could not connect media)'); - } else if (eventContent.reason === "ice_timeout") { - // We established a connection but it died - getReason = () => _t('(connection failed)'); - } else if (eventContent.reason === "user_media_failed") { - // The other side couldn't open capture devices - getReason = () => _t("(their device couldn't start the camera / microphone)"); - } else if (eventContent.reason === "unknown_error") { - // An error code the other side doesn't have a way to express - // (as opposed to an error code they gave but we don't know about, - // in which case we show the error code) - getReason = () => _t("(an error occurred)"); - } else if (eventContent.reason === "invite_timeout") { - getReason = () => _t('(no answer)'); - } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { - // workaround for https://github.com/vector-im/element-web/issues/5178 - // it seems Android randomly sets a reason of "user hangup" which is - // interpreted as an error code :( - // https://github.com/vector-im/riot-android/issues/2623 - // Also the correct hangup code as of VoIP v1 (with underscore) - getReason = () => ''; - } else { - getReason = () => _t('(unknown failure: %(reason)s)', { reason: eventContent.reason }); - } - } - return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason(); -} - -function textForCallRejectEvent(event: MatrixEvent): () => string | null { - return () => { - const senderName = event.sender ? event.sender.name : _t('Someone'); - return _t('%(senderName)s declined the call.', { senderName }); - }; -} - -function textForCallInviteEvent(event: MatrixEvent): () => string | null { - const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); - // FIXME: Find a better way to determine this from the event? - let isVoice = true; - if (event.getContent().offer && event.getContent().offer.sdp && - event.getContent().offer.sdp.indexOf('m=video') !== -1) { - isVoice = false; - } - const isSupported = MatrixClientPeg.get().supportsVoip(); - - // This ladder could be reduced down to a couple string variables, however other languages - // can have a hard time translating those strings. In an effort to make translations easier - // and more accurate, we break out the string-based variables to a couple booleans. - if (isVoice && isSupported) { - return () => _t("%(senderName)s placed a voice call.", { - senderName: getSenderName(), - }); - } else if (isVoice && !isSupported) { - return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", { - senderName: getSenderName(), - }); - } else if (!isVoice && isSupported) { - return () => _t("%(senderName)s placed a video call.", { - senderName: getSenderName(), - }); - } else if (!isVoice && !isSupported) { - return () => _t("%(senderName)s placed a video call. (not supported by this browser)", { - senderName: getSenderName(), - }); - } -} - function textForThreePidInviteEvent(event: MatrixEvent): () => string | null { const senderName = event.sender ? event.sender.name : event.getSender(); @@ -652,10 +567,6 @@ interface IHandlers { const handlers: IHandlers = { 'm.room.message': textForMessageEvent, - 'm.call.invite': textForCallInviteEvent, - 'm.call.answer': textForCallAnswerEvent, - 'm.call.hangup': textForCallHangupEvent, - 'm.call.reject': textForCallRejectEvent, }; const stateHandlers: IHandlers = { diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts new file mode 100644 index 00000000000..384f20cd4e9 --- /dev/null +++ b/src/components/structures/CallEventGrouper.ts @@ -0,0 +1,145 @@ +/* +Copyright 2021 Šimon Brandner + +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 { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { CallEvent, CallState, CallType, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import CallHandler, { CallHandlerEvent } from '../../CallHandler'; +import { EventEmitter } from 'events'; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import defaultDispatcher from "../../dispatcher/dispatcher"; + +export enum CallEventGrouperEvent { + StateChanged = "state_changed", + SilencedChanged = "silenced_changed", +} + +const SUPPORTED_STATES = [ + CallState.Connected, + CallState.Connecting, + CallState.Ringing, +]; + +export enum CustomCallState { + Missed = "missed", +} + +export default class CallEventGrouper extends EventEmitter { + private events: Set = new Set(); + private call: MatrixCall; + public state: CallState | CustomCallState; + + constructor() { + super(); + + CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.setCall); + CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); + } + + private get invite(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallInvite); + } + + private get hangup(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallHangup); + } + + private get reject(): MatrixEvent { + return [...this.events].find((event) => event.getType() === EventType.CallReject); + } + + public get isVoice(): boolean { + const invite = this.invite; + if (!invite) return; + + // FIXME: Find a better way to determine this from the event? + if (invite.getContent()?.offer?.sdp?.indexOf('m=video') !== -1) return false; + return true; + } + + public get hangupReason(): string | null { + return this.hangup?.getContent()?.reason; + } + + /** + * Returns true if there are only events from the other side - we missed the call + */ + private get callWasMissed(): boolean { + return ![...this.events].some((event) => event.sender?.userId === MatrixClientPeg.get().getUserId()); + } + + private get callId(): string { + return [...this.events][0].getContent().call_id; + } + + private onSilencedCallsChanged = () => { + const newState = CallHandler.sharedInstance().isCallSilenced(this.callId); + this.emit(CallEventGrouperEvent.SilencedChanged, newState); + }; + + public answerCall = () => { + this.call?.answer(); + }; + + public rejectCall = () => { + this.call?.reject(); + }; + + public callBack = () => { + defaultDispatcher.dispatch({ + action: 'place_call', + type: this.isVoice ? CallType.Voice : CallType.Video, + room_id: [...this.events][0]?.getRoomId(), + }); + }; + + public toggleSilenced = () => { + const silenced = CallHandler.sharedInstance().isCallSilenced(this.callId); + silenced ? + CallHandler.sharedInstance().unSilenceCall(this.callId) : + CallHandler.sharedInstance().silenceCall(this.callId); + }; + + private setCallListeners() { + if (!this.call) return; + this.call.addListener(CallEvent.State, this.setState); + } + + private setState = () => { + if (SUPPORTED_STATES.includes(this.call?.state)) { + this.state = this.call.state; + } else { + if (this.callWasMissed) this.state = CustomCallState.Missed; + else if (this.reject) this.state = CallState.Ended; + else if (this.hangup) this.state = CallState.Ended; + else if (this.invite && this.call) this.state = CallState.Connecting; + } + this.emit(CallEventGrouperEvent.StateChanged, this.state); + }; + + private setCall = () => { + if (this.call) return; + + this.call = CallHandler.sharedInstance().getCallById(this.callId); + this.setCallListeners(); + this.setState(); + }; + + public add(event: MatrixEvent) { + this.events.add(event); + this.setCall(); + } +} diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 9fd96b161a9..514cf4db097 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -36,6 +36,7 @@ import DMRoomMap from "../../utils/DMRoomMap"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; import { replaceableComponent } from "../../utils/replaceableComponent"; import defaultDispatcher from '../../dispatcher/dispatcher'; +import CallEventGrouper from "./CallEventGrouper"; import WhoIsTypingTile from '../views/rooms/WhoIsTypingTile'; import ScrollPanel, { IScrollState } from "./ScrollPanel"; import EventListSummary from '../views/elements/EventListSummary'; @@ -232,6 +233,9 @@ export default class MessagePanel extends React.Component { private readonly showTypingNotificationsWatcherRef: string; private eventNodes: Record; + // A map of + private callEventGroupers = new Map(); + constructor(props, context) { super(props, context); @@ -576,6 +580,20 @@ export default class MessagePanel extends React.Component { const last = (mxEv === lastShownEvent); const { nextEvent, nextTile } = this.getNextEventInfo(this.props.events, i); + if ( + mxEv.getType().indexOf("m.call.") === 0 || + mxEv.getType().indexOf("org.matrix.call.") === 0 + ) { + const callId = mxEv.getContent().call_id; + if (this.callEventGroupers.has(callId)) { + this.callEventGroupers.get(callId).add(mxEv); + } else { + const callEventGrouper = new CallEventGrouper(); + callEventGrouper.add(mxEv); + this.callEventGroupers.set(callId, callEventGrouper); + } + } + if (grouper) { if (grouper.shouldGroup(mxEv)) { grouper.add(mxEv, this.showHiddenEvents); @@ -692,6 +710,8 @@ export default class MessagePanel extends React.Component { // it's successful: we received it. isLastSuccessful = isLastSuccessful && mxEv.getSender() === MatrixClientPeg.get().getUserId(); + const callEventGrouper = this.callEventGroupers.get(mxEv.getContent().call_id); + // use txnId as key if available so that we don't remount during sending ret.push( @@ -722,6 +742,7 @@ export default class MessagePanel extends React.Component { layout={this.props.layout} enableFlair={this.props.enableFlair} showReadReceipts={this.props.showReadReceipts} + callEventGrouper={callEventGrouper} hideSender={this.props.room.getMembers().length <= 2 && this.props.layout === Layout.Bubble} /> , diff --git a/src/components/views/elements/InfoTooltip.tsx b/src/components/views/elements/InfoTooltip.tsx index 37a9db54be1..123e1189655 100644 --- a/src/components/views/elements/InfoTooltip.tsx +++ b/src/components/views/elements/InfoTooltip.tsx @@ -22,9 +22,16 @@ import Tooltip, { Alignment } from './Tooltip'; import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +export enum InfoTooltipKind { + Info = "info", + Warning = "warning", +} + interface ITooltipProps { tooltip?: React.ReactNode; + className?: string; tooltipClassName?: string; + kind?: InfoTooltipKind; } interface IState { @@ -53,8 +60,12 @@ export default class InfoTooltip extends React.PureComponent :
; return ( -
- +
+ { children } { tip }
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx new file mode 100644 index 00000000000..c0be3b46bbe --- /dev/null +++ b/src/components/views/messages/CallEvent.tsx @@ -0,0 +1,218 @@ +/* +Copyright 2021 Šimon Brandner + +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 React from 'react'; + +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { _t, _td } from '../../../languageHandler'; +import MemberAvatar from '../avatars/MemberAvatar'; +import CallEventGrouper, { CallEventGrouperEvent, CustomCallState } from '../../structures/CallEventGrouper'; +import AccessibleButton from '../elements/AccessibleButton'; +import { CallErrorCode, CallState } from 'matrix-js-sdk/src/webrtc/call'; +import InfoTooltip, { InfoTooltipKind } from '../elements/InfoTooltip'; +import classNames from 'classnames'; +import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; + +interface IProps { + mxEvent: MatrixEvent; + callEventGrouper: CallEventGrouper; +} + +interface IState { + callState: CallState | CustomCallState; + silenced: boolean; +} + +const TEXTUAL_STATES: Map = new Map([ + [CallState.Connected, _td("Connected")], + [CallState.Connecting, _td("Connecting")], +]); + +export default class CallEvent extends React.Component { + constructor(props: IProps) { + super(props); + + this.state = { + callState: this.props.callEventGrouper.state, + silenced: false, + }; + } + + componentDidMount() { + this.props.callEventGrouper.addListener(CallEventGrouperEvent.StateChanged, this.onStateChanged); + this.props.callEventGrouper.addListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged); + } + + componentWillUnmount() { + this.props.callEventGrouper.removeListener(CallEventGrouperEvent.StateChanged, this.onStateChanged); + this.props.callEventGrouper.removeListener(CallEventGrouperEvent.SilencedChanged, this.onSilencedChanged); + } + + private onSilencedChanged = (newState) => { + this.setState({ silenced: newState }); + }; + + private onStateChanged = (newState: CallState) => { + this.setState({ callState: newState }); + }; + + private renderContent(state: CallState | CustomCallState): JSX.Element { + if (state === CallState.Ringing) { + const silenceClass = classNames({ + "mx_CallEvent_iconButton": true, + "mx_CallEvent_unSilence": this.state.silenced, + "mx_CallEvent_silence": !this.state.silenced, + }); + + return ( +
+ + + { _t("Decline") } + + + { _t("Accept") } + +
+ ); + } + if (state === CallState.Ended) { + const hangupReason = this.props.callEventGrouper.hangupReason; + + if ([CallErrorCode.UserHangup, "user hangup"].includes(hangupReason) || !hangupReason) { + // workaround for https://github.com/vector-im/element-web/issues/5178 + // it seems Android randomly sets a reason of "user hangup" which is + // interpreted as an error code :( + // https://github.com/vector-im/riot-android/issues/2623 + // Also the correct hangup code as of VoIP v1 (with underscore) + // Also, if we don't have a reason + return ( +
+ { _t("This call has ended") } +
+ ); + } + + let reason; + if (hangupReason === CallErrorCode.IceFailed) { + // We couldn't establish a connection at all + reason = _t("Could not connect media"); + } else if (hangupReason === "ice_timeout") { + // We established a connection but it died + reason = _t("Connection failed"); + } else if (hangupReason === CallErrorCode.NoUserMedia) { + // The other side couldn't open capture devices + reason = _t("Their device couldn't start the camera or microphone"); + } else if (hangupReason === "unknown_error") { + // An error code the other side doesn't have a way to express + // (as opposed to an error code they gave but we don't know about, + // in which case we show the error code) + reason = _t("An unknown error occurred"); + } else if (hangupReason === CallErrorCode.InviteTimeout) { + reason = _t("No answer"); + } else if (hangupReason === CallErrorCode.UserBusy) { + reason = _t("The user you called is busy."); + } else { + reason = _t('Unknown failure: %(reason)s)', { reason: hangupReason }); + } + + return ( +
+ + { _t("This call has failed") } +
+ ); + } + if (Array.from(TEXTUAL_STATES.keys()).includes(state)) { + return ( +
+ { TEXTUAL_STATES.get(state) } +
+ ); + } + if (state === CustomCallState.Missed) { + return ( +
+ { _t("You missed this call") } + + { _t("Call back") } + +
+ ); + } + + return ( +
+ { _t("The call is in an unknown state!") } +
+ ); + } + + render() { + const event = this.props.mxEvent; + const sender = event.sender ? event.sender.name : event.getSender(); + const isVoice = this.props.callEventGrouper.isVoice; + const callType = isVoice ? _t("Voice call") : _t("Video call"); + const content = this.renderContent(this.state.callState); + const className = classNames({ + mx_CallEvent: true, + mx_CallEvent_voice: isVoice, + mx_CallEvent_video: !isVoice, + }); + + return ( +
+
+ +
+
+ { sender } +
+
+
+ { callType } +
+
+
+ { content } +
+ ); + } +} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index a2a88335ac3..6861ea7af5d 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -44,6 +44,7 @@ import EditorStateTransfer from "../../../utils/EditorStateTransfer"; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import NotificationBadge from "./NotificationBadge"; +import CallEventGrouper from "../../structures/CallEventGrouper"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from '../../../dispatcher/actions'; import MemberAvatar from '../avatars/MemberAvatar'; @@ -60,10 +61,7 @@ const eventTileTypes = { [EventType.Sticker]: 'messages.MessageEvent', [EventType.KeyVerificationCancel]: 'messages.MKeyVerificationConclusion', [EventType.KeyVerificationDone]: 'messages.MKeyVerificationConclusion', - [EventType.CallInvite]: 'messages.TextualEvent', - [EventType.CallAnswer]: 'messages.TextualEvent', - [EventType.CallHangup]: 'messages.TextualEvent', - [EventType.CallReject]: 'messages.TextualEvent', + [EventType.CallInvite]: 'messages.CallEvent', }; const stateEventTileTypes = { @@ -290,6 +288,9 @@ interface IProps { // Helper to build permalinks for the room permalinkCreator?: RoomPermalinkCreator; + // CallEventGrouper for this event + callEventGrouper?: CallEventGrouper; + // Symbol of the root node as?: string; @@ -1154,6 +1155,7 @@ export default class EventTile extends React.Component { showUrlPreview={this.props.showUrlPreview} permalinkCreator={this.props.permalinkCreator} onHeightChanged={this.props.onHeightChanged} + callEventGrouper={this.props.callEventGrouper} /> { keyRequestInfo } { actionBar } diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 61bd4281266..95e97f10803 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -21,7 +21,7 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import { ActionPayload } from '../../../dispatcher/payloads'; -import CallHandler, { AudioID } from '../../../CallHandler'; +import CallHandler, { CallHandlerEvent } from '../../../CallHandler'; import RoomAvatar from '../avatars/RoomAvatar'; import AccessibleButton from '../elements/AccessibleButton'; import { CallState } from 'matrix-js-sdk/src/webrtc/call'; @@ -51,8 +51,13 @@ export default class IncomingCallBox extends React.Component { }; } + componentDidMount = () => { + CallHandler.sharedInstance().addListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); + }; + public componentWillUnmount() { dis.unregister(this.dispatcherRef); + CallHandler.sharedInstance().removeListener(CallHandlerEvent.SilencedCallsChanged, this.onSilencedCallsChanged); } private onAction = (payload: ActionPayload) => { @@ -73,6 +78,12 @@ export default class IncomingCallBox extends React.Component { } }; + private onSilencedCallsChanged = () => { + const callId = this.state.incomingCall?.callId; + if (!callId) return; + this.setState({ silenced: CallHandler.sharedInstance().isCallSilenced(callId) }); + }; + private onAnswerClick: React.MouseEventHandler = (e) => { e.stopPropagation(); dis.dispatch({ @@ -91,9 +102,10 @@ export default class IncomingCallBox extends React.Component { private onSilenceClick: React.MouseEventHandler = (e) => { e.stopPropagation(); - const newState = !this.state.silenced; - this.setState({ silenced: newState }); - newState ? CallHandler.sharedInstance().pause(AudioID.Ring) : CallHandler.sharedInstance().play(AudioID.Ring); + const callId = this.state.incomingCall.callId; + this.state.silenced ? + CallHandler.sharedInstance().unSilenceCall(callId): + CallHandler.sharedInstance().silenceCall(callId); }; public render() { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6d63bc4e293..5147e5a1f02 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -541,22 +541,8 @@ "%(senderName)s changed the alternative addresses for this room.": "%(senderName)s changed the alternative addresses for this room.", "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.", "%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.", - "Someone": "Someone", - "(not supported by this browser)": "(not supported by this browser)", - "%(senderName)s answered the call.": "%(senderName)s answered the call.", - "(could not connect media)": "(could not connect media)", - "(connection failed)": "(connection failed)", - "(their device couldn't start the camera / microphone)": "(their device couldn't start the camera / microphone)", - "(an error occurred)": "(an error occurred)", - "(no answer)": "(no answer)", - "(unknown failure: %(reason)s)": "(unknown failure: %(reason)s)", - "%(senderName)s ended the call.": "%(senderName)s ended the call.", - "%(senderName)s declined the call.": "%(senderName)s declined the call.", - "%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.", - "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)", - "%(senderName)s placed a video call.": "%(senderName)s placed a video call.", - "%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s placed a video call. (not supported by this browser)", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.", + "Someone": "Someone", "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.", "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.", "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.", @@ -1856,6 +1842,18 @@ "You cancelled verification.": "You cancelled verification.", "Verification cancelled": "Verification cancelled", "Compare emoji": "Compare emoji", + "Connected": "Connected", + "This call has ended": "This call has ended", + "Could not connect media": "Could not connect media", + "Connection failed": "Connection failed", + "Their device couldn't start the camera or microphone": "Their device couldn't start the camera or microphone", + "An unknown error occurred": "An unknown error occurred", + "No answer": "No answer", + "Unknown failure: %(reason)s)": "Unknown failure: %(reason)s)", + "This call has failed": "This call has failed", + "You missed this call": "You missed this call", + "Call back": "Call back", + "The call is in an unknown state!": "The call is in an unknown state!", "Sunday": "Sunday", "Monday": "Monday", "Tuesday": "Tuesday", diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts index 849e5464858..e2af1c74641 100644 --- a/src/utils/EventUtils.ts +++ b/src/utils/EventUtils.ts @@ -111,14 +111,19 @@ export function getEventDisplayInfo(mxEvent: MatrixEvent): { let tileHandler = getHandlerTile(mxEvent); // Info messages are basically information about commands processed on a room - let isBubbleMessage = eventType.startsWith("m.key.verification") || - (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || - (eventType === EventType.RoomCreate) || - (eventType === EventType.RoomEncryption) || - (tileHandler === "messages.MJitsiWidgetEvent"); + let isBubbleMessage = ( + eventType.startsWith("m.key.verification") || + (eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) || + (eventType === EventType.RoomCreate) || + (eventType === EventType.RoomEncryption) || + (eventType === EventType.CallInvite) || + (tileHandler === "messages.MJitsiWidgetEvent") + ); let isInfoMessage = ( - !isBubbleMessage && eventType !== EventType.RoomMessage && - eventType !== EventType.Sticker && eventType !== EventType.RoomCreate + !isBubbleMessage && + eventType !== EventType.RoomMessage && + eventType !== EventType.Sticker && + eventType !== EventType.RoomCreate ); // If we're showing hidden events in the timeline, we should use the diff --git a/test/components/structures/CallEventGrouper-test.ts b/test/components/structures/CallEventGrouper-test.ts new file mode 100644 index 00000000000..5719d92902b --- /dev/null +++ b/test/components/structures/CallEventGrouper-test.ts @@ -0,0 +1,140 @@ +/* +Copyright 2021 Šimon Brandner + +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 "../../skinned-sdk"; +import { stubClient } from '../../test-utils'; +import { MatrixClientPeg } from '../../../src/MatrixClientPeg'; +import { MatrixClient } from 'matrix-js-sdk'; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import CallEventGrouper, { CustomCallState } from "../../../src/components/structures/CallEventGrouper"; +import { CallState } from "matrix-js-sdk/src/webrtc/call"; + +const MY_USER_ID = "@me:here"; +const THEIR_USER_ID = "@they:here"; + +let client: MatrixClient; + +describe('CallEventGrouper', () => { + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + client.getUserId = () => { + return MY_USER_ID; + }; + }); + + it("detects a missed call", () => { + const grouper = new CallEventGrouper(); + + grouper.add({ + getContent: () => { + return { + call_id: "callId", + }; + }, + getType: () => { + return EventType.CallInvite; + }, + sender: { + userId: THEIR_USER_ID, + }, + }); + + expect(grouper.state).toBe(CustomCallState.Missed); + }); + + it("detects an ended call", () => { + const grouperHangup = new CallEventGrouper(); + const grouperReject = new CallEventGrouper(); + + grouperHangup.add({ + getContent: () => { + return { + call_id: "callId", + }; + }, + getType: () => { + return EventType.CallInvite; + }, + sender: { + userId: MY_USER_ID, + }, + }); + grouperHangup.add({ + getContent: () => { + return { + call_id: "callId", + }; + }, + getType: () => { + return EventType.CallHangup; + }, + sender: { + userId: THEIR_USER_ID, + }, + }); + + grouperReject.add({ + getContent: () => { + return { + call_id: "callId", + }; + }, + getType: () => { + return EventType.CallInvite; + }, + sender: { + userId: MY_USER_ID, + }, + }); + grouperReject.add({ + getContent: () => { + return { + call_id: "callId", + }; + }, + getType: () => { + return EventType.CallReject; + }, + sender: { + userId: THEIR_USER_ID, + }, + }); + + expect(grouperHangup.state).toBe(CallState.Ended); + expect(grouperReject.state).toBe(CallState.Ended); + }); + + it("detects call type", () => { + const grouper = new CallEventGrouper(); + + grouper.add({ + getContent: () => { + return { + call_id: "callId", + offer: { + sdp: "this is definitely an SDP m=video", + }, + }; + }, + getType: () => { + return EventType.CallInvite; + }, + }); + + expect(grouper.isVoice).toBe(false); + }); +});