diff --git a/package.json b/package.json index 4e2e933a52..b73462d188 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,8 @@ "counterpart": "^0.18.6", "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", - "emojibase-data": "^5.1.1", - "emojibase-regex": "^4.1.1", + "emojibase-data": "^6.2.0", + "emojibase-regex": "^5.1.3", "escape-html": "^1.0.3", "file-saver": "^2.0.5", "filesize": "6.1.0", diff --git a/res/css/_components.scss b/res/css/_components.scss index 8c44bc3f54..9365c58e36 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/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 7b975110e1..1a02c0d5ac 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -118,10 +118,6 @@ limitations under the License. padding-left: 0px; } -.mx_FilePanel .mx_EventTile:hover .mx_EventTile_line { - background-color: $primary-bg-color; -} - .mx_FilePanel_empty::before { mask-image: url('$(res)/img/element-icons/room/files.svg'); } diff --git a/res/css/views/elements/_InfoTooltip.scss b/res/css/views/elements/_InfoTooltip.scss index 5858a60629..5329e7f1f8 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 0000000000..54c7df3e0b --- /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 d6ad37f6bb..72328fab77 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/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 index a52e5a3800..128aac8139 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 index 660a93193d..a95e89c094 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-sbix.woff2 differ diff --git a/res/img/element-icons/warning.svg b/res/img/element-icons/warning.svg new file mode 100644 index 0000000000..eef5193140 --- /dev/null +++ b/res/img/element-icons/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index 19486865b8..086cc30952 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -97,7 +97,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', @@ -135,6 +135,7 @@ export enum PlaceCallType { export enum CallHandlerEvent { CallsChanged = "calls_changed", CallChangeRoom = "call_change_room", + SilencedCallsChanged = "silenced_calls_changed", } export default class CallHandler extends EventEmitter { @@ -157,6 +158,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(); @@ -217,6 +220,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(); @@ -294,6 +324,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; } @@ -434,6 +471,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/HtmlUtils.tsx b/src/HtmlUtils.tsx index a37b7f0ac9..3c34bf6837 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -33,7 +33,7 @@ import { IExtendedSanitizeOptions } from './@types/sanitize-html'; import linkifyMatrix from './linkify-matrix'; import SettingsStore from './settings/SettingsStore'; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; -import { SHORTCODE_TO_EMOJI, getEmojiFromUnicode } from "./emoji"; +import { getEmojiFromUnicode } from "./emoji"; import ReplyThread from "./components/views/elements/ReplyThread"; import { mediaFromMxc } from "./customisations/Media"; @@ -79,20 +79,8 @@ function mightContainEmoji(str: string): boolean { * @return {String} The shortcode (such as :thumbup:) */ export function unicodeToShortcode(char: string): string { - const data = getEmojiFromUnicode(char); - return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); -} - -/** - * Returns the unicode character for an emoji shortcode - * - * @param {String} shortcode The shortcode (such as :thumbup:) - * @return {String} The emoji character; null if none exists - */ -export function shortcodeToUnicode(shortcode: string): string { - shortcode = shortcode.slice(1, shortcode.length - 1); - const data = SHORTCODE_TO_EMOJI.get(shortcode); - return data ? data.unicode : null; + const shortcodes = getEmojiFromUnicode(char).shortcodes; + return shortcodes.length > 0 ? `:${shortcodes[0]}:` : ''; } export function processHtmlForSending(html: string): string { diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 0056a37c85..7bad8eb50e 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/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 2fc77e9a17..d3175edbdb 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -25,7 +25,6 @@ import { PillCompletion } from './Components'; import { ICompletion, ISelectionRange } from './Autocompleter'; import { uniq, sortBy } from 'lodash'; import SettingsStore from "../settings/SettingsStore"; -import { shortcodeToUnicode } from '../HtmlUtils'; import { EMOJI, IEmoji } from '../emoji'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; @@ -36,20 +35,18 @@ const LIMIT = 20; // anchored to only match from the start of parts otherwise it'll show emoji suggestions whilst typing matrix IDs const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|(?:^|\\s):[+-\\w]*:?)$', 'g'); -interface IEmojiShort { +interface ISortedEmoji { emoji: IEmoji; - shortname: string; _orderBy: number; } -const EMOJI_SHORTNAMES: IEmojiShort[] = EMOJI.sort((a, b) => { +const SORTED_EMOJI: ISortedEmoji[] = EMOJI.sort((a, b) => { if (a.group === b.group) { return a.order - b.order; } return a.group - b.group; }).map((emoji, index) => ({ emoji, - shortname: `:${emoji.shortcodes[0]}:`, // Include the index so that we can preserve the original order _orderBy: index, })); @@ -64,20 +61,18 @@ function score(query, space) { } export default class EmojiProvider extends AutocompleteProvider { - matcher: QueryMatcher; - nameMatcher: QueryMatcher; + matcher: QueryMatcher; + nameMatcher: QueryMatcher; constructor() { super(EMOJI_REGEX); - this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, { - keys: ['emoji.emoticon', 'shortname'], - funcs: [ - (o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases - ], + this.matcher = new QueryMatcher(SORTED_EMOJI, { + keys: ['emoji.emoticon'], + funcs: [o => o.emoji.shortcodes.map(s => `:${s}:`)], // For matching against ascii equivalents shouldMatchWordsOnly: false, }); - this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, { + this.nameMatcher = new QueryMatcher(SORTED_EMOJI, { keys: ['emoji.annotation'], // For removing punctuation shouldMatchWordsOnly: true, @@ -105,34 +100,33 @@ export default class EmojiProvider extends AutocompleteProvider { const sorters = []; // make sure that emoticons come first - sorters.push((c) => score(matchedString, c.emoji.emoticon || "")); + sorters.push(c => score(matchedString, c.emoji.emoticon || "")); - // then sort by score (Infinity if matchedString not in shortname) - sorters.push((c) => score(matchedString, c.shortname)); + // then sort by score (Infinity if matchedString not in shortcode) + sorters.push(c => score(matchedString, c.emoji.shortcodes[0])); // then sort by max score of all shortcodes, trim off the `:` - sorters.push((c) => Math.min(...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)))); - // If the matchedString is not empty, sort by length of shortname. Example: + sorters.push(c => Math.min( + ...c.emoji.shortcodes.map(s => score(matchedString.substring(1), s)), + )); + // If the matchedString is not empty, sort by length of shortcode. Example: // matchedString = ":bookmark" // completions = [":bookmark:", ":bookmark_tabs:", ...] if (matchedString.length > 1) { - sorters.push((c) => c.shortname.length); + sorters.push(c => c.emoji.shortcodes[0].length); } // Finally, sort by original ordering - sorters.push((c) => c._orderBy); + sorters.push(c => c._orderBy); completions = sortBy(uniq(completions), sorters); - completions = completions.map(({ shortname }) => { - const unicode = shortcodeToUnicode(shortname); - return { - completion: unicode, - component: ( - - { unicode } - - ), - range, - }; - }).slice(0, LIMIT); + completions = completions.map(c => ({ + completion: c.emoji.unicode, + component: ( + + { c.emoji.unicode } + + ), + range, + })).slice(0, LIMIT); } return completions; } diff --git a/src/components/structures/CallEventGrouper.ts b/src/components/structures/CallEventGrouper.ts new file mode 100644 index 0000000000..384f20cd4e --- /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 9fd96b161a..514cf4db09 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 37a9db54be..123e118965 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/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js index deb1cd92e2..03aa9e0d6d 100644 --- a/src/components/views/elements/PersistedElement.js +++ b/src/components/views/elements/PersistedElement.js @@ -83,7 +83,7 @@ export default class PersistedElement extends React.Component { // for this, so we bodge it by listening for document resize and // the timeline_resize action. window.addEventListener('resize', this._repositionChild); - this.dispatcherRef = dis.register(this._onAction); + this._dispatcherRef = dis.register(this._onAction); } /** diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 2ed40a304d..0884db7101 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -32,6 +32,8 @@ export const CATEGORY_HEADER_HEIGHT = 22; export const EMOJI_HEIGHT = 37; export const EMOJIS_PER_ROW = 8; +const ZERO_WIDTH_JOINER = "\u200D"; + interface IProps { selectedEmojis?: Set; showQuickReactions?: boolean; @@ -180,7 +182,7 @@ class EmojiPicker extends React.Component { } else { emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id]; } - emojis = emojis.filter(emoji => emoji.filterString.includes(filter)); + emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter)); this.memoizedDataByCategory[cat.id] = emojis; cat.enabled = emojis.length > 0; // The setState below doesn't re-render the header and we already have the refs for updateVisibility, so... @@ -192,6 +194,10 @@ class EmojiPicker extends React.Component { setTimeout(this.updateVisibility, 0); }; + private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => + [emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)] + .some(x => x?.includes(filter)); + private onEnterFilter = () => { const btn = this.bodyRef.current.querySelector(".mx_EmojiPicker_item"); if (btn) { diff --git a/src/components/views/emojipicker/Preview.tsx b/src/components/views/emojipicker/Preview.tsx index 4d446d7875..02b5669caf 100644 --- a/src/components/views/emojipicker/Preview.tsx +++ b/src/components/views/emojipicker/Preview.tsx @@ -27,11 +27,7 @@ interface IProps { @replaceableComponent("views.emojipicker.Preview") class Preview extends React.PureComponent { render() { - const { - unicode = "", - annotation = "", - shortcodes: [shortcode = ""], - } = this.props.emoji || {}; + const { unicode, annotation, shortcodes: [shortcode] } = this.props.emoji; return (
diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx new file mode 100644 index 0000000000..c0be3b46bb --- /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 a2a88335ac..6861ea7af5 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 61bd428126..95e97f1080 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/emoji.ts b/src/emoji.ts index 7caeb06d21..321eae63f6 100644 --- a/src/emoji.ts +++ b/src/emoji.ts @@ -15,26 +15,23 @@ limitations under the License. */ import EMOJIBASE from 'emojibase-data/en/compact.json'; +import SHORTCODES from 'emojibase-data/en/shortcodes/iamcal.json'; export interface IEmoji { annotation: string; - group: number; + group?: number; hexcode: string; - order: number; + order?: number; shortcodes: string[]; - tags: string[]; + tags?: string[]; unicode: string; + skins?: Omit[]; // Currently unused emoticon?: string; } -interface IEmojiWithFilterString extends IEmoji { - filterString?: string; -} - // The unicode is stored without the variant selector -const UNICODE_TO_EMOJI = new Map(); // not exported as gets for it are handled by getEmojiFromUnicode -export const EMOTICON_TO_EMOJI = new Map(); -export const SHORTCODE_TO_EMOJI = new Map(); +const UNICODE_TO_EMOJI = new Map(); // not exported as gets for it are handled by getEmojiFromUnicode +export const EMOTICON_TO_EMOJI = new Map(); export const getEmojiFromUnicode = unicode => UNICODE_TO_EMOJI.get(stripVariation(unicode)); @@ -62,17 +59,23 @@ export const DATA_BY_CATEGORY = { "flags": [], }; -const ZERO_WIDTH_JOINER = "\u200D"; - // Store various mappings from unicode/emoticon/shortcode to the Emoji objects -EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => { +export const EMOJI: IEmoji[] = EMOJIBASE.map((emojiData: Omit) => { + // If there's ever a gap in shortcode coverage, we fudge it by + // filling it in with the emoji's CLDR annotation + const shortcodeData = SHORTCODES[emojiData.hexcode] ?? + [emojiData.annotation.toLowerCase().replace(/ /g, "_")]; + + const emoji: IEmoji = { + ...emojiData, + // Homogenize shortcodes by ensuring that everything is an array + shortcodes: typeof shortcodeData === "string" ? [shortcodeData] : shortcodeData, + }; + const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group]; if (DATA_BY_CATEGORY.hasOwnProperty(categoryId)) { DATA_BY_CATEGORY[categoryId].push(emoji); } - // This is used as the string to match the query against when filtering emojis - emoji.filterString = (`${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}\n` + - `${emoji.unicode.split(ZERO_WIDTH_JOINER).join("\n")}`).toLowerCase(); // Add mapping from unicode to Emoji object // The 'unicode' field that we use in emojibase has either @@ -88,12 +91,7 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => { EMOTICON_TO_EMOJI.set(emoji.emoticon, emoji); } - if (emoji.shortcodes) { - // Add mapping from each shortcode to Emoji object - emoji.shortcodes.forEach(shortcode => { - SHORTCODE_TO_EMOJI.set(shortcode, emoji); - }); - } + return emoji; }); /** @@ -107,5 +105,3 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => { function stripVariation(str) { return str.replace(/[\uFE00-\uFE0F]$/, ""); } - -export const EMOJI: IEmoji[] = EMOJIBASE; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 35b8e3b0a8..2a901c564e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -540,22 +540,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.", @@ -1858,6 +1844,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 849e546485..e2af1c7464 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 0000000000..5719d92902 --- /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); + }); +}); diff --git a/yarn.lock b/yarn.lock index 228550a57a..75f1440d2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3027,15 +3027,15 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== -emojibase-data@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-5.1.1.tgz#0a0d63dd07ce1376b3d27642f28cafa46f651de6" - integrity sha512-za/ma5SfogHjwUmGFnDbTvSfm8GGFvFaPS27GPti16YZSp5EPgz+UDsZCATXvJGit+oRNBbG/FtybXHKi2UQgQ== +emojibase-data@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-6.2.0.tgz#db6c75c36905284fa623f4aa5f468d2be6ed364a" + integrity sha512-SWKaXD2QeQs06IE7qfJftsI5924Dqzp+V9xaa5RzZIEWhmlrG6Jt2iKwfgOPHu+5S8MEtOI7GdpKsXj46chXOw== -emojibase-regex@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-4.1.1.tgz#6e781aca520281600fe7a177f1582c33cf1fc545" - integrity sha512-KSigB1zQkNKFygLZ5bAfHs87LJa1ni8QTQtq8lc53Y74NF3Dk2r7kfa8MpooTO8JBb5Xz660X4tSjDB+I+7elA== +emojibase-regex@^5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-5.1.3.tgz#f0ef621ed6ec624becd2326f999fd4ea01b94554" + integrity sha512-gT8T9LxLA8VJdI+8KQtyykB9qKzd7WuUL3M2yw6y9tplFeufOUANg3UKVaKUvkMcRNvZsSElWhxcJrx8WPE12g== encoding@^0.1.11: version "0.1.13"