diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index afe7901d2f..9ced0e4eb3 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -539,6 +539,7 @@ The core, and by default whitelisted, plugins are:: converse-dragresize converse-fullscreen converse-headline + converse-jingle converse-mam converse-minimize converse-muc diff --git a/docs/source/other_frameworks.rst b/docs/source/other_frameworks.rst index 25ac82a038..ce9834dcfb 100644 --- a/docs/source/other_frameworks.rst +++ b/docs/source/other_frameworks.rst @@ -60,6 +60,7 @@ Below is an example code that wraps converse.js as an angular.js service. "converse-vcard", // XEP-0054 VCard-temp "converse-register", // XEP-0077 In-band registration "converse-ping", // XEP-0199 XMPP Ping + "converse-jingle", // XEP-0166 Support for the Jingle Protocol "converse-notification", // HTML5 Notifications "converse-minimize", // Allows chatboxes to be minimized "converse-dragresize", // Allows chatboxes to be resized by dragging them diff --git a/karma.conf.js b/karma.conf.js index 8166e3ae96..51667be931 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -64,6 +64,9 @@ module.exports = function(config) { { pattern: "src/plugins/controlbox/tests/controlbox.js", type: 'module' }, { pattern: "src/plugins/controlbox/tests/login.js", type: 'module' }, { pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' }, + { pattern: "src/plugins/jingle/tests/ui.js", type: 'module' }, + { pattern: "src/plugins/jingle/tests/message-initiation.js", type: 'module' }, + { pattern: "src/plugins/jingle/tests/jingle-chat-history.js", type: 'module' }, { pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' }, { pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' }, { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' }, diff --git a/src/converse.js b/src/converse.js index 42eed5b8b6..39eb802e5e 100644 --- a/src/converse.js +++ b/src/converse.js @@ -23,6 +23,7 @@ import "./plugins/controlbox/index.js"; // The control box import "./plugins/dragresize/index.js"; // Allows chat boxes to be resized by dragging them import "./plugins/fullscreen/index.js"; import "./plugins/headlines-view/index.js"; +import "./plugins/jingle/index.js" // Implements the jingle protocol import "./plugins/mam-views/index.js"; import "./plugins/minimize/index.js"; // Allows chat boxes to be minimized import "./plugins/muc-views/index.js"; // Views related to MUC diff --git a/src/headless/plugins/chat/model.js b/src/headless/plugins/chat/model.js index cdbcaf3144..cf11f2e2d3 100644 --- a/src/headless/plugins/chat/model.js +++ b/src/headless/plugins/chat/model.js @@ -228,6 +228,10 @@ const ChatBox = ModelWithContact.extend({ !this.handleChatMarker(attrs) && !(await this.handleRetraction(attrs)) ) { + const { handled } = await api.hook('onMessage', this, { handled: false, attrs }); + debugger + if (handled) return; + this.setEditable(attrs, attrs.time); if (attrs['chat_state'] && attrs.sender === 'them') { @@ -583,7 +587,7 @@ const ChatBox = ModelWithContact.extend({ if (attrs.is_tombstone) { return false; } - const message = this.messages.findWhere({'origin_id': attrs.retracted_id, 'from': attrs.from}); + const message = this.messages.findWhere({ 'origin_id': attrs.retracted_id, 'from': attrs.from }); if (!message) { attrs['dangling_retraction'] = true; await this.createMessage(attrs); diff --git a/src/headless/plugins/chat/utils.js b/src/headless/plugins/chat/utils.js index 0f865cee50..3b17fd5ad9 100644 --- a/src/headless/plugins/chat/utils.js +++ b/src/headless/plugins/chat/utils.js @@ -106,7 +106,7 @@ export function registerMessageHandlers () { /** * Handler method for all incoming single-user chat "message" stanzas. - * @param { MessageAttributes } attrs - The message attributes + * @param { XMLElement } stanza - The message stanza */ export async function handleMessageStanza (stanza) { if (isServerMessage(stanza)) { @@ -125,8 +125,8 @@ export async function handleMessageStanza (stanza) { return log.error(attrs.message); } // XXX: Need to take XEP-428 into consideration - const has_body = !!(attrs.body || attrs.plaintext) - const chatbox = await api.chats.get(attrs.contact_jid, { 'nickname': attrs.nick }, has_body); + const should_create = !!(attrs.body || attrs.plaintext || attrs.jingle_propose) + const chatbox = await api.chats.get(attrs.contact_jid, { 'nickname': attrs.nick }, should_create); await chatbox?.queueMessage(attrs); /** * @typedef { Object } MessageData diff --git a/src/headless/utils/core.js b/src/headless/utils/core.js index ea172daf79..f12b7ad8c3 100644 --- a/src/headless/utils/core.js +++ b/src/headless/utils/core.js @@ -24,7 +24,7 @@ export function isEmptyMessage (attrs) { return !attrs['oob_url'] && !attrs['file'] && !(attrs['is_encrypted'] && attrs['plaintext']) && - !attrs['message']; + !attrs['message'] && !attrs['jingle_propose']; } /* We distinguish between UniView and MultiView instances. diff --git a/src/plugins/chatview/heading.js b/src/plugins/chatview/heading.js index a58ee828ab..e81884bc1b 100644 --- a/src/plugins/chatview/heading.js +++ b/src/plugins/chatview/heading.js @@ -17,6 +17,7 @@ export default class ChatHeading extends CustomElement { initialize () { this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model, 'change:jingle_status', this.requestUpdate); this.listenTo(this.model, 'change:status', this.requestUpdate); this.listenTo(this.model, 'vcard:add', this.requestUpdate); this.listenTo(this.model, 'vcard:change', this.requestUpdate); diff --git a/src/plugins/chatview/styles/chat-head.scss b/src/plugins/chatview/styles/chat-head.scss index f6522a1112..5a67634481 100644 --- a/src/plugins/chatview/styles/chat-head.scss +++ b/src/plugins/chatview/styles/chat-head.scss @@ -64,6 +64,10 @@ flex-wrap: nowrap; padding: 0; } + + .chatbox-call-status { + width: 80%; + } a, a:visited, a:hover, a:not([href]):not([tabindex]) { &.chatbox-btn { diff --git a/src/plugins/chatview/templates/chat-head.js b/src/plugins/chatview/templates/chat-head.js index bdd5561c79..7ba8a3faaf 100644 --- a/src/plugins/chatview/templates/chat-head.js +++ b/src/plugins/chatview/templates/chat-head.js @@ -41,6 +41,7 @@ export default (o) => {
${ (o.type !== _converse.HEADLINES_TYPE) ? html`${ display_name }` : display_name }
+
${ until(tpl_dropdown_btns(), '') } diff --git a/src/plugins/chatview/tests/chatbox.js b/src/plugins/chatview/tests/chatbox.js index c1ce92c3bf..28e26e9d5d 100644 --- a/src/plugins/chatview/tests/chatbox.js +++ b/src/plugins/chatview/tests/chatbox.js @@ -297,7 +297,7 @@ describe("Chatboxes", function () { it("can contain a button for starting a call", - mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + mock.initConverse(['chatBoxesFetched'], { blacklisted_plugins: ['converse-jingle']}, async function (_converse) { const { api } = _converse; await mock.waitForRoster(_converse, 'current'); diff --git a/src/plugins/jingle/chat-header-notification.js b/src/plugins/jingle/chat-header-notification.js new file mode 100644 index 0000000000..e310f21f26 --- /dev/null +++ b/src/plugins/jingle/chat-header-notification.js @@ -0,0 +1,42 @@ +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api } from "@converse/headless/core"; +import tpl_header_button from "./templates/header-button.js"; +import { JINGLE_CALL_STATUS } from "./constants.js"; +import { retractCall, finishCall } from './utils.js'; + + +import './styles/jingle.scss'; + +export default class CallNotification extends CustomElement { + + static get properties() { + return { + 'jid': { type: String }, + } + } + + initialize() { + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model, 'change:jingle_status', () => this.requestUpdate()); + } + + render() { + return tpl_header_button(this); + } + + endCall() { + const jingle_status = this.model.get('jingle_status'); + if ( jingle_status === JINGLE_CALL_STATUS.OUTGOING_PENDING ) { + this.model.save('jingle_status', JINGLE_CALL_STATUS.ENDED); + retractCall(this); + return; + } + if ( jingle_status === JINGLE_CALL_STATUS.ACTIVE) { + this.model.save('jingle_status', JINGLE_CALL_STATUS.ENDED); + finishCall(this); + return; + } + } +} + +api.elements.define('converse-call-notification', CallNotification); diff --git a/src/plugins/jingle/constants.js b/src/plugins/jingle/constants.js new file mode 100644 index 0000000000..6595a0fc5b --- /dev/null +++ b/src/plugins/jingle/constants.js @@ -0,0 +1,6 @@ +export const JINGLE_CALL_STATUS = { + INCOMING_PENDING: 0, + OUTGOING_PENDING: 1, + ACTIVE: 2, + ENDED: 3 +}; diff --git a/src/plugins/jingle/index.js b/src/plugins/jingle/index.js new file mode 100644 index 0000000000..5eb13eb9e7 --- /dev/null +++ b/src/plugins/jingle/index.js @@ -0,0 +1,54 @@ +/** + * @description Converse.js plugin which uses XEP-0353, XEP-0166 & XEP-0167 + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ + +import { _converse, converse, api } from '@converse/headless/core.js'; +import 'plugins/modal/index.js'; +import "./chat-header-notification.js"; +import './toolbar-button.js'; +import { JINGLE_CALL_STATUS } from './constants.js'; +import { html } from "lit"; +import { parseJingleMessage, handleRetraction, getJingleTemplate } from './utils.js'; +import './jingle-message-history.js'; + +const { Strophe } = converse.env; + +Strophe.addNamespace('JINGLE', 'urn:xmpp:jingle:1'); +Strophe.addNamespace('JINGLEMESSAGE', 'urn:xmpp:jingle-message:1'); +Strophe.addNamespace('JINGLERTP', 'urn:xmpp:jingle:apps:rtp:1'); + +converse.plugins.add('converse-jingle', { + /* Plugin dependencies are other plugins which might be + * overridden or relied upon, and therefore need to be loaded before + * this plugin. + * + * If the setting "strict_plugin_dependencies" is set to true, + * an error will be raised if the plugin is not found. By default it's + * false, which means these plugins are only loaded opportunistically. + * + * NB: These plugins need to have already been loaded via require.js. + */ + dependencies: ['converse-chatview'], + + initialize: function () { + /* The initialize function gets called as soon as the plugin is + * loaded by converse.js's plugin machinery. + */ + _converse.JINGLE_CALL_STATUS = JINGLE_CALL_STATUS; + _converse.api.listen.on('getToolbarButtons', (toolbar_el, buttons) => { + if (!this.is_groupchat) { + buttons.push(html` + + + `); + } + + return buttons; + }); + api.listen.on('onMessage', handleRetraction); + api.listen.on('parseMessage', parseJingleMessage); + api.listen.on('getJingleTemplate', getJingleTemplate); + }, +}); diff --git a/src/plugins/jingle/jingle-message-history.js b/src/plugins/jingle/jingle-message-history.js new file mode 100644 index 0000000000..47dcea61c1 --- /dev/null +++ b/src/plugins/jingle/jingle-message-history.js @@ -0,0 +1,30 @@ +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api } from "@converse/headless/core"; +import tpl_jingle_message_history from "./templates/jingle-chat-history.js"; +import { finishCall } from './utils.js'; +import { JINGLE_CALL_STATUS } from "./constants.js"; + +export default class JingleMessageHistory extends CustomElement { + + static properties = { + 'jid': { type: String } + }; + + initialize() { + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model, 'change:jingle_status', () => this.requestUpdate()); + } + + render() { + const jid = this.jid; + return tpl_jingle_message_history(this); + } + + endCall() { + this.model.save('jingle_status', JINGLE_CALL_STATUS.ENDED); + finishCall(this); + return; + } +} + +api.elements.define('converse-jingle-message', JingleMessageHistory); diff --git a/src/plugins/jingle/modal/jingle-incoming-call-modal.js b/src/plugins/jingle/modal/jingle-incoming-call-modal.js new file mode 100644 index 0000000000..a5040579a4 --- /dev/null +++ b/src/plugins/jingle/modal/jingle-incoming-call-modal.js @@ -0,0 +1,18 @@ +import BootstrapModal from "plugins/modal/base.js"; +import tpl_incoming_call from "../templates/incoming-call.js"; + +export default BootstrapModal.extend({ + id: "start-jingle-call-modal", + persistent: true, + + initialize () { + this.items = []; + this.loading_items = false; + + BootstrapModal.prototype.initialize.apply(this, arguments); + }, + + toHTML () { + return tpl_incoming_call(); + } +}); diff --git a/src/plugins/jingle/styles/jingle.scss b/src/plugins/jingle/styles/jingle.scss new file mode 100644 index 0000000000..2566e15f9a --- /dev/null +++ b/src/plugins/jingle/styles/jingle.scss @@ -0,0 +1,9 @@ +.conversejs { + .chatbox { + .chat-head { + .jingle-call-initiated-button{ + color: var(--chat-head-text-color) !important; + } + } + } +} \ No newline at end of file diff --git a/src/plugins/jingle/templates/header-button.js b/src/plugins/jingle/templates/header-button.js new file mode 100644 index 0000000000..062ec35e17 --- /dev/null +++ b/src/plugins/jingle/templates/header-button.js @@ -0,0 +1,26 @@ +import { html } from 'lit'; +import { __ } from 'i18n'; +import { JINGLE_CALL_STATUS } from '../constants'; + +const tpl_active_call = (o) => { + const button = __('End Call'); + return html` +
+ ${ button } +
+ `; +} + + // ${(jingle_status === JINGLE_CALL_STATUS.ACTIVE) ? html`${tpl_active_call(el)}` : html`` } +export default (el) => { + const jingle_status = el.model.get('jingle_status'); + return html` +
+ ${(jingle_status === JINGLE_CALL_STATUS.OUTGOING_PENDING) ? html`Calling...` : '' } +
+
+ ${(jingle_status === JINGLE_CALL_STATUS.OUTGOING_PENDING) ? tpl_active_call(el) : '' } + ${(jingle_status === JINGLE_CALL_STATUS.ENDED) ? html`Call Ended` : '' } +
+ `; +} diff --git a/src/plugins/jingle/templates/incoming-call.js b/src/plugins/jingle/templates/incoming-call.js new file mode 100644 index 0000000000..8b83ff44ef --- /dev/null +++ b/src/plugins/jingle/templates/incoming-call.js @@ -0,0 +1,27 @@ +import { html } from 'lit'; +import { __ } from 'i18n'; + +const modal_close_button = html``; + +export default () => { + const i18n_modal_title = __('Jingle Call'); + return html` + + `; +} diff --git a/src/plugins/jingle/templates/jingle-chat-history.js b/src/plugins/jingle/templates/jingle-chat-history.js new file mode 100644 index 0000000000..81c3caffab --- /dev/null +++ b/src/plugins/jingle/templates/jingle-chat-history.js @@ -0,0 +1,58 @@ +import '../../../shared/avatar/avatar.js'; +import { __ } from 'i18n'; +import { html } from "lit"; + +// const ended_call = __('Call Ended'); +const pending_call = __('Initiated a call with'); +// const finished_call = __('Finished call'); + +function calling(el) { + const i18n_end_call = __('End Call'); + return html` +
+
+ + ${pending_call} + + + + + + + + +
` +} + +// function finishedCall() { +// return html`
+// +// +// +// +// ${finishedCall} +//
` +// } + +// function retractedCall() { +// return html`
+// +// +// +// +// ${ended_call} +//
` +// } + + +export default (el) => { + return calling(el); +} diff --git a/src/plugins/jingle/templates/toolbar-button.js b/src/plugins/jingle/templates/toolbar-button.js new file mode 100644 index 0000000000..1aabb00277 --- /dev/null +++ b/src/plugins/jingle/templates/toolbar-button.js @@ -0,0 +1,22 @@ +import { html } from 'lit'; +import { __ } from "i18n"; +import { JINGLE_CALL_STATUS } from '../constants'; + +export default (el) => { + const call_color = '--chat-toolbar-btn-color'; + const end_call_color = '--chat-toolbar-btn-close-color'; + const jingle_status = el.model.get('jingle_status'); + let button_color, i18n_call; + if (jingle_status === JINGLE_CALL_STATUS.OUTGOING_PENDING || jingle_status === JINGLE_CALL_STATUS.ACTIVE) { + button_color = end_call_color; + i18n_call = __('Stop the call'); + } + else { + button_color = call_color; + i18n_call = __('Start a call'); + } + return html` + ` +} diff --git a/src/plugins/jingle/tests/jingle-chat-history.js b/src/plugins/jingle/tests/jingle-chat-history.js new file mode 100644 index 0000000000..89c6b60be3 --- /dev/null +++ b/src/plugins/jingle/tests/jingle-chat-history.js @@ -0,0 +1,32 @@ +/* global mock */ + +describe("A jingle chat history message", function () { + + it("has been shown in the chat", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const call_button = view.querySelector('converse-jingle-toolbar-button button'); + call_button.click(); + const jingle_chat_history_component = view.querySelector('converse-jingle-message'); + expect(jingle_chat_history_component).not.toBe(undefined); + })); + + fit("has the end call button", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const call_button = view.querySelector('converse-jingle-toolbar-button button'); + call_button.click(); + const chatbox = view.model; + const end_call_button = view.querySelector('converse-jingle-message button .end-call'); + expect(end_call_button).not.toBe(undefined); + expect(chatbox.get('jingle_status')).toBe(_converse.JINGLE_CALL_STATUS.ENDED); + })); +}); diff --git a/src/plugins/jingle/tests/message-initiation.js b/src/plugins/jingle/tests/message-initiation.js new file mode 100644 index 0000000000..f93a45a841 --- /dev/null +++ b/src/plugins/jingle/tests/message-initiation.js @@ -0,0 +1,157 @@ +/*global mock, converse */ +const u = converse.env.utils; +const sizzle = converse.env.sizzle; + +const { Strophe } = converse.env; + +describe("A Jingle Message Initiation Request", function () { + + describe("from the initiator's perspective", function () { + + it("is sent out when one clicks the call button", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const call_button = view.querySelector('converse-jingle-toolbar-button button'); + call_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`propose[xmlns='${Strophe.NS.JINGLEMESSAGE}']`, s).length).pop()); + const propose_id = stanza.querySelector('propose'); + expect(Strophe.serialize(stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + ``+ + ``); + expect(view.model.messages.length).toEqual(1); + })); + + + it("is ended when the initiator clicks the call button again", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + // the first click starts the call, and the other one ends it + const call_button = view.querySelector('converse-jingle-toolbar-button button'); + call_button.click(); + call_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`retract[xmlns='${Strophe.NS.JINGLEMESSAGE}']`, s).length).pop()); + const jingle_retraction_id = stanza.querySelector('retract'); + expect(Strophe.serialize(stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + `Retracted`+ + ``+ + ``+ + ``+ + ``); + // This needs to be fixed + expect(view.model.messages.length).toEqual(1); + })); + + it("is ended when the initiator clicks the end call header button", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + // the first click starts the call, and the other one ends it + const call_button = view.querySelector('converse-jingle-toolbar-button button'); + call_button.click(); + const header_end_call_button = await u.waitUntil(() => view.querySelector('.jingle-call-initiated-button')); + header_end_call_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`retract[xmlns='${Strophe.NS.JINGLEMESSAGE}']`, s).length).pop()); + const jingle_retraction_id = stanza.querySelector('retract'); + expect(Strophe.serialize(stanza)).toBe( + ``+ + ``+ + ``+ + ``+ + `Retracted`+ + ``+ + ``+ + ``+ + ``); + // This needs to be fixed + expect(view.model.messages.length).toEqual(1); + })); + }); + + describe("from the receiver's perspective", function () { + + it("is received when the initiator clicks the call button", mock.initConverse( + ['chatBoxesFetched'], { allow_non_roster_messaging: true }, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g, '.').toLowerCase() + '@montague.lit'; + const propose_id = u.getUniqueId(); + const initiator_stanza = u.toStanza(` + + + + + + `); + _converse.connection._dataRecv(mock.createRequest(initiator_stanza)); + + const view = await u.waitUntil(() => _converse.chatboxviews.get(contact_jid)); + expect(view.model.messages.length).toEqual(1); + })); + + it("is received when the initiator clicks the end call button", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const call_button = view.querySelector('converse-jingle-toolbar-button button'); + call_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`propose[xmlns='${Strophe.NS.JINGLEMESSAGE}']`, s).length).pop()); + const propose_id = stanza.querySelector('propose'); + const initiator_stanza = u.toStanza(` + + + + + Retracted + + + + `); + _converse.connection._dataRecv(mock.createRequest(initiator_stanza)); + expect(view.model.messages.length).toEqual(1); + })); + }); +}); diff --git a/src/plugins/jingle/tests/ui.js b/src/plugins/jingle/tests/ui.js new file mode 100644 index 0000000000..38bdf2ed84 --- /dev/null +++ b/src/plugins/jingle/tests/ui.js @@ -0,0 +1,43 @@ +/* global mock, converse */ +const u = converse.env.utils; + +describe("A Jingle Status", function () { + + it("has been shown in the toolbar", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + spyOn(_converse.api, "trigger").and.callThrough(); + // First check that the button does show + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const toolbar = view.querySelector('.chat-toolbar'); + const call_button = toolbar.querySelector('converse-jingle-toolbar-button button'); + // Now check that the state changes + // toggleJingleCallStatus + const chatbox = view.model; + call_button.click(); + expect(chatbox.get('jingle_status')).toBe(_converse.JINGLE_CALL_STATUS.OUTGOING_PENDING); + })); + + it("has been shown in the chat-header", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + spyOn(_converse.api, "trigger").and.callThrough(); + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const chat_head = view.querySelector('.chatbox-title--row'); + const chatbox = view.model; + chatbox.save('jingle_status', _converse.JINGLE_CALL_STATUS.OUTGOING_PENDING); + const header_notification = chat_head.querySelector('converse-call-notification'); + const call_intialized = await u.waitUntil(() => header_notification.querySelector('.jingle-call-initiated-button')); + call_intialized.click(); + expect(chatbox.get('jingle_status') === _converse.JINGLE_CALL_STATUS.ENDED); + })); + +}); diff --git a/src/plugins/jingle/toolbar-button.js b/src/plugins/jingle/toolbar-button.js new file mode 100644 index 0000000000..80c2a44f54 --- /dev/null +++ b/src/plugins/jingle/toolbar-button.js @@ -0,0 +1,67 @@ +import { CustomElement } from 'shared/components/element.js'; +import { converse, _converse, api } from "@converse/headless/core"; +import { JINGLE_CALL_STATUS } from "./constants.js"; +import tpl_toolbar_button from "./templates/toolbar-button.js"; +import { retractCall, finishCall } from './utils.js'; + +const { Strophe, $msg } = converse.env; +const u = converse.env.utils; + +export default class JingleToolbarButton extends CustomElement { + + static get properties() { + return { + 'jid': { type: String }, + } + } + + initialize() { + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model, 'change:jingle_status', () => this.requestUpdate()); + } + + render() { + return tpl_toolbar_button(this); + } + toggleJingleCallStatus() { + const jingle_status = this.model.get('jingle_status'); + if ( jingle_status === JINGLE_CALL_STATUS.OUTGOING_PENDING) { + this.model.save('jingle_status', JINGLE_CALL_STATUS.ENDED); + retractCall(this); + return; + } + if ( jingle_status === JINGLE_CALL_STATUS.ACTIVE) { + this.model.save('jingle_status', JINGLE_CALL_STATUS.ENDED); + finishCall(this); + return; + } + if (!jingle_status || jingle_status === JINGLE_CALL_STATUS.ENDED) { + const propose_id = u.getUniqueId(); + const message_id = u.getUniqueId(); + this.model.save({ 'jingle_propose_id': propose_id, 'jingle_status': JINGLE_CALL_STATUS.OUTGOING_PENDING }); + api.send( + $msg({ + 'from': _converse.bare_jid, + 'to': this.jid, + 'type': 'chat', + 'id': message_id, + }).c('propose', {'xmlns': Strophe.NS.JINGLEMESSAGE, 'id': propose_id }) + .c('description', {'xmlns': Strophe.NS.JINGLERTP, 'media': 'audio'}).up().up() + .c('store', { 'xmlns': Strophe.NS.HINTS }) + ); + const attrs = { + 'from': _converse.bare_jid, + 'to': this.jid, + 'type': 'chat', + 'msg_id': message_id, + 'jingle_propose_id': propose_id, + 'media': 'audio', + 'template_hook': 'getJingleTemplate', + } + this.model.messages.create(attrs); + return; + } + } +} + +api.elements.define('converse-jingle-toolbar-button', JingleToolbarButton); diff --git a/src/plugins/jingle/utils.js b/src/plugins/jingle/utils.js new file mode 100644 index 0000000000..09579836c0 --- /dev/null +++ b/src/plugins/jingle/utils.js @@ -0,0 +1,137 @@ +import { converse, api, _converse } from '@converse/headless/core'; +import JingleCallModal from "./modal/jingle-incoming-call-modal.js"; +import { html } from 'lit'; + +const { Strophe, sizzle, $msg } = converse.env; +const u = converse.env.utils; + +/** + * This function merges the incoming attributes and the jingle propose attribute into one. + * It also determines the type of the media i.e. audio or video + * @param { XMLElement } stanza + * @param { Object } attrs + */ +export function parseJingleMessage(stanza, attrs) { + if (isAJingleMessage(stanza) === true) { + const jingle_propose_type = getJingleProposeType(stanza); + return { + ...attrs, ...{ + 'jingle_propose': jingle_propose_type, + 'jingle_retraction_id': getJingleRetractionId(stanza), + 'template_hook': 'getJingleTemplate', + 'jingle_status': jingleStatus(stanza) + } + } + } +} + +function isAJingleMessage(stanza) { + if (jingleStatus(stanza) === 'incoming_call' || jingleStatus(stanza) === 'retracted' || jingleStatus(stanza) === 'call_ended') { + return true; + } + else false; +} + +function jingleStatus(stanza) { + const el_propose = sizzle(`propose[xmlns="${Strophe.NS.JINGLEMESSAGE}"]`, stanza).pop(); + const el_retract = sizzle(`retract[xmlns="${Strophe.NS.JINGLEMESSAGE}"]`, stanza).pop(); + const el_finish = sizzle(`finish[xmlns="${Strophe.NS.JINGLEMESSAGE}"]`, stanza).pop(); + if (el_propose) { + return 'incoming_call'; + } + else if (el_retract) { + return 'retracted'; + } + else if (el_finish) { + return 'call_ended'; + } +} + +function getJingleProposeType(stanza){ + const el = sizzle(`propose[xmlns="${Strophe.NS.JINGLEMESSAGE}"] > description`, stanza).pop(); + return el?.getAttribute('media'); +} + +function getJingleRetractionId(stanza){ + const el = sizzle(`retract[xmlns="${Strophe.NS.JINGLEMESSAGE}"]`, stanza).pop(); + return el?.getAttribute('id'); +} + +export function getJingleTemplate(model) { + return html``; +} + +export function jingleCallInitialized() { + JingleCallModal; +} + +/** + * This function simply sends the retraction stanza and modifies the attributes + */ +export function retractCall(el) { + const jingle_propose_id = el.model.get('jingle_propose_id'); + const message_id = u.getUniqueId(); + api.send( + $msg({ + 'from': _converse.bare_jid, + 'to': el.jid, + 'type': 'chat', + id: message_id + }).c('retract', { + 'xmlns': Strophe.NS.JINGLEMESSAGE, 'id': jingle_propose_id }) + .c('reason', { 'xmlns': Strophe.NS.JINGLE }) + .c('cancel', {}).up() + .t('Retracted').up().up() + .c('store', { 'xmlns': Strophe.NS.HINTS }) + ); +} + +/** + * This function simply sends the stanza that ends the call + */ +export function finishCall(el) { + const message_id = u.getUniqueId(); + const finish_id = u.getUniqueId(); + const stanza = $msg({ + 'from': _converse.bare_jid, + 'to': el.jid, + 'type': 'chat' + }).c('finish', {'xmlns': Strophe.NS.JINGLEMESSAGE, 'id': finish_id}) + .c('reason', {'xmlns': Strophe.NS.JINGLE}) + .c('success', {}).up() + .t('Success').up().up() + .c('store', { 'xmlns': Strophe.NS.HINTS }) + const attrs = { + 'from': _converse.bare_jid, + 'to': el.jid, + 'type': 'chat', + 'msg_id': message_id, + 'jingle_status': el.model.get('jingle_status'), + 'template_hook': 'getJingleTemplate' + } + el.model.messages.create(attrs); + api.send(stanza); +} + +/* + * This is the handler for the 'onMessage' hook + * It inspects the incoming message attributes and checks whether we have a jingle retraction message + * if it is, then we find the jingle propose message and update it. + * @param { _converse.ChatBox } model + * @param { } data + */ +export async function handleRetraction(model, data) { + const jingle_retraction_id = data.attrs['jingle_retraction_id']; + if (jingle_retraction_id) { + const message = await model.messages.findWhere({ jingle_propose_id: jingle_retraction_id }); + if (message) { + message.save(data.attrs, { has_been_retracted: 'true' }); + data.handled = true; + } + } + else { + // It is a dangling retraction; we are waiting for the correct propose message + model.createMessage(data.attrs); + } + return data; +} diff --git a/src/shared/constants.js b/src/shared/constants.js index bdbed7dcec..be19bc819b 100644 --- a/src/shared/constants.js +++ b/src/shared/constants.js @@ -11,6 +11,7 @@ export const VIEW_PLUGINS = [ 'converse-dragresize', 'converse-fullscreen', 'converse-headlines-view', + 'converse-jingle', 'converse-mam-views', 'converse-minimize', 'converse-modal', diff --git a/src/shared/styles/themes/classic.scss b/src/shared/styles/themes/classic.scss index 69eceb443d..d2bc2f972c 100644 --- a/src/shared/styles/themes/classic.scss +++ b/src/shared/styles/themes/classic.scss @@ -73,6 +73,7 @@ --chat-head-color: var(--green); --chat-head-text-color: white; --chat-toolbar-btn-color: var(--green); + --chat-toolbar-btn-close-color: var(--dark-red); --chat-toolbar-btn-disabled-color: gray; --toolbar-btn-text-color: white;