diff options
Diffstat (limited to 'roles/reverseproxy/files/conversejs/src/plugins')
287 files changed, 40935 insertions, 0 deletions
diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js new file mode 100644 index 0000000..72b52e9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/adhoc-commands.js @@ -0,0 +1,190 @@ +import 'shared/autocomplete/index.js'; +import log from '@converse/headless/log'; +import tplAdhoc from './templates/ad-hoc.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { api, converse } from '@converse/headless/core.js'; +import { getNameAndValue } from 'utils/html.js'; + +const { Strophe, sizzle } = converse.env; + + +export default class AdHocCommands extends CustomElement { + static get properties () { + return { + 'alert': { type: String }, + 'alert_type': { type: String }, + 'commands': { type: Array }, + 'fetching': { type: Boolean }, + 'showform': { type: String }, + 'view': { type: String }, + }; + } + + constructor () { + super(); + this.view = 'choose-service'; + this.fetching = false; + this.showform = ''; + this.commands = []; + } + + render () { + return tplAdhoc(this) + } + + async fetchCommands (ev) { + ev.preventDefault(); + delete this.alert_type; + delete this.alert; + + this.fetching = true; + + const form_data = new FormData(ev.target); + const jid = form_data.get('jid').trim(); + let supported; + try { + supported = await api.disco.supports(Strophe.NS.ADHOC, jid); + } catch (e) { + log.error(e); + } finally { + this.fetching = false; + } + + if (supported) { + try { + this.commands = await api.adhoc.getCommands(jid); + this.view = 'list-commands'; + } catch (e) { + log.error(e); + this.alert_type = 'danger'; + this.alert = __('Sorry, an error occurred while looking for commands on that entity.'); + this.commands = []; + log.error(e); + return; + } + } else { + this.alert_type = 'danger'; + this.alert = __("The specified entity doesn't support ad-hoc commands"); + } + } + + async toggleCommandForm (ev) { + ev.preventDefault(); + const node = ev.target.getAttribute('data-command-node'); + const cmd = this.commands.filter(c => c.node === node)[0]; + if (this.showform === node) { + this.showform = ''; + this.requestUpdate(); + } else { + const form = await api.adhoc.fetchCommandForm(cmd); + cmd.sessionid = form.sessionid; + cmd.instructions = form.instructions; + cmd.fields = form.fields; + cmd.actions = form.actions; + this.showform = node; + } + } + + executeAction (ev) { + ev.preventDefault(); + + const action = ev.target.getAttribute('data-action'); + + if (['execute', 'next', 'prev', 'complete'].includes(action)) { + this.runCommand(ev.target.form, action); + } else { + log.error(`Unknown action: ${action}`); + } + } + + clearCommand (cmd) { + delete cmd.alert; + delete cmd.instructions; + delete cmd.sessionid; + delete cmd.alert_type; + cmd.fields = []; + cmd.acions = []; + this.showform = ''; + } + + async runCommand (form, action) { + const form_data = new FormData(form); + const jid = form_data.get('command_jid').trim(); + const node = form_data.get('command_node').trim(); + + const cmd = this.commands.filter(c => c.node === node)[0]; + delete cmd.alert; + this.requestUpdate(); + + const inputs = action === 'prev' ? [] : + sizzle(':input:not([type=button]):not([type=submit])', form) + .filter(i => !['command_jid', 'command_node'].includes(i.getAttribute('name'))) + .map(getNameAndValue) + .filter(n => n); + + const response = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, action, inputs); + + const { fields, status, note, instructions, actions } = response; + + if (status === 'error') { + cmd.alert_type = 'danger'; + cmd.alert = __( + 'Sorry, an error occurred while trying to execute the command. See the developer console for details' + ); + return this.requestUpdate(); + } + + if (status === 'executing') { + cmd.alert = __('Executing'); + cmd.fields = fields; + cmd.instructions = instructions; + cmd.alert_type = 'primary'; + cmd.actions = actions; + } else if (status === 'completed') { + this.alert_type = 'primary'; + this.alert = __('Completed'); + this.note = note; + this.clearCommand(cmd); + } else { + log.error(`Unexpected status for ad-hoc command: ${status}`); + cmd.alert = __('Completed'); + cmd.alert_type = 'primary'; + } + this.requestUpdate(); + } + + async cancel (ev) { + ev.preventDefault(); + this.showform = ''; + this.requestUpdate(); + + const form_data = new FormData(ev.target.form); + const jid = form_data.get('command_jid').trim(); + const node = form_data.get('command_node').trim(); + + const cmd = this.commands.filter(c => c.node === node)[0]; + delete cmd.alert; + this.requestUpdate(); + + const { status } = await api.adhoc.runCommand(jid, cmd.sessionid, cmd.node, 'cancel', []); + + if (status === 'error') { + cmd.alert_type = 'danger'; + cmd.alert = __( + 'An error occurred while trying to cancel the command. See the developer console for details' + ); + } else if (status === 'canceled') { + this.alert_type = ''; + this.alert = ''; + this.clearCommand(cmd); + } else { + log.error(`Unexpected status for ad-hoc command: ${status}`); + cmd.alert = __('Error: unexpected result'); + cmd.alert_type = 'danger'; + } + this.requestUpdate(); + } +} + +api.elements.define('converse-adhoc-commands', AdHocCommands); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js new file mode 100644 index 0000000..edb7491 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/index.js @@ -0,0 +1,23 @@ +/** + * @description + * Converse.js plugin which provides the UI for XEP-0050 Ad-Hoc commands + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './adhoc-commands.js'; +import { api, converse } from "@converse/headless/core.js"; + + +converse.plugins.add('converse-adhoc-views', { + + dependencies: [ + "converse-controlbox", + "converse-muc", + ], + + initialize () { + api.settings.extend({ + 'allow_adhoc_commands': true, + }); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js new file mode 100644 index 0000000..4b4de2a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command-form.js @@ -0,0 +1,43 @@ +import { __ } from 'i18n'; +import { html } from "lit"; + + +const action_map = { + execute: __('Execute'), + prev: __('Previous'), + next: __('Next'), + complete: __('Complete'), +} + +export default (el, command) => { + const i18n_cancel = __('Cancel'); + + return html` + <span> <!-- Don't remove this <span>, + this is a workaround for a lit bug where a <form> cannot be removed + if it contains an <input> with name "remove" --> + <form> + ${ command.alert ? html`<div class="alert alert-${command.alert_type}" role="alert">${command.alert}</div>` : '' } + <fieldset class="form-group"> + <input type="hidden" name="command_node" value="${command.node}"/> + <input type="hidden" name="command_jid" value="${command.jid}"/> + + <p class="form-instructions">${command.instructions}</p> + ${ command.fields } + </fieldset> + <fieldset> + ${ command.actions.map((action) => + html`<input data-action="${action}" + @click=${(ev) => el.executeAction(ev)} + type="button" + class="btn btn-primary" + value="${action_map[action]}">`) + }<input type="button" + class="btn btn-secondary button-cancel" + value="${i18n_cancel}" + @click=${(ev) => el.cancel(ev)}> + </fieldset> + </form> + </span> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js new file mode 100644 index 0000000..78f0b2b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc-command.js @@ -0,0 +1,17 @@ +import { html } from "lit"; +import tplCommandForm from './ad-hoc-command-form.js'; + +export default (el, command) => html` + <li class="room-item list-group-item"> + <div class="available-chatroom d-flex flex-row"> + <a class="open-room available-room w-100" + @click=${(ev) => el.toggleCommandForm(ev)} + data-command-node="${command.node}" + data-command-jid="${command.jid}" + data-command-name="${command.name}" + title="${command.name}" + href="#">${command.name || command.jid}</a> + </div> + ${ command.node === el.showform ? tplCommandForm(el, command) : '' } + </li> +`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js new file mode 100644 index 0000000..b85d214 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/templates/ad-hoc.js @@ -0,0 +1,47 @@ +import tplCommand from './ad-hoc-command.js'; +import tplSpinner from 'templates/spinner.js'; +import { __ } from 'i18n'; +import { getAutoCompleteList } from 'plugins/muc-views/utils.js'; +import { html } from "lit"; + + +export default (el) => { + const i18n_choose_service = __('On which entity do you want to run commands?'); + const i18n_choose_service_instructions = __( + 'Certain XMPP services and entities allow privileged users to execute ad-hoc commands on them.'); + const i18n_commands_found = __('Commands found'); + const i18n_fetch_commands = __('List available commands'); + const i18n_jid_placeholder = __('XMPP Address'); + const i18n_no_commands_found = __('No commands found'); + return html` + ${ el.alert ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert}</div>` : '' } + ${ el.note ? html`<p class="form-help">${el.note}</p>` : '' } + + <form class="converse-form" @submit=${el.fetchCommands}> + <fieldset class="form-group"> + <label> + ${i18n_choose_service} + <p class="form-help">${i18n_choose_service_instructions}</p> + <converse-autocomplete + .getAutoCompleteList="${getAutoCompleteList}" + required + placeholder="${i18n_jid_placeholder}" + name="jid"> + </converse-autocomplete> + </label> + </fieldset> + <fieldset class="form-group"> + ${ el.fetching ? tplSpinner() : html`<input type="submit" class="btn btn-primary" value="${i18n_fetch_commands}">` } + </fieldset> + ${ el.view === 'list-commands' ? html` + <fieldset class="form-group"> + <ul class="list-group"> + <li class="list-group-item active">${ el.commands.length ? i18n_commands_found : i18n_no_commands_found }:</li> + ${ el.commands.map(cmd => tplCommand(el, cmd)) } + </ul> + </fieldset>` + : '' } + + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js new file mode 100644 index 0000000..9e39fda --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/adhoc-views/tests/adhoc.js @@ -0,0 +1,565 @@ +/*global mock, converse */ + +const { Strophe, sizzle, u, stx } = converse.env; + +describe("Ad-hoc commands", function () { + + it("can be queried for via a modal", mock.initConverse([], {}, async (_converse) => { + const { api } = _converse; + const entity_jid = 'muc.montague.lit'; + const { IQ_stanzas } = _converse.connection; + + const modal = await api.modal.show('converse-user-settings-modal'); + await u.waitUntil(() => u.isVisible(modal)); + modal.querySelector('#commands-tab').click(); + + const adhoc_form = modal.querySelector('converse-adhoc-commands'); + await u.waitUntil(() => u.isVisible(adhoc_form)); + + adhoc_form.querySelector('input[name="jid"]').value = entity_jid; + adhoc_form.querySelector('input[type="submit"]').click(); + + await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info'); + + let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`; + let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq type="result" + id="${iq.getAttribute("id")}" + to="${_converse.jid}" + from="${entity_jid}" + xmlns="jabber:client"> + <query xmlns="http://jabber.org/protocol/disco#items" + node="http://jabber.org/protocol/commands"> + <item jid="${entity_jid}" + node="list" + name="List Service Configurations"/> + <item jid="${entity_jid}" + node="config" + name="Configure Service"/> + <item jid="${entity_jid}" + node="reset" + name="Reset Service Configuration"/> + <item jid="${entity_jid}" + node="start" + name="Start Service"/> + <item jid="${entity_jid}" + node="stop" + name="Stop Service"/> + <item jid="${entity_jid}" + node="restart" + name="Restart Service"/> + <item jid="${entity_jid}" + node="adduser" + name="Add User"/> + </query> + </iq>`)); + + const heading = await u.waitUntil(() => adhoc_form.querySelector('.list-group-item.active')); + expect(heading.textContent).toBe('Commands found:'); + + const items = adhoc_form.querySelectorAll('.list-group-item:not(.active)'); + expect(items.length).toBe(7); + expect(items[0].textContent.trim()).toBe('List Service Configurations'); + expect(items[1].textContent.trim()).toBe('Configure Service'); + expect(items[2].textContent.trim()).toBe('Reset Service Configuration'); + expect(items[3].textContent.trim()).toBe('Start Service'); + expect(items[4].textContent.trim()).toBe('Stop Service'); + expect(items[5].textContent.trim()).toBe('Restart Service'); + expect(items[6].textContent.trim()).toBe('Add User'); + + items[6].querySelector('a').click(); + + sel = `iq[to="${entity_jid}"][type="set"] command`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute("id")}" to="${entity_jid}" type="set" xmlns="jabber:client">`+ + `<command action="execute" node="adduser" xmlns="http://jabber.org/protocol/commands"/>`+ + `</iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq to="${_converse.jid}" xmlns="jabber:client" type="result" xml:lang="en" id="${iq.getAttribute('id')}" from="${entity_jid}"> + <command status="executing" node="adduser" sessionid="1653988890.6236324-886f3dc54ce443c6b4a1805877bf7faa" xmlns="http://jabber.org/protocol/commands"> + <actions> + <complete /> + </actions> + <x type="form" xmlns="jabber:x:data"> + <title>Title</title> + <instructions>Instructions</instructions> + <field type="boolean" label="Remove my registration" var="remove"> + <value>0</value> + <required /> + </field> + <field type="text-single" label="User name" var="username"> + <value>romeo</value> + <required /> + </field> + <field type="text-single" label="Password" var="password"> + <value>secret</value> + <required /> + </field> + </x> + </command> + </iq>`)); + + const form = await u.waitUntil(() => adhoc_form.querySelector('form form')); + expect(u.isVisible(form)).toBe(true); + const inputs = form.querySelectorAll('input'); + expect(inputs.length).toBe(7); + expect(inputs[0].getAttribute('name')).toBe('command_node'); + expect(inputs[0].getAttribute('type')).toBe('hidden'); + expect(inputs[0].getAttribute('value')).toBe('adduser'); + expect(inputs[1].getAttribute('name')).toBe('command_jid'); + expect(inputs[0].getAttribute('type')).toBe('hidden'); + expect(inputs[1].getAttribute('value')).toBe('muc.montague.lit'); + expect(inputs[2].getAttribute('name')).toBe('remove'); + expect(inputs[2].getAttribute('type')).toBe('checkbox'); + expect(inputs[3].getAttribute('name')).toBe('username'); + expect(inputs[3].getAttribute('type')).toBe('text'); + expect(inputs[3].getAttribute('value')).toBe('romeo'); + expect(inputs[4].getAttribute('name')).toBe('password'); + expect(inputs[4].getAttribute('type')).toBe('password'); + expect(inputs[4].getAttribute('value')).toBe('secret'); + expect(inputs[5].getAttribute('type')).toBe('button'); + expect(inputs[5].getAttribute('value')).toBe('Complete'); + expect(inputs[6].getAttribute('type')).toBe('button'); + expect(inputs[6].getAttribute('value')).toBe('Cancel'); + + inputs[6].click(); + await u.waitUntil(() => !u.isVisible(form)); + })); +}); + +describe("Ad-hoc commands consisting of multiple steps", function () { + + beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); + + it("can be queried and executed via a modal", mock.initConverse([], {}, async (_converse) => { + const { api } = _converse; + const entity_jid = 'montague.lit'; + const { IQ_stanzas } = _converse.connection; + + const modal = await api.modal.show('converse-user-settings-modal'); + await u.waitUntil(() => u.isVisible(modal)); + modal.querySelector('#commands-tab').click(); + + const adhoc_form = modal.querySelector('converse-adhoc-commands'); + await u.waitUntil(() => u.isVisible(adhoc_form)); + + adhoc_form.querySelector('input[name="jid"]').value = entity_jid; + adhoc_form.querySelector('input[type="submit"]').click(); + + await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info'); + + let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`; + let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq from="${_converse.jid}" id="${iq.getAttribute('id')}" to="${entity_jid}" type="get" xmlns="jabber:client"> + <query node="http://jabber.org/protocol/commands" xmlns="http://jabber.org/protocol/disco#items"/> + </iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands"> + <item node="uptime" name="Get uptime" jid="${entity_jid}"/> + <item node="urn:xmpp:mam#configure" name="Archive settings" jid="${entity_jid}"/> + <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#form" name="Dataforms Demo" jid="${entity_jid}"/> + <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command demo" jid="${entity_jid}"/> + </query> + </iq> + `)); + + const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]')); + item.click(); + + sel = `iq[to="${entity_jid}"] command`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq id="${iq.getAttribute('id')}" to="${entity_jid}" type="set" xmlns="jabber:client"> + <command action="execute" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" xmlns="http://jabber.org/protocol/commands"/> + </iq>` + ); + + const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99ad"; + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + <actions> + <next/> + <complete/> + </actions> + + <x xmlns="jabber:x:data" type="form"> + <title>Step 1</title> + <instructions>Here's a form.</instructions> + <field label="text-private-label" type="text-private" var="text-private-field"> + <value>text-private-value</value> + </field> + <field label="jid-multi-label" type="jid-multi" var="jid-multi-field"> + <value>jid@multi/value#1</value> + <value>jid@multi/value#2</value> + </field> + <field label="text-multi-label" type="text-multi" var="text-multi-field"> + <value>text</value> + <value>multi</value> + <value>value</value> + </field> + <field label="jid-single-label" type="jid-single" var="jid-single-field"> + <value>jid@single/value</value> + </field> + <field label="list-single-label" type="list-single" var="list-single-field"> + <option label="list-single-value"><value>list-single-value</value></option> + <option label="list-single-value#2"><value>list-single-value#2</value></option> + <option label="list-single-value#3"><value>list-single-value#3</value></option> + <value>list-single-value</value> + </field> + </x> + </command> + </iq> + `)); + + let button = await u.waitUntil(() => modal.querySelector('input[data-action="next"]')); + button.click(); + + sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}"> + <command sessionid="${sessionid}" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" action="next" xmlns="http://jabber.org/protocol/commands"> + <x type="submit" xmlns="jabber:x:data"> + <field var="text-private-field"> + <value>text-private-value</value> + </field> + <field var="jid-multi-field"> + <value>jid@multi/value#1</value> + </field> + <field var="text-multi-field"> + <value>text</value> + </field> + <field var="jid-single-field"> + <value>jid@single/value</value> + </field> + <field var="list-single-field"> + <value>list-single-value</value> + </field> + </x> + </command> + </iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + <actions> + <prev/> + <next/> + <complete/> + </actions> + <x xmlns="jabber:x:data" type="form"> + <title>Step 2</title> + <instructions>Here's another form.</instructions> + <field label="jid-multi-label" type="jid-multi" var="jid-multi-field"> + <value>jid@multi/value#1</value> + <value>jid@multi/value#2</value> + </field> + <field label="boolean-label" type="boolean" var="boolean-field"> + <value>1</value> + </field> + <field label="fixed-label" type="fixed" var="fixed-field#1"> + <value>fixed-value</value> + </field> + <field label="list-single-label" type="list-single" var="list-single-field"> + <option label="list-single-value"> + <value>list-single-value</value> + </option> + <option label="list-single-value#2"> + <value>list-single-value#2</value> + </option> + <option label="list-single-value#3"> + <value>list-single-value#3</value> + </option> + <value>list-single-value</value> + </field> + <field label="text-single-label" type="text-single" var="text-single-field"> + <value>text-single-value</value> + </field> + </x> + </command> + </iq> + `)); + + button = await u.waitUntil(() => modal.querySelector('input[data-action="complete"]')); + button.click(); + + sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"][action="complete"]`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq xmlns="jabber:client" + type="set" + to="${entity_jid}" + id="${iq.getAttribute('id')}"> + + <command xmlns="http://jabber.org/protocol/commands" + sessionid="${sessionid}" + node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" + action="complete"> + <x xmlns="jabber:x:data" + type="submit"> + <field var="text-private-field"> + <value>text-private-value</value></field> + <field var="jid-multi-field"><value>jid@multi/value#1</value></field> + <field var="text-multi-field"><value>text</value></field> + <field var="jid-single-field"><value>jid@single/value</value></field> + <field var="list-single-field"><value>list-single-value</value></field> + </x> + </command> + </iq>` + ); + + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:server" type="result" from="${entity_jid}" to="${_converse.jid}" id="${iq.getAttribute("id")}"> + <command xmlns="http://jabber.org/protocol/commands" + sessionid="${sessionid}" + node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" + status="completed"> + <note type="info">Service has been configured.</note> + </command> + </iq>`) + ); + })); + + it("can be canceled", mock.initConverse([], {}, async (_converse) => { + const { api } = _converse; + const entity_jid = 'montague.lit'; + const { IQ_stanzas } = _converse.connection; + + const modal = await api.modal.show('converse-user-settings-modal'); + await u.waitUntil(() => u.isVisible(modal)); + modal.querySelector('#commands-tab').click(); + + const adhoc_form = modal.querySelector('converse-adhoc-commands'); + await u.waitUntil(() => u.isVisible(adhoc_form)); + + adhoc_form.querySelector('input[name="jid"]').value = entity_jid; + adhoc_form.querySelector('input[type="submit"]').click(); + + await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info'); + + let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`; + let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands"> + <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command" jid="${entity_jid}"/> + </query> + </iq> + `)); + + const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]')); + item.click(); + + sel = `iq[to="${entity_jid}"] command`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99cc"; + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + <actions> + <next/> + <complete/> + </actions> + + <x xmlns="jabber:x:data" type="form"> + <title>Step 1</title> + <instructions>Here's a form.</instructions> + <field label="text-private-label" type="text-private" var="text-private-field"> + <value>text-private-value</value> + </field> + </x> + </command> + </iq> + `)); + + const button = await u.waitUntil(() => modal.querySelector('input.button-cancel')); + button.click(); + + sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}"> + <command sessionid="${sessionid}" + node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" + action="cancel" + xmlns="http://jabber.org/protocol/commands"> + </command> + </iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" + sessionid="${sessionid}" + status="canceled" + node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + </command> + </iq> + `)); + })); + + it("can be navigated backwards", mock.initConverse([], {}, async (_converse) => { + const { api } = _converse; + const entity_jid = 'montague.lit'; + const { IQ_stanzas } = _converse.connection; + + const modal = await api.modal.show('converse-user-settings-modal'); + await u.waitUntil(() => u.isVisible(modal)); + modal.querySelector('#commands-tab').click(); + + const adhoc_form = modal.querySelector('converse-adhoc-commands'); + await u.waitUntil(() => u.isVisible(adhoc_form)); + + adhoc_form.querySelector('input[name="jid"]').value = entity_jid; + adhoc_form.querySelector('input[type="submit"]').click(); + + await mock.waitUntilDiscoConfirmed(_converse, entity_jid, [], ['http://jabber.org/protocol/commands'], [], 'info'); + + let sel = `iq[to="${entity_jid}"] query[xmlns="http://jabber.org/protocol/disco#items"]`; + let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq from="${_converse.jid}" to="${entity_jid}" type="get" xmlns="jabber:client" id="${iq.getAttribute('id')}"> + <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands"/> + </iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <query xmlns="http://jabber.org/protocol/disco#items" node="http://jabber.org/protocol/commands"> + <item node="uptime" name="Get uptime" jid="${entity_jid}"/> + <item node="urn:xmpp:mam#configure" name="Archive settings" jid="${entity_jid}"/> + <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#form" name="Dataforms Demo" jid="${entity_jid}"/> + <item node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" name="Multi-step command demo" jid="${entity_jid}"/> + </query> + </iq> + `)); + + const item = await u.waitUntil(() => adhoc_form.querySelector('form a[data-command-node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"]')); + item.click(); + + sel = `iq[to="${entity_jid}"] command`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq id="${iq.getAttribute('id')}" to="${entity_jid}" type="set" xmlns="jabber:client"> + <command action="execute" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" xmlns="http://jabber.org/protocol/commands"/> + </iq>`); + + const sessionid = "f4d477d3-d8b1-452d-95c9-fece53ef99ad"; + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + <actions> + <next/> + <complete/> + </actions> + + <x xmlns="jabber:x:data" type="form"> + <title>Step 1</title> + <instructions>Here's a form.</instructions> + <field label="text-private-label" type="text-private" var="text-private-field"> + <value>text-private-value</value> + </field> + </x> + </command> + </iq> + `)); + + let button = await u.waitUntil(() => modal.querySelector('input[data-action="next"]')); + button.click(); + + sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"]`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}"> + <command sessionid="${sessionid}" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" action="next" xmlns="http://jabber.org/protocol/commands"> + <x type="submit" xmlns="jabber:x:data"> + <field var="text-private-field"> + <value>text-private-value</value> + </field> + </x> + </command> + </iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + <actions> + <prev/> + <next/> + <complete/> + </actions> + <x xmlns="jabber:x:data" type="form"> + <title>Step 2</title> + <instructions>Here's another form.</instructions> + <field label="jid-multi-label" type="jid-multi" var="jid-multi-field"> + <value>jid@multi/value#1</value> + <value>jid@multi/value#2</value> + </field> + </x> + </command> + </iq> + `)); + + button = await u.waitUntil(() => modal.querySelector('input[data-action="prev"]')); + button.click(); + + sel = `iq[to="${entity_jid}"] command[sessionid="${sessionid}"][action="prev"]`; + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(sel, iq).length).pop()); + + expect(iq).toEqualStanza(stx` + <iq type="set" to="${entity_jid}" xmlns="jabber:client" id="${iq.getAttribute('id')}"> + <command sessionid="${sessionid}" + node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi" + action="prev" + xmlns="http://jabber.org/protocol/commands"> + </command> + </iq>` + ); + + _converse.connection._dataRecv(mock.createRequest(stx` + <iq xmlns="jabber:client" id="${iq.getAttribute('id')}" type="result" from="${entity_jid}" to="${_converse.jid}"> + <command xmlns="http://jabber.org/protocol/commands" sessionid="${sessionid}" status="executing" node="xmpp:zash.se/mod_adhoc_dataforms_demo#multi"> + <actions> + <next/> + <complete/> + </actions> + + <x xmlns="jabber:x:data" type="form"> + <title>Step 1</title> + <instructions>Here's a form.</instructions> + <field label="text-private-label" type="text-private" var="text-private-field"> + <value>text-private-value</value> + </field> + </x> + </command> + </iq> + `)); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js new file mode 100644 index 0000000..a550aa1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmark-form.js @@ -0,0 +1,51 @@ +import tplMUCBookmarkForm from './templates/form.js'; +import { CustomElement } from 'shared/components/element'; +import { _converse, api } from "@converse/headless/core"; + + +class MUCBookmarkForm extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + willUpdate (changed_properties) { + if (changed_properties.has('jid')) { + this.model = _converse.chatboxes.get(this.jid); + this.bookmark = _converse.bookmarks.get(this.jid); + } + } + + render () { + return tplMUCBookmarkForm(this) + } + + onBookmarkFormSubmitted (ev) { + ev.preventDefault(); + _converse.bookmarks.createBookmark({ + 'jid': this.jid, + 'autojoin': ev.target.querySelector('input[name="autojoin"]')?.checked || false, + 'name': ev.target.querySelector('input[name=name]')?.value, + 'nick': ev.target.querySelector('input[name=nick]')?.value + }); + this.closeBookmarkForm(ev); + } + + removeBookmark (ev) { + this.bookmark?.destroy(); + this.closeBookmarkForm(ev); + } + + closeBookmarkForm (ev) { + ev.preventDefault(); + const evt = document.createEvent('Event'); + evt.initEvent('hide.bs.modal', true, true); + this.dispatchEvent(evt); + } +} + +api.elements.define('converse-muc-bookmark-form', MUCBookmarkForm); + +export default MUCBookmarkForm; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js new file mode 100644 index 0000000..8c981d9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/bookmarks-list.js @@ -0,0 +1,48 @@ +import debounce from "lodash-es/debounce"; +import tplBookmarksList from './templates/list.js'; +import tplSpinner from "templates/spinner.js"; +import { CustomElement } from 'shared/components/element.js'; +import { Model } from '@converse/skeletor/src/model.js'; +import { _converse, api } from '@converse/headless/core.js'; +import { initStorage } from '@converse/headless/utils/storage.js'; + +import '../styles/bookmarks.scss'; + + +export default class BookmarksView extends CustomElement { + + async initialize () { + await api.waitUntil('bookmarksInitialized'); + const { bookmarks, chatboxes } = _converse; + + this.liveFilter = debounce((ev) => this.model.set({'filter_text': ev.target.value}), 100); + + this.listenTo(bookmarks, 'add', () => this.requestUpdate()); + this.listenTo(bookmarks, 'remove', () => this.requestUpdate()); + + this.listenTo(chatboxes, 'add', () => this.requestUpdate()); + this.listenTo(chatboxes, 'remove', () => this.requestUpdate()); + + const id = `converse.bookmarks-list-model-${_converse.bare_jid}`; + this.model = new Model({ id }); + initStorage(this.model, id); + + this.listenTo(this.model, 'change', () => this.requestUpdate()); + + this.model.fetch({ + 'success': () => this.requestUpdate(), + 'error': () => this.requestUpdate(), + }); + } + + render () { + return _converse.bookmarks && this.model ? tplBookmarksList(this) : tplSpinner(); + } + + clearFilter (ev) { + ev?.stopPropagation?.(); + this.model.set('filter_text', ''); + } +} + +api.elements.define('converse-bookmarks', BookmarksView); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js new file mode 100644 index 0000000..39f2066 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/form.js @@ -0,0 +1,37 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + + +export default (el) => { + const name = el.model.getDisplayName(); + const nick = el.bookmark?.get('nick') ?? el.model.get('nick'); + + const i18n_heading = __('Bookmark for "%1$s"', name); + const i18n_autojoin = __('Would you like this groupchat to be automatically joined upon startup?'); + const i18n_remove = __('Remove'); + const i18n_name = __('The name for this bookmark:'); + const i18n_nick = __('What should your nickname for this groupchat be?'); + const i18n_submit = el.bookmark ? __('Update') : __('Save'); + + return html` + <form class="converse-form chatroom-form" @submit=${(ev) => el.onBookmarkFormSubmitted(ev)}> + <legend>${i18n_heading}</legend> + <fieldset class="form-group"> + <label for="converse_muc_bookmark_name">${i18n_name}</label> + <input class="form-control" type="text" value="${name}" name="name" required="required" id="converse_muc_bookmark_name"/> + </fieldset> + <fieldset class="form-group"> + <label for="converse_muc_bookmark_nick">${i18n_nick}</label> + <input class="form-control" type="text" name="nick" value="${nick || ''}" id="converse_muc_bookmark_nick"/> + </fieldset> + <fieldset class="form-group form-check"> + <input class="form-check-input" id="converse_muc_bookmark_autojoin" type="checkbox" ?checked=${el.bookmark?.get('autojoin')} name="autojoin"/> + <label class="form-check-label" for="converse_muc_bookmark_autojoin">${i18n_autojoin}</label> + </fieldset> + <fieldset class="form-group"> + <input class="btn btn-primary" type="submit" value="${i18n_submit}"> + ${el.bookmark ? html`<input class="btn btn-secondary button-remove" type="button" value="${i18n_remove}" @click=${(ev) => el.removeBookmark(ev)}>` : '' } + </fieldset> + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js new file mode 100644 index 0000000..35cb566 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/item.js @@ -0,0 +1,24 @@ +import { __ } from 'i18n'; +import { html } from "lit"; +import { openRoomViaEvent, removeBookmarkViaEvent } from '../../utils.js'; + +export default (bm) => { + const jid = bm.get('jid'); + const info_remove_bookmark = __('Unbookmark this groupchat'); + const open_title = __('Click to open this groupchat'); + return html` + <div class="list-item room-item available-chatroom d-flex flex-row" data-room-jid="${jid}"> + <a class="list-item-link open-room w-100" data-room-jid="${jid}" + title="${open_title}" + @click=${openRoomViaEvent}>${bm.getDisplayName()}</a> + + <a class="list-item-action remove-bookmark align-self-center ${ bm.get('bookmarked') ? 'button-on' : '' }" + data-room-jid="${jid}" + data-bookmark-name="${bm.getDisplayName()}" + title="${info_remove_bookmark}" + @click=${removeBookmarkViaEvent}> + <converse-icon class="fa fa-bookmark" size="1em"></converse-icon> + </a> + </div> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js new file mode 100644 index 0000000..94e42db --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/components/templates/list.js @@ -0,0 +1,35 @@ +import bookmark_item from './item.js'; +import { __ } from 'i18n'; +import { _converse } from '@converse/headless/core.js'; +import { html } from "lit"; + +const filterBookmark = (b, text) => b.get('name')?.includes(text) || b.get('jid')?.includes(text); + +export default (el) => { + const i18n_placeholder = __('Filter'); + const filter_text = el.model.get('filter_text'); + const { bookmarks } = _converse; + const shown_bookmarks = filter_text ? bookmarks.filter(b => filterBookmark(b, filter_text)) : bookmarks; + + return html` + <form class="converse-form bookmarks-filter"> + <div class="btn-group w-100"> + <input + .value=${filter_text ?? ''} + @keydown="${ev => el.liveFilter(ev)}" + class="form-control" + placeholder="${i18n_placeholder}"/> + + <converse-icon size="1em" class="fa fa-times clear-input ${ !filter_text ? 'hidden' : '' }" + @click=${el.clearFilter}> + </converse-icon> + </div> + </form> + + <div class="list-container list-container--bookmarks"> + <div class="items-list bookmarks rooms-list"> + ${ shown_bookmarks.map(bm => bookmark_item(bm)) } + </div> + </div> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js new file mode 100644 index 0000000..b731c9f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/index.js @@ -0,0 +1,49 @@ +/** + * @description Converse.js plugin which adds views for XEP-0048 bookmarks + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './modals/bookmark-list.js'; +import './modals/bookmark-form.js'; +import '@converse/headless/plugins/muc/index.js'; +import BookmarkForm from './components/bookmark-form.js'; +import BookmarksView from './components/bookmarks-list.js'; +import { _converse, api, converse } from '@converse/headless/core'; +import { bookmarkableChatRoomView } from './mixins.js'; +import { getHeadingButtons, removeBookmarkViaEvent, addBookmarkViaEvent } from './utils.js'; + +import './styles/bookmarks.scss'; + + +converse.plugins.add('converse-bookmark-views', { + /* 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. + */ + dependencies: ['converse-chatboxes', 'converse-muc', 'converse-muc-views'], + + initialize () { + // Configuration values for this plugin + // ==================================== + // Refer to docs/source/configuration.rst for explanations of these + // configuration settings. + api.settings.extend({ + hide_open_bookmarks: true + }); + + _converse.removeBookmarkViaEvent = removeBookmarkViaEvent; + _converse.addBookmarkViaEvent = addBookmarkViaEvent; + + Object.assign(_converse.ChatRoomView.prototype, bookmarkableChatRoomView); + + _converse.MUCBookmarkForm = BookmarkForm; + _converse.BookmarksView = BookmarksView; + + api.listen.on('getHeadingButtons', getHeadingButtons); + api.listen.on('chatRoomViewInitialized', view => view.setBookmarkState()); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js new file mode 100644 index 0000000..693b98b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/mixins.js @@ -0,0 +1,38 @@ +import { _converse, api, converse } from '@converse/headless/core'; + +const { u } = converse.env; + +export const bookmarkableChatRoomView = { + /** + * Set whether the groupchat is bookmarked or not. + * @private + */ + setBookmarkState () { + if (_converse.bookmarks !== undefined) { + const models = _converse.bookmarks.where({ 'jid': this.model.get('jid') }); + if (!models.length) { + this.model.save('bookmarked', false); + } else { + this.model.save('bookmarked', true); + } + } + }, + + renderBookmarkForm () { + if (!this.bookmark_form) { + this.bookmark_form = new _converse.MUCBookmarkForm({ + 'model': this.model, + 'chatroomview': this + }); + const container_el = this.querySelector('.chatroom-body'); + container_el.insertAdjacentElement('beforeend', this.bookmark_form.el); + } + u.showElement(this.bookmark_form.el); + }, + + showBookmarkModal(ev) { + ev?.preventDefault(); + const jid = this.model.get('jid'); + api.modal.show('converse-bookmark-form-modal', { jid }, ev); + } +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js new file mode 100644 index 0000000..8505228 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-form.js @@ -0,0 +1,20 @@ +import '../components/bookmark-form.js'; +import BaseModal from "plugins/modal/modal.js"; +import { html } from "lit"; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; + +export default class BookmarkFormModal extends BaseModal { + + renderModal () { + return html` + <converse-muc-bookmark-form class="muc-form-container" jid="${this.jid}"> + </converse-muc-bookmark-form>`; + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Bookmark'); + } +} + +api.elements.define('converse-bookmark-form-modal', BookmarkFormModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js new file mode 100644 index 0000000..ad72a63 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/modals/bookmark-list.js @@ -0,0 +1,18 @@ +import '../components/bookmarks-list.js'; +import BaseModal from "plugins/modal/modal.js"; +import { html } from "lit"; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; + +export default class BookmarkListModal extends BaseModal { + + renderModal () { // eslint-disable-line class-methods-use-this + return html`<converse-bookmarks></converse-bookmarks>`; + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Bookmarks'); + } +} + +api.elements.define('converse-bookmark-list-modal', BookmarkListModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss new file mode 100644 index 0000000..bf701f3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/styles/bookmarks.scss @@ -0,0 +1,32 @@ +.conversejs { + #controlbox { + .bookmarks-toggle, .bookmarks-toggle .fa { + color: var(--groupchats-header-color) !important; + &:hover { + color: var(--chatroom-head-bg-color-dark) !important; + } + } + } +} + +.conversejs.fullscreen { + #controlbox { + #chatrooms { + .bookmarks-list { + dl.rooms-list.bookmarks { + dd.available-chatroom { + a.open-room { + width: 80%; + } + } + } + } + } + } +} + +converse-bookmarks { + .list-item-link { + padding: 0 1em; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js new file mode 100644 index 0000000..afa3a5f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks-list.js @@ -0,0 +1,144 @@ +/* global mock, converse */ + +const { Strophe, u, sizzle, $iq } = converse.env; + +describe("The bookmarks list modal", function () { + + it("shows a list of bookmarks", mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + mock.openControlBox(_converse); + + const controlbox = _converse.chatboxviews.get('controlbox'); + controlbox.querySelector('.show-bookmark-list-modal').click(); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil( + () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()); + + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+ + '<items node="storage:bookmarks"/>'+ + '</pubsub>'+ + '</iq>' + ); + + const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')}) + .c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) + .c('items', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns': 'storage:bookmarks'}) + .c('conference', { + 'name': 'The Play's the Thing', + 'autojoin': 'false', + 'jid': 'theplay@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': '1st Bookmark', + 'autojoin': 'false', + 'jid': 'first@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'autojoin': 'false', + 'jid': 'noname@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': 'Bookmark with a very very long name that will be shortened', + 'autojoin': 'false', + 'jid': 'longname@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': 'Another room', + 'autojoin': 'false', + 'jid': 'another@conference.shakespeare.lit' + }).c('nick').t('JC').up().up(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + const modal = _converse.api.modal.get('converse-bookmark-list-modal'); + await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length); + expect(modal.querySelectorAll('.bookmarks.rooms-list .room-item').length).toBe(5); + let els = modal.querySelectorAll('.bookmarks.rooms-list .room-item a.list-item-link'); + expect(els[0].textContent).toBe("1st Bookmark"); + expect(els[1].textContent).toBe("Another room"); + expect(els[2].textContent).toBe("Bookmark with a very very long name that will be shortened"); + expect(els[3].textContent).toBe("noname@conference.shakespeare.lit"); + expect(els[4].textContent).toBe("The Play's the Thing"); + + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + modal.querySelector('.bookmarks.rooms-list .room-item:nth-child(2) a:nth-child(2)').click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length === 4) + els = modal.querySelectorAll('.bookmarks.rooms-list .room-item a.list-item-link'); + expect(els[0].textContent).toBe("1st Bookmark"); + expect(els[1].textContent).toBe("Bookmark with a very very long name that will be shortened"); + expect(els[2].textContent).toBe("noname@conference.shakespeare.lit"); + expect(els[3].textContent).toBe("The Play's the Thing"); + })); + + it("can be used to open a MUC from a bookmark", mock.initConverse( + ['chatBoxesFetched'], {'view_mode': 'fullscreen'}, + async function (_converse) { + + const api = _converse.api; + + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + mock.openControlBox(_converse); + + const controlbox = await _converse.chatboxviews.get('controlbox'); + controlbox.querySelector('.show-bookmark-list-modal').click(); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil( + () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()); + const stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')}) + .c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) + .c('items', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns': 'storage:bookmarks'}) + .c('conference', { + 'name': 'The Play's the Thing', + 'autojoin': 'false', + 'jid': 'theplay@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': '1st Bookmark', + 'autojoin': 'false', + 'jid': 'first@conference.shakespeare.lit' + }).c('nick').t('JC'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + const modal = api.modal.get('converse-bookmark-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + await u.waitUntil(() => modal.querySelectorAll('.bookmarks.rooms-list .room-item').length); + expect(modal.querySelectorAll('.bookmarks.rooms-list .room-item').length).toBe(2); + modal.querySelector('.bookmarks.rooms-list .open-room').click(); + await u.waitUntil(() => _converse.chatboxes.length === 2); + expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false); + + await u.waitUntil(() => modal.querySelectorAll('.list-container--bookmarks .available-chatroom').length); + modal.querySelector('.list-container--bookmarks .available-chatroom:last-child .open-room').click(); + await u.waitUntil(() => _converse.chatboxes.length === 3); + + expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(true); + expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(false); + + controlbox.querySelector('.list-container--openrooms .open-room:first-child').click(); + await u.waitUntil(() => controlbox.querySelector('.list-item.open').getAttribute('data-room-jid') === 'first@conference.shakespeare.lit'); + expect((await api.rooms.get('first@conference.shakespeare.lit')).get('hidden')).toBe(false); + expect((await api.rooms.get('theplay@conference.shakespeare.lit')).get('hidden')).toBe(true); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js new file mode 100644 index 0000000..8c2824b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/tests/bookmarks.js @@ -0,0 +1,493 @@ +/* global mock, converse */ + +const { Strophe, sizzle } = converse.env; + + +describe("A chat room", function () { + + it("can be bookmarked", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + const { u, $iq } = converse.env; + const nick = 'JC'; + const muc_jid = 'theplay@conference.shakespeare.lit'; + await mock.openChatRoom(_converse, 'theplay', 'conference.shakespeare.lit', 'JC'); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.waitForReservedNick(_converse, muc_jid, nick); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']); + + await u.waitUntil(() => view.querySelector('.toggle-bookmark') !== null); + + const toggle = view.querySelector('.toggle-bookmark'); + expect(toggle.title).toBe('Bookmark this groupchat'); + toggle.click(); + + const modal = _converse.api.modal.get('converse-bookmark-form-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + /* Client uploads data: + * -------------------- + * <iq from='juliet@capulet.lit/balcony' type='set' id='pip1'> + * <pubsub xmlns='http://jabber.org/protocol/pubsub'> + * <publish node='storage:bookmarks'> + * <item id='current'> + * <storage xmlns='storage:bookmarks'> + * <conference name='The Play's the Thing' + * autojoin='true' + * jid='theplay@conference.shakespeare.lit'> + * <nick>JC</nick> + * </conference> + * </storage> + * </item> + * </publish> + * <publish-options> + * <x xmlns='jabber:x:data' type='submit'> + * <field var='FORM_TYPE' type='hidden'> + * <value>http://jabber.org/protocol/pubsub#publish-options</value> + * </field> + * <field var='pubsub#persist_items'> + * <value>true</value> + * </field> + * <field var='pubsub#access_model'> + * <value>whitelist</value> + * </field> + * </x> + * </publish-options> + * </pubsub> + * </iq> + */ + expect(view.model.get('bookmarked')).toBeFalsy(); + const form = await u.waitUntil(() => modal.querySelector('.chatroom-form')); + form.querySelector('input[name="name"]').value = 'Play's the Thing'; + form.querySelector('input[name="autojoin"]').checked = 'checked'; + form.querySelector('input[name="nick"]').value = 'JC'; + + const IQ_stanzas = _converse.connection.IQ_stanzas; + modal.querySelector('converse-muc-bookmark-form .btn-primary').click(); + + const sent_stanza = await u.waitUntil( + () => IQ_stanzas.filter(s => sizzle('iq publish[node="storage:bookmarks"]', s).length).pop()); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<publish node="storage:bookmarks">`+ + `<item id="current">`+ + `<storage xmlns="storage:bookmarks">`+ + `<conference autojoin="true" jid="theplay@conference.shakespeare.lit" name="Play&apos;s the Thing">`+ + `<nick>JC</nick>`+ + `</conference>`+ + `</storage>`+ + `</item>`+ + `</publish>`+ + `<publish-options>`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+ + `</field>`+ + `<field var="pubsub#persist_items">`+ + `<value>true</value>`+ + `</field>`+ + `<field var="pubsub#access_model">`+ + `<value>whitelist</value>`+ + `</field>`+ + `</x>`+ + `</publish-options>`+ + `</pubsub>`+ + `</iq>` + ); + /* Server acknowledges successful storage + * + * <iq to='juliet@capulet.lit/balcony' type='result' id='pip1'/> + */ + const stanza = $iq({ + 'to':_converse.connection.jid, + 'type':'result', + 'id': sent_stanza.getAttribute('id') + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.get('bookmarked')); + expect(view.model.get('bookmarked')).toBeTruthy(); + expect(u.hasClass('on-button', view.querySelector('.toggle-bookmark')), true); + // We ignore this IQ stanza... (unless it's an error stanza), so + // nothing to test for here. + })); + + + it("will be automatically opened if 'autojoin' is set on the bookmark", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + const { u } = converse.env; + const { api } = _converse; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + await u.waitUntil(() => _converse.bookmarks); + let jid = 'lounge@montague.lit'; + _converse.bookmarks.create({ + 'jid': jid, + 'autojoin': false, + 'name': 'The Lounge', + 'nick': ' Othello' + }); + expect(_converse.chatboxviews.get(jid) === undefined).toBeTruthy(); + + jid = 'theplay@conference.shakespeare.lit'; + _converse.bookmarks.create({ + 'jid': jid, + 'autojoin': true, + 'name': 'The Play', + 'nick': ' Othello' + }); + await new Promise(resolve => _converse.api.listen.once('chatRoomViewInitialized', resolve)); + expect(!!_converse.chatboxviews.get(jid)).toBe(true); + + // Check that we don't auto-join if muc_respect_autojoin is false + api.settings.set('muc_respect_autojoin', false); + jid = 'balcony@conference.shakespeare.lit'; + _converse.bookmarks.create({ + 'jid': jid, + 'autojoin': true, + 'name': 'Balcony', + 'nick': ' Othello' + }); + expect(_converse.chatboxviews.get(jid) === undefined).toBe(true); + })); + + + describe("when bookmarked", function () { + + it("will use the nickname from the bookmark", mock.initConverse([], {}, async function (_converse) { + const { u } = converse.env; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + const muc_jid = 'coven@chat.shakespeare.lit'; + _converse.bookmarks.create({ + 'jid': muc_jid, + 'autojoin': false, + 'name': 'The Play', + 'nick': 'Othello' + }); + spyOn(_converse.ChatRoom.prototype, 'getAndPersistNickname').and.callThrough(); + const room_creation_promise = _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + const room = await room_creation_promise; + await u.waitUntil(() => room.getAndPersistNickname.calls.count()); + expect(room.get('nick')).toBe('Othello'); + })); + + it("displays that it's bookmarked through its bookmark icon", mock.initConverse([], {}, async function (_converse) { + + const { u } = converse.env; + await mock.waitForRoster(_converse, 'current', 0); + mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid, nick); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null); + _converse.bookmarks.create({ + 'jid': view.model.get('jid'), + 'autojoin': false, + 'name': 'The lounge', + 'nick': ' some1' + }); + view.model.set('bookmarked', true); + await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') !== null); + view.model.set('bookmarked', false); + await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') === null); + })); + + it("can be unbookmarked", mock.initConverse([], {}, async function (_converse) { + const { u, Strophe } = converse.env; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + const nick = 'romeo'; + const muc_jid = 'theplay@conference.shakespeare.lit'; + await _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid, nick); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('.toggle-bookmark')); + + spyOn(view, 'showBookmarkModal').and.callThrough(); + spyOn(_converse.bookmarks, 'sendBookmarkStanza').and.callThrough(); + + _converse.bookmarks.create({ + 'jid': view.model.get('jid'), + 'autojoin': false, + 'name': 'The Play', + 'nick': 'Othello' + }); + + expect(_converse.bookmarks.length).toBe(1); + await u.waitUntil(() => _converse.chatboxes.length >= 1); + expect(view.model.get('bookmarked')).toBeTruthy(); + await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') !== null); + spyOn(_converse.connection, 'getUniqueId').and.callThrough(); + const bookmark_icon = view.querySelector('.toggle-bookmark'); + bookmark_icon.click(); + expect(view.showBookmarkModal).toHaveBeenCalled(); + + const modal = _converse.api.modal.get('converse-bookmark-form-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const form = await u.waitUntil(() => modal.querySelector('.chatroom-form')); + + expect(form.querySelector('input[name="name"]').value).toBe('The Play'); + expect(form.querySelector('input[name="autojoin"]').checked).toBeFalsy(); + expect(form.querySelector('input[name="nick"]').value).toBe('Othello'); + + // Remove the bookmark + modal.querySelector('.button-remove').click(); + + await u.waitUntil(() => view.querySelector('.chatbox-title__text .fa-bookmark') === null); + expect(_converse.bookmarks.length).toBe(0); + + // Check that an IQ stanza is sent out, containing no + // conferences to bookmark (since we removed the one and + // only bookmark). + const sent_stanza = _converse.connection.IQ_stanzas.pop(); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<publish node="storage:bookmarks">`+ + `<item id="current">`+ + `<storage xmlns="storage:bookmarks"/>`+ + `</item>`+ + `</publish>`+ + `<publish-options>`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+ + `</field>`+ + `<field var="pubsub#persist_items">`+ + `<value>true</value>`+ + `</field>`+ + `<field var="pubsub#access_model">`+ + `<value>whitelist</value>`+ + `</field>`+ + `</x>`+ + `</publish-options>`+ + `</pubsub>`+ + `</iq>` + ); + })); + }); + + describe("and when autojoin is set", function () { + + it("will be be opened and joined automatically upon login", mock.initConverse( + [], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + spyOn(_converse.api.rooms, 'create').and.callThrough(); + const jid = 'theplay@conference.shakespeare.lit'; + const model = _converse.bookmarks.create({ + 'jid': jid, + 'autojoin': false, + 'name': 'The Play', + 'nick': '' + }); + expect(_converse.api.rooms.create).not.toHaveBeenCalled(); + _converse.bookmarks.remove(model); + _converse.bookmarks.create({ + 'jid': jid, + 'autojoin': true, + 'name': 'Hamlet', + 'nick': '' + }); + expect(_converse.api.rooms.create).toHaveBeenCalled(); + })); + }); +}); + +describe("Bookmarks", function () { + + it("can be pushed from the XMPP server", mock.initConverse( + ['connected', 'chatBoxesFetched'], {}, async function (_converse) { + + const { $msg, u } = converse.env; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilBookmarksReturned(_converse); + + /* The stored data is automatically pushed to all of the user's + * connected resources. + * + * Publisher receives event notification + * ------------------------------------- + * <message from='juliet@capulet.lit' + * to='juliet@capulet.lit/balcony' + * type='headline' + * id='rnfoo1'> + * <event xmlns='http://jabber.org/protocol/pubsub#event'> + * <items node='storage:bookmarks'> + * <item id='current'> + * <storage xmlns='storage:bookmarks'> + * <conference name='The Play's the Thing' + * autojoin='true' + * jid='theplay@conference.shakespeare.lit'> + * <nick>JC</nick> + * </conference> + * </storage> + * </item> + * </items> + * </event> + * </message> + */ + let stanza = $msg({ + 'from': 'romeo@montague.lit', + 'to': _converse.jid, + 'type': 'headline', + 'id': u.getUniqueId() + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns': 'storage:bookmarks'}) + .c('conference', { + 'name': 'The Play's the Thing', + 'autojoin': 'true', + 'jid':'theplay@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': 'Another bookmark', + 'autojoin': 'false', + 'jid':'another@conference.shakespeare.lit' + }).c('nick').t('JC'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.bookmarks.length); + expect(_converse.bookmarks.length).toBe(2); + expect(_converse.bookmarks.map(b => b.get('name'))).toEqual(['Another bookmark', 'The Play's the Thing']); + expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined(); + + stanza = $msg({ + 'from': 'romeo@montague.lit', + 'to': _converse.jid, + 'type': 'headline', + 'id': u.getUniqueId() + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns': 'storage:bookmarks'}) + .c('conference', { + 'name': 'The Play's the Thing', + 'autojoin': 'true', + 'jid':'theplay@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': 'Second bookmark', + 'autojoin': 'false', + 'jid':'another@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': 'Yet another bookmark', + 'autojoin': 'false', + 'jid':'yab@conference.shakespeare.lit' + }).c('nick').t('JC'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => _converse.bookmarks.length === 3); + expect(_converse.bookmarks.map(b => b.get('name'))).toEqual(['Second bookmark', 'The Play's the Thing', 'Yet another bookmark']); + expect(_converse.chatboxviews.get('theplay@conference.shakespeare.lit')).not.toBeUndefined(); + expect(Object.keys(_converse.chatboxviews.getAll()).length).toBe(2); + })); + + + it("can be retrieved from the XMPP server", mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + const { Strophe, sizzle, u, $iq } = converse.env; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + /* Client requests all items + * ------------------------- + * + * <iq from='juliet@capulet.lit/randomID' type='get' id='retrieve1'> + * <pubsub xmlns='http://jabber.org/protocol/pubsub'> + * <items node='storage:bookmarks'/> + * </pubsub> + * </iq> + */ + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil( + () => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()); + + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+ + '<items node="storage:bookmarks"/>'+ + '</pubsub>'+ + '</iq>'); + + /* + * Server returns all items + * ------------------------ + * <iq type='result' + * to='juliet@capulet.lit/randomID' + * id='retrieve1'> + * <pubsub xmlns='http://jabber.org/protocol/pubsub'> + * <items node='storage:bookmarks'> + * <item id='current'> + * <storage xmlns='storage:bookmarks'> + * <conference name='The Play's the Thing' + * autojoin='true' + * jid='theplay@conference.shakespeare.lit'> + * <nick>JC</nick> + * </conference> + * </storage> + * </item> + * </items> + * </pubsub> + * </iq> + */ + expect(_converse.bookmarks.models.length).toBe(0); + + spyOn(_converse.bookmarks, 'onBookmarksReceived').and.callThrough(); + var stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')}) + .c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) + .c('items', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns': 'storage:bookmarks'}) + .c('conference', { + 'name': 'The Play's the Thing', + 'autojoin': 'true', + 'jid': 'theplay@conference.shakespeare.lit' + }).c('nick').t('JC').up().up() + .c('conference', { + 'name': 'Another room', + 'autojoin': 'false', + 'jid': 'another@conference.shakespeare.lit' + }); // Purposefully exclude the <nick> element to test #1043 + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.bookmarks.onBookmarksReceived.calls.count()); + await _converse.api.waitUntil('bookmarksInitialized'); + expect(_converse.bookmarks.models.length).toBe(2); + expect(_converse.bookmarks.get('theplay@conference.shakespeare.lit').get('autojoin')).toBe(true); + expect(_converse.bookmarks.get('another@conference.shakespeare.lit').get('autojoin')).toBe(false); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js new file mode 100644 index 0000000..4c54993 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/bookmark-views/utils.js @@ -0,0 +1,52 @@ +import invokeMap from 'lodash-es/invokeMap'; +import { Model } from '@converse/skeletor/src/model.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from '@converse/headless/core'; +import { checkBookmarksSupport } from '@converse/headless/plugins/bookmarks/utils'; + + +export function getHeadingButtons (view, buttons) { + if (api.settings.get('allow_bookmarks') && view.model.get('type') === _converse.CHATROOMS_TYPE) { + const data = { + 'i18n_title': __('Bookmark this groupchat'), + 'i18n_text': __('Bookmark'), + 'handler': (ev) => view.showBookmarkModal(ev), + 'a_class': 'toggle-bookmark', + 'icon_class': 'fa-bookmark', + 'name': 'bookmark' + }; + const names = buttons.map(t => t.name); + const idx = names.indexOf('details'); + const data_promise = checkBookmarksSupport().then((s) => (s ? data : null)); + return idx > -1 ? [...buttons.slice(0, idx), data_promise, ...buttons.slice(idx)] : [data_promise, ...buttons]; + } + return buttons; +} + +export async function removeBookmarkViaEvent (ev) { + ev.preventDefault(); + const name = ev.currentTarget.getAttribute('data-bookmark-name'); + const jid = ev.currentTarget.getAttribute('data-room-jid'); + const result = await api.confirm(__('Are you sure you want to remove the bookmark "%1$s"?', name)); + if (result) { + invokeMap(_converse.bookmarks.where({ jid }), Model.prototype.destroy); + } +} + +export function addBookmarkViaEvent (ev) { + ev.preventDefault(); + const jid = ev.currentTarget.getAttribute('data-room-jid'); + api.modal.show('converse-bookmark-form-modal', { jid }, ev); +} + + +export function openRoomViaEvent (ev) { + ev.preventDefault(); + const { Strophe } = converse.env; + const name = ev.target.textContent; + const jid = ev.target.getAttribute('data-room-jid'); + const data = { + 'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid + }; + api.rooms.open(jid, data, true); +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js new file mode 100644 index 0000000..c26422a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/container.js @@ -0,0 +1,54 @@ + +class ChatBoxViews { + + constructor () { + this.views = {}; + } + + add (key, val) { + this.views[key] = val; + } + + get (key) { + return this.views[key]; + } + + xget (id) { + return this.keys() + .filter(k => (k !== id)) + .reduce((acc, k) => { + acc[k] = this.views[k] + return acc; + }, {}); + } + + getAll () { + return Object.values(this.views); + } + + keys () { + return Object.keys(this.views); + } + + remove (key) { + delete this.views[key]; + } + + map (f) { + return Object.values(this.views).map(f); + } + + forEach (f) { + return Object.values(this.views).forEach(f); + } + + filter (f) { + return Object.values(this.views).filter(f); + } + + closeAllChatBoxes () { + return Promise.all(Object.values(this.views).map(v => v.close({ 'name': 'closeAllChatBoxes' }))); + } +} + +export default ChatBoxViews; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js new file mode 100644 index 0000000..adf2fc9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/index.js @@ -0,0 +1,64 @@ +/** + * @module converse-chatboxviews + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './view.js'; +import '@converse/headless/plugins/chatboxes/index.js'; +import ChatBoxViews from './container.js'; +import { _converse, api, converse } from '@converse/headless/core'; +import { calculateViewportHeightUnit } from './utils.js'; + +import './styles/chats.scss'; + + +converse.plugins.add('converse-chatboxviews', { + dependencies: ['converse-chatboxes', 'converse-vcard'], + + initialize () { + api.promises.add(['chatBoxViewsInitialized']); + + // Configuration values for this plugin + // ==================================== + // Refer to docs/source/configuration.rst for explanations of these + // configuration settings. + api.settings.extend({ 'animate': true }); + + _converse.chatboxviews = new ChatBoxViews(); + + /************************ BEGIN Event Handlers ************************/ + api.listen.on('chatBoxesInitialized', () => { + _converse.chatboxes.on('destroy', m => _converse.chatboxviews.remove(m.get('jid'))); + }); + + api.listen.on('cleanup', () => delete _converse.chatboxviews); + api.listen.on('clearSession', () => _converse.chatboxviews.closeAllChatBoxes()); + api.listen.on('chatBoxViewsInitialized', calculateViewportHeightUnit); + + window.addEventListener('resize', calculateViewportHeightUnit); + /************************ END Event Handlers ************************/ + + Object.assign(converse, { + /** + * Public API method which will ensure that the #conversejs element + * is inserted into a container element. + * + * This method is useful when the #conversejs element has been + * detached from the DOM somehow. + * @async + * @memberOf converse + * @method insertInto + * @example + * converse.insertInto(document.querySelector('#converse-container')); + */ + insertInto (container) { + const el = _converse.chatboxviews?.el; + if (el && !container.contains(el)) { + container.insertAdjacentElement('afterBegin', el); + } else if (!el) { + throw new Error('Cannot insert non-existing #conversejs element into the DOM'); + } + } + }); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss new file mode 100644 index 0000000..6164337 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/styles/chats.scss @@ -0,0 +1,42 @@ +.conversejs { + converse-chats { + &.converse-chatboxes { + z-index: 1031; // One more than bootstrap navbar + position: fixed; + bottom: 0; + right: 0; + } + &.converse-overlayed { + height: 3em; + > .row { + flex-direction: row-reverse; + } + } + + &.converse-fullscreen, + &.converse-mobile { + flex-wrap: nowrap; + width: 100vw; + } + &.converse-embedded { + box-sizing: border-box; + *, *:before, *:after { + box-sizing: border-box; + } + bottom: auto; + height: 100%; // When embedded, it fills the containing element + position: relative; + right: auto; + width: 100%; + + &.converse-chatboxes { + z-index: 1031; // One more than bootstrap navbar + position: inherit; + flex-wrap: nowrap; + bottom: auto; + height: 100%; + width: 100%; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js new file mode 100644 index 0000000..8977de8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/templates/chats.js @@ -0,0 +1,44 @@ +import { html } from 'lit'; +import { repeat } from 'lit/directives/repeat.js'; +import { _converse, api } from '@converse/headless/core'; + + +function shouldShowChat (c) { + const { CONTROLBOX_TYPE } = _converse; + const is_minimized = (api.settings.get('view_mode') === 'overlayed' && c.get('minimized')); + return c.get('type') === CONTROLBOX_TYPE || !(c.get('hidden') || is_minimized); +} + + +export default () => { + const { chatboxes, CONTROLBOX_TYPE, CHATROOMS_TYPE, HEADLINES_TYPE } = _converse; + const view_mode = api.settings.get('view_mode'); + const connection = _converse?.connection; + const logged_out = !connection?.connected || !connection?.authenticated || connection?.disconnecting; + return html` + ${!logged_out && view_mode === 'overlayed' ? html`<converse-minimized-chats></converse-minimized-chats>` : ''} + ${repeat(chatboxes.filter(shouldShowChat), m => m.get('jid'), m => { + if (m.get('type') === CONTROLBOX_TYPE) { + return html` + ${view_mode === 'overlayed' ? html`<converse-controlbox-toggle class="${!m.get('closed') ? 'hidden' : ''}"></converse-controlbox-toggle>` : ''} + <converse-controlbox + id="controlbox" + class="chatbox ${view_mode === 'overlayed' && m.get('closed') ? 'hidden' : ''} ${logged_out ? 'logged-out': ''}" + style="${m.get('width') ? `width: ${m.get('width')}` : ''}"></converse-controlbox> + `; + } else if (m.get('type') === CHATROOMS_TYPE) { + return html` + <converse-muc jid="${m.get('jid')}" class="chatbox chatroom"></converse-muc> + `; + } else if (m.get('type') === HEADLINES_TYPE) { + return html` + <converse-headlines jid="${m.get('jid')}" class="chatbox headlines"></converse-headlines> + `; + } else { + return html` + <converse-chat jid="${m.get('jid')}" class="chatbox"></converse-chat> + `; + } + })} + `; +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js new file mode 100644 index 0000000..09e8be7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/utils.js @@ -0,0 +1,5 @@ + +export function calculateViewportHeightUnit () { + const vh = window.innerHeight * 0.01; + document.documentElement.style.setProperty('--vh', `${vh}px`); +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js new file mode 100644 index 0000000..3965fdc --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatboxviews/view.js @@ -0,0 +1,50 @@ +import tplBackgroundLogo from '../../templates/background_logo.js'; +import tplChats from './templates/chats.js'; +import { CustomElement } from 'shared/components/element.js'; +import { api, _converse } from '@converse/headless/core'; +import { getAppSettings } from '@converse/headless/shared/settings/utils.js'; +import { render } from 'lit'; + + +class ConverseChats extends CustomElement { + + initialize () { + this.model = _converse.chatboxes; + this.listenTo(this.model, 'add', () => this.requestUpdate()); + this.listenTo(this.model, 'change:closed', () => this.requestUpdate()); + this.listenTo(this.model, 'change:hidden', () => this.requestUpdate()); + this.listenTo(this.model, 'change:jid', () => this.requestUpdate()); + this.listenTo(this.model, 'change:minimized', () => this.requestUpdate()); + this.listenTo(this.model, 'destroy', () => this.requestUpdate()); + + // Use listenTo instead of api.listen.to so that event handlers + // automatically get deregistered when the component is dismounted + this.listenTo(_converse, 'connected', () => this.requestUpdate()); + this.listenTo(_converse, 'reconnected', () => this.requestUpdate()); + this.listenTo(_converse, 'disconnected', () => this.requestUpdate()); + + const settings = getAppSettings(); + this.listenTo(settings, 'change:view_mode', () => this.requestUpdate()) + this.listenTo(settings, 'change:singleton', () => this.requestUpdate()) + + const bg = document.getElementById('conversejs-bg'); + if (bg && !bg.innerHTML.trim()) { + render(tplBackgroundLogo(), bg); + } + const body = document.querySelector('body'); + body.classList.add(`converse-${api.settings.get('view_mode')}`); + + /** + * Triggered once the _converse.ChatBoxViews view-colleciton has been initialized + * @event _converse#chatBoxViewsInitialized + * @example _converse.api.listen.on('chatBoxViewsInitialized', () => { ... }); + */ + api.trigger('chatBoxViewsInitialized'); + } + + render () { // eslint-disable-line class-methods-use-this + return tplChats(); + } +} + +api.elements.define('converse-chats', ConverseChats); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js new file mode 100644 index 0000000..114cb69 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/bottom-panel.js @@ -0,0 +1,98 @@ +import './message-form.js'; +import debounce from 'lodash-es/debounce'; +import tplBottomPanel from './templates/bottom-panel.js'; +import { ElementView } from '@converse/skeletor/src/element.js'; +import { _converse, api } from '@converse/headless/core'; +import { clearMessages } from './utils.js'; +import { render } from 'lit'; + +import './styles/chat-bottom-panel.scss'; + + +export default class ChatBottomPanel extends ElementView { + events = { + 'click .send-button': 'sendButtonClicked', + 'click .toggle-clear': 'clearMessages' + }; + + constructor () { + super(); + this.debouncedRender = debounce(this.render, 100); + } + + async connectedCallback () { + super.connectedCallback(); + await this.initialize(); + this.render(); // don't call in initialize, since the MUCBottomPanel subclasses it + // and we want to render after it has finished as wel. + } + + async initialize () { + this.model = await api.chatboxes.get(this.getAttribute('jid')); + await this.model.initialized; + this.listenTo(this.model, 'change:num_unread', this.debouncedRender) + this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker); + + this.addEventListener('focusin', ev => this.emitFocused(ev)); + this.addEventListener('focusout', ev => this.emitBlurred(ev)); + } + + render () { + render(tplBottomPanel({ + 'model': this.model, + 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) + }), this); + } + + sendButtonClicked (ev) { + this.querySelector('converse-message-form')?.onFormSubmitted(ev); + } + + viewUnreadMessages (ev) { + ev?.preventDefault?.(); + this.model.ui.set({ 'scrolled': false }); + } + + emitFocused (ev) { + _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev); + } + + emitBlurred (ev) { + _converse.chatboxviews.get(this.getAttribute('jid'))?.emitBlurred(ev); + } + + onDrop (evt) { + if (evt.dataTransfer.files.length == 0) { + // There are no files to be dropped, so this isn’t a file + // transfer operation. + return; + } + evt.preventDefault(); + this.model.sendFiles(evt.dataTransfer.files); + } + + onDragOver (ev) { // eslint-disable-line class-methods-use-this + ev.preventDefault(); + } + + clearMessages (ev) { + ev?.preventDefault?.(); + clearMessages(this.model); + } + + async autocompleteInPicker (input, value) { + await api.emojis.initialize(); + const emoji_picker = this.querySelector('converse-emoji-picker'); + if (emoji_picker) { + emoji_picker.model.set({ + 'ac_position': input.selectionStart, + 'autocompleting': value, + 'query': value + }); + const emoji_dropdown = this.querySelector('converse-emoji-dropdown'); + emoji_dropdown?.showMenu(); + } + } +} + +api.elements.define('converse-chat-bottom-panel', ChatBottomPanel); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js new file mode 100644 index 0000000..916f15c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/chat.js @@ -0,0 +1,59 @@ +import 'plugins/chatview/heading.js'; +import 'plugins/chatview/bottom-panel.js'; +import BaseChatView from 'shared/chat/baseview.js'; +import tplChat from './templates/chat.js'; +import { __ } from 'i18n'; +import { _converse, api } from '@converse/headless/core'; + +/** + * The view of an open/ongoing chat conversation. + * @class + * @namespace _converse.ChatBoxView + * @memberOf _converse + */ +export default class ChatView extends BaseChatView { + length = 200 + + async initialize () { + _converse.chatboxviews.add(this.jid, this); + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); + this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown()); + this.listenTo(this.model, 'change:show_help_messages', () => this.requestUpdate()); + + await this.model.messages.fetched; + !this.model.get('hidden') && this.afterShown() + /** + * Triggered once the {@link _converse.ChatBoxView} has been initialized + * @event _converse#chatBoxViewInitialized + * @type { _converse.ChatBoxView } + * @example _converse.api.listen.on('chatBoxViewInitialized', view => { ... }); + */ + api.trigger('chatBoxViewInitialized', this); + } + + render () { + return tplChat(Object.assign({ + 'model': this.model, + 'help_messages': this.getHelpMessages(), + 'show_help_messages': this.model.get('show_help_messages'), + }, this.model.toJSON())); + } + + getHelpMessages () { // eslint-disable-line class-methods-use-this + return [ + `<strong>/clear</strong>: ${__('Remove messages')}`, + `<strong>/close</strong>: ${__('Close this chat')}`, + `<strong>/me</strong>: ${__('Write in the third person')}`, + `<strong>/help</strong>: ${__('Show this menu')}` + ]; + } + + afterShown () { + this.model.setChatState(_converse.ACTIVE); + this.model.clearUnreadMsgCounter(); + this.maybeFocus(); + } +} + +api.elements.define('converse-chat', ChatView); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js new file mode 100644 index 0000000..4fad77c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/heading.js @@ -0,0 +1,125 @@ +import 'shared/modals/user-details.js'; +import tplChatboxHead from './templates/chat-head.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; + +import './styles/chat-head.scss'; + + +export default class ChatHeading extends CustomElement { + + static get properties () { + return { + 'jid': { type: String }, + } + } + + initialize () { + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model, 'change:status', () => this.requestUpdate()); + this.listenTo(this.model, 'vcard:add', () => this.requestUpdate()); + this.listenTo(this.model, 'vcard:change', () => this.requestUpdate()); + if (this.model.contact) { + this.listenTo(this.model.contact, 'destroy', () => this.requestUpdate()); + } + this.model.rosterContactAdded?.then(() => { + this.listenTo(this.model.contact, 'change:nickname', () => this.requestUpdate()); + this.requestUpdate(); + }); + } + + render () { + return tplChatboxHead(Object.assign(this.model.toJSON(), { + 'heading_buttons_promise': this.getHeadingButtons(), + 'model': this.model, + 'showUserDetailsModal': ev => this.showUserDetailsModal(ev), + })); + } + + showUserDetailsModal (ev) { + ev.preventDefault(); + api.modal.show('converse-user-details-modal', { model: this.model }, ev); + } + + close (ev) { + ev.preventDefault(); + this.model.close(); + } + + /** + * Returns a list of objects which represent buttons for the chat's header. + * @async + * @emits _converse#getHeadingButtons + */ + getHeadingButtons () { + const buttons = [ + /** + * @typedef { Object } HeadingButtonAttributes + * An object representing a chat heading button + * @property { Boolean } standalone + * True if shown on its own, false if it must be in the dropdown menu. + * @property { Function } handler + * A handler function to be called when the button is clicked. + * @property { String } a_class - HTML classes to show on the button + * @property { String } i18n_text - The user-visiible name of the button + * @property { String } i18n_title - The tooltip text for this button + * @property { String } icon_class - What kind of CSS class to use for the icon + * @property { String } name - The internal name of the button + */ + { + 'a_class': 'show-user-details-modal', + 'handler': ev => this.showUserDetailsModal(ev), + 'i18n_text': __('Details'), + 'i18n_title': __('See more information about this person'), + 'icon_class': 'fa-id-card', + 'name': 'details', + 'standalone': api.settings.get('view_mode') === 'overlayed' + } + ]; + if (!api.settings.get('singleton')) { + buttons.push({ + 'a_class': 'close-chatbox-button', + 'handler': ev => this.close(ev), + 'i18n_text': __('Close'), + 'i18n_title': __('Close and end this conversation'), + 'icon_class': 'fa-times', + 'name': 'close', + 'standalone': api.settings.get('view_mode') === 'overlayed' + }); + } + const el = _converse.chatboxviews.get(this.getAttribute('jid')); + if (el) { + /** + * *Hook* which allows plugins to add more buttons to a chat's heading. + * + * Note: This hook is fired for both 1 on 1 chats and groupchats. + * If you only care about one, you need to add a check in your code. + * + * @event _converse#getHeadingButtons + * @param { HTMLElement } el + * The `converse-chat` (or `converse-muc`) DOM element that represents the chat + * @param { Array.<HeadingButtonAttributes> } + * An array of the existing buttons. New buttons may be added, + * and existing ones removed or modified. + * @example + * api.listen.on('getHeadingButtons', (el, buttons) => { + * buttons.push({ + * 'i18n_title': __('Foo'), + * 'i18n_text': __('Foo Bar'), + * 'handler': ev => alert('Foo!'), + * 'a_class': 'toggle-foo', + * 'icon_class': 'fa-foo', + * 'name': 'foo' + * }); + * return buttons; + * }); + */ + return _converse.api.hook('getHeadingButtons', el, buttons); + } else { + return buttons; // Happens during tests + } + } +} + +api.elements.define('converse-chat-heading', ChatHeading); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js new file mode 100644 index 0000000..b9783e7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/index.js @@ -0,0 +1,65 @@ +/** + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import '../chatboxviews/index.js'; +import 'shared/chat/chat-content.js'; +import 'shared/chat/help-messages.js'; +import 'shared/chat/toolbar.js'; +import ChatView from './chat.js'; +import { _converse, api, converse } from '@converse/headless/core'; +import { clearHistory } from './utils.js'; + +import './styles/index.scss'; + +const { Strophe } = converse.env; + + +converse.plugins.add('converse-chatview', { + /* 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-chatboxviews', 'converse-chat', 'converse-disco', 'converse-modal'], + + initialize () { + /* The initialize function gets called as soon as the plugin is + * loaded by converse.js's plugin machinery. + */ + api.settings.extend({ + 'allowed_audio_domains': null, + 'allowed_image_domains': null, + 'allowed_video_domains': null, + 'auto_focus': true, + 'debounced_content_rendering': true, + 'filter_url_query_params': null, + 'image_urls_regex': null, + 'message_limit': 0, + 'muc_hats': ['xep317'], + 'render_media': true, + 'show_message_avatar': true, + 'show_retraction_warning': true, + 'show_send_button': true, + 'show_toolbar': true, + 'time_format': 'HH:mm', + 'use_system_emojis': true, + 'visible_toolbar_buttons': { + 'call': false, + 'clear': true, + 'emoji': true, + 'spoiler': true + } + }); + + _converse.ChatBoxView = ChatView; + + api.listen.on('connected', () => api.disco.own.features.add(Strophe.NS.SPOILER)); + api.listen.on('chatBoxClosed', (model) => clearHistory(model.get('jid'))); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js new file mode 100644 index 0000000..3ce9e80 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/message-form.js @@ -0,0 +1,238 @@ +import tplMessageForm from './templates/message-form.js'; +import { ElementView } from '@converse/skeletor/src/element.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core.js"; +import { parseMessageForCommands } from './utils.js'; +import { prefixMentions } from '@converse/headless/utils/core.js'; + +const { u } = converse.env; + + +export default class MessageForm extends ElementView { + + async connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + await this.model.initialized; + this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting); + this.listenTo(this.model, 'change:composing_spoiler', () => this.render()); + + this.handleEmojiSelection = ({ detail }) => { + if (this.model.get('jid') === detail.jid) { + this.insertIntoTextArea(detail.value, detail.autocompleting, false, detail.ac_position); + } + } + document.addEventListener("emojiSelected", this.handleEmojiSelection); + this.render(); + } + + disconnectedCallback () { + super.disconnectedCallback(); + document.removeEventListener("emojiSelected", this.handleEmojiSelection); + } + + toHTML () { + return tplMessageForm( + Object.assign(this.model.toJSON(), { + 'onDrop': ev => this.onDrop(ev), + 'hint_value': this.querySelector('.spoiler-hint')?.value, + 'message_value': this.querySelector('.chat-textarea')?.value, + 'onChange': ev => this.model.set({'draft': ev.target.value}), + 'onKeyDown': ev => this.onKeyDown(ev), + 'onKeyUp': ev => this.onKeyUp(ev), + 'onPaste': ev => this.onPaste(ev), + 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) + }) + ); + } + + /** + * Insert a particular string value into the textarea of this chat box. + * @param { string } value - The value to be inserted. + * @param {(boolean|string)} [replace] - Whether an existing value + * should be replaced. If set to `true`, the entire textarea will + * be replaced with the new value. If set to a string, then only + * that string will be replaced *if* a position is also specified. + * @param { number } [position] - The end index of the string to be + * replaced with the new value. + */ + insertIntoTextArea (value, replace = false, correcting = false, position) { + const textarea = this.querySelector('.chat-textarea'); + if (correcting) { + u.addClass('correcting', textarea); + } else { + u.removeClass('correcting', textarea); + } + if (replace) { + if (position && typeof replace == 'string') { + textarea.value = textarea.value.replace(new RegExp(replace, 'g'), (match, offset) => + offset == position - replace.length ? value + ' ' : match + ); + } else { + textarea.value = value; + } + } else { + let existing = textarea.value; + if (existing && existing[existing.length - 1] !== ' ') { + existing = existing + ' '; + } + textarea.value = existing + value + ' '; + } + const ev = document.createEvent('HTMLEvents'); + ev.initEvent('change', false, true); + textarea.dispatchEvent(ev); + u.placeCaretAtEnd(textarea); + } + + onMessageCorrecting (message) { + if (message.get('correcting')) { + this.insertIntoTextArea(prefixMentions(message), true, true); + } else { + const currently_correcting = this.model.messages.findWhere('correcting'); + if (currently_correcting && currently_correcting !== message) { + this.insertIntoTextArea(prefixMentions(message), true, true); + } else { + this.insertIntoTextArea('', true, false); + } + } + } + + onEscapePressed (ev) { + const idx = this.model.messages.findLastIndex('correcting'); + const message = idx >= 0 ? this.model.messages.at(idx) : null; + if (message) { + ev.preventDefault(); + message.save('correcting', false); + this.insertIntoTextArea('', true, false); + } + } + + onPaste (ev) { + ev.stopPropagation(); + if (ev.clipboardData.files.length !== 0) { + ev.preventDefault(); + // Workaround for quirk in at least Firefox 60.7 ESR: + // It seems that pasted files disappear from the event payload after + // the event has finished, which apparently happens during async + // processing in sendFiles(). So we copy the array here. + this.model.sendFiles(Array.from(ev.clipboardData.files)); + return; + } + this.model.set({'draft': ev.clipboardData.getData('text/plain')}); + } + + onKeyUp (ev) { + this.model.set({'draft': ev.target.value}); + } + + onKeyDown (ev) { + if (ev.ctrlKey) { + // When ctrl is pressed, no chars are entered into the textarea. + return; + } + if (!ev.shiftKey && !ev.altKey && !ev.metaKey) { + if (ev.keyCode === converse.keycodes.TAB) { + const value = u.getCurrentWord(ev.target, null, /(:.*?:)/g); + if (value.startsWith(':')) { + ev.preventDefault(); + ev.stopPropagation(); + this.model.trigger('emoji-picker-autocomplete', ev.target, value); + } + } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) { + // Forward slash is used to run commands. Nothing to do here. + return; + } else if (ev.keyCode === converse.keycodes.ESCAPE) { + return this.onEscapePressed(ev, this); + } else if (ev.keyCode === converse.keycodes.ENTER) { + return this.onFormSubmitted(ev); + } else if (ev.keyCode === converse.keycodes.UP_ARROW && !ev.target.selectionEnd) { + const textarea = this.querySelector('.chat-textarea'); + if (!textarea.value || u.hasClass('correcting', textarea)) { + return this.model.editEarlierMessage(); + } + } else if ( + ev.keyCode === converse.keycodes.DOWN_ARROW && + ev.target.selectionEnd === ev.target.value.length && + u.hasClass('correcting', this.querySelector('.chat-textarea')) + ) { + return this.model.editLaterMessage(); + } + } + if ( + [ + converse.keycodes.SHIFT, + converse.keycodes.META, + converse.keycodes.META_RIGHT, + converse.keycodes.ESCAPE, + converse.keycodes.ALT + ].includes(ev.keyCode) + ) { + return; + } + if (this.model.get('chat_state') !== _converse.COMPOSING) { + // Set chat state to composing if keyCode is not a forward-slash + // (which would imply an internal command and not a message). + this.model.setChatState(_converse.COMPOSING); + } + } + + async onFormSubmitted (ev) { + ev?.preventDefault?.(); + + const textarea = this.querySelector('.chat-textarea'); + const message_text = textarea.value.trim(); + if ( + (api.settings.get('message_limit') && message_text.length > api.settings.get('message_limit')) || + !message_text.replace(/\s/g, '').length + ) { + return; + } + if (!_converse.connection.authenticated) { + const err_msg = __('Sorry, the connection has been lost, and your message could not be sent'); + api.alert('error', __('Error'), err_msg); + api.connection.reconnect(); + return; + } + let spoiler_hint, + hint_el = {}; + if (this.model.get('composing_spoiler')) { + hint_el = this.querySelector('form.sendXMPPMessage input.spoiler-hint'); + spoiler_hint = hint_el.value; + } + u.addClass('disabled', textarea); + textarea.setAttribute('disabled', 'disabled'); + this.querySelector('converse-emoji-dropdown')?.hideMenu(); + + const is_command = await parseMessageForCommands(this.model, message_text); + const message = is_command ? null : await this.model.sendMessage({'body': message_text, spoiler_hint}); + if (is_command || message) { + hint_el.value = ''; + textarea.value = ''; + u.removeClass('correcting', textarea); + textarea.style.height = 'auto'; + this.model.set({'draft': ''}); + } + if (api.settings.get('view_mode') === 'overlayed') { + // XXX: Chrome flexbug workaround. The .chat-content area + // doesn't resize when the textarea is resized to its original size. + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + const msgs_container = chatview.querySelector('.chat-content__messages'); + msgs_container.parentElement.style.display = 'none'; + } + textarea.removeAttribute('disabled'); + u.removeClass('disabled', textarea); + + if (api.settings.get('view_mode') === 'overlayed') { + // XXX: Chrome flexbug workaround. + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + const msgs_container = chatview.querySelector('.chat-content__messages'); + msgs_container.parentElement.style.display = ''; + } + // Suppress events, otherwise superfluous CSN gets set + // immediately after the message, causing rate-limiting issues. + this.model.setChatState(_converse.ACTIVE, { 'silent': true }); + textarea.focus(); + } +} + +api.elements.define('converse-message-form', MessageForm); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss new file mode 100644 index 0000000..9aeba39 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-bottom-panel.scss @@ -0,0 +1,73 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "shared/styles/_variables.scss"; + +.conversejs { + .chatbox { + .bottom-panel { + + .chat-content-sendbutton { + height: calc(100% - (var(--chat-textarea-height) + var(--send-button-height) + 2 * var(--send-button-margin))); + } + + .sendXMPPMessage { + -moz-background-clip: padding; + -webkit-background-clip: padding-box; + border-bottom-radius: var(--chatbox-border-radius); + background-clip: padding-box; + background-color: var(--chat-textarea-background-color); + border: 0; + margin: 0; + padding: 0; + @media screen and (max-height: $mobile-landscape-height) { + width: 100%; + } + @media screen and (max-width: $mobile-portrait-length) { + width: 100%; + } + + .suggestion-box__results { + &:after { + display: none; + } + } + + .spoiler-hint { + width: 100%; + color: var(--foreground); + background-color: var(--background); + } + + .chat-textarea, input { + &:active, &:focus{ + outline-color: var(--chat-head-color); + } + &.correcting { + background-color: var(--chat-correcting-color); + } + } + + .chat-textarea { + color: var(--chat-textarea-color); + background-color: var(--chat-textarea-background-color); + border-top-left-radius: 0; + border-top-right-radius: 0; + border-bottom-radius: var(--chatbox-border-radius); + padding-left: 0.5em; + padding-right: 4.5em; + padding-top: 0.5em; + padding-bottom:0.5em; + width: 100%; + border: none; + min-height: var(--chat-textarea-height); + margin-bottom: -4px; // Not clear why this is necessar :( + resize: none; + &.spoiler { + height: 42px; + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss new file mode 100644 index 0000000..bd6dae4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chat-head.scss @@ -0,0 +1,95 @@ +.conversejs { + .chatbox { + .chat-head { + display: flex; + flex-direction: row; + color: #ffffff; + font-size: 100%; + margin: 0; + padding: 0; + position: relative; + + &.chat-head-chatbox { + background-color: var(--chat-head-color); + border-bottom: var(--chat-head-border-bottom); + } + + .avatar { + margin-right: 0.5em; + } + + .show-msg-author-modal { + color: var(--chat-head-text-color) !important; + } + + .chat-head__desc { + color: var(--chat-head-color-lighten-50-percent); + font-size: var(--font-size-small); + margin: 0; + overflow: hidden; + padding: 0.5rem 1rem 0.5rem 1rem; + text-overflow: ellipsis; + width: 100%; + } + + .chatbox-title { + padding: 0.75rem 1rem 0 1rem; + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + } + + .chatbox-title--no-desc { + padding: 0.75rem 1rem; + } + + .chatbox-title--row { + display: flex; + flex-direction: row; + overflow: hidden; + width: 100%; + } + + .chatbox-title__text { + color: var(--chat-head-text-color);; + overflow: hidden; + text-overflow: ellipsis; + } + + .chatbox-title__buttons { + display: flex; + flex-direction: row-reverse; + flex-wrap: nowrap; + padding: 0; + } + + .chatbox-btn { + color: white; + &:active { + position: relative; + top: 1px; + } + } + + converse-dropdown { + .dropdown-menu { + converse-icon { + svg { + fill: var(--chat-color); + } + } + } + } + + + .chatbox-btn { + converse-icon { + svg { + fill: var(--chat-head-fg-color); + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss new file mode 100644 index 0000000..8a317cc --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/chatbox.scss @@ -0,0 +1,225 @@ +.conversejs { + .chatbox { + text-align: left; + margin: 0 var(--chat-gutter); + + @media screen and (max-height: $mobile-landscape-height) { + margin: 0; + width: var(--mobile-chat-width); + } + @media screen and (max-width: $mobile-portrait-length) { + margin: 0; + width: var(--mobile-chat-width); + } + + converse-controlbox-navback { + display: none; + } + + .flyout { + position: absolute; + + @media screen and (max-height: $mobile-landscape-height) { + border-radius: 0; + } + @media screen and (max-width: $mobile-portrait-length) { + border-radius: 0; + } + + @media screen and (max-height: $mobile-landscape-height) { + bottom: 0; + } + @media screen and (max-width: $mobile-portrait-length) { + bottom: 0; + } + } + + .chatbox-btn { + border-radius: 25%; + border: none; + cursor: pointer; + font-size: var(--chatbox-button-size); + margin: 0 0.2em; + padding: 0 0 0 0.5em; + text-decoration: none; + + &:active { + position: relative; + top: 1px; + } + } + + .box-flyout { + display: flex; + flex-direction: column; + justify-content: space-between; + box-shadow: 1px 3px 5px 3px rgba(0, 0, 0, 0.4); + z-index: 2; + overflow: hidden; + width: 100%; + + @media screen and (max-height: $mobile-landscape-height) { + height: var(--mobile-chat-height); + width: var(--mobile-chat-width); + height: var(--fullpage-chat-height); + } + @media screen and (max-width: $mobile-portrait-length) { + height: var(--mobile-chat-height); + width: var(--mobile-chat-width); + height: var(--fullpage-chat-height); + } + } + + .chat-title { + display: var(--heading-display); + font-family: var(--heading-font); + color: var(--heading-color); + display: block; + line-height: var(--line-height-large); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + &.groupchat { + padding-right: var(--chatroom-head-title-padding-right); + } + a { + color: var(--chat-head-text-color); + width: 100%; + } + } + + .chat-body { + display: flex; + flex-direction: column; + justify-content: space-between; + background-color: var(--chat-textarea-background-color); + border-bottom-left-radius: var(--chatbox-border-radius); + border-bottom-right-radius: var(--chatbox-border-radius); + + @media screen and (max-height: $mobile-landscape-height) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + @media screen and (max-width: $mobile-portrait-length) { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + border-top: 0; + height: 100%; + width: 100%; + overflow: hidden; + p { + color: var(--text-color); + font-size: var(--message-font-size); + margin: 0; + padding: 5px; + } + } + .new-msgs-indicator { + position: relative; + width: 100%; + cursor: pointer; + background-color: var(--chat-head-color); + color: var(--light-background-color); + padding: 0.5em; + font-size: 0.9em; + text-align: center; + z-index: 20; + white-space: nowrap; + margin-bottom: 0.25em; + } + .chat-content { + background-color: var(--chat-content-background-color); + border: 0; + color: var(--text-color); + font-size: var(--message-font-size); + height: 100%; + line-height: 1.3em; + overflow: hidden; + padding: 0; + display: flex; + flex-direction: column; + justify-content: space-between; + + converse-chat-message { + .spinner { + width: 100%; + overflow-y: hidden; + } + } + + .chat-content__help { + max-height: 100%; + converse-chat-help { + border-top: 1px solid var(--chat-head-color); + display: block; + height: 100%; + overflow-y: auto; + padding: 0.5em 0; + } + .close-chat-help { + float: right; + padding-right: 1em; + cursor: pointer; + color: var(--chat-content-background-color); + svg { + fill: var(--chat-head-color); + } + } + } + + .chat-content__messages { + overflow-x: hidden; + overflow-y: auto; + height: 100%; + } + + .chat-content__notifications { + height: 1.7em; + white-space: pre; + background-color: var(--chat-content-background-color); + color: var(--subdued-color); + font-size: 90%; + font-style: italic; + line-height: var(--line-height-small); + padding: 0 1em 0.3em; + &:before { + content: " "; + } + } + + progress { + margin: 0.5em 0; + width: 100% + } + } + + .dragresize { + background: transparent; + border: 0; + margin: 0; + position: absolute; + top: 0; + z-index: 20; + &-top { + cursor: n-resize; + height: 5px; + width: 100%; + } + &-left, + &-occupants-left { + cursor: w-resize; + width: 5px; + height: 100%; + left: 0; + } + &-topleft { + cursor: nw-resize; + width: 15px; + height: 15px; + top: 0; + left: 0; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss new file mode 100644 index 0000000..ac784c7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/styles/index.scss @@ -0,0 +1,240 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "shared/styles/_variables.scss"; +@import "bootstrap/scss/media"; +@import "./chatbox.scss"; + + +/* ******************* Overlay and embedded styles *************************** */ + +.conversejs { + converse-chats.converse-embedded, + converse-chats.converse-overlayed { + .controlbox-head { + padding: 0.5em; + } + .chat-head { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + .chatbox { + min-width: var(--overlayed-chat-width) !important; + width: var(--overlayed-chat-width); + + .box-flyout { + min-width: var(--overlayed-chat-width) !important; + width: var(--overlayed-chat-width); + } + } + } + + converse-chats.converse-overlayed { + .chat-head, .box-flyout { + border-top-left-radius: var(--chatbox-border-radius); + border-top-right-radius: var(--chatbox-border-radius); + @media screen and (max-height: $mobile-landscape-height) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + @media screen and (max-width: $mobile-portrait-length) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + + .flyout { + bottom: var(--overlayed-chatbox-hover-height); + } + .box-flyout { + height: var(--overlayed-chat-height); + min-height: calc(var(--overlayed-chat-height) / 2); + } + .chat-head { + min-height: var(--overlayed-chat-head-height); + } + .minimized-chats-flyout .chat-head { + cursor: default; + } + .chat-textarea { + max-height: var(--overlayed-max-chat-textarea-height); + } + .chatbox { + .chat-body { + height: calc(100% - var(--overlayed-chat-head-height)); + } + .chatbox-title { + padding: 0.5rem 0.75rem 0 0.75rem; + } + .chatbox-title--no-desc { + padding: 0.5rem 0.75rem; + } + } + } +} + +@include media-breakpoint-down(sm) { + .conversejs.converse-overlayed { + > .row { + flex-direction: column; + &.no-gutters { + margin: -1em; + } + } + } +} + + +.conversejs { + converse-chats.converse-embedded, + converse-chats.converse-fullscreen { + .flyout { + border-radius: 0; + border:none; + bottom: 0; + } + + .chatbox { + margin: 0; + margin-left: 15px; + .box-flyout { + box-shadow: none; + overflow: hidden; + margin-left: 0; + } + } + } + + converse-chats.converse-fullscreen { + &:not(.converse-singleton) { + .chatbox { + @include media-breakpoint-up(md) { + @include make-col(8); + } + @include media-breakpoint-up(lg) { + @include make-col(9); + } + @include media-breakpoint-up(xl) { + @include make-col(10); + } + + &:not(#controlbox) { + .box-flyout { + @include media-breakpoint-up(md) { + max-width: 66.666667%; + } + @include media-breakpoint-up(lg) { + max-width: 75%; + } + @include media-breakpoint-up(xl) { + max-width: 83.333333%; + } + } + } + } + } + } + + converse-chats.converse-embedded { + .chat-head { + font-size: var(--font-size-huge); + } + + .chatbox { + .box-flyout { + bottom: 0; + height: 100%; + min-width: auto; + width: 100%; + } + } + + .chat-textarea { + max-height: var(--fullpage-max-chat-textarea-height); + } + } +} + +/* ******************* Fullpage styles *************************** */ + +.conversejs { + converse-chats.converse-fullscreen { + .chatbox-btn { + font-size: var(--fullpage-chatbox-button-size); + margin: 0 0.3em; + } + .chat-head { + font-size: var(--font-size-huge); + } + .chat-textarea { + max-height: var(--fullpage-max-chat-textarea-height); + } + .chatbox { + .box-flyout { + box-shadow: none; + height: var(--fullpage-chat-height); + min-height: calc(var(--fullpage-chat-height) / 2); + width: var(--fullpage-chat-width); + overflow: hidden; + } + .chat-body { + height: inherit; + overflow: hidden; + background-color: var(--chat-background-color); + } + .chat-title { + font-size: var(--font-size-huge); + line-height: var(--line-height-huge); + } + .sendXMPPMessage { + ul { + width: 100%; + } + } + } + } +} + + +@include media-breakpoint-down(sm) { + .conversejs { + converse-chats:not(.converse-embedded) { + > .row { + flex-direction: row-reverse; + } + #converse-login-panel { + .converse-form { + padding: 3em 2em 3em; + } + } + .chatbox { + width: calc(100% - 50px); + .row { + .box-flyout { + left: 50px; + bottom: 0; + height: var(--fullpage-chat-height); + box-shadow: none; + } + } + } + } + + converse-chats.converse-mobile, + converse-chats.converse-overlayed, + converse-chats.converse-fullscreen { + .chat-head { + converse-controlbox-navback { + margin: auto 0; + margin-right: 1em; + display: flex; + .fa-arrow-left { + svg { + fill: var(--chat-head-text-color); + } + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js new file mode 100644 index 0000000..f60df5f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/bottom-panel.js @@ -0,0 +1,30 @@ +import { __ } from 'i18n'; +import { api } from '@converse/headless/core'; +import { html } from 'lit'; + + +export default (o) => { + const unread_msgs = __('You have unread messages'); + const message_limit = api.settings.get('message_limit'); + const show_call_button = api.settings.get('visible_toolbar_buttons').call; + const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji; + const show_send_button = api.settings.get('show_send_button'); + const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler; + const show_toolbar = api.settings.get('show_toolbar'); + return html` + ${ o.model.ui.get('scrolled') && o.model.get('num_unread') ? + html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' } + ${api.settings.get('show_toolbar') ? html` + <converse-chat-toolbar + class="chat-toolbar no-text-select" + .model=${o.model} + ?composing_spoiler="${o.model.get('composing_spoiler')}" + ?show_call_button="${show_call_button}" + ?show_emoji_button="${show_emoji_button}" + ?show_send_button="${show_send_button}" + ?show_spoiler_button="${show_spoiler_button}" + ?show_toolbar="${show_toolbar}" + message_limit="${message_limit}"></converse-chat-toolbar>` : '' } + <converse-message-form jid="${o.model.get('jid')}"></converse-message-form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js new file mode 100644 index 0000000..6f6e9e0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat-head.js @@ -0,0 +1,34 @@ +import { __ } from 'i18n'; +import { _converse } from '@converse/headless/core'; +import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js'; +import { html } from "lit"; +import { until } from 'lit/directives/until.js'; + + +export default (o) => { + const i18n_profile = __("The User's Profile Image"); + const avatar = html`<span title="${i18n_profile}"> + <converse-avatar + class="avatar chat-msg__avatar" + .data=${o.model.vcard?.attributes} + nonce=${o.model.vcard?.get('vcard_updated')} + height="40" width="40"></converse-avatar></span>`; + const display_name = o.model.getDisplayName(); + + return html` + <div class="chatbox-title ${ o.status ? '' : "chatbox-title--no-desc"}"> + <div class="chatbox-title--row"> + ${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' } + ${ (o.type !== _converse.HEADLINES_TYPE) ? html`<a class="show-msg-author-modal" @click=${o.showUserDetailsModal}>${ avatar }</a>` : '' } + <div class="chatbox-title__text" title="${o.jid}"> + ${ (o.type !== _converse.HEADLINES_TYPE) ? html`<a class="user show-msg-author-modal" @click=${o.showUserDetailsModal}>${ display_name }</a>` : display_name } + </div> + </div> + <div class="chatbox-title__buttons row no-gutters"> + ${ until(getDropdownButtons(o.heading_buttons_promise), '') } + ${ until(getStandaloneButtons(o.heading_buttons_promise), '') } + </div> + </div> + ${ o.status ? html`<p class="chat-head__desc">${ o.status }</p>` : '' } + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js new file mode 100644 index 0000000..754f864 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/chat.js @@ -0,0 +1,28 @@ +import { html } from "lit"; +import { _converse } from '@converse/headless/core'; + +export default (o) => html` + <div class="flyout box-flyout"> + <converse-dragresize></converse-dragresize> + ${ o.model ? html` + <converse-chat-heading jid="${o.jid}" class="chat-head chat-head-chatbox row no-gutters"></converse-chat-heading> + <div class="chat-body"> + <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite"> + <converse-chat-content + class="chat-content__messages" + jid="${o.jid}"></converse-chat-content> + + ${o.show_help_messages ? html`<div class="chat-content__help"> + <converse-chat-help + .model=${o.model} + .messages=${o.help_messages} + ?hidden=${!o.show_help_messages} + type="info" + chat_type="${_converse.CHATROOMS_TYPE}" + ></converse-chat-help></div>` : '' } + </div> + <converse-chat-bottom-panel jid="${o.jid}" class="bottom-panel"> </converse-chat-bottom-panel> + </div> + ` : '' } + </div> +`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js new file mode 100644 index 0000000..56a31a9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/templates/message-form.js @@ -0,0 +1,34 @@ +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; +import { html } from "lit"; +import { resetElementHeight } from '../utils.js'; + + +export default (o) => { + const label_message = o.composing_spoiler ? __('Hidden message') : __('Message'); + const label_spoiler_hint = __('Optional hint'); + const show_send_button = api.settings.get('show_send_button'); + + return html` + <form class="sendXMPPMessage"> + <input type="text" + enterkeyhint="send" + placeholder="${label_spoiler_hint || ''}"i + value="${o.hint_value || ''}" + class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/> + <textarea + autofocus + type="text" + enterkeyhint="send" + @drop=${o.onDrop} + @input=${resetElementHeight} + @keydown=${o.onKeyDown} + @keyup=${o.onKeyUp} + @paste=${o.onPaste} + @change=${o.onChange} + class="chat-textarea + ${ show_send_button ? 'chat-textarea-send-button' : '' } + ${ o.composing_spoiler ? 'spoiler' : '' }" + placeholder="${label_message}">${ o.message_value || '' }</textarea> + </form>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js new file mode 100644 index 0000000..c1ce92c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/chatbox.js @@ -0,0 +1,1056 @@ +/*global mock, converse */ + +const $msg = converse.env.$msg; +const Strophe = converse.env.Strophe; +const u = converse.env.utils; +const sizzle = converse.env.sizzle; +const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("Chatboxes", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + describe("A Chatbox", function () { + + it("has a /help command to show the available commands", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + 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); + mock.sendMessage(view, '/help'); + await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view).length); + const info_messages = await u.waitUntil(() => sizzle('.chat-info:not(.chat-date)', view)); + expect(info_messages.length).toBe(4); + expect(info_messages.pop().textContent).toBe('/help: Show this menu'); + expect(info_messages.pop().textContent).toBe('/me: Write in the third person'); + expect(info_messages.pop().textContent).toBe('/close: Close this chat'); + expect(info_messages.pop().textContent).toBe('/clear: Remove messages'); + + const msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('hello world').tree(); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__body'; + await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === 'hello world'); + })); + + + it("has a /clear command", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + 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); + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + + for (const i of Array(10).keys()) { + mock.sendMessage(view, `Message ${i}`); + } + await u.waitUntil(() => sizzle('converse-chat-message', view).length === 10); + + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = '/clear'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => _converse.api.confirm.calls.count() === 1); + await u.waitUntil(() => sizzle('converse-chat-message', view).length === 0); + expect(true).toBe(true); + })); + + + it("is created when you click on a roster item", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + // openControlBox was called earlier, so the controlbox is + // visible, but no other chat boxes have been created. + expect(_converse.chatboxes.length).toEqual(1); + spyOn(_converse.minimize, 'trimChats'); + expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700); + const online_contacts = rosterview.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat'); + expect(online_contacts.length).toBe(17); + let el = online_contacts[0]; + el.click(); + await u.waitUntil(() => document.querySelectorAll("#conversejs .chatbox").length == 2); + expect(_converse.minimize.trimChats).toHaveBeenCalled(); + online_contacts[1].click(); + await u.waitUntil(() => _converse.chatboxes.length == 3); + el = online_contacts[1]; + expect(_converse.minimize.trimChats).toHaveBeenCalled(); + // Check that new chat boxes are created to the left of the + // controlbox (but to the right of all existing chat boxes) + expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(3); + })); + + it("opens when a new message is received", mock.initConverse( + [], {'allow_non_roster_messaging': true}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const stanza = u.toStanza(` + <message from="${sender_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Hey\nHave you heard the news?</body> + </message>`); + + const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve)); + await u.waitUntil(() => message_promise); + expect(_converse.chatboxviews.keys().length).toBe(2); + expect(_converse.chatboxviews.keys().pop()).toBe(sender_jid); + })); + + it("doesn't open when a message without body is received", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const stanza = u.toStanza(` + <message from="${sender_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <composing xmlns="http://jabber.org/protocol/chatstates"/> + </message>`); + const message_promise = new Promise(resolve => _converse.api.listen.on('message', resolve)) + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => message_promise); + expect(_converse.chatboxviews.keys().length).toBe(1); + })); + + it("is focused if its already open and you click on its corresponding roster item", + mock.initConverse(['chatBoxesFetched'], {'auto_focus': true}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + expect(_converse.chatboxes.length).toEqual(1); + + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + spyOn(_converse.ChatBoxView.prototype, 'focus').and.callThrough(); + const view = await mock.openChatBoxFor(_converse, contact_jid); + const rosterview = document.querySelector('converse-roster'); + const el = sizzle('a.open-chat:contains("'+view.model.getDisplayName()+'")', rosterview).pop(); + await u.waitUntil(() => u.isVisible(el)); + const textarea = view.querySelector('.chat-textarea'); + await u.waitUntil(() => u.isVisible(textarea)); + textarea.blur(); + el.click(); + await u.waitUntil(() => view.focus.calls.count(), 1000); + expect(view.focus).toHaveBeenCalled(); + expect(_converse.chatboxes.length).toEqual(2); + })); + + it("can be saved to, and retrieved from, browserStorage", + mock.initConverse([], {}, async function (_converse) { + + spyOn(_converse.minimize, 'trimChats'); + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + spyOn(_converse.api, "trigger").and.callThrough(); + + mock.openChatBoxes(_converse, 6); + await u.waitUntil(() => _converse.chatboxes.length == 7); + expect(_converse.minimize.trimChats).toHaveBeenCalled(); + // We instantiate a new ChatBoxes collection, which by default + // will be empty. + const newchatboxes = new _converse.ChatBoxes(); + expect(newchatboxes.length).toEqual(0); + // The chatboxes will then be fetched from browserStorage inside the + // onConnected method + newchatboxes.onConnected(); + await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve)); + expect(newchatboxes.length).toEqual(7); + // Check that the chatboxes items retrieved from browserStorage + // have the same attributes values as the original ones. + const attrs = ['id', 'box_id', 'visible']; + let new_attrs, old_attrs; + for (let i=0; i<attrs.length; i++) { + new_attrs = newchatboxes.models.map(m => m.attributes[i]); + old_attrs = _converse.chatboxes.models.map(m => m.attributes[i]); + expect(new_attrs).toEqual(old_attrs); + } + })); + + it("can be closed by clicking a DOM element with class 'close-chatbox-button'", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + const chatview = _converse.chatboxviews.get(contact_jid); + spyOn(chatview.model, 'close').and.callThrough(); + spyOn(_converse.api, "trigger").and.callThrough(); + chatview.querySelector('.close-chatbox-button').click(); + expect(chatview.model.close).toHaveBeenCalled(); + await new Promise(resolve => _converse.api.listen.once('chatBoxClosed', resolve)); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object)); + })); + + it("will be removed from browserStorage when closed", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + spyOn(_converse.minimize, 'trimChats'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + spyOn(_converse.api, "trigger").and.callThrough(); + const promise = new Promise(resolve => _converse.api.listen.once('controlBoxClosed', resolve)); + mock.closeControlBox(); + await promise; + expect(_converse.chatboxes.length).toEqual(1); + expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']); + mock.openChatBoxes(_converse, 6); + await u.waitUntil(() => _converse.chatboxes.length == 7) + expect(_converse.minimize.trimChats).toHaveBeenCalled(); + expect(_converse.chatboxes.length).toEqual(7); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxViewInitialized', jasmine.any(Object)); + await mock.closeAllChatBoxes(_converse); + + expect(_converse.chatboxes.length).toEqual(1); + expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object)); + const newchatboxes = new _converse.ChatBoxes(); + expect(newchatboxes.length).toEqual(0); + expect(_converse.chatboxes.pluck('id')).toEqual(['controlbox']); + // onConnected will fetch chatboxes in browserStorage, but + // because there aren't any open chatboxes, there won't be any + // in browserStorage either. XXX except for the controlbox + newchatboxes.onConnected(); + await new Promise(resolve => _converse.api.listen.on('chatBoxesFetched', resolve)); + expect(newchatboxes.length).toEqual(1); + expect(newchatboxes.models[0].id).toBe("controlbox"); + })); + + describe("A chat toolbar", function () { + + it("shows the remaining character count if a message_limit is configured", + mock.initConverse(['chatBoxesFetched'], {'message_limit': 200}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 3); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const toolbar = view.querySelector('.chat-toolbar'); + const counter = toolbar.querySelector('.message-limit'); + expect(counter.textContent).toBe('200'); + view.getMessageForm().insertIntoTextArea('hello world'); + await u.waitUntil(() => counter.textContent === '188'); + + toolbar.querySelector('.toggle-emojis').click(); + const picker = await u.waitUntil(() => view.querySelector('.emoji-picker__lists')); + const item = await u.waitUntil(() => picker.querySelector('.emoji-picker li.insert-emoji a')); + item.click() + await u.waitUntil(() => counter.textContent === '179'); + + const textarea = view.querySelector('.chat-textarea'); + const ev = { + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown(ev); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + message_form.onKeyUp(ev); + expect(counter.textContent).toBe('200'); + + textarea.value = 'hello world'; + message_form.onKeyUp(ev); + await u.waitUntil(() => counter.textContent === '189'); + })); + + + it("does not show a remaining character count if message_limit is zero", + mock.initConverse(['chatBoxesFetched'], {'message_limit': 0}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 3); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const counter = view.querySelector('.chat-toolbar .message-limit'); + expect(counter).toBe(null); + })); + + + it("can contain a button for starting a call", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const { api } = _converse; + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + let toolbar, call_button; + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + spyOn(_converse.api, "trigger").and.callThrough(); + // First check that the button doesn't show if it's not enabled + // via "visible_toolbar_buttons" + + let buttons = api.settings.get('visible_toolbar_buttons'); + api.settings.set('visible_toolbar_buttons', Object.assign({}, buttons, {'call': false})); + + await mock.openChatBoxFor(_converse, contact_jid); + let view = _converse.chatboxviews.get(contact_jid); + toolbar = view.querySelector('.chat-toolbar'); + call_button = toolbar.querySelector('.toggle-call'); + expect(call_button === null).toBeTruthy(); + view.close(); + // Now check that it's shown if enabled and that it emits + // callButtonClicked + buttons = api.settings.get('visible_toolbar_buttons'); + api.settings.set('visible_toolbar_buttons', Object.assign({}, buttons, {'call': true})); + + await mock.openChatBoxFor(_converse, contact_jid); + view = _converse.chatboxviews.get(contact_jid); + toolbar = view.querySelector('.chat-toolbar'); + call_button = toolbar.querySelector('.toggle-call'); + call_button.click(); + expect(_converse.api.trigger).toHaveBeenCalledWith('callButtonClicked', jasmine.any(Object)); + })); + }); + + describe("A Chat Status Notification", function () { + + it("does not open a new chatbox", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + // <composing> state + const stanza = $msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + + spyOn(_converse.api, "trigger").and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.api.trigger.calls.count()); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + expect(_converse.chatboxviews.keys().length).toBe(1); + })); + + describe("An active notification", function () { + + it("is sent when the user opens a chat box", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + spyOn(_converse.connection, 'send'); + await mock.openChatBoxFor(_converse, contact_jid); + const model = _converse.chatboxes.get(contact_jid); + expect(model.get('chat_state')).toBe('active'); + expect(_converse.connection.send).toHaveBeenCalled(); + const stanza = _converse.connection.send.calls.argsFor(0)[0]; + expect(stanza.getAttribute('to')).toBe(contact_jid); + expect(stanza.childNodes.length).toBe(3); + expect(stanza.childNodes[0].tagName).toBe('active'); + expect(stanza.childNodes[1].tagName).toBe('no-store'); + expect(stanza.childNodes[2].tagName).toBe('no-permanent-store'); + })); + + it("is sent when the user maximizes a minimized a chat box", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + const model = _converse.chatboxes.get(contact_jid); + _converse.minimize.minimize(model); + const sent_stanzas = _converse.connection.sent_stanzas; + sent_stanzas.splice(0, sent_stanzas.length); + expect(model.get('chat_state')).toBe('inactive'); + _converse.minimize.maximize(model); + await u.waitUntil(() => model.get('chat_state') === 'active', 1000); + const stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`active`, s).length).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>` + ); + })); + }); + + describe("A composing notification", function () { + + it("is sent as soon as the user starts typing a message which is not a command", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + expect(view.model.get('chat_state')).toBe('active'); + spyOn(_converse.connection, 'send'); + spyOn(_converse.api, "trigger").and.callThrough(); + + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + expect(view.model.get('chat_state')).toBe('composing'); + expect(_converse.connection.send).toHaveBeenCalled(); + + const stanza = _converse.connection.send.calls.argsFor(0)[0]; + expect(stanza.getAttribute('to')).toBe(contact_jid); + expect(stanza.childNodes.length).toBe(3); + expect(stanza.childNodes[0].tagName).toBe('composing'); + expect(stanza.childNodes[1].tagName).toBe('no-store'); + expect(stanza.childNodes[2].tagName).toBe('no-permanent-store'); + + // The notification is not sent again + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + expect(view.model.get('chat_state')).toBe('composing'); + expect(_converse.api.trigger.calls.count(), 1); + })); + + it("is NOT sent out if send_chat_state_notifications doesn't allow it", + mock.initConverse(['chatBoxesFetched'], {'send_chat_state_notifications': []}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + expect(view.model.get('chat_state')).toBe('active'); + spyOn(_converse.connection, 'send'); + spyOn(_converse.api, "trigger").and.callThrough(); + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + expect(view.model.get('chat_state')).toBe('composing'); + expect(_converse.connection.send).not.toHaveBeenCalled(); + })); + + it("will be shown if received", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, sender_jid); + + // <composing> state + let msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + + _converse.connection._dataRecv(mock.createRequest(msg)); + const view = _converse.chatboxviews.get(sender_jid); + let csn = mock.cur_names[1] + ' is typing'; + await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn); + expect(view.model.messages.length).toEqual(0); + + // <paused> state + msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + csn = mock.cur_names[1] + ' has stopped typing'; + await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn); + + msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('hello world').tree(); + await _converse.handleMessageStanza(msg); + const msg_el = await u.waitUntil(() => view.querySelector('.chat-msg')); + await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === ''); + expect(msg_el.querySelector('.chat-msg__text').textContent).toBe('hello world'); + })); + + it("is ignored if it's a composing carbon message sent by this user from a different client", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); + await mock.waitForRoster(_converse, 'current'); + // Send a message from a different resource + const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, recipient_jid); + + spyOn(u, 'shouldCreateMessage').and.callThrough(); + + const msg = $msg({ + 'from': _converse.bare_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': _converse.bare_jid+'/another-resource', + 'to': recipient_jid, + 'type': 'chat' + }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + + await u.waitUntil(() => u.shouldCreateMessage.calls.count()); + expect(view.model.messages.length).toEqual(0); + const el = view.querySelector('.chat-content__notifications'); + expect(el.textContent).toBe(''); + })); + }); + + describe("A paused notification", function () { + + it("is sent if the user has stopped typing since 30 seconds", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700); + _converse.TIMEOUTS.PAUSED = 200; // Make the timeout shorter so that we can test + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + spyOn(view.model, 'setChatState').and.callThrough(); + expect(view.model.get('chat_state')).toBe('active'); + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + expect(view.model.get('chat_state')).toBe('composing'); + + const xmlns = 'https://jabber.org/protocol/chatstates'; + const sent_stanzas = _converse.connection.sent_stanzas; + let stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`composing`, s).length).pop(), 1000); + + expect(Strophe.serialize(stanza)).toBe( + `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>` + ); + + await u.waitUntil(() => view.model.get('chat_state') === 'paused', 500); + + stanza = await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${xmlns}"]`, s)).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<message id="${stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>` + ); + + // Test #359. A paused notification should not be sent + // out if the user simply types longer than the + // timeout. + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + expect(view.model.setChatState).toHaveBeenCalled(); + expect(view.model.get('chat_state')).toBe('composing'); + + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + expect(view.model.get('chat_state')).toBe('composing'); + })); + + it("will be shown if received", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + // TODO: only show paused state if the previous state was composing + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + spyOn(_converse.api, "trigger").and.callThrough(); + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, sender_jid); + // <paused> state + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + + _converse.connection._dataRecv(mock.createRequest(msg)); + const csn = mock.cur_names[1] + ' has stopped typing'; + await u.waitUntil( () => view.querySelector('.chat-content__notifications').innerText === csn); + expect(view.model.messages.length).toEqual(0); + })); + + it("will not be shown if it's a paused carbon message that this user sent from a different client", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); + await mock.waitForRoster(_converse, 'current'); + // Send a message from a different resource + const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + spyOn(u, 'shouldCreateMessage').and.callThrough(); + const view = await mock.openChatBoxFor(_converse, recipient_jid); + const msg = $msg({ + 'from': _converse.bare_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': _converse.bare_jid+'/another-resource', + 'to': recipient_jid, + 'type': 'chat' + }).c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await u.waitUntil(() => u.shouldCreateMessage.calls.count()); + expect(view.model.messages.length).toEqual(0); + const el = view.querySelector('.chat-content__notifications'); + expect(el.textContent).toBe(''); + })); + }); + + describe("An inactive notification", function () { + + it("is sent if the user has stopped typing since 2 minutes", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const sent_stanzas = _converse.connection.sent_stanzas; + // Make the timeouts shorter so that we can test + _converse.TIMEOUTS.PAUSED = 100; + _converse.TIMEOUTS.INACTIVE = 100; + + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 1000); + + sent_stanzas.splice(0, sent_stanzas.length); + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + await u.waitUntil(() => view.model.get('chat_state') === 'active'); + expect(view.model.get('chat_state')).toBe('active'); + + const messages = sent_stanzas.filter(s => s.matches('message')); + expect(Strophe.serialize(messages[0])).toBe( + `<message id="${messages[0].getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>`); + + + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + keyCode: 1 + }); + await u.waitUntil(() => view.model.get('chat_state') === 'composing', 600); + let stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message composing')).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+ + `<composing xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>`); + + await u.waitUntil(() => view.model.get('chat_state') === 'paused', 600); + stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message paused')).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+ + `<paused xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>`); + + await u.waitUntil(() => view.model.get('chat_state') === 'inactive', 600); + stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message inactive')).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<message id="${stanza.getAttribute('id')}" to="mercutio@montague.lit" type="chat" xmlns="jabber:client">`+ + `<inactive xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>`); + + })); + + it("is sent when the user a minimizes a chat box", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + 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); + spyOn(_converse.connection, 'send'); + _converse.minimize.minimize(view.model); + expect(view.model.get('chat_state')).toBe('inactive'); + expect(_converse.connection.send).toHaveBeenCalled(); + var stanza = _converse.connection.send.calls.argsFor(0)[0]; + expect(stanza.getAttribute('to')).toBe(contact_jid); + expect(stanza.childNodes[0].tagName).toBe('inactive'); + })); + + it("is sent if the user closes a chat box", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + const view = await mock.openChatBoxFor(_converse, contact_jid); + expect(view.model.get('chat_state')).toBe('active'); + spyOn(_converse.connection, 'send'); + view.close(); + expect(view.model.get('chat_state')).toBe('inactive'); + expect(_converse.connection.send).toHaveBeenCalled(); + const stanza = _converse.connection.send.calls.argsFor(0)[0]; + expect(stanza.getAttribute('to')).toBe(contact_jid); + expect(stanza.childNodes.length).toBe(3); + expect(stanza.childNodes[0].tagName).toBe('inactive'); + expect(stanza.childNodes[1].tagName).toBe('no-store'); + expect(stanza.childNodes[2].tagName).toBe('no-permanent-store'); + })); + + it("will clear any other chat status notifications", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + await mock.openChatBoxFor(_converse, sender_jid); + const view = _converse.chatboxviews.get(sender_jid); + expect(view.querySelectorAll('.chat-event').length).toBe(0); + // Insert <composing> message, to also check that + // text messages are inserted correctly with + // temporary chat events in the chat contents. + let msg = $msg({ + 'to': _converse.bare_jid, + 'xmlns': 'jabber:client', + 'from': sender_jid, + 'type': 'chat'}) + .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up() + .tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext).toEqual(mock.cur_names[1] + ' is typing'); + expect(view.model.messages.length).toBe(0); + + msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('inactive', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + + await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent); + })); + }); + + describe("A gone notification", function () { + + it("will be shown if received", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 3); + await mock.openControlBox(_converse); + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, sender_jid); + + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').c('gone', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + + const view = _converse.chatboxviews.get(sender_jid); + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext).toEqual(mock.cur_names[1] + ' has gone away'); + })); + }); + + describe("On receiving a message correction", function () { + + it("will be removed", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, sender_jid); + + // Original message + const original_id = u.getUniqueId(); + const original = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: original_id, + body: "Original message", + }).c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + + spyOn(_converse.api, "trigger").and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(original)); + await u.waitUntil(() => _converse.api.trigger.calls.count()); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + const view = _converse.chatboxviews.get(sender_jid); + expect(view).toBeDefined(); + + // <composing> state + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext).toEqual(mock.cur_names[1] + ' is typing'); + + // Edited message + const edited = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId(), + body: "Edited message", + }) + .c('active', {'xmlns': Strophe.NS.CHATSTATES}).up() + .c('replace', {'xmlns': Strophe.NS.MESSAGE_CORRECT, 'id': original_id }).tree(); + + await _converse.handleMessageStanza(edited); + await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent); + })); + }); + }); + }); + + describe("Special Messages", function () { + + it("'/clear' can be used to clear messages in a conversation", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + spyOn(_converse.api, "trigger").and.callThrough(); + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + let message = 'This message is another sent from this chatbox'; + await mock.sendMessage(view, message); + + expect(view.model.messages.length === 1).toBeTruthy(); + const stored_messages = await view.model.messages.browserStorage.findAll(); + expect(stored_messages.length).toBe(1); + await u.waitUntil(() => view.querySelector('.chat-msg')); + + message = '/clear'; + const message_form = view.querySelector('converse-message-form'); + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + view.querySelector('.chat-textarea').value = message; + message_form.onKeyDown({ + target: view.querySelector('textarea.chat-textarea'), + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => _converse.api.confirm.calls.count() === 1); + expect(_converse.api.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?'); + await u.waitUntil(() => view.model.messages.length === 0); + await u.waitUntil(() => !view.querySelectorAll('.chat-msg__body').length); + })); + }); + + + describe("A RosterView's Unread Message Count", function () { + + it("is updated when message is received and chatbox is scrolled up", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + let msg, indicator_el; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500); + await mock.openChatBoxFor(_converse, sender_jid); + const chatbox = _converse.chatboxes.get(sender_jid); + chatbox.ui.set('scrolled', true); + msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length); + const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator'; + indicator_el = sizzle(selector, rosterview).pop(); + expect(indicator_el.textContent).toBe('1'); + msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too'); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length > 1); + indicator_el = sizzle(selector, rosterview).pop(); + expect(indicator_el.textContent).toBe('2'); + })); + + it("is updated when message is received and chatbox is minimized", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + let indicator_el, msg; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500); + await mock.openChatBoxFor(_converse, sender_jid); + const chatbox = _converse.chatboxes.get(sender_jid); + _converse.minimize.minimize(chatbox); + + msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length); + const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator'; + indicator_el = sizzle(selector, rosterview).pop(); + expect(indicator_el.textContent).toBe('1'); + + msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread too'); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length === 2); + indicator_el = sizzle(selector, rosterview).pop(); + expect(indicator_el.textContent).toBe('2'); + })); + + it("is cleared when chatbox is maximzied after receiving messages in minimized mode", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500); + await mock.openChatBoxFor(_converse, sender_jid); + const chatbox = _converse.chatboxes.get(sender_jid); + const view = _converse.chatboxviews.get(sender_jid); + const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator'; + const select_msgs_indicator = () => sizzle(selector, rosterview).pop(); + _converse.minimize.minimize(view.model); + _converse.handleMessageStanza(msgFactory()); + await u.waitUntil(() => chatbox.messages.length); + expect(select_msgs_indicator().textContent).toBe('1'); + _converse.handleMessageStanza(msgFactory()); + await u.waitUntil(() => chatbox.messages.length > 1); + expect(select_msgs_indicator().textContent).toBe('2'); + _converse.minimize.maximize(view.model); + u.waitUntil(() => typeof select_msgs_indicator() === 'undefined'); + })); + + it("is cleared when unread messages are viewed which were received in scrolled-up chatbox", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500); + await mock.openChatBoxFor(_converse, sender_jid); + const chatbox = _converse.chatboxes.get(sender_jid); + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read'); + const selector = `a.open-chat:contains("${chatbox.get('nickname')}") .msgs-indicator`; + const select_msgs_indicator = () => sizzle(selector, rosterview).pop(); + chatbox.ui.set('scrolled', true); + _converse.handleMessageStanza(msgFactory()); + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.model.messages.length); + expect(select_msgs_indicator().textContent).toBe('1'); + const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator')); + chat_new_msgs_indicator.click(); + await u.waitUntil(() => select_msgs_indicator() === undefined); + })); + + it("is not cleared after user clicks on roster view when chatbox is already opened and scrolled up", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 500); + await mock.openChatBoxFor(_converse, sender_jid); + const chatbox = _converse.chatboxes.get(sender_jid); + const view = _converse.chatboxviews.get(sender_jid); + const msg = 'This message will be received as unread, but eventually will be read'; + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, msg); + const selector = 'a.open-chat:contains("' + chatbox.get('nickname') + '") .msgs-indicator'; + const select_msgs_indicator = () => sizzle(selector, rosterview).pop(); + chatbox.ui.set('scrolled', true); + _converse.handleMessageStanza(msgFactory()); + await u.waitUntil(() => view.model.messages.length); + expect(select_msgs_indicator().textContent).toBe('1'); + await mock.openChatBoxFor(_converse, sender_jid); + expect(select_msgs_indicator().textContent).toBe('1'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js new file mode 100644 index 0000000..b6f395e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/corrections.js @@ -0,0 +1,354 @@ +/*global mock, converse */ + +const { Promise, $msg, Strophe, sizzle, u } = converse.env; + +describe("A Chat Message", function () { + + it("can be sent as a correction by using the up arrow", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + 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 textarea = view.querySelector('textarea.chat-textarea'); + expect(textarea.value).toBe(''); + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe(''); + + textarea.value = 'But soft, what light through yonder airlock breaks?'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + + spyOn(_converse.connection, 'send'); + let new_text = 'But soft, what light through yonder window breaks?'; + textarea.value = new_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text); + + expect(_converse.connection.send).toHaveBeenCalled(); + const msg = _converse.connection.send.calls.all()[0].args[0]; + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="mercutio@montague.lit" type="chat" `+ + `xmlns="jabber:client">`+ + `<body>But soft, what light through yonder window breaks?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + expect(view.model.messages.models.length).toBe(1); + const corrected_message = view.model.messages.at(0); + expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid')); + expect(corrected_message.get('correcting')).toBe(false); + + const older_versions = corrected_message.get('older_versions'); + const keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?'); + + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500); + + // Test that pressing the down arrow cancels message correction + await u.waitUntil(() => textarea.value === '') + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + message_form.onKeyDown({ + target: textarea, + keyCode: 40 // Down arrow + }); + expect(textarea.value).toBe(''); + expect(view.model.messages.at(0).get('correcting')).toBe(false); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500); + + new_text = 'It is the east, and Juliet is the one.'; + textarea.value = new_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')) + .filter(m => m.textContent.replace(/<!-.*?->/g, '') === new_text).length); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + + textarea.value = 'Arise, fair sun, and kill the envious moon'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('Arise, fair sun, and kill the envious moon'); + await u.waitUntil(() => view.model.messages.at(2).get('correcting') === true); + expect(view.model.messages.at(0).get('correcting')).toBeFalsy(); + expect(view.model.messages.at(1).get('correcting')).toBeFalsy(); + await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg:last', view).pop()), 750); + + textarea.selectionEnd = 0; // Happens by pressing up, + // but for some reason not in tests, so we set it manually. + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('It is the east, and Juliet is the one.'); + expect(view.model.messages.at(0).get('correcting')).toBeFalsy(); + expect(view.model.messages.at(1).get('correcting')).toBe(true); + expect(view.model.messages.at(2).get('correcting')).toBeFalsy(); + await u.waitUntil(() => u.hasClass('correcting', sizzle('.chat-msg', view)[1]), 500); + + textarea.value = 'It is the east, and Juliet is the sun.'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => textarea.value === ''); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).filter( + m => m.textContent === 'It is the east, and Juliet is the sun.').length); + + const messages = view.querySelectorAll('.chat-msg'); + expect(messages.length).toBe(3); + expect(messages[0].querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder window breaks?'); + expect(messages[1].querySelector('.chat-msg__text').textContent) + .toBe('It is the east, and Juliet is the sun.'); + expect(messages[2].querySelector('.chat-msg__text').textContent) + .toBe('Arise, fair sun, and kill the envious moon'); + + expect(view.model.messages.at(0).get('correcting')).toBeFalsy(); + expect(view.model.messages.at(1).get('correcting')).toBeFalsy(); + expect(view.model.messages.at(2).get('correcting')).toBeFalsy(); + })); + + + it("can be sent as a correction by clicking the pencil icon", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + 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 textarea = view.querySelector('textarea.chat-textarea'); + + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + await u.waitUntil(() => textarea.value === ''); + + const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action').length === 2); + let action = view.querySelector('.chat-msg .chat-msg__action'); + expect(action.textContent.trim()).toBe('Edit'); + + action.style.opacity = 1; + action.click(); + + expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg'))); + + spyOn(_converse.connection, 'send'); + const text = 'But soft, what light through yonder window breaks?'; + textarea.value = text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === text); + expect(_converse.connection.send).toHaveBeenCalled(); + + const msg = _converse.connection.send.calls.all()[0].args[0]; + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="mercutio@montague.lit" type="chat" `+ + `xmlns="jabber:client">`+ + `<body>But soft, what light through yonder window breaks?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + expect(view.model.messages.models.length).toBe(1); + const corrected_message = view.model.messages.at(0); + expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid')); + expect(corrected_message.get('correcting')).toBe(false); + + const older_versions = corrected_message.get('older_versions'); + const keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?'); + + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === false); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + // Test that clicking the pencil icon a second time cancels editing. + action = view.querySelector('.chat-msg .chat-msg__action'); + action.style.opacity = 1; + action.click(); + + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')) === true); + + action = view.querySelector('.chat-msg .chat-msg__action'); + action.style.opacity = 1; + action.click(); + expect(view.model.messages.at(0).get('correcting')).toBe(false); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(textarea.value).toBe(''); + await u.waitUntil(() => (u.hasClass('correcting', view.querySelector('.chat-msg')) === false), 500); + + // Test that messages from other users don't have the pencil icon + _converse.handleMessageStanza( + $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('body').t('Hello').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree() + ); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2); + + // Test confirmation dialog + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + action = view.querySelector('.chat-msg .chat-msg__action'); + action.style.opacity = 1; + action.click(); + + await u.waitUntil(() => _converse.api.confirm.calls.count()); + expect(_converse.api.confirm).toHaveBeenCalledWith( + 'You have an unsent message which will be lost if you continue. Are you sure?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + + textarea.value = 'But soft, what light through yonder airlock breaks?' + action.click(); + + await u.waitUntil(() => _converse.api.confirm.calls.count() === 2); + expect(view.model.messages.at(0).get('correcting')).toBe(false); + expect(_converse.api.confirm.calls.argsFor(0)).toEqual( + ['You have an unsent message which will be lost if you continue. Are you sure?']); + expect(_converse.api.confirm.calls.argsFor(1)).toEqual( + ['You have an unsent message which will be lost if you continue. Are you sure?']); + })); + + + describe("when received from someone else", function () { + + it("can be replaced with a correction", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg_id = u.getUniqueId(); + const view = await mock.openChatBoxFor(_converse, sender_jid); + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': msg_id, + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder chimney breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder chimney breaks?'); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); + expect(view.model.messages.models.length).toBe(1); + + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder window breaks?'); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); + view.querySelector('.chat-msg__content .fa-edit').click(); + + const modal = _converse.api.modal.get('converse-message-versions-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const older_msgs = modal.querySelectorAll('.older-msg'); + expect(older_msgs.length).toBe(2); + expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true); + expect(view.model.messages.models.length).toBe(1); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js new file mode 100644 index 0000000..88d3ecd --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/emojis.js @@ -0,0 +1,210 @@ +/*global mock, converse */ + +const { Promise, $msg } = converse.env; +const u = converse.env.utils; +const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("Emojis", function () { + describe("The emoji picker", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + it("can be opened by clicking a button in the chat toolbar", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const toolbar = await u.waitUntil(() => view.querySelector('converse-chat-toolbar')); + toolbar.querySelector('.toggle-emojis').click(); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000); + const item = view.querySelector('.emoji-picker li.insert-emoji a'); + item.click() + expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: '); + toolbar.querySelector('.toggle-emojis').click(); // Close the panel again + })); + }); + + describe("A Chat Message", function () { + + it("will display larger if it's only emojis", + mock.initConverse(['chatBoxesFetched'], {'use_system_emojis': true}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': _converse.connection.getUniqueId() + }).c('body').t('😇').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve)); + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector('.chat-msg__text'))); + + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': _converse.connection.getUniqueId() + }).c('body').t('😇 Hello world! 😇 😇').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + let sel = '.message:last-child .chat-msg__text'; + await u.waitUntil(() => u.hasClass('chat-msg__text--larger', view.querySelector(sel))); + + // Test that a modified message that no longer contains only + // emojis now renders normally again. + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':poop: :innocent:'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text'; + await u.waitUntil(() => view.querySelector(last_msg_sel).textContent === '💩 😇'); + + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('💩 😇'); + expect(view.model.messages.at(2).get('correcting')).toBe(true); + sel = 'converse-chat-message:last-child .chat-msg' + await u.waitUntil(() => u.hasClass('correcting', view.querySelector(sel)), 500); + const edited_text = textarea.value += 'This is no longer an emoji-only message'; + textarea.value = edited_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')) + .filter(el => el.textContent === edited_text).length); + expect(view.model.messages.models.length).toBe(3); + let message = view.querySelector(last_msg_sel); + expect(u.hasClass('chat-msg__text--larger', message)).toBe(false); + + textarea.value = ':smile: Hello world!'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); + + textarea.value = ':smile: :smiley: :imp:'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5); + + message = view.querySelector('.message:last-child .chat-msg__text'); + expect(u.hasClass('chat-msg__text--larger', message)).toBe(true); + })); + + it("can render emojis as images", + mock.initConverse( + ['chatBoxesFetched'], {'use_system_emojis': false}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.handleMessageStanza($msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': _converse.connection.getUniqueId() + }).c('body').t('😇').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve)); + const view = _converse.chatboxviews.get(contact_jid); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '') === + '<img class="emoji" loading="lazy" draggable="false" title=":innocent:" alt="😇" src="https://twemoji.maxcdn.com/v/12.1.6//72x72/1f607.png">'); + + const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text'; + let message = view.querySelector(last_msg_sel); + await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000); + let imgs = message.querySelectorAll('.emoji'); + expect(imgs.length).toBe(1); + expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png'); + + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':poop: :innocent:'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + message = view.querySelector(last_msg_sel); + await u.waitUntil(() => u.isVisible(message.querySelector('.emoji')), 1000); + imgs = message.querySelectorAll('.emoji'); + expect(imgs.length).toBe(2); + expect(imgs[0].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f4a9.png'); + expect(imgs[1].src).toBe(_converse.api.settings.get('emoji_image_path')+'/72x72/1f607.png'); + + const sent_stanzas = _converse.connection.sent_stanzas; + const sent_stanza = sent_stanzas.filter(s => s.nodeName === 'message').pop(); + expect(sent_stanza.querySelector('body').innerHTML).toBe('💩 😇'); + })); + + it("can show custom emojis", + mock.initConverse( + ['chatBoxesFetched'], + { emoji_categories: { + "smileys": ":grinning:", + "people": ":thumbsup:", + "activity": ":soccer:", + "travel": ":motorcycle:", + "objects": ":bomb:", + "nature": ":rainbow:", + "food": ":hotdog:", + "symbols": ":musical_note:", + "flags": ":flag_ac:", + "custom": ':xmpp:' + } }, + 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 toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + toolbar.querySelector('.toggle-emojis').click(); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists')), 1000); + const picker = await u.waitUntil(() => view.querySelector('converse-emoji-picker'), 1000); + const custom_category = picker.querySelector('.pick-category[data-category="custom"]'); + expect(custom_category.innerHTML.replace(/<!-.*?->/g, '').trim()).toBe( + '<img class="emoji" loading="lazy" draggable="false" title=":xmpp:" alt=":xmpp:" src="/dist/images/custom_emojis/xmpp.png">'); + + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = 'Running tests for :converse:'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + const body = view.querySelector('converse-chat-message-body'); + await u.waitUntil(() => body.innerHTML.replace(/<!-.*?->/g, '').trim() === + 'Running tests for <img class="emoji" loading="lazy" draggable="false" title=":converse:" alt=":converse:" src="/dist/images/custom_emojis/converse.png">'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js new file mode 100644 index 0000000..8bbd8f2 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/http-file-upload.js @@ -0,0 +1,477 @@ +/*global mock, converse */ + +const Strophe = converse.env.Strophe; +const $iq = converse.env.$iq; +const u = converse.env.utils; + +describe("XEP-0363: HTTP File Upload", function () { + + describe("Discovering support", function () { + + it("is done automatically", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const { api } = _converse; + const IQ_stanzas = _converse.connection.IQ_stanzas; + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []); + let selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'; + let stanza = await u.waitUntil(() => IQ_stanzas.find(iq => iq.querySelector(selector)), 1000); + + /* <iq type='result' + * from='plays.shakespeare.lit' + * to='romeo@montague.net/orchard' + * id='info1'> + * <query xmlns='http://jabber.org/protocol/disco#info'> + * <identity + * category='server' + * type='im'/> + * <feature var='http://jabber.org/protocol/disco#info'/> + * <feature var='http://jabber.org/protocol/disco#items'/> + * </query> + * </iq> + */ + stanza = $iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': stanza.getAttribute('id'), + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'server', + 'type': 'im'}).up() + .c('feature', { + 'var': 'http://jabber.org/protocol/disco#info'}).up() + .c('feature', { + 'var': 'http://jabber.org/protocol/disco#items'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // Converse.js sees that the entity has a disco#items feature, + // so it will make a query for it. + selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'; + await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length, 1000); + /* <iq from='montague.tld' + * id='step_01' + * to='romeo@montague.tld/garden' + * type='result'> + * <query xmlns='http://jabber.org/protocol/disco#items'> + * <item jid='upload.montague.tld' name='HTTP File Upload' /> + * <item jid='conference.montague.tld' name='Chatroom Service' /> + * </query> + * </iq> + */ + selector = 'iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'; + stanza = IQ_stanzas.find(iq => iq.querySelector(selector), 500); + stanza = $iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': stanza.getAttribute('id'), + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'}) + .c('item', { + 'jid': 'upload.montague.lit', + 'name': 'HTTP File Upload'}); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + + let entities = await api.disco.entities.get(); + expect(entities.length).toBe(3); + expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']); + + expect(entities.get(_converse.domain).features.length).toBe(2); + expect(entities.get(_converse.domain).identities.length).toBe(1); + + api.disco.entities.get().then(entities => { + expect(entities.length).toBe(3); + expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']); + expect(api.disco.entities.items('montague.lit').length).toBe(1); + // Converse.js sees that the entity has a disco#info feature, so it will make a query for it. + const selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'; + return u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).length > 0); + }); + + selector = 'iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]'; + stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop(), 1000); + expect(Strophe.serialize(stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="`+stanza.getAttribute('id')+`" to="upload.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/disco#info"/>`+ + `</iq>`); + + // Upload service responds and reports a maximum file size of 5MiB + /* <iq from='upload.montague.tld' + * id='step_02' + * to='romeo@montague.tld/garden' + * type='result'> + * <query xmlns='http://jabber.org/protocol/disco#info'> + * <identity category='store' + * type='file' + * name='HTTP File Upload' /> + * <feature var='urn:xmpp:http:upload:0' /> + * <x type='result' xmlns='jabber:x:data'> + * <field var='FORM_TYPE' type='hidden'> + * <value>urn:xmpp:http:upload:0</value> + * </field> + * <field var='max-file-size'> + * <value>5242880</value> + * </field> + * </x> + * </query> + * </iq> + */ + stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': stanza.getAttribute('id'), 'from': 'upload.montague.lit'}) + .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up() + .c('feature', {'var':'urn:xmpp:http:upload:0'}).up() + .c('x', {'type':'result', 'xmlns':'jabber:x:data'}) + .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) + .c('value').t('urn:xmpp:http:upload:0').up().up() + .c('field', {'var':'max-file-size'}) + .c('value').t('5242880'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + entities = await _converse.api.disco.entities.get(); + const entity = await api.disco.entities.get('upload.montague.lit'); + expect(entity.get('parent_jids')).toEqual(['montague.lit']); + expect(entity.identities.where({'category': 'store'}).length).toBe(1); + const supported = await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain); + expect(supported).toBe(true); + const features = await _converse.api.disco.features.get(Strophe.NS.HTTPUPLOAD, _converse.domain); + expect(features.length).toBe(1); + expect(features[0].get('jid')).toBe('upload.montague.lit'); + expect(features[0].dataforms.where({'FORM_TYPE': {value: "urn:xmpp:http:upload:0", type: "hidden"}}).length).toBe(1); + })); + }); + + describe("When not supported", function () { + describe("A file upload toolbar button", function () { + + it("does not appear in private chats", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 3); + mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items'); + const view = _converse.chatboxviews.get(contact_jid); + expect(view.querySelector('.chat-toolbar .fileupload')).toBe(null); + })); + }); + }); + + describe("When supported", function () { + + describe("A file upload toolbar button", function () { + + it("appears in private chats", mock.initConverse([], {}, async (_converse) => { + await mock.waitForRoster(_converse, 'current', 3); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items') + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []); + + const el = await u.waitUntil(() => view.querySelector('.chat-toolbar .fileupload')); + expect(el).not.toEqual(null); + })); + + describe("when clicked and a file chosen", function () { + + it("is uploaded and sent out", mock.initConverse(['chatBoxesFetched'], {} ,async (_converse) => { + const base_url = 'https://conversejs.org'; + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + const send_backup = XMLHttpRequest.prototype.send; + const IQ_stanzas = _converse.connection.IQ_stanzas; + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items'); + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []); + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const file = { + 'type': 'image/jpeg', + 'size': '23456' , + 'lastModifiedDate': "", + 'name': "my-juliet.jpg" + }; + view.model.sendFiles([file]); + + await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length); + const iq = IQ_stanzas.pop(); + expect(Strophe.serialize(iq)).toBe( + `<iq from="romeo@montague.lit/orchard" `+ + `id="${iq.getAttribute("id")}" `+ + `to="upload.montague.tld" `+ + `type="get" `+ + `xmlns="jabber:client">`+ + `<request `+ + `content-type="image/jpeg" `+ + `filename="my-juliet.jpg" `+ + `size="23456" `+ + `xmlns="urn:xmpp:http:upload:0"/>`+ + `</iq>`); + + const message = base_url+"/logo/conversejs-filled.svg"; + + const stanza = u.toStanza(` + <iq from="upload.montague.tld" + id="${iq.getAttribute("id")}" + to="romeo@montague.lit/orchard" + type="result"> + <slot xmlns="urn:xmpp:http:upload:0"> + <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg"> + <header name="Authorization">Basic Base64String==</header> + <header name="Cookie">foo=bar; user=romeo</header> + </put> + <get url="${message}" /> + </slot> + </iq>`); + + spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () { + const message = view.model.messages.at(0); + const el = await u.waitUntil(() => view.querySelector('.chat-content progress')); + expect(el.getAttribute('value')).toBe('0'); + message.set('progress', 0.5); + await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5') + message.set('progress', 1); + await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1') + message.save({ + 'upload': _converse.SUCCESS, + 'oob_url': message.get('get'), + 'body': message.get('get'), + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + }); + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => sent_stanza, 1000); + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="lady.montague@montague.lit" `+ + `type="chat" `+ + `xmlns="jabber:client">`+ + `<body>${message}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<x xmlns="jabber:x:oob">`+ + `<url>${message}</url>`+ + `</x>`+ + `<origin-id id="${sent_stanza.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + const img_link_el = await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image__link'), 1000); + // Check that the image renders + expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+ + `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`); + XMLHttpRequest.prototype.send = send_backup; + })); + + it("shows an error message if the file is too large", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const { api } = _converse; + const IQ_stanzas = _converse.connection.IQ_stanzas; + const IQ_ids = _converse.connection.IQ_ids; + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], []); + await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).length + ); + + let stanza = IQ_stanzas.find((iq) => + iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')); + + const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)]; + stanza = $iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': info_IQ_id + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'server', + 'type': 'im'}).up() + .c('feature', { + 'var': 'http://jabber.org/protocol/disco#info'}).up() + .c('feature', { + 'var': 'http://jabber.org/protocol/disco#items'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(function () { + // Converse.js sees that the entity has a disco#items feature, + // so it will make a query for it. + return IQ_stanzas.filter(function (iq) { + return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'); + }).length > 0; + }, 300); + + stanza = IQ_stanzas.find(function (iq) { + return iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]'); + }); + const items_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)]; + stanza = $iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': items_IQ_id + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'}) + .c('item', { + 'jid': 'upload.montague.lit', + 'name': 'HTTP File Upload'}); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + + let entities = await _converse.api.disco.entities.get(); + + expect(entities.length).toBe(3); + expect(entities.get(_converse.domain).features.length).toBe(2); + expect(entities.get(_converse.domain).identities.length).toBe(1); + expect(entities.pluck('jid')).toEqual(['montague.lit', 'romeo@montague.lit', 'upload.montague.lit']); + expect(api.disco.entities.items('montague.lit').length).toBe(1); + await u.waitUntil(function () { + // Converse.js sees that the entity has a disco#info feature, + // so it will make a query for it. + return IQ_stanzas.filter(iq => + iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]') + ).length > 0; + }, 300); + + stanza = IQ_stanzas.find(iq => iq.querySelector('iq[to="upload.montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')); + const IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)]; + expect(Strophe.serialize(stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" to="upload.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/disco#info"/>`+ + `</iq>`); + + // Upload service responds and reports a maximum file size of 5MiB + stanza = $iq({'type': 'result', 'to': 'romeo@montague.lit/orchard', 'id': IQ_id, 'from': 'upload.montague.lit'}) + .c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category':'store', 'type':'file', 'name':'HTTP File Upload'}).up() + .c('feature', {'var':'urn:xmpp:http:upload:0'}).up() + .c('x', {'type':'result', 'xmlns':'jabber:x:data'}) + .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) + .c('value').t('urn:xmpp:http:upload:0').up().up() + .c('field', {'var':'max-file-size'}) + .c('value').t('5242880'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + entities = await _converse.api.disco.entities.get(); + const entity = await api.disco.entities.get('upload.montague.lit'); + expect(entity.get('parent_jids')).toEqual(['montague.lit']); + expect(entity.identities.where({'category': 'store'}).length).toBe(1); + await _converse.api.disco.supports(Strophe.NS.HTTPUPLOAD, _converse.domain); + await mock.waitForRoster(_converse, 'current'); + + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const file = { + 'type': 'image/jpeg', + 'size': '5242881', + 'lastModifiedDate': "", + 'name': "my-juliet.jpg" + }; + view.model.sendFiles([file]); + await u.waitUntil(() => view.querySelectorAll('.message').length) + const messages = view.querySelectorAll('.message.chat-error'); + expect(messages.length).toBe(1); + expect(messages[0].textContent.trim()).toBe( + 'The size of your file, my-juliet.jpg, exceeds the maximum allowed by your server, which is 5.24 MB.'); + })); + }); + }); + + describe("While a file is being uploaded", function () { + + it("shows a progress bar", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items'); + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []); + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + const file = { + 'type': 'image/jpeg', + 'size': '23456' , + 'lastModifiedDate': "", + 'name': "my-juliet.jpg" + }; + view.model.sendFiles([file]); + await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length) + const iq = IQ_stanzas.pop(); + expect(Strophe.serialize(iq)).toBe( + `<iq from="romeo@montague.lit/orchard" `+ + `id="${iq.getAttribute("id")}" `+ + `to="upload.montague.tld" `+ + `type="get" `+ + `xmlns="jabber:client">`+ + `<request `+ + `content-type="image/jpeg" `+ + `filename="my-juliet.jpg" `+ + `size="23456" `+ + `xmlns="urn:xmpp:http:upload:0"/>`+ + `</iq>`); + + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/conversejs-filled.svg"; + const stanza = u.toStanza(` + <iq from="upload.montague.tld" + id="${iq.getAttribute("id")}" + to="romeo@montague.lit/orchard" + type="result"> + <slot xmlns="urn:xmpp:http:upload:0"> + <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg"> + <header name="Authorization">Basic Base64String==</header> + <header name="Cookie">foo=bar; user=romeo</header> + </put> + <get url="${message}" /> + </slot> + </iq>`); + + const promise = u.getOpenPromise(); + + spyOn(XMLHttpRequest.prototype, 'setRequestHeader'); + spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async () => { + const message = view.model.messages.at(0); + const el = await u.waitUntil(() => view.querySelector('.chat-content progress')); + expect(el.getAttribute('value')).toBe('0'); + message.set('progress', 0.5); + await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5'); + message.set('progress', 1); + await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '1'); + expect(view.querySelector('.chat-content .chat-msg__text').textContent).toBe('Uploading file: my-juliet.jpg, 23.46 kB'); + promise.resolve(); + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await promise; + expect(XMLHttpRequest.prototype.setRequestHeader.calls.count()).toBe(2); + expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[0].args[0]).toBe('Content-type'); + expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[0].args[1]).toBe('image/jpeg'); + expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[1].args[0]).toBe('Authorization'); + expect(XMLHttpRequest.prototype.setRequestHeader.calls.all()[1].args[1]).toBe('Basic Base64String=='); + })); + }); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js new file mode 100644 index 0000000..5b61b84 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/markers.js @@ -0,0 +1,114 @@ +/*global mock, converse */ + +const Strophe = converse.env.Strophe; +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("A XEP-0333 Chat Marker", function () { + + it("is sent when a markable message is received from a roster contact", + mock.initConverse([], {}, 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 msgid = u.getUniqueId(); + const stanza = u.toStanza(` + <message from='${contact_jid}' + id='${msgid}' + type="chat" + to='${_converse.jid}'> + <body>My lord, dispatch; read o'er these articles.</body> + <markable xmlns='urn:xmpp:chat-markers:0'/> + </message>`); + + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => sent_stanzas.length === 2); + expect(Strophe.serialize(sent_stanzas[0])).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanzas[0].getAttribute('id')}" `+ + `to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<received id="${msgid}" xmlns="urn:xmpp:chat-markers:0"/>`+ + `</message>`); + })); + + it("is not sent when a markable message is received from someone not on the roster", + mock.initConverse([], {'allow_non_roster_messaging': true}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const contact_jid = 'someone@montague.lit'; + const msgid = u.getUniqueId(); + const stanza = u.toStanza(` + <message from='${contact_jid}' + id='${msgid}' + type="chat" + to='${_converse.jid}'> + <body>My lord, dispatch; read o'er these articles.</body> + <markable xmlns='urn:xmpp:chat-markers:0'/> + </message>`); + + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s)); + await _converse.handleMessageStanza(stanza); + const sent_messages = sent_stanzas + .map(s => s?.nodeTree ?? s) + .filter(e => e.nodeName === 'message'); + + await u.waitUntil(() => sent_messages.length === 2); + expect(Strophe.serialize(sent_messages[0])).toBe( + `<message id="${sent_messages[0].getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>` + ); + })); + + it("is ignored if it's a carbon copy of one that I sent from a different client", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + + 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); + + let stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="2e972ea0-0050-44b7-a830-f6638a2595b3" + from="${contact_jid}"> + <body>😊</body> + <markable xmlns="urn:xmpp:chat-markers:0"/> + <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(1); + + stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.bare_jid}" type="chat" from="${contact_jid}"> + <sent xmlns="urn:xmpp:carbons:2"> + <forwarded xmlns="urn:xmpp:forward:0"> + <message xmlns="jabber:client" to="${contact_jid}" type="chat" from="${_converse.bare_jid}/other-resource"> + <received xmlns="urn:xmpp:chat-markers:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/> + <store xmlns="urn:xmpp:hints"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="F4TC6CvHwzqRbeHb" by="${_converse.bare_jid}"/> + </message> + </forwarded> + </sent> + </message>`); + spyOn(_converse.api, "trigger").and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.api.trigger.calls.count(), 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js new file mode 100644 index 0000000..aed3bf9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/me-messages.js @@ -0,0 +1,56 @@ +/*global mock, converse */ + +const { u, sizzle, $msg } = converse.env; + +describe("A Message", function () { + + it("supports the /me command", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); + await mock.openControlBox(_converse); + expect(_converse.chatboxes.length).toEqual(1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + let message = '/me is tired'; + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + + await _converse.handleMessageStanza(msg); + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(view.querySelectorAll('.chat-msg--action').length).toBe(1); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired'); + expect(view.querySelector('.chat-msg__author').textContent.includes('**Mercutio')).toBeTruthy(); + + message = '/me is as well'; + await mock.sendMessage(view, message); + expect(view.querySelectorAll('.chat-msg--action').length).toBe(2); + await u.waitUntil(() => sizzle('.chat-msg__author:last', view).pop().textContent.trim() === '**Romeo'); + const last_el = sizzle('.chat-msg__text:last', view).pop(); + await u.waitUntil(() => last_el.textContent === 'is as well'); + expect(u.hasClass('chat-msg--followup', last_el)).toBe(false); + + // Check that /me messages after a normal message don't + // get the 'chat-msg--followup' class. + message = 'This a normal message'; + await mock.sendMessage(view, message); + const msg_txt_sel = 'converse-chat-message:last-child .chat-msg__text'; + await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === message); + let el = view.querySelector('converse-chat-message:last-child .chat-msg__body'); + expect(u.hasClass('chat-msg--followup', el)).toBeFalsy(); + + message = '/me wrote a 3rd person message'; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelector(msg_txt_sel).textContent.trim() === message.replace('/me ', '')); + el = view.querySelector('converse-chat-message:last-child .chat-msg__body'); + expect(view.querySelectorAll('.chat-msg--action').length).toBe(3); + + expect(sizzle('.chat-msg__text:last', view).pop().textContent).toBe('wrote a 3rd person message'); + expect(u.isVisible(sizzle('.chat-msg__author:last', view).pop())).toBeTruthy(); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js new file mode 100644 index 0000000..2d4dbf3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-audio.js @@ -0,0 +1,24 @@ +/*global mock, converse */ + +const { sizzle, u } = converse.env; + +describe("A Chat Message", function () { + + it("will render audio files from their URLs", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/audio.mp3"; + + 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); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content audio').length, 1000) + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual( + `<audio controls="" src="${message}"></audio>`+ + `<a target="_blank" rel="noopener" href="${message}">${message}</a>`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js new file mode 100644 index 0000000..5552dc5 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-gifs.js @@ -0,0 +1,23 @@ +/*global mock, converse */ + +const { sizzle, u } = converse.env; + +describe("A Chat Message", function () { + + it("will render gifs from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + const gif_url = 'https://media.giphy.com/media/Byana3FscAMGQ/giphy.gif'; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, gif_url); + await u.waitUntil(() => view.querySelectorAll('.chat-content canvas').length); + expect(view.model.sendMessage).toHaveBeenCalled(); + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + const html = `<converse-gif autoplay="" noloop="" fallback="empty" src="${gif_url}">`+ + `<canvas class="gif-canvas"><img class="gif" src="${gif_url}"></canvas></converse-gif>`+ + `<a target="_blank" rel="noopener" href="${gif_url}">${gif_url}</a>`; + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === html, 1000); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js new file mode 100644 index 0000000..517fd12 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-images.js @@ -0,0 +1,239 @@ +/*global mock, converse */ + +const { sizzle, u } = converse.env; + +describe("A Chat Message", function () { + + it("will render images from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + const base_url = 'https://conversejs.org'; + let message = base_url+"/logo/conversejs-filled.svg"; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000) + expect(view.model.sendMessage).toHaveBeenCalled(); + let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+ + `<img class="chat-image img-thumbnail" loading="lazy" src="https://conversejs.org/logo/conversejs-filled.svg">`+ + `</a>`); + + message += "?param1=val1¶m2=val2"; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 2, 1000); + expect(view.model.sendMessage).toHaveBeenCalled(); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg?param1=val1&param2=val2">`+ + `<img class="chat-image img-thumbnail" loading="lazy" src="${message.replace(/&/g, '&')}">`+ + `</a>`); + + // Test now with two images in one message + message += ' hello world '+base_url+"/logo/conversejs-filled.svg"; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 4, 1000); + expect(view.model.sendMessage).toHaveBeenCalled(); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.textContent.trim()).toEqual('hello world'); + expect(msg.querySelectorAll('img.chat-image').length).toEqual(2); + + // Configured image URLs are rendered + _converse.api.settings.set('image_urls_regex', /^https?:\/\/(?:www.)?(?:imgur\.com\/\w{7})\/?$/i); + message = 'https://imgur.com/oxymPax'; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 5, 1000); + expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(5); + + // Check that the Imgur URL gets a .png attached to make it render + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-content .chat-image')).pop().src.endsWith('png'), 1000); + })); + + it("will not render images if render_media is false", + mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/conversejs-filled.svg"; + + 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); + await mock.sendMessage(view, message); + const sel = '.chat-content .chat-msg:last .chat-msg__text'; + await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message); + expect(true).toBe(true); + })); + + it("will automatically render images from approved URLs only", + mock.initConverse( + ['chatBoxesFetched'], {'render_media': ['imgur.com']}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const base_url = 'https://conversejs.org'; + let message = 'https://imgur.com/oxymPax.png'; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1); + + message = base_url+"/logo/conversejs-filled.svg"; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 2, 1000); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length === 1, 1000) + expect(view.querySelectorAll('.chat-content .chat-image').length).toBe(1); + })); + + it("will automatically update its rendering of media and the message actions when settings change", + mock.initConverse( + ['chatBoxesFetched'], {'render_media': ['imgur.com']}, + async function (_converse) { + + const { api } = _converse; + await mock.waitForRoster(_converse, 'current'); + const message = 'https://imgur.com/oxymPax.png'; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1); + + const actions_el = view.querySelector('converse-message-actions'); + await u.waitUntil(() => actions_el.textContent.includes('Hide media')); + + actions_el.querySelector('.chat-msg__action-hide-previews').click(); + await u.waitUntil(() => !view.querySelector('converse-chat-message-body img')); + await u.waitUntil(() => actions_el.textContent.includes('Show media')); + + actions_el.querySelector('.chat-msg__action-hide-previews').click(); + await u.waitUntil(() => actions_el.textContent.includes('Hide media')); + + api.settings.set('render_media', false); + await u.waitUntil(() => actions_el.textContent.includes('Show media')); + await u.waitUntil(() => !view.querySelector('converse-chat-message-body img')); + + actions_el.querySelector('.chat-msg__action-hide-previews').click(); + await u.waitUntil(() => actions_el.textContent.includes('Hide media')); + + api.settings.set('render_media', ['imgur.com']); + await u.waitUntil(() => actions_el.textContent.includes('Hide media')); + await u.waitUntil(() => view.querySelector('converse-chat-message-body img')); + + api.settings.set('render_media', ['conversejs.org']); + await u.waitUntil(() => actions_el.textContent.includes('Show media')); + await u.waitUntil(() => !view.querySelector('converse-chat-message-body img')); + + api.settings.set('allowed_image_domains', ['conversejs.org']); + await u.waitUntil(() => !actions_el.textContent.includes('Show media')); + expect(actions_el.textContent.includes('Hide media')).toBe(false); + + api.settings.set('render_media', ['imgur.com']); + return new Promise(resolve => setTimeout(() => { + expect(actions_el.textContent.includes('Hide media')).toBe(false); + expect(actions_el.textContent.includes('Show media')).toBe(false); + expect(view.querySelector('converse-chat-message-body img')).toBe(null); + resolve(); + }, 500)); + })); + + + it("will fall back to rendering images as URLs", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/non-existing.svg"; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-image').length, 1000) + expect(view.model.sendMessage).toHaveBeenCalled(); + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() == + `<a target="_blank" rel="noopener" href="https://conversejs.org/logo/non-existing.svg">https://conversejs.org/logo/non-existing.svg</a>`, 1000); + })); + + it("will fall back to rendering URLs that match image_urls_regex as URLs", + mock.initConverse( + ['rosterGroupsFetched', 'chatBoxesFetched'], { + 'render_media': true, + 'image_urls_regex': /^https?:\/\/(www.)?(pbs\.twimg\.com\/)/i + }, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const message = "https://pbs.twimg.com/media/string?format=jpg&name=small"; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + expect(view.model.sendMessage).toHaveBeenCalled(); + await u.waitUntil(() => view.querySelector('.chat-content .chat-msg'), 1000); + const msg = view.querySelector('.chat-content .chat-msg .chat-msg__text'); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() == + `<a target="_blank" rel="noopener" href="https://pbs.twimg.com/media/string?format=jpg&name=small">https://pbs.twimg.com/media/string?format=jpg&name=small</a>`, 1000); + })); + + it("will respect a changed allowed_image_domains setting when re-rendered", + mock.initConverse( + ['chatBoxesFetched'], {'render_media': true}, + async function (_converse) { + + const { api } = _converse; + await mock.waitForRoster(_converse, 'current'); + const message = 'https://imgur.com/oxymPax.png'; + 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); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('converse-chat-message-body .chat-image').length === 1); + expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null); + + api.settings.set('allowed_image_domains', []); + + await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image') === null); + expect(view.querySelector('.chat-msg__action-hide-previews')).toBe(null); + + api.settings.set('allowed_image_domains', null); + await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image')); + expect(view.querySelector('.chat-msg__action-hide-previews')).not.toBe(null); + })); + + it("will allow the user to toggle visibility of rendered images", + mock.initConverse(['chatBoxesFetched'], {'render_media': true}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + // let message = "https://i.imgur.com/Py9ifJE.mp4"; + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/conversejs-filled.svg"; + + 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); + await mock.sendMessage(view, message); + + const sel = '.chat-content .chat-msg:last .chat-msg__text'; + await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message); + + const actions_el = view.querySelector('converse-message-actions'); + await u.waitUntil(() => actions_el.textContent.includes('Hide media')); + await u.waitUntil(() => view.querySelector('converse-chat-message-body img')); + + actions_el.querySelector('.chat-msg__action-hide-previews').click(); + await u.waitUntil(() => actions_el.textContent.includes('Show media')); + await u.waitUntil(() => !view.querySelector('converse-chat-message-body img')); + + expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim()) + .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`) + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js new file mode 100644 index 0000000..dfa388e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/message-videos.js @@ -0,0 +1,98 @@ +/*global mock, converse */ + +const { Strophe, sizzle, u } = converse.env; + +describe("A chat message containing video URLs", function () { + + it("will render videos from their URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + // let message = "https://i.imgur.com/Py9ifJE.mp4"; + const base_url = 'https://conversejs.org'; + let message = base_url+"/logo/conversejs-filled.mp4"; + + 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); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000) + let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<video controls="" preload="metadata" src="${message}"></video>`+ + `<a target="_blank" rel="noopener" href="${message}">${message}</a>`); + + message += "?param1=val1¶m2=val2"; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content video').length === 2, 1000); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<video controls="" preload="metadata" src="${Strophe.xmlescape(message)}"></video>`+ + `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(message)}">${Strophe.xmlescape(message)}</a>`); + })); + + it("will not render videos if render_media is false", + mock.initConverse(['chatBoxesFetched'], {'render_media': false}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + // let message = "https://i.imgur.com/Py9ifJE.mp4"; + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/conversejs-filled.mp4"; + + 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); + await mock.sendMessage(view, message); + const sel = '.chat-content .chat-msg:last .chat-msg__text'; + await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message); + expect(true).toBe(true); + })); + + it("will allow rendering of videos from approved URLs only", + mock.initConverse( + ['chatBoxesFetched'], {'allowed_video_domains': ['conversejs.org']}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + let message = "https://i.imgur.com/Py9ifJE.mp4"; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length === 1); + + const base_url = 'https://conversejs.org'; + message = base_url+"/logo/conversejs-filled.mp4"; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content video').length, 1000) + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text').pop(); + expect(msg.innerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<video controls="" preload="metadata" src="${message}"></video>`+ + `<a target="_blank" rel="noopener" href="${message}">${message}</a>`); + })); + + it("will allow the user to toggle visibility of rendered videos", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + // let message = "https://i.imgur.com/Py9ifJE.mp4"; + const base_url = 'https://conversejs.org'; + const message = base_url+"/logo/conversejs-filled.mp4"; + + 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); + await mock.sendMessage(view, message); + const sel = '.chat-content .chat-msg:last .chat-msg__text'; + await u.waitUntil(() => sizzle(sel).pop().innerHTML.replace(/<!-.*?->/g, '').trim() === message); + + const actions_el = view.querySelector('converse-message-actions'); + await u.waitUntil(() => actions_el.textContent.includes('Hide media')); + await u.waitUntil(() => view.querySelector('converse-chat-message-body video')); + + actions_el.querySelector('.chat-msg__action-hide-previews').click(); + await u.waitUntil(() => actions_el.textContent.includes('Show media')); + await u.waitUntil(() => !view.querySelector('converse-chat-message-body video')); + + expect(view.querySelector('converse-chat-message-body').innerHTML.replace(/<!-.*?->/g, '').trim()) + .toBe(`<a target="_blank" rel="noopener" href="${message}">${message}</a>`) + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js new file mode 100644 index 0000000..777494c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/messages.js @@ -0,0 +1,1331 @@ +/*global mock, converse */ + +const { Promise, Strophe, $msg, dayjs, sizzle, u } = converse.env; + + +describe("A Chat Message", function () { + + it("will be demarcated if it's the first newly received message", + 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); + await _converse.handleMessageStanza(mock.createChatMessage(_converse, contact_jid, 'This message will be read')); + await u.waitUntil(() => view.querySelector('converse-chat-message .chat-msg__text')?.textContent === 'This message will be read'); + expect(view.model.get('num_unread')).toBe(0); + + _converse.windowState = 'hidden'; + await _converse.handleMessageStanza(mock.createChatMessage(_converse, contact_jid, 'This message will be new')); + + await u.waitUntil(() => view.model.messages.length); + expect(view.model.get('num_unread')).toBe(1); + expect(view.model.get('first_unread_id')).toBe(view.model.messages.last().get('id')); + + await u.waitUntil(() => view.querySelectorAll('converse-chat-message').length === 2); + await u.waitUntil(() => view.querySelector('converse-chat-message:last-child .chat-msg__text')?.textContent === 'This message will be new'); + const last_msg_el = view.querySelector('converse-chat-message:last-child'); + expect(last_msg_el.firstElementChild?.textContent).toBe('New messages'); + })); + + + it("is rejected if it's an unencapsulated forwarded message", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 2); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const forwarded_contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + let models = await _converse.api.chats.get(); + expect(models.length).toBe(1); + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${contact_jid}' type='chat' id='${_converse.connection.getUniqueId()}'> + <body>A most courteous exposition!</body> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-07-10T23:08:25Z'/> + <message from='${forwarded_contact_jid}' + id='0202197' + to='${_converse.bare_jid}' + type='chat' + xmlns='jabber:client'> + <body>Yet I should kill thee with much cherishing.</body> + <mood xmlns='http://jabber.org/protocol/mood'> + <amorous/> + </mood> + </message> + </forwarded> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(received_stanza)); + const sent_stanzas = _converse.connection.sent_stanzas; + const sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('error')).pop()); + expect(Strophe.serialize(sent_stanza)).toBe( + `<message id="${received_stanza.getAttribute('id')}" to="${contact_jid}" type="error" xmlns="jabber:client">`+ + '<error type="cancel">'+ + '<not-allowed xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>'+ + '<text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">'+ + 'Forwarded messages not part of an encapsulating protocol are not supported</text>'+ + '</error>'+ + '</message>'); + models = await _converse.api.chats.get(); + expect(models.length).toBe(1); + })); + + it("can be received out of order, and will still be displayed in the right order", + mock.initConverse([], {}, async function (_converse) { + + const { api } = _converse; + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length) + api.settings.set('filter_by_resource', true); + + let msg = $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat'}) + .c('body').t("message").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T13:08:25Z'}) + .tree(); + await _converse.handleMessageStanza(msg); + const view = _converse.chatboxviews.get(sender_jid); + + msg = $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat'}) + .c('body').t("Older message").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2017-12-31T22:08:25Z'}) + .tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + + msg = $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat'}) + .c('body').t("Inbetween message").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'}) + .tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3); + + msg = $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat'}) + .c('body').t("another inbetween message").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-01T13:18:23Z'}) + .tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4); + + msg = $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat'}) + .c('body').t("An earlier message on the next day").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T12:18:23Z'}) + .tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 5); + + msg = $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat'}) + .c('body').t("newer message from the next day").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':'2018-01-02T22:28:23Z'}) + .tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 6); + + // Insert <composing> message, to also check that + // text messages are inserted correctly with + // temporary chat events in the chat contents. + msg = $msg({ + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'xmlns': 'jabber:client', + 'from': sender_jid, + 'type': 'chat'}) + .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up() + .tree(); + _converse.handleMessageStanza(msg); + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual('Mercutio is typing'); + + msg = $msg({ + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'xmlns': 'jabber:client', + 'from': sender_jid, + 'type': 'chat'}) + .c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up() + .c('body').t("latest message") + .tree(); + + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 7); + + expect(view.querySelectorAll('.date-separator').length).toEqual(4); + + let day = sizzle('.date-separator:first', view).pop(); + expect(day.getAttribute('data-isodate')).toEqual(dayjs('2017-12-31T00:00:00').toISOString()); + + let time = sizzle('time:first', view).pop(); + expect(time.textContent).toEqual('Sunday Dec 31st 2017') + + day = sizzle('.date-separator:first', view).pop(); + expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Older message'); + + let el = sizzle('.chat-msg:first', view).pop().querySelector('.chat-msg__text') + expect(u.hasClass('chat-msg--followup', el)).toBe(false); + expect(el.textContent).toEqual('Older message'); + + time = sizzle('time.separator-text:eq(1)', view).pop(); + expect(time.textContent).toEqual("Monday Jan 1st 2018"); + + day = sizzle('.date-separator:eq(1)', view).pop(); + expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-01T00:00:00').toISOString()); + expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('Inbetween message'); + + el = sizzle('.chat-msg:eq(1)', view).pop(); + expect(el.querySelector('.chat-msg__text').textContent).toEqual('Inbetween message'); + expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('another inbetween message'); + el = sizzle('.chat-msg:eq(2)', view).pop(); + expect(el.querySelector('.chat-msg__text').textContent) + .toEqual('another inbetween message'); + expect(u.hasClass('chat-msg--followup', el)).toBe(true); + + time = sizzle('time.separator-text:nth(2)', view).pop(); + expect(time.textContent).toEqual("Tuesday Jan 2nd 2018"); + + day = sizzle('.date-separator:nth(2)', view).pop(); + expect(day.getAttribute('data-isodate')).toEqual(dayjs('2018-01-02T00:00:00').toISOString()); + expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('An earlier message on the next day'); + + el = sizzle('.chat-msg:eq(3)', view).pop(); + expect(el.querySelector('.chat-msg__text').textContent).toEqual('An earlier message on the next day'); + expect(u.hasClass('chat-msg--followup', el)).toBe(false); + + el = sizzle('.chat-msg:eq(4)', view).pop(); + expect(el.querySelector('.chat-msg__text').textContent).toEqual('message'); + expect(el.parentElement.nextElementSibling.querySelector('.chat-msg__text').textContent).toEqual('newer message from the next day'); + expect(u.hasClass('chat-msg--followup', el)).toBe(false); + + day = sizzle('.date-separator:last', view).pop(); + expect(day.getAttribute('data-isodate')).toEqual(dayjs().startOf('day').toISOString()); + expect(day.nextElementSibling.querySelector('.chat-msg__text').textContent).toBe('latest message'); + expect(u.hasClass('chat-msg--followup', el)).toBe(false); + })); + + it("is ignored if it's a malformed headline message", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + // Ideally we wouldn't have to filter out headline + // messages, but Prosody gives them the wrong 'type' :( + spyOn(converse.env.log, 'info'); + spyOn(_converse.api.chatboxes, 'get'); + const msg = $msg({ + from: 'montague.lit', + to: _converse.bare_jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t("This headline message will not be shown").tree(); + await _converse.handleMessageStanza(msg); + expect(converse.env.log.info).toHaveBeenCalledWith( + "handleMessageStanza: Ignoring incoming server message from JID: montague.lit" + ); + expect(_converse.api.chatboxes.get).not.toHaveBeenCalled(); + })); + + it("will render Openstreetmap-URL from geo-URI", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const message = "geo:37.786971,-122.399677"; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg').length, 1000); + expect(view.model.sendMessage).toHaveBeenCalled(); + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + await u.waitUntil(() => msg.innerHTML.replace(/\<!-.*?-\>/g, '') === + '<a target="_blank" rel="noopener" href="https://www.openstreetmap.org/?mlat=37.786971&'+ + 'mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&mlon=-122.399677#map=18/37.786971/-122.399677</a>'); + })); + + it("can be a carbon message, as defined in XEP-0280", + mock.initConverse([], {}, async function (_converse) { + + const include_nick = false; + await mock.waitForRoster(_converse, 'current', 2, include_nick); + await mock.openControlBox(_converse); + + // Send a message from a different resource + const msgtext = 'This is a carbon message'; + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + 'from': _converse.bare_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('received', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': sender_jid, + 'to': _converse.bare_jid+'/another-resource', + 'type': 'chat' + }).c('body').t(msgtext).tree(); + + await _converse.handleMessageStanza(msg); + const chatbox = _converse.chatboxes.get(sender_jid); + const view = _converse.chatboxviews.get(sender_jid); + + expect(chatbox).toBeDefined(); + expect(view).toBeDefined(); + // Check that the message was received and check the message parameters + await u.waitUntil(() => chatbox.messages.length); + const msg_obj = chatbox.messages.models[0]; + expect(msg_obj.get('message')).toEqual(msgtext); + expect(msg_obj.get('fullname')).toBeUndefined(); + expect(msg_obj.get('nickname')).toBe(null); + expect(msg_obj.get('sender')).toEqual('them'); + expect(msg_obj.get('is_delayed')).toEqual(false); + // Now check that the message appears inside the chatbox in the DOM + await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text')); + + expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(msgtext); + expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy(); + await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet') + expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet'); + })); + + it("can be a carbon message that this user sent from a different client, as defined in XEP-0280", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + // Send a message from a different resource + const msgtext = 'This is a sent carbon message'; + const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + 'from': _converse.bare_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('sent', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': _converse.bare_jid+'/another-resource', + 'to': recipient_jid, + 'type': 'chat' + }).c('body').t(msgtext).tree(); + + await _converse.handleMessageStanza(msg); + // Check that the chatbox and its view now exist + const chatbox = await _converse.api.chats.get(recipient_jid); + const view = _converse.chatboxviews.get(recipient_jid); + expect(chatbox).toBeDefined(); + expect(view).toBeDefined(); + + // Check that the message was received and check the message parameters + expect(chatbox.messages.length).toEqual(1); + const msg_obj = chatbox.messages.models[0]; + expect(msg_obj.get('message')).toEqual(msgtext); + expect(msg_obj.get('fullname')).toEqual(_converse.xmppstatus.get('fullname')); + expect(msg_obj.get('sender')).toEqual('me'); + expect(msg_obj.get('is_delayed')).toEqual(false); + // Now check that the message appears inside the chatbox in the DOM + const msg_el = await u.waitUntil(() => view.querySelector('.chat-content .chat-msg .chat-msg__text')); + expect(msg_el.textContent).toEqual(msgtext); + })); + + it("will be discarded if it's a malicious message meant to look like a carbon copy", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + /* <message from="mallory@evil.example" to="b@xmpp.example"> + * <received xmlns='urn:xmpp:carbons:2'> + * <forwarded xmlns='urn:xmpp:forward:0'> + * <message from="alice@xmpp.example" to="bob@xmpp.example/client1"> + * <body>Please come to Creepy Valley tonight, alone!</body> + * </message> + * </forwarded> + * </received> + * </message> + */ + const msgtext = 'Please come to Creepy Valley tonight, alone!'; + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const impersonated_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + 'from': sender_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('received', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': impersonated_jid, + 'to': _converse.connection.jid, + 'type': 'chat' + }).c('body').t(msgtext).tree(); + await _converse.handleMessageStanza(msg); + + // Check that chatbox for impersonated user is not created. + let chatbox = await _converse.api.chats.get(impersonated_jid); + expect(chatbox).toBe(null); + + // Check that the chatbox for the malicous user is not created + chatbox = await _converse.api.chats.get(sender_jid); + expect(chatbox).toBe(null); + })); + + it("will indicate when it has a time difference of more than a day between it and its predecessor", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const include_nick = false; + await mock.waitForRoster(_converse, 'current', 2, include_nick); + await mock.openControlBox(_converse); + spyOn(_converse.api, "trigger").and.callThrough(); + const contact_name = mock.cur_names[1]; + const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + + const one_day_ago = dayjs().subtract(1, 'day'); + const chatbox = _converse.chatboxes.get(contact_jid); + const view = _converse.chatboxviews.get(contact_jid); + + let message = 'This is a day old message'; + let msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: one_day_ago.toDate().getTime() + }).c('body').t(message).up() + .c('delay', { xmlns:'urn:xmpp:delay', from: 'montague.lit', stamp: one_day_ago.toISOString() }) + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + expect(chatbox.messages.length).toEqual(1); + let msg_obj = chatbox.messages.models[0]; + expect(msg_obj.get('message')).toEqual(message); + expect(msg_obj.get('fullname')).toBeUndefined(); + expect(msg_obj.get('nickname')).toBe(null); + expect(msg_obj.get('sender')).toEqual('them'); + expect(msg_obj.get('is_delayed')).toEqual(true); + await u.waitUntil(() => chatbox.vcard.get('fullname') === 'Juliet Capulet') + expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message); + expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy(); + expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Juliet Capulet'); + + expect(view.querySelectorAll('.date-separator').length).toEqual(1); + let day = view.querySelector('.date-separator'); + expect(day.getAttribute('class')).toEqual('message date-separator'); + expect(day.getAttribute('data-isodate')).toEqual(dayjs(one_day_ago.startOf('day')).toISOString()); + + let time = view.querySelector('time.separator-text'); + expect(time.textContent).toEqual(dayjs(one_day_ago.startOf('day')).format("dddd MMM Do YYYY")); + + message = 'This is a current message'; + msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: new Date().getTime() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + // Check that there is a <time> element, with the required props. + expect(view.querySelectorAll('time.separator-text').length).toEqual(2); // There are now two time elements + + const message_date = new Date(); + day = sizzle('.date-separator:last', view); + expect(day.length).toEqual(1); + expect(day[0].getAttribute('class')).toEqual('message date-separator'); + expect(day[0].getAttribute('data-isodate')).toEqual(dayjs(message_date).startOf('day').toISOString()); + + time = sizzle('time.separator-text:last', view).pop(); + expect(time.textContent).toEqual(dayjs(message_date).startOf('day').format("dddd MMM Do YYYY")); + + // Normal checks for the 2nd message + expect(chatbox.messages.length).toEqual(2); + msg_obj = chatbox.messages.models[1]; + expect(msg_obj.get('message')).toEqual(message); + expect(msg_obj.get('fullname')).toBeUndefined(); + expect(msg_obj.get('sender')).toEqual('them'); + expect(msg_obj.get('is_delayed')).toEqual(false); + const msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent; + expect(msg_txt).toEqual(message); + + expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').textContent).toEqual(message); + expect(view.querySelector('converse-chat-message:last-child .chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy(); + expect(view.querySelector('converse-chat-message:last-child .chat-msg__author').textContent.trim()).toBe('Juliet Capulet'); + })); + + it("is sanitized to prevent Javascript injection attacks", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + 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 message = '<p>This message contains <em>some</em> <b>markup</b></p>'; + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + expect(view.model.sendMessage).toHaveBeenCalled(); + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('<p>This message contains <em>some</em> <b>markup</b></p>'); + })); + + it("can contain hyperlinks, which will be clickable", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + 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 message = 'This message contains a hyperlink: www.opkode.com'; + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + expect(view.model.sendMessage).toHaveBeenCalled(); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + 'This message contains a hyperlink: <a target="_blank" rel="noopener" href="http://www.opkode.com">www.opkode.com</a>'); + })); + + it("will remove url query parameters from hyperlinks as set", + mock.initConverse(['chatBoxesFetched'], {'filter_url_query_params': ['utm_medium', 'utm_content', 's']}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + 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); + let message = 'This message contains a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1'; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + 'This message contains a hyperlink with forbidden query params: <a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0">https://www.opkode.com/?id=0</a>'); + + // Test assigning a string to filter_url_query_params + _converse.api.settings.set('filter_url_query_params', 'utm_medium'); + message = 'Another message with a hyperlink with forbidden query params: https://www.opkode.com/?id=0&utm_content=1&utm_medium=2&s=1'; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + 'Another message with a hyperlink with forbidden query params: '+ + '<a target="_blank" rel="noopener" href="https://www.opkode.com/?id=0&utm_content=1&s=1">https://www.opkode.com/?id=0&utm_content=1&s=1</a>'); + })); + + it("properly renders URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + 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 message = 'https://mov.im/?node/pubsub.movim.eu/Dino/urn-uuid-979bd24f-0bf3-5099-9fa7-510b9ce9a884'; + await mock.sendMessage(view, message); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + const anchor = await u.waitUntil(() => msg.querySelector('a')); + expect(anchor.innerHTML.replace(/<!-.*?->/g, '')).toBe(message); + expect(anchor.getAttribute('href')).toBe(message); + })); + + it("will render newlines", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + let stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Hey\nHave you heard the news?</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(view.querySelector('.chat-msg__text').innerHTML.replace(/<!-.*?->/g, '')).toBe('Hey\nHave you heard the news?'); + stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Hey\n\n\nHave you heard the news?</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + const text = view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, ''); + expect(text).toBe('Hey\n\u200B\nHave you heard the news?'); + stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Hey\nHave you heard\nthe news?</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + expect(view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, '')).toBe('Hey\nHave you heard\nthe news?'); + + stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Hey\nHave you heard\n\n\nthe news?\nhttps://conversejs.org</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); + await u.waitUntil(() => { + const text = view.querySelector('converse-chat-message:last-child .chat-msg__text').innerHTML.replace(/<!-.*?->/g, ''); + return text === 'Hey\nHave you heard\n\u200B\nthe news?\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'; + }); + })); + + it("will render the message time as configured", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + const { api } = _converse; + await mock.waitForRoster(_converse, 'current'); + api.settings.set('time_format', 'hh:mm'); + 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 message = 'This message is sent from this chatbox'; + await mock.sendMessage(view, message); + + const chatbox = await _converse.api.chats.get(contact_jid); + expect(chatbox.messages.models.length, 1); + const msg_object = chatbox.messages.models[0]; + + const msg_author = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__author'); + expect(msg_author.textContent.trim()).toBe('Romeo'); + + const msg_time = view.querySelector('.chat-content .chat-msg:last-child .chat-msg__time'); + const time = dayjs(msg_object.get('time')).format(api.settings.get('time_format')); + expect(msg_time.textContent).toBe(time); + })); + + it("will be correctly identified and rendered as a followup message", + mock.initConverse( + [], {'debounced_content_rendering': false}, + async function (_converse) { + + const { api } = _converse; + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const base_time = new Date(); + const ONE_MINUTE_LATER = 60000; + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + api.settings.set('filter_by_resource', true); + + jasmine.clock().install(); + jasmine.clock().mockDate(base_time); + + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('body').t('A message').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await new Promise(resolve => _converse.on('chatBoxViewInitialized', resolve)); + const view = _converse.chatboxviews.get(sender_jid); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + jasmine.clock().tick(3*ONE_MINUTE_LATER); + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('body').t("Another message 3 minutes later").up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + jasmine.clock().tick(11*ONE_MINUTE_LATER); + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('body').t("Another message 14 minutes since we started").up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + jasmine.clock().tick(1*ONE_MINUTE_LATER); + + _converse.handleMessageStanza($msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': _converse.connection.getUniqueId() + }).c('body').t("Another message 1 minute and 1 second since the previous one").up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + jasmine.clock().tick(1*ONE_MINUTE_LATER); + await mock.sendMessage(view, "Another message within 10 minutes, but from a different person"); + + await u.waitUntil(() => view.querySelectorAll('.message').length === 6); + expect(view.querySelectorAll('.chat-msg').length).toBe(5); + + const nth_child = (n) => `converse-chat-message:nth-child(${n}) .chat-msg`; + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false); + expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message"); + + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true); + expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe( + "Another message 3 minutes later"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(false); + expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe( + "Another message 14 minutes since we started"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(true); + expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe( + "Another message 1 minute and 1 second since the previous one"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(false); + expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe( + "Another message within 10 minutes, but from a different person"); + + // Let's add a delayed, inbetween message + _converse.handleMessageStanza( + $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat' + }).c('body').t("A delayed message, sent 5 minutes since we started").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp': dayjs(base_time).add(5, 'minutes').toISOString()}) + .tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.querySelectorAll('.message').length).toBe(7); + expect(view.querySelectorAll('.chat-msg').length).toBe(6); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false); + expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message"); + + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true); + expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe( + "Another message 3 minutes later"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(true); + expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe( + "A delayed message, sent 5 minutes since we started"); + + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(false); + expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe( + "Another message 14 minutes since we started"); + + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(true); + expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe( + "Another message 1 minute and 1 second since the previous one"); + + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(7)))).toBe(false); + expect(view.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe( + "Another message within 10 minutes, but from a different person"); + + _converse.handleMessageStanza( + $msg({ + 'xmlns': 'jabber:client', + 'id': _converse.connection.getUniqueId(), + 'to': sender_jid, + 'from': _converse.bare_jid+"/some-other-resource", + 'type': 'chat'}) + .c('body').t("A carbon message 4 minutes later").up() + .c('delay', {'xmlns': 'urn:xmpp:delay', 'stamp':dayjs(base_time).add(4, 'minutes').toISOString()}) + .tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.querySelectorAll('.chat-msg').length).toBe(7); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false); + expect(view.querySelector(`${nth_child(2)} .chat-msg__text`).textContent).toBe("A message"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true); + expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe( + "Another message 3 minutes later"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(4)))).toBe(false); + expect(view.querySelector(`${nth_child(4)} .chat-msg__text`).textContent).toBe( + "A carbon message 4 minutes later"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(5)))).toBe(true); + expect(view.querySelector(`${nth_child(5)} .chat-msg__text`).textContent).toBe( + "A delayed message, sent 5 minutes since we started"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(6)))).toBe(false); + expect(view.querySelector(`${nth_child(6)} .chat-msg__text`).textContent).toBe( + "Another message 14 minutes since we started"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(7)))).toBe(true); + expect(view.querySelector(`${nth_child(7)} .chat-msg__text`).textContent).toBe( + "Another message 1 minute and 1 second since the previous one"); + expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(8)))).toBe(false); + expect(view.querySelector(`${nth_child(8)} .chat-msg__text`).textContent).toBe( + "Another message within 10 minutes, but from a different person"); + + jasmine.clock().uninstall(); + + })); + + + describe("when sent", function () { + + it("will appear inside the chatbox it was sent from", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + spyOn(_converse.api, "trigger").and.callThrough(); + 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 message = 'This message is sent from this chatbox'; + spyOn(view.model, 'sendMessage').and.callThrough(); + await mock.sendMessage(view, message); + expect(view.model.sendMessage).toHaveBeenCalled(); + expect(view.model.messages.length, 2); + expect(sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop().textContent).toEqual(message); + })); + + + it("will be trimmed of leading and trailing whitespace", + 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 message = ' \nThis message is sent from this chatbox \n \n'; + await mock.sendMessage(view, message); + expect(view.model.messages.at(0).get('message')).toEqual(message.trim()); + const message_el = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(message_el.textContent).toEqual(message.trim()); + })); + }); + + + describe("when received from someone else", function () { + + it("will open a chatbox and be displayed inside it", + mock.initConverse([], {}, async function (_converse) { + + const include_nick = false; + await mock.waitForRoster(_converse, 'current', 1, include_nick); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300); + spyOn(_converse.api, "trigger").and.callThrough(); + const message = 'This is a received message'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + // We don't already have an open chatbox for this user + expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined(); + await _converse.handleMessageStanza( + $msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree() + ); + const chatbox = await _converse.chatboxes.get(sender_jid); + expect(chatbox).toBeDefined(); + const view = _converse.chatboxviews.get(sender_jid); + expect(view).toBeDefined(); + + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + // Check that the message was received and check the message parameters + await u.waitUntil(() => chatbox.messages.length); + expect(chatbox.messages.length).toEqual(1); + const msg_obj = chatbox.messages.models[0]; + expect(msg_obj.get('message')).toEqual(message); + expect(msg_obj.get('fullname')).toBeUndefined(); + expect(msg_obj.get('sender')).toEqual('them'); + expect(msg_obj.get('is_delayed')).toEqual(false); + // Now check that the message appears inside the chatbox in the DOM + const mel = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text')); + expect(mel.textContent).toEqual(message); + expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy(); + await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0]); + expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio'); + })); + + it("will be trimmed of leading and trailing whitespace", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1, false); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length, 300); + const message = '\n\n This is a received message \n\n'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await _converse.handleMessageStanza( + $msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree() + ); + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.model.messages.length); + expect(view.model.messages.length).toEqual(1); + const msg_obj = view.model.messages.at(0); + expect(msg_obj.get('message')).toEqual(message.trim()); + const mel = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__text')); + expect(mel.textContent).toEqual(message.trim()); + })); + + + describe("when a chatbox is opened for someone who is not in the roster", function () { + + it("the VCard for that user is fetched and the chatbox updated with the results", + mock.initConverse([], {'allow_non_roster_messaging': true}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + spyOn(_converse.api, "trigger").and.callThrough(); + + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + var vcard_fetched = false; + spyOn(_converse.api.vcard, "get").and.callFake(function () { + vcard_fetched = true; + return Promise.resolve({ + 'fullname': mock.cur_names[0], + 'vcard_updated': (new Date()).toISOString(), + 'jid': sender_jid + }); + }); + const message = 'This is a received message from someone not on the roster'; + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + + // We don't already have an open chatbox for this user + expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined(); + + await _converse.handleMessageStanza(msg); + const view = await u.waitUntil(() => _converse.chatboxviews.get(sender_jid)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + + // Check that the chatbox and its view now exist + const chatbox = await _converse.api.chats.get(sender_jid); + expect(chatbox.get('fullname') === sender_jid); + + await u.waitUntil(() => view.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio'); + let author_el = view.querySelector('.chat-msg__author'); + expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy(); + await u.waitUntil(() => vcard_fetched, 100); + expect(_converse.api.vcard.get).toHaveBeenCalled(); + await u.waitUntil(() => chatbox.vcard.get('fullname') === mock.cur_names[0]) + author_el = view.querySelector('.chat-msg__author'); + expect(author_el.textContent.trim().includes('Mercutio')).toBeTruthy(); + })); + }); + + + describe("who is not on the roster", function () { + + it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true", + mock.initConverse( + [], {'allow_non_roster_messaging': false}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + + const { api } = _converse; + spyOn(api, "trigger").and.callThrough(); + const message = 'This is a received message from someone not on the roster'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + + // We don't already have an open chatbox for this user + expect(_converse.chatboxes.get(sender_jid)).not.toBeDefined(); + + let chatbox = await _converse.api.chats.get(sender_jid); + expect(chatbox).toBe(null); + await _converse.handleMessageStanza(msg); + let view = _converse.chatboxviews.get(sender_jid); + expect(view).not.toBeDefined(); + + api.settings.set('allow_non_roster_messaging', true); + await _converse.handleMessageStanza(msg); + view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + // Check that the chatbox and its view now exist + chatbox = await _converse.api.chats.get(sender_jid); + expect(chatbox).toBeDefined(); + expect(view).toBeDefined(); + // Check that the message was received and check the message parameters + expect(chatbox.messages.length).toEqual(1); + const msg_obj = chatbox.messages.models[0]; + expect(msg_obj.get('message')).toEqual(message); + expect(msg_obj.get('fullname')).toEqual(undefined); + expect(msg_obj.get('sender')).toEqual('them'); + expect(msg_obj.get('is_delayed')).toEqual(false); + + await u.waitUntil(() => view.querySelector('.chat-msg__author').textContent.trim() === 'Mercutio'); + // Now check that the message appears inside the chatbox in the DOM + expect(view.querySelector('.chat-msg .chat-msg__text').textContent).toEqual(message); + expect(view.querySelector('.chat-msg__time').textContent.match(/^[0-9][0-9]:[0-9][0-9]/)).toBeTruthy(); + expect(view.querySelector('span.chat-msg__author').textContent.trim()).toBe('Mercutio'); + })); + }); + + describe("and for which then an error message is received from the server", function () { + + it("will have the error message displayed after itself", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await mock.openChatBoxFor(_converse, sender_jid); + + // TODO: what could still be done for error + // messages... if the <error> element has type + // "cancel", then we know the messages wasn't sent, + // and can give the user a nicer indication of + // that. + /* <message from="scotty@enterprise.com/_converse.js-84843526" + * to="kirk@enterprise.com.com" + * type="chat" + * id="82bc02ce-9651-4336-baf0-fa04762ed8d2" + * xmlns="jabber:client"> + * <body>yo</body> + * <active xmlns="http://jabber.org/protocol/chatstates"/> + * </message> + */ + const error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout'; + let msg_text = 'This message will not be sent, due to an error'; + const view = _converse.chatboxviews.get(sender_jid); + const message = await view.model.sendMessage({'body': msg_text}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + let msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent; + expect(msg_txt).toEqual(msg_text); + + // We send another message, for which an error will + // not be received, to test that errors appear + // after the relevant message. + msg_text = 'This message will be sent, and also receive an error'; + const second_message = await view.model.sendMessage({'body': msg_text}); + await u.waitUntil(() => sizzle('.chat-msg .chat-msg__text', view).length === 2, 1000); + msg_txt = sizzle('.chat-msg:last .chat-msg__text', view).pop().textContent; + expect(msg_txt).toEqual(msg_text); + + /* <message xmlns="jabber:client" + * to="scotty@enterprise.com/_converse.js-84843526" + * type="error" + * id="82bc02ce-9651-4336-baf0-fa04762ed8d2" + * from="kirk@enterprise.com.com"> + * <error type="cancel"> + * <remote-server-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + * <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Server-to-server connection failed: Connecting failed: connection timeout</text> + * </error> + * </message> + */ + let stanza = $msg({ + 'to': _converse.connection.jid, + 'type': 'error', + 'id': message.get('msgid'), + 'from': sender_jid + }) + .c('error', {'type': 'cancel'}) + .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up() + .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }) + .t('Server-to-server connection failed: Connecting failed: connection timeout'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.chat-msg__error').textContent.trim() === error_txt); + + const other_error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout'; + stanza = $msg({ + 'to': _converse.connection.jid, + 'type': 'error', + 'id': second_message.get('id'), + 'from': sender_jid + }) + .c('error', {'type': 'cancel'}) + .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up() + .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }) + .t(other_error_txt); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => + view.querySelector('converse-chat-message:last-child .chat-msg__error').textContent.trim() === other_error_txt); + + // We don't render duplicates + stanza = $msg({ + 'to': _converse.connection.jid, + 'type':'error', + 'id': second_message.get('id'), + 'from': sender_jid + }) + .c('error', {'type': 'cancel'}) + .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up() + .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }) + .t('Server-to-server connection failed: Connecting failed: connection timeout'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(view.querySelectorAll('.chat-msg__error').length).toEqual(2); + + msg_text = 'This message will be sent, and also receive an error'; + const third_message = await view.model.sendMessage({'body': msg_text}); + await u.waitUntil(() => sizzle('converse-chat-message:last-child .chat-msg__text', view).pop()?.textContent === msg_text); + + // A different error message will however render + stanza = $msg({ + 'to': _converse.connection.jid, + 'type':'error', + 'id': third_message.get('id'), + 'from': sender_jid + }) + .c('error', {'type': 'cancel'}) + .c('not-allowed', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up() + .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }) + .t('Something else went wrong as well'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length > 2); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 3); + + // Ensure messages with error are not editable or retractable + await u.waitUntil(() => !view.model.messages.models.reduce((acc, m) => acc || m.get('editable'), false), 1000); + view.querySelectorAll('.chat-msg').forEach(el => { + expect(el.querySelector('.chat-msg__action-edit')).toBe(null) + expect(el.querySelector('.chat-msg__action-retract')).toBe(null) + }) + })); + + it("will not show to the user an error message for a CSI message", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + // See #1317 + // https://github.com/conversejs/converse.js/issues/1317 + 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 textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello world' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown(enter_event); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + const msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xml:lang="en" type="error" from="${contact_jid}"> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <no-store xmlns="urn:xmpp:hints"/> + <no-permanent-store xmlns="urn:xmpp:hints"/> + <error code="503" type="cancel"> + <service-unavailable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + <text xml:lang="en" xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">User session not found</text></error> + </message> + `))); + return new Promise(resolve => setTimeout(() => { + expect(view.querySelector('.chat-msg__error').textContent).toBe(''); + resolve(); + }, 500)); + })); + + it("will have the error displayed below it", + mock.initConverse([], {}, 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 textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello world' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown(enter_event); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + // Normally "modify" errors need to have their id set to the + // message that couldn't be sent. Not doing that here on purpose to + // check the case where it's not done. + // See issue #2683 + const err_txt = `Your message to ${contact_jid} was not end-to-end encrypted. For security reasons, using one of the following E2EE schemes is *REQUIRED* for conversations on this server: pgp, omemo`; + const error = u.toStanza(` + <message xmlns="jabber:client" from="${contact_jid}" type="error" to="${_converse.jid}"> + <error type="modify"> + <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_txt}</text> + </error> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(error)); + + expect(await u.waitUntil(() => view.querySelector('.chat-error')?.textContent?.trim())).toBe(err_txt); + expect(view.model.messages.length).toBe(2); + })); + + }); + + it("will cause the chat area to be scrolled down only if it was at the bottom originally", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, sender_jid) + const view = _converse.chatboxviews.get(sender_jid); + // Create enough messages so that there's a scrollbar. + const promises = []; + view.querySelector('.chat-content').scrollTop = 0; + view.model.ui.set('scrolled', true); + + for (let i=0; i<20; i++) { + _converse.handleMessageStanza($msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: _converse.connection.getUniqueId(), + }).c('body').t('Message: '+i).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + promises.push(new Promise(resolve => view.model.messages.once('rendered', resolve))); + } + await Promise.all(promises); + + const indicator_el = await u.waitUntil(() => view.querySelector('.new-msgs-indicator')); + + expect(view.model.ui.get('scrolled')).toBe(true); + expect(view.querySelector('.chat-content').scrollTop).toBe(0); + indicator_el.click(); + await u.waitUntil(() => !view.querySelector('.new-msgs-indicator')); + await u.waitUntil(() => !view.model.get('scrolled')); + })); + + it("is ignored if it's intended for a different resource and filter_by_resource is set to true", + mock.initConverse([], {}, async function (_converse) { + + const { api } = _converse; + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length) + // Send a message from a different resource + spyOn(converse.env.log, 'error'); + spyOn(_converse.api.chatboxes, 'create').and.callThrough(); + api.settings.set('filter_by_resource', true); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + let msg = $msg({ + from: sender_jid, + to: _converse.bare_jid+"/some-other-resource", + type: 'chat', + id: u.getUniqueId() + }).c('body').t("This message will not be shown").up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); + + expect(converse.env.log.error.calls.all().pop().args[0]).toBe( + "Ignoring incoming message intended for a different resource: romeo@montague.lit/some-other-resource", + ); + expect(_converse.api.chatboxes.create).not.toHaveBeenCalled(); + api.settings.set('filter_by_resource', false); + + const message = "This message sent to a different resource will be shown"; + msg = $msg({ + from: sender_jid, + to: _converse.bare_jid+"/some-other-resource", + type: 'chat', + id: '134234623462346' + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => _converse.chatboxviews.keys().length > 1, 1000); + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.model.messages.length); + expect(_converse.api.chatboxes.create).toHaveBeenCalled(); + const last_message = await u.waitUntil(() => sizzle('.chat-content:last .chat-msg__text', view).pop()); + const msg_txt = last_message.textContent; + expect(msg_txt).toEqual(message); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js new file mode 100644 index 0000000..62ae5ba --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/oob.js @@ -0,0 +1,168 @@ +/*global mock, converse */ + +const { Strophe, Promise, u } = converse.env; + +describe("A Chat Message", function () { + describe("which contains an OOB URL", function () { + + it("will render audio from oob mp3 URLs", + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + + const url = 'https://montague.lit/audio.mp3'; + let stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Have you heard this funny audio?</body> + <x xmlns="jabber:x:oob"><url>${url}</url></x> + </message>`) + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg audio').length, 1000); + let msg = view.querySelector('.chat-msg .chat-msg__text'); + expect(msg.classList.length).toEqual(1); + expect(u.hasClass('chat-msg__text', msg)).toBe(true); + expect(msg.textContent).toEqual('Have you heard this funny audio?'); + const media = view.querySelector('.chat-msg .chat-msg__media'); + expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim()).toEqual( + `<audio controls="" src="https://montague.lit/audio.mp3"></audio>`+ + `<a target="_blank" rel="noopener" href="https://montague.lit/audio.mp3">${url}</a>`); + + // If the <url> and <body> contents is the same, don't duplicate. + stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>${url}</body> + <x xmlns="jabber:x:oob"><url>${url}</url></x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg = view.querySelector('.chat-msg .chat-msg__text'); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('Have you heard this funny audio?'); // Emtpy + + // We don't render the OOB data + expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null); + + // But we do render the body + const msg_el = view.querySelector('converse-chat-message:last-child .chat-msg__text'); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "").trim() === + `<audio controls="" src="https://montague.lit/audio.mp3"></audio>`+ + `<a target="_blank" rel="noopener" href="${url}">${url}</a>`); + })); + + it("will render video from oob mp4 URLs", + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + + const url = 'https://montague.lit/video.mp4'; + let stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Have you seen this funny video?</body> + <x xmlns="jabber:x:oob"><url>${url}</url></x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg video').length, 2000) + let msg = view.querySelector('.chat-msg .chat-msg__text'); + expect(msg.classList.length).toBe(1); + expect(msg.textContent).toEqual('Have you seen this funny video?'); + const media = view.querySelector('.chat-msg .chat-msg__media'); + expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(/<!-.*?->/g, '')).toEqual( + `<video controls="" preload="metadata" src="${Strophe.xmlescape(url)}"></video>`+ + `<a target="_blank" rel="noopener" href="${Strophe.xmlescape(url)}">${Strophe.xmlescape(url)}</a>`); + + // If the <url> and <body> contents is the same, don't duplicate. + stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>https://montague.lit/video.mp4</body> + <x xmlns="jabber:x:oob"><url>https://montague.lit/video.mp4</url></x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + msg = view.querySelector('converse-chat-message .chat-msg__text'); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('Have you seen this funny video?'); + expect(view.querySelector('converse-chat-message:last-child .chat-msg__media')).toBe(null); + })); + + it("will render download links for files from oob URLs", + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + const stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Have you downloaded this funny file?</body> + <x xmlns="jabber:x:oob"><url>https://montague.lit/funny.pdf</url></x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg a').length, 1000); + const msg = view.querySelector('.chat-msg .chat-msg__text'); + expect(u.hasClass('chat-msg__text', msg)).toBe(true); + expect(msg.textContent).toEqual('Have you downloaded this funny file?'); + const media = view.querySelector('.chat-msg .chat-msg__media'); + expect(media.innerHTML.replace(/(\r\n|\n|\r)/gm, "").replace(/<!-.*?->/g, '')).toEqual( + `<a target="_blank" rel="noopener" href="https://montague.lit/funny.pdf">Download file "funny.pdf"</a>`); + })); + + it("will render images from oob URLs", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + const base_url = 'https://conversejs.org'; + 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); + spyOn(view.model, 'sendMessage').and.callThrough(); + const url = base_url+"/logo/conversejs-filled.svg"; + + const stanza = u.toStanza(` + <message from="${contact_jid}" + type="chat" + to="romeo@montague.lit/orchard"> + <body>Have you seen this funny image?</body> + <x xmlns="jabber:x:oob"><url>${url}</url></x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-msg a').length, 1000); + const msg = view.querySelector('.chat-msg .chat-msg__text'); + expect(u.hasClass('chat-msg__text', msg)).toBe(true); + expect(msg.textContent).toEqual('Have you seen this funny image?'); + const media = view.querySelector('.chat-msg .chat-msg__media'); + expect(media.innerHTML.replace(/<!-.*?->/g, '').replace(/(\r\n|\n|\r)/gm, "")).toEqual( + `<a target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+ + `Download file "conversejs-filled.svg"</a>`); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js new file mode 100644 index 0000000..8fac60f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/receipts.js @@ -0,0 +1,151 @@ +/*global mock, converse, _ */ + +const { Promise, Strophe, $msg, sizzle } = converse.env; +const u = converse.env.utils; + + +describe("A delivery receipt", function () { + + it("is emitted for a received message which requests it", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg_id = u.getUniqueId(); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(stanza => sent_stanzas.push(stanza)); + const msg = $msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': msg_id, + }).c('body').t('Message!').up() + .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree(); + await _converse.handleMessageStanza(msg); + const sent_messages = sent_stanzas.map(s => _.isElement(s) ? s : s.nodeTree).filter(s => s.nodeName === 'message'); + // A chat state message is also included + expect(sent_messages.length).toBe(2); + const receipt = sizzle(`received[xmlns="${Strophe.NS.RECEIPTS}"]`, sent_messages[1]).pop(); + expect(Strophe.serialize(receipt)).toBe(`<received id="${msg_id}" xmlns="${Strophe.NS.RECEIPTS}"/>`); + })); + + it("is not emitted for a carbon message", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg_id = u.getUniqueId(); + const view = await mock.openChatBoxFor(_converse, sender_jid); + spyOn(view.model, 'sendReceiptStanza').and.callThrough(); + const msg = $msg({ + 'from': sender_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId(), + }).c('received', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': sender_jid, + 'to': _converse.bare_jid+'/another-resource', + 'type': 'chat', + 'id': msg_id + }).c('body').t('Message!').up() + .c('request', {'xmlns': Strophe.NS.RECEIPTS}).tree(); + await _converse.handleMessageStanza(msg); + expect(view.model.sendReceiptStanza).not.toHaveBeenCalled(); + })); + + it("is not emitted for an archived message", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, sender_jid); + spyOn(view.model, 'sendReceiptStanza').and.callThrough(); + + const stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}"> + <result xmlns="urn:xmpp:mam:2" id="9ZWxmXMR8SVor-tC" queryid="f543c5f9-55e7-400e-860a-56baac121e6a"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2020-01-10T22:19:30Z"/> + <message xmlns="jabber:client" type="chat" to="${_converse.jid}" from="${sender_jid}" id="id8b6426b4-40fe-4151-941e-4c64e380acb9"> + <body>Please confirm receipt</body> + <request xmlns="urn:xmpp:receipts"/> + <origin-id xmlns="urn:xmpp:sid:0" id="id8b6426b4-40fe-4151-941e-4c64e380acb9"/> + </message> + </forwarded> + </result> + </message>`); + + spyOn(view.model, 'getDuplicateMessage').and.callThrough(); + _converse.handleMAMResult(view.model, { 'messages': [stanza] }); + let message_attrs; + _converse.api.listen.on('MAMResult', async data => { + message_attrs = await data.messages[0]; + }); + await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); + expect(message_attrs.is_archived).toBe(true); + expect(message_attrs.is_valid_receipt_request).toBe(false); + expect(view.model.sendReceiptStanza).not.toHaveBeenCalled(); + })); + + it("can be received for a sent message", + 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 textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + const chatbox = _converse.chatboxes.get(contact_jid); + expect(chatbox).toBeDefined(); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + let msg_obj = chatbox.messages.models[0]; + let msg_id = msg_obj.get('msgid'); + let msg = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'id': u.getUniqueId(), + }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__receipt').length === 1); + + // Also handle receipts with type 'chat'. See #1353 + spyOn(_converse, 'handleMessageStanza').and.callThrough(); + textarea.value = 'Another message'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + msg_obj = chatbox.messages.models[1]; + msg_id = msg_obj.get('msgid'); + msg = $msg({ + 'from': contact_jid, + 'type': 'chat', + 'to': _converse.connection.jid, + 'id': u.getUniqueId(), + }).c('received', {'id': msg_id, xmlns: Strophe.NS.RECEIPTS}).up().tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__receipt').length === 2); + expect(_converse.handleMessageStanza.calls.count()).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js new file mode 100644 index 0000000..8035b63 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/spoilers.js @@ -0,0 +1,238 @@ +/* global mock, converse */ + +const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("A spoiler message", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + it("can be received with a hint", + mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'> + * <body>And at the end of the story, both of them die! It is so tragic!</body> + * <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler> + * </message> + */ + const spoiler_hint = "Love story end" + const spoiler = "And at the end of the story, both of them die! It is so tragic!"; + const $msg = converse.env.$msg; + const u = converse.env.utils; + const msg = $msg({ + 'xmlns': 'jabber:client', + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat' + }).c('body').t(spoiler).up() + .c('spoiler', { + 'xmlns': 'urn:xmpp:spoiler:0', + }).t(spoiler_hint) + .tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve)); + const view = _converse.chatboxviews.get(sender_jid); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio') + expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Mercutio'); + const message_content = view.querySelector('.chat-msg__text'); + await u.waitUntil(() => message_content.textContent === spoiler); + const spoiler_hint_el = view.querySelector('.spoiler-hint'); + expect(spoiler_hint_el.textContent).toBe(spoiler_hint); + })); + + it("can be received without a hint", + mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + /* <message to='romeo@montague.net/orchard' from='juliet@capulet.net/balcony' id='spoiler2'> + * <body>And at the end of the story, both of them die! It is so tragic!</body> + * <spoiler xmlns='urn:xmpp:spoiler:0'>Love story end</spoiler> + * </message> + */ + const $msg = converse.env.$msg; + const u = converse.env.utils; + const spoiler = "And at the end of the story, both of them die! It is so tragic!"; + const msg = $msg({ + 'xmlns': 'jabber:client', + 'to': _converse.bare_jid, + 'from': sender_jid, + 'type': 'chat' + }).c('body').t(spoiler).up() + .c('spoiler', { + 'xmlns': 'urn:xmpp:spoiler:0', + }).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await new Promise(resolve => _converse.api.listen.once('chatBoxViewInitialized', resolve)); + const view = _converse.chatboxviews.get(sender_jid); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => u.isVisible(view)); + await u.waitUntil(() => view.model.vcard.get('fullname') === 'Mercutio') + await u.waitUntil(() => u.isVisible(view.querySelector('.chat-msg__author'))); + expect(view.querySelector('.chat-msg__author').textContent.includes('Mercutio')).toBeTruthy(); + const message_content = view.querySelector('.chat-msg__text'); + await u.waitUntil(() => message_content.textContent === spoiler); + const spoiler_hint_el = view.querySelector('.spoiler-hint'); + expect(spoiler_hint_el.textContent).toBe(''); + })); + + it("can be sent without a hint", + mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current', 1); + mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + const { $pres, Strophe} = converse.env; + const u = converse.env.utils; + + // XXX: We need to send a presence from the contact, so that we + // have a resource, that resource is then queried to see + // whether Strophe.NS.SPOILER is supported, in which case + // the spoiler button will appear. + const presence = $pres({ + 'from': contact_jid+'/phone', + 'to': 'romeo@montague.lit' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await mock.openChatBoxFor(_converse, contact_jid); + await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]); + const view = _converse.chatboxviews.get(contact_jid); + spyOn(_converse.connection, 'send'); + + await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler')); + let spoiler_toggle = view.querySelector('.toggle-compose-spoiler'); + spoiler_toggle.click(); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'This is the spoiler'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + /* Test the XML stanza + * + * <message from="romeo@montague.lit/orchard" + * to="max.frankfurter@montague.lit" + * type="chat" + * id="4547c38b-d98b-45a5-8f44-b4004dbc335e" + * xmlns="jabber:client"> + * <body>This is the spoiler</body> + * <active xmlns="http://jabber.org/protocol/chatstates"/> + * <spoiler xmlns="urn:xmpp:spoiler:0"/> + * </message>" + */ + const stanza = _converse.connection.send.calls.argsFor(0)[0]; + const spoiler_el = await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]')); + expect(spoiler_el.textContent).toBe(''); + + const spoiler = 'This is the spoiler'; + const body_el = stanza.querySelector('body'); + expect(body_el.textContent).toBe(spoiler); + + /* Test the HTML spoiler message */ + expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo'); + + const message_content = view.querySelector('.chat-msg__text'); + await u.waitUntil(() => message_content.textContent === spoiler); + + const spoiler_msg_el = view.querySelector('.chat-msg__text.spoiler'); + expect(Array.from(spoiler_msg_el.classList).includes('hidden')).toBeTruthy(); + + spoiler_toggle = view.querySelector('.spoiler-toggle'); + expect(spoiler_toggle.textContent.trim()).toBe('Show more'); + spoiler_toggle.click(); + await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('hidden')); + expect(spoiler_toggle.textContent.trim()).toBe('Show less'); + spoiler_toggle.click(); + await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden')); + })); + + it("can be sent with a hint", + mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current', 1); + mock.openControlBox(_converse); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + const { $pres, Strophe} = converse.env; + const u = converse.env.utils; + + // XXX: We need to send a presence from the contact, so that we + // have a resource, that resource is then queried to see + // whether Strophe.NS.SPOILER is supported, in which case + // the spoiler button will appear. + const presence = $pres({ + 'from': contact_jid+'/phone', + 'to': 'romeo@montague.lit' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await mock.openChatBoxFor(_converse, contact_jid); + await mock.waitUntilDiscoConfirmed(_converse, contact_jid+'/phone', [], [Strophe.NS.SPOILER]); + const view = _converse.chatboxviews.get(contact_jid); + + await u.waitUntil(() => view.querySelector('.toggle-compose-spoiler')); + let spoiler_toggle = view.querySelector('.toggle-compose-spoiler'); + spoiler_toggle.click(); + + spyOn(_converse.connection, 'send'); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'This is the spoiler'; + const hint_input = view.querySelector('.spoiler-hint'); + hint_input.value = 'This is the hint'; + + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + const stanza = _converse.connection.send.calls.argsFor(0)[0]; + expect(Strophe.serialize(stanza)).toBe( + `<message from="romeo@montague.lit/orchard" ` + + `id="${stanza.getAttribute('id')}" `+ + `to="mercutio@montague.lit" `+ + `type="chat" `+ + `xmlns="jabber:client">`+ + `<body>This is the spoiler</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<spoiler xmlns="urn:xmpp:spoiler:0">This is the hint</spoiler>`+ + `<origin-id id="${stanza.querySelector('origin-id').getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>` + ); + + await u.waitUntil(() => stanza.querySelector('spoiler[xmlns="urn:xmpp:spoiler:0"]')?.textContent === 'This is the hint'); + + const spoiler = 'This is the spoiler' + const body_el = stanza.querySelector('body'); + expect(body_el.textContent).toBe(spoiler); + + expect(view.querySelector('.chat-msg__author').textContent.trim()).toBe('Romeo'); + + const message_content = view.querySelector('.chat-msg__text'); + await u.waitUntil(() => message_content.textContent === spoiler); + + const spoiler_msg_el = view.querySelector('.chat-msg__text.spoiler'); + expect(Array.from(spoiler_msg_el.classList).includes('hidden')).toBeTruthy(); + + spoiler_toggle = view.querySelector('.spoiler-toggle'); + expect(spoiler_toggle.textContent.trim()).toBe('Show more'); + spoiler_toggle.click(); + await u.waitUntil(() => !Array.from(spoiler_msg_el.classList).includes('hidden')); + expect(spoiler_toggle.textContent.trim()).toBe('Show less'); + spoiler_toggle.click(); + await u.waitUntil(() => Array.from(spoiler_msg_el.classList).includes('hidden')); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js new file mode 100644 index 0000000..f6f5872 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/styling.js @@ -0,0 +1,517 @@ +/*global mock, converse */ + +const { u, $msg } = converse.env; + +describe("An incoming chat Message", function () { + + it("can have styling disabled via an \"unstyled\" element", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + const include_nick = false; + await mock.waitForRoster(_converse, 'current', 2, include_nick); + await mock.openControlBox(_converse); + + const msg_text = '> _ >'; + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + 'from': sender_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('body').t(msg_text).up() + .c('unstyled', {'xmlns': 'urn:xmpp:styling:0'}).tree(); + await _converse.handleMessageStanza(msg); + + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.model.messages.length); + expect(view.model.messages.models[0].get('is_unstyled')).toBe(true); + + setTimeout(() => { + const msg_el = view.querySelector('converse-chat-message-body'); + expect(msg_el.innerText).toBe(msg_text); + }, 500); + })); + + + it("can have styling disabled via the allow_message_styling setting", + mock.initConverse(['chatBoxesFetched'], {'allow_message_styling': false}, + async function (_converse) { + + const include_nick = false; + await mock.waitForRoster(_converse, 'current', 2, include_nick); + await mock.openControlBox(_converse); + + const msg_text = '> _ >'; + const sender_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = $msg({ + 'from': sender_jid, + 'id': u.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'chat', + 'xmlns': 'jabber:client' + }).c('body').t(msg_text).tree(); + await _converse.handleMessageStanza(msg); + + const view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => view.model.messages.length); + expect(view.model.messages.models[0].get('is_unstyled')).toBe(false); + + setTimeout(() => { + const msg_el = view.querySelector('converse-chat-message-body'); + expect(msg_el.innerText).toBe(msg_text); + }, 500); + })); + + it("can be styled with span XEP-0393 message styling hints", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + let msg_text, msg, msg_el; + 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); + + msg_text = "This *message _contains_* styling hints! \`Here's *some* code\`"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + msg_el = view.querySelector('converse-chat-message-body'); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'This <span class="styling-directive">*</span>'+ + '<b>message <span class="styling-directive">_</span><i>contains</i><span class="styling-directive">_</span></b>'+ + '<span class="styling-directive">*</span>'+ + ' styling hints! '+ + '<span class="styling-directive">`</span><code>Here\'s *some* code</code><span class="styling-directive">`</span>' + ); + + msg_text = "Here's a ~strikethrough section~"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'Here\'s a <span class="styling-directive">~</span><del>strikethrough section</del><span class="styling-directive">~</span>'); + + // Span directives containing hyperlinks + msg_text = "~Check out this site: https://conversejs.org~" + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<span class="styling-directive">~</span>'+ + '<del>Check out this site: <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a></del>'+ + '<span class="styling-directive">~</span>'); + + // Images inside directives aren't shown inline + const base_url = 'https://conversejs.org'; + msg_text = `*${base_url}/logo/conversejs-filled.svg*`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<span class="styling-directive">*</span>'+ + '<b><a target="_blank" rel="noopener" href="https://conversejs.org/logo/conversejs-filled.svg">https://conversejs.org/logo/conversejs-filled.svg</a></b>'+ + '<span class="styling-directive">*</span>'); + + // Emojis inside directives + msg_text = `~ Hello! :poop: ~`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<span class="styling-directive">~</span><del> Hello! <span title=":poop:">💩</span> </del><span class="styling-directive">~</span>'); + + // Span directives don't cross lines + msg_text = "This *is not a styling hint \n * _But this is_!"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'This *is not a styling hint \n'+ + ' * <span class="styling-directive">_</span><i>But this is</i><span class="styling-directive">_</span>!'); + + msg_text = `(There are three blocks in this body marked by parens,)\n (but there is no *formatting)\n (as spans* may not escape blocks.)\n ~strikethrough~`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '(There are three blocks in this body marked by parens,)\n'+ + ' (but there is no *formatting)\n'+ + ' (as spans* may not escape blocks.)\n'+ + ' <span class="styling-directive">~</span><del>strikethrough</del><span class="styling-directive">~</span>'); + + // Some edge-case (unspecified) spans + msg_text = `__ hello world _`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '_<span class="styling-directive">_</span><i> hello world </i><span class="styling-directive">_</span>'); + + // Directives which are parts of words aren't matched + msg_text = `Go to ~https://conversejs.org~now _please_`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'Go to ~https://conversejs.org~now <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>'); + + msg_text = `Go to _https://converse_js.org_ _please_`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'Go to <span class="styling-directive">_</span>'+ + '<i><a target="_blank" rel="noopener" href="https://converse_js.org/">https://converse_js.org</a></i>'+ + '<span class="styling-directive">_</span> <span class="styling-directive">_</span><i>please</i><span class="styling-directive">_</span>'); + + })); + + it("can be styled with block XEP-0393 message styling hints", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + let msg_text, msg, msg_el; + 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); + + msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + 'Here\'s a code block: \n'+ + '<div class="styling-directive">```</div><code class="block">Inside the code-block, <code>hello</code> we don\'t enable *styling hints* like ~these~\n'+ + '</code><div class="styling-directive">```</div>' + ); + + msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<div class="styling-directive">```</div>'+ + '<code class="block">ignored\n(println "Hello, world!")\n</code>'+ + '<div class="styling-directive">```</div>\n'+ + 'This should show up as monospace, preformatted text ^'); + + + msg_text = "```ignored\n (println \"Hello, world!\")\n ```\n\n This should not show up as monospace, *preformatted* text ^"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '```ignored\n (println "Hello, world!")\n ```\n\n'+ + ' This should not show up as monospace, '+ + '<span class="styling-directive">*</span><b>preformatted</b><span class="styling-directive">*</span> text ^'); + })); + + it("can be styled with quote XEP-0393 message styling hints", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + let msg_text, msg, msg_el; + 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); + + msg_text = `> https://conversejs.org\n> https://conversejs.org`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + '<blockquote>'+ + '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\u200B\u200B'+ + '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'+ + '</blockquote>'); + + msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + '<blockquote>This is quoted text\n\u200BThis is also quoted</blockquote>\nThis is not quoted'); + + msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + '<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n\u200B'+ + 'This is <span class="styling-directive">`</span><code>also _quoted_</code><span class="styling-directive">`</span></blockquote>\n'+ + 'This is not quoted'); + + msg_text = `> > This is doubly quoted text`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>"); + + msg_text = `>> This is doubly quoted text`; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>"); + + msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^"; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + '<blockquote>'+ + '<div class="styling-directive">```</div>'+ + '<code class="block">\u200Bignored\n\u200B\u200B<span></span> (println "Hello, world!")\n\u200B'+ + '</code><div class="styling-directive">```</div>\n\u200B\u200B'+ + 'This should show up as monospace, preformatted text ^'+ + '</blockquote>'); + + msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!'; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + '<blockquote>```\n\u200B\u200B(println "Hello, world!")</blockquote>\n\n'+ + 'The entire blockquote is a preformatted text block, but this line is plaintext!'); + + msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path' + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+ + '<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/configuration.html#assets-path">https://conversejs.org/docs/html/configuration.html#assets-path</a>'); + + msg_text = '> Where is it located?\ngeo:37.786971,-122.399677'; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<blockquote>Where is it located?</blockquote>\n'+ + '<a target="_blank" rel="noopener" '+ + 'href="https://www.openstreetmap.org/?mlat=37.786971&mlon=-122.399677#map=18/37.786971/-122.399677">https://www.openstreetmap.org/?mlat=37.786971&mlon=-122.399677#map=18/37.786971/-122.399677</a>'); + + msg_text = '> What do you think of it?\n :poop:'; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>'); + + msg_text = '> What do you think of it?\n~hello~'; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>'); + + msg_text = 'hello world > this is not a quote'; + msg = mock.createChatMessage(_converse, contact_jid, msg_text) + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 12); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === 'hello world > this is not a quote'); + + msg_text = '> What do you think of it romeo?\n Did you see this romeo?'; + msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: (new Date()).getTime() + }).c('body').t(msg_text).up() + .c('reference', { + 'xmlns':'urn:xmpp:reference:0', + 'begin':'26', + 'end':'31', + 'type':'mention', + 'uri': _converse.bare_jid + }) + .c('reference', { + 'xmlns':'urn:xmpp:reference:0', + 'begin':'51', + 'end':'56', + 'type':'mention', + 'uri': _converse.bare_jid + }).nodeTree; + await _converse.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 13); + msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + `<blockquote>What do you think of it <span class="mention" data-uri="romeo@montague.lit">romeo</span>?</blockquote>\n `+ + `Did you see this <span class="mention" data-uri="romeo@montague.lit">romeo</span>?`); + + expect(true).toBe(true); + })); + + it("won't style invalid block quotes", + 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 msg_text = '```\ncode```'; + const msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: (new Date()).getTime() + }).c('body').t(msg_text).up() + .c('reference', { + 'xmlns':'urn:xmpp:reference:0', + 'begin':'26', + 'end':'31', + 'type':'mention', + 'uri': _converse.bare_jid + }) + .c('reference', { + 'xmlns':'urn:xmpp:reference:0', + 'begin':'51', + 'end':'56', + 'type':'mention', + 'uri': _converse.bare_jid + }).nodeTree; + await _converse.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === '```\ncode```'); + expect(true).toBe(true); + })); +}); + + +describe("An XEP-0393 styled message ", function () { + + it("can be replaced with a correction and will still render properly", + 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 msg_text = `https://conversejs.org\nhttps://opkode.com`; + const msg_id = u.getUniqueId(); + _converse.handleMessageStanza($msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': msg_id, + }).c('body').t(msg_text).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('https://conversejs.org\nhttps://opkode.com'); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); + const msg_el = view.querySelector('converse-chat-message-body'); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n'+ + '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>' + ); + + _converse.handleMessageStanza($msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': u.getUniqueId(), + }).c('body').t(`A\nhttps://conversejs.org\n\nhttps://opkode.com`).up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.querySelectorAll('.chat-msg__text').length).toBe(1); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'A\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\n'+ + '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>' + ); + })); + + it("can be sent as a correction by using the up arrow", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + 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 textarea = view.querySelector('textarea.chat-textarea'); + const message_form = view.querySelector('converse-message-form'); + + textarea.value = `https://conversejs.org\nhttps://opkode.com`; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + const msg_el = view.querySelector('converse-chat-message-body'); + expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe( + '<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n'+ + '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>' + ); + + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + + textarea.value = `A\nhttps://conversejs.org\n\nhttps://opkode.com`; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.querySelectorAll('.chat-msg__text').length).toBe(1); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'A\n<a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>\n\n'+ + '<a target="_blank" rel="noopener" href="https://opkode.com/">https://opkode.com</a>' + ); + })); + +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js new file mode 100644 index 0000000..40eb4ea --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/unreads.js @@ -0,0 +1,156 @@ +/*global mock, converse */ + +const { u } = converse.env; + + +describe("A ChatBox's Unread Message Count", function () { + + it("is incremented when the message is received and ChatBoxView is scrolled up", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', + msg = mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + + const view = await mock.openChatBoxFor(_converse, sender_jid) + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + view.model.ui.set('scrolled', true); + await _converse.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.length); + expect(view.model.get('num_unread')).toBe(1); + const msgid = view.model.messages.last().get('id'); + expect(view.model.get('first_unread_id')).toBe(msgid); + await u.waitUntil(() => sent_stanzas.length); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); + })); + + it("is not incremented when the message is received and ChatBoxView is scrolled down", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msg = mock.createChatMessage(_converse, sender_jid, 'This message will be read'); + await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + const chatbox = _converse.chatboxes.get(sender_jid); + await _converse.handleMessageStanza(msg); + expect(chatbox.get('num_unread')).toBe(0); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2); + expect(sent_stanzas[1].querySelector('displayed')).toBeDefined(); + })); + + it("is incremented when message is received, chatbox is scrolled down and the window is not focused", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msgFactory = function () { + return mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + }; + await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + const chatbox = _converse.chatboxes.get(sender_jid); + _converse.windowState = 'hidden'; + const msg = msgFactory(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length); + expect(chatbox.get('num_unread')).toBe(1); + const msgid = chatbox.messages.last().get('id'); + expect(chatbox.get('first_unread_id')).toBe(msgid); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); + })); + + it("is incremented when message is received, chatbox is scrolled up and the window is not focused", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + const chatbox = _converse.chatboxes.get(sender_jid); + chatbox.ui.set('scrolled', true); + _converse.windowState = 'hidden'; + const msg = msgFactory(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length); + expect(chatbox.get('num_unread')).toBe(1); + const msgid = chatbox.messages.last().get('id'); + expect(chatbox.get('first_unread_id')).toBe(msgid); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); + })); + + it("is cleared when the chat was scrolled down and the window become focused", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + const chatbox = _converse.chatboxes.get(sender_jid); + _converse.windowState = 'hidden'; + const msg = msgFactory(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length); + expect(chatbox.get('num_unread')).toBe(1); + const msgid = chatbox.messages.last().get('id'); + expect(chatbox.get('first_unread_id')).toBe(msgid); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); + u.saveWindowState({'type': 'focus'}); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 2); + expect(sent_stanzas[1].querySelector('displayed')).toBeDefined(); + expect(chatbox.get('num_unread')).toBe(0); + })); + + it("is cleared when the chat was hidden in fullscreen mode and then becomes visible", + mock.initConverse(['chatBoxesFetched'], {'view_mode': 'fullscreen'}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, sender_jid); + const chatbox = _converse.chatboxes.get(sender_jid); + chatbox.save({'hidden': true}); + _converse.handleMessageStanza(mock.createChatMessage(_converse, sender_jid, 'This message will be unread')); + await u.waitUntil(() => chatbox.messages.length); + expect(chatbox.get('num_unread')).toBe(1); + chatbox.save({'hidden': false}); + await u.waitUntil(() => chatbox.get('num_unread') === 0); + chatbox.close(); + })); + + it("is not cleared when ChatBoxView was scrolled up and the windows become focused", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be unread'); + await mock.openChatBoxFor(_converse, sender_jid); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + const chatbox = _converse.chatboxes.get(sender_jid); + chatbox.ui.set('scrolled', true); + _converse.windowState = 'hidden'; + const msg = msgFactory(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatbox.messages.length); + expect(chatbox.get('num_unread')).toBe(1); + const msgid = chatbox.messages.last().get('id'); + expect(chatbox.get('first_unread_id')).toBe(msgid); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'message').length === 1); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); + u.saveWindowState({'type': 'focus'}); + await u.waitUntil(() => chatbox.get('num_unread') === 1); + expect(chatbox.get('first_unread_id')).toBe(msgid); + expect(sent_stanzas[0].querySelector('received')).toBeDefined(); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js new file mode 100644 index 0000000..8e54382 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/tests/xss.js @@ -0,0 +1,254 @@ +/*global mock, converse */ + +const sizzle = converse.env.sizzle; +const u = converse.env.utils; + +describe("XSS", function () { + describe("A Chat Message", function () { + + it("will escape IMG payload XSS attempts", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + spyOn(window, 'alert').and.callThrough(); + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + 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); + + let message = "<img src=x onerror=alert('XSS');>"; + await mock.sendMessage(view, message); + let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<img src=x onerror=alert('XSS');>"); + expect(window.alert).not.toHaveBeenCalled(); + + message = "<img src=x onerror=alert('XSS')//"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<img src=x onerror=alert('XSS')//"); + + message = "<img src=x onerror=alert(String.fromCharCode(88,83,83));>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<img src=x onerror=alert(String.fromCharCode(88,83,83));>"); + + message = "<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<img src=x oneonerrorrror=alert(String.fromCharCode(88,83,83));>"); + + message = "<img src=x:alert(alt) onerror=eval(src) alt=xss>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<img src=x:alert(alt) onerror=eval(src) alt=xss>"); + + message = "><img src=x onerror=alert('XSS');>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("><img src=x onerror=alert('XSS');>"); + + message = "><img src=x onerror=alert(String.fromCharCode(88,83,83));>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("><img src=x onerror=alert(String.fromCharCode(88,83,83));>"); + + expect(window.alert).not.toHaveBeenCalled(); + })); + + it("will escape SVG payload XSS attempts", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + spyOn(window, 'alert').and.callThrough(); + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + 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); + + let message = "<svgonload=alert(1)>"; + await mock.sendMessage(view, message); + let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('<svgonload=alert(1)>'); + + message = "<svg/onload=alert('XSS')>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<svg/onload=alert('XSS')>"); + + message = "<svg onload=alert(1)//"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<svg onload=alert(1)//"); + + message = "<svg/onload=alert(String.fromCharCode(88,83,83))>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<svg/onload=alert(String.fromCharCode(88,83,83))>"); + + message = "<svg id=alert(1) onload=eval(id)>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual("<svg id=alert(1) onload=eval(id)>"); + + message = '"><svg/onload=alert(String.fromCharCode(88,83,83))>'; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('"><svg/onload=alert(String.fromCharCode(88,83,83))>'); + + message = '"><svg/onload=alert(/XSS/)'; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual('"><svg/onload=alert(/XSS/)'); + + expect(window.alert).not.toHaveBeenCalled(); + })); + + it("will have properly escaped URLs", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + 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); + + let message = "http://www.opkode.com/'onmouseover='alert(1)'whatever"; + await mock.sendMessage(view, message); + + let msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')) + .toEqual('http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever'); + + + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + `<a target="_blank" rel="noopener" href="http://www.opkode.com/%27onmouseover=%27alert%281%29%27whatever">http://www.opkode.com/\'onmouseover=\'alert(1)\'whatever</a>`); + + message = 'http://www.opkode.com/"onmouseover="alert(1)"whatever'; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + `<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>`); + + message = "https://en.wikipedia.org/wiki/Ender's_Game"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + `<a target="_blank" rel="noopener" href="https://en.wikipedia.org/wiki/Ender%27s_Game">https://en.wikipedia.org/wiki/Ender's_Game</a>`); + + message = "<https://bugs.documentfoundation.org/show_bug.cgi?id=123737>"; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + `<<a target="_blank" rel="noopener" href="https://bugs.documentfoundation.org/show_bug.cgi?id=123737">https://bugs.documentfoundation.org/show_bug.cgi?id=123737</a>>`); + + message = '<http://www.opkode.com/"onmouseover="alert(1)"whatever>'; + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + `<<a target="_blank" rel="noopener" href="http://www.opkode.com/%22onmouseover=%22alert%281%29%22whatever">http://www.opkode.com/"onmouseover="alert(1)"whatever</a>>`); + + message = `https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2` + await mock.sendMessage(view, message); + msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(message); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === + `<a target="_blank" rel="noopener" href="https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=%213m6%211e1%213m4%211sQ7SdHo_bPLPlLlU8GSGWaQ%212e0%217i13312%218i6656%214m5%213m4%211s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08%218m2%213d52.3773668%214d4.5489388%215m1%211e2">https://www.google.com/maps/place/Kochstraat+6,+2041+CE+Zandvoort/@52.3775999,4.548971,3a,15y,170.85h,88.39t/data=!3m6!1e1!3m4!1sQ7SdHo_bPLPlLlU8GSGWaQ!2e0!7i13312!8i6656!4m5!3m4!1s0x47c5ec1e56f845ad:0x1de0bc4a5771fb08!8m2!3d52.3773668!4d4.5489388!5m1!1e2</a>`); + })); + + it("will avoid malformed and unsafe urls urls from rendering as anchors", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + 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 bad_urls =[ + 'http://^$^(*^#$%^_1*(', + 'file://devili.sh' + ]; + + const good_urls =[{ + entered: 'http://www.google.com', + href: 'http://www.google.com/' + }, { + entered: 'https://www.google.com/', + href: 'https://www.google.com/' + }, { + entered: 'www.url.com/something?else=1', + href: 'http://www.url.com/something?else=1', + }, { + entered: 'xmpp://anything/?join', + href: 'xmpp://anything/?join', + }, { + entered: 'WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj', + href: 'http://WWW.SOMETHING.COM/?x=dKasdDAsd4JAsd3OAJSD23osajAidj', + }, { + entered: 'mailto:test@mail.org', + href: 'mailto:test@mail.org', + }]; + + function checkNonParsedURL (url) { + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(url); + expect(msg.innerHTML.replace(/<!-.*?->/g, '')).toEqual(url); + } + + async function checkParsedURL ({ entered, href }) { + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent).toEqual(entered); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '') === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`); + } + + async function checkParsedXMPPURL ({ entered, href }) { + const msg = sizzle('.chat-content .chat-msg:last .chat-msg__text', view).pop(); + expect(msg.textContent.trim()).toEqual(entered); + await u.waitUntil(() => msg.innerHTML.replace(/<!-.*?->/g, '').trim() === `<a target="_blank" rel="noopener" href="${href}">${entered}</a>`); + } + + await mock.sendMessage(view, bad_urls[0]); + checkNonParsedURL(bad_urls[0]); + + await mock.sendMessage(view, bad_urls[1]); + checkNonParsedURL(bad_urls[1]); + + await mock.sendMessage(view, good_urls[0].entered); + await checkParsedURL(good_urls[0]); + + await mock.sendMessage(view, good_urls[1].entered); + await checkParsedURL(good_urls[1]); + + await mock.sendMessage(view, good_urls[2].entered); + await checkParsedURL(good_urls[2]); + + await mock.sendMessage(view, good_urls[3].entered); + await checkParsedXMPPURL(good_urls[3]); + + await mock.sendMessage(view, good_urls[4].entered); + await checkParsedURL(good_urls[4]); + + await mock.sendMessage(view, good_urls[5].entered); + await checkParsedURL(good_urls[5]); + + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js new file mode 100644 index 0000000..6791bec --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/chatview/utils.js @@ -0,0 +1,65 @@ +import { __ } from 'i18n'; +import { _converse, api } from '@converse/headless/core'; + +export function clearHistory (jid) { + if (_converse.router.history.getFragment() === `converse/chat?jid=${jid}`) { + _converse.router.navigate(''); + } +} + +export async function clearMessages (chat) { + const result = await api.confirm(__('Are you sure you want to clear the messages from this conversation?')); + if (result) { + await chat.clearMessages(); + } +} + +export async function parseMessageForCommands (chat, text) { + const match = text.replace(/^\s*/, '').match(/^\/(.*)\s*$/); + if (match) { + let handled = false; + /** + * *Hook* which allows plugins to add more commands to a chat's textbox. + * Data provided is the chatbox model and the text typed - {model, text}. + * Check `handled` to see if the hook was already handled. + * @event _converse#parseMessageForCommands + * @example + * api.listen.on('parseMessageForCommands', (data, handled) { + * if (!handled) { + * const command = (data.text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); + * // custom code comes here + * } + * return handled; + * } + */ + handled = await api.hook('parseMessageForCommands', { model: chat, text }, handled); + if (handled) { + return true; + } + + if (match[1] === 'clear') { + clearMessages(chat); + return true; + } else if (match[1] === 'close') { + _converse.chatboxviews.get(chat.get('jid'))?.close(); + return true; + } else if (match[1] === 'help') { + chat.set({ 'show_help_messages': false }, { 'silent': true }); + chat.set({ 'show_help_messages': true }); + return true; + } + } + return false; +} + +export function resetElementHeight (ev) { + if (ev.target.value) { + const height = ev.target.scrollHeight + 'px'; + if (ev.target.style.height != height) { + ev.target.style.height = 'auto'; + ev.target.style.height = height; + } + } else { + ev.target.style = ''; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js new file mode 100644 index 0000000..bc3bee8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/api.js @@ -0,0 +1,37 @@ +import { _converse, api, converse } from "@converse/headless/core"; + +const { u } = converse.env; + +export default { + /** + * The "controlbox" namespace groups methods pertaining to the + * controlbox view + * + * @namespace _converse.api.controlbox + * @memberOf _converse.api + */ + controlbox: { + /** + * Opens the controlbox + * @method _converse.api.controlbox.open + * @returns { Promise<_converse.ControlBox> } + */ + async open () { + await api.waitUntil('chatBoxesFetched'); + const model = await api.chatboxes.get('controlbox') || + api.chatboxes.create('controlbox', {}, _converse.Controlbox); + u.safeSave(model, {'closed': false}); + return model; + }, + + /** + * Returns the controlbox view. + * @method _converse.api.controlbox.get + * @returns { View } View representing the controlbox + * @example const view = _converse.api.controlbox.get(); + */ + get () { + return _converse.chatboxviews.get('controlbox'); + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js new file mode 100644 index 0000000..2ca9174 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/constants.js @@ -0,0 +1,42 @@ +import { converse } from '@converse/headless/core.js'; + +const { Strophe } = converse.env; + +export const REPORTABLE_STATUSES = [ + Strophe.Status.ERROR, + Strophe.Status.CONNECTING, + Strophe.Status.CONNFAIL, + Strophe.Status.AUTHENTICATING, + Strophe.Status.AUTHFAIL, + Strophe.Status.DISCONNECTING, + Strophe.Status.RECONNECTING, +]; + +export const PRETTY_CONNECTION_STATUS = Object.fromEntries([ + [Strophe.Status.ERROR, 'Error'], + [Strophe.Status.CONNECTING, 'Connecting'], + [Strophe.Status.CONNFAIL, 'Connection failure'], + [Strophe.Status.AUTHENTICATING, 'Authenticating'], + [Strophe.Status.AUTHFAIL, 'Authentication failure'], + [Strophe.Status.CONNECTED, 'Connected'], + [Strophe.Status.DISCONNECTED, 'Disconnected'], + [Strophe.Status.DISCONNECTING, 'Disconnecting'], + [Strophe.Status.ATTACHED, 'Attached'], + [Strophe.Status.REDIRECT, 'Redirect'], + [Strophe.Status.CONNTIMEOUT, 'Connection timeout'], + [Strophe.Status.RECONNECTING, 'Reconnecting'], +]); + +export const CONNECTION_STATUS_CSS_CLASS = Object.fromEntries([ + [Strophe.Status.ERROR, 'error'], + [Strophe.Status.CONNECTING, 'info'], + [Strophe.Status.CONNFAIL, 'error'], + [Strophe.Status.AUTHENTICATING, 'info'], + [Strophe.Status.AUTHFAIL, 'error'], + [Strophe.Status.CONNECTED, 'info'], + [Strophe.Status.DISCONNECTED, 'error'], + [Strophe.Status.DISCONNECTING, 'warn'], + [Strophe.Status.ATTACHED, 'info'], + [Strophe.Status.REDIRECT, 'info'], + [Strophe.Status.RECONNECTING, 'warn'], +]); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js new file mode 100644 index 0000000..6611ca7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/controlbox.js @@ -0,0 +1,79 @@ +import tplControlbox from './templates/controlbox.js'; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api, converse } from '@converse/headless/core.js'; +import { LOGOUT } from '@converse/headless/shared/constants.js'; + +const u = converse.env.utils; + +/** + * The ControlBox is the section of the chat that contains the open groupchats, + * bookmarks and roster. + * + * In `overlayed` `view_mode` it's a box like the chat boxes, in `fullscreen` + * `view_mode` it's a left-aligned sidebar. + */ +class ControlBox extends CustomElement { + + initialize () { + this.setModel(); + _converse.chatboxviews.add('controlbox', this); + if (this.model.get('connected') && this.model.get('closed') === undefined) { + this.model.set('closed', !api.settings.get('show_controlbox_by_default')); + } + this.requestUpdate(); + + /** + * Triggered when the _converse.ControlBoxView has been initialized and therefore + * exists. The controlbox contains the login and register forms when the user is + * logged out and a list of the user's contacts and group chats when logged in. + * @event _converse#controlBoxInitialized + * @type { _converse.ControlBoxView } + * @example _converse.api.listen.on('controlBoxInitialized', view => { ... }); + */ + api.trigger('controlBoxInitialized', this); + } + + setModel () { + this.model = _converse.chatboxes.get('controlbox'); + this.listenTo(_converse.connfeedback, 'change:connection_status', () => this.requestUpdate()); + this.listenTo(this.model, 'change:active-form', () => this.requestUpdate()); + this.listenTo(this.model, 'change:connected', () => this.requestUpdate()); + this.listenTo(this.model, 'change:closed', () => !this.model.get('closed') && this.afterShown()); + this.requestUpdate(); + } + + render () { + return this.model ? tplControlbox(this) : ''; + } + + close (ev) { + ev?.preventDefault?.(); + if ( + ev?.name === 'closeAllChatBoxes' && + (_converse.disconnection_cause !== LOGOUT || + api.settings.get('show_controlbox_by_default')) + ) { + return; + } + if (api.settings.get('sticky_controlbox')) { + return; + } + u.safeSave(this.model, { 'closed': true }); + api.trigger('controlBoxClosed', this); + return this; + } + + afterShown () { + /** + * Triggered once the controlbox has been opened + * @event _converse#controlBoxOpened + * @type {_converse.ControlBox} + */ + api.trigger('controlBoxOpened', this); + return this; + } +} + +api.elements.define('converse-controlbox', ControlBox); + +export default ControlBox; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js new file mode 100644 index 0000000..3026d33 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/index.js @@ -0,0 +1,78 @@ +/** + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import "shared/components/brand-heading.js"; +import "../chatview/index.js"; +import './loginform.js'; +import './navback.js'; +import ControlBox from './model.js'; +import ControlBoxToggle from './toggle.js'; +import ControlBoxView from './controlbox.js'; +import controlbox_api from './api.js'; +import log from '@converse/headless/log'; +import { _converse, api, converse } from '@converse/headless/core'; +import { addControlBox, clearSession, disconnect, onChatBoxesFetched } from './utils.js'; + +import './styles/_controlbox.scss'; +import './styles/controlbox-head.scss'; + + +converse.plugins.add('converse-controlbox', { + /* 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. + */ + dependencies: ['converse-modal', 'converse-chatboxes', 'converse-chat', 'converse-rosterview', 'converse-chatview'], + + enabled (_converse) { + return !_converse.api.settings.get('singleton'); + }, + + // Overrides mentioned here will be picked up by converse.js's + // plugin architecture they will replace existing methods on the + // relevant objects or classes. + // New functions which don't exist yet can also be added. + overrides: { + ChatBoxes: { + model (attrs, options) { + if (attrs && attrs.id == 'controlbox') { + return new ControlBox(attrs, options); + } else { + return this.__super__.model.apply(this, arguments); + } + } + } + }, + + initialize () { + api.settings.extend({ + allow_logout: true, + allow_user_trust_override: true, + default_domain: undefined, + locked_domain: undefined, + show_connection_url_input: false, + show_controlbox_by_default: false, + sticky_controlbox: false + }); + + api.promises.add('controlBoxInitialized', false); + Object.assign(api, controlbox_api); + + _converse.ControlBoxView = ControlBoxView; + _converse.ControlBox = ControlBox; + _converse.ControlBoxToggle = ControlBoxToggle; + + api.listen.on('chatBoxesFetched', onChatBoxesFetched); + api.listen.on('clearSession', clearSession); + api.listen.on('will-reconnect', disconnect); + + api.waitUntil('chatBoxViewsInitialized') + .then(addControlBox) + .catch(e => log.fatal(e)); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js new file mode 100644 index 0000000..0636aad --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/loginform.js @@ -0,0 +1,98 @@ +import bootstrap from 'bootstrap.native'; +import tplLoginPanel from './templates/loginform.js'; +import { ANONYMOUS } from '@converse/headless/shared/constants'; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api, converse } from '@converse/headless/core.js'; +import { initConnection } from '@converse/headless/utils/init.js'; +import { updateSettingsWithFormData, validateJID } from './utils.js'; + +const { Strophe, u } = converse.env; + + +class LoginForm extends CustomElement { + + initialize () { + this.listenTo(_converse.connfeedback, 'change', () => this.requestUpdate()); + this.handler = () => this.requestUpdate() + } + + connectedCallback () { + super.connectedCallback(); + api.settings.listen.on('change', this.handler); + } + + disconnectedCallback () { + super.disconnectedCallback(); + api.settings.listen.not('change', this.handler); + } + + render () { + return tplLoginPanel(this); + } + + firstUpdated () { + this.initPopovers(); + } + + async onLoginFormSubmitted (ev) { + ev?.preventDefault(); + + if (api.settings.get('authentication') === ANONYMOUS) { + return this.connect(_converse.jid); + } + + if (!validateJID(ev.target)) { + return; + } + updateSettingsWithFormData(ev.target); + + if (!api.settings.get('bosh_service_url') && !api.settings.get('websocket_url')) { + // We don't have a connection URL available, so we try here to discover + // XEP-0156 connection methods now, and if not found we present the user + // with the option to enter their own connection URL + await this.discoverConnectionMethods(ev); + } + + if (api.settings.get('bosh_service_url') || api.settings.get('websocket_url')) { + // FIXME: The connection class will still try to discover XEP-0156 connection methods + this.connect(); + } else { + api.settings.set('show_connection_url_input', true); + } + } + + // eslint-disable-next-line class-methods-use-this + discoverConnectionMethods (ev) { + if (!api.settings.get("discover_connection_methods")) { + return; + } + const form_data = new FormData(ev.target); + const jid = form_data.get('jid'); + const domain = Strophe.getDomainFromJid(jid); + if (!_converse.connection?.jid || (jid && !u.isSameDomain(_converse.connection.jid, jid))) { + initConnection(); + } + return _converse.connection.discoverConnectionMethods(domain); + } + + initPopovers () { + Array.from(this.querySelectorAll('[data-title]')).forEach(el => { + new bootstrap.Popover(el, { + 'trigger': (api.settings.get('view_mode') === 'mobile' && 'click') || 'hover', + 'dismissible': (api.settings.get('view_mode') === 'mobile' && true) || false, + 'container': this.parentElement.parentElement.parentElement, + }); + }); + } + + // eslint-disable-next-line class-methods-use-this + connect (jid) { + if (['converse/login', 'converse/register'].includes(_converse.router.history.getFragment())) { + _converse.router.navigate('', { 'replace': true }); + } + _converse.connection?.reset(); + api.user.login(jid); + } +} + +api.elements.define('converse-login-form', LoginForm); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js new file mode 100644 index 0000000..7adbfe4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/model.js @@ -0,0 +1,52 @@ +import { _converse, api, converse } from '@converse/headless/core'; +import { Model } from '@converse/skeletor/src/model.js'; + +const { dayjs } = converse.env; + +/** + * The ControlBox is the section of the chat that contains the open groupchats, + * bookmarks and roster. + * + * In `overlayed` `view_mode` it's a box like the chat boxes, in `fullscreen` + * `view_mode` it's a left-aligned sidebar. + * @mixin + */ +const ControlBox = Model.extend({ + + defaults () { + return { + 'bookmarked': false, + 'box_id': 'controlbox', + 'chat_state': undefined, + 'closed': !api.settings.get('show_controlbox_by_default'), + 'num_unread': 0, + 'time_opened': dayjs(0).valueOf(), + 'type': _converse.CONTROLBOX_TYPE, + 'url': '' + }; + }, + + validate (attrs) { + if (attrs.type === _converse.CONTROLBOX_TYPE) { + if (api.settings.get('view_mode') === 'embedded' && api.settings.get('singleton')) { + return 'Controlbox not relevant in embedded view mode'; + } + return; + } + return _converse.ChatBox.prototype.validate.call(this, attrs); + }, + + maybeShow (force) { + if (!force && this.get('id') === 'controlbox') { + // Must return the chatbox + return this; + } + return _converse.ChatBox.prototype.maybeShow.call(this, force); + }, + + onReconnection () { + this.save('connected', true); + } +}); + +export default ControlBox; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js new file mode 100644 index 0000000..efa2eb1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/navback.js @@ -0,0 +1,21 @@ +import tplControlboxNavback from "./templates/navback.js"; +import { CustomElement } from 'shared/components/element.js'; +import { api } from "@converse/headless/core"; + + +class ControlBoxNavback extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + render () { + return tplControlboxNavback(this.jid); + } +} + +api.elements.define('converse-controlbox-navback', ControlBoxNavback); + +export default ControlBoxNavback; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss new file mode 100644 index 0000000..f162a03 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/_controlbox.scss @@ -0,0 +1,583 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "shared/styles/_variables.scss"; +@import "shared/styles/_mixins.scss"; + +.conversejs { + .set-xmpp-status, + .xmpp-status { + .chat-status--online { + color: var(--chat-status-online); + } + .chat-status--busy { + color: var(--chat-status-busy); + } + .chat-status--away { + color: var(--chat-status-away); + } + .far.fa-circle, + .fa-times-circle { + color: var(--subdued-color); + } + } + + .set-xmpp-status { + .chat-status { + padding-right: 0.5em; + } + } + + .room-info { + font-size: var(--font-size-small); + font-style: normal; + font-weight: normal; + + li.room-info { + display: block; + margin-left: 5px; + } + p.room-info { + line-height: var(--line-height); + margin: 0; + display: block; + white-space: normal; + } + } + div.room-info { + padding: 0.3em 0; + clear: left; + width: 100%; + } + + #controlbox { + order: -1; + color: var(--controlbox-text-color); + + .chat-status--avatar { + border: 1px solid var(--controlbox-pane-background-color); + background: var(--controlbox-pane-background-color); + } + + converse-brand-logo { + width: 100%; + display: block; + } + + converse-brand-heading { + width: 100%; + display: block; + } + + .brand-name-wrapper { + font-size: 200%; + } + + .brand-name-wrapper--fullscreen { + font-size: 100%; + } + + .box-flyout { + background-color: var(--controlbox-pane-background-color); + } + + margin-right: calc(3 * var(--chat-gutter)); + + &.logged-out { + .box-flyout { + .controlbox-pane { + overflow-y: auto; + } + } + } + + form.search-xmpp-contact { + margin: 0; + padding-left: 5px; + padding: 0 0 5px 5px; + input { + width: 8em; + } + } + + .msgs-indicator { + margin-right: 0.5em; + } + + a.subscribe-to-user { + padding-left: 2em; + font-weight: bold; + } + + .conn-feedback { + color: var(--controlbox-head-color); + &.error { + color: var(--error-color); + } + p { + padding-bottom: 1em; + &.feedback-subject.error { + font-weight: bold; + } + } + } + + #converse-login-panel, #converse-register-panel { + padding-top: 0; + padding-bottom: 0; + } + + #converse-login-panel { + flex-direction: row; + } + + .toggle-register-login { + font-weight: bold; + } + + .controlbox-pane { + .userinfo { + padding-bottom: 1em; + + .username { + margin-left: 0.5em; + overflow: hidden; + text-overflow: ellipsis; + } + .profile { + margin-bottom: 0.75em; + } + } + } + + #chatrooms { + padding: 0; + + .add-chatroom { + input[type=button], + input[type=submit], + input[type=text] { + width: 100%; + } + margin: 0; + padding: 0; + } + } + + .controlbox-section { + + .controlbox-heading { + font-family: var(--heading-font); + color: var(--controlbox-heading-color); + font-weight: var(--controlbox-heading-font-weight); + padding: 0; + font-size: 1.1em; + line-height: 1.1em; + text-transform: uppercase; + } + + .controlbox-heading--groupchats { + color: var(--groupchats-header-color); + } + + .controlbox-heading--contacts { + color: var(--chat-head-color-dark); + } + + .controlbox-heading--headline { + color: var(--headlines-head-color); + } + + .controlbox-heading__btn { + cursor: pointer; + padding: 0 0 0 1em; + font-size: 1em; + margin: var(--controlbox-heading-top-margin) 0 var(--inline-action-margin) 0; + text-align: center; + &.fa-vcard { + margin-top: 1em; + } + } + } + + .dropdown { + a { + width: 143px; + display: inline-block; + } + li { + list-style: none; + padding-left: 0; + } + dd { + ul { + padding: 0; + list-style: none; + position: absolute; + left: 0; + top: 0; + width: 100%; + z-index: 21; + background-color: var(--light-background-color); + li:hover { + background-color: var(--highlight-color); + } + } + } + + dd.search-xmpp { + height: 0; + .contact-form-container { + position: absolute; + z-index: 22; + form { + box-shadow: 1px 4px 10px 1px rgba(0, 0, 0, 0.4); + background-color: white; + } + } + li:hover { + background-color: var(--light-background-color); + } + } + dt a span { + cursor: pointer; + display: block; + padding: 4px 7px 0 5px; + } + } + + .controlbox-panes { + background-color: var(--controlbox-pane-background-color); + height: 100%; + overflow-y: auto; + } + + .controlbox-subtitle { + font-size: 90%; + padding: 0.5em; + text-align: right; + } + + .controlbox-pane { + background-color: var(--controlbox-pane-background-color); + border: 0; + font-size: var(--font-size); + left: 0; + text-align: left; + overflow-x: hidden; + padding: 0 0 1em 0; + + .controlbox-padded { + padding-left: 1em; + padding-right: 1em; + align-items: center; + line-height: normal; + .change-status { + min-width: 25px; + text-align: center; + } + } + + .add-converse-contact { + margin: 0 0 0.75em 0; + } + + .chatbox-btn { + margin: 0; + } + + .switch-form { + text-align: center; + padding: 2em 0; + } + dd { + margin-left: 0; + margin-bottom: 0; + &.odd { + background-color: #DCEAC5; + } + } + } + + .add-xmpp-contact { + padding: 1em 0.5em; + input { + margin: 0 0 1rem; + width: 100%; + } + button { + width: 100%; + } + } + } +} + +.conversejs { + converse-chats { + &.converse-overlayed { + + display: flex; + flex-direction: row-reverse; + + .toggle-controlbox { + order: -2; + text-align: center; + background-color: var(--controlbox-head-color); + border-top-left-radius: var(--button-border-radius); + border-top-right-radius: var(--button-border-radius); + color: #0a0a0a; + float: right; + height: 100%; + margin: 0 var(--chat-gutter); + padding: 1em; + span { + color: var(--inverse-link-color); + } + } + + #controlbox { + order: -1; + min-width: var(--controlbox-width) !important; + width: var(--controlbox-width); + .box-flyout { + min-width: var(--controlbox-width) !important; + width: var(--controlbox-width); + } + + @media screen and (max-width: $mobile-portrait-length) { + margin-left: -15px; + } + @include media-breakpoint-down(sm) { + margin-left: -15px; + } + + .login-trusted { + white-space: nowrap; + font-size: 90%; + } + + #converse-login-trusted { + margin-top: 0.5em; + } + &:not(.logged-out) { + .controlbox-head { + height: 15px; + } + } + + #converse-register, #converse-login { + @include make-col(12); + padding-bottom: 0; + } + + #converse-register { + .button-cancel { + font-size: 90%; + } + } + } + + .brand-heading { + padding-top: 0.8rem; + padding-left: 0.8rem; + width: 100%; + } + .converse-svg-logo { + height: 1em; + } + #controlbox { + #converse-login-panel { + height: 100%; + } + .controlbox-panes { + margin-top: 0.5em; + } + } + } + + &.converse-embedded, + &.converse-fullscreen{ + .controlbox-panes { + border-right: 0.2rem solid var(--panel-divider-color); + } + .toggle-controlbox { + display: none; + } + } + + &.converse-embedded, + &.converse-fullscreen, + &.converse-mobile { + #controlbox { + @include make-col-ready(); + + @include media-breakpoint-up(md) { + @include make-col(4); + } + @include media-breakpoint-up(lg) { + @include make-col(3); + } + @include media-breakpoint-up(xl) { + @include make-col(2); + } + + &.logged-out { + @include make-col(12); + } + + margin: 0; + + .flyout { + border-radius: 0; + } + + #converse-login-panel { + border-radius: 0; + .converse-form { + padding: 3em 2em 3em; + } + } + + .toggle-register-login { + line-height: var(--line-height-huge); + } + + converse-brand-logo { + @include make-col(12); + margin-top: 5em; + margin-bottom: 1em; + .brand-heading { + width: 100%; + font-size: 500%; + padding: 0.7em 0 0 0; + opacity: 0.8; + color: var(--brand-heading-color); + } + .brand-subtitle { + font-size: 90%; + padding: 0.5em; + } + @media screen and (max-width: $mobile-portrait-length) { + .brand-heading { + font-size: 300%; + } + } + } + + &.logged-out { + @include make-col(12); + @include fade-in; + width: 100%; + .box-flyout { + width: 100%; + } + } + .box-flyout { + border: 0; + width: 100%; + z-index: 1; + background-color: var(--controlbox-head-color); + + .controlbox-head { + display: none; + } + } + + #converse-register, #converse-login { + @include make-col-ready(); + @include make-col(8); + @include make-col-offset(2); + + @include media-breakpoint-up(sm) { + @include make-col(8); + @include make-col-offset(2); + } + @include media-breakpoint-up(md) { + @include make-col(8); + @include make-col-offset(2); + } + @include media-breakpoint-up(lg) { + @include make-col(6); + @include make-col-offset(3); + } + .title, .instructions { + margin: 1em 0; + } + input[type=submit], + input[type=button] { + width: auto; + } + } + } + } + + &.converse-fullscreen { + #controlbox { + margin-left: -15px; + @media screen and (max-width: $mobile-portrait-length) { + margin-left: 0; + } + @include media-breakpoint-down(sm) { + margin-left: 0; + } + } + .controlbox-panes { + padding-top: 2em; + } + } + } +} + +@include media-breakpoint-down(sm) { + + .conversejs { + left: 0; + right: 0; + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + + .converse-chatboxes { + margin: 0 !important; + flex-direction: row !important; + justify-content: space-between; + + .converse-chatroom { + font-size: 14px; + } + + .chatbox { + .box-flyout { + left: 0; + bottom: 0; + border-radius: 0; + width: 100vw !important; + height: var(--fullpage-chat-height); + } + } + + #controlbox { + margin-left: 0; + width: 100vw !important; + .box-flyout { + width: 100vw !important; + height: var(--fullpage-chat-height); + margin-right: -15px; + } + .sidebar { + display: block; + } + } + + &.sidebar-open { + .chatbox:not(#controlbox) { + display: none; + } + #controlbox { + .controlbox-pane { + display: block; + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss new file mode 100644 index 0000000..f10c333 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/styles/controlbox-head.scss @@ -0,0 +1,24 @@ +.conversejs { + #controlbox { + .controlbox-head { + display: flex; + flex-direction: row-reverse; + flex-wrap: nowrap; + justify-content: space-between; + min-height: 0; + + .brand-heading { + color: var(--controlbox-text-color); + font-size: 2em; + } + .chatbox-btn { + margin: 0; + converse-icon { + svg { + fill: var(--controlbox-head-btn-color); + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js new file mode 100644 index 0000000..437acc4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/controlbox.js @@ -0,0 +1,51 @@ +import tplSpinner from "templates/spinner.js"; +import { _converse, api, converse } from "@converse/headless/core.js"; +import { html } from 'lit'; + +const { Strophe } = converse.env; + + +function whenNotConnected (o) { + const connection_status = _converse.connfeedback.get('connection_status'); + if ([Strophe.Status.RECONNECTING, Strophe.Status.CONNECTING].includes(connection_status)) { + return tplSpinner(); + } + if (o['active-form'] === 'register') { + return html`<converse-register-panel></converse-register-panel>`; + } + return html`<converse-login-form id="converse-login-panel" class="controlbox-pane fade-in row no-gutters"></converse-login-form>`; +} + + +export default (el) => { + const o = el.model.toJSON(); + const sticky_controlbox = api.settings.get('sticky_controlbox'); + + return html` + <div class="flyout box-flyout"> + <converse-dragresize></converse-dragresize> + <div class="chat-head controlbox-head"> + ${sticky_controlbox + ? '' + : html` + <a class="chatbox-btn close-chatbox-button" @click=${(ev) => el.close(ev)}> + <converse-icon class="fa fa-times" size="1em"></converse-icon> + </a> + `} + </div> + <div class="controlbox-panes"> + <div class="controlbox-pane"> + ${o.connected + ? html` + <converse-user-profile></converse-user-profile> + <converse-headlines-feeds-list class="controlbox-section"></converse-headlines-feeds-list> + <div id="chatrooms" class="controlbox-section"><converse-rooms-list></converse-rooms-list></div> + ${ api.settings.get("authentication") === _converse.ANONYMOUS ? '' : + html`<div id="converse-roster" class="controlbox-section"><converse-roster></converse-roster></div>` + }` + : whenNotConnected(o) + } + </div> + </div> + </div>` +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js new file mode 100644 index 0000000..e68015c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/loginform.js @@ -0,0 +1,163 @@ +import 'shared/components/brand-heading.js'; +import tplSpinner from 'templates/spinner.js'; +import { ANONYMOUS, EXTERNAL, LOGIN, PREBIND, CONNECTION_STATUS } from '@converse/headless/shared/constants'; +import { REPORTABLE_STATUSES, PRETTY_CONNECTION_STATUS, CONNECTION_STATUS_CSS_CLASS } from '../constants.js'; +import { __ } from 'i18n'; +import { _converse, api } from '@converse/headless/core'; +import { html } from 'lit'; + +const trust_checkbox = (checked) => { + const i18n_hint_trusted = __( + 'To improve performance, we cache your data in this browser. ' + + 'Uncheck this box if this is a public computer or if you want your data to be deleted when you log out. ' + + "It's important that you explicitly log out, otherwise not all cached data might be deleted. " + + 'Please note, when using an untrusted device, OMEMO encryption is NOT available.' + ); + const i18n_trusted = __('This is a trusted device'); + return html` + <div class="form-group form-check login-trusted"> + <input + id="converse-login-trusted" + type="checkbox" + class="form-check-input" + name="trusted" + ?checked=${checked} + /> + <label for="converse-login-trusted" class="form-check-label login-trusted__desc">${i18n_trusted}</label> + + <converse-icon + class="fa fa-info-circle" + data-toggle="popover" + data-title="Trusted device?" + data-content="${i18n_hint_trusted}" + size="1.2em" + title="${i18n_hint_trusted}" + ></converse-icon> + </div> + `; +}; + +const connection_url_input = () => { + const i18n_connection_url = __('Connection URL'); + const i18n_form_help = __('HTTP or websocket URL that is used to connect to your XMPP server'); + const i18n_placeholder = __('e.g. wss://example.org/xmpp-websocket'); + return html` + <div class="form-group fade-in"> + <label for="converse-conn-url">${i18n_connection_url}</label> + <p class="form-help instructions">${i18n_form_help}</p> + <input + id="converse-conn-url" + class="form-control" + type="url" + name="connection-url" + placeholder="${i18n_placeholder}" + /> + </div> + `; +}; + +const password_input = () => { + const i18n_password = __('Password'); + return html` + <div class="form-group"> + <label for="converse-login-password">${i18n_password}</label> + <input + id="converse-login-password" + class="form-control" + required="required" + value="${api.settings.get('password') ?? ''}" + type="password" + name="password" + placeholder="${i18n_password}" + /> + </div> + `; +}; + +const tplRegisterLink = () => { + const i18n_create_account = __('Create an account'); + const i18n_hint_no_account = __("Don't have a chat account?"); + return html` + <fieldset class="switch-form"> + <p>${i18n_hint_no_account}</p> + <p> + <a class="register-account toggle-register-login" href="#converse/register">${i18n_create_account}</a> + </p> + </fieldset> + `; +}; + +const tplShowRegisterLink = () => { + return ( + api.settings.get('allow_registration') && + !api.settings.get('auto_login') && + _converse.pluggable.plugins['converse-register'].enabled(_converse) + ); +}; + +const auth_fields = (el) => { + const authentication = api.settings.get('authentication'); + const i18n_login = __('Log in'); + const i18n_xmpp_address = __('XMPP Address'); + const locked_domain = api.settings.get('locked_domain'); + const default_domain = api.settings.get('default_domain'); + const placeholder_username = ((locked_domain || default_domain) && __('Username')) || __('user@domain'); + const show_trust_checkbox = api.settings.get('allow_user_trust_override'); + + return html` + <div class="form-group"> + <label for="converse-login-jid">${i18n_xmpp_address}:</label> + <input + id="converse-login-jid" + ?autofocus=${api.settings.get('auto_focus') ? true : false} + @changed=${el.validate} + value="${api.settings.get('jid') ?? ''}" + required + class="form-control" + type="text" + name="jid" + placeholder="${placeholder_username}" + /> + </div> + ${authentication !== EXTERNAL ? password_input() : ''} + ${api.settings.get('show_connection_url_input') ? connection_url_input() : ''} + ${show_trust_checkbox ? trust_checkbox(show_trust_checkbox === 'off' ? false : true) : ''} + <fieldset class="form-group buttons"> + <input class="btn btn-primary" type="submit" value="${i18n_login}" /> + </fieldset> + ${tplShowRegisterLink() ? tplRegisterLink() : ''} + `; +}; + +const form_fields = (el) => { + const authentication = api.settings.get('authentication'); + const i18n_disconnected = __('Disconnected'); + const i18n_anon_login = __('Click here to log in anonymously'); + return html` + ${authentication == LOGIN || authentication == EXTERNAL ? auth_fields(el) : ''} + ${authentication == ANONYMOUS + ? html`<input class="btn btn-primary login-anon" type="submit" value="${i18n_anon_login}" />` + : ''} + ${authentication == PREBIND ? html`<p>${i18n_disconnected}</p>` : ''} + `; +}; + +export default (el) => { + const connection_status = _converse.connfeedback.get('connection_status'); + let feedback_class, pretty_status; + if (REPORTABLE_STATUSES.includes(connection_status)) { + pretty_status = PRETTY_CONNECTION_STATUS[connection_status]; + feedback_class = CONNECTION_STATUS_CSS_CLASS[connection_status]; + } + const conn_feedback_message = _converse.connfeedback.get('message'); + return html` <converse-brand-heading></converse-brand-heading> + <form id="converse-login" class="converse-form" method="post" @submit=${el.onLoginFormSubmitted}> + <div class="conn-feedback fade-in ${!pretty_status ? 'hidden' : feedback_class}"> + <p class="feedback-subject">${pretty_status}</p> + <p class="feedback-message ${!conn_feedback_message ? 'hidden' : ''}">${conn_feedback_message}</p> + </div> + ${CONNECTION_STATUS[connection_status] === 'CONNECTING' + ? tplSpinner({ 'classes': 'hor_centered' }) + : form_fields(el)} + </form>`; +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js new file mode 100644 index 0000000..57819fc --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/navback.js @@ -0,0 +1,6 @@ +import { html } from "lit"; +import { navigateToControlBox } from '../utils.js'; + +export default (jid) => { + return html`<converse-icon size="1em" class="fa fa-arrow-left" @click=${() => navigateToControlBox(jid)}></converse-icon>` +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js new file mode 100644 index 0000000..2ce005c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/templates/toggle.js @@ -0,0 +1,8 @@ +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; +import { html } from "lit"; + +export default (o) => { + const i18n_toggle = api.connection.connected() ? __('Chat Contacts') : __('Toggle chat'); + return html`<a id="toggle-controlbox" class="toggle-controlbox ${o.hide ? 'hidden' : ''}" @click=${o.onClick}><span class="toggle-feedback">${i18n_toggle}</span></a>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js new file mode 100644 index 0000000..01100c0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/controlbox.js @@ -0,0 +1,107 @@ +/*global mock, converse */ + +const $msg = converse.env.$msg; +const u = converse.env.utils; + +describe("The Controlbox", function () { + + it("can be opened by clicking a DOM element with class 'toggle-controlbox'", + mock.initConverse([], {}, async function (_converse) { + + spyOn(_converse.api, "trigger").and.callThrough(); + document.querySelector('.toggle-controlbox').click(); + expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxOpened', jasmine.any(Object)); + const el = await u.waitUntil(() => document.querySelector("#controlbox")); + expect(u.isVisible(el)).toBe(true); + })); + + + it("can be closed by clicking a DOM element with class 'close-chatbox-button'", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openControlBox(_converse); + const view = _converse.chatboxviews.get('controlbox'); + + spyOn(view, 'close').and.callThrough(); + spyOn(_converse.api, "trigger").and.callThrough(); + + view.querySelector('.close-chatbox-button').click(); + expect(view.close).toHaveBeenCalled(); + expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxClosed', jasmine.any(Object)); + })); + + + describe("The \"Contacts\" section", function () { + + it("can be used to add contact and it checks for case-sensivity", + mock.initConverse([], {}, async function (_converse) { + + spyOn(_converse.api, "trigger").and.callThrough(); + await mock.waitForRoster(_converse, 'all', 0); + await mock.openControlBox(_converse); + // Adding two contacts one with Capital initials and one with small initials of same JID (Case sensitive check) + _converse.roster.create({ + jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'none', + ask: 'subscribe', + fullname: mock.pend_names[0] + }); + _converse.roster.create({ + jid: mock.pend_names[0].replace(/ /g,'.') + '@montague.lit', + subscription: 'none', + ask: 'subscribe', + fullname: mock.pend_names[0] + }); + const rosterview = await u.waitUntil(() => document.querySelector('converse-roster')); + await u.waitUntil(() => Array.from(rosterview.querySelectorAll('.roster-group li')).filter(u.isVisible).length, 700); + // Checking that only one entry is created because both JID is same (Case sensitive check) + expect(Array.from(rosterview.querySelectorAll('li')).filter(u.isVisible).length).toBe(1); + })); + + it("shows the number of unread mentions received", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'all'); + await mock.openControlBox(_converse); + + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, sender_jid); + await u.waitUntil(() => _converse.chatboxes.length); + const chatview = _converse.chatboxviews.get(sender_jid); + chatview.model.set({'minimized': true}); + + const el = document.querySelector('converse-chats'); + expect(el.querySelector('.restore-chat .message-count') === null).toBeTruthy(); + const rosterview = document.querySelector('converse-roster'); + expect(rosterview.querySelector('.msgs-indicator') === null).toBeTruthy(); + + let msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('hello').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => rosterview.querySelectorAll(".msgs-indicator").length); + spyOn(chatview.model, 'handleUnreadMessage').and.callThrough(); + await u.waitUntil(() => el.querySelector('.restore-chat .message-count')?.textContent === '1'); + expect(rosterview.querySelector('.msgs-indicator').textContent).toBe('1'); + + msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('hello again').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + _converse.handleMessageStanza(msg); + await u.waitUntil(() => chatview.model.handleUnreadMessage.calls.count()); + await u.waitUntil(() => el.querySelector('.restore-chat .message-count')?.textContent === '2'); + expect(rosterview.querySelector('.msgs-indicator').textContent).toBe('2'); + chatview.model.set({'minimized': false}); + await u.waitUntil(() => el.querySelector('.restore-chat .message-count') === null); + await u.waitUntil(() => rosterview.querySelector('.msgs-indicator') === null); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js new file mode 100644 index 0000000..cd8a25b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/tests/login.js @@ -0,0 +1,70 @@ +/*global mock, converse */ + +const u = converse.env.utils; + +describe("The Login Form", function () { + + it("contains a checkbox to indicate whether the computer is trusted or not", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + allow_registration: false }, + async function (_converse) { + + const cbview = await u.waitUntil(() => _converse.chatboxviews.get('controlbox')); + mock.toggleControlBox(); + const checkboxes = cbview.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes.length).toBe(1); + + const checkbox = checkboxes[0]; + const label = cbview.querySelector(`label[for="${checkbox.getAttribute('id')}"]`); + expect(label.textContent).toBe('This is a trusted device'); + expect(checkbox.checked).toBe(true); + + cbview.querySelector('input[name="jid"]').value = 'romeo@montague.lit'; + cbview.querySelector('input[name="password"]').value = 'secret'; + + expect(_converse.config.get('trusted')).toBe(true); + expect(_converse.getDefaultStore()).toBe('persistent'); + cbview.querySelector('input[type="submit"]').click(); + expect(_converse.config.get('trusted')).toBe(true); + expect(_converse.getDefaultStore()).toBe('persistent'); + + checkbox.click(); + cbview.querySelector('input[type="submit"]').click(); + expect(_converse.config.get('trusted')).toBe(false); + expect(_converse.getDefaultStore()).toBe('session'); + })); + + it("checkbox can be set to false by default", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + allow_user_trust_override: 'off', + allow_registration: false }, + async function (_converse) { + + await u.waitUntil(() => _converse.chatboxviews.get('controlbox')) + const cbview = _converse.chatboxviews.get('controlbox'); + mock.toggleControlBox(); + const checkboxes = cbview.querySelectorAll('input[type="checkbox"]'); + expect(checkboxes.length).toBe(1); + + const checkbox = checkboxes[0]; + const label = cbview.querySelector(`label[for="${checkbox.getAttribute('id')}"]`); + expect(label.textContent).toBe('This is a trusted device'); + expect(checkbox.checked).toBe(false); + + cbview.querySelector('input[name="jid"]').value = 'romeo@montague.lit'; + cbview.querySelector('input[name="password"]').value = 'secret'; + + cbview.querySelector('input[type="submit"]').click(); + expect(_converse.config.get('trusted')).toBe(false); + expect(_converse.getDefaultStore()).toBe('session'); + + checkbox.click(); + cbview.querySelector('input[type="submit"]').click(); + expect(_converse.config.get('trusted')).toBe(true); + expect(_converse.getDefaultStore()).toBe('persistent'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js new file mode 100644 index 0000000..e2293f6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/toggle.js @@ -0,0 +1,27 @@ +import tplControlboxToggle from "./templates/toggle.js"; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api } from "@converse/headless/core"; +import { showControlBox } from './utils.js'; + + +class ControlBoxToggle extends CustomElement { + + async connectedCallback () { + super.connectedCallback(); + await api.waitUntil('initialized') + this.model = _converse.chatboxes.get('controlbox'); + this.listenTo(this.model, 'change:closed', () => this.requestUpdate()); + this.requestUpdate(); + } + + render () { + return tplControlboxToggle({ + 'onClick': showControlBox, + 'hide': !this.model?.get('closed') + }); + } +} + +api.elements.define('converse-controlbox-toggle', ControlBoxToggle); + +export default ControlBoxToggle; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js new file mode 100644 index 0000000..9da262e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/controlbox/utils.js @@ -0,0 +1,102 @@ +import { __ } from 'i18n/index.js'; +import { _converse, api, converse } from "@converse/headless/core.js"; + +const { Strophe, u } = converse.env; + +export function addControlBox () { + const m = _converse.chatboxes.add(new _converse.ControlBox({'id': 'controlbox'})); + _converse.chatboxviews.get('controlbox')?.setModel(); + return m; +} + +export function showControlBox (ev) { + ev?.preventDefault?.(); + const controlbox = _converse.chatboxes.get('controlbox') || addControlBox(); + u.safeSave(controlbox, {'closed': false}); +} + +export function navigateToControlBox (jid) { + showControlBox(); + const model = _converse.chatboxes.get(jid); + u.safeSave(model, {'hidden': true}); +} + +export function disconnect () { + /* Upon disconnection, set connected to `false`, so that if + * we reconnect, "onConnected" will be called, + * to fetch the roster again and to send out a presence stanza. + */ + const view = _converse.chatboxviews.get('controlbox'); + view.model.set({ 'connected': false }); + return view; +} + +export function clearSession () { + const chatboxviews = _converse?.chatboxviews; + const view = chatboxviews && chatboxviews.get('controlbox'); + if (view) { + u.safeSave(view.model, { 'connected': false }); + if (view?.controlbox_pane) { + view.controlbox_pane.remove(); + delete view.controlbox_pane; + } + } +} + +export function onChatBoxesFetched () { + const controlbox = _converse.chatboxes.get('controlbox') || addControlBox(); + controlbox.save({ 'connected': true }); +} + + +/** + * Given the login `<form>` element, parse its data and update the + * converse settings with the supplied JID, password and connection URL. + * @param { HTMLElement } form + * @param { Object } settings - Extra settings that may be passed in and will + * also be set together with the form settings. + */ +export function updateSettingsWithFormData (form, settings={}) { + const form_data = new FormData(form); + + const connection_url = form_data.get('connection-url'); + if (connection_url?.startsWith('ws')) { + settings['websocket_url'] = connection_url; + } else if (connection_url?.startsWith('http')) { + settings['bosh_service_url'] = connection_url; + } + + let jid = form_data.get('jid'); + if (api.settings.get('locked_domain')) { + const last_part = '@' + api.settings.get('locked_domain'); + if (jid.endsWith(last_part)) { + jid = jid.substr(0, jid.length - last_part.length); + } + jid = Strophe.escapeNode(jid) + last_part; + } else if (api.settings.get('default_domain') && !jid.includes('@')) { + jid = jid + '@' + api.settings.get('default_domain'); + } + settings['jid'] = jid; + settings['password'] = form_data.get('password'); + + api.settings.set(settings); + + _converse.config.save({ 'trusted': (form_data.get('trusted') && true) || false }); +} + + +export function validateJID (form) { + const jid_element = form.querySelector('input[name=jid]'); + if ( + jid_element.value && + !api.settings.get('locked_domain') && + !api.settings.get('default_domain') && + !u.isValidJID(jid_element.value) + ) { + jid_element.setCustomValidity(__('Please enter a valid XMPP address')); + return false; + } + jid_element.setCustomValidity(''); + return true; +} + diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js new file mode 100644 index 0000000..0742fbd --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/components/dragresize.js @@ -0,0 +1,13 @@ +import tplDragresize from "../templates/dragresize.js"; +import { CustomElement } from 'shared/components/element.js'; +import { api } from '@converse/headless/core.js'; + + +class ConverseDragResize extends CustomElement { + + render () { // eslint-disable-line class-methods-use-this + return tplDragresize(); + } +} + +api.elements.define('converse-dragresize', ConverseDragResize); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js new file mode 100644 index 0000000..990a560 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/index.js @@ -0,0 +1,97 @@ +/** + * @module converse-dragresize + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './components/dragresize.js'; +import { applyDragResistance, onMouseUp, onMouseMove } from './utils.js'; +import DragResizableMixin from './mixin.js'; +import { _converse, api, converse } from '@converse/headless/core'; + +converse.plugins.add('converse-dragresize', { + /* 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. + */ + dependencies: ['converse-chatview', 'converse-headlines-view', 'converse-muc-views'], + + enabled (_converse) { + return _converse.api.settings.get('view_mode') == 'overlayed'; + }, + + // Overrides mentioned here will be picked up by converse.js's + // plugin architecture they will replace existing methods on the + // relevant objects or classes. + overrides: { + ChatBox: { + initialize () { + const result = this.__super__.initialize.apply(this, arguments); + const height = this.get('height'); + const width = this.get('width'); + const save = this.get('id') === 'controlbox' ? a => this.set(a) : a => this.save(a); + save({ + 'height': applyDragResistance(height, this.get('default_height')), + 'width': applyDragResistance(width, this.get('default_width')) + }); + return result; + } + } + }, + + initialize () { + /* The initialize function gets called as soon as the plugin is + * loaded by converse.js's plugin machinery. + */ + api.settings.extend({ + 'allow_dragresize': true + }); + + Object.assign(_converse.ChatBoxView.prototype, DragResizableMixin); + Object.assign(_converse.ChatRoomView.prototype, DragResizableMixin); + if (_converse.ControlBoxView) { + Object.assign(_converse.ControlBoxView.prototype, DragResizableMixin); + } + + /************************ BEGIN Event Handlers ************************/ + function registerGlobalEventHandlers () { + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + } + + function unregisterGlobalEventHandlers () { + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + } + + /** + * This function registers mousedown and mouseup events hadlers to + * all iframes in the DOM when converse UI resizing events are called + * to prevent mouse drag stutter effect which is bad user experience. + * @function dragresizeOverIframeHandler + * @param {Object} e - dragging node element. + */ + function dragresizeOverIframeHandler (e) { + const iframes = document.getElementsByTagName('iframe'); + for (let iframe of iframes) { + e.addEventListener('mousedown', () => { + iframe.style.pointerEvents = 'none'; + }, { once: true }); + + e.addEventListener('mouseup', () => { + iframe.style.pointerEvents = 'initial'; + }, { once: true }); + } + } + + api.listen.on('registeredGlobalEventHandlers', registerGlobalEventHandlers); + api.listen.on('unregisteredGlobalEventHandlers', unregisterGlobalEventHandlers); + api.listen.on('beforeShowingChatView', view => view.initDragResize().setDimensions()); + api.listen.on('startDiagonalResize', dragresizeOverIframeHandler); + api.listen.on('startHorizontalResize', dragresizeOverIframeHandler); + api.listen.on('startVerticalResize', dragresizeOverIframeHandler); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js new file mode 100644 index 0000000..a05ef94 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/mixin.js @@ -0,0 +1,114 @@ +import debounce from 'lodash-es/debounce'; +import { _converse } from '@converse/headless/core'; +import { applyDragResistance } from './utils.js'; + +const DragResizableMixin = { + initDragResize () { + const view = this; + const debouncedSetDimensions = debounce(() => view.setDimensions()); + window.addEventListener('resize', view.debouncedSetDimensions); + this.listenTo(this.model, 'destroy', () => window.removeEventListener('resize', debouncedSetDimensions)); + + // Determine and store the default box size. + // We need this information for the drag-resizing feature. + const flyout = this.querySelector('.box-flyout'); + const style = window.getComputedStyle(flyout); + + if (this.model.get('height') === undefined) { + const height = parseInt(style.height.replace(/px$/, ''), 10); + const width = parseInt(style.width.replace(/px$/, ''), 10); + this.model.set('height', height); + this.model.set('default_height', height); + this.model.set('width', width); + this.model.set('default_width', width); + } + const min_width = style['min-width']; + const min_height = style['min-height']; + this.model.set('min_width', min_width.endsWith('px') ? Number(min_width.replace(/px$/, '')) : 0); + this.model.set('min_height', min_height.endsWith('px') ? Number(min_height.replace(/px$/, '')) : 0); + // Initialize last known mouse position + this.prev_pageY = 0; + this.prev_pageX = 0; + if (_converse.connection?.connected) { + this.height = this.model.get('height'); + this.width = this.model.get('width'); + } + return this; + }, + + resizeChatBox (ev) { + let diff; + if (_converse.resizing.direction.indexOf('top') === 0) { + diff = ev.pageY - this.prev_pageY; + if (diff) { + this.height = + this.height - diff > (this.model.get('min_height') || 0) + ? this.height - diff + : this.model.get('min_height'); + this.prev_pageY = ev.pageY; + this.setChatBoxHeight(this.height); + } + } + if (_converse.resizing.direction.includes('left')) { + diff = this.prev_pageX - ev.pageX; + if (diff) { + this.width = + this.width + diff > (this.model.get('min_width') || 0) + ? this.width + diff + : this.model.get('min_width'); + this.prev_pageX = ev.pageX; + this.setChatBoxWidth(this.width); + } + } + }, + + setDimensions () { + // Make sure the chat box has the right height and width. + this.adjustToViewport(); + this.setChatBoxHeight(this.model.get('height')); + this.setChatBoxWidth(this.model.get('width')); + }, + + setChatBoxHeight (height) { + if (height) { + height = applyDragResistance(height, this.model.get('default_height')) + 'px'; + } else { + height = ''; + } + const flyout_el = this.querySelector('.box-flyout'); + if (flyout_el !== null) { + flyout_el.style.height = height; + } + }, + + setChatBoxWidth (width) { + if (width) { + width = applyDragResistance(width, this.model.get('default_width')) + 'px'; + } else { + width = ''; + } + this.style.width = width; + const flyout_el = this.querySelector('.box-flyout'); + if (flyout_el !== null) { + flyout_el.style.width = width; + } + }, + + adjustToViewport () { + /* Event handler called when viewport gets resized. We remove + * custom width/height from chat boxes. + */ + const viewport_width = Math.max(document.documentElement.clientWidth, window.innerWidth || 0); + const viewport_height = Math.max(document.documentElement.clientHeight, window.innerHeight || 0); + if (viewport_width <= 480) { + this.model.set('height', undefined); + this.model.set('width', undefined); + } else if (viewport_width <= this.model.get('width')) { + this.model.set('width', undefined); + } else if (viewport_height <= this.model.get('height')) { + this.model.set('height', undefined); + } + } +}; + +export default DragResizableMixin; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js new file mode 100644 index 0000000..c795ffe --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/templates/dragresize.js @@ -0,0 +1,8 @@ +import { html } from 'lit'; +import { onStartDiagonalResize, onStartHorizontalResize, onStartVerticalResize } from '../utils.js'; + +export default () => html` + <div class="dragresize dragresize-top" @mousedown="${onStartVerticalResize}"></div> + <div class="dragresize dragresize-topleft" @mousedown="${onStartDiagonalResize}"></div> + <div class="dragresize dragresize-left" @mousedown="${onStartHorizontalResize}"></div> +`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js new file mode 100644 index 0000000..f15877e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/dragresize/utils.js @@ -0,0 +1,117 @@ +import { _converse, api, converse } from '@converse/headless/core'; + +const { u } = converse.env; + + +export function onStartVerticalResize (ev, trigger = true) { + if (!api.settings.get('allow_dragresize')) { + return true; + } + ev.preventDefault(); + // Record element attributes for mouseMove(). + const flyout = u.ancestor(ev.target, '.box-flyout'); + const style = window.getComputedStyle(flyout); + const chatbox_el = flyout.parentElement; + chatbox_el.height = parseInt(style.height.replace(/px$/, ''), 10); + _converse.resizing = { + 'chatbox': chatbox_el, + 'direction': 'top' + }; + chatbox_el.prev_pageY = ev.pageY; + if (trigger) { + /** + * Triggered once the user starts to vertically resize a {@link _converse.ChatBoxView} + * @event _converse#startVerticalResize + * @example _converse.api.listen.on('startVerticalResize', (view) => { ... }); + */ + api.trigger('startVerticalResize', chatbox_el); + } +} + +export function onStartHorizontalResize (ev, trigger = true) { + if (!api.settings.get('allow_dragresize')) { + return true; + } + ev.preventDefault(); + const flyout = u.ancestor(ev.target, '.box-flyout'); + const style = window.getComputedStyle(flyout); + const chatbox_el = flyout.parentElement; + chatbox_el.width = parseInt(style.width.replace(/px$/, ''), 10); + _converse.resizing = { + 'chatbox': chatbox_el, + 'direction': 'left' + }; + chatbox_el.prev_pageX = ev.pageX; + if (trigger) { + /** + * Triggered once the user starts to horizontally resize a {@link _converse.ChatBoxView} + * @event _converse#startHorizontalResize + * @example _converse.api.listen.on('startHorizontalResize', (view) => { ... }); + */ + api.trigger('startHorizontalResize', chatbox_el); + } +} + +export function onStartDiagonalResize (ev) { + onStartHorizontalResize(ev, false); + onStartVerticalResize(ev, false); + _converse.resizing.direction = 'topleft'; + /** + * Triggered once the user starts to diagonally resize a {@link _converse.ChatBoxView} + * @event _converse#startDiagonalResize + * @example _converse.api.listen.on('startDiagonalResize', (view) => { ... }); + */ + api.trigger('startDiagonalResize', this); +} + +/** + * Applies some resistance to `value` around the `default_value`. + * If value is close enough to `default_value`, then it is returned, otherwise + * `value` is returned. + * @param { number } value + * @param { number } default_value + * @returns { number } + */ +export function applyDragResistance (value, default_value) { + if (value === undefined) { + return undefined; + } else if (default_value === undefined) { + return value; + } + const resistance = 10; + if (value !== default_value && Math.abs(value - default_value) < resistance) { + return default_value; + } + return value; +} + +export function onMouseMove (ev) { + if (!_converse.resizing || !api.settings.get('allow_dragresize')) { + return true; + } + ev.preventDefault(); + _converse.resizing.chatbox.resizeChatBox(ev); +} + +export function onMouseUp (ev) { + if (!_converse.resizing || !api.settings.get('allow_dragresize')) { + return true; + } + ev.preventDefault(); + const height = applyDragResistance( + _converse.resizing.chatbox.height, + _converse.resizing.chatbox.model.get('default_height') + ); + const width = applyDragResistance( + _converse.resizing.chatbox.width, + _converse.resizing.chatbox.model.get('default_width') + ); + if (api.connection.connected()) { + _converse.resizing.chatbox.model.save({ 'height': height }); + _converse.resizing.chatbox.model.save({ 'width': width }); + } else { + _converse.resizing.chatbox.model.set({ 'height': height }); + _converse.resizing.chatbox.model.set({ 'width': width }); + } + _converse.resizing = null; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js new file mode 100644 index 0000000..72b6733 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/index.js @@ -0,0 +1,27 @@ +/** + * @module converse-fullscreen + * @license Mozilla Public License (MPLv2) + * @copyright 2022, the Converse.js contributors + */ +import { api, converse } from "@converse/headless/core"; +import { isUniView } from '@converse/headless/utils/core.js'; + +import './styles/fullscreen.scss'; + + +converse.plugins.add('converse-fullscreen', { + + enabled () { + return isUniView(); + }, + + initialize () { + api.settings.extend({ + chatview_avatar_height: 50, + chatview_avatar_width: 50, + hide_open_bookmarks: true, + show_controlbox_by_default: true, + sticky_controlbox: true + }); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss new file mode 100644 index 0000000..70a7712 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/fullscreen/styles/fullscreen.scss @@ -0,0 +1,5 @@ +body.converse-fullscreen { + margin: 0; + background-color: var(--global-background-color); + overflow: hidden; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js new file mode 100644 index 0000000..bd60f05 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/feed-list.js @@ -0,0 +1,37 @@ +import tplFeedsList from './templates/feeds-list.js'; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api } from '@converse/headless/core'; + +/** + * Custom element which renders a list of headline feeds + * @class + * @namespace _converse.HeadlinesFeedsList + * @memberOf _converse + */ +export class HeadlinesFeedsList extends CustomElement { + + initialize () { + this.model = _converse.chatboxes; + this.listenTo(this.model, 'add', (m) => this.renderIfHeadline(m)); + this.listenTo(this.model, 'remove', (m) => this.renderIfHeadline(m)); + this.listenTo(this.model, 'destroy', (m) => this.renderIfHeadline(m)); + this.requestUpdate(); + } + + render () { + return tplFeedsList(this); + } + + renderIfHeadline (model) { + return model?.get('type') === _converse.HEADLINES_TYPE && this.requestUpdate(); + } + + async openHeadline (ev) { // eslint-disable-line class-methods-use-this + ev.preventDefault(); + const jid = ev.target.getAttribute('data-headline-jid'); + const feed = await api.headlines.get(jid); + feed.maybeShow(true); + } +} + +api.elements.define('converse-headlines-feeds-list', HeadlinesFeedsList); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js new file mode 100644 index 0000000..6df5335 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/heading.js @@ -0,0 +1,59 @@ +import tplChatHead from './templates/chat-head.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core.js"; + + +export default class HeadlinesHeading extends CustomElement { + + static get properties () { + return { + 'jid': { type: String }, + } + } + + async initialize () { + this.model = _converse.chatboxes.get(this.jid); + await this.model.initialized; + this.requestUpdate(); + } + + render () { + return tplChatHead({ + ...this.model.toJSON(), + ...{ + 'display_name': this.model.getDisplayName(), + 'heading_buttons_promise': this.getHeadingButtons() + } + }); + } + + /** + * Returns a list of objects which represent buttons for the headlines header. + * @async + * @emits _converse#getHeadingButtons + * @method HeadlinesHeading#getHeadingButtons + */ + getHeadingButtons () { + const buttons = []; + if (!api.settings.get('singleton')) { + buttons.push({ + 'a_class': 'close-chatbox-button', + 'handler': ev => this.close(ev), + 'i18n_text': __('Close'), + 'i18n_title': __('Close these announcements'), + 'icon_class': 'fa-times', + 'name': 'close', + 'standalone': api.settings.get('view_mode') === 'overlayed' + }); + } + return _converse.api.hook('getHeadingButtons', this, buttons); + } + + close (ev) { + ev.preventDefault(); + this.model.close(); + } +} + +api.elements.define('converse-headlines-heading', HeadlinesHeading); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js new file mode 100644 index 0000000..4252cba --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/index.js @@ -0,0 +1,34 @@ +/** + * @module converse-headlines-view + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import '../chatview/index.js'; +import './view.js'; +import { HeadlinesFeedsList } from './feed-list.js'; +import { _converse, converse } from '@converse/headless/core'; + +import './styles/headlines.scss'; +import './styles/headlines-head.scss'; + + +converse.plugins.add('converse-headlines-view', { + /* 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 by the bundler + */ + dependencies: ['converse-headlines', 'converse-chatview'], + + initialize () { + _converse.HeadlinesFeedsList = HeadlinesFeedsList; + + // Deprecated + _converse.HeadlinesPanel = HeadlinesFeedsList; + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss new file mode 100644 index 0000000..a74af8c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines-head.scss @@ -0,0 +1,53 @@ +.conversejs { + .chatbox { + &.headlines { + converse-headlines-heading { + &.chat-head { + .chatbox-title--no-desc { + padding: 0.75rem 1rem; + } + + &.chat-head-chatbox { + background-color: var(--headlines-head-bg-color); + border-bottom: var(--headlines-head-border-bottom); + } + background-color: var(--headlines-head-bg-color); + + .chatbox-title__text { + color: var(--headlines-head-text-color) !important; + } + + converse-dropdown { + .dropdown-menu { + converse-icon { + svg { + fill: var(--headlines-color); + } + } + } + } + + .chatbox-btn { + converse-icon { + svg { + fill: var(--headlines-head-fg-color); + } + } + } + } + } + + converse-chats { + &.converse-fullscreen { + .chatbox.headlines { + .chat-head { + &.chat-head-chatbox { + background-color: var(--headlines-head-bg-color); + } + } + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss new file mode 100644 index 0000000..517f921 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/styles/headlines.scss @@ -0,0 +1,51 @@ +.conversejs { + .chatbox { + &.headlines { + .chat-body { + background-color: var(--background); + .chat-message { + color: var(--headline-message-color); + } + hr { + border-bottom: var(--headline-separator-border-bottom); + } + } + .chat-content { + height: 100%; + } + } + + } + + .message { + &.chat-msg { + &.headline { + .chat-msg__body { + margin-left: 0; + } + } + } + } + + #controlbox { + .controlbox-section { + .controlbox-heading--headline { + color: var(--headlines-head-text-color); + } + } + } + + + converse-chats { + &.converse-fullscreen { + .chatbox.headlines { + .box-flyout { + background-color: var(--headlines-head-text-color); + } + .flyout { + border-color: var(--headlines-head-text-color); + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js new file mode 100644 index 0000000..bed5e0b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/chat-head.js @@ -0,0 +1,21 @@ +import { _converse } from '@converse/headless/core'; +import { html } from "lit"; +import { until } from 'lit/directives/until.js'; +import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js'; + + +export default (o) => { + return html` + <div class="chatbox-title ${ o.status ? '' : "chatbox-title--no-desc"}"> + <div class="chatbox-title--row"> + ${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' } + <div class="chatbox-title__text" title="${o.jid}">${ o.display_name }</div> + </div> + <div class="chatbox-title__buttons row no-gutters"> + ${ until(getDropdownButtons(o.heading_buttons_promise), '') } + ${ until(getStandaloneButtons(o.heading_buttons_promise), '') } + </div> + </div> + ${ o.status ? html`<p class="chat-head__desc">${ o.status }</p>` : '' } + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js new file mode 100644 index 0000000..9656e20 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/feeds-list.js @@ -0,0 +1,33 @@ +import { __ } from 'i18n'; +import { _converse } from '@converse/headless/core'; +import { html } from "lit"; + +function tplHeadlinesFeedsListItem (el, feed) { + const open_title = __('Click to open this server message'); + return html` + <div class="list-item controlbox-padded d-flex flex-row" + data-headline-jid="${feed.get('jid')}"> + <a class="list-item-link open-headline available-room w-100" + data-headline-jid="${feed.get('jid')}" + title="${open_title}" + @click=${ev => el.openHeadline(ev)} + href="#">${feed.get('jid')}</a> + </div> + `; +} + +export default (el) => { + const feeds = el.model.filter(m => m.get('type') === _converse.HEADLINES_TYPE); + const heading_headline = __('Announcements'); + return html` + <div class="controlbox-section" id="headline"> + <div class="d-flex controlbox-padded ${ feeds.length ? '' : 'hidden' }"> + <span class="w-100 controlbox-heading controlbox-heading--headline">${heading_headline}</span> + </div> + </div> + <div class="list-container list-container--headline ${ feeds.length ? '' : 'hidden' }"> + <div class="items-list rooms-list headline-list"> + ${ feeds.map(feed => tplHeadlinesFeedsListItem(el, feed)) } + </div> + </div>` +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js new file mode 100644 index 0000000..9b5fa89 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/templates/headlines.js @@ -0,0 +1,18 @@ +import '../heading.js'; +import { html } from "lit"; + +export default (model) => html` + <div class="flyout box-flyout"> + <converse-dragresize></converse-dragresize> + ${ model ? html` + <converse-headlines-heading jid="${model.get('jid')}" class="chat-head chat-head-chatbox row no-gutters"> + </converse-headlines-heading> + <div class="chat-body"> + <div class="chat-content" aria-live="polite"> + <converse-chat-content + class="chat-content__messages" + jid="${model.get('jid')}"></converse-chat-content> + </div> + </div>` : '' } + </div> +`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js new file mode 100644 index 0000000..beb4a4c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/tests/headline.js @@ -0,0 +1,169 @@ +/*global mock, converse, _ */ + +describe("A headlines box", function () { + + it("will not open nor display non-headline messages", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const { $msg } = converse.env; + /* XMPP spam message: + * + * <message xmlns="jabber:client" + * to="romeo@montague.lit" + * type="chat" + * from="gapowa20102106@rds-rostov.ru/Adium"> + * <nick xmlns="http://jabber.org/protocol/nick">-wwdmz</nick> + * <body>SORRY FOR THIS ADVERT</body + * </message + */ + const stanza = $msg({ + 'xmlns': 'jabber:client', + 'to': 'romeo@montague.lit', + 'type': 'chat', + 'from': 'gapowa20102106@rds-rostov.ru/Adium', + }) + .c('nick', {'xmlns': "http://jabber.org/protocol/nick"}).t("-wwdmz").up() + .c('body').t('SORRY FOR THIS ADVERT'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => setTimeout(resolve, 100)); + const headlines = await _converse.api.headlines.get(); + expect(headlines.length).toBe(0); + })); + + it("will open and display headline messages", mock.initConverse( + [], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const { u, $msg} = converse.env; + /* <message from='notify.example.com' + * to='romeo@im.example.com' + * type='headline' + * xml:lang='en'> + * <subject>SIEVE</subject> + * <body><juliet@example.com> You got mail.</body> + * <x xmlns='jabber:x:oob'> + * <url> + * imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18 + * </url> + * </x> + * </message> + */ + const stanza = $msg({ + 'type': 'headline', + 'from': 'notify.example.com', + 'to': 'romeo@montague.lit', + 'xml:lang': 'en' + }) + .c('subject').t('SIEVE').up() + .c('body').t('<juliet@example.com> You got mail.').up() + .c('x', {'xmlns': 'jabber:x:oob'}) + .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18'); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.chatboxviews.keys().includes('notify.example.com')); + const view = _converse.chatboxviews.get('notify.example.com'); + expect(view.model.get('show_avatar')).toBeFalsy(); + expect(view.querySelector('img.avatar')).toBe(null); + })); + + it("will show headline messages in the controlbox", mock.initConverse( + [], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, sender_jid); + + const { u, $msg} = converse.env; + /* <message from='notify.example.com' + * to='romeo@im.example.com' + * type='headline' + * xml:lang='en'> + * <subject>SIEVE</subject> + * <body><juliet@example.com> You got mail.</body> + * <x xmlns='jabber:x:oob'> + * <url> + * imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18 + * </url> + * </x> + * </message> + */ + const stanza = $msg({ + 'type': 'headline', + 'from': 'notify.example.com', + 'to': 'romeo@montague.lit', + 'xml:lang': 'en' + }) + .c('subject').t('SIEVE').up() + .c('body').t('<juliet@example.com> You got mail.').up() + .c('x', {'xmlns': 'jabber:x:oob'}) + .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18'); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('controlbox'); + await u.waitUntil(() => view.querySelectorAll(".open-headline").length); + expect(view.querySelectorAll('.open-headline').length).toBe(1); + expect(view.querySelector('.open-headline').text).toBe('notify.example.com'); + })); + + it("will remove headline messages from the controlbox if closed", mock.initConverse( + [], {}, async function (_converse) { + + const { u, $msg} = converse.env; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openControlBox(_converse); + /* <message from='notify.example.com' + * to='romeo@im.example.com' + * type='headline' + * xml:lang='en'> + * <subject>SIEVE</subject> + * <body><juliet@example.com> You got mail.</body> + * <x xmlns='jabber:x:oob'> + * <url> + * imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18 + * </url> + * </x> + * </message> + */ + const stanza = $msg({ + 'type': 'headline', + 'from': 'notify.example.com', + 'to': 'romeo@montague.lit', + 'xml:lang': 'en' + }) + .c('subject').t('SIEVE').up() + .c('body').t('<juliet@example.com> You got mail.').up() + .c('x', {'xmlns': 'jabber:x:oob'}) + .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18'); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + const cbview = _converse.chatboxviews.get('controlbox'); + await u.waitUntil(() => cbview.querySelectorAll(".open-headline").length); + const hlview = _converse.chatboxviews.get('notify.example.com'); + await u.isVisible(hlview); + const close_el = await u.waitUntil(() => hlview.querySelector('.close-chatbox-button')); + close_el.click(); + await u.waitUntil(() => cbview.querySelectorAll(".open-headline").length === 0); + expect(cbview.querySelectorAll('.open-headline').length).toBe(0); + })); + + it("will not show a headline messages from a full JID if allow_non_roster_messaging is false", + mock.initConverse( + ['chatBoxesFetched'], {'allow_non_roster_messaging': false}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const { $msg } = converse.env; + const stanza = $msg({ + 'type': 'headline', + 'from': 'andre5114@jabber.snc.ru/Spark', + 'to': 'romeo@montague.lit', + 'xml:lang': 'en' + }) + .c('nick').t('gpocy').up() + .c('body').t('Здравствуйте друзья'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(_.without('controlbox', _converse.chatboxviews.keys()).length).toBe(0); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js new file mode 100644 index 0000000..a0c6d43 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/headlines-view/view.js @@ -0,0 +1,55 @@ +import BaseChatView from 'shared/chat/baseview.js'; +import tplHeadlines from './templates/headlines.js'; +import { _converse, api } from '@converse/headless/core'; + + +class HeadlinesFeedView extends BaseChatView { + + async initialize() { + _converse.chatboxviews.add(this.jid, this); + + this.model = _converse.chatboxes.get(this.jid); + this.model.disable_mam = true; // Don't do MAM queries for this box + this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); + this.listenTo(this.model, 'change:hidden', () => this.afterShown()); + this.listenTo(this.model, 'destroy', this.remove); + this.listenTo(this.model.messages, 'add', () => this.requestUpdate()); + this.listenTo(this.model.messages, 'remove', () => this.requestUpdate()); + this.listenTo(this.model.messages, 'reset', () => this.requestUpdate()); + + await this.model.messages.fetched; + this.model.maybeShow(); + /** + * Triggered once the { @link _converse.HeadlinesFeedView } has been initialized + * @event _converse#headlinesBoxViewInitialized + * @type { _converse.HeadlinesFeedView } + * @example _converse.api.listen.on('headlinesBoxViewInitialized', view => { ... }); + */ + api.trigger('headlinesBoxViewInitialized', this); + } + + render () { + return tplHeadlines(this.model); + } + + async close (ev) { + ev?.preventDefault?.(); + if (_converse.router.history.getFragment() === 'converse/chat?jid=' + this.model.get('jid')) { + _converse.router.navigate(''); + } + await this.model.close(ev); + return this; + } + + getNotifications () { // eslint-disable-line class-methods-use-this + // Override method in ChatBox. We don't show notifications for + // headlines boxes. + return []; + } + + afterShown () { + this.model.clearUnreadMsgCounter(); + } +} + +api.elements.define('converse-headlines', HeadlinesFeedView); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js new file mode 100644 index 0000000..13359f9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/index.js @@ -0,0 +1,18 @@ +/** + * @description UI code XEP-0313 Message Archive Management + * @copyright 2021, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './placeholder.js'; +import { api, converse } from '@converse/headless/core'; +import { fetchMessagesOnScrollUp, getPlaceholderTemplate } from './utils.js'; + + +converse.plugins.add('converse-mam-views', { + dependencies: ['converse-mam', 'converse-chatview', 'converse-muc-views'], + + initialize () { + api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp); + api.listen.on('getMessageTemplate', getPlaceholderTemplate); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js new file mode 100644 index 0000000..bf2a0e8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/placeholder.js @@ -0,0 +1,33 @@ +import { CustomElement } from 'shared/components/element.js'; +import tplPlaceholder from './templates/placeholder.js'; +import { api } from "@converse/headless/core"; +import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils.js'; + +import './styles/placeholder.scss'; + + +class Placeholder extends CustomElement { + + static get properties () { + return { + 'model': { type: Object } + } + } + + render () { + return tplPlaceholder(this); + } + + async fetchMissingMessages (ev) { + ev?.preventDefault?.(); + this.model.set('fetching', true); + const options = { + 'before': this.model.get('before'), + 'start': this.model.get('start') + } + await fetchArchivedMessages(this.model.collection.chatbox, options); + this.model.destroy(); + } +} + +api.elements.define('converse-mam-placeholder', Placeholder); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss new file mode 100644 index 0000000..c0adf86 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/styles/placeholder.scss @@ -0,0 +1,31 @@ +converse-mam-placeholder { + .mam-placeholder { + position: relative; + height: 2em; + margin: 0.5em 0; + &:before, + &:after { + content: ""; + display: block; + position: absolute; + left: 0; + right: 0; + } + &:before { + height: 1em; + top: 1em; + background: linear-gradient(-135deg, lightgray 0.5em, transparent 0) 0 0.5em, linear-gradient( 135deg, lightgray 0.5em, transparent 0) 0 0.5em; + background-position: top left; + background-repeat: repeat-x; + background-size: 1em 1em; + } + &:after { + height: 1em; + top: 0.75em; + background: linear-gradient(-135deg, var(--chat-background-color) 0.5em, transparent 0) 0 0.5em, linear-gradient( 135deg, var(--chat-background-color) 0.5em, transparent 0) 0 0.5em; + background-position: top left; + background-repeat: repeat-x; + background-size: 1em 1em; + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js new file mode 100644 index 0000000..87ca27d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/templates/placeholder.js @@ -0,0 +1,10 @@ +import tplSpinner from 'templates/spinner.js'; +import { __ } from 'i18n'; +import { html } from 'lit/html.js'; + +export default (el) => { + return el.model.get('fetching') ? tplSpinner({'classes': 'hor_centered'}) : + html`<a @click="${(ev) => el.fetchMissingMessages(ev)}" title="${__('Click to load missing messages')}"> + <div class="message mam-placeholder"></div> + </a>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js new file mode 100644 index 0000000..1c601c2 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/mam.js @@ -0,0 +1,1215 @@ +/*global mock, converse */ + +const Model = converse.env.Model; +const Strophe = converse.env.Strophe; +const $iq = converse.env.$iq; +const $msg = converse.env.$msg; +const dayjs = converse.env.dayjs; +const u = converse.env.utils; +const sizzle = converse.env.sizzle; +const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; +// See: https://xmpp.org/rfcs/rfc3921.html + +// Implements the protocol defined in https://xmpp.org/extensions/xep-0313.html#config +describe("Message Archive Management", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + describe("The XEP-0313 Archive", function () { + + it("is queried when the user scrolls up", + mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, 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); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + const sent_IQs = _converse.connection.IQ_stanzas; + let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + let msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': _converse.bare_jid, + 'id': _converse.connection.getUniqueId(), + 'from': contact_jid, + 'type':'chat' + }).c('body').t("Meet me at the dance"); + _converse.connection._dataRecv(mock.createRequest(msg)); + + msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': _converse.bare_jid, + 'id': _converse.connection.getUniqueId(), + 'from': contact_jid, + 'type':'chat' + }).c('body').t("Thrice the brinded cat hath mew'd."); + _converse.connection._dataRecv(mock.createRequest(msg)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('09af3-cc343-b409f').up() + .c('count').t('16'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + expect(view.model.messages.length).toBe(2); + + while (sent_IQs.length) { sent_IQs.pop(); } + _converse.api.trigger('chatBoxScrolledUp', view); + stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query queryid="${stanza.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field><field var="with"><value>mercutio@montague.lit</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before>${view.model.messages.at(0).get('stanza_id romeo@montague.lit')}</before><max>2</max></set></query>`+ + `</iq>` + ); + })); + + it("is queried when the user enters a new MUC", + mock.initConverse(['discoInitialized'], + { + 'archived_messages_page_size': 2, + 'muc_clear_messages_on_leave': false, + }, async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'orchard@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let view = _converse.chatboxviews.get(muc_jid); + let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>2</max></set>`+ + `</query>`+ + `</iq>`); + + let first_msg_id = _converse.connection.getUniqueId(); + let last_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>3rd Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + // Clear so that we don't match the older query + while (sent_IQs.length) { sent_IQs.pop(); } + + // XXX: Even though the count is 3, when fetching messages for + // the first time, we don't paginate, so that message + // is not fetched. The user needs to manually load older + // messages for it to be fetched. + // TODO: we need to add a clickable link to load older messages + let result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>3</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 2); + view.close(); + // Clear so that we don't match the older query + while (sent_IQs.length) { sent_IQs.pop(); } + + await u.waitUntil(() => _converse.chatboxes.length === 1); + + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.messages.length); + + iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><after>${message.querySelector('result').getAttribute('id')}</after><max>2</max></set>`+ + `</query>`+ + `</iq>`); + + first_msg_id = _converse.connection.getUniqueId(); + last_msg_id = _converse.connection.getUniqueId(); + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>4th Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:18:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>5th Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + // Clear so that we don't match the older query + while (sent_IQs.length) { sent_IQs.pop(); } + + result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>5</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 4); + + iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="orchard@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm">`+ + `<after>${last_msg_id}</after>`+ + `<max>2</max>`+ + `</set>`+ + `</query>`+ + `</iq>`); + + const msg_id = _converse.connection.getUniqueId(); + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:19:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>6th Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns="urn:xmpp:mam:2" complete="true"> + <set xmlns="http://jabber.org/protocol/rsm"> + <first index="0">${msg_id}</first> + <last>${msg_id}</last> + <count>6</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 5); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')) + .map(e => e.textContent).join(' ') === "2nd Message 3rd Message 4th Message 5th Message 6th Message", 1000); + })); + + it("queries for messages since the most recent cached message in a newly entered MUC", + mock.initConverse(['discoInitialized'], + { + 'archived_messages_page_size': 2, + 'muc_nickname_from_jid': false, + 'muc_clear_messages_on_leave': false, + }, async function (_converse) { + + const { api } = _converse; + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'orchard@chat.shakespeare.lit'; + const nick = 'romeo'; + const room_creation_promise = api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid, nick); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await room_creation_promise; + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + + // Create "cached" message to test that only messages newer than the + // last cached message with body text will be fetched + view.model.messages.create({ + 'type': 'groupchat', + 'to': muc_jid, + 'from': `${_converse.bare_jid}/orchard`, + 'body': 'Hello world', + 'message': 'Hello world', + 'time': '2021-02-02T12:00:00Z' + }); + // Hack: Manually set attributes that would otherwise happen in fetchMessages + view.model.messages.fetched_flag = true; + view.model.afterMessagesFetched(view.model.messages); + view.model.messages.fetched.resolve(); + + const affs = api.settings.get('muc_fetch_members'); + const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []); + await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations); + + const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `<field var="start"><value>2021-02-02T12:00:00.000Z</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><max>2</max></set>`+ + `</query>`+ + `</iq>`); + })); + }); + + describe("An archived message", function () { + describe("when received", function () { + + it("is discarded if it doesn't come from the right sender", + mock.initConverse( + ['discoInitialized'], {}, + 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); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + let msg = $msg({'id': _converse.connection.getUniqueId(), 'from': 'impersonator@capulet.lit', 'to': _converse.bare_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': _converse.bare_jid, + 'id': _converse.connection.getUniqueId(), + 'from': contact_jid, + 'type':'chat' + }).c('body').t("Meet me at the dance"); + spyOn(converse.env.log, 'warn'); + _converse.connection._dataRecv(mock.createRequest(msg)); + expect(converse.env.log.warn).toHaveBeenCalledWith(`Ignoring alleged MAM message from ${msg.nodeTree.getAttribute('from')}`); + + msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': _converse.bare_jid, + 'id': _converse.connection.getUniqueId(), + 'from': contact_jid, + 'type':'chat' + }).c('body').t("Thrice the brinded cat hath mew'd."); + _converse.connection._dataRecv(mock.createRequest(msg)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('09af3-cc343-b409f').up() + .c('count').t('16'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')) + .filter(el => el.textContent === "Thrice the brinded cat hath mew'd.").length, 1000); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('message')).toBe("Thrice the brinded cat hath mew'd."); + })); + + it("is not discarded if it comes from the right sender", + mock.initConverse( + ['discoInitialized'], {}, + 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); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + let msg = $msg({'id': _converse.connection.getUniqueId(), 'from': _converse.bare_jid, 'to': _converse.bare_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': _converse.bare_jid, + 'id': _converse.connection.getUniqueId(), + 'from': contact_jid, + 'type':'chat' + }).c('body').t("Meet me at the dance"); + spyOn(converse.env.log, 'warn'); + _converse.connection._dataRecv(mock.createRequest(msg)); + + msg = $msg({'id': _converse.connection.getUniqueId(), 'to': _converse.bare_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id': _converse.connection.getUniqueId()}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': _converse.bare_jid, + 'id': _converse.connection.getUniqueId(), + 'from': contact_jid, + 'type':'chat' + }).c('body').t("Thrice the brinded cat hath mew'd."); + _converse.connection._dataRecv(mock.createRequest(msg)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('09af3-cc343-b409f').up() + .c('count').t('16'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + expect(view.model.messages.length).toBe(2); + expect(view.model.messages.at(0).get('message')).toBe("Meet me at the dance"); + expect(view.model.messages.at(1).get('message')).toBe("Thrice the brinded cat hath mew'd."); + })); + + it("updates the is_archived value of an already cached version", + mock.initConverse( + ['discoInitialized'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'romeo'); + + const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org'); + let stanza = u.toStanza( + `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" type="groupchat" from="trek-radio@conference.lightwitch.org/some1"> + <body>Hello</body> + <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('is_archived')).toBe(false); + expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621'); + + stanza = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="trek-radio@conference.lightwitch.org"> + <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/> + <message from="trek-radio@conference.lightwitch.org/some1" type="groupchat"> + <body>Hello</body> + </message> + </forwarded> + </result> + </message>`); + spyOn(view.model, 'getDuplicateMessage').and.callThrough(); + spyOn(view.model, 'updateMessage').and.callThrough(); + _converse.handleMAMResult(view.model, { 'messages': [stanza] }); + await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); + expect(view.model.getDuplicateMessage.calls.count()).toBe(1); + const result = view.model.getDuplicateMessage.calls.all()[0].returnValue + expect(result instanceof _converse.Message).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + await u.waitUntil(() => view.model.updateMessage.calls.count()); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('is_archived')).toBe(true); + expect(view.model.messages.at(0).get('stanza_id trek-radio@conference.lightwitch.org')).toBe('45fbbf2a-1059-479d-9283-c8effaf05621'); + })); + + it("isn't shown as duplicate by comparing its stanza id or archive id", + mock.initConverse( + ['discoInitialized'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'trek-radio@conference.lightwitch.org', 'jcbrand'); + const view = _converse.chatboxviews.get('trek-radio@conference.lightwitch.org'); + let stanza = u.toStanza( + `<message xmlns="jabber:client" to="jcbrand@lightwitch.org/converse.js-73057452" type="groupchat" from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)"> + <body>negan</body> + <stanza-id xmlns="urn:xmpp:sid:0" id="45fbbf2a-1059-479d-9283-c8effaf05621" by="trek-radio@conference.lightwitch.org"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + // Not sure whether such a race-condition might pose a problem + // in "real-world" situations. + stanza = u.toStanza( + `<message xmlns="jabber:client" + to="jcbrand@lightwitch.org/converse.js-73057452" + from="trek-radio@conference.lightwitch.org"> + <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="45fbbf2a-1059-479d-9283-c8effaf05621"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/> + <message from="trek-radio@conference.lightwitch.org/comndrdukath#0805 (STO)" type="groupchat"> + <body>negan</body> + </message> + </forwarded> + </result> + </message>`); + spyOn(view.model, 'getDuplicateMessage').and.callThrough(); + _converse.handleMAMResult(view.model, { 'messages': [stanza] }); + await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); + expect(view.model.getDuplicateMessage.calls.count()).toBe(1); + const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue + expect(result instanceof _converse.Message).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + })); + + it("isn't shown as duplicate by comparing only the archive id", + mock.initConverse( + ['discoInitialized'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'discuss@conference.conversejs.org', 'romeo'); + const view = _converse.chatboxviews.get('discuss@conference.conversejs.org'); + let stanza = u.toStanza( + `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org"> + <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/> + <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel"> + <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="prezel@blubber.im" role="participant"/> + </x> + </message> + </forwarded> + </result> + </message>`); + _converse.handleMAMResult(view.model, { 'messages': [stanza] }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + stanza = u.toStanza( + `<message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="discuss@conference.conversejs.org"> + <result xmlns="urn:xmpp:mam:2" queryid="06fea9ca-97c9-48c4-8583-009ff54ea2e8" id="7a9fde91-4387-4bf8-b5d3-978dab8f6bf3"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-12-05T04:53:12Z"/> + <message xmlns="jabber:client" to="discuss@conference.conversejs.org" type="groupchat" xml:lang="en" from="discuss@conference.conversejs.org/prezel"> + <body>looks like omemo fails completely with "bundle is undefined" when there is a device in the devicelist that has no keys published</body> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="prezel@blubber.im" role="participant"/> + </x> + </message> + </forwarded> + </result> + </message>`); + + spyOn(view.model, 'getDuplicateMessage').and.callThrough(); + _converse.handleMAMResult(view.model, { 'messages': [stanza] }); + await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); + expect(view.model.getDuplicateMessage.calls.count()).toBe(1); + const result = await view.model.getDuplicateMessage.calls.all()[0].returnValue + expect(result instanceof _converse.Message).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + })) + }); + }); + + describe("The archive.query API", function () { + + it("can be used to query for all archived messages", + mock.initConverse(['discoInitialized'], {}, async function (_converse) { + + const sendIQ = _converse.connection.sendIQ; + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + _converse.api.archive.query(); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client"><query queryid="${queryid}" xmlns="urn:xmpp:mam:2"/></iq>`); + })); + + it("can be used to query for all messages to/from a particular JID", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + _converse.api.archive.query({'with':'juliet@capulet.lit'}); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `<field var="with">`+ + `<value>juliet@capulet.lit</value>`+ + `</field>`+ + `</x>`+ + `</query>`+ + `</iq>`); + })); + + it("can be used to query for archived messages from a chat room", + mock.initConverse(['statusInitialized'], {}, async function (_converse) { + + const room_jid = 'coven@chat.shakespeare.lit'; + _converse.api.archive.query({'with': room_jid, 'groupchat': true}); + await mock.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]); + + const sent_stanzas = _converse.connection.sent_stanzas; + const stanza = await u.waitUntil( + () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop()); + + const queryid = stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `</x>`+ + `</query>`+ + `</iq>`); + })); + + it("checks whether returned MAM messages from a MUC room are from the right JID", + mock.initConverse(['statusInitialized'], {}, async function (_converse) { + + const room_jid = 'coven@chat.shakespeare.lit'; + const promise = _converse.api.archive.query({'with': room_jid, 'groupchat': true, 'max':'10'}); + + await mock.waitUntilDiscoConfirmed(_converse, room_jid, null, [Strophe.NS.MAM]); + + const sent_stanzas = _converse.connection.sent_stanzas; + const sent_stanza = await u.waitUntil( + () => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop()); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + + /* <message id='iasd207' from='coven@chat.shakespeare.lit' to='hag66@shakespeare.lit/pda'> + * <result xmlns='urn:xmpp:mam:2' queryid='g27' id='34482-21985-73620'> + * <forwarded xmlns='urn:xmpp:forward:0'> + * <delay xmlns='urn:xmpp:delay' stamp='2002-10-13T23:58:37Z'/> + * <message xmlns="jabber:client" + * from='coven@chat.shakespeare.lit/firstwitch' + * id='162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2' + * type='groupchat'> + * <body>Thrice the brinded cat hath mew'd.</body> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' + * jid='witch1@shakespeare.lit' + * role='participant' /> + * </x> + * </message> + * </forwarded> + * </result> + * </message> + */ + const msg1 = $msg({'id':'iasd207', 'from': 'other@chat.shakespear.lit', 'to': 'romeo@montague.lit'}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'34482-21985-73620'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to':'romeo@montague.lit', + 'id':'162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', + 'from':'coven@chat.shakespeare.lit/firstwitch', + 'type':'groupchat' }) + .c('body').t("Thrice the brinded cat hath mew'd."); + _converse.connection._dataRecv(mock.createRequest(msg1)); + + /* Send an <iq> stanza to indicate the end of the result set. + * + * <iq type='result' id='juliet1'> + * <fin xmlns='urn:xmpp:mam:2'> + * <set xmlns='http://jabber.org/protocol/rsm'> + * <first index='0'>28482-98726-73623</first> + * <last>09af3-cc343-b409f</last> + * <count>20</count> + * </set> + * </iq> + */ + const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('09af3-cc343-b409f').up() + .c('count').t('16'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + const result = await promise; + expect(result.messages.length).toBe(0); + })); + + it("can be used to query for all messages in a certain timespan", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + const start = '2010-06-07T00:00:00Z'; + const end = '2010-07-07T13:23:54Z'; + _converse.api.archive.query({ + 'start': start, + 'end': end + }); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `<field var="start">`+ + `<value>${dayjs(start).toISOString()}</value>`+ + `</field>`+ + `<field var="end">`+ + `<value>${dayjs(end).toISOString()}</value>`+ + `</field>`+ + `</x>`+ + `</query>`+ + `</iq>` + ); + })); + + it("throws a TypeError if an invalid date is provided", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + try { + await _converse.api.archive.query({'start': 'not a real date'}); + } catch (e) { + expect(() => {throw e}).toThrow(new TypeError('archive.query: invalid date provided for: start')); + } + })); + + it("can be used to query for all messages after a certain time", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + if (!_converse.disco_entities.get(_converse.domain).features.findWhere({'var': Strophe.NS.MAM})) { + _converse.disco_entities.get(_converse.domain).features.create({'var': Strophe.NS.MAM}); + } + const start = '2010-06-07T00:00:00Z'; + _converse.api.archive.query({'start': start}); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `<field var="start">`+ + `<value>${dayjs(start).toISOString()}</value>`+ + `</field>`+ + `</x>`+ + `</query>`+ + `</iq>` + ); + })); + + it("can be used to query for a limited set of results", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + const start = '2010-06-07T00:00:00Z'; + _converse.api.archive.query({'start': start, 'max':10}); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `<field var="start">`+ + `<value>${dayjs(start).toISOString()}</value>`+ + `</field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm">`+ + `<max>10</max>`+ + `</set>`+ + `</query>`+ + `</iq>` + ); + })); + + it("can be used to page through results", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + const start = '2010-06-07T00:00:00Z'; + _converse.api.archive.query({ + 'start': start, + 'after': '09af3-cc343-b409f', + 'max':10 + }); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `<field var="start">`+ + `<value>${dayjs(start).toISOString()}</value>`+ + `</field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm">`+ + `<after>09af3-cc343-b409f</after>`+ + `<max>10</max>`+ + `</set>`+ + `</query>`+ + `</iq>`); + })); + + it("accepts \"before\" with an empty string as value to reverse the order", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + _converse.api.archive.query({'before': '', 'max':10}); + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${IQ_id}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>urn:xmpp:mam:2</value>`+ + `</field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm">`+ + `<before></before>`+ + `<max>10</max>`+ + `</set>`+ + `</query>`+ + `</iq>`); + })); + + it("returns an object which includes the messages and a _converse.RSM object", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'}); + + await u.waitUntil(() => sent_stanza); + const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + + /* <message id='aeb213' to='juliet@capulet.lit/chamber'> + * <result xmlns='urn:xmpp:mam:2' queryid='f27' id='28482-98726-73623'> + * <forwarded xmlns='urn:xmpp:forward:0'> + * <delay xmlns='urn:xmpp:delay' stamp='2010-07-10T23:08:25Z'/> + * <message xmlns='jabber:client' + * to='juliet@capulet.lit/balcony' + * from='romeo@montague.lit/orchard' + * type='chat'> + * <body>Call me but love, and I'll be new baptized; Henceforth I never will be Romeo.</body> + * </message> + * </forwarded> + * </result> + * </message> + */ + const msg1 = $msg({'id':'aeb212', 'to':'juliet@capulet.lit/chamber'}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to':'juliet@capulet.lit/balcony', + 'from':'romeo@montague.lit/orchard', + 'type':'chat' }) + .c('body').t("Call me but love, and I'll be new baptized;"); + _converse.connection._dataRecv(mock.createRequest(msg1)); + + const msg2 = $msg({'id':'aeb213', 'to':'juliet@capulet.lit/chamber'}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73624'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to':'juliet@capulet.lit/balcony', + 'from':'romeo@montague.lit/orchard', + 'type':'chat' }) + .c('body').t("Henceforth I never will be Romeo."); + _converse.connection._dataRecv(mock.createRequest(msg2)); + + /* Send an <iq> stanza to indicate the end of the result set. + * + * <iq type='result' id='juliet1'> + * <fin xmlns='urn:xmpp:mam:2'> + * <set xmlns='http://jabber.org/protocol/rsm'> + * <first index='0'>28482-98726-73623</first> + * <last>09af3-cc343-b409f</last> + * <count>20</count> + * </set> + * </iq> + */ + const stanza = $iq({'type': 'result', 'id': IQ_id}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('09af3-cc343-b409f').up() + .c('count').t('16'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + const result = await promise; + expect(result.messages.length).toBe(2); + expect(result.messages[0].outerHTML).toBe(msg1.nodeTree.outerHTML); + expect(result.messages[1].outerHTML).toBe(msg2.nodeTree.outerHTML); + expect(result.rsm.query.max).toBe('10'); + expect(result.rsm.result.count).toBe(16); + expect(result.rsm.result.first).toBe('23452-4534-1'); + expect(result.rsm.result.last).toBe('09af3-cc343-b409f'); + })); + }); + + describe("The default preference", function () { + + it("is set once server support for MAM has been confirmed", + mock.initConverse([], {}, async function (_converse) { + + const { api } = _converse; + + const entity = await _converse.api.disco.entities.get(_converse.domain); + spyOn(_converse, 'onMAMPreferences').and.callThrough(); + api.settings.set('message_archiving', 'never'); + + const feature = new Model({ + 'var': Strophe.NS.MAM + }); + spyOn(feature, 'save').and.callFake(feature.set); // Save will complain about a url not being set + + entity.onFeatureAdded(feature); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="get"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop()); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + `<prefs xmlns="urn:xmpp:mam:2"/>`+ + `</iq>`); + + /* Example 20. Server responds with current preferences + * + * <iq type='result' id='juliet2'> + * <prefs xmlns='urn:xmpp:mam:0' default='roster'> + * <always/> + * <never/> + * </prefs> + * </iq> + */ + let stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')}) + .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'roster'}) + .c('always').c('jid').t('romeo@montague.lit').up().up() + .c('never').c('jid').t('montague@montague.lit'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => _converse.onMAMPreferences.calls.count()); + expect(_converse.onMAMPreferences).toHaveBeenCalled(); + + sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('iq[type="set"] prefs[xmlns="urn:xmpp:mam:2"]', s).length).pop()); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<prefs default="never" xmlns="urn:xmpp:mam:2">`+ + `<always><jid>romeo@montague.lit</jid></always>`+ + `<never><jid>montague@montague.lit</jid></never>`+ + `</prefs>`+ + `</iq>` + ); + + expect(feature.get('preference')).toBe(undefined); + /* <iq type='result' id='juliet3'> + * <prefs xmlns='urn:xmpp:mam:0' default='always'> + * <always> + * <jid>romeo@montague.lit</jid> + * </always> + * <never> + * <jid>montague@montague.lit</jid> + * </never> + * </prefs> + * </iq> + */ + stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')}) + .c('prefs', {'xmlns': Strophe.NS.MAM, 'default':'always'}) + .c('always').up() + .c('never'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => feature.save.calls.count()); + expect(feature.save).toHaveBeenCalled(); + expect(feature.get('preferences')['default']).toBe('never'); // eslint-disable-line dot-notation + })); + }); +}); + +describe("Chatboxes", function () { + describe("A Chatbox", function () { + + it("will fetch archived messages once it's opened", + mock.initConverse(['discoInitialized'], {}, 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); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + + let sent_stanza, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_stanza = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + await u.waitUntil(() => sent_stanza); + const stanza_el = sent_stanza; + const queryid = stanza_el.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${stanza_el.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `<field var="with"><value>mercutio@montague.lit</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+ + `</query>`+ + `</iq>` + ); + const msg1 = $msg({'id':'aeb212', 'to': contact_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73623'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': contact_jid, + 'from': _converse.bare_jid, + 'type':'chat' }) + .c('body').t("Call me but love, and I'll be new baptized;"); + _converse.connection._dataRecv(mock.createRequest(msg1)); + const msg2 = $msg({'id':'aeb213', 'to': contact_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid':queryid, 'id':'28482-98726-73624'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': contact_jid, + 'from': _converse.bare_jid, + 'type':'chat' }) + .c('body').t("Henceforth I never will be Romeo."); + _converse.connection._dataRecv(mock.createRequest(msg2)); + const stanza = $iq({'type': 'result', 'id': IQ_id}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('23452-4534-1').up() + .c('last').t('09af3-cc343-b409f').up() + .c('count').t('16'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + })); + + it("will show an error message if the MAM query times out", + mock.initConverse(['discoInitialized'], {}, async function (_converse) { + + const sendIQ = _converse.connection.sendIQ; + + let timeout_happened = false; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sendIQ.bind(this)(iq, callback, errback); + if (!timeout_happened) { + if (typeof(iq.tree) === "function") { + iq = iq.tree(); + } + if (sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length) { + // We emulate a timeout event + callback(null); + timeout_happened = true; + } + } + }); + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + let sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop()); + let queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `<field var="with"><value>mercutio@montague.lit</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+ + `</query>`+ + `</iq>`); + + const view = _converse.chatboxviews.get(contact_jid); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(30000); + expect(view.model.messages.at(0).get('type')).toBe('error'); + expect(view.model.messages.at(0).get('message')).toBe('Timeout while trying to fetch archived messages.'); + + let err_message = await u.waitUntil(() => view.querySelector('.message.chat-error')); + err_message.querySelector('.retry').click(); + + while (_converse.connection.IQ_stanzas.length) { + _converse.connection.IQ_stanzas.pop(); + } + sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle('query[xmlns="urn:xmpp:mam:2"]', iq).length).pop()); + queryid = sent_stanza.querySelector('query').getAttribute('queryid'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query queryid="${queryid}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `<field var="with"><value>mercutio@montague.lit</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+ + `</query>`+ + `</iq>`); + + const msg1 = $msg({'id':'aeb212', 'to': contact_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73623'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:08:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': contact_jid, + 'from': _converse.bare_jid, + 'type':'chat' }) + .c('body').t("Call me but love, and I'll be new baptized;"); + _converse.connection._dataRecv(mock.createRequest(msg1)); + + const msg2 = $msg({'id':'aeb213', 'to': contact_jid}) + .c('result', {'xmlns': 'urn:xmpp:mam:2', 'queryid': queryid, 'id':'28482-98726-73624'}) + .c('forwarded', {'xmlns':'urn:xmpp:forward:0'}) + .c('delay', {'xmlns':'urn:xmpp:delay', 'stamp':'2010-07-10T23:18:25Z'}).up() + .c('message', { + 'xmlns':'jabber:client', + 'to': contact_jid, + 'from': _converse.bare_jid, + 'type':'chat' }) + .c('body').t("Henceforth I never will be Romeo."); + _converse.connection._dataRecv(mock.createRequest(msg2)); + + const stanza = $iq({'type': 'result', 'id': sent_stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2', 'complete': true}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t('28482-98726-73623').up() + .c('last').t('28482-98726-73624').up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 2, 500); + err_message = view.querySelector('.message.chat-error'); + expect(err_message).toBe(null); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js new file mode 100644 index 0000000..b71dba3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/tests/placeholder.js @@ -0,0 +1,217 @@ +/*global mock, converse */ + +const { Strophe, u } = converse.env; + +describe("Message Archive Management", function () { + + describe("A placeholder message", function () { + + it("is created to indicate a gap in the history", + mock.initConverse( + ['discoInitialized'], + { + 'archived_messages_page_size': 2, + 'persistent_store': 'localStorage', + 'mam_request_all_pages': false + }, + async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'orchard@chat.shakespeare.lit'; + const msgid = u.getUniqueId(); + + // We put an already cached message in localStorage + const key_prefix = `converse-test-persistent/${_converse.bare_jid}`; + let key = `${key_prefix}/converse.messages-${muc_jid}-${_converse.bare_jid}`; + localStorage.setItem(key, `["converse.messages-${muc_jid}-${_converse.bare_jid}-${msgid}"]`); + + key = `${key_prefix}/converse.messages-${muc_jid}-${_converse.bare_jid}-${msgid}`; + const msgtxt = "existing cached message"; + localStorage.setItem(key, `{ + "body": "${msgtxt}", + "message": "${msgtxt}", + "editable":true, + "from": "${muc_jid}/romeo", + "fullname": "Romeo", + "id": "${msgid}", + "is_archived": false, + "is_only_emojis": false, + "nick": "jc", + "origin_id": "${msgid}", + "received": "2021-06-15T11:17:15.451Z", + "sender": "me", + "stanza_id ${muc_jid}": "1e1c2355-c5b8-4d48-9e33-1310724578c2", + "time": "2021-06-15T11:17:15.424Z", + "type": "groupchat", + "msgid": "${msgid}" + }`); + + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const first_msg_id = _converse.connection.getUniqueId(); + const second_msg_id = _converse.connection.getUniqueId(); + const third_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${second_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T11:18:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${third_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T12:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>3rd MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + // Clear so that we don't match the older query + while (sent_IQs.length) { sent_IQs.pop(); } + + let result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${second_msg_id}</first> + <last>${third_msg_id}</last> + <count>3</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 4); + + const msg = view.model.messages.at(1); + expect(msg instanceof _converse.MAMPlaceholderMessage).toBe(true); + expect(msg.get('time')).toBe('2021-06-15T11:18:22.999Z'); + + const placeholder_el = view.querySelector('converse-mam-placeholder'); + placeholder_el.firstElementChild.click(); + await u.waitUntil(() => view.querySelector('converse-mam-placeholder .spinner')); + + iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="urn:xmpp:mam:2">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `<field var="start"><value>2021-06-15T11:17:15.424Z</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before>${view.model.messages.at(2).get(`stanza_id ${muc_jid}`)}</before>`+ + `<max>2</max>`+ + `</set>`+ + `</query>`+ + `</iq>`); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-06-15T11:18:20Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>1st MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + // Clear so that we don't match the older query + while (sent_IQs.length) { sent_IQs.pop(); } + + result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2' complete='true'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${first_msg_id}</last> + <count>1</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 4); + await u.waitUntil(() => view.querySelector('converse-mam-placeholder') === null); + })); + + it("is not created when there isn't a gap because the cached history is empty", + mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, + async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'orchard@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + + const first_msg_id = _converse.connection.getUniqueId(); + const last_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>3rd Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + // Clear so that we don't match the older query + while (sent_IQs.length) { sent_IQs.pop(); } + + const result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>3</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 2); + expect(true).toBe(true); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js new file mode 100644 index 0000000..666a189 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/mam-views/utils.js @@ -0,0 +1,44 @@ +import MAMPlaceholderMessage from '@converse/headless/plugins/mam/placeholder.js'; +import log from '@converse/headless/log.js'; +import { _converse, api } from '@converse/headless/core'; +import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils'; +import { html } from 'lit/html.js'; + + +export function getPlaceholderTemplate (message, tpl) { + if (message instanceof MAMPlaceholderMessage) { + return html`<converse-mam-placeholder .model=${message}></converse-mam-placeholder>`; + } else { + return tpl; + } +} + +export async function fetchMessagesOnScrollUp (view) { + if (view.model.ui.get('chat-content-spinner-top')) { + return; + } + if (view.model.messages.length) { + const is_groupchat = view.model.get('type') === _converse.CHATROOMS_TYPE; + const oldest_message = view.model.getOldestMessage(); + if (oldest_message) { + const by_jid = is_groupchat ? view.model.get('jid') : _converse.bare_jid; + const stanza_id = oldest_message && oldest_message.get(`stanza_id ${by_jid}`); + view.model.ui.set('chat-content-spinner-top', true); + try { + if (stanza_id) { + await fetchArchivedMessages(view.model, { 'before': stanza_id }); + } else { + await fetchArchivedMessages(view.model, { 'end': oldest_message.get('time') }); + } + } catch (e) { + log.error(e); + view.model.ui.set('chat-content-spinner-top', false); + return; + } + if (api.settings.get('allow_url_history_change')) { + _converse.router.history.navigate(`#${oldest_message.get('msgid')}`); + } + setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250); + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js new file mode 100644 index 0000000..972eb11 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/components/minimized-chat.js @@ -0,0 +1,40 @@ +import tplTrimmedChat from "../templates/trimmed_chat.js"; +import { CustomElement } from 'shared/components/element.js'; +import { api } from "@converse/headless/core"; +import { maximize } from '../utils.js'; + + +export default class MinimizedChat extends CustomElement { + + static get properties () { + return { + model: { type: Object }, + title: { type: String }, + type: { type: String }, + num_unread: { type: Number } + } + } + + render () { + const data = { + 'close': ev => this.close(ev), + 'num_unread': this.num_unread, + 'restore': ev => this.restore(ev), + 'title': this.title, + 'type': this.type + }; + return tplTrimmedChat(data); + } + + close (ev) { + ev?.preventDefault(); + this.model.close(); + } + + restore (ev) { + ev?.preventDefault(); + maximize(this.model); + } +} + +api.elements.define('converse-minimized-chat', MinimizedChat); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js new file mode 100644 index 0000000..702662c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/index.js @@ -0,0 +1,118 @@ +/** + * @module converse-minimize + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './view.js'; +import './components/minimized-chat.js'; +import debounce from 'lodash-es/debounce'; +import MinimizedChatsToggle from './toggle.js'; +import { _converse, api, converse } from '@converse/headless/core'; +import { + addMinimizeButtonToChat, + addMinimizeButtonToMUC, + initializeChat, + maximize, + minimize, + onMinimizedChanged, + trimChats +} from './utils.js'; + +import './styles/minimize.scss'; + + +converse.plugins.add('converse-minimize', { + /* Optional dependencies are other plugins which might be + * overridden or relied upon, and therefore need to be loaded before + * this plugin. They are called "optional" because they might not be + * available, in which case any overrides applicable to them will be + * ignored. + * + * It's possible however to make optional dependencies non-optional. + * If the setting "strict_plugin_dependencies" is set to true, + * an error will be raised if the plugin is not found. + */ + dependencies: [ + "converse-chatview", + "converse-controlbox", + "converse-muc-views", + "converse-headlines-view", + "converse-dragresize" + ], + + enabled (_converse) { + return _converse.api.settings.get("view_mode") === 'overlayed'; + }, + + // Overrides mentioned here will be picked up by converse.js's + // plugin architecture they will replace existing methods on the + // relevant objects or classes. + // New functions which don't exist yet can also be added. + overrides: { + ChatBox: { + maybeShow (force) { + if (!force && this.get('minimized')) { + // Must return the chatbox + return this; + } + return this.__super__.maybeShow.apply(this, arguments); + }, + + isHidden () { + return this.__super__.isHidden.call(this) || this.get('minimized'); + } + }, + + ChatBoxView: { + isNewMessageHidden () { + return this.model.get('minimized') || + this.__super__.isNewMessageHidden.apply(this, arguments); + }, + + setChatBoxHeight (height) { + if (!this.model.get('minimized')) { + return this.__super__.setChatBoxHeight.call(this, height); + } + }, + + setChatBoxWidth (width) { + if (!this.model.get('minimized')) { + return this.__super__.setChatBoxWidth.call(this, width); + } + } + } + }, + + + initialize () { + api.settings.extend({'no_trimming': false}); + + api.promises.add('minimizedChatsInitialized'); + + _converse.MinimizedChatsToggle = MinimizedChatsToggle; + _converse.minimize = { trimChats, minimize, maximize }; + + function onChatInitialized (model) { + initializeChat(model); + model.on( 'change:minimized', () => onMinimizedChanged(model)); + } + + api.listen.on('chatBoxViewInitialized', view => _converse.minimize.trimChats(view)); + api.listen.on('chatRoomViewInitialized', view => _converse.minimize.trimChats(view)); + api.listen.on('controlBoxOpened', view => _converse.minimize.trimChats(view)); + api.listen.on('chatBoxInitialized', onChatInitialized); + api.listen.on('chatRoomInitialized', onChatInitialized); + + api.listen.on('getHeadingButtons', (view, buttons) => { + if (view.model.get('type') === _converse.CHATROOMS_TYPE) { + return addMinimizeButtonToMUC(view, buttons); + } else { + return addMinimizeButtonToChat(view, buttons); + } + }); + + const debouncedTrimChats = debounce(() => _converse.minimize.trimChats(), 250); + api.listen.on('registeredGlobalEventHandlers', () => window.addEventListener("resize", debouncedTrimChats)); + api.listen.on('unregisteredGlobalEventHandlers', () => window.removeEventListener("resize", debouncedTrimChats)); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss b/roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss new file mode 100644 index 0000000..fe06bd0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/styles/minimize.scss @@ -0,0 +1,111 @@ +.conversejs { + converse-chats { + &.converse-overlayed { + converse-minimized-chats { + order: 100; + } + + #minimized-chats { + + width: var(--minimized-chats-width); + margin-bottom: 0; + border-top-left-radius: var(--chatbox-border-radius); + border-top-right-radius: var(--chatbox-border-radius); + color: var(--inverse-link-color); + margin-right: var(--chat-gutter); + padding: 0; + + .badge { + bottom: 8px; + border: 1px solid var(--overlayed-badge-color); + } + + #toggle-minimized-chats { + border-top-left-radius: var(--chatbox-border-radius); + border-top-right-radius: var(--chatbox-border-radius); + background-color: var(--subdued-color); + padding: 1em 0 0 0; + text-align: center; + color: white; + white-space: nowrap; + overflow-y: hidden; + text-overflow: ellipsis; + display: block; + height: 45px; + width: 9em; + } + + a.restore-chat { + cursor: pointer; + padding: 1px 0 1px 5px; + color: var(--chat-head-text-color); + line-height: 15px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + &:hover { + text-decoration: none; + } + } + + a.restore-chat:visited { + color: var(--chat-head-text-color); + } + + .minimized-chats-flyout { + flex-direction: column-reverse; + bottom: 45px; + width: var(--minimized-chats-width); + + .chat-head { + min-height: 0; + padding: 0.3em; + border-radius: var(--chatbox-border-radius); + height: 35px; + margin-bottom: 0.2em; + width: 100%; + max-width: 9em; + flex-wrap: nowrap; + background-color: var(--chat-head-color); + } + .chat-head-chatroom { + background-color: var(--chatroom-head-bg-color); + a.restore-chat { + color: var(--chatroom-head-color); + } + } + .chat-head-headline { + background-color: var(--headlines-head-bg-color); + a.restore-chat { + color: var(--headlines-head-text-color); + } + } + + &.minimized { + height: auto; + } + } + + .unread-message-count { + font-weight: bold; + background-color: white; + border: 1px solid; + text-shadow: 1px 1px 0 var(--text-shadow-color); + color: var(--warning-color); + border-radius: 5px; + padding: 2px 4px; + font-size: 16px; + text-align: center; + position: absolute; + right: 116px; + bottom: 10px; + } + .unread-message-count-hidden, + .chat-head-message-count-hidden { + display: none; + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js new file mode 100644 index 0000000..95e0537 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/chats-panel.js @@ -0,0 +1,18 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + +export default (o) => + html`<div id="minimized-chats" class="${o.chats.length ? '' : 'hidden'}"> + <a id="toggle-minimized-chats" class="row no-gutters" @click=${o.toggle}> + ${o.num_minimized} ${__('Minimized')} + <span class="unread-message-count ${!o.num_unread ? 'unread-message-count-hidden' : ''}" href="#">${o.num_unread}</span> + </a> + <div class="flyout minimized-chats-flyout row no-gutters ${o.collapsed ? 'hidden' : ''}"> + ${o.chats.map(chat => + html`<converse-minimized-chat + .model=${chat} + title=${chat.getDisplayName()} + type=${chat.get('type')} + num_unread=${chat.get('num_unread')}></converse-minimized-chat>`)} + </div> + </div>`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js new file mode 100644 index 0000000..7d8e067 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/templates/trimmed_chat.js @@ -0,0 +1,26 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + + +export default (o) => { + const i18n_tooltip = __('Click to restore this chat'); + let close_color; + if (o.type === 'chatroom') { + close_color = "var(--chatroom-head-color)"; + } else if (o.type === 'headline') { + close_color = "var(--headlines-head-text-color)"; + } else { + close_color = "var(--chat-head-text-color)"; + } + + return html` + <div class="chat-head-${o.type} chat-head row no-gutters"> + <a class="restore-chat w-100 align-self-center" title="${i18n_tooltip}" @click=${o.restore}> + ${o.num_unread ? html`<span class="message-count badge badge-light">${o.num_unread}</span>` : '' } + ${o.title} + </a> + <a class="chatbox-btn close-chatbox-button" @click=${o.close}> + <converse-icon color=${close_color} class="fas fa-times" @click=${o.close} size="1em"></converse-icon> + </a> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js new file mode 100644 index 0000000..b3c8e61 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/tests/minchats.js @@ -0,0 +1,365 @@ +/*global mock, converse */ + +const { $msg, u } = converse.env; + + +describe("A chat message", function () { + + it("received for a minimized chat box will increment a counter on its header", + mock.initConverse(['chatBoxesFetched'], {'view_mode': 'overlayed'}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const contact_name = mock.cur_names[0]; + const contact_jid = contact_name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openControlBox(_converse); + spyOn(_converse.api, "trigger").and.callThrough(); + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + const chatview = _converse.chatboxviews.get(contact_jid); + expect(u.isVisible(chatview)).toBeTruthy(); + expect(chatview.model.get('minimized')).toBeFalsy(); + chatview.querySelector('.toggle-chatbox-button').click(); + expect(chatview.model.get('minimized')).toBeTruthy(); + var message = 'This message is sent to a minimized chatbox'; + var sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + var msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); + + await u.waitUntil(() => chatview.model.messages.length); + + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + let count = document.querySelector('converse-minimized-chat .message-count'); + expect(u.isVisible(chatview)).toBeFalsy(); + expect(chatview.model.get('minimized')).toBeTruthy(); + + expect(u.isVisible(count)).toBeTruthy(); + expect(count.textContent).toBe('1'); + _converse.handleMessageStanza( + $msg({ + from: mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('This message is also sent to a minimized chatbox').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree() + ); + + await u.waitUntil(() => (chatview.model.messages.length > 1)); + expect(u.isVisible(chatview)).toBeFalsy(); + expect(chatview.model.get('minimized')).toBeTruthy(); + count = document.querySelector('converse-minimized-chat .message-count'); + expect(u.isVisible(count)).toBeTruthy(); + expect(count.textContent).toBe('2'); + document.querySelector("converse-minimized-chat a.restore-chat").click(); + expect(_converse.chatboxes.filter('minimized').length).toBe(0); + })); + +}); + +describe("A Groupchat", function () { + + it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@conference.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(_converse.api, "trigger").and.callThrough(); + const button = await u.waitUntil(() => view.querySelector('.toggle-chatbox-button')); + button.click(); + + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object)); + await u.waitUntil(() => !u.isVisible(view)); + expect(view.model.get('minimized')).toBeTruthy(); + const el = await u.waitUntil(() => document.querySelector("converse-minimized-chat a.restore-chat")); + el.click(); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object)); + expect(view.model.get('minimized')).toBeFalsy(); + expect(_converse.api.trigger.calls.count(), 3); + })); +}); + + +describe("A Chatbox", function () { + + it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const contact_jid = mock.cur_names[7].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length); + await mock.openChatBoxFor(_converse, contact_jid); + const chatview = _converse.chatboxviews.get(contact_jid); + spyOn(_converse.api, "trigger").and.callThrough(); + chatview.querySelector('.toggle-chatbox-button').click(); + + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object)); + expect(_converse.api.trigger.calls.count(), 2); + await u.waitUntil(() => !u.isVisible(chatview)); + expect(chatview.model.get('minimized')).toBeTruthy(); + const restore_el = await u.waitUntil(() => document.querySelector("converse-minimized-chat a.restore-chat")); + restore_el.click(); + await u.waitUntil(() => _converse.chatboxviews.keys().length); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMaximized', jasmine.any(Object)); + expect(chatview.model.get('minimized')).toBeFalsy(); + })); + + + it("can be opened in minimized mode initially", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current'); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + expect(u.isVisible(minimized_chats.firstElementChild)).toBe(false); + await _converse.api.chats.create(sender_jid, {'minimized': true}); + await u.waitUntil(() => _converse.chatboxes.length > 1); + expect(_converse.chatboxviews.get(sender_jid)).toBe(undefined); + expect(u.isVisible(minimized_chats.firstElementChild)).toBe(true); + expect(minimized_chats.firstElementChild.querySelectorAll('converse-minimized-chat').length).toBe(1); + expect(_converse.chatboxes.filter('minimized').length).toBe(1); + })); + + + it("can be trimmed to conserve space", mock.initConverse([], {}, async function (_converse) { + spyOn(_converse.minimize, 'trimChats'); + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + // openControlBox was called earlier, so the controlbox is + // visible, but no other chat boxes have been created. + expect(_converse.chatboxes.length).toEqual(1); + expect(document.querySelectorAll("#conversejs .chatbox").length).toBe(1); // Controlbox is open + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length); + // Test that they can be maximized again + const online_contacts = rosterview.querySelectorAll('.roster-group .current-xmpp-contact a.open-chat'); + expect(online_contacts.length).toBe(17); + let i; + for (i=0; i<online_contacts.length; i++) { + const el = online_contacts[i]; + el.click(); + } + await u.waitUntil(() => _converse.chatboxes.length == 16); + expect(_converse.minimize.trimChats.calls.count()).toBe(16); + + for (i=0; i<online_contacts.length; i++) { + const el = online_contacts[i]; + const jid = el.textContent.trim().replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const model = _converse.chatboxes.get(jid); + model.set({'minimized': true}); + } + await u.waitUntil(() => _converse.chatboxviews.keys().length === 1); + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + minimized_chats.querySelector("a.restore-chat").click(); + expect(_converse.minimize.trimChats.calls.count()).toBe(16); + })); +}); + + +describe("A Minimized ChatBoxView's Unread Message Count", function () { + + it("is displayed when scrolled up chatbox is minimized after receiving unread messages", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, sender_jid); + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read'); + const chatbox = _converse.chatboxes.get(sender_jid); + chatbox.ui.set('scrolled', true); + _converse.handleMessageStanza(msgFactory()); + await u.waitUntil(() => chatbox.messages.length); + await u.waitUntil(() => chatbox.get('num_unread') === 1); + _converse.minimize.minimize(chatbox); + + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + const unread_count = minimized_chats.querySelector('#toggle-minimized-chats .unread-message-count'); + expect(u.isVisible(unread_count)).toBeTruthy(); + expect(unread_count.innerHTML.replace(/<!-.*?->/g, '')).toBe('1'); + })); + + it("is incremented when message is received and windows is not focused", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, sender_jid) + const msgFactory = () => mock.createChatMessage(_converse, sender_jid, 'This message will be received as unread, but eventually will be read'); + _converse.minimize.minimize(view.model); + _converse.handleMessageStanza(msgFactory()); + await u.waitUntil(() => view.model.messages.length); + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + const unread_count = minimized_chats.querySelector('#toggle-minimized-chats .unread-message-count'); + expect(u.isVisible(unread_count)).toBeTruthy(); + expect(unread_count.innerHTML.replace(/<!-.*?->/g, '')).toBe('1'); + })); +}); + + +describe("The Minimized Chats Widget", function () { + + it("shows chats that have been minimized", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + minimized_chats.initToggle(); + + let contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid) + let chatview = _converse.chatboxviews.get(contact_jid); + expect(chatview.model.get('minimized')).toBeFalsy(); + expect(u.isVisible(minimized_chats.firstElementChild)).toBe(false); + chatview.querySelector('.toggle-chatbox-button').click(); + expect(chatview.model.get('minimized')).toBeTruthy(); + expect(u.isVisible(minimized_chats)).toBe(true); + expect(_converse.chatboxes.filter('minimized').length).toBe(1); + expect(_converse.chatboxes.models.filter(c => c.get('minimized')).pop().get('jid')).toBe(contact_jid); + + contact_jid = mock.cur_names[1].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + chatview = _converse.chatboxviews.get(contact_jid); + expect(chatview.model.get('minimized')).toBeFalsy(); + chatview.querySelector('.toggle-chatbox-button').click(); + expect(chatview.model.get('minimized')).toBeTruthy(); + expect(u.isVisible(minimized_chats)).toBe(true); + expect(_converse.chatboxes.filter('minimized').length).toBe(2); + expect(_converse.chatboxes.filter('minimized').map(c => c.get('jid')).includes(contact_jid)).toBeTruthy(); + })); + + it("can be toggled to hide or show minimized chats", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + let minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + minimized_chats.initToggle(); + + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid); + const chatview = _converse.chatboxviews.get(contact_jid); + expect(u.isVisible(minimized_chats.firstElementChild)).toBe(false); + + chatview.model.set({'minimized': true}); + expect(u.isVisible(minimized_chats)).toBeTruthy(); + expect(_converse.chatboxes.filter('minimized').length).toBe(1); + expect(_converse.chatboxes.models.filter(c => c.get('minimized')).pop().get('jid')).toBe(contact_jid); + + minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + expect(u.isVisible(minimized_chats.querySelector('.minimized-chats-flyout'))).toBeTruthy(); + expect(minimized_chats.minchats.get('collapsed')).toBeFalsy(); + minimized_chats.querySelector('#toggle-minimized-chats').click(); + await u.waitUntil(() => u.isVisible(minimized_chats.querySelector('.minimized-chats-flyout'))); + expect(minimized_chats.minchats.get('collapsed')).toBeTruthy(); + })); + + it("shows the number messages received to minimized chats", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 4); + await mock.openControlBox(_converse); + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + minimized_chats.initToggle(); + minimized_chats.minchats.set({'collapsed': true}); + + const unread_el = minimized_chats.querySelector('.unread-message-count'); + expect(u.isVisible(unread_el)).toBe(false); + + const promises = []; + let i, contact_jid; + for (i=0; i<3; i++) { + contact_jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + promises.push(mock.openChatBoxFor(_converse, contact_jid)); + } + await Promise.all(promises); + await u.waitUntil(() => _converse.chatboxes.length == 4); + + const chatview = _converse.chatboxviews.get(contact_jid); + chatview.model.set({'minimized': true}); + for (i=0; i<3; i++) { + const msg = $msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('This message is sent to a minimized chatbox').up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + _converse.handleMessageStanza(msg); + } + await u.waitUntil(() => chatview.model.messages.length === 3, 500); + + + expect(u.isVisible(minimized_chats.querySelector('.unread-message-count'))).toBeTruthy(); + expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((3).toString()); + // Chat state notifications don't increment the unread messages counter + // <composing> state + _converse.handleMessageStanza($msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('composing', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString()); + + // <paused> state + _converse.handleMessageStanza($msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('paused', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString()); + + // <gone> state + _converse.handleMessageStanza($msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('gone', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString()); + + // <inactive> state + _converse.handleMessageStanza($msg({ + from: contact_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('inactive', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()); + expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe((i).toString()); + })); + + it("shows the number messages received to minimized groupchats", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'kitchen@conference.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, 'kitchen@conference.shakespeare.lit', 'fires'); + const view = _converse.chatboxviews.get(muc_jid); + view.model.set({'minimized': true}); + const message = 'fires: Your attention is required'; + const nick = mock.chatroom_names[0]; + const msg = $msg({ + from: muc_jid+'/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message).tree(); + view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.length); + const minimized_chats = await u.waitUntil(() => document.querySelector("converse-minimized-chats")); + expect(u.isVisible(minimized_chats.querySelector('.unread-message-count'))).toBeTruthy(); + expect(minimized_chats.querySelector('.unread-message-count').textContent).toBe('1'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js new file mode 100644 index 0000000..896ec02 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/toggle.js @@ -0,0 +1,9 @@ +import { Model } from '@converse/skeletor/src/model.js'; + +const MinimizedChatsToggle = Model.extend({ + defaults: { + 'collapsed': false + } +}); + +export default MinimizedChatsToggle; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js new file mode 100644 index 0000000..3cb4bf5 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/utils.js @@ -0,0 +1,211 @@ +import { _converse, api, converse } from '@converse/headless/core'; +import { __ } from 'i18n'; + +const { dayjs, u } = converse.env; + +export function initializeChat (chat) { + chat.on('change:hidden', m => !m.get('hidden') && maximize(chat), chat); + + if (chat.get('id') === 'controlbox') { + return; + } + chat.save({ + 'minimized': chat.get('minimized') || false, + 'time_minimized': chat.get('time_minimized') || dayjs(), + }); +} + +function getChatBoxWidth (view) { + if (view.model.get('id') === 'controlbox') { + // We return the width of the controlbox or its toggle, + // depending on which is visible. + if (u.isVisible(view)) { + return u.getOuterWidth(view, true); + } else { + const toggle = document.querySelector('converse-controlbox-toggle'); + return toggle ? u.getOuterWidth(toggle, true) : 0; + } + } else if (!view.model.get('minimized') && u.isVisible(view)) { + return u.getOuterWidth(view, true); + } + return 0; +} + +function getShownChats () { + return _converse.chatboxviews.filter(el => + // The controlbox can take a while to close, + // so we need to check its state. That's why we checked the 'closed' state. + !el.model.get('minimized') && !el.model.get('closed') && u.isVisible(el) + ); +} + +function getMinimizedWidth () { + const minimized_el = document.querySelector('converse-minimized-chats'); + return _converse.chatboxes.pluck('minimized').includes(true) ? u.getOuterWidth(minimized_el, true) : 0; +} + +function getBoxesWidth (newchat) { + const new_id = newchat ? newchat.model.get('id') : null; + const newchat_width = newchat ? u.getOuterWidth(newchat, true) : 0; + return Object.values(_converse.chatboxviews.xget(new_id)) + .reduce((memo, view) => memo + getChatBoxWidth(view), newchat_width); +} + +/** + * This method is called when a newly created chat box will be shown. + * It checks whether there is enough space on the page to show + * another chat box. Otherwise it minimizes the oldest chat box + * to create space. + * @private + * @method _converse.ChatBoxViews#trimChats + * @param { _converse.ChatBoxView|_converse.ChatRoomView|_converse.ControlBoxView|_converse.HeadlinesFeedView } [newchat] + */ +export function trimChats (newchat) { + if (_converse.isTestEnv() || api.settings.get('no_trimming') || api.settings.get("view_mode") !== 'overlayed') { + return; + } + const shown_chats = getShownChats(); + if (shown_chats.length <= 1) { + return; + } + const body_width = u.getOuterWidth(document.querySelector('body'), true); + if (getChatBoxWidth(shown_chats[0]) === body_width) { + // If the chats shown are the same width as the body, + // then we're in responsive mode and the chats are + // fullscreen. In this case we don't trim. + return; + } + const minimized_el = document.querySelector('converse-minimized-chats'); + if (minimized_el) { + while ((getMinimizedWidth() + getBoxesWidth(newchat)) > body_width) { + const new_id = newchat ? newchat.model.get('id') : null; + const oldest_chat = getOldestMaximizedChat([new_id]); + if (oldest_chat) { + const model = _converse.chatboxes.get(oldest_chat.get('id')); + model?.save('hidden', true); + minimize(oldest_chat); + } else { + break; + } + } + } +} + +function getOldestMaximizedChat (exclude_ids) { + // Get oldest view (if its id is not excluded) + exclude_ids.push('controlbox'); + let i = 0; + let model = _converse.chatboxes.sort().at(i); + while (exclude_ids.includes(model.get('id')) || model.get('minimized') === true) { + i++; + model = _converse.chatboxes.at(i); + if (!model) { + return null; + } + } + return model; +} + +export function addMinimizeButtonToChat (view, buttons) { + const data = { + 'a_class': 'toggle-chatbox-button', + 'handler': ev => minimize(ev, view.model), + 'i18n_text': __('Minimize'), + 'i18n_title': __('Minimize this chat'), + 'icon_class': "fa-minus", + 'name': 'minimize', + 'standalone': _converse.api.settings.get("view_mode") === 'overlayed' + } + const names = buttons.map(t => t.name); + const idx = names.indexOf('close'); + return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons]; +} + +export function addMinimizeButtonToMUC (view, buttons) { + const data = { + 'a_class': 'toggle-chatbox-button', + 'handler': ev => minimize(ev, view.model), + 'i18n_text': __('Minimize'), + 'i18n_title': __('Minimize this groupchat'), + 'icon_class': "fa-minus", + 'name': 'minimize', + 'standalone': _converse.api.settings.get("view_mode") === 'overlayed' + } + const names = buttons.map(t => t.name); + const idx = names.indexOf('signout'); + return idx > -1 ? [...buttons.slice(0, idx), data, ...buttons.slice(idx)] : [data, ...buttons]; +} + + +export function maximize (ev, chatbox) { + if (ev?.preventDefault) { + ev.preventDefault(); + } else { + chatbox = ev; + } + u.safeSave(chatbox, { + 'hidden': false, + 'minimized': false, + 'time_opened': new Date().getTime() + }); +} + +export function minimize (ev, model) { + if (ev?.preventDefault) { + ev.preventDefault(); + } else { + model = ev; + } + model.setChatState(_converse.INACTIVE); + u.safeSave(model, { + 'hidden': true, + 'minimized': true, + 'time_minimized': new Date().toISOString() + }); +} + +/** + * Handler which gets called when a {@link _converse#ChatBox} has it's + * `minimized` property set to false. + * + * Will trigger {@link _converse#chatBoxMaximized} + * @returns {_converse.ChatBoxView|_converse.ChatRoomView} + */ +function onMaximized (model) { + if (!model.isScrolledUp()) { + model.clearUnreadMsgCounter(); + } + model.setChatState(_converse.ACTIVE); + /** + * Triggered when a previously minimized chat gets maximized + * @event _converse#chatBoxMaximized + * @type { _converse.ChatBoxView } + * @example _converse.api.listen.on('chatBoxMaximized', view => { ... }); + */ + api.trigger('chatBoxMaximized', model); +} + +/** + * Handler which gets called when a {@link _converse#ChatBox} has it's + * `minimized` property set to true. + * + * Will trigger {@link _converse#chatBoxMinimized} + * @returns {_converse.ChatBoxView|_converse.ChatRoomView} + */ +function onMinimized (model) { + /** + * Triggered when a previously maximized chat gets Minimized + * @event _converse#chatBoxMinimized + * @type { _converse.ChatBoxView } + * @example _converse.api.listen.on('chatBoxMinimized', view => { ... }); + */ + api.trigger('chatBoxMinimized', model); +} + +export function onMinimizedChanged (model) { + if (model.get('minimized')) { + onMinimized(model); + } else { + onMaximized(model); + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js b/roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js new file mode 100644 index 0000000..a8e690a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/minimize/view.js @@ -0,0 +1,50 @@ +import MinimizedChatsToggle from './toggle.js'; +import tplChatsPanel from './templates/chats-panel.js'; +import { CustomElement } from 'shared/components/element'; +import { _converse, api } from '@converse/headless/core'; +import { initStorage } from '@converse/headless/utils/storage.js'; + + +export default class MinimizedChats extends CustomElement { + + async initialize () { + this.model = _converse.chatboxes; + await this.initToggle(); + this.listenTo(this.minchats, 'change:collapsed', () => this.requestUpdate()) + this.listenTo(this.model, 'add', () => this.requestUpdate()) + this.listenTo(this.model, 'change:fullname', () => this.requestUpdate()) + this.listenTo(this.model, 'change:jid', () => this.requestUpdate()) + this.listenTo(this.model, 'change:minimized', () => this.requestUpdate()) + this.listenTo(this.model, 'change:name', () => this.requestUpdate()) + this.listenTo(this.model, 'change:num_unread', () => this.requestUpdate()) + this.listenTo(this.model, 'remove', () => this.requestUpdate()) + + this.listenTo(_converse, 'connected', () => this.requestUpdate()); + this.listenTo(_converse, 'reconnected', () => this.requestUpdate()); + this.listenTo(_converse, 'disconnected', () => this.requestUpdate()); + } + + render () { + const chats = this.model.where({'minimized': true}); + const num_unread = chats.reduce((acc, chat) => (acc + chat.get('num_unread')), 0); + const num_minimized = chats.reduce((acc, chat) => (acc + (chat.get('minimized') ? 1 : 0)), 0); + const collapsed = this.minchats.get('collapsed'); + const data = { chats, num_unread, num_minimized, collapsed }; + data.toggle = ev => this.toggle(ev); + return tplChatsPanel(data); + } + + async initToggle () { + const id = `converse.minchatstoggle-${_converse.bare_jid}`; + this.minchats = new MinimizedChatsToggle({id}); + initStorage(this.minchats, id, 'session'); + await new Promise(resolve => this.minchats.fetch({'success': resolve, 'error': resolve})); + } + + toggle (ev) { + ev?.preventDefault(); + this.minchats.save({'collapsed': !this.minchats.get('collapsed')}); + } +} + +api.elements.define('converse-minimized-chats', MinimizedChats); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js new file mode 100644 index 0000000..b112b60 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/alert.js @@ -0,0 +1,23 @@ +import BaseModal from "plugins/modal/modal.js"; +import tplAlertModal from "./templates/alert.js"; +import { api } from "@converse/headless/core"; + + +export default class Alert extends BaseModal { + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change', () => this.render()) + this.addEventListener('hide.bs.modal', () => this.remove(), false); + } + + renderModal () { + return tplAlertModal(this.model.toJSON()); + } + + getModalTitle () { + return this.model.get('title'); + } +} + +api.elements.define('converse-alert-modal', Alert); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/api.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/api.js new file mode 100644 index 0000000..9b6512c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/api.js @@ -0,0 +1,188 @@ +import './alert.js'; +import Confirm from './confirm.js'; +import { Model } from '@converse/skeletor/src/model.js'; + +let modals = []; +let modals_map = {}; + +const modal_api = { + /** + * API namespace for methods relating to modals + * @namespace _converse.api.modal + * @memberOf _converse.api + */ + modal: { + /** + * Shows a modal of type `ModalClass` to the user. + * Will create a new instance of that class if an existing one isn't + * found. + * @param { Class } ModalClass + * @param { Object } [properties] - Optional properties that will be set on a newly created modal instance. + * @param { Event } [event] - The DOM event that causes the modal to be shown. + */ + show (name, properties, ev) { + let modal; + if (typeof name === 'string') { + modal = this.get(name) ?? this.create(name, properties); + Object.assign(modal, properties); + } else { + // Legacy... + const ModalClass = name; + const id = ModalClass.id ?? properties.id; + modal = this.get(id) ?? this.create(ModalClass, properties); + } + modal.show(ev); + return modal; + }, + + /** + * Return a modal with the passed-in identifier, if it exists. + * @param { String } id + */ + get (id) { + return modals_map[id] ?? modals.filter(m => m.id == id).pop(); + }, + + /** + * Create a modal of the passed-in type. + * @param { String } name + * @param { Object } [properties] - Optional properties that will be + * set on the modal instance. + */ + create (name, properties) { + let modal; + if (typeof name === 'string') { + const ModalClass = customElements.get(name); + modal = modals_map[name] = new ModalClass(properties); + } else { + // Legacy... + const ModalClass = name; + modal = new ModalClass(properties); + modals.push(modal); + } + return modal; + }, + + /** + * Remove a particular modal + * @param { String } name + */ + remove (name) { + let modal; + if (typeof name === 'string') { + modal = modals_map[name]; + delete modals_map[name]; + } else { + // Legacy... + modal = name; + modals = modals.filter(m => m !== modal); + } + modal?.remove(); + }, + + /** + * Remove all modals + */ + removeAll () { + modals.forEach(m => m.remove()); + modals = []; + modals_map = {}; + } + }, + + /** + * Show a confirm modal to the user. + * @method _converse.api.confirm + * @param { String } title - The header text for the confirmation dialog + * @param { (Array<String>|String) } messages - The text to show to the user + * @param { Array<Field> } fields - An object representing a fields presented to the user. + * @property { String } Field.label - The form label for the input field. + * @property { String } Field.name - The name for the input field. + * @property { String } [Field.challenge] - A challenge value that must be provided by the user. + * @property { String } [Field.placeholder] - The placeholder for the input field. + * @property { Boolean} [Field.required] - Whether the field is required or not + * @returns { Promise<Array|false> } A promise which resolves with an array of + * filled in fields or `false` if the confirm dialog was closed or canceled. + */ + async confirm (title, messages=[], fields=[]) { + if (typeof messages === 'string') { + messages = [messages]; + } + const model = new Model({title, messages, fields, 'type': 'confirm'}) + const confirm = new Confirm({model}); + confirm.show(); + let result; + try { + result = await confirm.confirmation; + } catch (e) { + result = false; + } + confirm.remove(); + return result; + }, + + /** + * Show a prompt modal to the user. + * @method _converse.api.prompt + * @param { String } title - The header text for the prompt + * @param { (Array<String>|String) } messages - The prompt text to show to the user + * @param { String } placeholder - The placeholder text for the prompt input + * @returns { Promise<String|false> } A promise which resolves with the text provided by the + * user or `false` if the user canceled the prompt. + */ + async prompt (title, messages=[], placeholder='') { + if (typeof messages === 'string') { + messages = [messages]; + } + const model = new Model({ + title, + messages, + 'fields': [{ + 'name': 'reason', + 'placeholder': placeholder, + }], + 'type': 'prompt' + }) + const prompt = new Confirm({model}); + prompt.show(); + let result; + try { + result = (await prompt.confirmation).pop()?.value; + } catch (e) { + result = false; + } + prompt.remove(); + return result; + }, + + /** + * Show an alert modal to the user. + * @method _converse.api.alert + * @param { ('info'|'warn'|'error') } type - The type of alert. + * @param { String } title - The header text for the alert. + * @param { (Array<String>|String) } messages - The alert text to show to the user. + */ + alert (type, title, messages) { + if (typeof messages === 'string') { + messages = [messages]; + } + let level; + if (type === 'error') { + level = 'alert-danger'; + } else if (type === 'info') { + level = 'alert-info'; + } else if (type === 'warn') { + level = 'alert-warning'; + } + + const model = new Model({ + 'title': title, + 'messages': messages, + 'level': level, + 'type': 'alert' + }) + modal_api.modal.show('converse-alert-modal', { model }); + } +} + +export default modal_api; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/base.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/base.js new file mode 100644 index 0000000..8006462 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/base.js @@ -0,0 +1,92 @@ +import api from "@converse/headless/shared/api/index.js"; +import bootstrap from "bootstrap.native"; +import log from "@converse/headless/log"; +import sizzle from 'sizzle'; +import tplAlertComponent from "./templates/modal-alert.js"; +import { View } from '@converse/skeletor/src/view.js'; +import { hasClass, addClass, removeElement, removeClass } from '../../utils/html.js'; +import { render } from 'lit'; + +import './styles/_modal.scss'; + + + +const BaseModal = View.extend({ + className: "modal", + persistent: false, // Whether this modal should persist in the DOM once it's been closed + events: { + 'click .nav-item .nav-link': 'switchTab' + }, + + initialize (options) { + if (!this.id) { + throw new Error("Each modal class must have a unique id attribute"); + } + // Allow properties to be set via passed in options + Object.assign(this, options); + + this.render() + + this.el.setAttribute('tabindex', '-1'); + this.el.setAttribute('role', 'dialog'); + this.el.setAttribute('aria-hidden', 'true'); + const label_id = this.el.querySelector('.modal-title').getAttribute('id'); + label_id && this.el.setAttribute('aria-labelledby', label_id); + + this.insertIntoDOM(); + const Modal = bootstrap.Modal; + this.modal = new Modal(this.el, { + backdrop: true, + keyboard: true + }); + this.el.addEventListener('hide.bs.modal', () => this.onHide(), false); + }, + + onHide () { + removeClass('selected', this.trigger_el); + !this.persistent && api.modal.remove(this); + }, + + insertIntoDOM () { + const container_el = document.querySelector("#converse-modals"); + container_el.insertAdjacentElement('beforeEnd', this.el); + }, + + switchTab (ev) { + ev.stopPropagation(); + ev.preventDefault(); + sizzle('.nav-link.active', this.el).forEach(el => { + removeClass('active', this.el.querySelector(el.getAttribute('href'))); + removeClass('active', el); + }); + addClass('active', ev.target); + addClass('active', this.el.querySelector(ev.target.getAttribute('href'))) + }, + + alert (message, type='primary') { + const body = this.el.querySelector('.modal-alert'); + if (body === null) { + log.error("Could not find a .modal-alert element in the modal to show an alert message in!"); + return; + } + // FIXME: Instead of adding the alert imperatively, we should + // find a way to let the modal rerender with an alert message + render(tplAlertComponent({'type': `alert-${type}`, 'message': message}), body); + const el = body.firstElementChild; + setTimeout(() => { + addClass('fade-out', el); + setTimeout(() => removeElement(el), 600); + }, 5000); + }, + + show (ev) { + if (ev) { + ev.preventDefault(); + this.trigger_el = ev.target; + !hasClass('chat-image', this.trigger_el) && addClass('selected', this.trigger_el); + } + this.modal.show(); + } +}); + +export default BaseModal; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js new file mode 100644 index 0000000..8f775f0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/confirm.js @@ -0,0 +1,59 @@ +import BaseModal from "plugins/modal/modal.js"; +import tplPrompt from "./templates/prompt.js"; +import { getOpenPromise } from '@converse/openpromise'; +import { api } from "@converse/headless/core"; + +export default class Confirm extends BaseModal { + + constructor (options) { + super(options); + this.confirmation = getOpenPromise(); + } + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change', () => this.render()) + this.addEventListener('hide.bs.modal', () => { + if (!this.confirmation.isResolved) { + this.confirmation.reject() + } + }, false); + } + + renderModal () { + return tplPrompt(this); + } + + getModalTitle () { + return this.model.get('title'); + } + + onConfimation (ev) { + ev.preventDefault(); + const form_data = new FormData(ev.target); + const fields = (this.model.get('fields') || []) + .map(field => { + const value = form_data.get(field.name).trim(); + field.value = value; + if (field.challenge) { + field.challenge_failed = (value !== field.challenge); + } + return field; + }); + + if (fields.filter(c => c.challenge_failed).length) { + this.model.set('fields', fields); + // Setting an array doesn't trigger a change event + this.model.trigger('change'); + return; + } + this.confirmation.resolve(fields); + this.modal.hide(); + } + + renderModalFooter () { // eslint-disable-line class-methods-use-this + return ''; + } +} + +api.elements.define('converse-confirm-modal', Confirm); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/index.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/index.js new file mode 100644 index 0000000..93816b3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/index.js @@ -0,0 +1,26 @@ +/** + * @copyright The Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import BootstrapModal from './base.js'; +import modal_api from './api.js'; +import { _converse, api, converse } from "@converse/headless/core"; + +converse.env.BootstrapModal = BootstrapModal; // expose to plugins + + +converse.plugins.add('converse-modal', { + + initialize () { + api.listen.on('disconnect', () => { + const container = document.querySelector("#converse-modals"); + if (container) { + container.innerHTML = ''; + } + }); + + api.listen.on('clearSession', () => api.modal.removeAll()); + + Object.assign(_converse.api, modal_api); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js new file mode 100644 index 0000000..d785c5a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/modal.js @@ -0,0 +1,71 @@ +import bootstrap from "bootstrap.native"; +import tplModal from './templates/modal.js'; +import { ElementView } from '@converse/skeletor/src/element.js'; +import { getOpenPromise } from '@converse/openpromise'; + + +import './styles/_modal.scss'; + +class BaseModal extends ElementView { + + constructor (options) { + super(); + this.className = 'modal'; + this.initialized = getOpenPromise(); + + // Allow properties to be set via passed in options + Object.assign(this, options); + setTimeout(() => this.insertIntoDOM()); + + this.addEventListener('hide.bs.modal', () => this.onHide(), false); + } + + initialize () { + this.modal = new bootstrap.Modal(this, { + backdrop: true, + keyboard: true + }); + this.initialized.resolve(); + this.render() + } + + toHTML () { + return tplModal(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + // Intended to be overwritten + return ''; + } + + switchTab (ev) { + ev?.stopPropagation(); + ev?.preventDefault(); + this.tab = ev.target.getAttribute('data-name'); + this.render(); + } + + onHide () { + this.modal.hide(); + } + + insertIntoDOM () { + const container_el = document.querySelector("#converse-modals"); + container_el.insertAdjacentElement('beforeEnd', this); + } + + alert (message, type='primary') { + this.model.set('alert', { message, type }); + setTimeout(() => { + this.model.set('alert', undefined); + }, 5000); + } + + async show () { + await this.initialized; + this.modal.show(); + this.render(); + } +} + +export default BaseModal; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss b/roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss new file mode 100644 index 0000000..24aefd6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/styles/_modal.scss @@ -0,0 +1,119 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; + +.conversejs { + @import "bootstrap/scss/modal"; + + .modal-header { + &.alert-danger { + background-color: var(--error-color); + color: var(--background); + border-bottom: none; + + .close { + color: var(--background); + } + } + } + + .modal-content { + background-color: var(--modal-background-color); + } + + .modal-body { + .row { + margin-left: 0; + margin-right: 0; + } + } + + .occupant-details { + li { + margin-bottom: 1em; + } + } + + #converse-modals { + .modal { + .nav-item { + margin: 0.25em; + .nav-link { + &.active { + color: var(--background); + } + } + &:hover { + .nav-link { + color: var(--foreground); + background-color: var(--primary-color-light); + &.active { + color: var(--background); + background-color: var(--primary-color); + } + } + } + } + + .modal-content { + box-shadow: var(--raised-el-shadow); + } + + .modal-body { + overflow-y: auto; + max-height: 75vh; + margin-bottom: 2em; + p { + padding: 0.25rem 0; + } + .confirm { + .form-group { + p:first-child { + font-size: 110%; + font-weight: bold; + } + } + } + &.fit-content { + box-sizing: content-box; + + img { + max-width: 90vw; + } + } + } + .modal-footer { + justify-content: flex-start; + } + .roomid-policy-error { + color: var(--error-color); + font-size: var(--font-size-small); + float: right; + } + } + + .scrollable-container { + max-height: 45vh; + overflow-y: auto; + } + + .role-form, .affiliation-form { + padding: 2em 0 1em 0; + } + + .set-xmpp-status { + margin: 1em; + .custom-control-label { + padding-top: 0.25em; + } + } + + #omemo-tabpanel { + margin-top: 1em; + } + + .btn { + font-weight: normal; + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js new file mode 100644 index 0000000..0b35eed --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/alert.js @@ -0,0 +1,8 @@ +import { html } from "lit"; + + +export default (o) => html` + <div class="modal-body"> + <span class="modal-alert"></span> + ${ o.messages.map(message => html`<p>${message}</p>`) } + </div>`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js new file mode 100644 index 0000000..73dee7f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/buttons.js @@ -0,0 +1,9 @@ +import { __ } from 'i18n'; +import { html } from "lit"; + + +export const modal_close_button = + html`<button type="button" class="btn btn-secondary" data-dismiss="modal">${__('Close')}</button>`; + +export const modal_header_close_button = + html`<button type="button" class="close" data-dismiss="modal" aria-label="${__('Close')}"><span aria-hidden="true">×</span></button>`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js new file mode 100644 index 0000000..06dd9ca --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal-alert.js @@ -0,0 +1,3 @@ +import { html } from "lit"; + +export default (o) => html`<div class="alert ${o.type}" role="alert"><p>${o.message}</p></div>` diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js new file mode 100644 index 0000000..daa2c29 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/modal.js @@ -0,0 +1,26 @@ +import tplAlertComponent from "./modal-alert.js"; +import { html } from "lit"; +import { modal_close_button, modal_header_close_button } from "plugins/modal/templates/buttons.js"; + + +export default (el) => { + const alert = el.model?.get('alert'); + const level = el.model?.get('level') ?? ''; + return html` + <div class="modal-dialog" role="document" tabindex="-1" role="dialog" aria-hidden="true"> + <div class="modal-content"> + <div class="modal-header ${level}"> + <h5 class="modal-title">${el.getModalTitle()}</h5> + ${modal_header_close_button} + </div> + <div class="modal-body"> + <span class="modal-alert"> + ${ alert ? tplAlertComponent({'type': `alert-${alert.type}`, 'message': alert.message}) : ''} + </span> + ${ el.renderModal?.() ?? '' } + </div> + ${ el.renderModalFooter?.() ?? html`<div class="modal-footer">${ modal_close_button }</div>` } + </div> + </div> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js new file mode 100644 index 0000000..2c0ad21 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/modal/templates/prompt.js @@ -0,0 +1,30 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + + +const tplField = (f) => html` + <div class="form-group"> + <label> + ${f.label || ''} + <input type="text" + name="${f.name}" + class="${(f.challenge_failed) ? 'error' : ''} form-control form-control--labeled" + ?required="${f.required}" + placeholder="${f.placeholder}" /> + </label> + </div> +`; + +export default (el) => { + return html` + <form class="converse-form converse-form--modal confirm" action="#" @submit=${ev => el.onConfimation(ev)}> + <div class="form-group"> + ${ el.model.get('messages')?.map(message => html`<p>${message}</p>`) } + </div> + ${ el.model.get('fields')?.map(f => tplField(f)) } + <div class="form-group"> + <button type="submit" class="btn btn-primary">${__('OK')}</button> + <input type="button" class="btn btn-secondary" data-dismiss="modal" value="${__('Cancel')}"/> + </div> + </form>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js new file mode 100644 index 0000000..65b12d0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/affiliation-form.js @@ -0,0 +1,69 @@ +import log from '@converse/headless/log'; +import tplAffiliationForm from './templates/affiliation-form.js'; +import { CustomElement } from 'shared/components/element'; +import { __ } from 'i18n'; +import { api, converse } from '@converse/headless/core'; +import { setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js'; + +const { Strophe, sizzle } = converse.env; + +class AffiliationForm extends CustomElement { + static get properties () { + return { + muc: { type: Object }, + jid: { type: String }, + affiliation: { type: String }, + alert_message: { type: String, attribute: false }, + alert_type: { type: String, attribute: false }, + }; + } + + render () { + return tplAffiliationForm(this); + } + + alert (message, type) { + this.alert_message = message; + this.alert_type = type; + } + + async assignAffiliation (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.alert(); // clear alert messages + + const data = new FormData(ev.target); + const affiliation = data.get('affiliation'); + const attrs = { + jid: this.jid, + reason: data.get('reason'), + }; + const muc_jid = this.muc.get('jid'); + try { + await setAffiliation(affiliation, muc_jid, [attrs]); + } catch (e) { + if (e === null) { + this.alert(__('Timeout error while trying to set the affiliation'), 'danger'); + } else if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) { + this.alert(__("Sorry, you're not allowed to make that change"), 'danger'); + } else { + this.alert(__('Sorry, something went wrong while trying to set the affiliation'), 'danger'); + } + log.error(e); + return; + } + + await this.muc.occupants.fetchMembers(); + + /** + * @event affiliationChanged + * @example + * const el = document.querySelector('converse-muc-affiliation-form'); + * el.addEventListener('affiliationChanged', () => { ... }); + */ + const event = new CustomEvent('affiliationChanged', { bubbles: true }); + this.dispatchEvent(event); + } +} + +api.elements.define('converse-muc-affiliation-form', AffiliationForm); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js new file mode 100644 index 0000000..c8e9412 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/bottom-panel.js @@ -0,0 +1,53 @@ +import 'shared/autocomplete/index.js'; +import BottomPanel from 'plugins/chatview/bottom-panel.js'; +import tplMUCBottomPanel from './templates/muc-bottom-panel.js'; +import { _converse, api, converse } from "@converse/headless/core"; +import { render } from 'lit'; + +import './styles/muc-bottom-panel.scss'; + + +export default class MUCBottomPanel extends BottomPanel { + + events = { + 'click .hide-occupants': 'hideOccupants', + 'click .send-button': 'sendButtonClicked', + } + + async initialize () { + await super.initialize(); + this.listenTo(this.model, 'change:hidden_occupants', this.debouncedRender); + this.listenTo(this.model, 'change:num_unread_general', this.debouncedRender) + this.listenTo(this.model.features, 'change:moderated', this.debouncedRender); + this.listenTo(this.model.occupants, 'add', this.renderIfOwnOccupant) + this.listenTo(this.model.occupants, 'change:role', this.renderIfOwnOccupant); + this.listenTo(this.model.session, 'change:connection_status', this.debouncedRender); + } + + render () { + const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; + const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); + render(tplMUCBottomPanel({ + can_edit, entered, + 'model': this.model, + 'is_groupchat': true, + 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) + }), this); + } + + renderIfOwnOccupant (o) { + (o.get('jid') === _converse.bare_jid) && this.debouncedRender(); + } + + sendButtonClicked (ev) { + this.querySelector('converse-muc-message-form')?.onFormSubmitted(ev); + } + + hideOccupants (ev) { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + this.model.save({ 'hidden_occupants': true }); + } +} + +api.elements.define('converse-muc-bottom-panel', MUCBottomPanel); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js new file mode 100644 index 0000000..ef665d9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/chatarea.js @@ -0,0 +1,157 @@ +import tplMUCChatarea from './templates/muc-chatarea.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { api, converse } from '@converse/headless/core'; + + +const { u } = converse.env; + + +export default class MUCChatArea extends CustomElement { + + static get properties () { + return { + jid: { type: String }, + show_help_messages: { type: Boolean }, + type: { type: String }, + } + } + + async initialize () { + this.model = await api.rooms.get(this.jid); + this.listenTo(this.model, 'change:show_help_messages', () => this.requestUpdate()); + this.listenTo(this.model, 'change:hidden_occupants', () => this.requestUpdate()); + this.listenTo(this.model.session, 'change:connection_status', () => this.requestUpdate()); + + // Bind so that we can pass it to addEventListener and removeEventListener + this.onMouseMove = this._onMouseMove.bind(this); + this.onMouseUp = this._onMouseUp.bind(this); + + this.requestUpdate(); // Make sure we render again after the model has been attached + } + + render () { + return tplMUCChatarea({ + 'getHelpMessages': () => this.getHelpMessages(), + 'jid': this.jid, + 'model': this.model, + 'onMousedown': ev => this.onMousedown(ev), + 'show_send_button': api.settings.get('show_send_button'), + 'shouldShowSidebar': () => this.shouldShowSidebar(), + 'type': this.type, + }); + } + + shouldShowSidebar () { + return ( + !this.model.get('hidden_occupants') && + this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED + ); + } + + getHelpMessages () { + const setting = api.settings.get('muc_disable_slash_commands'); + const disabled_commands = Array.isArray(setting) ? setting : []; + return [ + `<strong>/admin</strong>: ${__("Change user's affiliation to admin")}`, + `<strong>/ban</strong>: ${__('Ban user by changing their affiliation to outcast')}`, + `<strong>/clear</strong>: ${__('Clear the chat area')}`, + `<strong>/close</strong>: ${__('Close this groupchat')}`, + `<strong>/deop</strong>: ${__('Change user role to participant')}`, + `<strong>/destroy</strong>: ${__('Remove this groupchat')}`, + `<strong>/help</strong>: ${__('Show this menu')}`, + `<strong>/kick</strong>: ${__('Kick user from groupchat')}`, + `<strong>/me</strong>: ${__('Write in 3rd person')}`, + `<strong>/member</strong>: ${__('Grant membership to a user')}`, + `<strong>/modtools</strong>: ${__('Opens up the moderator tools GUI')}`, + `<strong>/mute</strong>: ${__("Remove user's ability to post messages")}`, + `<strong>/nick</strong>: ${__('Change your nickname')}`, + `<strong>/op</strong>: ${__('Grant moderator role to user')}`, + `<strong>/owner</strong>: ${__('Grant ownership of this groupchat')}`, + `<strong>/register</strong>: ${__('Register your nickname')}`, + `<strong>/revoke</strong>: ${__("Revoke the user's current affiliation")}`, + `<strong>/subject</strong>: ${__('Set groupchat subject')}`, + `<strong>/topic</strong>: ${__('Set groupchat subject (alias for /subject)')}`, + `<strong>/voice</strong>: ${__('Allow muted user to post messages')}` + ] + .filter(line => disabled_commands.every(c => !line.startsWith(c + '<', 9))) + .filter(line => this.model.getAllowedCommands().some(c => line.startsWith(c + '<', 9))); + } + + onMousedown (ev) { + if (u.hasClass('dragresize-occupants-left', ev.target)) { + this.onStartResizeOccupants(ev); + } + } + + onStartResizeOccupants (ev) { + this.resizing = true; + this.addEventListener('mousemove', this.onMouseMove); + this.addEventListener('mouseup', this.onMouseUp); + + const sidebar_el = this.querySelector('converse-muc-sidebar'); + const style = window.getComputedStyle(sidebar_el); + this.width = parseInt(style.width.replace(/px$/, ''), 10); + this.prev_pageX = ev.pageX; + } + + _onMouseMove (ev) { + if (this.resizing) { + ev.preventDefault(); + const delta = this.prev_pageX - ev.pageX; + this.resizeSidebarView(delta, ev.pageX); + this.prev_pageX = ev.pageX; + } + } + + _onMouseUp (ev) { + if (this.resizing) { + ev.preventDefault(); + this.resizing = false; + this.removeEventListener('mousemove', this.onMouseMove); + this.removeEventListener('mouseup', this.onMouseUp); + const sidebar_el = this.querySelector('converse-muc-sidebar'); + const element_position = sidebar_el.getBoundingClientRect(); + const occupants_width = this.calculateSidebarWidth(element_position, 0); + u.safeSave(this.model, { occupants_width }); + } + } + + calculateSidebarWidth (element_position, delta) { + let occupants_width = element_position.width + delta; + const room_width = this.clientWidth; + // keeping display in boundaries + if (occupants_width < room_width * 0.2) { + // set pixel to 20% width + occupants_width = room_width * 0.2; + this.is_minimum = true; + } else if (occupants_width > room_width * 0.75) { + // set pixel to 75% width + occupants_width = room_width * 0.75; + this.is_maximum = true; + } else if (room_width - occupants_width < 250) { + // resize occupants if chat-area becomes smaller than 250px (min-width property set in css) + occupants_width = room_width - 250; + this.is_maximum = true; + } else { + this.is_maximum = false; + this.is_minimum = false; + } + return occupants_width; + } + + resizeSidebarView (delta, current_mouse_position) { + const sidebar_el = this.querySelector('converse-muc-sidebar'); + const element_position = sidebar_el.getBoundingClientRect(); + if (this.is_minimum) { + this.is_minimum = element_position.left < current_mouse_position; + } else if (this.is_maximum) { + this.is_maximum = element_position.left > current_mouse_position; + } else { + const occupants_width = this.calculateSidebarWidth(element_position, delta); + sidebar_el.style.flex = '0 0 ' + occupants_width + 'px'; + } + } +} + +api.elements.define('converse-muc-chatarea', MUCChatArea); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js new file mode 100644 index 0000000..338a5fe --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/config-form.js @@ -0,0 +1,65 @@ +import log from "@converse/headless/log"; +import tplMUCConfigForm from "./templates/muc-config-form.js"; +import { CustomElement } from 'shared/components/element'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; + +const { sizzle } = converse.env; +const u = converse.env.utils; + + +class MUCConfigForm extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model.features, 'change:passwordprotected', () => this.requestUpdate()); + this.listenTo(this.model.session, 'change:config_stanza', () => this.requestUpdate()); + this.getConfig(); + } + + render () { + return tplMUCConfigForm({ + 'model': this.model, + 'closeConfigForm': ev => this.closeForm(ev), + 'submitConfigForm': ev => this.submitConfigForm(ev), + }); + } + + async getConfig () { + const iq = await this.model.fetchRoomConfiguration(); + this.model.session.set('config_stanza', iq.outerHTML); + } + + async submitConfigForm (ev) { + ev.preventDefault(); + const inputs = sizzle(':input:not([type=button]):not([type=submit])', ev.target); + const config_array = inputs.map(u.webForm2xForm).filter(f => f); + try { + await this.model.sendConfiguration(config_array); + } catch (e) { + log.error(e); + const message = + __("Sorry, an error occurred while trying to submit the config form.") + " " + + __("Check your browser's developer console for details."); + api.alert('error', __('Error'), message); + } + await this.model.refreshDiscoInfo(); + this.closeForm(); + } + + closeForm (ev) { + ev?.preventDefault?.(); + this.model.session.set('view', null); + } +} + +api.elements.define('converse-muc-config-form', MUCConfigForm); + +export default MUCConfigForm diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js new file mode 100644 index 0000000..2d7b2b9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/constants.js @@ -0,0 +1,9 @@ +export const PRETTY_CHAT_STATUS = { + 'offline': 'Offline', + 'unavailable': 'Unavailable', + 'xa': 'Extended Away', + 'away': 'Away', + 'dnd': 'Do not disturb', + 'chat': 'Chattty', + 'online': 'Online' +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js new file mode 100644 index 0000000..6316a3d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/destroyed.js @@ -0,0 +1,38 @@ +import tplMUCDestroyed from './templates/muc-destroyed.js'; +import { CustomElement } from 'shared/components/element'; +import { _converse, api } from "@converse/headless/core"; + + +class MUCDestroyed extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.jid); + } + + render () { + const reason = this.model.get('destroyed_reason'); + const moved_jid = this.model.get('moved_jid'); + return tplMUCDestroyed({ + moved_jid, + reason, + 'onSwitch': ev => this.onSwitch(ev) + }); + } + + async onSwitch (ev) { + ev.preventDefault(); + const moved_jid = this.model.get('moved_jid'); + const room = await api.rooms.get(moved_jid, {}, true); + room.maybeShow(true); + this.model.destroy(); + } +} + +api.elements.define('converse-muc-destroyed', MUCDestroyed); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js new file mode 100644 index 0000000..15e89a8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/disconnected.js @@ -0,0 +1,38 @@ +import tplMUCDisconnect from './templates/muc-disconnect.js'; +import { CustomElement } from 'shared/components/element'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; + + +class MUCDisconnected extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.jid); + } + + render () { + const message = this.model.session.get('disconnection_message'); + if (!message) { + return; + } + const messages = [message]; + const actor = this.model.session.get('disconnection_actor'); + if (actor) { + messages.push(__('This action was done by %1$s.', actor)); + } + const reason = this.model.session.get('disconnection_reason'); + if (reason) { + messages.push(__('The reason given is: "%1$s".', reason)); + } + return tplMUCDisconnect(messages); + } +} + +api.elements.define('converse-muc-disconnected', MUCDisconnected); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js new file mode 100644 index 0000000..c2c7db6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/heading.js @@ -0,0 +1,190 @@ +import './modals/muc-details.js'; +import './modals/muc-invite.js'; +import './modals/nickname.js'; +import tplMUCHead from './templates/muc-head.js'; +import { CustomElement } from 'shared/components/element.js'; +import { Model } from '@converse/skeletor/src/model.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core.js"; +import { destroyMUC, showModeratorToolsModal } from './utils.js'; + +import './styles/muc-head.scss'; + + +export default class MUCHeading extends CustomElement { + + async initialize () { + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + this.listenTo(this.model, 'change', () => this.requestUpdate()); + this.listenTo(this.model, 'vcard:add', () => this.requestUpdate()); + this.listenTo(this.model, 'vcard:change', () => this.requestUpdate()); + + this.user_settings = await _converse.api.user.settings.getModel(); + this.listenTo(this.user_settings, 'change:mucs_with_hidden_subject', () => this.requestUpdate()); + + await this.model.initialized; + this.listenTo(this.model.features, 'change:open', () => this.requestUpdate()); + this.model.occupants.forEach(o => this.onOccupantAdded(o)); + this.listenTo(this.model.occupants, 'add', this.onOccupantAdded); + this.listenTo(this.model.occupants, 'change:affiliation', this.onOccupantAffiliationChanged); + this.requestUpdate(); + } + + render () { + return (this.model && this.user_settings) ? tplMUCHead(this) : ''; + } + + onOccupantAdded (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.requestUpdate(); + } + } + + onOccupantAffiliationChanged (occupant) { + if (occupant.get('jid') === _converse.bare_jid) { + this.requestUpdate(); + } + } + + showRoomDetailsModal (ev) { + ev.preventDefault(); + api.modal.show('converse-muc-details-modal', { 'model': this.model }, ev); + } + + showInviteModal (ev) { + ev.preventDefault(); + api.modal.show('converse-muc-invite-modal', { 'model': new Model(), 'chatroomview': this }, ev); + } + + toggleTopic (ev) { + ev?.preventDefault?.(); + this.model.toggleSubjectHiddenState(); + } + + getAndRenderConfigurationForm () { + this.model.session.set('view', converse.MUC.VIEWS.CONFIG); + } + + close (ev) { + ev.preventDefault(); + this.model.close(); + } + + destroy (ev) { + ev.preventDefault(); + destroyMUC(this.model); + } + + /** + * Returns a list of objects which represent buttons for the groupchat header. + * @emits _converse#getHeadingButtons + */ + getHeadingButtons (subject_hidden) { + const buttons = []; + buttons.push({ + 'i18n_text': __('Details'), + 'i18n_title': __('Show more information about this groupchat'), + 'handler': ev => this.showRoomDetailsModal(ev), + 'a_class': 'show-muc-details-modal', + 'icon_class': 'fa-info-circle', + 'name': 'details' + }); + + if (this.model.getOwnAffiliation() === 'owner') { + buttons.push({ + 'i18n_text': __('Configure'), + 'i18n_title': __('Configure this groupchat'), + 'handler': () => this.getAndRenderConfigurationForm(), + 'a_class': 'configure-chatroom-button', + 'icon_class': 'fa-wrench', + 'name': 'configure' + }); + } + + buttons.push({ + 'i18n_text': __('Nickname'), + 'i18n_title': __("Change the nickname you're using in this groupchat"), + 'handler': ev => api.modal.show('converse-muc-nickname-modal', { 'model': this.model }, ev), + 'a_class': 'open-nickname-modal', + 'icon_class': 'fa-smile', + 'name': 'nickname' + }); + + if (this.model.invitesAllowed()) { + buttons.push({ + 'i18n_text': __('Invite'), + 'i18n_title': __('Invite someone to join this groupchat'), + 'handler': ev => this.showInviteModal(ev), + 'a_class': 'open-invite-modal', + 'icon_class': 'fa-user-plus', + 'name': 'invite' + }); + } + + const subject = this.model.get('subject'); + if (subject && subject.text) { + buttons.push({ + 'i18n_text': subject_hidden ? __('Show topic') : __('Hide topic'), + 'i18n_title': subject_hidden + ? __('Show the topic message in the heading') + : __('Hide the topic in the heading'), + 'handler': ev => this.toggleTopic(ev), + 'a_class': 'hide-topic', + 'icon_class': 'fa-minus-square', + 'name': 'toggle-topic' + }); + } + + const conn_status = this.model.session.get('connection_status'); + if (conn_status === converse.ROOMSTATUS.ENTERED) { + const allowed_commands = this.model.getAllowedCommands(); + if (allowed_commands.includes('modtools')) { + buttons.push({ + 'i18n_text': __('Moderate'), + 'i18n_title': __('Moderate this groupchat'), + 'handler': () => showModeratorToolsModal(this.model), + 'a_class': 'moderate-chatroom-button', + 'icon_class': 'fa-user-cog', + 'name': 'moderate' + }); + } + if (allowed_commands.includes('destroy')) { + buttons.push({ + 'i18n_text': __('Destroy'), + 'i18n_title': __('Remove this groupchat'), + 'handler': ev => this.destroy(ev), + 'a_class': 'destroy-chatroom-button', + 'icon_class': 'fa-trash', + 'name': 'destroy' + }); + } + } + + if (!api.settings.get('singleton')) { + buttons.push({ + 'i18n_text': __('Leave'), + 'i18n_title': __('Leave and close this groupchat'), + 'handler': async ev => { + ev.stopPropagation(); + const messages = [__('Are you sure you want to leave this groupchat?')]; + const result = await api.confirm(__('Confirm'), messages); + result && this.close(ev); + }, + 'a_class': 'close-chatbox-button', + 'standalone': api.settings.get('view_mode') === 'overlayed', + 'icon_class': 'fa-sign-out-alt', + 'name': 'signout' + }); + } + + const el = _converse.chatboxviews.get(this.getAttribute('jid')); + if (el) { + // This hook is described in src/plugins/chatview/heading.js + return _converse.api.hook('getHeadingButtons', el, buttons); + } else { + return Promise.resolve(buttons); // Happens during tests + } + } +} + +api.elements.define('converse-muc-heading', MUCHeading); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js new file mode 100644 index 0000000..d7543c1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/index.js @@ -0,0 +1,97 @@ +/** + * @copyright The Converse.js developers + * @description XEP-0045 Multi-User Chat Views + * @license Mozilla Public License (MPLv2) + */ +import '../chatboxviews/index.js'; +import './affiliation-form.js'; +import './role-form.js'; +import MUCView from './muc.js'; +import { api, converse } from '@converse/headless/core.js'; +import { clearHistory, confirmDirectMUCInvitation, parseMessageForMUCCommands } from './utils.js'; + +const { Strophe } = converse.env; + +import './styles/index.scss'; + +converse.MUC.VIEWS = { + CONFIG: 'config-form', +} + +converse.plugins.add('converse-muc-views', { + /* Dependencies are other plugins which might be + * overridden or relied upon, and therefore need to be loaded before + * this plugin. They are "optional" because they might not be + * available, in which case any overrides applicable to them will be + * ignored. + * + * NB: These plugins need to have already been loaded via require.js. + * + * It's possible to make these dependencies "non-optional". + * If the setting "strict_plugin_dependencies" is set to true, + * an error will be raised if the plugin is not found. + */ + dependencies: ['converse-modal', 'converse-controlbox', 'converse-chatview'], + + initialize () { + const { _converse } = this; + + // Configuration values for this plugin + // ==================================== + // Refer to docs/source/configuration.rst for explanations of these + // configuration settings. + api.settings.extend({ + 'auto_list_rooms': false, + 'cache_muc_messages': true, + 'locked_muc_nickname': false, + 'modtools_disable_query': [], + 'muc_disable_slash_commands': false, + 'muc_mention_autocomplete_filter': 'contains', + 'muc_mention_autocomplete_min_chars': 0, + 'muc_mention_autocomplete_show_avatar': true, + 'muc_roomid_policy': null, + 'muc_roomid_policy_hint': null, + 'roomconfig_whitelist': [], + 'show_retraction_warning': true, + 'visible_toolbar_buttons': { + 'toggle_occupants': true + } + }); + + _converse.ChatRoomView = MUCView; + + if (!api.settings.get('muc_domain')) { + // Use service discovery to get the default MUC domain + api.listen.on('serviceDiscovered', async (feature) => { + if (feature?.get('var') === Strophe.NS.MUC) { + if (feature.entity.get('jid').includes('@')) { + // Ignore full JIDs, we're only looking for a MUC service, not a room + return; + } + const identity = await feature.entity.getIdentity('conference', 'text'); + if (identity) { + api.settings.set('muc_domain', Strophe.getDomainFromJid(feature.get('from'))); + } + } + }); + } + + api.listen.on('clearsession', () => { + const view = _converse.chatboxviews.get('controlbox'); + if (view && view.roomspanel) { + view.roomspanel.model.destroy(); + view.roomspanel.remove(); + delete view.roomspanel; + } + }); + + api.listen.on('chatBoxClosed', (model) => { + if (model.get('type') === _converse.CHATROOMS_TYPE) { + clearHistory(model.get('jid')); + } + }); + + api.listen.on('parseMessageForCommands', parseMessageForMUCCommands); + api.listen.on('confirmDirectMUCInvitation', confirmDirectMUCInvitation); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js new file mode 100644 index 0000000..3561414 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/message-form.js @@ -0,0 +1,72 @@ +import MessageForm from 'plugins/chatview/message-form.js'; +import tplMUCMessageForm from './templates/message-form.js'; +import { _converse, api, converse } from "@converse/headless/core"; +import { getAutoCompleteListItem } from './utils.js'; + + +export default class MUCMessageForm extends MessageForm { + + async connectedCallback () { + super.connectedCallback(); + await this.model.initialized; + } + + toHTML () { + return tplMUCMessageForm( + Object.assign(this.model.toJSON(), { + 'hint_value': this.querySelector('.spoiler-hint')?.value, + 'message_value': this.querySelector('.chat-textarea')?.value, + 'onChange': ev => this.model.set({'draft': ev.target.value}), + 'onDrop': ev => this.onDrop(ev), + 'onKeyDown': ev => this.onKeyDown(ev), + 'onKeyUp': ev => this.onKeyUp(ev), + 'onPaste': ev => this.onPaste(ev), + 'scrolled': this.model.ui.get('scrolled'), + 'viewUnreadMessages': ev => this.viewUnreadMessages(ev) + })); + } + + afterRender () { + const entered = this.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED; + const can_edit = entered && !(this.model.features.get('moderated') && this.model.getOwnRole() === 'visitor'); + if (entered && can_edit) { + this.initMentionAutoComplete(); + } + } + + initMentionAutoComplete () { + this.mention_auto_complete = new _converse.AutoComplete(this, { + 'auto_first': true, + 'auto_evaluate': false, + 'min_chars': api.settings.get('muc_mention_autocomplete_min_chars'), + 'match_current_word': true, + 'list': () => this.getAutoCompleteList(), + 'filter': + api.settings.get('muc_mention_autocomplete_filter') == 'contains' + ? _converse.FILTER_CONTAINS + : _converse.FILTER_STARTSWITH, + 'ac_triggers': ['Tab', '@'], + 'include_triggers': [], + 'item': getAutoCompleteListItem + }); + this.mention_auto_complete.on('suggestion-box-selectcomplete', () => (this.auto_completing = false)); + } + + getAutoCompleteList () { + return this.model.getAllKnownNicknames().map(nick => ({ 'label': nick, 'value': `@${nick}` })); + } + + onKeyDown (ev) { + if (this.mention_auto_complete.onKeyDown(ev)) { + return; + } + super.onKeyDown(ev); + } + + onKeyUp (ev) { + this.mention_auto_complete.evaluate(ev); + super.onKeyUp(ev); + } +} + +api.elements.define('converse-muc-message-form', MUCMessageForm); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js new file mode 100644 index 0000000..0cc94bf --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/add-muc.js @@ -0,0 +1,92 @@ +import tplAddMuc from "./templates/add-muc.js"; +import BaseModal from "plugins/modal/modal.js"; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; + +import '../styles/add-muc-modal.scss'; + +const u = converse.env.utils; +const { Strophe } = converse.env; + + +export default class AddMUCModal extends BaseModal { + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change:muc_domain', () => this.render()); + this.muc_roomid_policy_error_msg = null; + this.render(); + this.addEventListener('shown.bs.modal', () => { + this.querySelector('input[name="chatroom"]').focus(); + }, false); + } + + renderModal () { + return tplAddMuc(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Enter a new Groupchat'); + } + + parseRoomDataFromEvent (form) { // eslint-disable-line class-methods-use-this + const data = new FormData(form); + const jid = data.get('chatroom')?.trim(); + let nick; + if (api.settings.get('locked_muc_nickname')) { + nick = _converse.getDefaultMUCNickname(); + if (!nick) { + throw new Error("Using locked_muc_nickname but no nickname found!"); + } + } else { + nick = data.get('nickname').trim(); + } + return { + 'jid': jid, + 'nick': nick + } + } + + openChatRoom (ev) { + ev.preventDefault(); + if (this.checkRoomidPolicy()) return; + + const data = this.parseRoomDataFromEvent(ev.target); + if (data.nick === "") { + // Make sure defaults apply if no nick is provided. + data.nick = undefined; + } + let jid; + if (api.settings.get('locked_muc_domain') || (api.settings.get('muc_domain') && !u.isValidJID(data.jid))) { + jid = `${Strophe.escapeNode(data.jid)}@${api.settings.get('muc_domain')}`; + } else { + jid = data.jid + this.model.setDomain(jid); + } + + api.rooms.open(jid, Object.assign(data, {jid}), true); + ev.target.reset(); + this.modal.hide(); + } + + checkRoomidPolicy () { + if (api.settings.get('muc_roomid_policy') && api.settings.get('muc_domain')) { + let jid = this.querySelector('converse-autocomplete input').value; + if (api.settings.get('locked_muc_domain') || !u.isValidJID(jid)) { + jid = `${Strophe.escapeNode(jid)}@${api.settings.get('muc_domain')}`; + } + const roomid = Strophe.getNodeFromJid(jid); + const roomdomain = Strophe.getDomainFromJid(jid); + if (api.settings.get('muc_domain') !== roomdomain || + api.settings.get('muc_roomid_policy').test(roomid)) { + this.muc_roomid_policy_error_msg = null; + } else { + this.muc_roomid_policy_error_msg = __('Groupchat id is invalid.'); + return true; + } + this.render(); + } + } +} + +api.elements.define('converse-add-muc-modal', AddMUCModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js new file mode 100644 index 0000000..25b12ff --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/moderator-tools.js @@ -0,0 +1,24 @@ +import '../modtools.js'; +import BaseModal from "plugins/modal/modal.js"; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; +import { html } from 'lit'; + +export default class ModeratorToolsModal extends BaseModal { + + constructor (options) { + super(options); + this.id = "converse-modtools-modal"; + } + + renderModal () { + return html`<converse-modtools jid=${this.jid} affiliation=${this.affiliation}></converse-modtools>`; + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Moderator Tools'); + } + +} + +api.elements.define('converse-modtools-modal', ModeratorToolsModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js new file mode 100644 index 0000000..a180232 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-details.js @@ -0,0 +1,29 @@ +import BaseModal from "plugins/modal/modal.js"; +import tplMUCDetails from "./templates/muc-details.js"; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; + +import '../styles/muc-details-modal.scss'; + + +export default class MUCDetailsModal extends BaseModal { + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change', () => this.render()); + this.listenTo(this.model.features, 'change', () => this.render()); + this.listenTo(this.model.occupants, 'add', () => this.render()); + this.listenTo(this.model.occupants, 'change', () => this.render()); + } + + renderModal () { + return tplMUCDetails(this.model); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Groupchat info for %1$s', this.model.getDisplayName()); + } + +} + +api.elements.define('converse-muc-details-modal', MUCDetailsModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js new file mode 100644 index 0000000..944391f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-invite.js @@ -0,0 +1,44 @@ +import 'shared/autocomplete/index.js'; +import BaseModal from "plugins/modal/modal.js"; +import tplMUCInviteModal from "./templates/muc-invite.js"; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; + +const u = converse.env.utils; + +export default class MUCInviteModal extends BaseModal { + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change', () => this.render()); + } + + renderModal () { + return tplMUCInviteModal(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Invite someone to this groupchat'); + } + + getAutoCompleteList () { // eslint-disable-line class-methods-use-this + return _converse.roster.map(i => ({'label': i.getDisplayName(), 'value': i.get('jid')})); + } + + submitInviteForm (ev) { + ev.preventDefault(); + // TODO: Add support for sending an invite to multiple JIDs + const data = new FormData(ev.target); + const jid = data.get('invitee_jids')?.trim(); + const reason = data.get('reason'); + if (u.isValidJID(jid)) { + // TODO: Create and use API here + this.chatroomview.model.directInvite(jid, reason); + this.modal.hide(); + } else { + this.model.set({'invalid_invite_jid': true}); + } + } +} + +api.elements.define('converse-muc-invite-modal', MUCInviteModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js new file mode 100644 index 0000000..b257f4b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/muc-list.js @@ -0,0 +1,177 @@ +import BaseModal from "plugins/modal/modal.js"; +import head from "lodash-es/head"; +import log from "@converse/headless/log"; +import tplMUCDescription from "../templates/muc-description.js"; +import tplMUCList from "../templates/muc-list.js"; +import tplSpinner from "templates/spinner.js"; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; +import { getAttributes } from '@converse/headless/shared/parsers'; + +const { Strophe, $iq, sizzle } = converse.env; +const u = converse.env.utils; + + +/* Insert groupchat info (based on returned #disco IQ stanza) + * @function insertRoomInfo + * @param { HTMLElement } el - The HTML DOM element that contains the info. + * @param { Element } stanza - The IQ stanza containing the groupchat info. + */ +function insertRoomInfo (el, stanza) { + // All MUC features found here: https://xmpp.org/registrar/disco-features.html + el.querySelector('span.spinner').remove(); + el.querySelector('a.room-info').classList.add('selected'); + el.insertAdjacentHTML( + 'beforeEnd', + u.getElementFromTemplateResult(tplMUCDescription({ + 'jid': stanza.getAttribute('from'), + 'desc': head(sizzle('field[var="muc#roominfo_description"] value', stanza))?.textContent, + 'occ': head(sizzle('field[var="muc#roominfo_occupants"] value', stanza))?.textContent, + 'hidden': sizzle('feature[var="muc_hidden"]', stanza).length, + 'membersonly': sizzle('feature[var="muc_membersonly"]', stanza).length, + 'moderated': sizzle('feature[var="muc_moderated"]', stanza).length, + 'nonanonymous': sizzle('feature[var="muc_nonanonymous"]', stanza).length, + 'open': sizzle('feature[var="muc_open"]', stanza).length, + 'passwordprotected': sizzle('feature[var="muc_passwordprotected"]', stanza).length, + 'persistent': sizzle('feature[var="muc_persistent"]', stanza).length, + 'publicroom': sizzle('feature[var="muc_publicroom"]', stanza).length, + 'semianonymous': sizzle('feature[var="muc_semianonymous"]', stanza).length, + 'temporary': sizzle('feature[var="muc_temporary"]', stanza).length, + 'unmoderated': sizzle('feature[var="muc_unmoderated"]', stanza).length + }))); +} + + +/** + * Show/hide extra information about a groupchat in a listing. + * @function toggleRoomInfo + * @param { Event } + */ +function toggleRoomInfo (ev) { + const parent_el = u.ancestor(ev.target, '.room-item'); + const div_el = parent_el.querySelector('div.room-info'); + if (div_el) { + u.slideIn(div_el).then(u.removeElement) + parent_el.querySelector('a.room-info').classList.remove('selected'); + } else { + parent_el.insertAdjacentElement( + 'beforeend', + u.getElementFromTemplateResult(tplSpinner()) + ); + api.disco.info(ev.target.getAttribute('data-room-jid'), null) + .then(stanza => insertRoomInfo(parent_el, stanza)) + .catch(e => log.error(e)); + } +} + + +export default class MUCListModal extends BaseModal { + + constructor (options) { + super(options); + this.items = []; + this.loading_items = false; + } + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change:muc_domain', this.onDomainChange); + this.listenTo(this.model, 'change:feedback_text', () => this.render()); + + this.addEventListener('shown.bs.modal', () => api.settings.get('locked_muc_domain') && this.updateRoomsList()); + + this.model.save('feedback_text', ''); + } + + renderModal () { + return tplMUCList( + Object.assign(this.model.toJSON(), { + 'show_form': !api.settings.get('locked_muc_domain'), + 'server_placeholder': this.model.get('muc_domain') || __('conference.example.org'), + 'items': this.items, + 'loading_items': this.loading_items, + 'openRoom': ev => this.openRoom(ev), + 'setDomainFromEvent': ev => this.setDomainFromEvent(ev), + 'submitForm': ev => this.showRooms(ev), + 'toggleRoomInfo': ev => this.toggleRoomInfo(ev) + })); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Query for Groupchats'); + } + + openRoom (ev) { + ev.preventDefault(); + const jid = ev.target.getAttribute('data-room-jid'); + const name = ev.target.getAttribute('data-room-name'); + this.modal.hide(); + api.rooms.open(jid, {'name': name}, true); + } + + toggleRoomInfo (ev) { // eslint-disable-line + ev.preventDefault(); + toggleRoomInfo(ev); + } + + onDomainChange () { + api.settings.get('auto_list_rooms') && this.updateRoomsList(); + } + + /** + * Handle the IQ stanza returned from the server, containing + * all its public groupchats. + * @private + * @method _converse.ChatRoomView#onRoomsFound + * @param { HTMLElement } iq + */ + onRoomsFound (iq) { + this.loading_items = false; + const rooms = iq ? sizzle('query item', iq) : []; + if (rooms.length) { + this.model.set({'feedback_text': __('Groupchats found')}, {'silent': true}); + this.items = rooms.map(getAttributes); + } else { + this.items = []; + this.model.set({'feedback_text': __('No groupchats found')}, {'silent': true}); + } + this.render(); + return true; + } + + /** + * Send an IQ stanza to the server asking for all groupchats + * @private + * @method _converse.ChatRoomView#updateRoomsList + */ + updateRoomsList () { + const iq = $iq({ + 'to': this.model.get('muc_domain'), + 'from': _converse.connection.jid, + 'type': "get" + }).c("query", {xmlns: Strophe.NS.DISCO_ITEMS}); + api.sendIQ(iq) + .then(iq => this.onRoomsFound(iq)) + .catch(() => this.onRoomsFound()) + } + + showRooms (ev) { + ev.preventDefault(); + this.loading_items = true; + this.render(); + + const data = new FormData(ev.target); + this.model.setDomain(data.get('server')); + this.updateRoomsList(); + } + + setDomainFromEvent (ev) { + this.model.setDomain(ev.target.value); + } + + setNick (ev) { + this.model.save({nick: ev.target.value}); + } +} + +api.elements.define('converse-muc-list-modal', MUCListModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js new file mode 100644 index 0000000..3284e1e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/nickname.js @@ -0,0 +1,17 @@ +import BaseModal from "plugins/modal/modal.js"; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core.js"; +import { html } from 'lit'; + +export default class MUCNicknameModal extends BaseModal { + + renderModal () { + return html`<converse-muc-nickname-form jid="${this.model.get('jid')}"></converse-muc-nickname-form>`; + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Change your nickname'); + } +} + +api.elements.define('converse-muc-nickname-modal', MUCNicknameModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js new file mode 100644 index 0000000..65988e9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/occupant.js @@ -0,0 +1,67 @@ +import BaseModal from "plugins/modal/modal.js"; +import tplOccupantModal from "./templates/occupant.js"; +import { Model } from '@converse/skeletor/src/model.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; + +const { u } = converse.env; + +export default class OccupantModal extends BaseModal { + + constructor () { + super(); + this.addEventListener("affiliationChanged", () => this.alert(__('Affiliation changed'))); + this.addEventListener("roleChanged", () => this.alert(__('role changed'))); + } + + initialize () { + super.initialize() + const model = this.model ?? this.message; + this.listenTo(model, 'change', () => this.render()); + /** + * Triggered once the OccupantModal has been initialized + * @event _converse#occupantModalInitialized + * @type { Object } + * @example _converse.api.listen.on('occupantModalInitialized', data); + */ + api.trigger('occupantModalInitialized', { 'model': this.model, 'message': this.message }); + } + + getVcard () { + const model = this.model ?? this.message; + if (model.vcard) { + return model.vcard; + } + const jid = model?.get('jid') || model?.get('from'); + return jid ? _converse.vcards.get(jid) : null; + } + + renderModal () { + return tplOccupantModal(this); + } + + getModalTitle () { + const model = this.model ?? this.message; + return model?.getDisplayName(); + } + + addToContacts () { + const model = this.model ?? this.message; + const jid = model.get('jid'); + if (jid) api.modal.show('converse-add-contact-modal', {'model': new Model({ jid })}); + } + + toggleForm (ev) { + const toggle = u.ancestor(ev.target, '.toggle-form'); + const form = toggle.getAttribute('data-form'); + + if (form === 'row-form') { + this.show_role_form = !this.show_role_form; + } else { + this.show_affiliation_form = !this.show_affiliation_form; + } + this.render(); + } +} + +api.elements.define('converse-muc-occupant-modal', OccupantModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js new file mode 100644 index 0000000..2469707 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/add-muc.js @@ -0,0 +1,57 @@ +import DOMPurify from 'dompurify'; +import { __ } from 'i18n'; +import { api } from '@converse/headless/core.js'; +import { html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { getAutoCompleteList } from "../../search.js"; + + +const nickname_input = (el) => { + const i18n_nickname = __('Nickname'); + const i18n_required_field = __('This field is required'); + return html` + <div class="form-group" > + <label for="nickname">${i18n_nickname}:</label> + <input type="text" + title="${i18n_required_field}" + required="required" + name="nickname" + value="${el.model.get('nick') || ''}" + class="form-control"/> + </div> + `; +} + +export default (el) => { + const i18n_join = __('Join'); + const muc_domain = el.model.get('muc_domain') || api.settings.get('muc_domain'); + + let placeholder = ''; + if (!api.settings.get('locked_muc_domain')) { + placeholder = muc_domain ? `name@${muc_domain}` : __('name@conference.example.org'); + } + + const label_room_address = muc_domain ? __('Groupchat name') : __('Groupchat address'); + const muc_roomid_policy_error_msg = el.muc_roomid_policy_error_msg; + const muc_roomid_policy_hint = api.settings.get('muc_roomid_policy_hint'); + return html` + <form class="converse-form add-chatroom" @submit=${(ev) => el.openChatRoom(ev)}> + <div class="form-group"> + <label for="chatroom">${label_room_address}:</label> + ${ (muc_roomid_policy_error_msg) ? html`<label class="roomid-policy-error">${muc_roomid_policy_error_msg}</label>` : '' } + <converse-autocomplete + .getAutoCompleteList=${getAutoCompleteList} + ?autofocus=${true} + min_chars="3" + position="below" + placeholder="${placeholder}" + class="add-muc-autocomplete" + name="chatroom"> + </converse-autocomplete> + </div> + ${ muc_roomid_policy_hint ? html`<div class="form-group">${unsafeHTML(DOMPurify.sanitize(muc_roomid_policy_hint, {'ALLOWED_TAGS': ['b', 'br', 'em']}))}</div>` : '' } + ${ !api.settings.get('locked_muc_nickname') ? nickname_input(el) : '' } + <input type="submit" class="btn btn-primary" name="join" value="${i18n_join || ''}" ?disabled=${muc_roomid_policy_error_msg}/> + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js new file mode 100644 index 0000000..ed5ca67 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-details.js @@ -0,0 +1,78 @@ +import { __ } from 'i18n'; +import { html } from "lit"; + + +const subject = (o) => { + const i18n_topic = __('Topic'); + const i18n_topic_author = __('Topic author'); + return html` + <p class="room-info"><strong>${i18n_topic}</strong>: <converse-rich-text text=${o.subject.text} render_styling></converse-rich-text></p> + <p class="room-info"><strong>${i18n_topic_author}</strong>: ${o.subject && o.subject.author}</p> + `; +} + + +export default (model) => { + const o = model.toJSON(); + const config = model.config.toJSON(); + const features = model.features.toJSON(); + const num_occupants = model.occupants.filter(o => o.get('show') !== 'offline').length; + + const i18n_address = __('XMPP address'); + const i18n_archiving = __('Message archiving'); + const i18n_archiving_help = __('Messages are archived on the server'); + const i18n_desc = __('Description'); + const i18n_features = __('Features'); + const i18n_hidden = __('Hidden'); + const i18n_hidden_help = __('This groupchat is not publicly searchable'); + const i18n_members_help = __('This groupchat is restricted to members only'); + const i18n_members_only = __('Members only'); + const i18n_moderated = __('Moderated'); + const i18n_moderated_help = __('Participants entering this groupchat need to request permission to write'); + const i18n_name = __('Name'); + const i18n_no_pass_help = __('This groupchat does not require a password upon entry'); + const i18n_no_password_required = __('No password required'); + const i18n_not_anonymous = __('Not anonymous'); + const i18n_not_anonymous_help = __('All other groupchat participants can see your XMPP address'); + const i18n_not_moderated = __('Not moderated'); + const i18n_not_moderated_help = __('Participants entering this groupchat can write right away'); + const i18n_online_users = __('Online users'); + const i18n_open = __('Open'); + const i18n_open_help = __('Anyone can join this groupchat'); + const i18n_password_help = __('This groupchat requires a password before entry'); + const i18n_password_protected = __('Password protected'); + const i18n_persistent = __('Persistent'); + const i18n_persistent_help = __('This groupchat persists even if it\'s unoccupied'); + const i18n_public = __('Public'); + const i18n_semi_anon = __('Semi-anonymous'); + const i18n_semi_anon_help = __('Only moderators can see your XMPP address'); + const i18n_temporary = __('Temporary'); + const i18n_temporary_help = __('This groupchat will disappear once the last person leaves'); + return html` + <div class="room-info"> + <p class="room-info"><strong>${i18n_name}</strong>: ${o.name}</p> + <p class="room-info"><strong>${i18n_address}</strong>: <converse-rich-text text="xmpp:${o.jid}?join"></converse-rich-text></p> + <p class="room-info"><strong>${i18n_desc}</strong>: <converse-rich-text text="${config.description}" render_styling></converse-rich-text></p> + ${ (o.subject) ? subject(o) : '' } + <p class="room-info"><strong>${i18n_online_users}</strong>: ${num_occupants}</p> + <p class="room-info"><strong>${i18n_features}</strong>: + <div class="chatroom-features"> + <ul class="features-list"> + ${ features.passwordprotected ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-lock"></converse-icon>${i18n_password_protected} - <em>${i18n_password_help}</em></li>` : '' } + ${ features.unsecured ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-unlock"></converse-icon>${i18n_no_password_required} - <em>${i18n_no_pass_help}</em></li>` : '' } + ${ features.hidden ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye-slash"></converse-icon>${i18n_hidden} - <em>${i18n_hidden_help}</em></li>` : '' } + ${ features.public_room ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-eye"></converse-icon>${i18n_public} - <em>${o.__('This groupchat is publicly searchable') }</em></li>` : '' } + ${ features.membersonly ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-address-book"></converse-icon>${i18n_members_only} - <em>${i18n_members_help}</em></li>` : '' } + ${ features.open ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-globe"></converse-icon>${i18n_open} - <em>${i18n_open_help}</em></li>` : '' } + ${ features.persistent ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-save"></converse-icon>${i18n_persistent} - <em>${i18n_persistent_help}</em></li>` : '' } + ${ features.temporary ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-snowflake"></converse-icon>${i18n_temporary} - <em>${i18n_temporary_help}</em></li>` : '' } + ${ features.nonanonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-id-card"></converse-icon>${i18n_not_anonymous} - <em>${i18n_not_anonymous_help}</em></li>` : '' } + ${ features.semianonymous ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-user-secret"></converse-icon>${i18n_semi_anon} - <em>${i18n_semi_anon_help}</em></li>` : '' } + ${ features.moderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-gavel"></converse-icon>${i18n_moderated} - <em>${i18n_moderated_help}</em></li>` : '' } + ${ features.unmoderated ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-info-circle"></converse-icon>${i18n_not_moderated} - <em>${i18n_not_moderated_help}</em></li>` : '' } + ${ features.mam_enabled ? html`<li class="feature" ><converse-icon size="1em" class="fa fa-database"></converse-icon>${i18n_archiving} - <em>${i18n_archiving_help}</em></li>` : '' } + </ul> + </div> + </p> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js new file mode 100644 index 0000000..78e64ae --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/muc-invite.js @@ -0,0 +1,35 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + +export default (el) => { + const i18n_invite = __('Invite'); + const i18n_jid_placeholder = __('user@example.org'); + const i18n_error_message = __('Please enter a valid XMPP address'); + const i18n_invite_label = __('XMPP Address'); + const i18n_reason = __('Optional reason for the invitation'); + return html` + <form class="converse-form" @submit=${(ev) => el.submitInviteForm(ev)}> + <div class="form-group"> + <label class="clearfix" for="invitee_jids">${i18n_invite_label}:</label> + ${ el.model.get('invalid_invite_jid') ? html`<div class="error error-feedback">${i18n_error_message}</div>` : '' } + <converse-autocomplete + .getAutoCompleteList=${() => el.getAutoCompleteList()} + ?autofocus=${true} + min_chars="1" + position="below" + required="required" + name="invitee_jids" + id="invitee_jids" + placeholder="${i18n_jid_placeholder}"> + </converse-autocomplete> + </div> + <div class="form-group"> + <label>${i18n_reason}:</label> + <textarea class="form-control" name="reason"></textarea> + </div> + <div class="form-group"> + <input type="submit" class="btn btn-primary" value="${i18n_invite}"/> + </div> + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js new file mode 100644 index 0000000..3ccc87a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modals/templates/occupant.js @@ -0,0 +1,87 @@ +import 'shared/avatar/avatar.js'; +import { __ } from 'i18n'; +import { html } from "lit"; +import { until } from 'lit/directives/until.js'; +import { _converse, api } from "@converse/headless/core"; + + +export default (el) => { + const model = el.model ?? el.message; + const jid = model?.get('jid'); + const vcard = el.getVcard(); + const nick = model.get('nick'); + const occupant_id = model.get('occupant_id'); + const role = el.model?.get('role'); + const affiliation = el.model?.get('affiliation'); + const hats = el.model?.get('hats')?.length ? el.model.get('hats') : null; + const muc = el.model.collection.chatroom; + + const allowed_commands = muc.getAllowedCommands(); + const may_moderate = allowed_commands.includes('modtools'); + + const i18n_add_to_contacts = __('Add to Contacts'); + + const can_see_real_jids = muc.features.get('nonanonymous') || muc.getOwnRole() === 'moderator'; + const not_me = jid != _converse.bare_jid; + + const add_to_contacts = api.contacts.get(jid) + .then(contact => !contact && not_me && can_see_real_jids) + .then(add => add ? html`<li><button class="btn btn-primary" type="button" @click=${() => el.addToContacts()}>${i18n_add_to_contacts}</button></li>` : ''); + + return html` + <div class="row"> + <div class="col-auto"> + <converse-avatar + class="avatar modal-avatar" + .data=${vcard?.attributes} + nonce=${vcard?.get('vcard_updated')} + height="120" width="120"></converse-avatar> + </div> + <div class="col"> + <ul class="occupant-details"> + <li> + ${ nick ? html`<div class="row"><strong>${__('Nickname')}:</strong></div><div class="row">${nick}</div>` : '' } + </li> + <li> + ${ jid ? html`<div class="row"><strong>${__('XMPP Address')}:</strong></div><div class="row">${jid}</div>` : '' } + </li> + <li> + <div class="row"><strong>${__('Affiliation')}:</strong></div> + <div class="row">${affiliation} + ${ may_moderate ? html` + <a href="#" + data-form="affiliation-form" + class="toggle-form right" + color="var(--subdued-color)" + @click=${(ev) => el.toggleForm(ev)}><converse-icon class="fa fa-wrench" size="1em"></converse-icon> + </a> + ${ el.show_affiliation_form ? html`<converse-muc-affiliation-form jid=${jid} .muc=${muc} affiliation=${affiliation}></converse-muc-affiliation-form>` : '' }` : '' + } + </div> + </li> + <li> + <div class="row"><strong>${__('Role')}:</strong></div> + <div class="row">${role} + ${ may_moderate && role ? html` + <a href="#" + data-form="row-form" + class="toggle-form right" + color="var(--subdued-color)" + @click=${(ev) => el.toggleForm(ev)}><converse-icon class="fa fa-wrench" size="1em"></converse-icon> + </a> + ${ el.show_role_form ? html`<converse-muc-role-form jid=${jid} .muc=${muc} role=${role}></converse-muc-role-form>` : '' }` : '' + } + </div> + </li> + <li> + ${ hats ? html`<div class="row"><strong>${__('Hats')}:</strong></div><div class="row">${hats}</div>` : '' } + </li> + <li> + ${ occupant_id ? html`<div class="row"><strong>${__('Occupant Id')}:</strong></div><div class="row">${occupant_id}</div>` : '' } + </li> + ${ until(add_to_contacts, '') } + </ul> + </div> + </div> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js new file mode 100644 index 0000000..b9b6377 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/modtools.js @@ -0,0 +1,200 @@ +import tplModeratorTools from './templates/moderator-tools.js'; +import { AFFILIATIONS, ROLES } from '@converse/headless/plugins/muc/constants.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { api, converse } from '@converse/headless/core.js'; +import { getAffiliationList, getAssignableAffiliations } from '@converse/headless/plugins/muc/affiliations/utils.js'; +import { getAssignableRoles, getAutoFetchedAffiliationLists } from '@converse/headless/plugins/muc/utils.js'; +import { getOpenPromise } from '@converse/openpromise'; + +import './styles/moderator-tools.scss'; + +const { u } = converse.env; + +export default class ModeratorTools extends CustomElement { + static get properties () { + return { + affiliation: { type: String }, + affiliations_filter: { type: String, attribute: false }, + alert_message: { type: String, attribute: false }, + alert_type: { type: String, attribute: false }, + jid: { type: String }, + muc: { type: Object, attribute: false }, + role: { type: String }, + roles_filter: { type: String, attribute: false }, + tab: { type: String }, + users_with_affiliation: { type: Array, attribute: false }, + users_with_role: { type: Array, attribute: false }, + }; + } + + constructor () { + super(); + this.tab = 'affiliations'; + this.affiliation = ''; + this.affiliations_filter = ''; + this.role = ''; + this.roles_filter = ''; + + this.addEventListener("affiliationChanged", () => { + this.alert(__('Affiliation changed'), 'primary'); + this.onSearchAffiliationChange(); + this.requestUpdate() + }); + + this.addEventListener("roleChanged", () => { + this.alert(__('Role changed'), 'primary'); + this.requestUpdate() + }); + } + + updated (changed) { + changed.has('role') && this.onSearchRoleChange(); + changed.has('affiliation') && this.onSearchAffiliationChange(); + changed.has('jid') && changed.get('jid') && this.initialize(); + } + + async initialize () { + this.initialized = getOpenPromise(); + const muc = await api.rooms.get(this.jid); + await muc.initialized; + this.muc = muc; + this.initialized.resolve(); + } + + render () { + if (this.muc?.occupants) { + const occupant = this.muc.occupants.getOwnOccupant(); + return tplModeratorTools(this, { + 'affiliations_filter': this.affiliations_filter, + 'alert_message': this.alert_message, + 'alert_type': this.alert_type, + 'assignRole': ev => this.assignRole(ev), + 'assignable_affiliations': getAssignableAffiliations(occupant), + 'assignable_roles': getAssignableRoles(occupant), + 'filterAffiliationResults': ev => this.filterAffiliationResults(ev), + 'filterRoleResults': ev => this.filterRoleResults(ev), + 'loading_users_with_affiliation': this.loading_users_with_affiliation, + 'queryAffiliation': ev => this.queryAffiliation(ev), + 'queryRole': ev => this.queryRole(ev), + 'queryable_affiliations': AFFILIATIONS.filter( + a => !api.settings.get('modtools_disable_query').includes(a) + ), + 'queryable_roles': ROLES.filter(a => !api.settings.get('modtools_disable_query').includes(a)), + 'roles_filter': this.roles_filter, + 'switchTab': ev => this.switchTab(ev), + 'tab': this.tab, + 'toggleForm': ev => this.toggleForm(ev), + 'users_with_affiliation': this.users_with_affiliation, + 'users_with_role': this.users_with_role, + }); + } else { + return ''; + } + } + + switchTab (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.tab = ev.target.getAttribute('data-name'); + this.requestUpdate(); + } + + async onSearchAffiliationChange () { + if (!this.affiliation) return; + + await this.initialized; + this.clearAlert(); + this.loading_users_with_affiliation = true; + this.users_with_affiliation = null; + + if (this.shouldFetchAffiliationsList()) { + const result = await getAffiliationList(this.affiliation, this.jid); + if (result instanceof Error) { + this.alert(result.message, 'danger'); + this.users_with_affiliation = []; + } else { + this.users_with_affiliation = result; + } + } else { + this.users_with_affiliation = this.muc.getOccupantsWithAffiliation(this.affiliation); + } + this.loading_users_with_affiliation = false; + } + + async onSearchRoleChange () { + if (!this.role) { + return; + } + await this.initialized; + this.clearAlert(); + this.users_with_role = this.muc.getOccupantsWithRole(this.role); + } + + shouldFetchAffiliationsList () { + const affiliation = this.affiliation; + if (affiliation === 'none') { + return false; + } + const auto_fetched_affs = getAutoFetchedAffiliationLists(); + if (auto_fetched_affs.includes(affiliation)) { + return false; + } else { + return true; + } + } + + // eslint-disable-next-line class-methods-use-this + toggleForm (ev) { + ev.stopPropagation(); + ev.preventDefault(); + const toggle = u.ancestor(ev.target, '.toggle-form'); + const sel = toggle.getAttribute('data-form'); + const form = u.ancestor(toggle, '.list-group-item').querySelector(sel); + if (u.hasClass('hidden', form)) { + u.removeClass('hidden', form); + } else { + u.addClass('hidden', form); + } + } + + filterRoleResults (ev) { + this.roles_filter = ev.target.value; + this.render(); + } + + filterAffiliationResults (ev) { + this.affiliations_filter = ev.target.value; + } + + queryRole (ev) { + ev.stopPropagation(); + ev.preventDefault(); + const data = new FormData(ev.target); + const role = data.get('role'); + this.role = null; + this.role = role; + } + + queryAffiliation (ev) { + ev.stopPropagation(); + ev.preventDefault(); + const data = new FormData(ev.target); + const affiliation = data.get('affiliation'); + this.affiliation = null; + this.affiliation = affiliation; + } + + alert (message, type) { + this.alert_message = message; + this.alert_type = type; + } + + clearAlert () { + this.alert_message = undefined; + this.alert_type = undefined; + } + +} + +api.elements.define('converse-modtools', ModeratorTools); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js new file mode 100644 index 0000000..7d5dd72 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/muc.js @@ -0,0 +1,53 @@ +import BaseChatView from 'shared/chat/baseview.js'; +import tplMuc from './templates/muc.js'; +import { _converse, api, converse } from '@converse/headless/core'; + + +export default class MUCView extends BaseChatView { + length = 300 + is_chatroom = true + + async initialize () { + this.model = await api.rooms.get(this.jid); + _converse.chatboxviews.add(this.jid, this); + this.setAttribute('id', this.model.get('box_id')); + + this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); + this.listenTo(this.model, 'change:composing_spoiler', this.requestUpdateMessageForm); + this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged); + this.listenTo(this.model.session, 'change:view', () => this.requestUpdate()); + + this.onConnectionStatusChanged(); + this.model.maybeShow(); + /** + * Triggered once a {@link _converse.ChatRoomView} has been opened + * @event _converse#chatRoomViewInitialized + * @type { _converse.ChatRoomView } + * @example _converse.api.listen.on('chatRoomViewInitialized', view => { ... }); + */ + api.trigger('chatRoomViewInitialized', this); + } + + render () { + return tplMuc({ 'model': this.model }); + } + + onConnectionStatusChanged () { + const conn_status = this.model.session.get('connection_status'); + if (conn_status === converse.ROOMSTATUS.CONNECTING) { + this.model.session.save({ + 'disconnection_actor': undefined, + 'disconnection_message': undefined, + 'disconnection_reason': undefined, + }); + this.model.save({ + 'moved_jid': undefined, + 'password_validation_message': undefined, + 'reason': undefined, + }); + } + this.requestUpdate(); + } +} + +api.elements.define('converse-muc', MUCView); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js new file mode 100644 index 0000000..6f244e3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/nickname-form.js @@ -0,0 +1,48 @@ +import tplMUCNicknameForm from './templates/muc-nickname-form.js'; +import { CustomElement } from 'shared/components/element'; +import { _converse, api } from "@converse/headless/core"; + +import './styles/nickname-form.scss'; + + +class MUCNicknameForm extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.jid); + } + + render () { + return tplMUCNicknameForm(this); + } + + submitNickname (ev) { + ev.preventDefault(); + const nick = ev.target.nick.value.trim(); + if (!nick) { + return; + } + if (this.model.isEntered()) { + this.model.setNickname(nick); + this.closeModal(); + } else { + this.model.join(nick); + } + } + + closeModal () { + const evt = document.createEvent('Event'); + evt.initEvent('hide.bs.modal', true, true); + this.dispatchEvent(evt); + } +} + +api.elements.define('converse-muc-nickname-form', MUCNicknameForm); + +export default MUCNicknameForm; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js new file mode 100644 index 0000000..25f81d0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/password-form.js @@ -0,0 +1,39 @@ +import tplMUCPasswordForm from "./templates/muc-password-form.js"; +import { CustomElement } from 'shared/components/element'; +import { _converse, api } from "@converse/headless/core"; + + +class MUCPasswordForm extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model, 'change:password_validation_message', this.render); + this.render(); + } + + render () { + return tplMUCPasswordForm({ + 'jid': this.model.get('jid'), + 'submitPassword': ev => this.submitPassword(ev), + 'validation_message': this.model.get('password_validation_message') + }); + } + + submitPassword (ev) { + ev.preventDefault(); + const password = this.querySelector('input[type=password]').value; + this.model.join(this.model.get('nick'), password); + this.model.set('password_validation_message', null); + } +} + +api.elements.define('converse-muc-password-form', MUCPasswordForm); + +export default MUCPasswordForm; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js new file mode 100644 index 0000000..d845ae6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/role-form.js @@ -0,0 +1,68 @@ +import log from '@converse/headless/log'; +import tplRoleForm from './templates/role-form.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { api, converse } from '@converse/headless/core.js'; +import { isErrorObject } from '@converse/headless/utils/core.js'; + +const { Strophe, sizzle } = converse.env; + +class RoleForm extends CustomElement { + static get properties () { + return { + muc: { type: Object }, + jid: { type: String }, + role: { type: String }, + alert_message: { type: String, attribute: false }, + alert_type: { type: String, attribute: false }, + }; + } + + render () { + return tplRoleForm(this); + } + + alert (message, type) { + this.alert_message = message; + this.alert_type = type; + } + + assignRole (ev) { + ev.stopPropagation(); + ev.preventDefault(); + this.alert(); // clear alert + + const data = new FormData(ev.target); + const occupant = this.muc.getOccupant(data.get('jid') || data.get('nick')); + const role = data.get('role'); + const reason = data.get('reason'); + + this.muc.setRole( + occupant, + role, + reason, + () => { + /** + * @event roleChanged + * @example + * const el = document.querySelector('converse-muc-role-form'); + * el.addEventListener('roleChanged', () => { ... }); + */ + const event = new CustomEvent('roleChanged', { bubbles: true }); + this.dispatchEvent(event); + + }, + e => { + if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) { + this.alert(__("You're not allowed to make that change"), 'danger'); + } else { + this.alert(__('Sorry, something went wrong while trying to set the role'), 'danger'); + if (isErrorObject(e)) log.error(e); + } + } + ); + + } +} + +api.elements.define('converse-muc-role-form', RoleForm); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js new file mode 100644 index 0000000..6277273 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/search.js @@ -0,0 +1,58 @@ +import log from "@converse/headless/log"; +import { _converse, api, converse } from "@converse/headless/core"; + +const { Strophe, $iq, sizzle } = converse.env; + +Strophe.addNamespace('MUCSEARCH', 'https://xmlns.zombofant.net/muclumbus/search/1.0'); + +const rooms_cache = {}; + +async function searchRooms (query) { + const iq = $iq({ + 'type': 'get', + 'from': _converse.bare_jid, + 'to': 'api@search.jabber.network' + }).c('search', { 'xmlns': Strophe.NS.MUCSEARCH }) + .c('set', { 'xmlns': Strophe.NS.RSM }) + .c('max').t(10).up().up() + .c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' }) + .c('field', { 'var': 'FORM_TYPE', 'type': 'hidden' }) + .c('value').t('https://xmlns.zombofant.net/muclumbus/search/1.0#params').up().up() + .c('field', { 'var': 'q', 'type': 'text-single' }) + .c('value').t(query).up().up() + .c('field', { 'var': 'sinname', 'type': 'boolean' }) + .c('value').t('true').up().up() + .c('field', { 'var': 'sindescription', 'type': 'boolean' }) + .c('value').t('false').up().up() + .c('field', { 'var': 'sinaddr', 'type': 'boolean' }) + .c('value').t('true').up().up() + .c('field', { 'var': 'min_users', 'type': 'text-single' }) + .c('value').t('1').up().up() + .c('field', { 'var': 'key', 'type': 'list-single' }) + .c('value').t('address').up() + .c('option').c('value').t('nusers').up().up() + .c('option').c('value').t('address') + + let iq_result; + try { + iq_result = await api.sendIQ(iq); + } catch (e) { + log.error(e); + return []; + } + const s = `result[xmlns="${Strophe.NS.MUCSEARCH}"] item`; + return sizzle(s, iq_result).map(i => { + const jid = i.getAttribute('address'); + return { + 'label': `${i.querySelector('name')?.textContent} (${jid})`, + 'value': jid + } + }); +} + +export function getAutoCompleteList (query) { + if (!rooms_cache[query]) { + rooms_cache[query] = searchRooms(query); + } + return rooms_cache[query]; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js new file mode 100644 index 0000000..16688c0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/sidebar.js @@ -0,0 +1,54 @@ +import 'shared/autocomplete/index.js'; +import tplMUCSidebar from "./templates/muc-sidebar.js"; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api, converse } from "@converse/headless/core"; + +import 'shared/styles/status.scss'; +import './styles/muc-occupants.scss'; + +const { u } = converse.env; + +export default class MUCSidebar extends CustomElement { + + static get properties () { + return { + jid: { type: String } + } + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.jid); + this.listenTo(this.model.occupants, 'add', () => this.requestUpdate()); + this.listenTo(this.model.occupants, 'remove', () => this.requestUpdate()); + this.listenTo(this.model.occupants, 'change', () => this.requestUpdate()); + this.listenTo(this.model.occupants, 'vcard:change', () => this.requestUpdate()); + this.listenTo(this.model.occupants, 'vcard:add', () => this.requestUpdate()); + this.model.initialized.then(() => this.requestUpdate()); + } + + render () { + const tpl = tplMUCSidebar(Object.assign( + this.model.toJSON(), { + 'occupants': [...this.model.occupants.models], + 'closeSidebar': ev => this.closeSidebar(ev), + 'onOccupantClicked': ev => this.onOccupantClicked(ev), + } + )); + return tpl; + } + + closeSidebar(ev) { + ev?.preventDefault?.(); + ev?.stopPropagation?.(); + u.safeSave(this.model, { 'hidden_occupants': true }); + } + + onOccupantClicked (ev) { + ev?.preventDefault?.(); + const view = _converse.chatboxviews.get(this.getAttribute('jid')); + view?.getMessageForm().insertIntoTextArea(`@${ev.target.textContent}`); + } +} + +api.elements.define('converse-muc-sidebar', MUCSidebar); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss new file mode 100644 index 0000000..4e8e550 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/add-muc-modal.scss @@ -0,0 +1,14 @@ +converse-add-muc-modal { + .add-chatroom { + converse-autocomplete { + .suggestion-box__results--below { + height: 10em; + overflow: auto; + } + + .suggestion-box ul li { + display: block; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss new file mode 100644 index 0000000..a5b7e26 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/controlbox.scss @@ -0,0 +1,28 @@ +.conversejs { + #controlbox { + #chatrooms { + padding: 0; + + .add-chatroom { + input[type=button], + input[type=submit], + input[type=text] { + width: 100%; + } + margin: 0; + padding: 0; + } + } + + .open-rooms-toggle, .open-rooms-toggle .fa { + color: var(--groupchats-header-color) !important; + &:hover { + color: var(--chatroom-head-bg-color-dark) !important; + } + } + + .open-rooms-toggle { + white-space: nowrap; + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss new file mode 100644 index 0000000..41cb8a3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/index.scss @@ -0,0 +1,158 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "shared/styles/_variables.scss"; + +@import "./controlbox.scss"; +@import "./muc.scss"; + +converse-muc-disconnected, +converse-muc-destroyed { + padding: 2em; + width: 100%; + height: 100%; +} + +.conversejs.converse-embedded, +.conversejs { + .badge--muc { + background-color: var(--groupchats-header-color); + } + + .add-chatroom { + input[type="submit"], + input[type="button"] { + margin: 0.3em 0; + } + } +} + + +/* ******************* Overlay styles *************************** */ + +.conversejs { + converse-chats { + &.converse-overlayed { + .chatbox { + &.chatroom { + min-width: var(--chatroom-width) !important; + width: var(--chatroom-width); + .box-flyout { + min-width: var(--chatroom-width) !important; + width: var(--chatroom-width); + } + .chatbox-title__text { + @include make-col(10); + } + .chatbox-title__buttons { + @include make-col(2); + } + + .chat-head__desc { + font-size: 80%; + margin-bottom: 1em; + } + .chatroom-body { + .occupants { + .occupants-heading { + padding: 0; + } + .occupant-list { + border-bottom: none; + } + ul { + .occupant { + .occupant-nick-badge { + .occupant-badges { + display: none; + } + } + } + } + } + .chat-area { + min-width: var(--overlayed-chat-width); + } + } + } + } + } + + &.converse-embedded, + &.converse-fullscreen, + &.converse-mobile { + + .chatroom { + .box-flyout { + width: 100%; + + .chatroom-body { + .chat-area { + &.full { + .new-msgs-indicator { + max-width: 100%; + } + } + } + .occupants { + padding: var(--occupants-padding); + .occupants-heading { + font-size: var(--font-size-large); + } + ul { + &.occupant-list { + li { + font-size: var(--font-size-small); + } + } + } + } + } + } + .room-invite { + span { + .invited-contact { + margin: 0 0 0.5em -1px; + } + } + } + } + } + + &.converse-embedded { + .chatroom { + margin: 0; + width: 100%; + .box-flyout { + .occupants-heading { + font-size: 120%; + } + .chat-content { + .chat-message { + margin: 0.5em; + font-size: 120%; + } + } + .sendXMPPMessage { + .chat-textarea { + padding: 0.5em; + font-size: 110%; + } + } + .chatroom-body { + height: 100%; + .muc-form-container { + height: 100%; + position: relative; + } + } + .occupants { + .occupant-list { + padding-left: 0.3em; + } + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss new file mode 100644 index 0000000..25007f9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/moderator-tools.scss @@ -0,0 +1,5 @@ +converse-modtools { + converse-icon svg { + fill: var(--link-color); + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss new file mode 100644 index 0000000..e9c5569 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottom-panel.scss @@ -0,0 +1,49 @@ +.conversejs { + converse-muc.chatroom { + converse-muc-bottom-panel.bottom-panel { + display: contents; + height: 3em; + padding: 0.5em; + text-align: center; + font-size: var(--font-size-small); + background-color: var(--chatroom-head-bg-color); + color: white; + + &.muc-bottom-panel--muted { + height: 4em; + width: 100%; + } + + &.muc-bottom-panel--nickname { + padding: 0; + height: 16em; + + .muc-form-container { + .chatroom-form { + padding-top: 2em; + padding-bottom: 0; + } + } + } + + .sendXMPPMessage { + .suggestion-box__results--above { + bottom: 4.5em; + } + .chat-textarea, input { + &:active, &:focus{ + outline-color: var(--chatroom-head-bg-color) !important; + } + &.correcting { + background-color: var(--chatroom-correcting-color); + } + } + .chat-textarea { + width: 100%; + border: none; + border-bottom-right-radius: 0; + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss new file mode 100644 index 0000000..559a643 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-bottompanel.scss @@ -0,0 +1,29 @@ +converse-muc-bottom-panel { + display: contents; +} + +.muc-bottom-panel { + height: 3em; + padding: 0.5em; + text-align: center; + font-size: var(--font-size-small); + background-color: var(--chatroom-head-bg-color); + color: white; + + &.muc-bottom-panel--muted { + height: 4em; + width: 100%; + } + + &.muc-bottom-panel--nickname { + padding: 0; + height: 16em; + + .muc-form-container { + .chatroom-form { + padding-top: 2em; + padding-bottom: 0; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss new file mode 100644 index 0000000..83ec98c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-details-modal.scss @@ -0,0 +1,28 @@ +converse-muc-details-modal { + .features-list { + margin-left: 1em; + } + + .room-info { + strong { + color: var(--muc-color); + } + } + + .chatroom-features { + width: 100%; + .features-list { + padding-top: 0; + .feature { + width: 100%; + margin-right: 0.5em; + padding-right: 0; + font-size: 1em; + cursor: help; + converse-icon { + margin-right: 0.5em; + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss new file mode 100644 index 0000000..6f90433 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-forms.scss @@ -0,0 +1,39 @@ +converse-muc-config-form { + width: 100%; + overflow: auto; +} + +.conversejs { + .chatroom { + .box-flyout { + .muc-form-container { + background-color: var(--background); + border: 0; + color: var(--text-color); + font-size: var(--font-size); + height: 100%; + width: 100%; + overflow-y: auto; + + .validation-message { + font-size: 90%; + color: var(--error-color); + } + input[type=button], + input[type=submit] { + margin: 0 0.5em; + } + .button-primary { + background-color: var(--chatroom-head-fg-color); + } + } + + .chatroom-form { + display: flex; + flex-direction: column; + justify-content: center; + padding: 2em; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss new file mode 100644 index 0000000..cca7df3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-head.scss @@ -0,0 +1,86 @@ +.conversejs { + converse-muc.chatroom { + .chat-head-chatroom { + + converse-controlbox-navback { + .fa-arrow-left { + svg { + fill: var(--chatroom-head-color); + } + } + } + + color: var(--chatroom-head-color); + background-color: var(--chatroom-head-bg-color); + border-bottom: var(--chatroom-head-border-bottom); + + .chat-head__desc { + color: var(--chatroom-head-color); + display: var(--chatroom-head-description-display); + a { + color: var(--chatroom-head-description-link-color); + } + &:hover { + button { + display: inline-block; + } + } + } + + .chatbox-title { + .btn--transparent { + i { + color: var(--chatroom-head-color); + } + } + .chatbox-title__text--bookmarked { + margin-left: 0.5em; + } + } + + .chatbox-title__buttons { + background-color: var(--chatroom-head-bg-color); + } + + a, a:visited, a:hover, a:not([href]):not([tabindex]) { + &.chatbox-btn { + &.fa { + color: var(--chatroom-head-color); + &.button-on:before { + color: var(--chatroom-head-fg-color); + } + } + } + } + + converse-dropdown { + .dropdown-menu { + converse-icon { + svg { + fill: var(--chatroom-color); + } + } + } + } + + .chatbox-btn { + converse-icon { + svg { + fill: var(--chatroom-head-fg-color); + } + } + } + .chatbox-title__text { + color: var(--chatroom-head-color); + display: var(--heading-display); + font-weight: var(--chatroom-head-title-font-weight); + margin: auto 0; + padding-right: var(--chatroom-head-title-padding-right); + white-space: nowrap; + .chatroom-jid { + font-size: var(--font-size-small); + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss new file mode 100644 index 0000000..dab5cf1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc-occupants.scss @@ -0,0 +1,122 @@ +.conversejs { + converse-muc.chatroom { + + .chat-status--avatar { + background: var(--occupants-background-color); + border: 1px solid var(--occupants-background-color); + } + + .badge-groupchat { + background-color: var(--groupchats-header-color); + } + + .box-flyout { + .occupants { + display: flex; + flex-direction: column; + justify-content: space-between; + overflow-x: hidden; + overflow-y: hidden; + vertical-align: top; + background-color: var(--occupants-background-color); + border-left: var(--occupants-border-left); + padding: 0.5em; + max-width: 75%; + min-width: 20%; + flex: 0 0 25%; + + .occupants-header--title { + display: flex; + flex-direction: row; + margin-bottom: 0.5em; + + .hide-occupants { + align-self: flex-end; + cursor: pointer; + font-size: var(--font-size-small); + } + } + + .fa-user-plus { + margin-right: 0.25em; + } + + .occupants-heading { + width: 100%; + font-family: var(--heading-font); + color: var(--groupchats-header-color-dark); + padding-left: 0; + margin-right: 1em; + } + .suggestion-box{ + ul { + padding: 0; + li { + padding: 0.5em; + } + } + } + ul { + padding: 0; + margin-bottom: 0.5em; + overflow-x: hidden; + overflow-y: auto; + list-style: none; + + &.occupant-list { + overflow-y: auto; + flex-basis: 0; + flex-grow: 1; + } + li { + cursor: default; + display: block; + font-size: var(--font-size-small); + overflow: hidden; + padding: 0.25em 0.25em 0.25em 0; + text-overflow: ellipsis; + .fa { + margin-right: 0.5em; + } + &.feature { + font-size: var(--font-size-tiny); + } + &.occupant { + cursor: pointer; + color: var(--link-color); + &:hover { + color: var(--link-hover-color); + } + + .occupant-nick-badge { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + + .occupant-badges { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + flex-direction: row; + + span { + height: 1.6em; + margin-right: 0.25rem; + } + } + } + + div.row.no-gutters { + flex-wrap: nowrap; + min-height: 1.5em; + } + .badge { + margin-bottom: 0.125rem; + } + } + } + } + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss new file mode 100644 index 0000000..4dd9a39 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/muc.scss @@ -0,0 +1,119 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "shared/styles/_variables.scss"; +@import "plugins/chatview/styles/chatbox.scss"; +@import "./muc-forms.scss"; + +.conversejs { + .chatroom { + width: var(--chatroom-width); + @media screen and (max-height: $mobile-landscape-height){ + width: var(--mobile-chat-width); + } + @media screen and (max-width: $mobile-portrait-length) { + width: var(--mobile-chat-width); + } + + .box-flyout { + background-color: var(--chatroom-head-bg-color); + overflow-y: hidden; + width: var(--chatroom-width); + + @media screen and (max-height: $mobile-landscape-height) { + height: var(--mobile-chat-height); + width: var(--mobile-chat-width); + height: var(--fullpage-chat-height); + } + @media screen and (max-width: $mobile-portrait-length) { + height: var(--mobile-chat-height); + width: var(--mobile-chat-width); + height: var(--fullpage-chat-height); + } + + .empty-history-feedback { + position: relative; + span { + width: 100%; + text-align: center; + position: absolute; + margin-top: 50%; + } + } + + .chatroom-body { + flex-direction: row; + flex-flow: nowrap; + background-color: var(--background); + border-top: 0; + height: 100%; + width: 100%; + overflow: hidden; + + converse-muc-chatarea { + width: 100%; + display: flex; + flex-direction: row; + flex-flow: nowrap; + } + + .row { + flex-direction: row; + } + .chat-topic { + font-weight: bold; + color: var(--chatroom-head-bg-color); + } + .chat-info { + color: var(--chat-info-color); + line-height: normal; + &.badge { + color: var(--chat-head-text-color); + } + &.chat-msg--retracted { + color: var(--subdued-color); + } + } + .disconnect-container { + margin: 1em; + width: 100%; + h3.disconnect-msg { + padding-bottom: 1em; + } + } + .chat-area { + display: flex; + flex-direction: column; + flex: 0 1 100%; + justify-content: flex-end; + min-width: 25%; + word-wrap: break-word; + .new-msgs-indicator { + background-color: var(--chatroom-color); + } + .chat-content { + height: 100%; + } + .chat-content__help { + converse-chat-help { + border-top: 1px solid var(--chatroom-color); + } + .close-chat-help { + svg { + fill: var(--chatroom-color); + } + } + } + } + + } + } + + .room-invite { + .invited-contact { + margin: -1px 0 0 -1px; + width: 100%; + border: 1px solid #999; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss new file mode 100644 index 0000000..39b63e7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/styles/nickname-form.scss @@ -0,0 +1,3 @@ +converse-muc-nickname-form { + width: 100%; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js new file mode 100644 index 0000000..6261cdc --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/affiliation-form.js @@ -0,0 +1,36 @@ +import { __ } from 'i18n'; +import { html } from "lit"; +import { getAssignableAffiliations } from '@converse/headless/plugins/muc/affiliations/utils.js'; + +export default (el) => { + const i18n_change_affiliation = __('Change affiliation'); + const i18n_new_affiliation = __('New affiliation'); + const i18n_reason = __('Reason'); + const occupant = el.muc.getOwnOccupant(); + const assignable_affiliations = getAssignableAffiliations(occupant); + + return html` + <form class="affiliation-form" @submit=${ev => el.assignAffiliation(ev)}> + ${el.alert_message ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert_message}</div>` : '' } + <div class="form-group"> + <div class="row"> + <div class="col"> + <label><strong>${i18n_new_affiliation}:</strong></label> + <select class="custom-select select-affiliation" name="affiliation"> + ${ assignable_affiliations.map(aff => html`<option value="${aff}" ?selected=${aff === el.affiliation}>${aff}</option>`) } + </select> + </div> + <div class="col"> + <label><strong>${i18n_reason}:</strong></label> + <input class="form-control" type="text" name="reason"/> + </div> + </div> + </div> + <div class="form-group"> + <div class="col"> + <input type="submit" class="btn btn-primary" name="change" value="${i18n_change_affiliation}"/> + </div> + </div> + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js new file mode 100644 index 0000000..9fca6b7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/mep-message.js @@ -0,0 +1,33 @@ +import { converse } from '@converse/headless/core'; +import { html } from 'lit'; + +const { dayjs } = converse.env; + +export default (el) => { + const isodate = dayjs(el.model.get('time')).toISOString(); + return html` + <div class="message chat-info message--mep ${ el.getExtraMessageClasses() }" + data-isodate="${isodate}" + data-type="${el.data_name}" + data-value="${el.data_value}"> + + <div class="chat-msg__content"> + <div class="chat-msg__body chat-msg__body--${el.model.get('type')} ${el.model.get('is_delayed') ? 'chat-msg__body--delayed' : '' }"> + <div class="chat-info__message"> + ${ el.isRetracted() ? el.renderRetraction() : html` + <converse-rich-text + .mentions=${el.model.get('references')} + render_styling + text=${el.model.getMessageText()}> + </converse-rich-text> + ${ el.model.get('reason') ? + html`<q class="reason"><converse-rich-text text=${el.model.get('reason')}></converse-rich-text></q>` : `` } + `} + </div> + <converse-message-actions + ?is_retracted=${el.isRetracted()} + .model=${el.model}></converse-message-actions> + </div> + </div> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js new file mode 100644 index 0000000..2ac1894 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/message-form.js @@ -0,0 +1,35 @@ +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; +import { html } from "lit"; +import { resetElementHeight } from 'plugins/chatview/utils.js'; + + +export default (o) => { + const label_message = o.composing_spoiler ? __('Hidden message') : __('Message'); + const label_spoiler_hint = __('Optional hint'); + const show_send_button = api.settings.get('show_send_button'); + return html` + <form class="setNicknameButtonForm hidden"> + <input type="submit" class="btn btn-primary" name="join" value="Join"/> + </form> + <form class="sendXMPPMessage"> + <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/> + <div class="suggestion-box"> + <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul> + <textarea + autofocus + type="text" + @drop=${o.onDrop} + @input=${resetElementHeight} + @keydown=${o.onKeyDown} + @keyup=${o.onKeyUp} + @paste=${o.onPaste} + @change=${o.onChange} + class="chat-textarea suggestion-box__input + ${ show_send_button ? 'chat-textarea-send-button' : '' } + ${ o.composing_spoiler ? 'spoiler' : '' }" + placeholder="${label_message}">${ o.message_value || '' }</textarea> + <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span> + </div> + </form>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js new file mode 100644 index 0000000..837efb6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/moderator-tools.js @@ -0,0 +1,218 @@ +import spinner from "templates/spinner.js"; +import { __ } from 'i18n'; +import { html } from "lit"; + + +function getRoleHelpText (role) { + if (role === 'moderator') { + return __("Moderators are privileged users who can change the roles of other users (except those with admin or owner affiliations."); + } else if (role === 'participant') { + return __("The default role, implies that you can read and write messages."); + } else if (role == 'visitor') { + return __("Visitors aren't allowed to write messages in a moderated multi-user chat."); + } +} + +function getAffiliationHelpText (aff) { + if (aff === 'owner') { + return __("Owner is the highest affiliation. Owners can modify roles and affiliations of all other users."); + } else if (aff === 'admin') { + return __("Admin is the 2nd highest affiliation. Admins can modify roles and affiliations of all other users except owners."); + } else if (aff === 'outcast') { + return __("To ban a user, you give them the affiliation of \"outcast\"."); + } +} + + +const role_option = (o) => html` + <option value="${o.item || ''}" + ?selected=${o.item === o.role} + title="${getRoleHelpText(o.item)}">${o.item}</option> +`; + + +const affiliation_option = (o) => html` + <option value="${o.item || ''}" + ?selected=${o.item === o.affiliation} + title="${getAffiliationHelpText(o.item)}">${o.item}</option> +`; + + +const tplRoleFormToggle = (o) => html` + <a href="#" data-form="converse-muc-role-form" class="toggle-form right" color="var(--subdued-color)" @click=${o.toggleForm}> + <converse-icon class="fa fa-wrench" size="1em"></converse-icon> + </a>`; + + +const tplRoleListItem = (el, o) => html` + <li class="list-group-item" data-nick="${o.item.nick}"> + <ul class="list-group"> + <li class="list-group-item active"> + <div><strong>JID:</strong> ${o.item.jid}</div> + </li> + <li class="list-group-item"> + <div><strong>Nickname:</strong> ${o.item.nick}</div> + </li> + <li class="list-group-item"> + <div><strong>Role:</strong> ${o.item.role} ${o.assignable_roles.length ? tplRoleFormToggle(o) : ''}</div> + ${o.assignable_roles.length ? + html`<converse-muc-role-form class="hidden" .muc=${el.muc} jid=${o.item.jid} role=${o.item.role}></converse-muc-role-form>` : '' + } + </li> + </ul> + </li> +`; + + +const affiliation_form_toggle = (o) => html` + <a href="#" data-form="converse-muc-affiliation-form" class="toggle-form right" color="var(--subdued-color)" @click=${o.toggleForm}> + <converse-icon class="fa fa-wrench" size="1em"></converse-icon> + </a>`; + + +const affiliation_list_item = (el, o) => html` + <li class="list-group-item" data-nick="${o.item.nick}"> + <ul class="list-group"> + <li class="list-group-item active"> + <div><strong>JID:</strong> ${o.item.jid}</div> + </li> + <li class="list-group-item"> + <div><strong>Nickname:</strong> ${o.item.nick}</div> + </li> + <li class="list-group-item"> + <div><strong>Affiliation:</strong> ${o.item.affiliation} ${o.assignable_affiliations.length ? affiliation_form_toggle(o) : ''}</div> + ${o.assignable_affiliations.length ? + html`<converse-muc-affiliation-form class="hidden" .muc=${el.muc} jid=${o.item.jid} affiliation=${o.item.affiliation}></converse-muc-affiliation-form>` : '' + } + </li> + </ul> + </li> +`; + + +const tplNavigation = (o) => html` + <ul class="nav nav-pills justify-content-center"> + <li role="presentation" class="nav-item"> + <a class="nav-link ${o.tab === "affiliations" ? "active" : ""}" + id="affiliations-tab" + href="#affiliations-tabpanel" + aria-controls="affiliations-tabpanel" + role="tab" + data-name="affiliations" + @click=${o.switchTab}>Affiliations</a> + </li> + <li role="presentation" class="nav-item"> + <a class="nav-link ${o.tab === "roles" ? "active" : ""}" + id="roles-tab" + href="#roles-tabpanel" + aria-controls="roles-tabpanel" + role="tab" + data-name="roles" + @click=${o.switchTab}>Roles</a> + </li> + </ul> +`; + + +export default (el, o) => { + const i18n_affiliation = __('Affiliation'); + const i18n_no_users_with_aff = __('No users with that affiliation found.') + const i18n_no_users_with_role = __('No users with that role found.'); + const i18n_filter = __('Type here to filter the search results'); + const i18n_role = __('Role'); + const i18n_show_users = __('Show users'); + const i18n_helptext_role = __( + "Roles are assigned to users to grant or deny them certain abilities in a multi-user chat. "+ + "They're assigned either explicitly or implicitly as part of an affiliation. "+ + "A role that's not due to an affiliation, is only valid for the duration of the user's session." + ); + const i18n_helptext_affiliation = __( + "An affiliation is a long-lived entitlement which typically implies a certain role and which "+ + "grants privileges and responsibilities. For example admins and owners automatically have the "+ + "moderator role." + ); + const show_both_tabs = o.queryable_roles.length && o.queryable_affiliations.length; + return html` + ${o.alert_message ? html`<div class="alert alert-${o.alert_type}" role="alert">${o.alert_message}</div>` : '' } + ${ show_both_tabs ? tplNavigation(o) : '' } + + <div class="tab-content"> + + ${ o.queryable_affiliations.length ? html` + <div class="tab-pane tab-pane--columns ${ o.tab === 'affiliations' ? 'active' : ''}" id="affiliations-tabpanel" role="tabpanel" aria-labelledby="affiliations-tab"> + <form class="converse-form query-affiliation" @submit=${o.queryAffiliation}> + <p class="helptext pb-3">${i18n_helptext_affiliation}</p> + <div class="form-group"> + <label for="affiliation"> + <strong>${i18n_affiliation}:</strong> + </label> + <div class="row"> + <div class="col"> + <select class="custom-select select-affiliation" name="affiliation"> + ${o.queryable_affiliations.map(item => affiliation_option(Object.assign({item}, o)))} + </select> + </div> + <div class="col"> + <input type="submit" class="btn btn-primary" name="users_with_affiliation" value="${i18n_show_users}"/> + </div> + </div> + <div class="row"> + <div class="col mt-3"> + ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length > 5) ? + html`<input class="form-control" .value="${o.affiliations_filter}" @keyup=${o.filterAffiliationResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' } + </div> + </div> + + ${ getAffiliationHelpText(o.affiliation) ? + html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getAffiliationHelpText(o.affiliation)}</p></div></div>` : '' } + </div> + </form> + <div class="scrollable-container"> + <ul class="list-group list-group--users"> + ${ (o.loading_users_with_affiliation) ? html`<li class="list-group-item"> ${spinner()} </li>` : '' } + ${ (Array.isArray(o.users_with_affiliation) && o.users_with_affiliation.length === 0) ? + html`<li class="list-group-item">${i18n_no_users_with_aff}</li>` : '' } + + ${ (o.users_with_affiliation instanceof Error) ? + html`<li class="list-group-item">${o.users_with_affiliation.message}</li>` : + (o.users_with_affiliation || []).map(item => ((item.nick || item.jid).match(new RegExp(o.affiliations_filter, 'i')) ? affiliation_list_item(el, Object.assign({item}, o)) : '')) } + </ul> + </div> + </div>` : '' } + + ${ o.queryable_roles.length ? html` + <div class="tab-pane tab-pane--columns ${ o.tab === 'roles' ? 'active' : ''}" id="roles-tabpanel" role="tabpanel" aria-labelledby="roles-tab"> + <form class="converse-form query-role" @submit=${o.queryRole}> + <p class="helptext pb-3">${i18n_helptext_role}</p> + <div class="form-group"> + <label for="role"><strong>${i18n_role}:</strong></label> + <div class="row"> + <div class="col"> + <select class="custom-select select-role" name="role"> + ${o.queryable_roles.map(item => role_option(Object.assign({item}, o)))} + </select> + </div> + <div class="col"> + <input type="submit" class="btn btn-primary" name="users_with_role" value="${i18n_show_users}"/> + </div> + </div> + <div class="row"> + <div class="col mt-3"> + ${ (Array.isArray(o.users_with_role) && o.users_with_role.length > 5) ? + html`<input class="form-control" .value="${o.roles_filter}" @keyup=${o.filterRoleResults} type="text" name="filter" placeholder="${i18n_filter}"/>` : '' } + </div> + </div> + + ${ getRoleHelpText(o.role) ? html`<div class="row"><div class="col pt-2"><p class="helptext pb-3">${getRoleHelpText(o.role)}</p></div></div>` : ''} + </div> + </form> + <div class="scrollable-container"> + <ul class="list-group list-group--users"> + ${ o.loading_users_with_role ? html`<li class="list-group-item"> ${spinner()} </li>` : '' } + ${ (o.users_with_role && o.users_with_role.length === 0) ? html`<li class="list-group-item">${i18n_no_users_with_role}</li>` : '' } + ${ (o.users_with_role || []).map(item => (item.nick.match(o.roles_filter) ? tplRoleListItem(el, Object.assign({item}, o)) : '')) } + </ul> + </div> + </div>`: '' } + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js new file mode 100644 index 0000000..941daa9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-bottom-panel.js @@ -0,0 +1,54 @@ +import '../message-form.js'; +import '../nickname-form.js'; +import 'shared/chat/toolbar.js'; +import { __ } from 'i18n'; +import { api, converse } from "@converse/headless/core"; +import { html } from "lit"; + + +const tplCanEdit = (o) => { + const unread_msgs = __('You have unread messages'); + const message_limit = api.settings.get('message_limit'); + const show_call_button = api.settings.get('visible_toolbar_buttons').call; + const show_emoji_button = api.settings.get('visible_toolbar_buttons').emoji; + const show_send_button = api.settings.get('show_send_button'); + const show_spoiler_button = api.settings.get('visible_toolbar_buttons').spoiler; + const show_toolbar = api.settings.get('show_toolbar'); + return html` + ${ (o.model.ui.get('scrolled') && o.model.get('num_unread')) ? + html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' } + ${show_toolbar ? html` + <converse-chat-toolbar + class="chat-toolbar no-text-select" + .model=${o.model} + ?hidden_occupants="${o.model.get('hidden_occupants')}" + ?is_groupchat="${o.is_groupchat}" + ?show_call_button="${show_call_button}" + ?show_emoji_button="${show_emoji_button}" + ?show_send_button="${show_send_button}" + ?show_spoiler_button="${show_spoiler_button}" + ?show_toolbar="${show_toolbar}" + message_limit="${message_limit}"></converse-chat-toolbar>` : '' } + <converse-muc-message-form jid=${o.model.get('jid')}></converse-muc-message-form>`; +} + + +export default (o) => { + const unread_msgs = __('You have unread messages'); + const conn_status = o.model.session.get('connection_status'); + const i18n_not_allowed = __("You're not allowed to send messages in this room"); + if (conn_status === converse.ROOMSTATUS.ENTERED) { + return html` + ${ o.model.ui.get('scrolled') && o.model.get('num_unread_general') ? + html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' } + ${(o.can_edit) ? tplCanEdit(o) : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`}`; + } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) { + if (api.settings.get('muc_show_logs_before_join')) { + return html`<span class="muc-bottom-panel muc-bottom-panel--nickname"> + <converse-muc-nickname-form jid="${o.model.get('jid')}"></converse-muc-nickname-form> + </span>`; + } + } else { + return ''; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js new file mode 100644 index 0000000..3ef241b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-chatarea.js @@ -0,0 +1,33 @@ +import '../bottom-panel.js'; +import '../sidebar.js'; +import 'shared/chat/chat-content.js'; +import 'shared/chat/help-messages.js'; +import { _converse } from '@converse/headless/core'; +import { html } from "lit"; + +export default (o) => html` + <div class="chat-area"> + <div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite"> + <converse-chat-content + class="chat-content__messages" + jid="${o.jid}"></converse-chat-content> + + ${(o.model?.get('show_help_messages')) ? + html`<div class="chat-content__help"> + <converse-chat-help + .model=${o.model} + .messages=${o.getHelpMessages()} + type="info" + chat_type="${_converse.CHATROOMS_TYPE}" + ></converse-chat-help></div>` : '' } + </div> + <converse-muc-bottom-panel jid="${o.jid}" class="bottom-panel"></converse-muc-bottom-panel> + </div> + <div class="disconnect-container hidden"></div> + ${o.model ? html` + <converse-muc-sidebar + class="occupants col-md-3 col-4 ${o.shouldShowSidebar() ? '' : 'hidden' }" + style="flex: 0 0 ${o.model.get('occupants_width')}px" + jid=${o.jid} + @mousedown=${o.onMousedown}></converse-muc-sidebar>` : '' } +`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js new file mode 100644 index 0000000..b6496a1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-config-form.js @@ -0,0 +1,51 @@ +import tplSpinner from 'templates/spinner.js'; +import { __ } from 'i18n'; +import { api, converse } from "@converse/headless/core"; +import { html } from "lit"; + +const { sizzle } = converse.env; +const u = converse.env.utils; + +export default (o) => { + const whitelist = api.settings.get('roomconfig_whitelist'); + const config_stanza = o.model.session.get('config_stanza'); + let fields = []; + let instructions = ''; + let title; + if (config_stanza) { + const stanza = u.toStanza(config_stanza); + fields = sizzle('field', stanza); + if (whitelist.length) { + fields = fields.filter(f => whitelist.includes(f.getAttribute('var'))); + } + const password_protected = o.model.features.get('passwordprotected'); + const options = { + 'new_password': !password_protected, + 'fixed_username': o.model.get('jid') + }; + fields = fields.map(f => u.xForm2TemplateResult(f, stanza, options)); + instructions = stanza.querySelector('instructions')?.textContent; + title = stanza.querySelector('title')?.textContent; + } else { + title = __('Loading configuration form'); + } + const i18n_save = __('Save'); + const i18n_cancel = __('Cancel'); + return html` + <form class="converse-form chatroom-form ${fields.length ? '' : 'converse-form--spinner'}" + autocomplete="off" + @submit=${o.submitConfigForm}> + + <fieldset class="form-group"> + <legend class="centered">${title}</legend> + ${ (title !== instructions) ? html`<p class="form-help">${instructions}</p>` : '' } + ${ fields.length ? fields : tplSpinner({'classes': 'hor_centered'}) } + </fieldset> + ${ fields.length ? html` + <fieldset> + <input type="submit" class="btn btn-primary" value="${i18n_save}"> + <input type="button" class="btn btn-secondary button-cancel" value="${i18n_cancel}" @click=${o.closeConfigForm}> + </fieldset>` : '' } + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js new file mode 100644 index 0000000..7f6fdd2 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-description.js @@ -0,0 +1,41 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + +export default (o) => { + const i18n_desc = __('Description:'); + const i18n_jid = __('Groupchat XMPP Address:'); + const i18n_occ = __('Participants:'); + const i18n_features = __('Features:'); + const i18n_requires_auth = __('Requires authentication'); + const i18n_hidden = __('Hidden'); + const i18n_requires_invite = __('Requires an invitation'); + const i18n_moderated = __('Moderated'); + const i18n_non_anon = __('Non-anonymous'); + const i18n_open_room = __('Open'); + const i18n_permanent_room = __('Permanent'); + const i18n_public = __('Public'); + const i18n_semi_anon = __('Semi-anonymous'); + const i18n_temp_room = __('Temporary'); + const i18n_unmoderated = __('Unmoderated'); + return html` + <div class="room-info"> + <p class="room-info"><strong>${i18n_jid}</strong> ${o.jid}</p> + <p class="room-info"><strong>${i18n_desc}</strong> ${o.desc}</p> + <p class="room-info"><strong>${i18n_occ}</strong> ${o.occ}</p> + <p class="room-info"><strong>${i18n_features}</strong> + <ul> + ${ o.passwordprotected ? html`<li class="room-info locked">${i18n_requires_auth}</li>` : '' } + ${ o.hidden ? html`<li class="room-info">${i18n_hidden}</li>` : '' } + ${ o.membersonly ? html`<li class="room-info">${i18n_requires_invite}</li>` : '' } + ${ o.moderated ? html`<li class="room-info">${i18n_moderated}</li>` : '' } + ${ o.nonanonymous ? html`<li class="room-info">${i18n_non_anon}</li>` : '' } + ${ o.open ? html`<li class="room-info">${i18n_open_room}</li>` : '' } + ${ o.persistent ? html`<li class="room-info">${i18n_permanent_room}</li>` : '' } + ${ o.publicroom ? html`<li class="room-info">${i18n_public}</li>` : '' } + ${ o.semianonymous ? html`<li class="room-info">${i18n_semi_anon}</li>` : '' } + ${ o.temporary ? html`<li class="room-info">${i18n_temp_room}</li>` : '' } + ${ o.unmoderated ? html`<li class="room-info">${i18n_unmoderated}</li>` : '' } + </ul> + </p> + </div> +`}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js new file mode 100644 index 0000000..1e090d8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-destroyed.js @@ -0,0 +1,23 @@ +import { __ } from 'i18n'; +import { html } from "lit"; + +const tplMoved = (o) => { + const i18n_moved = __('The conversation has moved to a new address. Click the link below to enter.'); + return html` + <p class="moved-label">${i18n_moved}</p> + <p class="moved-link"> + <a class="switch-chat" @click=${ev => o.onSwitch(ev)}>${o.moved_jid}</a> + </p>`; +} + +export default (o) => { + const i18n_non_existent = __('This groupchat no longer exists'); + const i18n_reason = __('The following reason was given: "%1$s"', o.reason || ''); + return html` + <div class="alert alert-danger"> + <h3 class="alert-heading disconnect-msg">${i18n_non_existent}</h3> + </div> + ${ o.reason ? html`<p class="destroyed-reason">${i18n_reason}</p>` : '' } + ${ o.moved_jid ? tplMoved(o) : '' } + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js new file mode 100644 index 0000000..aab97b4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-disconnect.js @@ -0,0 +1,10 @@ +import { html } from "lit"; + + +export default (messages) => { + return html` + <div class="alert alert-danger"> + <h3 class="alert-heading disconnect-msg">${messages[0]}</h3> + ${ messages.slice(1).map(m => html`<p class="disconnect-msg">${m}</p>`) } + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js new file mode 100644 index 0000000..efd7789 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-head.js @@ -0,0 +1,50 @@ +import 'shared/components/dropdown.js'; +import 'shared/components/rich-text.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core.js"; +import { getStandaloneButtons, getDropdownButtons } from 'shared/chat/utils.js'; +import { html } from "lit"; +import { until } from 'lit/directives/until.js'; + + +export default (el) => { + const o = el.model.toJSON(); + const subject_hidden = el.user_settings?.get('mucs_with_hidden_subject', [])?.includes(el.model.get('jid')); + const heading_buttons_promise = el.getHeadingButtons(subject_hidden); + const i18n_hide_topic = __('Hide the groupchat topic'); + const i18n_bookmarked = __('This groupchat is bookmarked'); + const subject = o.subject ? o.subject.text : ''; + const show_subject = (subject && !subject_hidden); + const muc_vcard = el.model.vcard?.get('image'); + return html` + <div class="chatbox-title ${ show_subject ? '' : "chatbox-title--no-desc"}"> + + ${ muc_vcard && muc_vcard !== _converse.DEFAULT_IMAGE ? html` + <converse-avatar class="avatar align-self-center" + .data=${el.model.vcard?.attributes} + nonce=${el.model.vcard?.get('vcard_updated')} + height="40" width="40"></converse-avatar>` : '' + } + + <div class="chatbox-title--row"> + ${ (!_converse.api.settings.get("singleton")) ? html`<converse-controlbox-navback jid="${o.jid}"></converse-controlbox-navback>` : '' } + <div class="chatbox-title__text" title="${ (api.settings.get('locked_muc_domain') !== 'hidden') ? o.jid : '' }">${ el.model.getDisplayName() } + ${ (o.bookmarked) ? + html`<converse-icon + class="fa fa-bookmark chatbox-title__text--bookmarked" + size="1em" + color="var(--chatroom-head-color)" + title="${i18n_bookmarked}"> + </converse-icon>` : '' } + </div> + </div> + <div class="chatbox-title__buttons row no-gutters"> + ${ until(getStandaloneButtons(heading_buttons_promise), '') } + ${ until(getDropdownButtons(heading_buttons_promise), '') } + </div> + </div> + ${ show_subject ? html`<p class="chat-head__desc" title="${i18n_hide_topic}"> + <converse-rich-text text=${subject} render_styling></converse-rich-text> + </p>` : '' } + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js new file mode 100644 index 0000000..00af8ab --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-list.js @@ -0,0 +1,62 @@ +import { __ } from 'i18n'; +import { html } from "lit"; +import { repeat } from 'lit/directives/repeat.js'; +import spinner from "templates/spinner.js"; + + +const form = (o) => { + const i18n_query = __('Show groupchats'); + const i18n_server_address = __('Server address'); + return html` + <form class="converse-form list-chatrooms" + @submit=${o.submitForm}> + <div class="form-group"> + <label for="chatroom">${i18n_server_address}:</label> + <input type="text" + autofocus + @change=${o.setDomainFromEvent} + value="${o.muc_domain || ''}" + required="required" + name="server" + class="form-control" + placeholder="${o.server_placeholder}"/> + </div> + <input type="submit" class="btn btn-primary" name="list" value="${i18n_query}"/> + </form> + `; +} + + +const tplItem = (o, item) => { + const i18n_info_title = __('Show more information on this groupchat'); + const i18n_open_title = __('Click to open this groupchat'); + return html` + <li class="room-item list-group-item"> + <div class="available-chatroom d-flex flex-row"> + <a class="open-room available-room w-100" + @click=${o.openRoom} + data-room-jid="${item.jid}" + data-room-name="${item.name}" + title="${i18n_open_title}" + href="#">${item.name || item.jid}</a> + <a class="right room-info icon-room-info" + @click=${o.toggleRoomInfo} + data-room-jid="${item.jid}" + title="${i18n_info_title}" + href="#"></a> + </div> + </li> + `; +} + + +export default (o) => { + return html` + ${o.show_form ? form(o) : '' } + <ul class="available-chatrooms list-group"> + ${ o.loading_items ? html`<li class="list-group-item"> ${ spinner() } </li>` : '' } + ${ o.feedback_text ? html`<li class="list-group-item active">${ o.feedback_text }</li>` : '' } + ${ repeat(o.items, (item) => item.jid, (item) => tplItem(o, item)) } + </ul> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js new file mode 100644 index 0000000..54b758d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-nickname-form.js @@ -0,0 +1,36 @@ +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; +import { html } from "lit"; + +export default (el) => { + const i18n_nickname = __('Nickname'); + const i18n_join = el.model?.isEntered() ? __('Change nickname') : __('Enter groupchat'); + const i18n_heading = api.settings.get('muc_show_logs_before_join') ? + __('Choose a nickname to enter') : + __('Please choose your nickname'); + + const validation_message = el.model?.get('nickname_validation_message'); + + return html` + <div class="chatroom-form-container muc-nickname-form"> + <form class="converse-form chatroom-form converse-centered-form" + @submit=${ev => el.submitNickname(ev)}> + <fieldset class="form-group"> + <label>${i18n_heading}</label> + <p class="validation-message">${validation_message}</p> + <input type="text" + required="required" + name="nick" + value="${el.model?.get('nick') || ''}" + class="form-control ${validation_message ? 'error': ''}" + placeholder="${i18n_nickname}"/> + </fieldset> + <fieldset class="form-group"> + <input type="submit" + class="btn btn-primary" + name="join" + value="${i18n_join}"/> + </fieldset> + </form> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js new file mode 100644 index 0000000..f259814 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-password-form.js @@ -0,0 +1,26 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + + +export default (o) => { + const i18n_heading = __('This groupchat requires a password'); + const i18n_password = __('Password: '); + const i18n_submit = __('Submit'); + return html` + <form class="converse-form chatroom-form converse-centered-form" @submit=${o.submitPassword}> + <fieldset class="form-group"> + <label>${i18n_heading}</label> + <p class="validation-message">${o.validation_message}</p> + <input class="hidden-username" type="text" autocomplete="username" value="${o.jid}"></input> + <input type="password" + name="password" + required="required" + class="form-control ${o.validation_message ? 'error': ''}" + placeholder="${i18n_password}"/> + </fieldset> + <fieldset class="form-group"> + <input class="btn btn-primary" type="submit" value="${i18n_submit}"/> + </fieldset> + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js new file mode 100644 index 0000000..defdf36 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc-sidebar.js @@ -0,0 +1,21 @@ +import tplOccupant from "./occupant.js"; +import { __ } from 'i18n'; +import { html } from "lit"; +import { repeat } from 'lit/directives/repeat.js'; + + +export default (o) => { + const i18n_participants = o.occupants.length === 1 ? __('Participant') : __('Participants'); + return html` + <div class="occupants-header"> + <div class="occupants-header--title"> + <span class="occupants-heading">${o.occupants.length} ${i18n_participants}</span> + <i class="hide-occupants" @click=${o.closeSidebar}> + <converse-icon class="fa fa-times" size="1em"></converse-icon> + </i> + </div> + </div> + <div class="dragresize dragresize-occupants-left"></div> + <ul class="occupant-list">${ repeat(o.occupants, (occ) => occ.get('jid'), (occ) => tplOccupant(occ, o)) }</ul> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js new file mode 100644 index 0000000..4ac05f2 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/muc.js @@ -0,0 +1,22 @@ +import '../chatarea.js'; +import '../config-form.js'; +import '../destroyed.js'; +import '../disconnected.js'; +import '../heading.js'; +import '../nickname-form.js'; +import '../password-form.js'; +import { html } from "lit"; +import { getChatRoomBodyTemplate } from '../utils.js'; + + +export default (o) => { + return html` + <div class="flyout box-flyout"> + <converse-dragresize></converse-dragresize> + ${ o.model ? html` + <converse-muc-heading jid="${o.model.get('jid')}" class="chat-head chat-head-chatroom row no-gutters"> + </converse-muc-heading> + <div class="chat-body chatroom-body row no-gutters">${getChatRoomBodyTemplate(o)}</div> + ` : '' } + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js new file mode 100644 index 0000000..c3facf8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/occupant.js @@ -0,0 +1,80 @@ +import { PRETTY_CHAT_STATUS } from '../constants.js'; +import { __ } from 'i18n'; +import { html } from "lit"; +import { showOccupantModal } from '../utils.js'; + +const i18n_occupant_hint = (o) => __('Click to mention %1$s in your message.', o.get('nick')) + +const occupant_title = (o) => { + const role = o.get('role'); + const hint_occupant = i18n_occupant_hint(o); + const i18n_moderator_hint = __('This user is a moderator.'); + const i18n_participant_hint = __('This user can send messages in this groupchat.'); + const i18n_visitor_hint = __('This user can NOT send messages in this groupchat.') + const spaced_jid = o.get('jid') ? `${o.get('jid')} ` : ''; + if (role === "moderator") { + return `${spaced_jid}${i18n_moderator_hint} ${hint_occupant}`; + } else if (role === "participant") { + return `${spaced_jid}${i18n_participant_hint} ${hint_occupant}`; + } else if (role === "visitor") { + return `${spaced_jid}${i18n_visitor_hint} ${hint_occupant}`; + } else if (!["visitor", "participant", "moderator"].includes(role)) { + return `${spaced_jid}${hint_occupant}`; + } +} + + +export default (o, chat) => { + const affiliation = o.get('affiliation'); + const hint_show = PRETTY_CHAT_STATUS[o.get('show')]; + const i18n_admin = __('Admin'); + const i18n_member = __('Member'); + const i18n_moderator = __('Moderator'); + const i18n_owner = __('Owner'); + const i18n_visitor = __('Visitor'); + const role = o.get('role'); + + const show = o.get('show'); + let classes, color; + if (show === 'online') { + [classes, color] = ['fa fa-circle', 'chat-status-online']; + } else if (show === 'dnd') { + [classes, color] = ['fa fa-minus-circle', 'chat-status-busy']; + } else if (show === 'away') { + [classes, color] = ['fa fa-circle', 'chat-status-away']; + } else { + [classes, color] = ['fa fa-circle', 'subdued-color']; + } + + return html` + <li class="occupant" id="${o.id}" title="${occupant_title(o)}"> + <div class="row no-gutters"> + <div class="col-auto"> + <a class="show-msg-author-modal" @click=${(ev) => showOccupantModal(ev, o)}> + <converse-avatar + class="avatar chat-msg__avatar" + .data=${o.vcard?.attributes} + nonce=${o.vcard?.get('vcard_updated')} + height="30" width="30"></converse-avatar> + <converse-icon + title="${hint_show}" + color="var(--${color})" + style="margin-top: -0.1em" + size="0.82em" + class="${classes} chat-status chat-status--avatar"></converse-icon> + </a> + </div> + <div class="col occupant-nick-badge"> + <span class="occupant-nick" @click=${chat.onOccupantClicked}>${o.getDisplayName()}</span> + <span class="occupant-badges"> + ${ (affiliation === "owner") ? html`<span class="badge badge-groupchat">${i18n_owner}</span>` : '' } + ${ (affiliation === "admin") ? html`<span class="badge badge-info">${i18n_admin}</span>` : '' } + ${ (affiliation === "member") ? html`<span class="badge badge-info">${i18n_member}</span>` : '' } + ${ (role === "moderator") ? html`<span class="badge badge-info">${i18n_moderator}</span>` : '' } + ${ (role === "visitor") ? html`<span class="badge badge-secondary">${i18n_visitor}</span>` : '' } + </span> + </div> + </div> + </li> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js new file mode 100644 index 0000000..8e89df4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/templates/role-form.js @@ -0,0 +1,37 @@ +import { __ } from 'i18n'; +import { html } from "lit"; +import { getAssignableRoles } from '@converse/headless/plugins/muc/utils.js'; + +export default (el) => { + const i18n_change_role = __('Change role'); + const i18n_new_role = __('New Role'); + const i18n_reason = __('Reason'); + const occupant = el.muc.getOwnOccupant(); + const assignable_roles = getAssignableRoles(occupant); + + return html` + <form class="role-form" @submit=${el.assignRole}> + <div class="form-group"> + <input type="hidden" name="jid" value="${el.jid}"/> + <input type="hidden" name="nick" value="${el.nick}"/> + <div class="row"> + <div class="col"> + <label><strong>${i18n_new_role}:</strong></label> + <select class="custom-select select-role" name="role"> + ${ assignable_roles.map(role => html`<option value="${role}" ?selected=${role === el.role}>${role}</option>`) } + </select> + </div> + <div class="col"> + <label><strong>${i18n_reason}:</strong></label> + <input class="form-control" type="text" name="reason"/> + </div> + </div> + </div> + <div class="form-group"> + <div class="col"> + <input type="submit" class="btn btn-primary" value="${i18n_change_role}"/> + </div> + </div> + </form> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js new file mode 100644 index 0000000..4994e52 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/autocomplete.js @@ -0,0 +1,367 @@ +/*global mock, converse */ + +const $pres = converse.env.$pres; +const $msg = converse.env.$msg; +const Strophe = converse.env.Strophe; +const u = converse.env.utils; + +describe("The nickname autocomplete feature", function () { + + it("shows all autocompletion options when the user presses @", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.last()?.get('received')); + + // Test that pressing @ brings up all options + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(at_event); + textarea.value = '@'; + message_form.onKeyUp(at_event); + + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + })); + + it("shows all autocompletion options when the user presses @ right after a new line", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.last()?.get('received')); + + // Test that pressing @ brings up all options + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + const message_form = view.querySelector('converse-muc-message-form'); + textarea.value = '\n' + message_form.onKeyDown(at_event); + textarea.value = '\n@'; + message_form.onKeyUp(at_event); + + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + })); + + it("shows all autocompletion options when the user presses @ right after an allowed character", + mock.initConverse( + ['chatBoxesFetched'], {'opening_mention_characters':['(']}, + async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['dick', 'harry'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Nicknames from messages + const msg = $msg({ + from: 'lounge@montague.lit/jane', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Hello world').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.model.messages.last()?.get('received')); + + // Test that pressing @ brings up all options + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 50, + 'key': '@' + }; + textarea.value = '(' + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(at_event); + textarea.value = '(@'; + message_form.onKeyUp(at_event); + + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 4); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('dick'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('harry'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('jane'); + expect(view.querySelector('.suggestion-box__results li:nth-child(4)').textContent).toBe('tom'); + })); + + it("should order by query index position and length", mock.initConverse( + ['chatBoxesFetched'], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'tom'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + + // Nicknames from presences + ['bernard', 'naber', 'helberlo', 'john', 'jones'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', { xmlns: Strophe.NS.MUC_USER }) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const at_event = { + 'target': textarea, + 'preventDefault': function preventDefault() { }, + 'stopPropagation': function stopPropagation() { }, + 'keyCode': 50, + 'key': '@' + }; + + const message_form = view.querySelector('converse-muc-message-form'); + // Test that results are sorted by query index + message_form.onKeyDown(at_event); + textarea.value = '@ber'; + message_form.onKeyUp(at_event); + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 3); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('bernard'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('naber'); + expect(view.querySelector('.suggestion-box__results li:nth-child(3)').textContent).toBe('helberlo'); + + // Test that when the query index is equal, results should be sorted by length + textarea.value = '@jo'; + message_form.onKeyUp(at_event); + await u.waitUntil(() => view.querySelectorAll('.suggestion-box__results li').length === 2); + expect(view.querySelector('.suggestion-box__results li:first-child').textContent).toBe('john'); + expect(view.querySelector('.suggestion-box__results li:nth-child(2)').textContent).toBe('jones'); + })); + + it("autocompletes when the user presses tab", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.occupants.length).toBe(1); + let presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/some1' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'some1@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.occupants.length).toBe(2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = "hello som"; + + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1); + expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1'); + + const backspace_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'keyCode': 8 + } + for (let i=0; i<3; i++) { + // Press backspace 3 times to remove "som" + message_form.onKeyDown(backspace_event); + textarea.value = textarea.value.slice(0, textarea.value.length-1) + message_form.onKeyUp(backspace_event); + } + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === true); + + presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/some2' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'some2@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + textarea.value = "hello s s"; + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2); + + const up_arrow_event = { + 'target': textarea, + 'preventDefault': () => (up_arrow_event.defaultPrevented = true), + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 38 + } + message_form.onKeyDown(up_arrow_event); + message_form.onKeyUp(up_arrow_event); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(2); + expect(view.querySelector('.suggestion-box__results li[aria-selected="false"]').textContent).toBe('some1'); + expect(view.querySelector('.suggestion-box__results li[aria-selected="true"]').textContent).toBe('some2'); + + message_form.onKeyDown({ + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + }); + expect(textarea.value).toBe('hello s @some2 '); + + // Test that pressing tab twice selects + presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/z3r0' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'z3r0@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + textarea.value = "hello z"; + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + + message_form.onKeyDown(tab_event); + message_form.onKeyUp(tab_event); + await u.waitUntil(() => textarea.value === 'hello @z3r0 '); + })); + + it("autocompletes when the user presses backspace", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.occupants.length).toBe(1); + const presence = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/some1' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'some1@montague.lit/resource', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.occupants.length).toBe(2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = "hello @some1 "; + + // Press backspace + const backspace_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 8, + 'key': 'Backspace' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(backspace_event); + textarea.value = "hello @some1"; // Mimic backspace + message_form.onKeyUp(backspace_event); + await u.waitUntil(() => view.querySelector('.suggestion-box__results').hidden === false); + expect(view.querySelectorAll('.suggestion-box__results li').length).toBe(1); + expect(view.querySelector('.suggestion-box__results li').textContent).toBe('some1'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js new file mode 100644 index 0000000..f8e1790 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/component.js @@ -0,0 +1,96 @@ +/*global mock, converse */ + +const u = converse.env.utils; + + +describe("The <converse-muc> component", function () { + + it("can be rendered as a standalone component", + mock.initConverse([], {'auto_insert': false}, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + + const span_el = document.createElement('span'); + span_el.classList.add('conversejs'); + span_el.classList.add('converse-embedded'); + + const muc_el = document.createElement('converse-muc'); + muc_el.classList.add('chatbox'); + muc_el.classList.add('chatroom'); + muc_el.setAttribute('jid', muc_jid); + span_el.appendChild(muc_el); + + const body = document.querySelector('body'); + body.appendChild(span_el); + await u.waitUntil(() => muc_el.querySelector('converse-muc-bottom-panel')); + body.removeChild(span_el); + expect(true).toBe(true); + })); + + it("will update correctly when the jid property changes", + mock.initConverse([], {'auto_insert': false}, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + + + const muc_creation_promise = api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + const affs = api.settings.get('muc_fetch_members'); + const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []); + await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations); + await model.messages.fetched; + + model.sendMessage({'body': 'hello from the lounge!'}); + + const span_el = document.createElement('span'); + span_el.classList.add('conversejs'); + span_el.classList.add('converse-embedded'); + + + const muc_el = document.createElement('converse-muc'); + muc_el.classList.add('chatbox'); + muc_el.classList.add('chatroom'); + muc_el.setAttribute('jid', muc_jid); + span_el.appendChild(muc_el); + + const body = document.querySelector('body'); + body.appendChild(span_el); + await u.waitUntil(() => muc_el.querySelector('converse-muc-bottom-panel')); + muc_el.querySelector('.box-flyout').setAttribute('style', 'height: 80vh'); + + const message = await u.waitUntil(() => muc_el.querySelector('converse-chat-message')); + expect(message.model.get('body')).toBe('hello from the lounge!'); + + _converse.connection.sent_stanzas = []; + + const muc2_jid = 'bar@montague.lit'; + const muc2_creation_promise = api.rooms.open(muc2_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc2_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc2_jid, nick); + await muc2_creation_promise; + const model2 = _converse.chatboxes.get(muc2_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc2_jid, [], all_affiliations); + await model.messages.fetched; + + model2.sendMessage({'body': 'hello from the bar!'}); + muc_el.setAttribute('jid', muc2_jid); + + await u.waitUntil(() => muc_el.querySelector('converse-chat-message-body').textContent.trim() === 'hello from the bar!'); + body.removeChild(span_el); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js new file mode 100644 index 0000000..bd2d401 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/corrections.js @@ -0,0 +1,427 @@ +/*global mock, converse */ + +const { $msg, $pres, Strophe, u, stx } = converse.env; + +describe("A Groupchat Message", function () { + + it("can be replaced with a correction", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const stanza = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const msg_id = u.getUniqueId(); + await model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': msg_id, + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder chimney breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'But soft, what light through yonder chimney breaks?', 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'But soft, what light through yonder window breaks?', 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); + const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + edit.click(); + const modal = _converse.api.modal.get('converse-message-versions-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const older_msgs = modal.querySelectorAll('.older-msg'); + expect(older_msgs.length).toBe(2); + expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true); + expect(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true); + })); + + it("keeps the same position in history after a correction", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const stanza = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const msg_id = u.getUniqueId(); + + // Receiving the first message + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': msg_id, + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + + // Receiving own message to check order against + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/romeo', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder airlock breaks?').tree()); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + expect(view.querySelectorAll('.chat-msg__text')[0].textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + expect(view.querySelectorAll('.chat-msg__text')[1].textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + // First message correction + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder chimney breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'But soft, what light through yonder chimney breaks?', 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + + // Second message correction + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/newguy', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').up() + .c('replace', {'id': msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).tree()); + + // Second own message + await view.model.handleMessageStanza($msg({ + 'from': 'lounge@montague.lit/romeo', + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': u.getUniqueId(), + }).c('body').t('But soft, what light through yonder window breaks?').tree()); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[0].textContent === + 'But soft, what light through yonder window breaks?', 500); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text')[2].textContent === + 'But soft, what light through yonder window breaks?', 500); + + expect(view.querySelectorAll('.chat-msg').length).toBe(3); + expect(view.querySelectorAll('.chat-msg__content .fa-edit').length).toBe(1); + const edit = await u.waitUntil(() => view.querySelector('.chat-msg__content .fa-edit')); + edit.click(); + const modal = _converse.api.modal.get('converse-message-versions-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const older_msgs = modal.querySelectorAll('.older-msg'); + expect(older_msgs.length).toBe(2); + expect(older_msgs[0].textContent.includes('But soft, what light through yonder airlock breaks?')).toBe(true); + expect(older_msgs[1].textContent.includes('But soft, what light through yonder chimney breaks?')).toBe(true); + })); + + it("can be sent as a correction by using the up arrow", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + expect(textarea.value).toBe(''); + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe(''); + + textarea.value = 'But soft, what light through yonder airlock breaks?'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg'))); + + spyOn(_converse.connection, 'send'); + const new_text = 'But soft, what light through yonder window breaks?' + textarea.value = new_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')) + .filter(m => m.textContent.replace(/<!-.*?->/g, '') === new_text).length); + + expect(_converse.connection.send).toHaveBeenCalled(); + const msg = _converse.connection.send.calls.all()[0].args[0]; + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>But soft, what light through yonder window breaks?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + expect(view.model.messages.models.length).toBe(1); + const corrected_message = view.model.messages.at(0); + expect(corrected_message.get('msgid')).toBe(first_msg.get('msgid')); + expect(corrected_message.get('correcting')).toBe(false); + + const older_versions = corrected_message.get('older_versions'); + const keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?'); + + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(u.hasClass('correcting', view.querySelector('.chat-msg'))).toBe(false); + + // Check that messages from other users are skipped + await view.model.handleMessageStanza($msg({ + 'from': muc_jid+'/someone-else', + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t('Hello world').tree()); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + + // Test that pressing the down arrow cancels message correction + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + expect(textarea.value).toBe('But soft, what light through yonder window breaks?'); + message_form.onKeyDown({ + target: textarea, + keyCode: 40 // Down arrow + }); + expect(textarea.value).toBe(''); + expect(view.model.messages.at(0).get('correcting')).toBe(false); + expect(view.querySelectorAll('.chat-msg').length).toBe(2); + await u.waitUntil(() => !u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + })); +}); + + +describe('A Groupchat Message XEP-0308 correction ', function () { + it( + "is ignored if it's from a different occupant-id", + mock.initConverse([], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID]; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const msg_id = u.getUniqueId(); + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/newguy" + to="_converse.connection.jid" + type="groupchat" + id="${msg_id}"> + + <body>But soft, what light through yonder airlock breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="1"></occupant-id> + </message>` + ); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/newguy" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder chimney breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="2"></occupant-id> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(model.messages.length).toBe(2); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + expect(model.messages.at(0).get('edited')).toBeFalsy(); + + expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(1).get('edited')).toBeTruthy(); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/newguy" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder hatch breaks?</body> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3); + expect(model.messages.length).toBe(3); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + expect(model.messages.at(0).get('edited')).toBeFalsy(); + + expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(1).get('edited')).toBeTruthy(); + + expect(model.messages.at(2).get('body')).toBe('But soft, what light through yonder hatch breaks?'); + expect(model.messages.at(2).get('edited')).toBeTruthy(); + + const message_els = Array.from(view.querySelectorAll('.chat-msg')); + expect(message_els.reduce((acc, m) => acc && u.hasClass('chat-msg--followup', m), true)).toBe(false); + }) + ); + + it( + "cannot be edited if it's from a different occupant id", + mock.initConverse([], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID]; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + + expect(model.get('occupant_id')).toBe(model.occupants.at(0).get('occupant_id')); + + const msg_id = u.getUniqueId(); + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/${nick}" + to="_converse.connection.jid" + type="groupchat" + id="${msg_id}"> + + <body>But soft, what light through yonder airlock breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${model.get('occupant_id')}"></occupant-id> + </message>` + ); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder airlock breaks?'); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/${nick}" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder chimney breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${model.get('occupant_id')}"></occupant-id> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(0).get('edited')).toBeTruthy(); + + await model.handleMessageStanza( + stx` + <message + xmlns="jabber:server" + from="lounge@montague.lit/${nick}" + to="_converse.connection.jid" + type="groupchat" + id="${u.getUniqueId()}"> + + <body>But soft, what light through yonder hatch breaks?</body> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="${u.getUniqueId()}"></occupant-id> + <replace id="${msg_id}" xmlns="urn:xmpp:message-correct:0"></replace> + </message>` + ); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(model.messages.length).toBe(2); + expect(model.messages.at(0).get('body')).toBe('But soft, what light through yonder chimney breaks?'); + expect(model.messages.at(0).get('edited')).toBeTruthy(); + expect(model.messages.at(0).get('editable')).toBeTruthy(); + + expect(model.messages.at(1).get('body')).toBe('But soft, what light through yonder hatch breaks?'); + expect(model.messages.at(1).get('edited')).toBeTruthy(); + expect(model.messages.at(1).get('editable')).toBeFalsy(); + + const message_els = Array.from(view.querySelectorAll('.chat-msg')); + expect(message_els.reduce((acc, m) => acc && u.hasClass('chat-msg--followup', m), true)).toBe(false); + + // We can edit our own message, but not the other + expect(message_els[0].querySelector('converse-dropdown .chat-msg__action-edit')).toBeDefined(); + expect(message_els[1].querySelector('converse-dropdown .chat-msg__action-edit')).toBe(null); + }) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js new file mode 100644 index 0000000..13376b0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/disco.js @@ -0,0 +1,66 @@ +/*global mock, converse */ + +describe("Service Discovery", function () { + + it("can be used to set the muc_domain", mock.initConverse( ['discoInitialized'], {}, async function (_converse) { + const { u, $iq } = converse.env; + const IQ_stanzas = _converse.connection.IQ_stanzas; + const IQ_ids = _converse.connection.IQ_ids; + const { api } = _converse; + + expect(api.settings.get('muc_domain')).toBe(undefined); + + await u.waitUntil(() => IQ_stanzas.filter( + (iq) => iq.querySelector(`iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).length > 0 + ); + + let stanza = IQ_stanzas.find((iq) => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')); + const info_IQ_id = IQ_ids[IQ_stanzas.indexOf(stanza)]; + stanza = $iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': info_IQ_id + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { 'category': 'server', 'type': 'im'}).up() + .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms'}).up() + .c('feature', { 'var': 'http://jabber.org/protocol/disco#info'}).up() + .c('feature', { 'var': 'http://jabber.org/protocol/disco#items'}).up(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + + stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector('iq[to="montague.lit"] query[xmlns="http://jabber.org/protocol/disco#items"]')).pop() + ); + + _converse.connection._dataRecv(mock.createRequest($iq({ + 'type': 'result', + 'from': 'montague.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': IQ_ids[IQ_stanzas.indexOf(stanza)] + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#items'}) + .c('item', { 'jid': 'chat.shakespeare.lit', 'name': 'Chatroom Service'}))); + + stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector('iq[to="chat.shakespeare.lit"] query[xmlns="http://jabber.org/protocol/disco#info"]')).pop() + ); + _converse.connection._dataRecv(mock.createRequest($iq({ + 'type': 'result', + 'from': 'chat.shakespeare.lit', + 'to': 'romeo@montague.lit/orchard', + 'id': IQ_ids[IQ_stanzas.indexOf(stanza)] + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { 'category': 'conference', 'name': 'Play-Specific Chatrooms', 'type': 'text'}).up() + .c('feature', { 'var': 'http://jabber.org/protocol/muc'}).up())); + + const entities = await _converse.api.disco.entities.get(); + expect(entities.length).toBe(3); // We have an extra entity, which is the user's JID + expect(entities.get(_converse.domain).identities.length).toBe(2); + expect(entities.get('montague.lit').features.where( + {'var': 'http://jabber.org/protocol/disco#items'}).length).toBe(1); + expect(entities.get('montague.lit').features.where( + {'var': 'http://jabber.org/protocol/disco#info'}).length).toBe(1); + + await u.waitUntil(() => _converse.api.settings.get('muc_domain') === 'chat.shakespeare.lit'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js new file mode 100644 index 0000000..eb85da5 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/emojis.js @@ -0,0 +1,226 @@ +/*global mock, converse */ + +const { $pres, sizzle } = converse.env; +const u = converse.env.utils; + +describe("Emojis", function () { + + describe("The emoji picker", function () { + + it("is opened to autocomplete emojis in the textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':gri'; + + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + await u.waitUntil(() => view.querySelector('converse-emoji-picker .emoji-search')?.value === ':gri'); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000); + let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji', view); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:'); + expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':grin:'); + expect(visible_emojis[2].getAttribute('data-emoji')).toBe(':grinning:'); + + const picker = view.querySelector('converse-emoji-picker'); + const input = picker.querySelector('.emoji-search'); + // Test that TAB autocompletes the to first match + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + + await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker).length === 1, 1000); + visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", picker); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':grimacing:'); + expect(input.value).toBe(':grimacing:'); + + // Check that ENTER now inserts the match + const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input, 'bubbles': true}); + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + + await u.waitUntil(() => input.value === ''); + await u.waitUntil(() => textarea.value === ':grimacing: '); + + // Test that username starting with : doesn't cause issues + const presence = $pres({ + 'from': `${muc_jid}/:username`, + 'id': '27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': _converse.jid + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'some1@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + textarea.value = ':use'; + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + await u.waitUntil(() => input.value === ':use'); + visible_emojis = sizzle('.insert-emoji:not(.hidden)', picker); + expect(visible_emojis.length).toBe(0); + })); + + it("is focused to autocomplete emojis in the textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':'; + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + + const picker = view.querySelector('converse-emoji-picker'); + const input = picker.querySelector('.emoji-search'); + expect(input.value).toBe(':'); + input.value = ':gri'; + const event = { + 'target': input, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {} + }; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 3, 1000); + let emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop(); + emoji.click(); + await u.waitUntil(() => textarea.value === ':grinning: '); + textarea.value = ':grinning: :'; + message_form.onKeyDown(tab_event); + + await u.waitUntil(() => input.value === ':'); + input.value = ':grimacing'; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji', view).length === 1, 1000); + emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop(); + emoji.click(); + await u.waitUntil(() => textarea.value === ':grinning: :grimacing: '); + })); + + + it("properly inserts emojis into the chat textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-picker')); + const textarea = view.querySelector('textarea.chat-textarea'); + textarea.value = ':gri'; + + // Press tab + const tab_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 9, + 'key': 'Tab' + } + textarea.value = ':'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + const picker = view.querySelector('converse-emoji-picker'); + const input = picker.querySelector('.emoji-search'); + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + await u.waitUntil(() => input.value === ':100:'); + const enter_event = Object.assign({}, tab_event, {'keyCode': 13, 'key': 'Enter', 'target': input, 'bubbles': true}); + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + expect(textarea.value).toBe(':100: '); + + textarea.value = ':'; + message_form.onKeyDown(tab_event); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + await u.waitUntil(() => input.value === ':'); + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + await u.waitUntil(() => input.value === ':100:'); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 1, 1000); + const emoji = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden) a', view).pop(); + emoji.click(); + expect(textarea.value).toBe(':100: '); + })); + + + it("allows you to search for particular emojis", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-emoji-dropdown')); + const toolbar = view.querySelector('converse-chat-toolbar'); + toolbar.querySelector('.toggle-emojis').click(); + await u.waitUntil(() => u.isVisible(view.querySelector('.emoji-picker__lists'))); + await u.waitUntil(() => sizzle('converse-chat-toolbar .insert-emoji:not(.hidden)', view).length === 1589); + + const input = view.querySelector('.emoji-search'); + input.value = 'smiley'; + const event = { + 'target': input, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {} + }; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000); + let visible_emojis = sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); + expect(visible_emojis[1].getAttribute('data-emoji')).toBe(':smiley_cat:'); + + // Check that pressing enter without an unambiguous match does nothing + const enter_event = Object.assign({}, event, {'keyCode': 13, 'bubbles': true}); + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + expect(input.value).toBe('smiley'); + + // Check that search results update when chars are deleted + input.value = 'sm'; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 25, 1000); + + input.value = 'smiley'; + input.dispatchEvent(new KeyboardEvent('keydown', event)); + await u.waitUntil(() => sizzle('.emojis-lists__container--search .insert-emoji:not(.hidden)', view).length === 2, 1000); + + // Test that TAB autocompletes the to first match + const tab_event = Object.assign({}, event, {'keyCode': 9, 'key': 'Tab'}); + input.dispatchEvent(new KeyboardEvent('keydown', tab_event)); + + await u.waitUntil(() => input.value === ':smiley:'); + await u.waitUntil(() => sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view).length === 1, 1000); + visible_emojis = sizzle(".emojis-lists__container--search .insert-emoji:not('.hidden')", view); + expect(visible_emojis[0].getAttribute('data-emoji')).toBe(':smiley:'); + + // Check that ENTER now inserts the match + input.dispatchEvent(new KeyboardEvent('keydown', enter_event)); + await u.waitUntil(() => input.value === ''); + expect(view.querySelector('textarea.chat-textarea').value).toBe(':smiley: '); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js new file mode 100644 index 0000000..dd8a398 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/hats.js @@ -0,0 +1,74 @@ +/*global mock, converse */ + +const u = converse.env.utils; + +describe("A XEP-0317 MUC Hat", function () { + + it("can be included in a presence stanza", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const hat1_id = u.getUniqueId(); + const hat2_id = u.getUniqueId(); + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" role="participant"/> + </x> + <hats xmlns="xmpp:prosody.im/protocol/hats:1"> + <hat title="Teacher's Assistant" id="${hat1_id}"/> + <hat title="Dark Mage" id="${hat2_id}"/> + </hats> + </presence> + `))); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and Terry have entered the groupchat"); + + let hats = view.model.getOccupant("Terry").get('hats'); + expect(hats.length).toBe(2); + expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage"); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message type="groupchat" from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <body>Hello world</body> + </message> + `))); + + const msg_el = await u.waitUntil(() => view.querySelector('.chat-msg')); + let badges = Array.from(msg_el.querySelectorAll('.badge')); + expect(badges.length).toBe(2); + expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage"); + + const hat3_id = u.getUniqueId(); + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" role="participant"/> + </x> + <hats xmlns="xmpp:prosody.im/protocol/hats:1"> + <hat title="Teacher's Assistant" id="${hat1_id}"/> + <hat title="Dark Mage" id="${hat2_id}"/> + <hat title="Mad hatter" id="${hat3_id}"/> + </hats> + </presence> + `))); + + await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 3); + hats = view.model.getOccupant("Terry").get('hats'); + expect(hats.map(h => h.title).join(' ')).toBe("Teacher's Assistant Dark Mage Mad hatter"); + await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 3, 1000); + badges = Array.from(view.querySelectorAll('.chat-msg .badge')); + expect(badges.map(b => b.textContent.trim()).join(' ' )).toBe("Teacher's Assistant Dark Mage Mad hatter"); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <presence from="${muc_jid}/Terry" id="${u.getUniqueId()}" to="${_converse.jid}"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" role="participant"/> + </x> + </presence> + `))); + await u.waitUntil(() => view.model.getOccupant("Terry").get('hats').length === 0); + await u.waitUntil(() => view.querySelectorAll('.chat-msg .badge').length === 0); + })); +}) diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js new file mode 100644 index 0000000..ddbc7ea --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/http-file-upload.js @@ -0,0 +1,152 @@ +/*global mock, converse */ + +const { Strophe, sizzle, u } = converse.env; + + +describe("XEP-0363: HTTP File Upload", function () { + + describe("When not supported", function () { + describe("A file upload toolbar button", function () { + + it("does not appear in MUC chats", mock.initConverse([], {}, async (_converse) => { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], [], 'items'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => view.querySelector('.chat-toolbar .fileupload') === null); + expect(1).toBe(1); + })); + + }); + }); + + describe("When supported", function () { + + describe("A file upload toolbar button", function () { + + it("appears in MUC chats", mock.initConverse(['chatBoxesFetched'], {}, async (_converse) => { + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.lit'], 'items'); + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.lit', [], [Strophe.NS.HTTPUPLOAD], []); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit').querySelector('.fileupload')); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.querySelector('.chat-toolbar .fileupload')).not.toBe(null); + })); + + describe("when clicked and a file chosen", function () { + + it("is uploaded and sent out from a groupchat", mock.initConverse(['chatBoxesFetched'], {} ,async (_converse) => { + const base_url = 'https://conversejs.org'; + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + const send_backup = XMLHttpRequest.prototype.send; + const IQ_stanzas = _converse.connection.IQ_stanzas; + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items'); + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + + // Wait until MAM query has been sent out + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop()); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const file = { + 'type': 'image/jpeg', + 'size': '23456' , + 'lastModifiedDate': "", + 'name': "my-juliet.jpg" + }; + view.model.sendFiles([file]); + + await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length); + const iq = IQ_stanzas.pop(); + expect(Strophe.serialize(iq)).toBe( + `<iq from="romeo@montague.lit/orchard" `+ + `id="${iq.getAttribute("id")}" `+ + `to="upload.montague.tld" `+ + `type="get" `+ + `xmlns="jabber:client">`+ + `<request `+ + `content-type="image/jpeg" `+ + `filename="my-juliet.jpg" `+ + `size="23456" `+ + `xmlns="urn:xmpp:http:upload:0"/>`+ + `</iq>`); + + const message = base_url+"/logo/conversejs-filled.svg"; + const stanza = u.toStanza(` + <iq from='upload.montague.tld' + id="${iq.getAttribute('id')}" + to='romeo@montague.lit/orchard' + type='result'> + <slot xmlns='urn:xmpp:http:upload:0'> + <put url='https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/my-juliet.jpg'> + <header name='Authorization'>Basic Base64String==</header> + <header name='Cookie'>foo=bar; user=romeo</header> + </put> + <get url="${message}" /> + </slot> + </iq>`); + + spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () { + const message = view.model.messages.at(0); + const el = await u.waitUntil(() => view.querySelector('.chat-content progress')); + expect(el.getAttribute('value')).toBe('0'); + message.set('progress', 0.5); + await u.waitUntil(() => view.querySelector('.chat-content progress').getAttribute('value') === '0.5') + message.set('progress', 1); + await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1') + message.save({ + 'upload': _converse.SUCCESS, + 'oob_url': message.get('get'), + 'body': message.get('get'), + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + }); + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => sent_stanza, 1000); + expect(Strophe.serialize(sent_stanza)).toBe( + `<message `+ + `from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="lounge@montague.lit" `+ + `type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>${message}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<x xmlns="jabber:x:oob">`+ + `<url>${message}</url>`+ + `</x>`+ + `<origin-id id="${sent_stanza.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + const img_link_el = await u.waitUntil(() => view.querySelector('converse-chat-message-body .chat-image__link'), 1000); + // Check that the image renders + expect(img_link_el.outerHTML.replace(/<!-.*?->/g, '').trim()).toEqual( + `<a class="chat-image__link" target="_blank" rel="noopener" href="${base_url}/logo/conversejs-filled.svg">`+ + `<img class="chat-image img-thumbnail" loading="lazy" src="${base_url}/logo/conversejs-filled.svg"></a>`); + + expect(view.querySelector('.chat-msg .chat-msg__media')).toBe(null); + XMLHttpRequest.prototype.send = send_backup; + })); + + + }); + }); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js new file mode 100644 index 0000000..ad88e70 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/info-messages.js @@ -0,0 +1,72 @@ +/*global mock, converse */ + +const u = converse.env.utils; + +describe("an info message", function () { + + it("is not rendered as a followup message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + let presence = u.toStanza(` + <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <status code="201"/> + <item role="moderator" affiliation="owner" jid="${_converse.jid}"/> + <status code="110"/> + </x> + </presence> + `); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + + presence = u.toStanza(` + <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo1"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <status code="210"/> + <item role="moderator" affiliation="owner" jid="${_converse.jid}"/> + <status code="110"/> + </x> + </presence> + `); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + + const messages = view.querySelectorAll('.chat-info'); + expect(u.hasClass('chat-msg--followup', messages[0])).toBe(false); + expect(u.hasClass('chat-msg--followup', messages[1])).toBe(false); + })); + + it("is not shown if its a duplicate", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const presence = u.toStanza(` + <presence xmlns="jabber:client" to="${_converse.jid}" from="${muc_jid}/romeo"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <status code="201"/> + <item role="moderator" affiliation="owner" jid="${_converse.jid}"/> + <status code="110"/> + </x> + </presence> + `); + // XXX: We wait for createInfoMessages to complete, if we don't + // we still get two info messages due to messages + // created from presences not being queued and run + // sequentially (i.e. by waiting for promises to resolve) + // like we do with message stanzas. + spyOn(view.model, 'createInfoMessages').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.createInfoMessages.calls.count()); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.createInfoMessages.calls.count() === 2); + expect(view.querySelectorAll('.chat-info').length).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js new file mode 100644 index 0000000..9f46e3e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mam.js @@ -0,0 +1,193 @@ +/*global mock, converse */ + +const { Strophe, $msg, $pres } = converse.env; +const u = converse.env.utils; + +describe("A MAM archived message", function () { + + it("will appear in the correct order", + mock.initConverse([], {}, async function (_converse) { + + const nick = 'romeo'; + const muc_jid = 'room@muc.example.com'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + + const messages = [ + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="9fe1a9d9-c979-488c-93a4-8a3c4dcbc63e"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:20Z"/> + <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="bc4caee0-380a-4f08-b20b-9015177a95bb"> + <body>first message</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="bc4caee0-380a-4f08-b20b-9015177a95bb"/> + </message> + </forwarded> + </result> + </message>`), + + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="64f68d52-76e6-4fa6-93ef-9fbf96bb237b"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:51:25Z"/> + <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="7aae4842-6a8b-4a10-a9c4-47cc408650ef"> + <body>2nd message</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="7aae4842-6a8b-4a10-a9c4-47cc408650ef"/> + </message> + </forwarded> + </result> + </message>`), + + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2c07703-b285-4529-a4b4-12594f749c58"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:17Z"/> + <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="hDs1J0QHfimjggw2"> + <store xmlns="urn:xmpp:hints"/> + <event xmlns="http://jabber.org/protocol/pubsub#event"> + <items node="urn:ietf:params:xml:ns:conference-info"> + <item id="wGkBOwEymL2l10Fj"> + <conference-info xmlns="urn:ietf:params:xml:ns:conference-info"> + <activity xmlns="http://jabber.org/protocol/activity"> + <other/> + <text id="activity-text">An anonymous user has tipped romeo 1 karma</text> + <reason>Thanks for your help the other day</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message> + </forwarded> + </result> + </message>`), + + u.toStanza(` + <message to="${_converse.connection.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="c03f0f53-8501-4ed9-9261-2eddd055486c" id="c2b2b039-f808-4b4c-bfbd-607173e012f9"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2021-10-13T17:52:22Z"/> + <message xmlns="jabber:client" xml:lang="en" from="${muc_jid}/dadmin" type="groupchat" id="ae0ab34c-4ff1-45c0-ab56-5231cc220424"> + <body>4th message</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="ae0ab34c-4ff1-45c0-ab56-5231cc220424"/> + </message> + </forwarded> + </result> + </message>`) + ] + spyOn(model, 'updateMessage'); + _converse.handleMAMResult(model, { messages }); + + await u.waitUntil(() => model.messages.length === 4); + expect(model.messages.at(0).get('time')).toBe('2021-10-13T17:51:20.000Z'); + expect(model.messages.at(1).get('time')).toBe('2021-10-13T17:51:25.000Z'); + expect(model.messages.at(2).get('time')).toBe('2021-10-13T17:52:17.000Z'); + expect(model.messages.at(3).get('time')).toBe('2021-10-13T17:52:22.000Z'); + })); + + it("is ignored if it has the same archive-id of an already received one", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'room@muc.example.com'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + spyOn(model, 'getDuplicateMessage').and.callThrough(); + let stanza = u.toStanza(` + <message xmlns="jabber:client" + from="room@muc.example.com/some1" + to="${_converse.connection.jid}" + type="groupchat"> + <body>Typical body text</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="room@muc.example.com"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => model.messages.length === 1); + await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 1); + let result = await model.getDuplicateMessage.calls.all()[0].returnValue; + expect(result).toBe(undefined); + + stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.connection.jid}" + from="room@muc.example.com"> + <result xmlns="urn:xmpp:mam:2" queryid="82d9db27-6cf8-4787-8c2c-5a560263d823" id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:17:23Z"/> + <message from="room@muc.example.com/some1" type="groupchat"> + <body>Typical body text</body> + </message> + </forwarded> + </result> + </message>`); + + spyOn(model, 'updateMessage'); + _converse.handleMAMResult(model, { 'messages': [stanza] }); + await u.waitUntil(() => model.getDuplicateMessage.calls.count() === 2); + result = await model.getDuplicateMessage.calls.all()[1].returnValue; + expect(result instanceof _converse.Message).toBe(true); + expect(model.messages.length).toBe(1); + await u.waitUntil(() => model.updateMessage.calls.count()); + })); + + it("will be discarded if it's a malicious message meant to look like a carbon copy", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + const muc_jid = 'xsf@muc.xmpp.org'; + const sender_jid = `${muc_jid}/romeo`; + const impersonated_jid = `${muc_jid}/i_am_groot` + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const stanza = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: sender_jid + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + /* + * <message to="romeo@montague.im/poezio" id="718d40df-3948-4798-a99b-35cc9f03cc4f-641" type="groupchat" from="xsf@muc.xmpp.org/romeo"> + * <received xmlns="urn:xmpp:carbons:2"> + * <forwarded xmlns="urn:xmpp:forward:0"> + * <message xmlns="jabber:client" to="xsf@muc.xmpp.org" type="groupchat" from="xsf@muc.xmpp.org/i_am_groot"> + * <body>I am groot.</body> + * </message> + * </forwarded> + * </received> + * </message> + */ + const msg = $msg({ + 'from': sender_jid, + 'id': _converse.connection.getUniqueId(), + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'xmlns': 'jabber:client' + }).c('received', {'xmlns': 'urn:xmpp:carbons:2'}) + .c('forwarded', {'xmlns': 'urn:xmpp:forward:0'}) + .c('message', { + 'xmlns': 'jabber:client', + 'from': impersonated_jid, + 'to': muc_jid, + 'type': 'groupchat' + }).c('body').t('I am groot').tree(); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(converse.env.log, 'error'); + await _converse.handleMAMResult(model, { 'messages': [msg] }); + await u.waitUntil(() => converse.env.log.error.calls.count()); + expect(converse.env.log.error).toHaveBeenCalledWith( + 'Invalid Stanza: MUC messages SHOULD NOT be XEP-0280 carbon copied' + ); + expect(view.querySelectorAll('.chat-msg').length).toBe(0); + expect(model.messages.length).toBe(0); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js new file mode 100644 index 0000000..f432a2b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/markers.js @@ -0,0 +1,68 @@ +/*global mock, converse */ + +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("A XEP-0333 Chat Marker", function () { + it("may be returned for a MUC message", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim()) + .toBe("But soft, what light through yonder airlock breaks?"); + + const msg_obj = view.model.messages.at(0); + let stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <received xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <displayed xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <acknowledged xmlns="urn:xmpp:chat-markers:0" id="${msg_obj.get('msgid')}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <body>'tis I!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js new file mode 100644 index 0000000..b42e00c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/me-messages.js @@ -0,0 +1,56 @@ +/*global mock, converse */ + +const { u, sizzle, $msg } = converse.env; + + +describe("A Groupchat Message", function () { + + it("supports the /me command", mock.initConverse([], {}, async function (_converse) { + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname')); + await mock.waitForRoster(_converse, 'current'); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + if (!view.querySelectorAll('.chat-area').length) { + view.renderChatArea(); + } + let message = '/me is tired'; + const nick = mock.chatroom_names[0]; + let msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop()); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.trim() === 'is tired'); + expect(view.querySelector('.chat-msg__author').textContent.includes('**Dyon van de Wege')).toBeTruthy(); + + message = '/me is as well'; + msg = $msg({ + from: 'lounge@montague.lit/Romeo Montague', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + await u.waitUntil(() => Array.from(view.querySelectorAll('.chat-msg__text')).pop().textContent.trim() === 'is as well'); + expect(sizzle('.chat-msg__author:last', view).pop().textContent.includes('**Romeo Montague')).toBeTruthy(); + + // Check rendering of a mention inside a me message + const msg_text = "/me mentions romeo"; + msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'13', 'end':'19', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3); + await u.waitUntil(() => sizzle('.chat-msg__text:last', view).pop().innerHTML.replace(/<!-.*?->/g, '') === + 'mentions <span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">romeo</span>'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js new file mode 100644 index 0000000..fc0b9d1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/member-lists.js @@ -0,0 +1,301 @@ +/*global mock, converse */ +const { $iq, Strophe, u } = converse.env; + +describe("A Groupchat", function () { + + describe("upon being entered", function () { + + it("will fetch the member list if muc_fetch_members is true", + mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) { + + const { api } = _converse; + let sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let view = _converse.chatboxviews.get(muc_jid); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(3); + + // Check in reverse order that we requested all three lists + const owner_iq = sent_IQs.pop(); + expect(Strophe.serialize(owner_iq)).toBe( + `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+ + `</iq>`); + + const admin_iq = sent_IQs.pop(); + expect(Strophe.serialize(admin_iq)).toBe( + `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+ + `</iq>`); + + const member_iq = sent_IQs.pop(); + expect(Strophe.serialize(member_iq)).toBe( + `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+ + `</iq>`); + view.close(); + + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + api.settings.set('muc_fetch_members', false); + await mock.openAndEnterChatRoom(_converse, 'orchard@montague.lit', 'romeo'); + view = _converse.chatboxviews.get('orchard@montague.lit'); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0); + await view.close(); + + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + api.settings.set('muc_fetch_members', ['admin']); + await mock.openAndEnterChatRoom(_converse, 'courtyard@montague.lit', 'romeo'); + view = _converse.chatboxviews.get('courtyard@montague.lit'); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="admin"]')).length).toBe(1); + view.close(); + + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + api.settings.set('muc_fetch_members', ['owner']); + await mock.openAndEnterChatRoom(_converse, 'garden@montague.lit', 'romeo'); + view = _converse.chatboxviews.get('garden@montague.lit'); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(1); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation="owner"]')).length).toBe(1); + view.close(); + })); + + it("will not fetch the member list if the user is not affiliated", + mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const sent_IQs = _converse.connection.IQ_stanzas; + spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough(); + // Join MUC without an affiliation + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], [], true, {}, 'none', 'participant'); + await u.waitUntil(() => model.occupants.fetchMembers.calls.count()); + expect(sent_IQs.filter(iq => iq.querySelector('query item[affiliation]')).length).toBe(0); + })); + + describe("when fetching the member lists", function () { + + it("gracefully handles being forbidden from fetching the lists for certain affiliations", + mock.initConverse([], {'muc_fetch_members': true}, async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_hidden', + 'muc_membersonly', + 'muc_passwordprotected', + Strophe.NS.MAM, + Strophe.NS.SID + ]; + const nick = 'romeo'; + await _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid, features); + await mock.waitForReservedNick(_converse, muc_jid, nick); + mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + + // Check in reverse order that we requested all three lists + const owner_iq = sent_IQs.pop(); + expect(Strophe.serialize(owner_iq)).toBe( + `<iq id="${owner_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+ + `</iq>`); + const admin_iq = sent_IQs.pop(); + expect(Strophe.serialize(admin_iq)).toBe( + `<iq id="${admin_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+ + `</iq>`); + const member_iq = sent_IQs.pop(); + expect(Strophe.serialize(member_iq)).toBe( + `<iq id="${member_iq.getAttribute('id')}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+ + `</iq>`); + + // It might be that the user is not allowed to fetch certain lists. + let err_stanza = u.toStanza( + `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${admin_iq.getAttribute('id')}"> + <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(err_stanza)); + + err_stanza = u.toStanza( + `<iq xmlns="jabber:client" type="error" to="${_converse.jid}" from="${muc_jid}" id="${owner_iq.getAttribute('id')}"> + <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(err_stanza)); + + // Now the service sends the member lists to the user + const member_list_stanza = $iq({ + 'from': muc_jid, + 'id': member_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'member', + 'jid': 'hag66@shakespeare.lit', + 'nick': 'thirdwitch', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(member_list_stanza)); + + await u.waitUntil(() => view.model.occupants.length > 1); + expect(view.model.occupants.length).toBe(2); + // The existing owner occupant should not have their + // affiliation removed due to the owner list + // not being returned (forbidden err). + expect(view.model.occupants.findWhere({'jid': _converse.bare_jid}).get('affiliation')).toBe('owner'); + expect(view.model.occupants.findWhere({'jid': 'hag66@shakespeare.lit'}).get('affiliation')).toBe('member'); + })); + }); + }); +}); + +describe("Someone being invited to a groupchat", function () { + + it("will first be added to the member list if the groupchat is members only", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough(); + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'coven@chat.shakespeare.lit'; + const nick = 'romeo'; + const room_creation_promise = _converse.api.rooms.open(muc_jid, {nick}); + + // Check that the groupchat queried for the features. + let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`)).pop()); + expect(Strophe.serialize(stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/disco#info"/>`+ + `</iq>`); + + // State that the chat is members-only via the features IQ + const view = _converse.chatboxviews.get(muc_jid); + const features_stanza = $iq({ + from: 'coven@chat.shakespeare.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_membersonly'}).up(); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop()); + expect(view.model.features.get('membersonly')).toBeTruthy(); + + await room_creation_promise; + await mock.createContacts(_converse, 'current'); + + let sent_stanza, sent_id; + spyOn(_converse.connection, 'send').and.callFake(function (stanza) { + if (stanza.nodeName === 'message') { + sent_id = stanza.getAttribute('id'); + sent_stanza = stanza; + } + }); + const invitee_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const reason = "Please join this groupchat"; + view.model.directInvite(invitee_jid, reason); + + // Check in reverse order that we requested all three lists + const owner_iq = sent_IQs.pop(); + expect(Strophe.serialize(owner_iq)).toBe( + `<iq id="${owner_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="owner"/></query>`+ + `</iq>`); + + const admin_iq = sent_IQs.pop(); + expect(Strophe.serialize(admin_iq)).toBe( + `<iq id="${admin_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="admin"/></query>`+ + `</iq>`); + + const member_iq = sent_IQs.pop(); + expect(Strophe.serialize(member_iq)).toBe( + `<iq id="${member_iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin"><item affiliation="member"/></query>`+ + `</iq>`); + + // Now the service sends the member lists to the user + const member_list_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': member_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'member', + 'jid': 'hag66@shakespeare.lit', + 'nick': 'thirdwitch', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(member_list_stanza)); + + const admin_list_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': admin_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'admin', + 'jid': 'wiccarocks@shakespeare.lit', + 'nick': 'secondwitch' + }); + _converse.connection._dataRecv(mock.createRequest(admin_list_stanza)); + + const owner_list_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': owner_iq.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }).c('query', {'xmlns': Strophe.NS.MUC_ADMIN}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'crone1@shakespeare.lit', + }); + _converse.connection._dataRecv(mock.createRequest(owner_list_stanza)); + + // Converse puts the user on the member list + stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/muc#admin"]`)).pop()); + expect(stanza.outerHTML, + `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="member" jid="${invitee_jid}">`+ + `<reason>Please join this groupchat</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + const result = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/orchard', + 'type': 'result' + }); + _converse.connection._dataRecv(mock.createRequest(result)); + + await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count()); + + // Finally check that the user gets invited. + expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec) + `<message from="romeo@montague.lit/orchard" id="${sent_id}" to="${invitee_jid}" xmlns="jabber:client">`+ + `<x jid="coven@chat.shakespeare.lit" reason="Please join this groupchat" xmlns="jabber:x:conference"/>`+ + `</message>` + ); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js new file mode 100644 index 0000000..72deb3f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mentions.js @@ -0,0 +1,548 @@ +/*global mock, converse */ + +const { Strophe, $msg, $pres, sizzle } = converse.env; +const u = converse.env.utils; + + +describe("An incoming groupchat message", function () { + + it("is specially marked when you are mentioned in it", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); } + const message = 'romeo: Your attention is required'; + const nick = mock.chatroom_names[0], + msg = $msg({ + from: 'lounge@montague.lit/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(u.hasClass('mentioned', view.querySelector('.chat-msg'))).toBeTruthy(); + })); + + + it("highlights all users mentioned via XEP-0372 references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + })) + ); + }); + let msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('hello z3r0 tom mr.robot, how are you?').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'6', 'end':'10', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'11', 'end':'14', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'15', 'end':'23', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') === + 'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+ + '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+ + '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?'); + let message = view.querySelector('.chat-msg__text'); + expect(message.classList.length).toEqual(1); + + msg = $msg({ + from: 'lounge@montague.lit/sw0rdf1sh', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('@gibson').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'1', 'end':'7', 'type':'mention', 'uri':'xmpp:gibson@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + message = sizzle('converse-chat-message:last .chat-msg__text', view).pop(); + expect(message.classList.length).toEqual(1); + expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('@<span class="mention" data-uri="xmpp:gibson@montague.lit">gibson</span>'); + })); + + it("properly renders mentions that contain the pipe character", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'romeo@montague.lit/resource', + 'from': `lounge@montague.lit/ThUnD3r|Gr33n` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + })) + ); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello @ThUnD3r|Gr33n' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const sent_stanzas = _converse.connection.sent_stanzas; + const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop()); + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello ThUnD3r|Gr33n</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('hello <span class="mention" data-uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n">ThUnD3r|Gr33n</span>'); + })); + + it("highlights all users mentioned via XEP-0372 references in a quoted message", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + })) + ); + }); + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('>hello z3r0 tom mr.robot, how are you?').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'7', 'end':'11', 'type':'mention', 'uri':'xmpp:z3r0@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'12', 'end':'15', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'16', 'end':'24', 'type':'mention', 'uri':'xmpp:mr.robot@montague.lit'}).nodeTree; + + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-msg__text')?.innerHTML.replace(/<!-.*?->/g, '') === + '<blockquote>hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+ + '<span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">tom</span> '+ + '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?</blockquote>'); + const message = view.querySelector('.chat-msg__text'); + expect(message.classList.length).toEqual(1); + })); +}); + + +describe("A sent groupchat message", function () { + + describe("in which someone is mentioned", function () { + + it("gets parsed for mentions which get turned into references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + + // Making the MUC non-anonymous so that real JIDs are included + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + Strophe.NS.SID, + Strophe.NS.MAM, + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_open', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', features); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh', 'Link Mauve', 'robot'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Also check that nicks from received messages, (but for which we don't have occupant objects) can be mentioned. + const stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${muc_jid}/gh0st" + to="${_converse.connection.bare_jid}" + type="groupchat"> + <body>Boo!</body> + </message>`); + await view.model.handleMessageStanza(stanza); + + // Run a few unit tests for the parseTextForReferences method + let [text, references] = view.model.parseTextForReferences('yo @robot') + expect(text).toBe('yo robot'); + expect(references) + .toEqual([{"begin":3,"end":8,"value":"robot","type":"mention","uri":"xmpp:robot@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('@@gh0st') + expect(text).toBe('@gh0st'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":1,"end":6,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]); + + [text, references] = view.model.parseTextForReferences('hello z3r0') + expect(references.length).toBe(0); + expect(text).toBe('hello z3r0'); + + [text, references] = view.model.parseTextForReferences('hello @z3r0') + expect(references.length).toBe(1); + expect(text).toBe('hello z3r0'); + expect(references) + .toEqual([{"begin":6,"end":10,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('hello @some1 @z3r0 @gibson @mr.robot, how are you?') + expect(text).toBe('hello @some1 z3r0 gibson mr.robot, how are you?'); + expect(references) + .toEqual([{"begin":13,"end":17,"value":"z3r0","type":"mention","uri":"xmpp:z3r0@montague.lit"}, + {"begin":18,"end":24,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}, + {"begin":25,"end":33,"value":"mr.robot","type":"mention","uri":"xmpp:mr.robot@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('yo @gib') + expect(text).toBe('yo @gib'); + expect(references.length).toBe(0); + + [text, references] = view.model.parseTextForReferences('yo @gibsonian') + expect(text).toBe('yo @gibsonian'); + expect(references.length).toBe(0); + + [text, references] = view.model.parseTextForReferences('yo @GiBsOn') + expect(text).toBe('yo gibson'); + expect(references.length).toBe(1); + + [text, references] = view.model.parseTextForReferences('@gibson') + expect(text).toBe('gibson'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":0,"end":6,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('hi @Link Mauve how are you?') + expect(text).toBe('hi Link Mauve how are you?'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":3,"end":13,"value":"Link Mauve","type":"mention","uri":"xmpp:Link-Mauve@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences('https://example.org/@gibson') + expect(text).toBe('https://example.org/@gibson'); + expect(references.length).toBe(0); + expect(references).toEqual([]); + + [text, references] = view.model.parseTextForReferences('mail@gibson.com') + expect(text).toBe('mail@gibson.com'); + expect(references.length).toBe(0); + expect(references) + .toEqual([]); + + [text, references] = view.model.parseTextForReferences( + "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html"); + expect(text).toBe("Welcome gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html"); + expect(references.length).toBe(1); + expect(references).toEqual([{"begin":8,"end":14,"value":"gibson","type":"mention","uri":"xmpp:gibson@montague.lit"}]); + + [text, references] = view.model.parseTextForReferences( + 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr') + expect(text).toBe( + 'https://linkmauve.fr@Link Mauve/ https://linkmauve.fr/@github/is_back gibson@gibson.com gibson@Link Mauve.fr'); + expect(references.length).toBe(0); + expect(references) + .toEqual([]); + + [text, references] = view.model.parseTextForReferences('@gh0st where are you?') + expect(text).toBe('gh0st where are you?'); + expect(references.length).toBe(1); + expect(references) + .toEqual([{"begin":0,"end":5,"value":"gh0st","type":"mention","uri":"xmpp:lounge@montague.lit/gh0st"}]); + })); + + it("gets parsed for mentions as indicated with an @ preceded by a space or at the start of the text", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + ['NotAnAdress', 'darnuria'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick.replace(/\s/g, '-')}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + + // Test that we don't match @nick in email adresses. + let [text, references] = view.model.parseTextForReferences('contact contact@NotAnAdress.eu'); + expect(references.length).toBe(0); + expect(text).toBe('contact contact@NotAnAdress.eu'); + + // Test that we don't match @nick in url + [text, references] = view.model.parseTextForReferences('nice website https://darnuria.eu/@darnuria'); + expect(references.length).toBe(0); + expect(text).toBe('nice website https://darnuria.eu/@darnuria'); + })); + + it("properly encodes the URIs in sent out references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom'); + const view = _converse.chatboxviews.get(muc_jid); + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/Link Mauve` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'role': 'participant' + }))); + await u.waitUntil(() => view.model.occupants.length === 2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello @Link Mauve' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + const sent_stanzas = _converse.connection.sent_stanzas; + const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop()); + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello Link Mauve</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="16" type="mention" uri="xmpp:lounge@montague.lit/Link%20Mauve" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); + + it("can get corrected and given new references", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + + // Making the MUC non-anonymous so that real JIDs are included + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + Strophe.NS.SID, + Strophe.NS.MAM, + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_open', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', features); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + await u.waitUntil(() => view.model.occupants.length === 5); + + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + const last_msg_sel = 'converse-chat-message:last-child .chat-msg__text'; + await u.waitUntil(() => + view.querySelector(last_msg_sel).innerHTML.replace(/<!-.*?->/g, '') === + 'hello <span class="mention" data-uri="xmpp:z3r0@montague.lit">z3r0</span> '+ + '<span class="mention" data-uri="xmpp:gibson@montague.lit">gibson</span> '+ + '<span class="mention" data-uri="xmpp:mr.robot@montague.lit">mr.robot</span>, how are you?' + ); + + const sent_stanzas = _converse.connection.sent_stanzas; + const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop()); + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello z3r0 gibson mr.robot, how are you?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + const action = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action')); + action.style.opacity = 1; + action.click(); + + expect(textarea.value).toBe('hello @z3r0 @gibson @mr.robot, how are you?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + await u.waitUntil(() => u.hasClass('correcting', view.querySelector('.chat-msg')), 500); + + textarea.value = 'hello @z3r0 @gibson @sw0rdf1sh, how are you?'; + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent === + 'hello z3r0 gibson sw0rdf1sh, how are you?', 500); + + const correction = sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop(); + expect(Strophe.serialize(correction)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+ + `<replace id="${msg.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${correction.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); + + it("includes a XEP-0372 references to that person", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => { + _converse.connection._dataRecv(mock.createRequest( + $pres({ + 'to': 'tom@montague.lit/resource', + 'from': `lounge@montague.lit/${nick}` + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${nick}@montague.lit/resource`, + 'role': 'participant' + }))); + }); + await u.waitUntil(() => view.model.occupants.length === 5); + + spyOn(_converse.connection, 'send'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello @z3r0 @gibson @mr.robot, how are you?' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + const msg = _converse.connection.send.calls.all()[1].args[0]; + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="lounge@montague.lit" type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>hello z3r0 gibson mr.robot, how are you?</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>`+ + `<reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); + }); + + it("highlights all users mentioned via XEP-0372 references in a quoted message", + mock.initConverse([], {}, async function (_converse) { + + const members = [{'jid': 'gibson@gibson.net', 'nick': 'gibson', 'affiliation': 'member'}]; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom', [], members); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = "Welcome @gibson 💩 We have a guide on how to do that here: https://conversejs.org/docs/html/index.html"; + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.innerHTML.replace(/<!-.*?->/g, '')).toEqual( + `Welcome <span class="mention" data-uri="xmpp:${muc_jid}/gibson">gibson</span> <span title=":poop:">💩</span> `+ + `We have a guide on how to do that here: `+ + `<a target="_blank" rel="noopener" href="https://conversejs.org/docs/html/index.html">https://conversejs.org/docs/html/index.html</a>`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js new file mode 100644 index 0000000..cb638f1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/mep.js @@ -0,0 +1,247 @@ +/*global mock, converse */ + +const { u, Strophe } = converse.env; + +describe("A XEP-0316 MEP notification", function () { + + it("is rendered as an info message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + let msg = 'An anonymous user has saluted romeo'; + let reason = 'Thank you for helping me yesterday'; + let message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + + // Check that duplicates aren't created + _converse.connection._dataRecv(mock.createRequest(message)); + let promise = u.getOpenPromise(); + setTimeout(() => { + expect(view.querySelectorAll('.chat-info').length).toBe(1); + promise.resolve(); + }, 250); + await promise; + + // Also check a MEP message of type "groupchat" + msg = 'An anonymous user has poked romeo'; + reason = 'Can you please help me with something else?'; + message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='groupchat' + id='zns61f39'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="28" end="33" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + expect(view.querySelector('converse-chat-message:last-child .chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('converse-chat-message:last-child .reason').textContent.trim()).toBe(reason); + + // Check that duplicates aren't created + _converse.connection._dataRecv(mock.createRequest(message)); + promise = u.getOpenPromise(); + setTimeout(() => { + expect(view.querySelectorAll('.chat-info').length).toBe(2); + promise.resolve(); + }, 250); + return promise; + })); + + it("can trigger a notification if sent to a hidden MUC", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + // spyOn(window, 'Notification').and.returnValue(stub); + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true, {'hidden': true}); + const msg = 'An anonymous user has saluted romeo'; + const reason = 'Thank you for helping me yesterday'; + const message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => model.messages.length === 1); + // expect(window.Notification.calls.count()).toBe(1); + + model.set('hidden', false); + + const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + })); + + it("renders URLs as links", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], [], true); + const msg = 'An anonymous user has waved at romeo'; + const reason = 'Check out https://conversejs.org'; + const message = u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="31" end="37" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => model.messages.length === 1); + + const view = await u.waitUntil(() => _converse.chatboxviews.get(muc_jid)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1, 1000); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason converse-rich-text').innerHTML.replace(/<!-.*?->/g, '').trim()).toBe( + 'Check out <a target="_blank" rel="noopener" href="https://conversejs.org/">https://conversejs.org</a>'); + })); + + it("can be retracted by a moderator", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + const view = _converse.chatboxviews.get(muc_jid); + const msg = 'An anonymous user has saluted romeo'; + const reason = 'Thank you for helping me yesterday'; + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message from='${muc_jid}' + to='${_converse.jid}' + type='headline' + id='zns61f38'> + <event xmlns='http://jabber.org/protocol/pubsub#event'> + <items node='urn:ietf:params:xml:ns:conference-info'> + <item id='ehs51f40'> + <conference-info xmlns='urn:ietf:params:xml:ns:conference-info'> + <activity xmlns='http://jabber.org/protocol/activity'> + <other/> + <text id="activity-text" xml:lang="en">${msg}</text> + <reference anchor="activity-text" xmlns="urn:xmpp:reference:0" begin="30" end="35" type="mention" uri="xmpp:${_converse.bare_jid}"/> + <reason id="activity-reason">${reason}</reason> + </activity> + </conference-info> + </item> + </items> + </event> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message>` + ))); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 1); + expect(view.querySelector('.chat-info__message converse-rich-text').textContent.trim()).toBe(msg); + expect(view.querySelector('.reason').textContent.trim()).toBe(reason); + expect(view.querySelectorAll('converse-message-actions converse-dropdown .chat-msg__action').length).toBe(1); + const action = view.querySelector('converse-message-actions converse-dropdown .chat-msg__action'); + expect(action.textContent.trim()).toBe('Retract'); + action.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+ + `<moderate xmlns="urn:xmpp:message-moderate:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `<reason></reason>`+ + `</moderate>`+ + `</apply-to>`+ + `</iq>`); + + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/${nick}"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason></reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(''); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + const msg_el = view.querySelector('.chat-msg--retracted .chat-info__message div'); + expect(msg_el.textContent).toBe(`${nick} has removed this message`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js new file mode 100644 index 0000000..574755f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/modtools.js @@ -0,0 +1,481 @@ +/*global mock, converse, _ */ + +const $iq = converse.env.$iq; +const $pres = converse.env.$pres; +const sizzle = converse.env.sizzle; +const Strophe = converse.env.Strophe; +const u = converse.env.utils; + + +async function openModtools (_converse, view) { + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/modtools'; + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); + await u.waitUntil(() => u.isVisible(modal), 1000); + return modal; +} + +describe("The groupchat moderator tool", function () { + + it("allows you to set affiliations and roles", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + + let members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 5), 1000); + + const modal = await openModtools(_converse, view); + const tab = modal.querySelector('#affiliations-tab'); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + tab.click(); + let select = modal.querySelector('.select-affiliation'); + expect(select.value).toBe('owner'); + select.value = 'admin'; + let button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length); + let user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(1); + expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: wiccarocks@shakespeare.lit'); + expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: wiccan'); + expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: admin'); + + _converse.connection.IQ_stanzas = []; + select.value = 'owner'; + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2); + user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(2); + expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit'); + expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo'); + expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner'); + + expect(user_els[1].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: crone1@shakespeare.lit'); + expect(user_els[1].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: thirdwitch'); + expect(user_els[1].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner'); + + const toggle = user_els[1].querySelector('.list-group-item:nth-child(3n) .toggle-form'); + const component = user_els[1].querySelector('.list-group-item:nth-child(3n) converse-muc-affiliation-form'); + expect(u.hasClass('hidden', component)).toBeTruthy(); + toggle.click(); + expect(u.hasClass('hidden', component)).toBeFalsy(); + + const form = user_els[1].querySelector('.list-group-item:nth-child(3n) .affiliation-form'); + select = form.querySelector('.select-affiliation'); + expect(select.value).toBe('owner'); + select.value = 'admin'; + const input = form.querySelector('input[name="reason"]'); + input.value = "You're an admin now"; + const submit = form.querySelector('.btn-primary'); + submit.click(); + + spyOn(_converse.ChatRoomOccupants.prototype, 'fetchMembers').and.callThrough(); + const sent_IQ = _converse.connection.IQ_stanzas.pop(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="admin" jid="crone1@shakespeare.lit">`+ + `<reason>You're an admin now</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + _converse.connection.IQ_stanzas = []; + const stanza = $iq({ + 'type': 'result', + 'id': sent_IQ.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.occupants.fetchMembers.calls.count()); + + members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'admin'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.returnMemberLists(_converse, muc_jid, members); + await u.waitUntil(() => view.model.occupants.pluck('affiliation').filter(o => o === 'owner').length === 1); + const alert = modal.querySelector('.alert-primary'); + expect(alert.textContent.trim()).toBe('Affiliation changed'); + + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1); + user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(1); + expect(user_els[0].querySelector('.list-group-item.active').textContent.trim()).toBe('JID: romeo@montague.lit'); + expect(user_els[0].querySelector('.list-group-item:nth-child(2n)').textContent.trim()).toBe('Nickname: romeo'); + expect(user_els[0].querySelector('.list-group-item:nth-child(3n) div').textContent.trim()).toBe('Affiliation: owner'); + + modal.querySelector('#roles-tab').click(); + select = modal.querySelector('.select-role'); + await u.waitUntil(() => u.isVisible(select)); + + expect(select.value).toBe('moderator'); + button = modal.querySelector('.btn-primary[name="users_with_role"]'); + button.click(); + + const roles_panel = modal.querySelector('#roles-tabpanel'); + await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li').length === 1); + select.value = 'participant'; + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => roles_panel.querySelectorAll('.list-group--users > li')[0]?.textContent.trim() === 'No users with that role found.'); + + })); + + it("allows you to filter affiliation search results", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'member'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'member'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'member'}, + {'jid': 'juliet@capulet.lit', 'nick': 'juliet', 'affiliation': 'member'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 6), 1000); + + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + const modal = await openModtools(_converse, view); + const select = modal.querySelector('.select-affiliation'); + expect(select.value).toBe('owner'); + select.value = 'member'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6); + + const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick')); + expect(nicks.join(' ')).toBe('gower juliet romeo thirdwitch wiccan witch'); + + const filter = modal.querySelector('[name="filter"]'); + expect(filter).not.toBe(null); + + filter.value = 'romeo'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + filter.value = 'r'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 3)); + + filter.value = 'gower'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + filter.value = 'RoMeO'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + })); + + it("allows you to filter role search results", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []); + const view = _converse.chatboxviews.get(muc_jid); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `nomorenicks@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/newb`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `newb@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/some1`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `some1@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/oldhag`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `oldhag@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/crone`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `crone@montague.lit`, + 'role': 'participant' + }) + )); + _converse.connection._dataRecv(mock.createRequest( + $pres({to: _converse.jid, from: `${muc_jid}/tux`}) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `tux@montague.lit`, + 'role': 'participant' + }) + )); + await u.waitUntil(() => (view.model.occupants.length === 7), 1000); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/modtools'; + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); + await u.waitUntil(() => u.isVisible(modal), 1000); + + const tab = modal.querySelector('#roles-tab'); + tab.click(); + + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + + const select = modal.querySelector('.select-role'); + expect(select.value).toBe('moderator'); + select.value = 'participant'; + + const button = modal.querySelector('.btn-primary[name="users_with_role"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_role); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 6); + + const nicks = Array.from(modal.querySelectorAll('.list-group--users > li')).map(el => el.getAttribute('data-nick')); + expect(nicks.join(' ')).toBe('crone newb nomorenicks oldhag some1 tux'); + + const filter = modal.querySelector('[name="filter"]'); + expect(filter).not.toBe(null); + + filter.value = 'tux'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + + filter.value = 'r'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 2)); + + filter.value = 'crone'; + u.triggerEvent(filter, "keyup", "KeyboardEvent"); + await u.waitUntil(() => ( modal.querySelectorAll('.list-group--users > li').length === 1)); + })); + + it("shows an error message if a particular affiliation list may not be retrieved", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'wiccarocks@shakespeare.lit', 'nick': 'wiccan', 'affiliation': 'admin'}, + {'jid': 'crone1@shakespeare.lit', 'nick': 'thirdwitch', 'affiliation': 'owner'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 5)); + const modal = await openModtools(_converse, view); + const tab = modal.querySelector('#affiliations-tab'); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + const IQ_stanzas = _converse.connection.IQ_stanzas; + tab.click(); + const select = modal.querySelector('.select-affiliation'); + select.value = 'outcast'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + + const iq_query = await u.waitUntil(() => _.filter( + IQ_stanzas, + s => sizzle(`iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_ADMIN}"] item[affiliation="outcast"]`, s).length + ).pop()); + + const error = u.toStanza( + `<iq from="${muc_jid}" + id="${iq_query.getAttribute('id')}" + type="error" + to="${_converse.jid}"> + + <error type="auth"> + <forbidden xmlns="${Strophe.NS.STANZAS}"/> + </error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(error)); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + + const alert = await u.waitUntil(() => modal.querySelector('.alert')); + expect(alert.textContent.trim()).toBe('Error: not allowed to fetch outcast list for MUC lounge@montague.lit'); + + const user_els = modal.querySelectorAll('.list-group--users > li'); + expect(user_els.length).toBe(1); + expect(user_els[0].textContent.trim()).toBe('No users with that affiliation found.'); + })); + + it("shows an error message if a particular affiliation may not be set", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 2)); + const modal = await openModtools(_converse, view); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + + const tab = modal.querySelector('#affiliations-tab'); + tab.click(); + const select = modal.querySelector('.select-affiliation'); + select.value = 'member'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 1); + + const user_els = modal.querySelectorAll('.list-group--users > li'); + const toggle = user_els[0].querySelector('.list-group-item:nth-child(3n) .toggle-form'); + const component = user_els[0].querySelector('.list-group-item:nth-child(3n) converse-muc-affiliation-form'); + expect(u.hasClass('hidden', component)).toBeTruthy(); + toggle.click(); + expect(u.hasClass('hidden', component)).toBeFalsy(); + + const form = user_els[0].querySelector('.list-group-item:nth-child(3n) .affiliation-form'); + const change_affiliation_dropdown = form.querySelector('.select-affiliation'); + expect(change_affiliation_dropdown.value).toBe('member'); + change_affiliation_dropdown.value = 'admin'; + const input = form.querySelector('input[name="reason"]'); + input.value = "You're an admin now"; + const submit = form.querySelector('.btn-primary'); + submit.click(); + + const sent_IQ = _converse.connection.IQ_stanzas.pop(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="admin" jid="gower@shakespeare.lit">`+ + `<reason>You're an admin now</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + const error = u.toStanza( + `<iq from="${muc_jid}" + id="${sent_IQ.getAttribute('id')}" + type="error" + to="${_converse.jid}"> + + <error type="cancel"> + <not-allowed xmlns="${Strophe.NS.STANZAS}"/> + </error> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(error)); + + })); + + + it("doesn't allow admins to make more admins", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [ + {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, + {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, + {'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'admin'}, + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.occupants.length === 3)); + const modal = await openModtools(_converse, view); + const tab = modal.querySelector('#affiliations-tab'); + // Clear so that we don't match older stanzas + _converse.connection.IQ_stanzas = []; + tab.click(); + const show_affiliation_dropdown = modal.querySelector('.select-affiliation'); + show_affiliation_dropdown.value = 'member'; + const button = modal.querySelector('.btn-primary[name="users_with_affiliation"]'); + button.click(); + + await u.waitUntil(() => !modal.loading_users_with_affiliation); + await u.waitUntil(() => modal.querySelectorAll('.list-group--users > li').length === 2); + + const user_els = modal.querySelectorAll('.list-group--users > li'); + let change_affiliation_dropdown = user_els[0].querySelector('.select-affiliation'); + expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']); + + change_affiliation_dropdown = user_els[1].querySelector('.select-affiliation'); + expect(Array.from(change_affiliation_dropdown.options).map(o => o.value)).toEqual(['member', 'outcast', 'none']); + })); + + it("lets the assignable affiliations and roles be configured via modtools_disable_assign", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/modtools'; + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + + await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); + const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); + + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']); + + _converse.api.settings.set('modtools_disable_assign', ['owner']); + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['admin', 'member', 'outcast', 'none']); + + _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin']); + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'outcast', 'none']); + + _converse.api.settings.set('modtools_disable_assign', ['owner', 'admin', 'outcast']); + expect(_converse.getAssignableAffiliations(occupant)).toEqual(['member', 'none']); + + expect(_converse.getAssignableRoles(occupant)).toEqual(['moderator', 'participant', 'visitor']); + + _converse.api.settings.set('modtools_disable_assign', ['admin', 'moderator']); + expect(_converse.getAssignableRoles(occupant)).toEqual(['participant', 'visitor']); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js new file mode 100644 index 0000000..b5ae67f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-add-modal.js @@ -0,0 +1,124 @@ +/*global mock, converse */ + +const { Promise, sizzle, u } = converse.env; + +describe('The "Groupchats" Add modal', function () { + + it('can be opened from a link in the "Groupchats" section of the controlbox', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + let label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat address:'); + const name_input = modal.querySelector('input[name="chatroom"]'); + expect(name_input.placeholder).toBe('name@conference.example.org'); + + const label_nick = modal.querySelector('label[for="nickname"]'); + expect(label_nick.textContent.trim()).toBe('Nickname:'); + const nick_input = modal.querySelector('input[name="nickname"]'); + expect(nick_input.value).toBe(''); + nick_input.value = 'romeo'; + + expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat'); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + modal.querySelector('input[name="chatroom"]').value = 'lounge@muc.montague.lit'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1); + + roomspanel.model.set('muc_domain', 'muc.example.org'); + roomspanel.querySelector('.show-add-muc-modal').click(); + label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat name:'); + await u.waitUntil(() => modal.querySelector('input[name="chatroom"]')?.placeholder === 'name@muc.example.org'); + }) + ); + + it("doesn't require the domain when muc_domain is set", + mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat'); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + const label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat name:'); + let name_input = modal.querySelector('input[name="chatroom"]'); + expect(name_input.placeholder).toBe('name@muc.example.org'); + name_input.value = 'lounge'; + let nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1); + expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true); + + // However, you can still open MUCs with different domains + roomspanel.querySelector('.show-add-muc-modal').click(); + await u.waitUntil(() => u.isVisible(modal), 1000); + name_input = modal.querySelector('input[name="chatroom"]'); + name_input.value = 'lounge@conference.example.org'; + nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2); + expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@conference.example.org')).toBe( + true + ); + }) + ); + + it('only uses the muc_domain is locked_muc_domain is true', + mock.initConverse( + ['chatBoxesFetched'], + { 'muc_domain': 'muc.example.org', 'locked_muc_domain': true }, + async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + expect(modal.querySelector('.modal-title').textContent.trim()).toBe('Enter a new Groupchat'); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + const label_name = modal.querySelector('label[for="chatroom"]'); + expect(label_name.textContent.trim()).toBe('Groupchat name:'); + let name_input = modal.querySelector('input[name="chatroom"]'); + expect(name_input.placeholder).toBe(''); + name_input.value = 'lounge'; + let nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 1); + expect(_converse.chatboxes.models.map(m => m.get('id')).includes('lounge@muc.example.org')).toBe(true); + + // However, you can still open MUCs with different domains + roomspanel.querySelector('.show-add-muc-modal').click(); + await u.waitUntil(() => u.isVisible(modal), 1000); + name_input = modal.querySelector('input[name="chatroom"]'); + name_input.value = 'lounge@conference'; + nick_input = modal.querySelector('input[name="nickname"]'); + nick_input.value = 'max'; + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil( + () => _converse.chatboxes.models.filter(c => c.get('type') === 'chatroom').length === 2 + ); + await u.waitUntil(() => sizzle('.chatroom', _converse.el).filter(u.isVisible).length === 2); + expect( + _converse.chatboxes.models.map(m => m.get('id')).includes('lounge\\40conference@muc.example.org') + ).toBe(true); + } + ) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js new file mode 100644 index 0000000..c3427d0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-api.js @@ -0,0 +1,262 @@ +/*global mock, converse */ + +const Model = converse.env.Model; +const { $pres, $iq, Strophe, sizzle, u } = converse.env; + +describe("Groupchats", function () { + + describe("The \"rooms\" API", function () { + + it("has a method 'close' which closes rooms by JID or all rooms when called with no arguments", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo'); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, 'news@montague.lit', 'romeo'); + + expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit'))).toBeTruthy(); + + _converse.chatboxviews.get('lounge@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('lounge@montague.lit')); + expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('news@montague.lit'))).toBeTruthy(); + + _converse.chatboxviews.get('leisure@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('leisure@montague.lit')); + + _converse.chatboxviews.get('news@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('news@montague.lit')); + + expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined(); + expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined(); + expect(_converse.chatboxviews.get('news@montague.lit')).toBeUndefined(); + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + await mock.openAndEnterChatRoom(_converse, 'leisure@montague.lit', 'romeo'); + expect(u.isVisible(_converse.chatboxviews.get('lounge@montague.lit'))).toBeTruthy(); + expect(u.isVisible(_converse.chatboxviews.get('leisure@montague.lit'))).toBeTruthy(); + + _converse.chatboxviews.get('leisure@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('leisure@montague.lit')); + + _converse.chatboxviews.get('lounge@montague.lit').close(); + await u.waitUntil(() => !_converse.chatboxviews.get('lounge@montague.lit')); + + expect(_converse.chatboxviews.get('lounge@montague.lit')).toBeUndefined(); + expect(_converse.chatboxviews.get('leisure@montague.lit')).toBeUndefined(); + })); + + it("has a method 'get' which returns a wrapped groupchat (if it exists)", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group .group-toggle').length, 300); + let muc_jid = 'chillout@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + + let chatroomview = _converse.chatboxviews.get(muc_jid); + expect(chatroomview.is_chatroom).toBeTruthy(); + + expect(u.isVisible(chatroomview)).toBeTruthy(); + await chatroomview.close(); + + // Test with mixed case + muc_jid = 'Leisure@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase()); + expect(u.isVisible(chatroomview)).toBeTruthy(); + + muc_jid = 'leisure@montague.lit'; + room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase()); + expect(u.isVisible(chatroomview)).toBeTruthy(); + + muc_jid = 'leiSure@montague.lit'; + room = await _converse.api.rooms.get(muc_jid); + expect(room instanceof Object).toBeTruthy(); + chatroomview = _converse.chatboxviews.get(muc_jid.toLowerCase()); + expect(u.isVisible(chatroomview)).toBeTruthy(); + chatroomview.close(); + + // Non-existing room + muc_jid = 'chillout2@montague.lit'; + room = await _converse.api.rooms.get(muc_jid); + expect(room).toBe(null); + })); + + it("has a method 'open' which opens (optionally configures) and returns a wrapped chat box", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const { api } = _converse; + // Mock 'getDiscoInfo', otherwise the room won't be + // displayed as it waits first for the features to be returned + // (when it's a new room being created). + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + + let jid = 'lounge@montague.lit'; + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group .group-toggle').length); + + let room = await _converse.api.rooms.open(jid); + // Test on groupchat that's not yet open + expect(room instanceof Model).toBeTruthy(); + let mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid)); + expect(mucview.is_chatroom).toBeTruthy(); + await u.waitUntil(() => u.isVisible(mucview)); + + // Test again, now that the room exists. + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid)); + expect(mucview.is_chatroom).toBeTruthy(); + expect(u.isVisible(mucview)).toBeTruthy(); + await mucview.close(); + + // Test with mixed case in JID + jid = 'Leisure@montague.lit'; + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase())); + await u.waitUntil(() => u.isVisible(mucview)); + + jid = 'leisure@montague.lit'; + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase())); + await u.waitUntil(() => u.isVisible(mucview)); + + jid = 'leiSure@montague.lit'; + room = await _converse.api.rooms.open(jid); + expect(room instanceof Model).toBeTruthy(); + mucview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase())); + await u.waitUntil(() => u.isVisible(mucview)); + mucview.close(); + + api.settings.set('muc_instant_rooms', false); + // Test with configuration + room = await _converse.api.rooms.open('room@conference.example.org', { + 'nick': 'some1', + 'auto_configure': true, + 'roomconfig': { + 'getmemberlist': ['moderator', 'participant'], + 'changesubject': false, + 'membersonly': true, + 'persistentroom': true, + 'publicroom': true, + 'roomdesc': 'Welcome to this groupchat', + 'whois': 'anyone' + } + }); + expect(room instanceof Model).toBeTruthy(); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const selector = `iq[to="room@conference.example.org"] query[xmlns="http://jabber.org/protocol/disco#info"]`; + const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop()); + + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'room@conference.example.org', + 'id': features_query.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + /* <presence xmlns="jabber:client" to="romeo@montague.lit/pda" from="room@conference.example.org/yo"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/pda" role="moderator"/> + * <status code="110"/> + * <status code="201"/> + * </x> + * </presence> + */ + const presence = $pres({ + from:'room@conference.example.org/some1', + to:'romeo@montague.lit/pda' + }) + .c('x', {xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item', { + affiliation: 'owner', + jid: 'romeo@montague.lit/pda', + role: 'moderator' + }).up() + .c('status', {code:'110'}).up() + .c('status', {code:'201'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(`query[xmlns="${Strophe.NS.MUC_OWNER}"]`)).pop()); + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" to="room@conference.example.org" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner"/></iq>`); + + const node = u.toStanza(` + <iq xmlns="jabber:client" + type="result" + to="romeo@montague.lit/pda" + from="room@conference.example.org" id="${iq.getAttribute('id')}"> + <query xmlns="http://jabber.org/protocol/muc#owner"> + <x xmlns="jabber:x:data" type="form"> + <title>Configuration for room@conference.example.org</title> + <instructions>Complete and submit this form to configure the room.</instructions> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/muc#roomconfig</value> + </field> + <field type="text-single" var="muc#roomconfig_roomname" label="Name"> + <value>Room</value> + </field> + <field type="text-single" var="muc#roomconfig_roomdesc" label="Description"><value/></field> + <field type="boolean" var="muc#roomconfig_persistentroom" label="Make Room Persistent?"/> + <field type="boolean" var="muc#roomconfig_publicroom" label="Make Room Publicly Searchable?"><value>1</value></field> + <field type="boolean" var="muc#roomconfig_changesubject" label="Allow Occupants to Change Subject?"/> + <field type="list-single" var="muc#roomconfig_whois" label="Who May Discover Real JIDs?"><option label="Moderators Only"> + <value>moderators</value></option><option label="Anyone"><value>anyone</value></option> + </field> + <field label="Roles and Affiliations that May Retrieve Member List" + type="list-multi" + var="muc#roomconfig_getmemberlist"> + <value>moderator</value> + <value>participant</value> + <value>visitor</value> + </field> + <field type="text-private" var="muc#roomconfig_roomsecret" label="Password"><value/></field> + <field type="boolean" var="muc#roomconfig_moderatedroom" label="Make Room Moderated?"/> + <field type="boolean" var="muc#roomconfig_membersonly" label="Make Room Members-Only?"/> + <field type="text-single" var="muc#roomconfig_historylength" label="Maximum Number of History Messages Returned by Room"> + <value>20</value></field> + </x> + </query> + </iq>`); + + mucview = _converse.chatboxviews.get('room@conference.example.org'); + spyOn(mucview.model, 'sendConfiguration').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(node)); + await u.waitUntil(() => mucview.model.sendConfiguration.calls.count() === 1); + + const sent_stanza = IQ_stanzas.filter(s => s.getAttribute('type') === 'set').pop(); + expect(sizzle('field[var="muc#roomconfig_roomname"] value', sent_stanza).pop().textContent.trim()).toBe('Room'); + expect(sizzle('field[var="muc#roomconfig_roomdesc"] value', sent_stanza).pop().textContent.trim()).toBe('Welcome to this groupchat'); + expect(sizzle('field[var="muc#roomconfig_persistentroom"] value', sent_stanza).pop().textContent.trim()).toBe('1'); + expect(sizzle('field[var="muc#roomconfig_getmemberlist"] value', sent_stanza).map(e => e.textContent.trim()).join(' ')).toBe('moderator participant'); + expect(sizzle('field[var="muc#roomconfig_publicroom"] value ', sent_stanza).pop().textContent.trim()).toBe('1'); + expect(sizzle('field[var="muc#roomconfig_changesubject"] value', sent_stanza).pop().textContent.trim()).toBe('0'); + expect(sizzle('field[var="muc#roomconfig_whois"] value ', sent_stanza).pop().textContent.trim()).toBe('anyone'); + expect(sizzle('field[var="muc#roomconfig_membersonly"] value', sent_stanza).pop().textContent.trim()).toBe('1'); + expect(sizzle('field[var="muc#roomconfig_historylength"] value', sent_stanza).pop().textContent.trim()).toBe('20'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js new file mode 100644 index 0000000..f43a88c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-list-modal.js @@ -0,0 +1,141 @@ +/*global mock, converse */ + +const { $iq, Strophe, Promise, sizzle, u } = converse.env; + +describe('The "Groupchats" List modal', function () { + + it('can be opened from a link in the "Groupchats" section of the controlbox', + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-list-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-muc-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + + // See: https://xmpp.org/extensions/xep-0045.html#disco-rooms + expect(modal.querySelectorAll('.available-chatrooms li').length).toBe(0); + + const server_input = modal.querySelector('input[name="server"]'); + expect(server_input.placeholder).toBe('conference.example.org'); + server_input.value = 'chat.shakespeare.lit'; + modal.querySelector('input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil(() => + IQ_stanzas.filter(s => sizzle(`query[xmlns="${Strophe.NS.DISCO_ITEMS}"]`, s).length).pop() + ); + const id = sent_stanza.getAttribute('id'); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${id}" ` + + `to="chat.shakespeare.lit" ` + + `type="get" ` + + `xmlns="jabber:client">` + + `<query xmlns="http://jabber.org/protocol/disco#items"/>` + + `</iq>` + ); + const iq = $iq({ + 'from': 'muc.montague.lit', + 'to': 'romeo@montague.lit/pda', + 'id': id, + 'type': 'result', + }) + .c('query') + .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up() + .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up() + .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up() + .c('item', { jid: 'inverness@chat.shakespeare.lit', name: 'Macbeth's Castle' }).up() + .c('item', { jid: 'orchard@chat.shakespeare.lit', name: "Capulet's Orchard" }).up() + .c('item', { jid: 'friar@chat.shakespeare.lit', name: "Friar Laurence's cell" }).up() + .c('item', { jid: 'hall@chat.shakespeare.lit', name: "Hall in Capulet's house" }).up() + .c('item', { jid: 'chamber@chat.shakespeare.lit', name: "Juliet's chamber" }).up() + .c('item', { jid: 'public@chat.shakespeare.lit', name: 'A public place' }).up() + .c('item', { jid: 'street@chat.shakespeare.lit', name: 'A street' }).nodeTree; + _converse.connection._dataRecv(mock.createRequest(iq)); + + await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 11); + const rooms = modal.querySelectorAll('.available-chatrooms li'); + expect(rooms[0].textContent.trim()).toBe('Groupchats found'); + expect(rooms[1].textContent.trim()).toBe('A Lonely Heath'); + expect(rooms[2].textContent.trim()).toBe('A Dark Cave'); + expect(rooms[3].textContent.trim()).toBe('The Palace'); + expect(rooms[4].textContent.trim()).toBe("Macbeth's Castle"); + expect(rooms[5].textContent.trim()).toBe("Capulet's Orchard"); + expect(rooms[6].textContent.trim()).toBe("Friar Laurence's cell"); + expect(rooms[7].textContent.trim()).toBe("Hall in Capulet's house"); + expect(rooms[8].textContent.trim()).toBe("Juliet's chamber"); + expect(rooms[9].textContent.trim()).toBe('A public place'); + expect(rooms[10].textContent.trim()).toBe('A street'); + + rooms[4].querySelector('.open-room').click(); + await u.waitUntil(() => _converse.chatboxes.length > 1); + expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom + const view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit'); + expect(view.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle"); + }) + ); + + it('is pre-filled with the muc_domain', + mock.initConverse(['chatBoxesFetched'], { 'muc_domain': 'muc.example.org' }, async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-list-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-muc-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const server_input = modal.querySelector('input[name="server"]'); + expect(server_input.value).toBe('muc.example.org'); + }) + ); + + it("doesn't let you set the MUC domain if it's locked", + mock.initConverse( + ['chatBoxesFetched'], + { 'muc_domain': 'chat.shakespeare.lit', 'locked_muc_domain': true }, + async function (_converse) { + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-list-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-muc-list-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + spyOn(_converse.ChatRoom.prototype, 'getDiscoInfo').and.callFake(() => Promise.resolve()); + + expect(modal.querySelector('input[name="server"]')).toBe(null); + expect(modal.querySelector('input[type="submit"]')).toBe(null); + await u.waitUntil(() => _converse.chatboxes.length); + const sent_stanza = await u.waitUntil(() => + _converse.connection.sent_stanzas + .filter(s => sizzle(`query[xmlns="http://jabber.org/protocol/disco#items"]`, s).length) + .pop() + ); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" ` + + `to="chat.shakespeare.lit" type="get" xmlns="jabber:client">` + + `<query xmlns="http://jabber.org/protocol/disco#items"/>` + + `</iq>` + ); + const iq = $iq({ + from: 'muc.montague.lit', + to: 'romeo@montague.lit/pda', + id: sent_stanza.getAttribute('id'), + type: 'result', + }) + .c('query') + .c('item', { jid: 'heath@chat.shakespeare.lit', name: 'A Lonely Heath' }).up() + .c('item', { jid: 'coven@chat.shakespeare.lit', name: 'A Dark Cave' }).up() + .c('item', { jid: 'forres@chat.shakespeare.lit', name: 'The Palace' }).up(); + _converse.connection._dataRecv(mock.createRequest(iq)); + + await u.waitUntil(() => modal.querySelectorAll('.available-chatrooms li').length === 4); + const rooms = modal.querySelectorAll('.available-chatrooms li'); + expect(rooms[0].textContent.trim()).toBe('Groupchats found'); + expect(rooms[1].textContent.trim()).toBe('A Lonely Heath'); + expect(rooms[2].textContent.trim()).toBe('A Dark Cave'); + expect(rooms[3].textContent.trim()).toBe('The Palace'); + } + ) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js new file mode 100644 index 0000000..8fab48a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-mentions.js @@ -0,0 +1,86 @@ +/*global mock, converse */ + +const { dayjs } = converse.env; +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("MUC Mention Notfications", function () { + + it("may be received from a MUC in which the user is not currently present", + mock.initConverse([], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + const { api } = _converse; + + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + expect(model.get('hidden')).toBe(true); + await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED); + + const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy(); + + const base_time = new Date(); + let message = u.toStanza(` + <message from="${muc_jid}"> + <mentions xmlns='urn:xmpp:mmn:0'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(5, 'minutes').toISOString()}'/> + <message type='groupchat' id='${_converse.connection.getUniqueId()}' + to='${muc_jid}' + from='${muc_jid}/juliet' + xml:lang='en'> + <body>Romeo, wherefore art though Romeo</body> + <reference xmlns='urn:xmpp:reference:0' + type='mention' + begin='0' + uri='xmpp:${_converse.bare_jid}' + end='5'/> + </message> + </forwarded> + </mentions> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(message)); + + await u.waitUntil(() => Array.from(room_el.classList).includes('unread-msgs')); + expect(room_el.querySelector('.msgs-indicator')?.textContent.trim()).toBe('1'); + + message = u.toStanza(` + <message from="${muc_jid}"> + <mentions xmlns='urn:xmpp:mmn:0'> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='${dayjs(base_time).subtract(4, 'minutes').toISOString()}'/> + <message type='groupchat' id='${_converse.connection.getUniqueId()}' + to='${muc_jid}' + from='${muc_jid}/juliet' + xml:lang='en'> + <body>Romeo, wherefore art though Romeo</body> + <reference xmlns='urn:xmpp:reference:0' + type='mention' + begin='0' + uri='xmpp:${_converse.bare_jid}' + end='5'/> + </message> + </forwarded> + </mentions> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(message)); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + await u.waitUntil(() => room_el.querySelector('.msgs-indicator')?.textContent.trim() === '2'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js new file mode 100644 index 0000000..378cb59 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-messages.js @@ -0,0 +1,354 @@ +/*global mock, converse */ + + const { Promise, Strophe, $msg, $pres, sizzle,u } = converse.env; + const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("A Groupchat Message", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + describe("which is succeeded by an error message", function () { + + it("will have the error displayed below it", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'hello world' + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter_event); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + const msg = view.model.messages.at(0); + const err_msg_text = "Message rejected because you're sending messages too quickly"; + const error = u.toStanza(` + <message xmlns="jabber:client" id="${msg.get('msgid')}" from="${muc_jid}" to="${_converse.jid}" type="error"> + <error type="wait"> + <policy-violation xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_msg_text}</text> + </error> + <body>hello world</body> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(error)); + expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(err_msg_text); + expect(view.model.messages.length).toBe(1); + const message = view.model.messages.at(0); + expect(message.get('received')).toBeUndefined(); + expect(message.get('body')).toBe('hello world'); + expect(message.get('error_text')).toBe(err_msg_text); + expect(message.get('editable')).toBe(false); + })); + }); + + it("can contain a chat state notification and will still be shown", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); } + const message = 'romeo: Your attention is required'; + const nick = mock.chatroom_names[0], + msg = $msg({ + from: 'lounge@montague.lit/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message) + .c('active', {'xmlns': "http://jabber.org/protocol/chatstates"}) + .tree(); + await view.model.handleMessageStanza(msg); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe(message); + })); + + it("can not be expected to have a unique id attribute", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + if (!view.querySelectorAll('.chat-area').length) { view.renderChatArea(); } + const id = u.getUniqueId(); + let msg = $msg({ + from: 'lounge@montague.lit/some1', + id: id, + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('First message').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + msg = $msg({ + from: 'lounge@montague.lit/some2', + id: id, + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Another message').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.model.messages.length).toBe(2); + })); + + it("is ignored if it has the same stanza-id of an already received one", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'room@muc.example.com'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(view.model, 'getStanzaIdQueryAttrs').and.callThrough(); + let stanza = u.toStanza(` + <message xmlns="jabber:client" + from="room@muc.example.com/some1" + to="${_converse.connection.jid}" + type="groupchat"> + <body>Typical body text</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="room@muc.example.com"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + await u.waitUntil(() => view.model.getStanzaIdQueryAttrs.calls.count() === 1); + let result = await view.model.getStanzaIdQueryAttrs.calls.all()[0].returnValue; + expect(result instanceof Array).toBe(true); + expect(result[0] instanceof Object).toBe(true); + expect(result[0]['stanza_id room@muc.example.com']).toBe("5f3dbc5e-e1d3-4077-a492-693f3769c7ad"); + + stanza = u.toStanza(` + <message xmlns="jabber:client" + from="room@muc.example.com/some1" + to="${_converse.connection.jid}" + type="groupchat"> + <body>Typical body text</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="room@muc.example.com"/> + </message>`); + spyOn(view.model, 'updateMessage'); + spyOn(view.model, 'getDuplicateMessage').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.getDuplicateMessage.calls.count()); + result = await view.model.getDuplicateMessage.calls.all()[0].returnValue; + expect(result instanceof _converse.Message).toBe(true); + expect(view.model.messages.length).toBe(1); + await u.waitUntil(() => view.model.updateMessage.calls.count()); + })); + + it("keeps track of the sender's role and affiliation", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + let msg = $msg({ + from: 'lounge@montague.lit/romeo', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('I wrote this message!').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.model.messages.last().occupant.get('affiliation')).toBe('owner'); + expect(view.model.messages.last().occupant.get('role')).toBe('moderator'); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar moderator owner'); + let presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/romeo', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'member', + jid: 'romeo@montague.lit/orchard', + role: 'participant' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'210'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.model.messages.length === 4); + + msg = $msg({ + from: 'lounge@montague.lit/romeo', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Another message!').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.model.messages.last().occupant.get('affiliation')).toBe('member'); + expect(view.model.messages.last().occupant.get('role')).toBe('participant'); + expect(sizzle('.chat-msg', view).pop().classList.value.trim()).toBe('message chat-msg groupchat chat-msg--with-avatar participant member'); + + presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/romeo', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/orchard', + role: 'moderator' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'210'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + view.model.sendMessage({'body': 'hello world'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 3); + + const occupant = await u.waitUntil(() => view.model.messages.filter(m => m.get('type') === 'groupchat')[2].occupant); + expect(occupant.get('affiliation')).toBe('owner'); + expect(occupant.get('role')).toBe('moderator'); + expect(view.querySelectorAll('.chat-msg').length).toBe(3); + await u.waitUntil(() => sizzle('.chat-msg', view).pop().classList.value.trim() === 'message chat-msg groupchat chat-msg--with-avatar moderator owner'); + + msg = $msg({ + from: 'lounge@montague.lit/some1', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('Message from someone not in the MUC right now').tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 4); + + expect(view.model.messages.last().occupant.get('nick')).toBe('some1'); + expect(view.model.messages.last().occupant.get('jid')).toBe(undefined); + + // Check that the occupant gets added/removed to the message as it + // gets removed or added. + presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/some1', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({jid: 'some1@montague.lit/orchard'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.messages.last().occupant); + expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now'); + expect(view.model.messages.last().occupant.get('nick')).toBe('some1'); + expect(view.model.messages.last().occupant.get('jid')).toBe('some1@montague.lit'); + + presence = $pres({ + to:'romeo@montague.lit/orchard', + type: 'unavailable', + from:'lounge@montague.lit/some1', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({jid: 'some1@montague.lit/orchard'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => !view.model.messages.last().occupant); + expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now'); + expect(view.model.messages.last().occupant).toBeUndefined(); + + presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/some1', + id: u.getUniqueId() + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({jid: 'some1@montague.lit/orchard'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.messages.last().occupant); + expect(view.model.messages.last().get('message')).toBe('Message from someone not in the MUC right now'); + expect(view.model.messages.last().occupant.get('nick')).toBe('some1'); + })); + + it("will be shown as received upon MUC reflection", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.OCCUPANTID]; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(0); + + const msg_obj = view.model.messages.at(0); + const stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <body>${msg_obj.get('message')}</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <occupant-id xmlns="urn:xmpp:occupant-id:0" id="dd72603deec90a38ba552f7c68cbcc61bca202cd" /> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.querySelectorAll('.chat-msg__receipt').length).toBe(0); + expect(view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length).toBe(1); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0); + expect(message.get('stanza_id lounge@montague.lit')).toBe('5f3dbc5e-e1d3-4077-a492-693f3769c7ad'); + expect(message.get('origin_id')).toBe(msg_obj.get('origin_id')); + expect(message.get('occupant_id')).toBe('dd72603deec90a38ba552f7c68cbcc61bca202cd'); + })); + + it("can cause a delivery receipt to be returned", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + const msg_obj = view.model.messages.at(0); + let stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <body>${msg_obj.get('message')}</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(stanza); + await u.waitUntil(() => view.model.messages.last().get('received')); + + stanza = u.toStanza(` + <message xml:lang="en" to="romeo@montague.lit/orchard" + from="lounge@montague.lit/some1" type="groupchat" xmlns="jabber:client"> + <received xmlns="urn:xmpp:receipts" id="${msg_obj.get('msgid')}"/> + <origin-id xmlns="urn:xmpp:sid:0" id="CE08D448-5ED8-4B6A-BB5B-07ED9DFE4FF0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js new file mode 100644 index 0000000..3fe0b26 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc-registration.js @@ -0,0 +1,59 @@ +/*global mock, converse */ + +const { $iq, Strophe, sizzle, u } = converse.env; + +describe("Chatrooms", function () { + + describe("The /register commmand", function () { + + it("allows you to register your nickname in a room", + mock.initConverse(['chatBoxesFetched'], {'auto_register_muc_nickname': true}, + async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo') + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/register'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + let stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => sizzle(`iq[to="${muc_jid}"][type="get"] query[xmlns="jabber:iq:register"]`, iq).length + ).pop()); + expect(Strophe.serialize(stanza)) + .toBe(`<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" `+ + `type="get" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:register"/></iq>`); + const result = $iq({ + 'from': view.model.get('jid'), + 'id': stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('query', {'type': 'jabber:iq:register'}) + .c('x', {'xmlns': 'jabber:x:data', 'type': 'form'}) + .c('field', { + 'label': 'Desired Nickname', + 'type': 'text-single', + 'var': 'muc#register_roomnick' + }).c('required'); + _converse.connection._dataRecv(mock.createRequest(result)); + stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => sizzle(`iq[to="${muc_jid}"][type="set"] query[xmlns="jabber:iq:register"]`, iq).length + ).pop()); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:register">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field var="FORM_TYPE"><value>http://jabber.org/protocol/muc#register</value></field>`+ + `<field var="muc#register_roomnick"><value>romeo</value></field>`+ + `</x>`+ + `</query>`+ + `</iq>`); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js new file mode 100644 index 0000000..be3c9ec --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/muc.js @@ -0,0 +1,3878 @@ +/*global mock, converse */ + +const { $pres, $iq, $msg, Strophe, Promise, sizzle, u } = converse.env; + +describe("Groupchats", function () { + + describe("An instant groupchat", function () { + + it("will be created when muc_instant_rooms is set to true", + mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: '' } }, async function (_converse) { + + let IQ_stanzas = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + const nick = 'nicky'; + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + + const disco_selector = `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`; + const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(disco_selector)).pop()); + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + 'from': 'lounge@montague.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + spyOn(view.model, 'join').and.callThrough(); + await mock.waitForReservedNick(_converse, muc_jid, ''); + const input = await u.waitUntil(() => view.querySelector('input[name="nick"]'), 1000); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED); + input.value = nick; + view.querySelector('input[type=submit]').click(); + expect(view.model.join).toHaveBeenCalled(); + + _converse.connection.IQ_stanzas = []; + await mock.getRoomFeatures(_converse, muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + + // The user has just entered the room (because join was called) + // and receives their own presence from the server. + // See example 24: + // https://xmpp.org/extensions/xep-0045.html#enter-pres + // + /* <presence xmlns="jabber:client" to="jordie.langen@chat.example.org/converse.js-11659299" from="myroom@conference.chat.example.org/jc"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item jid="jordie.langen@chat.example.org/converse.js-11659299" affiliation="owner" role="moderator"/> + * <status code="110"/> + * <status code="201"/> + * </x> + * </presence> + */ + const presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/nicky', + id:'5025e055-036c-4bc5-a227-706e7e352053' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/orchard', + role: 'moderator' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'201'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED); + await mock.returnMemberLists(_converse, muc_jid); + const num_info_msgs = await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + expect(num_info_msgs).toBe(1); + + const info_texts = Array.from(view.querySelectorAll('.chat-content .chat-info')).map(e => e.textContent.trim()); + expect(info_texts[0]).toBe('A new groupchat has been created'); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("nicky has entered the groupchat"); + + // An instant room is created by saving the default configuratoin. + // + /* <iq to="myroom@conference.chat.example.org" type="set" xmlns="jabber:client" id="5025e055-036c-4bc5-a227-706e7e352053:sendIQ"> + * <query xmlns="http://jabber.org/protocol/muc#owner"><x xmlns="jabber:x:data" type="submit"/></query> + * </iq> + */ + const selector = `query[xmlns="${Strophe.NS.MUC_OWNER}"]`; + IQ_stanzas = _converse.connection.IQ_stanzas; + const iq = await u.waitUntil(() => IQ_stanzas.filter(s => s.querySelector(selector)).pop()); + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner"><x type="submit" xmlns="jabber:x:data"/>`+ + `</query></iq>`); + })); + }); + + describe("A Groupchat", function () { + + it("will be visible when opened as the first chat in fullscreen-view", + mock.initConverse(['discoInitialized'], + { 'view_mode': 'fullscreen', 'auto_join_rooms': ['orchard@chat.shakespeare.lit']}, + async function (_converse) { + + const { api } = _converse; + await api.waitUntil('roomsAutoJoined'); + const room = await api.rooms.get('orchard@chat.shakespeare.lit'); + expect(room.get('hidden')).toBe(false); + })); + + it("Can be configured to show cached messages before being joined", + mock.initConverse(['discoInitialized'], + { + muc_show_logs_before_join: true, + archived_messages_page_size: 2, + muc_nickname_from_jid: false, + muc_clear_messages_on_leave: false, + vcard: { nickname: '' }, + }, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'orchard@chat.shakespeare.lit'; + const nick = 'romeo'; + api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid); + const view = _converse.chatboxviews.get(muc_jid); + await view.model.messages.fetched; + + view.model.messages.create({ + 'type': 'groupchat', + 'to': muc_jid, + 'from': `${_converse.bare_jid}/orchard`, + 'body': 'Hello world', + 'message': 'Hello world', + 'time': '2021-02-02T12:00:00Z' + }); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.NICKNAME_REQUIRED); + await u.waitUntil(() => view.querySelectorAll('converse-chat-message').length === 1); + + const sel = 'converse-message-history converse-chat-message .chat-msg__text'; + await u.waitUntil(() => view.querySelector(sel)?.textContent.trim()); + expect(view.querySelector(sel).textContent.trim()).toBe('Hello world') + + const nick_input = await u.waitUntil(() => view.querySelector('[name="nick"]')); + nick_input.value = nick; + view.querySelector('.muc-nickname-form input[type="submit"]').click(); + _converse.connection.IQ_stanzas = []; + await mock.getRoomFeatures(_converse, muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + })); + + it("maintains its state across reloads", + mock.initConverse([], { + 'clear_messages_on_reconnection': true, + 'enable_smacks': false + }, async function (_converse) { + + const { api } = _converse; + const nick = 'romeo'; + const sent_IQs = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit' + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], []); + const view = _converse.chatboxviews.get(muc_jid); + let iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const first_msg_id = _converse.connection.getUniqueId(); + const last_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>1st Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + const result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>2</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + while (sent_IQs.length) { sent_IQs.pop(); } // Clear so that we don't match the older query + await _converse.api.connection.reconnect(); + await mock.getRoomFeatures(_converse, muc_jid, []); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + // The user has just entered the room (because join was called) + // and receives their own presence from the server. + // See example 24: https://xmpp.org/extensions/xep-0045.html#enter-pres + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + + message = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" id="918172de-d5c5-4da4-b388-446ef4a05bec" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet"> + <body>Wherefore art though?</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="918172de-d5c5-4da4-b388-446ef4a05bec"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="88cc9c93-a8f4-4dd5-b02a-d19855eb6303" by="${muc_jid}"/> + <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:47Z" from="juliet@shakespeare.lit"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" id="awQo6a-mi-Wa6NYh" to="${_converse.jid}" from="${muc_jid}/ews000" xml:lang="en"> + <composing xmlns="http://jabber.org/protocol/chatstates"/> + <no-store xmlns="urn:xmpp:hints"/> + <no-permanent-store xmlns="urn:xmpp:hints"/> + <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:54Z" from="juliet@shakespeare.lit"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + const affs = api.settings.get('muc_fetch_members'); + const all_affiliations = Array.isArray(affs) ? affs : (affs ? ['member', 'admin', 'owner'] : []); + await mock.returnMemberLists(_converse, muc_jid, [], all_affiliations); + + iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + expect(Strophe.serialize(iq_get)).toBe( + `<iq id="${iq_get.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query queryid="${iq_get.querySelector('query').getAttribute('queryid')}" xmlns="${Strophe.NS.MAM}">`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE"><value>urn:xmpp:mam:2</value></field>`+ + `</x>`+ + `<set xmlns="http://jabber.org/protocol/rsm"><before></before><max>50</max></set>`+ + `</query>`+ + `</iq>`); + })); + + it("shows a new messages indicator when you're scrolled up", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const message = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" id="918172de-d5c5-4da4-b388-446ef4a05bec" to="${_converse.jid}" xml:lang="en" from="${muc_jid}/juliet"> + <body>Wherefore art though?</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="918172de-d5c5-4da4-b388-446ef4a05bec"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="88cc9c93-a8f4-4dd5-b02a-d19855eb6303" by="${muc_jid}"/> + <delay xmlns="urn:xmpp:delay" stamp="2020-07-14T17:46:47Z" from="juliet@shakespeare.lit"/> + </message>`); + + view.model.ui.set('scrolled', true); // hack + _converse.connection._dataRecv(mock.createRequest(message)); + + await u.waitUntil(() => view.model.messages.length); + const chat_new_msgs_indicator = await u.waitUntil(() => view.querySelector('.new-msgs-indicator')); + chat_new_msgs_indicator.click(); + expect(view.model.ui.get('scrolled')).toBeFalsy(); + await u.waitUntil(() => !u.isVisible(chat_new_msgs_indicator)); + })); + + + describe("topic", function () { + + it("is shown the header", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org'; + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>${text}</subject> + <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/> + <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + await new Promise(resolve => view.model.once('change:subject', resolve)); + const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc'), 1000); + expect(head_desc?.textContent.trim()).toBe(text); + + stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is a message subject</subject> + <body>This is a message</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(sizzle('.chat-msg__subject', view).length).toBe(1); + expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject'); + expect(sizzle('.chat-msg__text').length).toBe(1); + expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message'); + expect(view.querySelector('.chat-head__desc').textContent.trim()).toBe(text); + })); + + it("can be toggled by the user", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org'; + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>${text}</subject> + <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/> + <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + await new Promise(resolve => view.model.once('change:subject', resolve)); + + const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc')); + expect(head_desc?.textContent.trim()).toBe(text); + + stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is a message subject</subject> + <body>This is a message</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(sizzle('.chat-msg__subject', view).length).toBe(1); + expect(sizzle('.chat-msg__subject', view).pop().textContent.trim()).toBe('This is a message subject'); + expect(sizzle('.chat-msg__text').length).toBe(1); + expect(sizzle('.chat-msg__text').pop().textContent.trim()).toBe('This is a message'); + const topic_el = view.querySelector('.chat-head__desc'); + expect(topic_el.textContent.trim()).toBe(text); + expect(u.isVisible(topic_el)).toBe(true); + + await u.waitUntil(() => view.querySelector('.hide-topic').textContent.trim() === 'Hide topic'); + const toggle = view.querySelector('.hide-topic'); + expect(toggle.textContent.trim()).toBe('Hide topic'); + toggle.click(); + await u.waitUntil(() => view.querySelector('.hide-topic').textContent.trim() === 'Show topic'); + })); + + it("will always be shown when it's new", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + const text = 'Jabber/XMPP Development | RFCs and Extensions: https://xmpp.org/ | Protocol and XSF discussions: xsf@muc.xmpp.org'; + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>${text}</subject> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + await new Promise(resolve => view.model.once('change:subject', resolve)); + + const head_desc = await u.waitUntil(() => view.querySelector('.chat-head__desc')); + expect(head_desc?.textContent.trim()).toBe(text); + + let topic_el = view.querySelector('.chat-head__desc'); + expect(topic_el.textContent.trim()).toBe(text); + expect(u.isVisible(topic_el)).toBe(true); + + const toggle = view.querySelector('.hide-topic'); + expect(toggle.textContent.trim()).toBe('Hide topic'); + toggle.click(); + await u.waitUntil(() => !u.isVisible(topic_el)); + + stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>Another topic</subject> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => u.isVisible(view.querySelector('.chat-head__desc'))); + topic_el = view.querySelector('.chat-head__desc'); + expect(topic_el.textContent.trim()).toBe('Another topic'); + })); + + + it("causes an info message to be shown when received in real-time", mock.initConverse([], {}, async function (_converse) { + spyOn(_converse.ChatRoom.prototype, 'handleSubjectChange').and.callThrough(); + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'romeo'); + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is an older topic</subject> + <delay xmlns="urn:xmpp:delay" stamp="2014-02-04T09:35:39Z" from="jdev@conference.jabber.org"/> + <x xmlns="jabber:x:delay" stamp="20140204T09:35:39" from="jdev@conference.jabber.org"/> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count()); + expect(sizzle('.chat-info__message', view).length).toBe(0); + + const desc = await u.waitUntil(() => view.querySelector('.chat-head__desc')); + expect(desc.textContent.trim()).toBe('This is an older topic'); + + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>This is a new topic</subject> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 2); + + await u.waitUntil(() => sizzle('.chat-info__message', view).pop()?.textContent.trim() === 'Topic set by ralphm'); + await u.waitUntil(() => desc.textContent.trim() === 'This is a new topic'); + + // Doesn't show multiple subsequent topic change notifications + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/ralphm"> + <subject>Yet another topic</subject> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 3); + await u.waitUntil(() => desc.textContent.trim() === 'Yet another topic'); + expect(sizzle('.chat-info__message', view).length).toBe(1); + + // Sow multiple subsequent topic change notification from someone else + _converse.connection._dataRecv(mock.createRequest(u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1"> + <subject>Some1's topic</subject> + </message>`))); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 4); + await u.waitUntil(() => desc.textContent.trim() === "Some1's topic"); + expect(sizzle('.chat-info__message', view).length).toBe(2); + const el = sizzle('.chat-info__message', view).pop(); + expect(el.textContent.trim()).toBe('Topic set by some1'); + + // Removes current topic + const stanza = u.toStanza( + `<message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="jdev@conference.jabber.org/some1"> + <subject/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.handleSubjectChange.calls.count() === 5); + await u.waitUntil(() => view.querySelector('.chat-head__desc') === null); + await u.waitUntil(() => view.querySelector('converse-chat-message:last-child .chat-info').textContent.trim() === "Topic cleared by some1"); + })); + }); + + it("restores cached messages when it reconnects and clear_messages_on_reconnection and muc_clear_messages_on_leave are false", + mock.initConverse([], { + 'clear_messages_on_reconnection': false, + 'muc_clear_messages_on_leave': false + }, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + const model = _converse.chatboxes.get(muc_jid); + const message = 'Hello world', + nick = mock.chatroom_names[0], + msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + + await model.handleMessageStanza(msg); + await u.waitUntil(() => document.querySelector('converse-chat-message')); + await model.close(); + await u.waitUntil(() => !document.querySelector('converse-chat-message')); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + await u.waitUntil(() => document.querySelector('converse-chat-message')); + expect(model.messages.length).toBe(1); + expect(document.querySelectorAll('converse-chat-message').length).toBe(1); + })); + + it("clears cached messages when it reconnects and clear_messages_on_reconnection is true", + mock.initConverse([], {'clear_messages_on_reconnection': true}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const message = 'Hello world', + nick = mock.chatroom_names[0], + msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + + await view.model.handleMessageStanza(msg); + await view.model.close(); + + _converse.connection.IQ_stanzas = []; + await mock.openAndEnterChatRoom(_converse, muc_jid , 'romeo'); + expect(view.model.messages.length).toBe(0); + expect(view.querySelector('converse-chat-history')).toBe(null); + })); + + it("is opened when an xmpp: URI is clicked inside another groupchat", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + if (!view.querySelectorAll('.chat-area').length) { + view.renderChatArea(); + } + expect(_converse.chatboxes.length).toEqual(2); + const message = 'Please go to xmpp:coven@chat.shakespeare.lit?join', + nick = mock.chatroom_names[0], + msg = $msg({ + 'from': 'lounge@montague.lit/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree(); + + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-msg__text a')); + view.querySelector('.chat-msg__text a').click(); + await u.waitUntil(() => _converse.chatboxes.length === 3) + expect(_converse.chatboxes.pluck('id').includes('coven@chat.shakespeare.lit')).toBe(true); + })); + + it("shows a notification if it's not anonymous", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + const nick = 'romeo'; + await _converse.api.rooms.open(muc_jid); + await mock.getRoomFeatures(_converse, muc_jid); + await mock.waitForReservedNick(_converse, muc_jid, nick); + + const view = _converse.chatboxviews.get(muc_jid); + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * <status code="100"/> + * </x> + * </presence></body> + */ + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}).up() + .c('status', {code: '100'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']); + + const num_info_msgs = await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + expect(num_info_msgs).toBe(1); + expect(sizzle('div.chat-info', view).pop().textContent.trim()).toBe("This groupchat is not anonymous"); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + })); + + + it("shows join/leave messages when users enter or exit a groupchat", + mock.initConverse(['chatBoxesFetched'], {'muc_fetch_members': false}, async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + const nick = 'some1'; + const room_creation_promise = await _converse.api.rooms.open(muc_jid, {nick}); + await mock.getRoomFeatures(_converse, muc_jid); + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(iq => sizzle('presence history', iq).length).pop()); + + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + await _converse.api.waitUntil('chatRoomViewInitialized'); + + /* We don't show join/leave messages for existing occupants. We + * know about them because we receive their presences before we + * receive our own. + */ + let presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/oldguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'oldguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * </x> + * </presence></body> + */ + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications')?.textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + + await room_creation_promise; + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await view.model.messages.fetched; + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newguy have entered the groupchat"); + + const msg = $msg({ + 'from': 'coven@chat.shakespeare.lit/some1', + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t('hello world').tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + // Add another entrant, otherwise the above message will be + // collapsed if "newguy" leaves immediately again + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newgirl' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newgirl@montague.lit/_converse.js-213098781', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newguy and newgirl have entered the groupchat"); + + // Don't show duplicate join messages + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-290918392', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop' + * type='unavailable'> + * <status>Disconnected: Replaced by new connection</status> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='none'/> + * </x> + * </presence> + */ + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('status', 'Disconnected: Replaced by new connection').up() + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newgirl have entered the groupchat\nnewguy has left the groupchat"); + + // When the user immediately joins again, we collapse the + // multiple join/leave messages. + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and newguy have entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newgirl have entered the groupchat\nnewguy has left the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and nomorenicks have entered the groupchat\nnewguy has left the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-290918392', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newgirl have entered the groupchat\nnewguy and nomorenicks have left the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and nomorenicks have entered the groupchat\nnewguy has left the groupchat"); + + // Test a member joining and leaving + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-290918392', + from: 'coven@chat.shakespeare.lit/insider' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'member', + 'jid': 'insider@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop' + * type='unavailable'> + * <status>Disconnected: Replaced by new connection</status> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='none'/> + * </x> + * </presence> + */ + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + type: 'unavailable', + from: 'coven@chat.shakespeare.lit/insider' + }) + .c('status', 'Disconnected: Replaced by new connection').up() + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'member', + 'jid': 'insider@montague.lit/_converse.js-290929789', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newgirl and nomorenicks have entered the groupchat\nnewguy and insider have left the groupchat"); + + expect(view.model.occupants.length).toBe(5); + expect(view.model.occupants.findWhere({'jid': 'insider@montague.lit'}).get('show')).toBe('offline'); + + // New girl leaves + presence = $pres({ + 'to': 'romeo@montague.lit/_converse.js-29092160', + 'type': 'unavailable', + 'from': 'coven@chat.shakespeare.lit/newgirl' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newgirl@montague.lit/_converse.js-213098781', + 'role': 'none' + }); + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and nomorenicks have entered the groupchat\nnewguy, insider and newgirl have left the groupchat"); + expect(view.model.occupants.length).toBe(4); + })); + + it("combines subsequent join/leave messages when users enter or exit a groupchat", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo') + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo has entered the groupchat"); + + let presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo and fabio have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and Dele Olajide have entered the groupchat"); + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/jcbrand"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="owner" jid="jc@opkode.com/converse.js-30645022" role="moderator"/> + <status code="110"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "romeo, fabio and others have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-39320524" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and jcbrand have entered the groupchat\nDele Olajide has left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and others have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fuvuv" xml:lang="en"> + <c xmlns="http://jabber.org/protocol/caps" node="http://jabber.pix-art.de" ver="5tOurnuFnp2h50hKafeUyeN4Yl8=" hash="sha-1"/> + <x xmlns="vcard-temp:x:update"/> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and others have entered the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fuvuv"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fuvuv@blabber.im/Pix-Art Messenger.8zoB" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, fabio and others have entered the groupchat\nfuvuv has left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio"> + <status>Disconnected: Replaced by new connection</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and Dele Olajide have entered the groupchat\nfuvuv and fabio have left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/> + <status>Ready for a new day</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and others have entered the groupchat\nfuvuv has left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/fabio"> + <status>Disconnected: closed</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and Dele Olajide have entered the groupchat\nfuvuv and fabio have left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/Dele Olajide"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="deleo@traderlynk.4ng.net/converse.js-74567907" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and jcbrand have entered the groupchat\nfuvuv, fabio and Dele Olajide have left the groupchat"); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="coven@chat.shakespeare.lit/fabio"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="INI3xjRUioclBTP/aACfWi5m9UY=" hash="sha-1"/> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="fabio@montefuscolo.com.br/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo, jcbrand and fabio have entered the groupchat\nfuvuv and Dele Olajide have left the groupchat"); + + expect(1).toBe(1); + })); + + it("doesn't show the disconnection messages when join_leave_events is not in muc_show_info_messages setting", + mock.initConverse(['chatBoxesFetched'], {'muc_show_info_messages': []}, async function (_converse) { + + spyOn(_converse.ChatRoom.prototype, 'onOccupantAdded').and.callThrough(); + spyOn(_converse.ChatRoom.prototype, 'onOccupantRemoved').and.callThrough(); + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1'); + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + let presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'coven@chat.shakespeare.lit/newguy' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.model.onOccupantAdded.calls.count() === 2); + expect(view.model.notifications.get('entered')).toBeFalsy(); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe(''); + await mock.sendMessage(view, 'hello world'); + + presence = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="coven@chat.shakespeare.lit/newguy"> + <status>Gotta go!</status> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" jid="newguy@montague.lit/_converse.js-290929789" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.model.onOccupantRemoved.calls.count()); + expect(view.model.onOccupantRemoved.calls.count()).toBe(1); + expect(view.model.notifications.get('entered')).toBeFalsy(); + await mock.sendMessage(view, 'hello world'); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe(''); + })); + + it("role-change messages that follow a MUC leave are left out", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // See https://github.com/conversejs/converse.js/issues/1259 + + await mock.openAndEnterChatRoom(_converse, 'conversations@conference.siacs.eu', 'romeo'); + + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'conversations@conference.siacs.eu/Guus' + }).c('x', { + 'xmlns': Strophe.NS.MUC_USER + }).c('item', { + 'affiliation': 'none', + 'jid': 'Guus@montague.lit/xxx', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const view = _converse.chatboxviews.get('conversations@conference.siacs.eu'); + const msg = $msg({ + 'from': 'conversations@conference.siacs.eu/romeo', + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t('Some message').tree(); + + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => sizzle('.chat-msg:last .chat-msg__text', view).pop()); + + let stanza = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" type="unavailable" from="conversations@conference.siacs.eu/Guus"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" role="none"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + stanza = u.toStanza( + `<presence xmlns="jabber:client" to="romeo@montague.lit/orchard" from="conversations@conference.siacs.eu/Guus"> + <c xmlns="http://jabber.org/protocol/caps" node="http://conversations.im" ver="ISg6+9AoK1/cwhbNEDviSvjdPzI=" hash="sha-1"/> + <x xmlns="vcard-temp:x:update"> + <photo>bf987c486c51fbc05a6a4a9f20dd19b5efba3758</photo> + </x> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="none" role="visitor"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() + === "romeo and Guus have entered the groupchat"); + expect(1).toBe(1); + })); + + it("can be configured if you're its owner", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'}); + const view = await u.waitUntil(() => _converse.chatboxviews.get('coven@chat.shakespeare.lit')); + await u.waitUntil(() => u.isVisible(view)); + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'coven@chat.shakespeare.lit', + 'id': IQ_id, + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * </x> + * </presence></body> + */ + const presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.configure-chatroom-button') !== null); + + const own_occupant = view.model.getOwnOccupant(); + await u.waitUntil(() => own_occupant.get('affiliation') === 'owner'); + + view.querySelector('.configure-chatroom-button').click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const sel = 'iq query[xmlns="http://jabber.org/protocol/muc#owner"]'; + const iq = await u.waitUntil(() => sent_IQs.filter(iq => sizzle(sel, iq).length).pop()); + + /* Check that an IQ is sent out, asking for the + * configuration form. + * See: // https://xmpp.org/extensions/xep-0045.html#example-163 + * + * <iq from='crone1@shakespeare.lit/desktop' + * id='config1' + * to='coven@chat.shakespeare.lit' + * type='get'> + * <query xmlns='http://jabber.org/protocol/muc#owner'/> + * </iq> + */ + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" to="coven@chat.shakespeare.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner"/>`+ + `</iq>`); + + /* Server responds with the configuration form. + * See: // https://xmpp.org/extensions/xep-0045.html#example-165 + */ + const config_stanza = $iq({from: 'coven@chat.shakespeare.lit', + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result'}) + .c('query', { 'xmlns': 'http://jabber.org/protocol/muc#owner'}) + .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form'}) + .c('title').t('Configuration for "coven" Room').up() + .c('instructions').t('Complete this form to modify the configuration of your room.').up() + .c('field', {'type': 'hidden', 'var': 'FORM_TYPE'}) + .c('value').t('http://jabber.org/protocol/muc#roomconfig').up().up() + .c('field', { + 'label': 'Natural-Language Room Name', + 'type': 'text-single', + 'var': 'muc#roomconfig_roomname'}) + .c('value').t('A Dark Cave').up().up() + .c('field', { + 'label': 'Short Description of Room', + 'type': 'text-single', + 'var': 'muc#roomconfig_roomdesc'}) + .c('value').t('The place for all good witches!').up().up() + .c('field', { + 'label': 'Enable Public Logging?', + 'type': 'boolean', + 'var': 'muc#roomconfig_enablelogging'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Allow Occupants to Change Subject?', + 'type': 'boolean', + 'var': 'muc#roomconfig_changesubject'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Allow Occupants to Invite Others?', + 'type': 'boolean', + 'var': 'muc#roomconfig_allowinvites'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Who Can Send Private Messages?', + 'type': 'list-single', + 'var': 'muc#roomconfig_allowpm'}) + .c('value').t('anyone').up() + .c('option', {'label': 'Anyone'}) + .c('value').t('anyone').up().up() + .c('option', {'label': 'Anyone with Voice'}) + .c('value').t('participants').up().up() + .c('option', {'label': 'Moderators Only'}) + .c('value').t('moderators').up().up() + .c('option', {'label': 'Nobody'}) + .c('value').t('none').up().up().up() + .c('field', { + 'label': 'Roles for which Presence is Broadcasted', + 'type': 'list-multi', + 'var': 'muc#roomconfig_presencebroadcast'}) + .c('value').t('moderator').up() + .c('value').t('participant').up() + .c('value').t('visitor').up() + .c('option', {'label': 'Moderator'}) + .c('value').t('moderator').up().up() + .c('option', {'label': 'Participant'}) + .c('value').t('participant').up().up() + .c('option', {'label': 'Visitor'}) + .c('value').t('visitor').up().up().up() + .c('field', { + 'label': 'Roles and Affiliations that May Retrieve Member List', + 'type': 'list-multi', + 'var': 'muc#roomconfig_getmemberlist'}) + .c('value').t('moderator').up() + .c('value').t('participant').up() + .c('value').t('visitor').up() + .c('option', {'label': 'Moderator'}) + .c('value').t('moderator').up().up() + .c('option', {'label': 'Participant'}) + .c('value').t('participant').up().up() + .c('option', {'label': 'Visitor'}) + .c('value').t('visitor').up().up().up() + .c('field', { + 'label': 'Make Room Publicly Searchable?', + 'type': 'boolean', + 'var': 'muc#roomconfig_publicroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Publicly Searchable?', + 'type': 'boolean', + 'var': 'muc#roomconfig_publicroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Persistent?', + 'type': 'boolean', + 'var': 'muc#roomconfig_persistentroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Moderated?', + 'type': 'boolean', + 'var': 'muc#roomconfig_moderatedroom'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Make Room Members Only?', + 'type': 'boolean', + 'var': 'muc#roomconfig_membersonly'}) + .c('value').t(0).up().up() + .c('field', { + 'label': 'Password Required for Entry?', + 'type': 'boolean', + 'var': 'muc#roomconfig_passwordprotectedroom'}) + .c('value').t(1).up().up() + .c('field', {'type': 'fixed'}) + .c('value').t( + 'If a password is required to enter this groupchat, you must specify the password below.' + ).up().up() + .c('field', { + 'label': 'Password', + 'type': 'text-private', + 'var': 'muc#roomconfig_roomsecret'}) + .c('value').t('cauldronburn'); + _converse.connection._dataRecv(mock.createRequest(config_stanza)); + + const membersonly = await u.waitUntil(() => view.querySelector('input[name="muc#roomconfig_membersonly"]')); + expect(membersonly.getAttribute('type')).toBe('checkbox'); + membersonly.checked = true; + + const moderated = view.querySelectorAll('input[name="muc#roomconfig_moderatedroom"]'); + expect(moderated.length).toBe(1); + expect(moderated[0].getAttribute('type')).toBe('checkbox'); + moderated[0].checked = true; + + const password = view.querySelectorAll('input[name="muc#roomconfig_roomsecret"]'); + expect(password.length).toBe(1); + expect(password[0].getAttribute('type')).toBe('password'); + + const allowpm = view.querySelectorAll('select[name="muc#roomconfig_allowpm"]'); + expect(allowpm.length).toBe(1); + allowpm[0].value = 'moderators'; + + const presencebroadcast = view.querySelectorAll('select[name="muc#roomconfig_presencebroadcast"]'); + expect(presencebroadcast.length).toBe(1); + presencebroadcast[0].value = ['moderator']; + + view.querySelector('.chatroom-form input[type="submit"]').click(); + + expect(sent_IQ.querySelector('field[var="muc#roomconfig_membersonly"] value').textContent.trim()).toBe('1'); + expect(sent_IQ.querySelector('field[var="muc#roomconfig_moderatedroom"] value').textContent.trim()).toBe('1'); + expect(sent_IQ.querySelector('field[var="muc#roomconfig_allowpm"] value').textContent.trim()).toBe('moderators'); + expect(sent_IQ.querySelector('field[var="muc#roomconfig_presencebroadcast"] value').textContent.trim()).toBe('moderator'); + })); + + + it("properly handles notification that a room has been destroyed", + mock.initConverse([], {}, async function (_converse) { + + await mock.openChatRoomViaModal(_converse, 'problematic@muc.montague.lit', 'romeo') + const presence = $pres().attrs({ + from:'problematic@muc.montague.lit', + id:'n13mt3l', + to:'romeo@montague.lit/pda', + type:'error'}) + .c('error').attrs({'type':'cancel'}) + .c('gone').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}) + .t('xmpp:other-room@chat.jabberfr.org?join').up() + .c('text').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}) + .t("We didn't like the name").nodeTree; + + const view = _converse.chatboxviews.get('problematic@muc.montague.lit'); + _converse.connection._dataRecv(mock.createRequest(presence)); + const msg = await u.waitUntil(() => view.querySelector('.chatroom-body .disconnect-msg')); + expect(msg.textContent.trim()).toBe('This groupchat no longer exists'); + expect(view.querySelector('.chatroom-body .destroyed-reason').textContent.trim()) + .toBe(`The following reason was given: "We didn't like the name"`); + expect(view.querySelector('.chatroom-body .moved-label').textContent.trim()) + .toBe('The conversation has moved to a new address. Click the link below to enter.'); + expect(view.querySelector('.chatroom-body .moved-link').textContent.trim()) + .toBe(`other-room@chat.jabberfr.org`); + })); + + it("allows the user to invite their roster contacts to enter the groupchat", + mock.initConverse(['chatBoxesFetched'], {'view_mode': 'fullscreen'}, async function (_converse) { + + // We need roster contacts, so that we have someone to invite + await mock.waitForRoster(_converse, 'current'); + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_anonymous' + ] + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.getOwnAffiliation()).toBe('owner'); + expect(view.model.features.get('open')).toBe(false); + await u.waitUntil(() => view.querySelector('.open-invite-modal')); + + // Members can't invite if the room isn't open + view.model.getOwnOccupant().set('affiliation', 'member'); + + await u.waitUntil(() => view.querySelector('.open-invite-modal') === null); + + view.model.features.set('open', 'true'); + await u.waitUntil(() => view.querySelector('.open-invite-modal')); + + view.querySelector('.open-invite-modal').click(); + const modal = _converse.api.modal.get('converse-muc-invite-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + + expect(modal.querySelectorAll('#invitee_jids').length).toBe(1); + expect(modal.querySelectorAll('textarea').length).toBe(1); + + spyOn(view.model, 'directInvite').and.callThrough(); + + const input = modal.querySelector('#invitee_jids input'); + input.value = "Balt"; + modal.querySelector('input[type="submit"]').click(); + + await u.waitUntil(() => modal.querySelector('.error')); + + const error = modal.querySelector('.error'); + expect(error.textContent).toBe('Please enter a valid XMPP address'); + + let evt = new Event('input'); + input.dispatchEvent(evt); + + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza)); + const hint = await u.waitUntil(() => modal.querySelector('.suggestion-box__results li')); + expect(input.value).toBe('Balt'); + expect(hint.textContent.trim()).toBe('Balthasar'); + + evt = new Event('mousedown', {'bubbles': true}); + evt.button = 0; + hint.dispatchEvent(evt); + + const textarea = modal.querySelector('textarea'); + textarea.value = "Please join!"; + modal.querySelector('input[type="submit"]').click(); + + expect(view.model.directInvite).toHaveBeenCalled(); + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="balthasar@montague.lit" `+ + `xmlns="jabber:client">`+ + `<x jid="lounge@montague.lit" reason="Please join!" xmlns="jabber:x:conference"/>`+ + `</message>` + ); + })); + + it("can be joined automatically, based upon a received invite", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); // We need roster contacts, who can invite us + const name = mock.cur_names[0]; + const from_jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await u.waitUntil(() => _converse.roster.get(from_jid).vcard.get('fullname')); + + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await view.close(); // Hack, otherwise we have to mock stanzas. + + const muc_jid = 'lounge@montague.lit'; + const reason = "Please join this groupchat"; + + expect(_converse.chatboxes.models.length).toBe(1); + expect(_converse.chatboxes.models[0].id).toBe("controlbox"); + + const stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.bare_jid}" from="${from_jid}" id="9bceb415-f34b-4fa4-80d5-c0d076a24231"> + <x xmlns="jabber:x:conference" jid="${muc_jid}" reason="${reason}"/> + </message>`); + await _converse.onDirectMUCInvitation(stanza); + + expect(_converse.api.confirm).toHaveBeenCalledWith( + name + ' has invited you to join a groupchat: '+ muc_jid + + ', and left the following reason: "'+reason+'"'); + expect(_converse.chatboxes.models.length).toBe(2); + expect(_converse.chatboxes.models[0].id).toBe('controlbox'); + expect(_converse.chatboxes.models[1].id).toBe(muc_jid); + })); + + it("shows received groupchat messages", + mock.initConverse([], {}, async function (_converse) { + + const text = 'This is a received message'; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + spyOn(_converse.api, "trigger").and.callThrough(); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const nick = mock.chatroom_names[0]; + view.model.occupants.create({ + 'nick': nick, + 'muc_jid': `${view.model.get('jid')}/${nick}` + }); + + const message = $msg({ + from: 'lounge@montague.lit/'+nick, + id: '1', + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(text); + await view.model.handleMessageStanza(message.nodeTree); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent.trim()).toBe(text); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + })); + + it("shows sent groupchat messages", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + spyOn(_converse.api, "trigger").and.callThrough(); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const text = 'This is a sent message'; + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = text; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + + expect(_converse.api.trigger).toHaveBeenCalledWith('sendMessage', jasmine.any(Object)); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + // Let's check that if we receive the same message again, it's + // not shown. + const stanza = u.toStanza(` + <message xmlns="jabber:client" + from="lounge@montague.lit/romeo" + to="${_converse.connection.jid}" + type="groupchat"> + <body>${text}</body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${view.model.messages.at(0).get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(stanza); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(sizzle('.chat-msg__text:last').pop().textContent.trim()).toBe(text); + expect(view.model.messages.length).toBe(1); + // We don't emit an event if it's our own message + expect(_converse.api.trigger.calls.count(), 1); + })); + + it("will cause the chat area to be scrolled down only if it was at the bottom already", + mock.initConverse([], {}, async function (_converse) { + + const message = 'This message is received while the chat area is scrolled up'; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + // Create enough messages so that there's a scrollbar. + const promises = []; + for (let i=0; i<20; i++) { + promises.push( + view.model.handleMessageStanza( + $msg({ + from: 'lounge@montague.lit/someone', + to: 'romeo@montague.lit.com', + type: 'groupchat', + id: u.getUniqueId(), + }).c('body').t('Message: '+i).tree()) + ); + } + await Promise.all(promises); + const promise = u.getOpenPromise(); + + // Give enough time for `markScrolled` to have been called + setTimeout(async () => { + const content = view.querySelector('.chat-content'); + content.scrollTop = 0; + await view.model.handleMessageStanza( + $msg({ + from: 'lounge@montague.lit/someone', + to: 'romeo@montague.lit.com', + type: 'groupchat', + id: u.getUniqueId(), + }).c('body').t(message).tree()); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 21); + // Now check that the message appears inside the chatbox in the DOM + const msg_txt = sizzle('.chat-msg:last .chat-msg__text', content).pop().textContent; + expect(msg_txt).toEqual(message); + expect(content.scrollTop).toBe(0); + promise.resolve(); + }, 500); + + return promise; + })); + + + it("informs users if the room configuration has changed", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'coven@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + + const stanza = u.toStanza(` + <message from='${muc_jid}' + id='80349046-F26A-44F3-A7A6-54825064DD9E' + to='${_converse.jid}' + type='groupchat'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <status code='170'/> + </x> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + const info_messages = view.querySelectorAll('.chat-content .chat-info'); + expect(info_messages[0].textContent.trim()).toBe('Groupchat logging is now enabled'); + })); + + it("queries for the groupchat information before attempting to join the user", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const nick = "some1"; + const IQ_stanzas = _converse.connection.IQ_stanzas; + const muc_jid = 'coven@chat.shakespeare.lit'; + + await _converse.api.rooms.open(muc_jid, { nick }); + const stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + // Check that the groupchat queried for the feautures. + expect(Strophe.serialize(stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${stanza.getAttribute("id")}" to="${muc_jid}" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/disco#info"/>`+ + `</iq>`); + + /* <iq from='coven@chat.shakespeare.lit' + * id='ik3vs715' + * to='hag66@shakespeare.lit/pda' + * type='result'> + * <query xmlns='http://jabber.org/protocol/disco#info'> + * <identity + * category='conference' + * name='A Dark Cave' + * type='text'/> + * <feature var='http://jabber.org/protocol/muc'/> + * <feature var='muc_passwordprotected'/> + * <feature var='muc_hidden'/> + * <feature var='muc_temporary'/> + * <feature var='muc_open'/> + * <feature var='muc_unmoderated'/> + * <feature var='muc_nonanonymous'/> + * </query> + * </iq> + */ + const features_stanza = $iq({ + 'from': muc_jid, + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_passwordprotected'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_open'}).up() + .c('feature', {'var': 'muc_unmoderated'}).up() + .c('feature', {'var': 'muc_nonanonymous'}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + let view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/${nick}"]`)).pop()); + view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + expect(view.model.features.get('fetched')).toBeTruthy(); + expect(view.model.features.get('passwordprotected')).toBe(true); + expect(view.model.features.get('hidden')).toBe(true); + expect(view.model.features.get('temporary')).toBe(true); + expect(view.model.features.get('open')).toBe(true); + expect(view.model.features.get('unmoderated')).toBe(true); + expect(view.model.features.get('nonanonymous')).toBe(true); + })); + + it("updates the shown features when the groupchat configuration has changed", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + let features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_publicroom', + 'muc_temporary', + 'muc_open', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + const muc_jid = 'room@conference.example.org'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + + const info_el = view.querySelector(".show-muc-details-modal"); + info_el.click(); + let modal = _converse.api.modal.get('converse-muc-details-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + let features_list = modal.querySelector('.features-list'); + let features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s); + + expect(features_shown.join(' ')).toBe( + 'Password protected - This groupchat requires a password before entry '+ + 'Open - Anyone can join this groupchat '+ + 'Temporary - This groupchat will disappear once the last person leaves '+ + 'Not anonymous - All other groupchat participants can see your XMPP address '+ + 'Not moderated - Participants entering this groupchat can write right away'); + expect(view.model.features.get('hidden')).toBe(false); + expect(view.model.features.get('mam_enabled')).toBe(false); + expect(view.model.features.get('membersonly')).toBe(false); + expect(view.model.features.get('moderated')).toBe(false); + expect(view.model.features.get('nonanonymous')).toBe(true); + expect(view.model.features.get('open')).toBe(true); + expect(view.model.features.get('passwordprotected')).toBe(true); + expect(view.model.features.get('persistent')).toBe(false); + expect(view.model.features.get('publicroom')).toBe(true); + expect(view.model.features.get('semianonymous')).toBe(false); + expect(view.model.features.get('temporary')).toBe(true); + expect(view.model.features.get('unmoderated')).toBe(true); + expect(view.model.features.get('unsecured')).toBe(false); + await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room'); + + modal.querySelector('.close').click(); + view.querySelector('.configure-chatroom-button').click(); + + const IQs = _converse.connection.IQ_stanzas; + const s = `iq[to="${muc_jid}"] query[xmlns="${Strophe.NS.MUC_OWNER}"]`; + let iq = await u.waitUntil(() => IQs.filter(iq => iq.querySelector(s)).pop()); + + const response_el = u.toStanza( + `<iq xmlns="jabber:client" + type="result" + to="romeo@montague.lit/pda" + from="room@conference.example.org" id="${iq.getAttribute('id')}"> + <query xmlns="http://jabber.org/protocol/muc#owner"> + <x xmlns="jabber:x:data" type="form"> + <title>Configuration for room@conference.example.org</title> + <instructions>Complete and submit this form to configure the room.</instructions> + <field var="FORM_TYPE" type="hidden"> + <value>http://jabber.org/protocol/muc#roomconfig</value> + </field> + <field type="fixed"> + <value>Room information</value> + </field> + <field var="muc#roomconfig_roomname" type="text-single" label="Title"> + <value>Room</value> + </field> + <field var="muc#roomconfig_roomdesc" type="text-single" label="Description"> + <desc>A brief description of the room</desc> + <value>This room is used in tests</value> + </field> + <field var="muc#roomconfig_lang" type="text-single" label="Language tag for room (e.g. 'en', 'de', 'fr' etc.)"> + <desc>Indicate the primary language spoken in this room</desc> + <value>en</value> + </field> + <field var="muc#roomconfig_persistentroom" type="boolean" label="Persistent (room should remain even when it is empty)"> + <desc>Rooms are automatically deleted when they are empty, unless this option is enabled</desc> + <value>1</value> + </field> + <field var="muc#roomconfig_publicroom" type="boolean" label="Include room information in public lists"> + <desc>Enable this to allow people to find the room</desc> + <value>1</value> + </field> + <field type="fixed"><value>Access to the room</value></field> + <field var="muc#roomconfig_roomsecret" type="text-private" label="Password"><value/></field> + <field var="muc#roomconfig_membersonly" type="boolean" label="Only allow members to join"> + <desc>Enable this to only allow access for room owners, admins and members</desc> + </field> + <field var="{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites" type="boolean" label="Allow members to invite new members"/> + <field type="fixed"><value>Permissions in the room</value> + </field> + <field var="muc#roomconfig_changesubject" type="boolean" label="Allow anyone to set the room's subject"> + <desc>Choose whether anyone, or only moderators, may set the room's subject</desc> + </field> + <field var="muc#roomconfig_moderatedroom" type="boolean" label="Moderated (require permission to speak)"> + <desc>In moderated rooms occupants must be given permission to speak by a room moderator</desc> + </field> + <field var="muc#roomconfig_whois" type="list-single" label="Addresses (JIDs) of room occupants may be viewed by:"> + <option label="Moderators only"><value>moderators</value></option> + <option label="Anyone"><value>anyone</value></option> + <value>anyone</value> + </field> + <field type="fixed"><value>Other options</value></field> + <field var="muc#roomconfig_historylength" type="text-single" label="Maximum number of history messages returned by room"> + <desc>Specify the maximum number of previous messages that should be sent to users when they join the room</desc> + <value>50</value> + </field> + <field var="muc#roomconfig_defaulthistorymessages" type="text-single" label="Default number of history messages returned by room"> + <desc>Specify the number of previous messages sent to new users when they join the room</desc> + <value>20</value> + </field> + </x> + </query> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(response_el)); + await u.waitUntil(() => document.querySelector('.chatroom-form input')); + expect(view.querySelector('.chatroom-form legend').textContent.trim()).toBe("Configuration for room@conference.example.org"); + sizzle('[name="muc#roomconfig_membersonly"]', view).pop().click(); + sizzle('[name="muc#roomconfig_roomname"]', view).pop().value = "New room name" + view.querySelector('.chatroom-form input[type="submit"]').click(); + + iq = await u.waitUntil(() => IQs.filter(iq => u.matchesSelector(iq, `iq[to="${muc_jid}"][type="set"]`)).pop()); + const result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq.getAttribute('id') + }); + + IQs.length = 0; // Empty the array + _converse.connection._dataRecv(mock.createRequest(result)); + + iq = await u.waitUntil(() => IQs.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'New room name', + 'type': 'text' + }).up(); + features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + features.forEach(f => features_stanza.c('feature', {'var': f}).up()); + features_stanza.c('x', { 'xmlns':'jabber:x:data', 'type':'result'}) + .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) + .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up() + .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'}) + .c('value').t('This is the description').up().up() + .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'}) + .c('value').t(0); + + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + await u.waitUntil(() => new Promise(success => view.model.features.on('change', success))); + + info_el.click(); + modal = _converse.api.modal.get('converse-muc-details-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + features_list = modal.querySelector('.features-list'); + features_shown = features_list.textContent.split('\n').map(s => s.trim()).filter(s => s); + expect(features_shown.join(' ')).toBe( + 'Password protected - This groupchat requires a password before entry '+ + 'Hidden - This groupchat is not publicly searchable '+ + 'Members only - This groupchat is restricted to members only '+ + 'Temporary - This groupchat will disappear once the last person leaves '+ + 'Not anonymous - All other groupchat participants can see your XMPP address '+ + 'Not moderated - Participants entering this groupchat can write right away'); + expect(view.model.features.get('hidden')).toBe(true); + expect(view.model.features.get('mam_enabled')).toBe(false); + expect(view.model.features.get('membersonly')).toBe(true); + expect(view.model.features.get('moderated')).toBe(false); + expect(view.model.features.get('nonanonymous')).toBe(true); + expect(view.model.features.get('open')).toBe(false); + expect(view.model.features.get('passwordprotected')).toBe(true); + expect(view.model.features.get('persistent')).toBe(false); + expect(view.model.features.get('publicroom')).toBe(false); + expect(view.model.features.get('semianonymous')).toBe(false); + expect(view.model.features.get('temporary')).toBe(true); + expect(view.model.features.get('unmoderated')).toBe(true); + expect(view.model.features.get('unsecured')).toBe(false); + await u.waitUntil(() => view.querySelector('.chatbox-title__text')?.textContent.trim() === 'New room name'); + })); + + it("indicates when a room is no longer anonymous", + mock.initConverse([], {}, async function (_converse) { + + let IQ_id; + const sendIQ = _converse.connection.sendIQ; + + await mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'some1'); + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'coven@chat.shakespeare.lit', + 'id': IQ_id, + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + /* <message xmlns="jabber:client" + * type="groupchat" + * to="romeo@montague.lit/_converse.js-27854181" + * from="coven@chat.shakespeare.lit"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <status code="104"/> + * <status code="172"/> + * </x> + * </message> + */ + const message = $msg({ + type:'groupchat', + to: 'romeo@montague.lit/_converse.js-27854181', + from: 'coven@chat.shakespeare.lit' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('status', {code: '104'}).up() + .c('status', {code: '172'}); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + const chat_body = view.querySelector('.chatroom-body'); + expect(sizzle('.message:last', chat_body).pop().textContent.trim()) + .toBe('This groupchat is now no longer anonymous'); + })); + + it("informs users if they have been kicked out of the groupchat", + mock.initConverse([], {}, async function (_converse) { + + /* <presence + * from='harfleur@chat.shakespeare.lit/pistol' + * to='pistol@shakespeare.lit/harfleur' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' role='none'> + * <actor nick='Fluellen'/> + * <reason>Avaunt, you cullion!</reason> + * </item> + * <status code='110'/> + * <status code='307'/> + * </x> + * </presence> + */ + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + + const presence = $pres().attrs({ + from:'lounge@montague.lit/romeo', + to:'romeo@montague.lit/pda', + type:'unavailable' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: 'romeo@montague.lit/pda', + role: 'none' + }) + .c('actor').attrs({nick: 'Fluellen'}).up() + .c('reason').t('Avaunt, you cullion!').up() + .up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'307'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => !u.isVisible(view.querySelector('.chat-area'))); + expect(u.isVisible(view.querySelector('.occupants'))).toBeFalsy(); + const chat_body = view.querySelector('.chatroom-body'); + expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(3); + expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe( + 'You have been kicked from this groupchat'); + expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe( + 'This action was done by Fluellen.'); + expect(chat_body.querySelector('.disconnect-msg:nth-child(3)').textContent.trim()).toBe( + 'The reason given is: "Avaunt, you cullion!".'); + + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.DISCONNECTED); + })); + + it("informs users if they have exited the groupchat due to a technical reason", + mock.initConverse([], {}, async function (_converse) { + + /* <presence + * from='harfleur@chat.shakespeare.lit/pistol' + * to='pistol@shakespeare.lit/harfleur' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' role='none'> + * <actor nick='Fluellen'/> + * <reason>Avaunt, you cullion!</reason> + * </item> + * <status code='110'/> + * <status code='307'/> + * </x> + * </presence> + */ + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const presence = $pres().attrs({ + from:'lounge@montague.lit/romeo', + to:'romeo@montague.lit/pda', + type:'unavailable' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: 'romeo@montague.lit/pda', + role: 'none' + }) + .c('reason').t('Flux capacitor overload!').up() + .up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'333'}).up() + .c('status').attrs({code:'307'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => !u.isVisible(view.querySelector('.chat-area'))); + expect(u.isVisible(view.querySelector('.occupants'))).toBeFalsy(); + const chat_body = view.querySelector('.chatroom-body'); + expect(chat_body.querySelectorAll('.disconnect-msg').length).toBe(2); + expect(chat_body.querySelector('.disconnect-msg:first-child').textContent.trim()).toBe( + 'You have exited this groupchat due to a technical problem'); + expect(chat_body.querySelector('.disconnect-msg:nth-child(2)').textContent.trim()).toBe( + 'The reason given is: "Flux capacitor overload!".'); + })); + + + it("can be saved to, and retrieved from, browserStorage", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + // We instantiate a new ChatBoxes collection, which by default + // will be empty. + await mock.openControlBox(_converse); + const newchatboxes = new _converse.ChatBoxes(); + expect(newchatboxes.length).toEqual(0); + // The chatboxes will then be fetched from browserStorage inside the + // onConnected method + newchatboxes.onConnected(); + await new Promise(resolve => _converse.api.listen.once('chatBoxesFetched', resolve)); + + expect(newchatboxes.length).toEqual(2); + // Check that the chatrooms retrieved from browserStorage + // have the same attributes values as the original ones. + const attrs = ['id', 'box_id', 'visible']; + let new_attrs, old_attrs; + for (let i=0; i<attrs.length; i++) { + new_attrs = newchatboxes.models.map(m => m.attributes[attrs[i]]); + old_attrs = _converse.chatboxes.models.map(m => m.attributes[attrs[i]]); + expect(new_attrs.sort()).toEqual(old_attrs.sort()); + } + })); + + it("can be closed again by clicking a DOM element with class 'close-chatbox-button'", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const model = await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + spyOn(model, 'close').and.callThrough(); + spyOn(_converse.api, "trigger").and.callThrough(); + spyOn(model, 'leave'); + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + const view = await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit')); + const button = await u.waitUntil(() => view.querySelector('.close-chatbox-button')); + button.click(); + await u.waitUntil(() => model.close.calls.count()); + expect(model.leave).toHaveBeenCalled(); + await u.waitUntil(() => _converse.api.trigger.calls.count()); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object)); + })); + + it("informs users of role and affiliation changes", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and annoyingGuy have entered the groupchat"); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo has entered the groupchat\nannoyingGuy has been muted"); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo has entered the groupchat\nannoyingGuy has been given a voice"); + + // Check that we don't see an info message concerning the role, + // if the affiliation has changed. + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'none', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => + Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() === + "annoyingGuy is no longer a member of this groupchat" + ); + expect(1).toBe(1); + })); + + it("notifies users of role and affiliation changes for members not currently in the groupchat", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + let message = $msg({ + from: 'lounge@montague.lit', + id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F40', + to: 'romeo@montague.lit' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'absentguy@montague.lit', + 'affiliation': 'member', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(message)); + await u.waitUntil(() => view.model.occupants.length > 1); + expect(view.model.occupants.length).toBe(2); + expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('member'); + + message = $msg({ + from: 'lounge@montague.lit', + id: '2CF9013B-E8A8-42A1-9633-85AD7CA12F41', + to: 'romeo@montague.lit' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'absentguy@montague.lit', + 'affiliation': 'none', + 'role': 'none' + }); + _converse.connection._dataRecv(mock.createRequest(message)); + expect(view.model.occupants.length).toBe(2); + expect(view.model.occupants.findWhere({'jid': 'absentguy@montague.lit'}).get('affiliation')).toBe('none'); + + })); + }); + + + describe("Each chat groupchat can take special commands", function () { + + it("takes /help to show the available commands", + mock.initConverse([], {}, async function (_converse) { + + spyOn(window, 'confirm').and.callFake(() => true); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; + textarea.value = '/help'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + + await u.waitUntil(() => sizzle('converse-chat-help .chat-info', view).length); + let chat_help_el = view.querySelector('converse-chat-help'); + let info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(19); + expect(info_messages.pop().textContent.trim()).toBe('/voice: Allow muted user to post messages'); + expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)'); + expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject'); + expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation'); + expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user'); + expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/mute: Remove user\'s ability to post messages'); + expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI'); + expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user'); + expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person'); + expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu'); + expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant'); + expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area'); + expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast'); + expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin'); + + const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); + occupant.set('affiliation', 'admin'); + + view.querySelector('.close-chat-help').click(); + expect(view.model.get('show_help_messages')).toBe(false); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + + textarea.value = '/help'; + message_form.onKeyDown(enter); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(18); + let commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual([ + "/admin", "/ban", "/clear", "/deop", "/destroy", + "/help", "/kick", "/me", "/member", "/modtools", "/mute", "/nick", + "/op", "/register", "/revoke", "/subject", "/topic", "/voice" + ]); + occupant.set('affiliation', 'member'); + view.querySelector('.close-chat-help').click(); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + + textarea.value = '/help'; + message_form.onKeyDown(enter); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(9); + commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual(["/clear", "/help", "/kick", "/me", "/modtools", "/mute", "/nick", "/register", "/voice"]); + + view.querySelector('.close-chat-help').click(); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + expect(view.model.get('show_help_messages')).toBe(false); + + occupant.set('role', 'participant'); + // Role changes causes rerender, so we need to get the new textarea + + textarea.value = '/help'; + message_form.onKeyDown(enter); + await u.waitUntil(() => view.model.get('show_help_messages')); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(5); + commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register"]); + + // Test that /topic is available if all users may change the subject + // Note: we're making a shortcut here, this value should never be set manually + view.model.config.set('changesubject', true); + view.querySelector('.close-chat-help').click(); + await u.waitUntil(() => view.querySelector('converse-chat-help') === null); + + textarea.value = '/help'; + message_form.onKeyDown(enter); + chat_help_el = await u.waitUntil(() => view.querySelector('converse-chat-help')); + info_messages = sizzle('.chat-info', chat_help_el); + expect(info_messages.length).toBe(7); + commands = info_messages.map(m => m.textContent.replace(/:.*$/, '')); + expect(commands).toEqual(["/clear", "/help", "/me", "/nick", "/register", "/subject", "/topic"]); + })); + + it("takes /help to show the available commands and commands can be disabled by config", + mock.initConverse([], {muc_disable_slash_commands: ['mute', 'voice']}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + const enter = { 'target': textarea, 'preventDefault': function () {}, 'keyCode': 13 }; + spyOn(window, 'confirm').and.callFake(() => true); + textarea.value = '/clear'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown(enter); + textarea.value = '/help'; + message_form.onKeyDown(enter); + + await u.waitUntil(() => sizzle('.chat-info:not(.chat-event)', view).length); + const info_messages = sizzle('.chat-info:not(.chat-event)', view); + expect(info_messages.length).toBe(17); + expect(info_messages.pop().textContent.trim()).toBe('/topic: Set groupchat subject (alias for /subject)'); + expect(info_messages.pop().textContent.trim()).toBe('/subject: Set groupchat subject'); + expect(info_messages.pop().textContent.trim()).toBe('/revoke: Revoke the user\'s current affiliation'); + expect(info_messages.pop().textContent.trim()).toBe('/register: Register your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/owner: Grant ownership of this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/op: Grant moderator role to user'); + expect(info_messages.pop().textContent.trim()).toBe('/nick: Change your nickname'); + expect(info_messages.pop().textContent.trim()).toBe('/modtools: Opens up the moderator tools GUI'); + expect(info_messages.pop().textContent.trim()).toBe('/member: Grant membership to a user'); + expect(info_messages.pop().textContent.trim()).toBe('/me: Write in 3rd person'); + expect(info_messages.pop().textContent.trim()).toBe('/kick: Kick user from groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/help: Show this menu'); + expect(info_messages.pop().textContent.trim()).toBe('/destroy: Remove this groupchat'); + expect(info_messages.pop().textContent.trim()).toBe('/deop: Change user role to participant'); + expect(info_messages.pop().textContent.trim()).toBe('/clear: Clear the chat area'); + expect(info_messages.pop().textContent.trim()).toBe('/ban: Ban user by changing their affiliation to outcast'); + expect(info_messages.pop().textContent.trim()).toBe('/admin: Change user\'s affiliation to admin'); + })); + + it("takes /member to make an occupant a member", + mock.initConverse([], {}, async function (_converse) { + + let iq_stanza; + await mock.openAndEnterChatRoom(_converse, 'lounge@muc.montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@muc.montague.lit'); + /* We don't show join/leave messages for existing occupants. We + * know about them because we receive their presences before we + * receive our own. + */ + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'lounge@muc.montague.lit/marc' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'marc@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.occupants.length).toBe(2); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake((stanza) => { + sent_stanza = stanza; + }); + + // First check that an error message appears when a + // non-existent nick is used. + textarea.value = '/member chris Welcome to the club!'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + expect(_converse.connection.send).not.toHaveBeenCalled(); + await u.waitUntil(() => view.querySelectorAll('.chat-error').length); + expect(view.querySelector('.chat-error').textContent.trim()) + .toBe('Error: couldn\'t find a groupchat participant based on your arguments'); + + // Now test with an existing nick + textarea.value = '/member marc Welcome to the club!'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => Strophe.serialize(sent_stanza) === + `<iq id="${sent_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="member" jid="marc@montague.lit">`+ + `<reason>Welcome to the club!</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + let result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": sent_stanza.getAttribute('id') + }); + _converse.connection.IQ_stanzas = []; + _converse.connection._dataRecv(mock.createRequest(result)); + iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="member"]')).pop() + ); + + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="member"/>`+ + `</query>`+ + `</iq>`) + expect(view.model.occupants.length).toBe(2); + + result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq_stanza.getAttribute("id") + }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"}) + .c("item", {"jid": "marc", "affiliation": "member"}); + _converse.connection._dataRecv(mock.createRequest(result)); + + expect(view.model.occupants.length).toBe(2); + iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="owner"]')).pop() + ); + + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="owner"/>`+ + `</query>`+ + `</iq>`) + expect(view.model.occupants.length).toBe(2); + + result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq_stanza.getAttribute("id") + }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"}) + .c("item", {"jid": "romeo@montague.lit", "affiliation": "owner"}); + _converse.connection._dataRecv(mock.createRequest(result)); + + expect(view.model.occupants.length).toBe(2); + iq_stanza = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector('iq[to="lounge@muc.montague.lit"][type="get"] item[affiliation="admin"]')).pop() + ); + + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq id="${iq_stanza.getAttribute('id')}" to="lounge@muc.montague.lit" type="get" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="admin"/>`+ + `</query>`+ + `</iq>`) + expect(view.model.occupants.length).toBe(2); + + result = $iq({ + "xmlns": "jabber:client", + "type": "result", + "to": "romeo@montague.lit/orchard", + "from": "lounge@muc.montague.lit", + "id": iq_stanza.getAttribute("id") + }).c("query", {"xmlns": "http://jabber.org/protocol/muc#admin"}) + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.querySelectorAll('.occupant').length, 500); + await u.waitUntil(() => view.querySelectorAll('.badge').length > 1); + expect(view.model.occupants.length).toBe(2); + expect(view.querySelectorAll('.occupant').length).toBe(2); + })); + + it("takes /topic to set the groupchat topic", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + // Check the alias /topic + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/topic This is the groupchat subject'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + const { sent_stanzas } = _converse.connection; + await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is the groupchat subject')); + + // Check /subject + textarea.value = '/subject This is a new subject'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + let sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is a new subject').pop()); + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+ + '<subject xmlns="jabber:client">This is a new subject</subject>'+ + '</message>'); + + // Check case insensitivity + textarea.value = '/Subject This is yet another subject'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + sent_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.textContent.trim() === 'This is yet another subject').pop()); + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+ + '<subject xmlns="jabber:client">This is yet another subject</subject>'+ + '</message>'); + + while (sent_stanzas.length) { + sent_stanzas.pop(); + } + // Check unsetting the topic + textarea.value = '/topic'; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + sent_stanza = await u.waitUntil(() => sent_stanzas.pop()); + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + '<message from="romeo@montague.lit/orchard" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">'+ + '<subject xmlns="jabber:client"></subject>'+ + '</message>'); + })); + + it("takes /clear to clear messages", mock.initConverse([], {}, async function (_converse) { + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/clear'; + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(false)); + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => _converse.api.confirm.calls.count() === 1); + expect(_converse.api.confirm).toHaveBeenCalledWith('Are you sure you want to clear the messages from this conversation?'); + })); + + it("takes /owner to make a user an owner", mock.initConverse([], {}, async function (_converse) { + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/owner'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + const err_msg = await u.waitUntil(() => view.querySelector('.chat-error')); + expect(err_msg.textContent.trim()).toBe( + "Error: the \"owner\" command takes two arguments, the user's nickname and optionally a reason."); + + const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]'; + const stanzas = _converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length); + expect(stanzas.length).toBe(0); + + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/owner nobody You\'re responsible'; + message_form.onFormSubmitted(new Event('submit')); + await u.waitUntil(() => view.querySelectorAll('.chat-error').length === 2); + expect(Array.from(view.querySelectorAll('.chat-error')).pop().textContent.trim()).toBe( + "Error: couldn't find a groupchat participant based on your arguments"); + + expect(_converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length).length).toBe(0); + + // Call now with the correct of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/owner annoyingGuy You\'re responsible'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3); + // Check that the member list now gets updated + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="owner" jid="annoyingguy@montague.lit">`+ + `<reason>You're responsible</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D628', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'owner', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => + Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() === + "annoyingGuy is now an owner of this groupchat" + ); + })); + + it("takes /ban to ban a user", mock.initConverse([], {}, async function (_converse) { + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/ban'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"ban\" command takes two arguments, the user's nickname and optionally a reason."); + + const sel = 'iq[type="set"] query[xmlns="http://jabber.org/protocol/muc#admin"]'; + const stanzas = _converse.connection.IQ_stanzas.filter(s => sizzle(sel, s).length); + expect(stanzas.length).toBe(0); + + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/ban annoyingGuy You\'re annoying'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2); + // Check that the member list now gets updated + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item affiliation="outcast" jid="annoyingguy@montague.lit">`+ + `<reason>You're annoying</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D628', + 'to': 'romeo@montague.lit/desktop' + }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'outcast', + 'role': 'participant' + }).c('actor', {'nick': 'romeo'}).up() + .c('reason').t("You're annoying").up().up() + .c('status', {'code': '301'}); + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoyingGuy has been banned by romeo"); + expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying"); + presence = $pres({ + 'from': 'lounge@montague.lit/joe2', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'joe2@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + textarea.value = '/ban joe22'; + message_form.onFormSubmitted(new Event('submit')); + await u.waitUntil(() => view.querySelector('converse-chat-message:last-child')?.textContent?.trim() === + "Error: couldn't find a groupchat participant based on your arguments"); + })); + + + it("takes a /kick command to kick a user", mock.initConverse([], {}, async function (_converse) { + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(view.model, 'setRole').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + let presence = $pres({ + 'from': 'lounge@montague.lit/annoying guy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'none', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/kick'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"kick\" command takes two arguments, the user's nickname and optionally a reason."); + expect(view.model.setRole).not.toHaveBeenCalled(); + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/kick @annoying guy You\'re annoying'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="annoying guy" role="none">`+ + `<reason>You're annoying</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='harfleur@chat.shakespeare.lit/pistol' + * to='gower@shakespeare.lit/cell' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='none' role='none'/> + * <status code='307'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/annoying guy', + 'to': 'romeo@montague.lit/desktop', + 'type': 'unavailable' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'affiliation': 'none', + 'role': 'none' + }).c('actor', {'nick': 'romeo'}).up() + .c('reason').t("You're annoying").up().up() + .c('status', {'code': '307'}); + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelectorAll('.chat-info').length === 2); + expect(view.querySelectorAll('.chat-info__message')[1].textContent.trim()).toBe("annoying guy has been kicked out by romeo"); + expect(view.querySelector('.chat-info:last-child q').textContent.trim()).toBe("You're annoying"); + })); + + + it("takes /op and /deop to make a user a moderator or not", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + let sent_IQ, IQ_id; + const sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + spyOn(view.model, 'setRole').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + // New user enters the groupchat + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * id='27C55F89-1C6A-459A-9EB5-77690145D624' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' role='moderator'/> + * </x> + * </presence> + */ + let presence = $pres({ + 'from': 'lounge@montague.lit/trustworthyguy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'trustworthyguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and trustworthyguy have entered the groupchat"); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/op'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"op\" command takes two arguments, the user's nickname and optionally a reason."); + + expect(view.model.setRole).not.toHaveBeenCalled(); + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/op trustworthyguy You\'re trustworthy'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="trustworthyguy" role="moderator">`+ + `<reason>You're trustworthy</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='moderator'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/trustworthyguy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'trustworthyguy@montague.lit', + 'affiliation': 'member', + 'role': 'moderator' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + // Check now that things get restored when the user is given a voice + await u.waitUntil( + () => view.querySelector('.chat-content__notifications').textContent.split('\n', 2).pop()?.trim() === + "trustworthyguy is now a moderator"); + + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/deop trustworthyguy Perhaps not'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="trustworthyguy" role="participant">`+ + `<reason>Perhaps not</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='participant'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/trustworthyguy', + 'to': 'romeo@montague.lit/desktop' + }).c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'trustworthyguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("trustworthyguy is no longer a moderator")); + })); + + it("takes /mute and /voice to mute and unmute a user", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + var sent_IQ, IQ_id; + var sendIQ = _converse.connection.sendIQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback, errback) { + sent_IQ = iq; + IQ_id = sendIQ.bind(this)(iq, callback, errback); + }); + spyOn(view.model, 'setRole').and.callThrough(); + spyOn(view.model, 'validateRoleOrAffiliationChangeArgs').and.callThrough(); + + // New user enters the groupchat + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * id='27C55F89-1C6A-459A-9EB5-77690145D624' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' role='participant'/> + * </x> + * </presence> + */ + let presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'id':'27C55F89-1C6A-459A-9EB5-77690145D624', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "romeo and annoyingGuy have entered the groupchat"); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/mute'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count()); + await u.waitUntil(() => view.querySelector('.message:last-child')?.textContent?.trim() === + "Error: the \"mute\" command takes two arguments, the user's nickname and optionally a reason."); + expect(view.model.setRole).not.toHaveBeenCalled(); + // Call now with the correct amount of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/mute annoyingGuy You\'re annoying'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 2) + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="annoyingGuy" role="visitor">`+ + `<reason>You're annoying</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='visitor'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been muted")); + + // Call now with the correct of arguments. + // XXX: Calling onFormSubmitted directly, trying + // again via triggering Event doesn't work for some weird + // reason. + textarea.value = '/voice annoyingGuy Now you can talk again'; + message_form.onFormSubmitted(new Event('submit')); + + await u.waitUntil(() => view.model.validateRoleOrAffiliationChangeArgs.calls.count() === 3); + expect(view.model.setRole).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${IQ_id}" to="lounge@montague.lit" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#admin">`+ + `<item nick="annoyingGuy" role="participant">`+ + `<reason>Now you can talk again</reason>`+ + `</item>`+ + `</query>`+ + `</iq>`); + + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * to='crone1@shakespeare.lit/desktop'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='visitor'/> + * </x> + * </presence> + */ + presence = $pres({ + 'from': 'lounge@montague.lit/annoyingGuy', + 'to': 'romeo@montague.lit/desktop' + }) + .c('x', { 'xmlns': 'http://jabber.org/protocol/muc#user'}) + .c('item', { + 'jid': 'annoyingguy@montague.lit', + 'affiliation': 'member', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("annoyingGuy has been given a voice")); + })); + + it("takes /destroy to destroy a muc", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const new_muc_jid = 'foyer@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + let view = _converse.chatboxviews.get(muc_jid); + spyOn(_converse.api, 'confirm').and.callThrough(); + let textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/destroy'; + let message_form = view.querySelector('converse-muc-message-form'); + message_form.onFormSubmitted(new Event('submit')); + let modal = await u.waitUntil(() => document.querySelector('.modal-dialog')); + await u.waitUntil(() => u.isVisible(modal)); + + let challenge_el = modal.querySelector('[name="challenge"]'); + challenge_el.value = muc_jid+'e'; + const reason_el = modal.querySelector('[name="reason"]'); + reason_el.value = 'Moved to a new location'; + const newjid_el = modal.querySelector('[name="newjid"]'); + newjid_el.value = new_muc_jid; + let submit = modal.querySelector('[type="submit"]'); + submit.click(); + expect(u.isVisible(modal)).toBeTruthy(); + expect(u.hasClass('error', challenge_el)).toBeTruthy(); + challenge_el.value = muc_jid; + submit.click(); + + let sent_IQs = _converse.connection.IQ_stanzas; + let sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop()); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner">`+ + `<destroy jid="${new_muc_jid}">`+ + `<reason>`+ + `Moved to a new location`+ + `</reason>`+ + `</destroy>`+ + `</query>`+ + `</iq>`); + + let result_stanza = $iq({ + 'type': 'result', + 'id': sent_IQ.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }); + expect(_converse.chatboxes.length).toBe(2); + spyOn(_converse.api, "trigger").and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(result_stanza)); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED)); + await u.waitUntil(() => _converse.chatboxes.length === 1); + expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object)); + + // Try again without reason or new JID + _converse.connection.IQ_stanzas = []; + sent_IQs = _converse.connection.IQ_stanzas; + await mock.openAndEnterChatRoom(_converse, new_muc_jid, 'romeo'); + view = _converse.chatboxviews.get(new_muc_jid); + textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = '/destroy'; + message_form = view.querySelector('converse-muc-message-form'); + message_form.onFormSubmitted(new Event('submit')); + modal = await u.waitUntil(() => document.querySelector('.modal-dialog')); + await u.waitUntil(() => u.isVisible(modal)); + + challenge_el = modal.querySelector('[name="challenge"]'); + challenge_el.value = new_muc_jid; + submit = modal.querySelector('[type="submit"]'); + submit.click(); + + sent_IQ = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('destroy')).pop()); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq id="${sent_IQ.getAttribute('id')}" to="${new_muc_jid}" type="set" xmlns="jabber:client">`+ + `<query xmlns="http://jabber.org/protocol/muc#owner">`+ + `<destroy/>`+ + `</query>`+ + `</iq>`); + + result_stanza = $iq({ + 'type': 'result', + 'id': sent_IQ.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }); + expect(_converse.chatboxes.length).toBe(2); + _converse.connection._dataRecv(mock.createRequest(result_stanza)); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED)); + await u.waitUntil(() => _converse.chatboxes.length === 1); + })); + }); + + describe("When attempting to enter a groupchat", function () { + + it("will show an error message if the groupchat requires a password", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'protected'; + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + + const presence = $pres().attrs({ + 'from': `${muc_jid}/romeo`, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit/pda', + 'type': 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'auth'}) + .c('not-authorized').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}); + + _converse.connection._dataRecv(mock.createRequest(presence)); + + const chat_body = view.querySelector('.chatroom-body'); + await u.waitUntil(() => chat_body.querySelectorAll('form.chatroom-form').length === 1); + expect(chat_body.querySelector('.chatroom-form label').textContent.trim()) + .toBe('This groupchat requires a password'); + + // Let's submit the form + spyOn(view.model, 'join'); + const input_el = view.querySelector('[name="password"]'); + input_el.value = 'secret'; + view.querySelector('input[type=submit]').click(); + expect(view.model.join).toHaveBeenCalledWith('romeo', 'secret'); + })); + + it("will show an error message if the groupchat is members-only and the user not included", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'members-only@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + // State that the chat is members-only via the features IQ + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_membersonly'}).up(); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type: 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'auth'}) + .c('registration-required').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')?.textContent?.trim() === + 'You are not on the member list of this groupchat.'); + })); + + it("will show an error message if the user has been banned", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'off-limits@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type: 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'auth'}) + .c('forbidden').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe('You have been banned from this groupchat'); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.BANNED); + })); + + it("will show an error message if the user is not allowed to have created the groupchat", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'impermissable@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo') + + // We pretend this is a new room, so no disco info is returned. + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': 'room@conference.example.org', + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to:'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('not-allowed').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe('You are not allowed to create new groupchats.'); + })); + + it("will show an error message if the groupchat doesn't yet exist", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'nonexistent@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('item-not-found').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe("This groupchat does not (yet) exist."); + })); + + it("will show an error message if the groupchat has reached its maximum number of participants", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'maxed-out@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo') + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to:'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('service-unavailable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe("This groupchat has reached its maximum number of participants."); + })); + }); + + + describe("The affiliations delta", function () { + + it("can be computed in various ways", mock.initConverse([], {}, async function (_converse) { + await mock.openChatRoom(_converse, 'coven', 'chat.shakespeare.lit', 'romeo'); + var exclude_existing = false; + var remove_absentees = false; + var new_list = []; + var old_list = []; + const muc_utils = converse.env.muc_utils; + let delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + // When remove_absentees is false, then affiliations in the old + // list which are not in the new one won't be removed. + old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'}, + {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + // With exclude_existing set to false, any changed affiliations + // will be included in the delta (i.e. existing affiliations are included in the comparison). + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(1); + expect(delta[0].jid).toBe('wiccarocks@shakespeare.lit'); + expect(delta[0].affiliation).toBe('member'); + + // To also remove affiliations from the old list which are not + // in the new list, we set remove_absentees to true + remove_absentees = true; + old_list = [{'jid': 'oldhag666@shakespeare.lit', 'affiliation': 'owner'}, + {'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(1); + expect(delta[0].jid).toBe('oldhag666@shakespeare.lit'); + expect(delta[0].affiliation).toBe('none'); + + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, [], old_list); + expect(delta.length).toBe(2); + expect(delta[0].jid).toBe('oldhag666@shakespeare.lit'); + expect(delta[0].affiliation).toBe('none'); + expect(delta[1].jid).toBe('wiccarocks@shakespeare.lit'); + expect(delta[1].affiliation).toBe('none'); + + // To only add a user if they don't already have an + // affiliation, we set 'exclude_existing' to true + exclude_existing = true; + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'owner'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + + old_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'admin'}]; + delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list); + expect(delta.length).toBe(0); + })); + }); + + + describe("A XEP-0085 Chat Status Notification", function () { + + it("is is not sent out to a MUC if the user is a visitor in a moderated room", + mock.initConverse( + ['chatBoxesFetched'], {}, + async function (_converse) { + + spyOn(_converse.ChatRoom.prototype, 'sendChatState').and.callThrough(); + + const muc_jid = 'lounge@montague.lit'; + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_moderated', + 'muc_anonymous' + ] + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const view = _converse.chatboxviews.get(muc_jid); + view.model.setChatState(_converse.ACTIVE); + + expect(view.model.sendChatState).toHaveBeenCalled(); + const last_stanza = _converse.connection.sent_stanzas.pop(); + expect(Strophe.serialize(last_stanza)).toBe( + `<message to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<no-store xmlns="urn:xmpp:hints"/>`+ + `<no-permanent-store xmlns="urn:xmpp:hints"/>`+ + `</message>`); + + // Romeo loses his voice + const presence = $pres({ + to: 'romeo@montague.lit/orchard', + from: `${muc_jid}/romeo` + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', {'affiliation': 'none', 'role': 'visitor'}).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); + await u.waitUntil(() => occupant.get('role') === 'visitor'); + + spyOn(_converse.connection, 'send'); + view.model.setChatState(_converse.INACTIVE); + expect(view.model.sendChatState.calls.count()).toBe(2); + expect(_converse.connection.send).not.toHaveBeenCalled(); + })); + + + describe("A composing notification", function () { + + it("will be shown if received", mock.initConverse([], {}, async function (_converse) { + const muc_jid = 'coven@chat.shakespeare.lit'; + const members = [ + {'affiliation': 'member', 'nick': 'majortom', 'jid': 'majortom@example.org'}, + {'affiliation': 'admin', 'nick': 'groundcontrol', 'jid': 'groundcontrol@example.org'} + ]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1', [], members); + const view = _converse.chatboxviews.get(muc_jid); + + let csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + + let presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newguy have entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newguy and nomorenicks have entered the groupchat", 1000); + + // Manually clear so that we can more easily test + view.model.notifications.set('entered', []); + await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent, 1000); + + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + + const remove_notifications_timeouts = []; + const setTimeout = window.setTimeout; + spyOn(window, 'setTimeout').and.callFake((f, w) => { + if (f.toString() === "() => this.removeNotification(actor, state)") { + remove_notifications_timeouts.push(f) + } + setTimeout(f, w); + }); + + // <composing> state + let msg = $msg({ + from: muc_jid+'/newguy', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.connection._dataRecv(mock.createRequest(msg)); + + csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent, 1000); + expect(csntext.trim()).toEqual('newguy is typing'); + expect(remove_notifications_timeouts.length).toBe(1); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toEqual('newguy is typing'); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/nomorenicks', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy and nomorenicks are typing', 1000); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/majortom', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and majortom are typing', 1000); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/groundcontrol', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'newguy, nomorenicks and others are typing', 1000); + + msg = $msg({ + from: `${muc_jid}/some1`, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('hello world').tree(); + await view.model.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelector('.chat-msg .chat-msg__text').textContent.trim()).toBe('hello world'); + + // Test that the composing notifications get removed via timeout. + if (remove_notifications_timeouts.length) { + remove_notifications_timeouts[0](); + } + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === 'nomorenicks, majortom and groundcontrol are typing', 1000); + })); + }); + + describe("A paused notification", function () { + + it("will be shown if received", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'coven@chat.shakespeare.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'some1'); + const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit'); + + /* <presence to="romeo@montague.lit/_converse.js-29092160" + * from="coven@chat.shakespeare.lit/some1"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item affiliation="owner" jid="romeo@montague.lit/_converse.js-29092160" role="moderator"/> + * <status code="110"/> + * </x> + * </presence></body> + */ + let presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/some1' + }).c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'owner', + 'jid': 'romeo@montague.lit/_converse.js-29092160', + 'role': 'moderator' + }).up() + .c('status', {code: '110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("some1 has entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1 and newguy have entered the groupchat"); + + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/nomorenicks' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'nomorenicks@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === + "some1, newguy and nomorenicks have entered the groupchat"); + + // Manually clear so that we can more easily test + view.model.notifications.set('entered', []); + await u.waitUntil(() => !view.querySelector('.chat-content__notifications').textContent); + + // See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions + + // <composing> state + let msg = $msg({ + from: muc_jid+'/newguy', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(view.querySelector('.chat-content__notifications').textContent.trim()).toBe('newguy is typing'); + + // <composing> state for a different occupant + msg = $msg({ + from: muc_jid+'/nomorenicks', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('composing', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() == 'newguy and nomorenicks are typing'); + + // <paused> state from occupant who typed first + msg = $msg({ + from: muc_jid+'/newguy', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').c('paused', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await view.model.handleMessageStanza(msg); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() == 'nomorenicks is typing\nnewguy has stopped typing'); + })); + }); + }); + + describe("A muted user", function () { + + it("will receive a user-friendly error message when trying to send a message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'trollbox@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll'); + const view = _converse.chatboxviews.get(muc_jid); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + textarea.value = 'Hello world'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onFormSubmitted(new Event('submit')); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + let stanza = u.toStanza(` + <message id="${view.model.messages.at(0).get('msgid')}" + xmlns="jabber:client" + type="error" + to="troll@montague.lit/resource" + from="trollbox@montague.lit"> + <error type="auth"><forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent.trim(), 1000); + expect(view.querySelector('.chat-msg__error').textContent.trim()).toBe( + "Your message was not delivered because you weren't allowed to send it."); + + textarea.value = 'Hello again'; + message_form.onFormSubmitted(new Event('submit')); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2); + + stanza = u.toStanza(` + <message id="${view.model.messages.at(1).get('msgid')}" + xmlns="jabber:client" + type="error" + to="troll@montague.lit/resource" + from="trollbox@montague.lit"> + <error type="auth"> + <forbidden xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">Thou shalt not!</text> + </error> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 2); + const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error'; + await u.waitUntil(() => view.querySelector(sel)?.textContent.trim()); + expect(view.querySelector(sel).textContent.trim()).toBe('Thou shalt not!') + })); + + it("will see an explanatory message instead of a textarea", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + Strophe.NS.SID, + 'muc_moderated', + ] + const muc_jid = 'trollbox@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'troll', features); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('.chat-textarea')); + + let stanza = u.toStanza(` + <presence + from='trollbox@montague.lit/troll' + to='romeo@montague.lit/orchard'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='none' + nick='troll' + role='visitor'/> + <status code='110'/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.querySelector('.chat-textarea') === null); + let bottom_panel = view.querySelector('.muc-bottom-panel'); + expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room"); + + // This only applies to moderated rooms, so let's check that + // the textarea becomes visible when the room's + // configuration changes to be non-moderated + view.model.features.set('moderated', false); + await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null); + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + expect(textarea === null).toBe(false); + + view.model.features.set('moderated', true); + await u.waitUntil(() => view.querySelector('.chat-textarea') === null); + bottom_panel = view.querySelector('.muc-bottom-panel'); + expect(bottom_panel.textContent.trim()).toBe("You're not allowed to send messages in this room"); + + // Check now that things get restored when the user is given a voice + await u.waitUntil(() => + Array.from(view.querySelectorAll('.chat-info__message')).pop()?.textContent.trim() === + "troll is no longer an owner of this groupchat" + ); + + stanza = u.toStanza(` + <presence + from='trollbox@montague.lit/troll' + to='romeo@montague.lit/orchard'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='none' + nick='troll' + role='participant'/> + <status code='110'/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelector('.muc-bottom-panel') === null); + expect(textarea === null).toBe(false); + // Check now that things get restored when the user is given a voice + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.trim() === "troll has been given a voice"); + })); + }); + + describe("when muc_send_probes is true", function () { + + it("sends presence probes when muc_send_probes is true", + mock.initConverse([], {'muc_send_probes': true}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + + let stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/ralphm"> + <body>This message will trigger a presence probe</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + const view = _converse.chatboxviews.get(muc_jid); + + await u.waitUntil(() => view.model.messages.length); + let occupant = view.model.messages.at(0)?.occupant; + expect(occupant).toBeDefined(); + expect(occupant.get('nick')).toBe('ralphm'); + expect(occupant.get('affiliation')).toBeUndefined(); + expect(occupant.get('role')).toBeUndefined(); + + const sent_stanzas = _converse.connection.sent_stanzas; + let probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence[type="probe"]')).pop()); + expect(Strophe.serialize(probe)).toBe( + `<presence to="${muc_jid}/ralphm" type="probe" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`); + + let presence = u.toStanza( + `<presence xmlns="jabber:client" to="${converse.jid}" from="${muc_jid}/ralphm"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" jid="ralph@example.org/Conversations.ZvLu" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + + expect(occupant.get('affiliation')).toBe('member'); + expect(occupant.get('role')).toBe('participant'); + + // Check that unavailable but affiliated occupants don't get destroyed + stanza = u.toStanza(` + <message xmlns="jabber:client" to="${_converse.jid}" type="groupchat" from="${muc_jid}/gonePhising"> + <body>This message from an unavailable user will trigger a presence probe</body> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.model.messages.length === 2); + occupant = view.model.messages.at(1)?.occupant; + expect(occupant).toBeDefined(); + expect(occupant.get('nick')).toBe('gonePhising'); + expect(occupant.get('affiliation')).toBeUndefined(); + expect(occupant.get('role')).toBeUndefined(); + + probe = await u.waitUntil(() => sent_stanzas.filter(s => s.matches(`presence[to="${muc_jid}/gonePhising"]`)).pop()); + expect(Strophe.serialize(probe)).toBe( + `<presence to="${muc_jid}/gonePhising" type="probe" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`); + + presence = u.toStanza( + `<presence xmlns="jabber:client" type="unavailable" to="${converse.jid}" from="${muc_jid}/gonePhising"> + <x xmlns="http://jabber.org/protocol/muc#user"> + <item affiliation="member" jid="gonePhishing@example.org/d34dBEEF" role="participant"/> + </x> + </presence>`); + _converse.connection._dataRecv(mock.createRequest(presence)); + + expect(view.model.occupants.length).toBe(3); + expect(occupant.get('affiliation')).toBe('member'); + expect(occupant.get('role')).toBe('participant'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js new file mode 100644 index 0000000..2c9f23d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/nickname.js @@ -0,0 +1,475 @@ +/*global mock, converse */ + +const { $pres, $iq, Strophe, sizzle, u, stx } = converse.env; + +describe("A MUC", function () { + + it("allows you to change your nickname via a modal", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + + expect(model.get('nick')).toBe(nick); + expect(model.occupants.length).toBe(1); + expect(model.occupants.at(0).get('nick')).toBe(nick); + + const view = _converse.chatboxviews.get(muc_jid); + const dropdown_item = view.querySelector(".open-nickname-modal"); + dropdown_item.click(); + + const modal = _converse.api.modal.get('converse-muc-nickname-modal'); + await u.waitUntil(() => u.isVisible(modal)); + + const input = modal.querySelector('input[name="nick"]'); + expect(input.value).toBe(nick); + + const newnick = 'loverboy'; + input.value = newnick; + modal.querySelector('input[type="submit"]')?.click(); + + await u.waitUntil(() => !u.isVisible(modal)); + + const { sent_stanzas } = _converse.connection; + const sent_stanza = sent_stanzas.pop() + expect(Strophe.serialize(sent_stanza).toLocaleString()).toBe( + `<presence from="${_converse.jid}" id="${sent_stanza.getAttribute('id')}" to="${muc_jid}/${newnick}" xmlns="jabber:client"/>`); + + // Two presence stanzas are received from the MUC service + _converse.connection._dataRecv(mock.createRequest( + stx` + <presence + xmlns="jabber:server" + from='${muc_jid}/${nick}' + id='DC352437-C019-40EC-B590-AF29E879AF98' + to='${_converse.jid}' + type='unavailable'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='member' + jid='${_converse.jid}' + nick='${newnick}' + role='participant'/> + <status code='303'/> + <status code='110'/> + </x> + </presence>` + )); + + expect(model.get('nick')).toBe(newnick); + + _converse.connection._dataRecv(mock.createRequest( + stx` + <presence + xmlns="jabber:server" + from='${muc_jid}/${newnick}' + id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67' + to='${_converse.jid}'> + <x xmlns='http://jabber.org/protocol/muc#user'> + <item affiliation='member' + jid='${_converse.jid}' + role='participant'/> + <status code='110'/> + </x> + </presence>` + )); + + await u.waitUntil(() => model.occupants.at(0).get('nick') === newnick); + expect(model.occupants.length).toBe(1); + })); + + it("informs users if their nicknames have been changed.", + mock.initConverse([], {}, async function (_converse) { + + /* The service then sends two presence stanzas to the full JID + * of each occupant (including the occupant who is changing his + * or her room nickname), one of type "unavailable" for the old + * nickname and one indicating availability for the new + * nickname. + * + * See: https://xmpp.org/extensions/xep-0045.html#changenick + * + * <presence + * from='coven@montague.lit/thirdwitch' + * id='DC352437-C019-40EC-B590-AF29E879AF98' + * to='hag66@shakespeare.lit/pda' + * type='unavailable'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * nick='oldhag' + * role='participant'/> + * <status code='303'/> + * <status code='110'/> + * </x> + * </presence> + * + * <presence + * from='coven@montague.lit/oldhag' + * id='5B4F27A4-25ED-43F7-A699-382C6B4AFC67' + * to='hag66@shakespeare.lit/pda'> + * <x xmlns='http://jabber.org/protocol/muc#user'> + * <item affiliation='member' + * jid='hag66@shakespeare.lit/pda' + * role='participant'/> + * <status code='110'/> + * </x> + * </presence> + */ + const { __ } = _converse; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'oldnick'); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => view.querySelectorAll('li .occupant-nick').length, 500); + let occupants = view.querySelector('.occupant-list'); + expect(occupants.childElementCount).toBe(1); + expect(occupants.firstElementChild.querySelector('.occupant-nick').textContent.trim()).toBe("oldnick"); + + const csntext = await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent); + expect(csntext.trim()).toEqual("oldnick has entered the groupchat"); + + let presence = $pres().attrs({ + from:'lounge@montague.lit/oldnick', + id:'DC352437-C019-40EC-B590-AF29E879AF98', + to:'romeo@montague.lit/pda', + type:'unavailable' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/pda', + nick: 'newnick', + role: 'moderator' + }).up() + .c('status').attrs({code:'303'}).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.chat-info').length); + + expect(sizzle('div.chat-info:last').pop().textContent.trim()).toBe( + __(_converse.muc.new_nickname_messages["303"], "newnick") + ); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + + occupants = view.querySelector('.occupant-list'); + expect(occupants.childElementCount).toBe(1); + + presence = $pres().attrs({ + from:'lounge@montague.lit/newnick', + id:'5B4F27A4-25ED-43F7-A699-382C6B4AFC67', + to:'romeo@montague.lit/pda' + }) + .c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'owner', + jid: 'romeo@montague.lit/pda', + role: 'moderator' + }).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.session.get('connection_status')).toBe(converse.ROOMSTATUS.ENTERED); + expect(view.querySelectorAll('div.chat-info').length).toBe(1); + expect(sizzle('div.chat-info', view)[0].textContent.trim()).toBe( + __(_converse.muc.new_nickname_messages["303"], "newnick") + ); + occupants = view.querySelector('.occupant-list'); + await u.waitUntil(() => sizzle('.occupant-nick:first', occupants).pop().textContent.trim() === "newnick"); + expect(view.model.occupants.length).toBe(1); + expect(view.model.get('nick')).toBe("newnick"); + })); + + describe("when being entered", function () { + + it("will use the user's reserved nickname, if it exists", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const muc_jid = 'lounge@montague.lit'; + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + + let stanza = await u.waitUntil(() => IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop() + ); + // We pretend this is a new room, so no disco info is returned. + const features_stanza = $iq({ + from: 'lounge@montague.lit', + 'id': stanza.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + + /* <iq from='hag66@shakespeare.lit/pda' + * id='getnick1' + * to='coven@chat.shakespeare.lit' + * type='get'> + * <query xmlns='http://jabber.org/protocol/disco#info' + * node='x-roomuser-item'/> + * </iq> + */ + const iq = await u.waitUntil(() => IQ_stanzas.filter( + s => sizzle(`iq[to="${muc_jid}"] query[node="x-roomuser-item"]`, s).length + ).pop()); + + expect(Strophe.serialize(iq)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${iq.getAttribute('id')}" to="lounge@montague.lit" `+ + `type="get" xmlns="jabber:client">`+ + `<query node="x-roomuser-item" xmlns="http://jabber.org/protocol/disco#info"/></iq>`); + + /* <iq from='coven@chat.shakespeare.lit' + * id='getnick1' + * to='hag66@shakespeare.lit/pda' + * type='result'> + * <query xmlns='http://jabber.org/protocol/disco#info' + * node='x-roomuser-item'> + * <identity + * category='conference' + * name='thirdwitch' + * type='text'/> + * </query> + * </iq> + */ + const view = _converse.chatboxviews.get('lounge@montague.lit'); + stanza = $iq({ + 'type': 'result', + 'id': iq.getAttribute('id'), + 'from': view.model.get('jid'), + 'to': _converse.connection.jid + }).c('query', {'xmlns': 'http://jabber.org/protocol/disco#info', 'node': 'x-roomuser-item'}) + .c('identity', {'category': 'conference', 'name': 'thirdwitch', 'type': 'text'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // The user has just entered the groupchat (because join was called) + // and receives their own presence from the server. + // See example 24: + // https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/orchard', + from:'lounge@montague.lit/thirdwitch', + id:'DC352437-C019-40EC-B590-AF29E879AF97' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'member', + jid: 'romeo@montague.lit/orchard', + role: 'participant' + }).up() + .c('status').attrs({code:'110'}).up() + .c('status').attrs({code:'210'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + await mock.returnMemberLists(_converse, muc_jid, [], ['member', 'admin', 'owner']); + await u.waitUntil(() => view.querySelectorAll('.chat-content .chat-info').length); + const info_text = sizzle('.chat-content .chat-info:first', view).pop().textContent.trim(); + expect(info_text).toBe('Your nickname has been automatically set to thirdwitch'); + })); + + it("will use the nickname set in the global settings if the user doesn't have a VCard nickname", + mock.initConverse(['chatBoxesFetched'], {'nickname': 'Benedict-Cucumberpatch'}, + async function (_converse) { + + await mock.openChatRoomViaModal(_converse, 'roomy@muc.montague.lit'); + const view = _converse.chatboxviews.get('roomy@muc.montague.lit'); + expect(view.model.get('nick')).toBe('Benedict-Cucumberpatch'); + })); + + it("will render a nickname form if a nickname conflict happens and muc_nickname_from_jid=false", + mock.initConverse([], { vcard: { nickname: '' }}, async function (_converse) { + + const muc_jid = 'conflicted@muc.montague.lit'; + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to: 'romeo@montague.lit/pda', + type: 'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by: muc_jid, type:'cancel'}) + .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + const el = await u.waitUntil(() => view.querySelector('.muc-nickname-form .validation-message')); + expect(el.textContent.trim()).toBe('The nickname you chose is reserved or currently in use, please choose a different one.'); + })); + + + it("will automatically choose a new nickname if a nickname conflict happens and muc_nickname_from_jid=true", + mock.initConverse(['chatBoxesFetched'], {vcard: { nickname: '' }}, async function (_converse) { + + const { api } = _converse; + const muc_jid = 'conflicting@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + /* <presence + * from='coven@chat.shakespeare.lit/thirdwitch' + * id='n13mt3l' + * to='hag66@shakespeare.lit/pda' + * type='error'> + * <x xmlns='http://jabber.org/protocol/muc'/> + * <error by='coven@chat.shakespeare.lit' type='cancel'> + * <conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + * </error> + * </presence> + */ + api.settings.set('muc_nickname_from_jid', true); + + const attrs = { + 'from': `${muc_jid}/romeo`, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit/pda', + 'type': 'error' + }; + let presence = $pres().attrs(attrs) + .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({'by': muc_jid, 'type':'cancel'}) + .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + const view = _converse.chatboxviews.get(muc_jid); + spyOn(view.model, 'join').and.callThrough(); + + // Simulate repeatedly that there's already someone in the groupchat + // with that nickname + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.join).toHaveBeenCalledWith('romeo-2'); + + attrs.from = `${muc_jid}/romeo-2`; + attrs.id = u.getUniqueId(); + presence = $pres().attrs(attrs) + .c('x').attrs({'xmlns':'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({'by': muc_jid, type:'cancel'}) + .c('conflict').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + expect(view.model.join).toHaveBeenCalledWith('romeo-3'); + + attrs.from = `${muc_jid}/romeo-3`; + attrs.id = new Date().getTime(); + presence = $pres().attrs(attrs) + .c('x').attrs({'xmlns': 'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({'by': muc_jid, 'type': 'cancel'}) + .c('conflict').attrs({'xmlns':'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(view.model.join).toHaveBeenCalledWith('romeo-4'); + })); + + it("will show an error message if the user's nickname doesn't conform to groupchat policy", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'conformist@muc.montague.lit' + await mock.openChatRoomViaModal(_converse, muc_jid, 'romeo'); + + const iq = await u.waitUntil(() => _converse.connection.IQ_stanzas.filter( + iq => iq.querySelector( + `iq[to="${muc_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]` + )).pop()); + const features_stanza = $iq({ + 'from': muc_jid, + 'id': iq.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }).c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', {'category': 'conference', 'name': 'A Dark Cave', 'type': 'text'}).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => (view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)); + + const presence = $pres().attrs({ + from: `${muc_jid}/romeo`, + id: u.getUniqueId(), + to:'romeo@montague.lit/pda', + type:'error' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc'}).up() + .c('error').attrs({by:'lounge@montague.lit', type:'cancel'}) + .c('not-acceptable').attrs({xmlns:'urn:ietf:params:xml:ns:xmpp-stanzas'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const el = await u.waitUntil(() => view.querySelector('.chatroom-body converse-muc-disconnected .disconnect-msg:last-child')); + expect(el.textContent.trim()).toBe("Your nickname doesn't conform to this groupchat's policies."); + })); + + it("doesn't show the nickname field if locked_muc_nickname is true", + mock.initConverse(['chatBoxesFetched'], { + locked_muc_nickname: true, + muc_nickname_from_jid: true, + vcard: { nickname: '' }, + }, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + const name_input = modal.querySelector('input[name="chatroom"]'); + name_input.value = 'lounge@montague.lit'; + expect(modal.querySelector('label[for="nickname"]')).toBe(null); + expect(modal.querySelector('input[name="nickname"]')).toBe(null); + modal.querySelector('form input[type="submit"]').click(); + await u.waitUntil(() => _converse.chatboxes.length > 1); + const chatroom = _converse.chatboxes.get('lounge@montague.lit'); + expect(chatroom.get('nick')).toBe('romeo'); + })); + + it("uses the JID node if muc_nickname_from_jid is set to true", + mock.initConverse(['chatBoxesFetched'], {'muc_nickname_from_jid': true}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + const label_nick = modal.querySelector('label[for="nickname"]'); + expect(label_nick.textContent.trim()).toBe('Nickname:'); + const nick_input = modal.querySelector('input[name="nickname"]'); + expect(nick_input.value).toBe('romeo'); + })); + + it("uses the nickname passed in to converse.initialize", + mock.initConverse(['chatBoxesFetched'], {'nickname': 'st.nick'}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + roomspanel.querySelector('.show-add-muc-modal').click(); + mock.closeControlBox(_converse); + const modal = _converse.api.modal.get('converse-add-muc-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000) + const label_nick = modal.querySelector('label[for="nickname"]'); + expect(label_nick.textContent.trim()).toBe('Nickname:'); + const nick_input = modal.querySelector('input[name="nickname"]'); + expect(nick_input.value).toBe('st.nick'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js new file mode 100644 index 0000000..1ae2c94 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/occupants.js @@ -0,0 +1,228 @@ +/*global mock, converse */ + +const { $pres, sizzle, u } = converse.env; + +describe("The occupants sidebar", function () { + + it("shows all members even if they're not currently present in the groupchat", + mock.initConverse([], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit' + const members = [{ + 'nick': 'juliet', + 'jid': 'juliet@capulet.lit', + 'affiliation': 'member' + }]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.model.occupants.length === 2); + + const occupants = view.querySelector('.occupant-list'); + for (let i=0; i<mock.chatroom_names.length; i++) { + const name = mock.chatroom_names[i]; + const role = mock.chatroom_roles[name].role; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: mock.chatroom_roles[name].affiliation, + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: role + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + } + + await u.waitUntil(() => occupants.querySelectorAll('li').length > 2, 500); + expect(occupants.querySelectorAll('li').length).toBe(2+mock.chatroom_names.length); + expect(view.model.occupants.length).toBe(2+mock.chatroom_names.length); + + mock.chatroom_names.forEach(name => { + const model = view.model.occupants.findWhere({'nick': name}); + const index = view.model.occupants.indexOf(model); + expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name); + }); + + // Test users leaving the groupchat + // https://xmpp.org/extensions/xep-0045.html#exit + for (let i=mock.chatroom_names.length-1; i>-1; i--) { + const name = mock.chatroom_names[i]; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name, + type: 'unavailable' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: mock.chatroom_roles[name].affiliation, + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'none' + }).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + expect(occupants.querySelectorAll('li').length).toBe(8); + } + const presence = $pres({ + to: 'romeo@montague.lit/pda', + from: 'lounge@montague.lit/nonmember' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: null, + jid: 'servant@montague.lit', + role: 'visitor' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => occupants.querySelectorAll('li').length > 8, 500); + expect(occupants.querySelectorAll('li').length).toBe(9); + expect(view.model.occupants.length).toBe(9); + expect(view.model.occupants.filter(o => o.isMember()).length).toBe(8); + + view.model.rejoin(); + // Test that members aren't removed when we reconnect + expect(view.model.occupants.length).toBe(8); + view.model.session.set('connection_status', converse.ROOMSTATUS.ENTERED); // Hack + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length === 8); + })); + + it("shows users currently present in the groupchat", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + var view = _converse.chatboxviews.get('lounge@montague.lit'); + const occupants = view.querySelector('.occupant-list'); + for (var i=0; i<mock.chatroom_names.length; i++) { + const name = mock.chatroom_names[i]; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'participant' + }).up() + .c('status'); + _converse.connection._dataRecv(mock.createRequest(presence)); + } + + await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500); + expect(occupants.querySelectorAll('li').length).toBe(1+mock.chatroom_names.length); + + mock.chatroom_names.forEach(name => { + const model = view.model.occupants.findWhere({'nick': name}); + const index = view.model.occupants.indexOf(model); + expect(occupants.querySelectorAll('li .occupant-nick')[index].textContent.trim()).toBe(name); + }); + + // Test users leaving the groupchat + // https://xmpp.org/extensions/xep-0045.html#exit + for (i=mock.chatroom_names.length-1; i>-1; i--) { + const name = mock.chatroom_names[i]; + // See example 21 https://xmpp.org/extensions/xep-0045.html#enter-pres + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name, + type: 'unavailable' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: "none", + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'none' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + } + await u.waitUntil(() => occupants.querySelectorAll('li').length === 1); + })); + + it("lets you click on an occupant to insert it into the chat textarea", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + var view = _converse.chatboxviews.get(muc_jid); + const occupants = view.querySelector('.occupant-list'); + const name = mock.chatroom_names[0]; + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/'+name + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'none', + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + role: 'participant' + }).up() + .c('status'); + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => occupants.querySelectorAll('li').length > 1, 500); + expect(occupants.querySelectorAll('li').length).toBe(2); + view.querySelectorAll('.occupant-nick')[1].click() + + const textarea = view.querySelector('.chat-textarea'); + expect(textarea.value).toBe('@Dyon van de Wege '); + })); + + it("indicates moderators and visitors by means of a special css class and tooltip", + mock.initConverse([], {'view_mode': 'fullscreen'}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + let contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length, 500); + let occupants = view.querySelectorAll('.occupant-list li'); + expect(occupants.length).toBe(1); + expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("romeo"); + expect(occupants[0].querySelectorAll('.badge').length).toBe(2); + expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Owner'); + expect(sizzle('.badge:last', occupants[0]).pop().textContent.trim()).toBe('Moderator'); + + var presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/moderatorman' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'admin', + jid: contact_jid, + role: 'moderator', + }).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length > 1, 500); + occupants = view.querySelectorAll('.occupant-list li'); + expect(occupants.length).toBe(2); + expect(occupants[0].querySelector('.occupant-nick').textContent.trim()).toBe("moderatorman"); + expect(occupants[1].querySelector('.occupant-nick').textContent.trim()).toBe("romeo"); + expect(occupants[0].querySelectorAll('.badge').length).toBe(2); + expect(occupants[0].querySelectorAll('.badge')[0].textContent.trim()).toBe('Admin'); + expect(occupants[0].querySelectorAll('.badge')[1].textContent.trim()).toBe('Moderator'); + + expect(occupants[0].getAttribute('title')).toBe( + contact_jid + ' This user is a moderator. Click to mention moderatorman in your message.' + ); + + contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + presence = $pres({ + to:'romeo@montague.lit/pda', + from:'lounge@montague.lit/visitorwoman' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + jid: contact_jid, + role: 'visitor', + }).up() + .c('status').attrs({code:'110'}).nodeTree; + _converse.connection._dataRecv(mock.createRequest(presence)); + + await u.waitUntil(() => view.querySelectorAll('.occupant-list li').length > 2, 500); + occupants = view.querySelector('.occupant-list').querySelectorAll('li'); + expect(occupants.length).toBe(3); + expect(occupants[2].querySelector('.occupant-nick').textContent.trim()).toBe("visitorwoman"); + expect(occupants[2].querySelectorAll('.badge').length).toBe(1); + expect(sizzle('.badge', occupants[2]).pop().textContent.trim()).toBe('Visitor'); + expect(occupants[2].getAttribute('title')).toBe( + contact_jid + ' This user can NOT send messages in this groupchat. Click to mention visitorwoman in your message.' + ); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js new file mode 100644 index 0000000..147c94e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/rai.js @@ -0,0 +1,221 @@ +/*global mock, converse */ + +const { Strophe } = converse.env; +const u = converse.env.utils; +// See: https://xmpp.org/rfcs/rfc3921.html + + +describe("XEP-0437 Room Activity Indicators", function () { + + it("will be activated for a MUC that becomes hidden", + mock.initConverse( + [], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + expect(view.model.get('hidden')).toBe(false); + + const sent_IQs = _converse.connection.IQ_stanzas; + const iq_get = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const first_msg_id = _converse.connection.getUniqueId(); + const last_msg_id = _converse.connection.getUniqueId(); + let message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${first_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:15:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>1st MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + message = u.toStanza( + `<message xmlns="jabber:client" + to="romeo@montague.lit/orchard" + from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${iq_get.querySelector('query').getAttribute('queryid')}" id="${last_msg_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2018-01-09T06:16:23Z"/> + <message from="${muc_jid}/some1" type="groupchat"> + <body>2nd MAM Message</body> + </message> + </forwarded> + </result> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message)); + + const result = u.toStanza( + `<iq type='result' id='${iq_get.getAttribute('id')}'> + <fin xmlns='urn:xmpp:mam:2'> + <set xmlns='http://jabber.org/protocol/rsm'> + <first index='0'>${first_msg_id}</first> + <last>${last_msg_id}</last> + <count>2</count> + </set> + </fin> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => view.model.messages.length === 2); + + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + view.model.save({'hidden': true}); + await u.waitUntil(() => sent_stanzas.length === 3); + + expect(Strophe.serialize(sent_stanzas[0])).toBe( + `<message from="${_converse.jid}" id="${sent_stanzas[0].getAttribute('id')}" to="lounge@montague.lit" type="groupchat" xmlns="jabber:client">`+ + `<received id="${last_msg_id}" xmlns="urn:xmpp:chat-markers:0"/>`+ + `</message>` + ); + expect(Strophe.serialize(sent_stanzas[1])).toBe( + `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>` + ); + expect(Strophe.serialize(sent_stanzas[2])).toBe( + `<presence to="montague.lit" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `<rai xmlns="urn:xmpp:rai:0"/>`+ + `</presence>` + ); + + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED); + expect(view.model.get('has_activity')).toBe(false); + + const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy(); + + const activity_stanza = u.toStanza(` + <message from="${Strophe.getDomainFromJid(muc_jid)}"> + <rai xmlns="urn:xmpp:rai:0"> + <activity>${muc_jid}</activity> + </rai> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(activity_stanza)); + + await u.waitUntil(() => view.model.get('has_activity')); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + })); + + it("will be activated for a MUC that starts out hidden", + mock.initConverse( + [], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + const { api } = _converse; + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + const nick = 'romeo'; + const sent_stanzas = _converse.connection.sent_stanzas; + + const muc_creation_promise = await api.rooms.open(muc_jid, {nick, 'hidden': true}, false); + await mock.getRoomFeatures(_converse, muc_jid, []); + await mock.receiveOwnMUCPresence(_converse, muc_jid, nick); + await muc_creation_promise; + + const model = _converse.chatboxes.get(muc_jid); + await u.waitUntil(() => (model.session.get('connection_status') === converse.ROOMSTATUS.ENTERED)); + expect(model.get('hidden')).toBe(true); + + + const getSentPresences = () => sent_stanzas.filter(s => s.nodeName === 'presence'); + await u.waitUntil(() => getSentPresences().length === 3, 500); + const sent_presences = getSentPresences(); + + expect(Strophe.serialize(sent_presences[1])).toBe( + `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>` + ); + expect(Strophe.serialize(sent_presences[2])).toBe( + `<presence to="montague.lit" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `<rai xmlns="urn:xmpp:rai:0"/>`+ + `</presence>` + ); + + await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED); + expect(model.get('has_activity')).toBe(false); + + const room_el = await u.waitUntil(() => document.querySelector("converse-rooms-list .available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeFalsy(); + + const activity_stanza = u.toStanza(` + <message from="${Strophe.getDomainFromJid(muc_jid)}"> + <rai xmlns="urn:xmpp:rai:0"> + <activity>${muc_jid}</activity> + </rai> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(activity_stanza)); + + await u.waitUntil(() => model.get('has_activity')); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + })); + + + it("may not be activated due to server resource constraints", + mock.initConverse( + [], { + 'allow_bookmarks': false, // Hack to get the rooms list to render + 'muc_subscribe_to_rai': true, + 'view_mode': 'fullscreen'}, + async function (_converse) { + + expect(_converse.session.get('rai_enabled_domains')).toBe(undefined); + + const muc_jid = 'lounge@montague.lit'; + const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + expect(model.get('hidden')).toBe(false); + const sent_stanzas = []; + spyOn(_converse.connection, 'send').and.callFake(s => sent_stanzas.push(s?.nodeTree ?? s)); + model.save({'hidden': true}); + await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName === 'presence').length === 2); + + const sent_presences = sent_stanzas.filter(s => s.nodeName === 'presence'); + expect(Strophe.serialize(sent_presences[0])).toBe( + `<presence to="${muc_jid}/romeo" type="unavailable" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>` + ); + expect(Strophe.serialize(sent_presences[1])).toBe( + `<presence to="montague.lit" xmlns="jabber:client">`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="/5ng/Bnz6MXvkSDu6hjAlgQ8C60=" xmlns="http://jabber.org/protocol/caps"/>`+ + `<rai xmlns="urn:xmpp:rai:0"/>`+ + `</presence>` + ); + // If an error presence with "resource-constraint" is returned, we rejoin + const activity_stanza = u.toStanza(` + <presence type="error" from="${Strophe.getDomainFromJid(muc_jid)}"> + <error type="wait"><resource-constraint xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/></error> + </presence> + `); + _converse.connection._dataRecv(mock.createRequest(activity_stanza)); + + await u.waitUntil(() => model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING); + })); + +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js new file mode 100644 index 0000000..fda19a7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/retractions.js @@ -0,0 +1,1084 @@ +/*global mock, converse */ + +const { Strophe, $iq } = converse.env; +const u = converse.env.utils; + + +async function sendAndThenRetractMessage (_converse, view) { + view.model.sendMessage({'body': 'hello world'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1); + const msg_obj = view.model.messages.last(); + const reflection_stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <msg_body>${msg_obj.get('message')}</msg_body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="5f3dbc5e-e1d3-4077-a492-693f3769c7ad" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(reflection_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + const sent_stanzas = _converse.connection.sent_stanzas; + return u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); +} + + +describe("Message Retractions", function () { + + describe("A groupchat message retraction", function () { + + it("is not applied if it's not from the right author", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const retraction_stanza = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}/mallory" to="${muc_jid}/romeo"> + <apply-to id="stanza-id-1" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0" /> + </apply-to> + </message> + `); + spyOn(view.model, 'handleRetraction').and.callThrough(); + + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(2); + expect(view.model.messages.at(1).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(1).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(1).get('dangling_retraction')).toBe(true); + + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + })); + + it("can be received before the message it pertains to", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const date = (new Date()).toISOString(); + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const retraction_stanza = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo"> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" /> + </apply-to> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(converse.env.log, 'warn'); + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1); + await u.waitUntil(() => view.model.messages.length === 1); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(0).get('dangling_retraction')).toBe(true); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <delay xmlns='urn:xmpp:delay' stamp='${date}'/> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + <origin-id xmlns="urn:xmpp:sid:0" id="origin-id-1"/> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(received_stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1, 1000); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0) + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('dangling_retraction')).toBe(false); + expect(message.get('origin_id')).toBe('origin-id-1'); + expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('groupchat'); + expect(await view.model.handleRetraction.calls.all().pop().returnValue).toBe(true); + })); + }); + + describe("A groupchat message moderator retraction", function () { + + it("can be received before the message it pertains to", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const date = (new Date()).toISOString(); + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const retraction_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" type="groupchat" id="retraction-id-1"> + <apply-to xmlns="urn:xmpp:fasten:0" id="stanza-id-1"> + <moderated xmlns="urn:xmpp:message-moderate:0" by="${muc_jid}/madison"> + <retract xmlns="urn:xmpp:message-retract:0"/> + <reason>Insults</reason> + </moderated> + </apply-to> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + spyOn(converse.env.log, 'warn'); + spyOn(view.model, 'handleModeration').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + + await u.waitUntil(() => view.model.handleModeration.calls.count() === 1); + await u.waitUntil(() => view.model.messages.length === 1); + expect(await view.model.handleModeration.calls.first().returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('dangling_moderation')).toBe(true); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <delay xmlns='urn:xmpp:delay' stamp='${date}'/> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + + `); + + _converse.connection._dataRecv(mock.createRequest(received_stanza)); + await u.waitUntil(() => view.model.handleModeration.calls.count() === 2); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.model.messages.length).toBe(1); + + const message = view.model.messages.at(0) + expect(message.get('moderated')).toBe('retracted'); + expect(message.get('dangling_moderation')).toBe(false); + expect(message.get(`stanza_id ${muc_jid}`)).toBe('stanza-id-1'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('groupchat'); + expect(await view.model.handleModeration.calls.all().pop().returnValue).toBe(true); + })); + }); + + + describe("A message retraction", function () { + + it("can be received before the message it pertains to", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const date = (new Date()).toISOString(); + await mock.waitForRoster(_converse, 'current', 1); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + spyOn(view.model, 'handleRetraction').and.callThrough(); + + const retraction_stanza = u.toStanza(` + <message id="${u.getUniqueId()}" + to="${_converse.bare_jid}" + from="${contact_jid}" + type="chat" + xmlns="jabber:client"> + <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0"/> + </apply-to> + </message> + `); + + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + const message = view.model.messages.at(0); + expect(message.get('dangling_retraction')).toBe(true); + expect(message.get('is_ephemeral')).toBe(false); + expect(message.get('retracted')).toBeTruthy(); + expect(view.querySelectorAll('.chat-msg').length).toBe(0); + + const stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="2e972ea0-0050-44b7-a830-f6638a2595b3" + from="${contact_jid}"> + <body>Hello world</body> + <delay xmlns='urn:xmpp:delay' stamp='${date}'/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('dangling_retraction')).toBe(false); + expect(message.get('origin_id')).toBe('2e972ea0-0050-44b7-a830-f6638a2595b3'); + expect(message.get('time')).toBe(date); + expect(message.get('type')).toBe('chat'); + })); + }); + + describe("A Received Chat Message", function () { + + it("can be followed up by a retraction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 1); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + + let stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="29132ea0-0121-2897-b121-36638c259554" + from="${contact_jid}"> + <body>😊</body> + <markable xmlns="urn:xmpp:chat-markers:0"/> + <origin-id xmlns="urn:xmpp:sid:0" id="29132ea0-0121-2897-b121-36638c259554"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="kxViLhgbnNMcWv10" by="${_converse.bare_jid}"/> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 1); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + stanza = u.toStanza(` + <message xmlns="jabber:client" + to="${_converse.bare_jid}" + type="chat" + id="2e972ea0-0050-44b7-a830-f6638a2595b3" + from="${contact_jid}"> + <body>This message will be retracted</body> + <markable xmlns="urn:xmpp:chat-markers:0"/> + <origin-id xmlns="urn:xmpp:sid:0" id="2e972ea0-0050-44b7-a830-f6638a2595b3"/> + <stanza-id xmlns="urn:xmpp:sid:0" id="IxVDLJ0RYbWcWvqC" by="${_converse.bare_jid}"/> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.model.messages.length === 2); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + + const retraction_stanza = u.toStanza(` + <message id="${u.getUniqueId()}" + to="${_converse.bare_jid}" + from="${contact_jid}" + type="chat" + xmlns="jabber:client"> + <apply-to id="2e972ea0-0050-44b7-a830-f6638a2595b3" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0"/> + </apply-to> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + + expect(view.model.messages.length).toBe(2); + + const message = view.model.messages.at(1); + expect(message.get('retracted')).toBeTruthy(); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.textContent.trim()).toBe('Mercutio has removed this message'); + expect(u.hasClass('chat-msg--followup', view.querySelector('.chat-msg--retracted'))).toBe(true); + })); + }); + + describe("A Sent Chat Message", function () { + + it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], { vcard: { nickname: ''} }, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const view = await mock.openChatBoxFor(_converse, contact_jid); + + view.model.sendMessage({'body': 'hello world'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + const message = view.model.messages.at(0); + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeFalsy(); + expect(message.get('editable')).toBeTruthy(); + + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_stanzas = _converse.connection.sent_stanzas; + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + + const msg_obj = view.model.messages.at(0); + const retraction_stanza = await u.waitUntil(() => sent_stanzas.filter(s => s.querySelector('message apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + expect(Strophe.serialize(retraction_stanza)).toBe( + `<message id="${retraction_stanza.getAttribute('id')}" to="${contact_jid}" type="chat" xmlns="jabber:client">`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `</apply-to>`+ + `</message>`); + + expect(view.model.messages.length).toBe(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('editable')).toBeFalsy(); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(el.textContent.trim()).toBe('Romeo Montague has removed this message'); + })); + }); + + + describe("A Received Groupchat Message", function () { + + it("can be followed up by a retraction by the author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/eve' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Hello world</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + <origin-id xmlns='urn:xmpp:sid:0' id='origin-id-1' by='${muc_jid}'/> + </message> + `); + const view = _converse.chatboxviews.get(muc_jid); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + + const retraction_stanza = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}/eve" to="${muc_jid}/romeo"> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract by="${muc_jid}/eve" xmlns="urn:xmpp:message-retract:0" /> + </apply-to> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction_stanza)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeTruthy(); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.textContent.trim()).toBe('eve has removed this message'); + expect(msg_el.querySelector('.chat-msg--retracted q')).toBe(null); + })); + + + it("can be retracted by a moderator, with the IQ response received before the retraction message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Visit this site to get free Bitcoin!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.model.messages.length === 1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + + const reason = "This content is inappropriate for this forum!" + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + + const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]'); + reason_input.value = 'This content is inappropriate for this forum!'; + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+ + `<moderate xmlns="urn:xmpp:message-moderate:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `<reason>This content is inappropriate for this forum!</reason>`+ + `</moderate>`+ + `</apply-to>`+ + `</iq>`); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message'); + + const qel = msg_el.querySelector('q'); + expect(qel.textContent.trim()).toBe('This content is inappropriate for this forum!'); + + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason>${reason}</reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + + it("can not be retracted if the MUC doesn't support message moderation", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Visit this site to get free Bitcoin!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.querySelector('.chat-msg__content')); + expect(view.querySelector('.chat-msg__content .chat-msg__action-retract')).toBe(null); + const result = await view.model.canModerateMessages(); + expect(result).toBe(false); + })); + + + it("can be retracted by a moderator, with the retraction message received before the IQ response", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + const received_stanza = u.toStanza(` + <message to='${_converse.jid}' from='${muc_jid}/mallory' type='groupchat' id='${_converse.connection.getUniqueId()}'> + <body>Visit this site to get free Bitcoin!</body> + <stanza-id xmlns='urn:xmpp:sid:0' id='stanza-id-1' by='${muc_jid}'/> + </message> + `); + await view.model.handleMessageStanza(received_stanza); + await u.waitUntil(() => view.model.messages.length === 1); + expect(view.model.messages.length).toBe(1); + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + + const reason_input = document.querySelector('#converse-modals .modal input[name="reason"]'); + const reason = "This content is inappropriate for this forum!" + reason_input.value = reason; + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + const message = view.model.messages.at(0); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason>${reason}</reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(msg_el.textContent).toBe('romeo has removed this message'); + const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent).toBe('This content is inappropriate for this forum!'); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderated_by')).toBe(_converse.bare_jid); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + }); + + + describe("A Sent Groupchat Message", function () { + + it("can be retracted by its author", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + const retraction_stanza = await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1, 1000); + + const msg_obj = view.model.messages.last(); + expect(msg_obj.get('retracted')).toBeTruthy(); + + expect(Strophe.serialize(retraction_stanza)).toBe( + `<message id="${retraction_stanza.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<apply-to id="${msg_obj.get('origin_id')}" xmlns="urn:xmpp:fasten:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `</apply-to>`+ + `</message>`); + + const message = view.model.messages.last(); + expect(message.get('is_ephemeral')).toBe(false); + expect(message.get('editable')).toBeFalsy(); + + const stanza_id = message.get(`stanza_id ${muc_jid}`); + // The server responds with a retraction message + const reflection = u.toStanza(` + <message type="groupchat" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <retract xmlns='urn:xmpp:message-retract:0' /> + </apply-to> + </message>`); + + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(reflection)); + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 1, 1000); + + await u.waitUntil(() => view.model.messages.length === 2, 1000); + expect(view.model.messages.last().get('retracted')).toBeTruthy(); + expect(view.model.messages.last().get('is_ephemeral')).toBe(false); + expect(view.model.messages.last().get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent).toBe('romeo has removed this message'); + })); + + it("can be retracted by its author, causing an error message in response", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator")); + const retraction_stanza = await sendAndThenRetractMessage(_converse, view); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1, 1000); + + expect(view.model.messages.length).toBe(1); + await u.waitUntil(() => view.model.messages.last().get('retracted'), 1000); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('romeo has removed this message'); + + const message = view.model.messages.last(); + const stanza_id = message.get(`stanza_id ${view.model.get('jid')}`); + // The server responds with an error message + const error = u.toStanza(` + <message type="error" id="${retraction_stanza.getAttribute('id')}" from="${muc_jid}" to="${view.model.get('jid')}/romeo"> + <error by='${muc_jid}' type='auth'> + <forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + </error> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <retract xmlns='urn:xmpp:message-retract:0' /> + </apply-to> + </message>`); + + _converse.connection._dataRecv(mock.createRequest(error)); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 1, 1000); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 0, 1000); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(0).get('editable')).toBe(false); + + const errmsg = view.querySelector('.chat-msg__error'); + expect(errmsg.textContent.trim()).toBe("You're not allowed to retract your message."); + })); + + it("can be retracted by its author, causing a timeout error in response", + mock.initConverse(['chatBoxesFetched'], { stanza_timeout: 1 }, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + occupant.save('role', 'member'); + await u.waitUntil(() => view.querySelector('.chat-content__notifications').textContent.includes("romeo is no longer a moderator")) + await sendAndThenRetractMessage(_converse, view); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.last().get('retracted')).toBeTruthy(); + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('romeo has removed this message'); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 0); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('retracted')).toBeFalsy(); + expect(view.model.messages.at(0).get('is_ephemeral')).toBeFalsy(); + expect(view.model.messages.at(0).get('editable')).toBeTruthy(); + + const error_messages = view.querySelectorAll('.chat-msg__error'); + expect(error_messages.length).toBe(1); + expect(error_messages[0].textContent.trim()).toBe('A timeout happened while while trying to retract your message.'); + })); + + + it("can be retracted by a moderator", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + const stanza_id = 'retraction-id-1'; + const msg_obj = view.model.messages.at(0); + const reflection_stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <msg_body>${msg_obj.get('message')}</msg_body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="${stanza_id}" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + await view.model.handleMessageStanza(reflection_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('editable')).toBe(true); + + // The server responds with a retraction message + const reason = "This content is inappropriate for this forum!" + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + <reason>${reason}</reason> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + await u.waitUntil(() => view.model.messages.at(0).get('moderated') === 'retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(reason); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + + it("can be retracted by the sender if they're a moderator", + mock.initConverse(['chatBoxesFetched'], {'allow_message_retraction': 'moderator'}, async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + const occupant = view.model.getOwnOccupant(); + expect(occupant.get('role')).toBe('moderator'); + + view.model.sendMessage({'body': 'Visit this site to get free bitcoin'}); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + + // Check that you can only edit a message before it's been + // reflected. You can't retract because it hasn't + await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-edit')); + expect(view.querySelectorAll('.chat-msg__action').length).toBe(1); + + const stanza_id = 'retraction-id-1'; + const msg_obj = view.model.messages.at(0); + const reflection_stanza = u.toStanza(` + <message xmlns="jabber:client" + from="${msg_obj.get('from')}" + to="${_converse.connection.jid}" + type="groupchat"> + <msg_body>${msg_obj.get('message')}</msg_body> + <stanza-id xmlns="urn:xmpp:sid:0" + id="${stanza_id}" + by="lounge@montague.lit"/> + <origin-id xmlns="urn:xmpp:sid:0" id="${msg_obj.get('origin_id')}"/> + </message>`); + + await view.model.handleMessageStanza(reflection_stanza); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__body.chat-msg__body--received').length, 500); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('editable')).toBe(true); + + const retract_button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-retract')); + retract_button.click(); + await u.waitUntil(() => u.isVisible(document.querySelector('#converse-modals .modal'))); + const submit_button = document.querySelector('#converse-modals .modal button[type="submit"]'); + submit_button.click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq apply-to[xmlns="urn:xmpp:fasten:0"]')).pop()); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" to="${muc_jid}" type="set" xmlns="jabber:client">`+ + `<apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0">`+ + `<moderate xmlns="urn:xmpp:message-moderate:0">`+ + `<retract xmlns="urn:xmpp:message-retract:0"/>`+ + `<reason></reason>`+ + `</moderate>`+ + `</apply-to>`+ + `</iq>`); + + const result_iq = $iq({'from': muc_jid, 'id': stanza.getAttribute('id'), 'to': _converse.bare_jid, 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + + // We opportunistically save the message as retracted, even before receiving the retraction message + await u.waitUntil(() => view.querySelectorAll('.chat-msg--retracted').length === 1); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + + const msg_el = view.querySelector('.chat-msg--retracted .chat-msg__message'); + expect(msg_el.firstElementChild.textContent.trim()).toBe('romeo has removed this message'); + expect(msg_el.querySelector('q')).toBe(null); + + // The server responds with a retraction message + const retraction = u.toStanza(` + <message type="groupchat" id='retraction-id-1' from="${muc_jid}" to="${muc_jid}/romeo"> + <apply-to id="${stanza_id}" xmlns="urn:xmpp:fasten:0"> + <moderated by='${_converse.bare_jid}' xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns='urn:xmpp:message-retract:0' /> + </moderated> + </apply-to> + </message>`); + await view.model.handleMessageStanza(retraction); + expect(view.model.messages.length).toBe(1); + expect(view.model.messages.at(0).get('moderated')).toBe('retracted'); + expect(view.model.messages.at(0).get('moderation_reason')).toBe(undefined); + expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false); + expect(view.model.messages.at(0).get('editable')).toBe(false); + })); + }); + + + describe("when archived", function () { + + it("may be returned as a tombstone message", + mock.initConverse( + ['discoInitialized'], {}, + 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); + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, null, [Strophe.NS.MAM]); + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + const view = _converse.chatboxviews.get(contact_jid); + const first_id = u.getUniqueId(); + + spyOn(view.model, 'handleRetraction').and.callThrough(); + const first_message = u.toStanza(` + <message id='${u.getUniqueId()}' to='${_converse.jid}'> + <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${first_id}"> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:01:15Z'/> + <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-0"> + <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-0"/> + <body>😊</body> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(first_message)); + + const tombstone = u.toStanza(` + <message id='${u.getUniqueId()}' to='${_converse.jid}'> + <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${u.getUniqueId()}"> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/> + <message type="chat" from="${contact_jid}" to="${_converse.bare_jid}" id="message-id-1"> + <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/> + <retracted stamp='2019-09-20T23:09:32Z' xmlns='urn:xmpp:message-retract:0'/> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + <message id='${u.getUniqueId()}' to='${_converse.jid}'> + <result xmlns='urn:xmpp:mam:2' queryid='${queryid}' id="${last_id}"> + <forwarded xmlns='urn:xmpp:forward:0'> + <delay xmlns='urn:xmpp:delay' stamp='2019-09-20T23:08:25Z'/> + <message from="${contact_jid}" to='${_converse.bare_jid}' id='retract-message-1'> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract xmlns='urn:xmpp:message-retract:0'/> + </apply-to> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 3); + + expect(view.model.messages.length).toBe(2); + const message = view.model.messages.at(1); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false); + expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(false); + expect(await view.model.handleRetraction.calls.all()[2].returnValue).toBe(true); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 2); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('Mercutio has removed this message'); + expect(u.hasClass('chat-msg--followup', el.parentElement)).toBe(false); + })); + + it("may be returned as a tombstone groupchat message", + mock.initConverse( + ['discoInitialized'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + + const first_id = u.getUniqueId(); + const tombstone = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1"> + <origin-id xmlns='urn:xmpp:sid:0' id="origin-id-1"/> + <retracted stamp="2019-09-20T23:09:32Z" xmlns="urn:xmpp:message-retract:0"/> + </message> + </forwarded> + </result> + </message> + `); + spyOn(view.model, 'handleRetraction').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="retract-message-1"> + <apply-to id="origin-id-1" xmlns="urn:xmpp:fasten:0"> + <retract xmlns="urn:xmpp:message-retract:0"/> + </apply-to> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.model.messages.length === 1); + let message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + + await u.waitUntil(() => view.model.handleRetraction.calls.count() === 2); + expect(await view.model.handleRetraction.calls.first().returnValue).toBe(false); + expect(await view.model.handleRetraction.calls.all()[1].returnValue).toBe(true); + expect(view.model.messages.length).toBe(1); + message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('eve has removed this message'); + })); + + it("may be returned as a tombstone moderated groupchat message", + mock.initConverse( + ['discoInitialized', 'chatBoxesFetched'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + const features = [...mock.default_muc_features, Strophe.NS.MODERATE]; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get(muc_jid); + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.MAM}"]`)).pop()); + const queryid = stanza.querySelector('query').getAttribute('queryid'); + + const first_id = u.getUniqueId(); + const tombstone = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="stanza-id"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}/eve" to="${_converse.bare_jid}" id="message-id-1"> + <moderated by="${muc_jid}/bob" stamp="2019-09-20T23:09:32Z" xmlns='urn:xmpp:message-moderate:0'> + <retracted xmlns="urn:xmpp:message-retract:0"/> + <reason>This message contains inappropriate content</reason> + </moderated> + </message> + </forwarded> + </result> + </message> + `); + spyOn(view.model, 'handleModeration').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(tombstone)); + + const last_id = u.getUniqueId(); + const retraction = u.toStanza(` + <message id="${u.getUniqueId()}" to="${_converse.jid}" from="${muc_jid}"> + <result xmlns="urn:xmpp:mam:2" queryid="${queryid}" id="${last_id}"> + <forwarded xmlns="urn:xmpp:forward:0"> + <delay xmlns="urn:xmpp:delay" stamp="2019-09-20T23:08:25Z"/> + <message type="groupchat" from="${muc_jid}" to="${_converse.bare_jid}" id="retract-message-1"> + <apply-to id="stanza-id" xmlns="urn:xmpp:fasten:0"> + <moderated by="${muc_jid}/bob" xmlns='urn:xmpp:message-moderate:0'> + <retract xmlns="urn:xmpp:message-retract:0"/> + <reason>This message contains inappropriate content</reason> + </moderated> + </apply-to> + </message> + </forwarded> + </result> + </message> + `); + _converse.connection._dataRecv(mock.createRequest(retraction)); + + const iq_result = $iq({'type': 'result', 'id': stanza.getAttribute('id')}) + .c('fin', {'xmlns': 'urn:xmpp:mam:2'}) + .c('set', {'xmlns': 'http://jabber.org/protocol/rsm'}) + .c('first', {'index': '0'}).t(first_id).up() + .c('last').t(last_id).up() + .c('count').t('2'); + _converse.connection._dataRecv(mock.createRequest(iq_result)); + + await u.waitUntil(() => view.model.messages.length); + expect(view.model.messages.length).toBe(1); + let message = view.model.messages.at(0); + await u.waitUntil(() => message.get('retracted')); + expect(message.get('is_tombstone')).toBe(true); + + await u.waitUntil(() => view.model.handleModeration.calls.count() === 2); + expect(await view.model.handleModeration.calls.first().returnValue).toBe(false); + expect(await view.model.handleModeration.calls.all()[1].returnValue).toBe(true); + + expect(view.model.messages.length).toBe(1); + message = view.model.messages.at(0); + expect(message.get('retracted')).toBeTruthy(); + expect(message.get('is_tombstone')).toBe(true); + expect(message.get('moderation_reason')).toBe("This message contains inappropriate content"); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length, 500); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + + expect(view.querySelectorAll('.chat-msg--retracted').length).toBe(1); + const el = view.querySelector('.chat-msg--retracted .chat-msg__message div'); + expect(el.textContent.trim()).toBe('A moderator has removed this message'); + const qel = view.querySelector('.chat-msg--retracted .chat-msg__message q'); + expect(qel.textContent.trim()).toBe('This message contains inappropriate content'); + })); + }); +}) diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js new file mode 100644 index 0000000..e285892 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/styling.js @@ -0,0 +1,58 @@ +/*global mock, converse */ + +const { u, $msg } = converse.env; + +describe("An incoming groupchat Message", function () { + + it("can be styled with span XEP-0393 message styling hints that contain mentions", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const msg_text = "This *message mentions romeo*"; + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'23', 'end':'29', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.classList.length).toEqual(1); + + const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + 'This <span class="styling-directive">*</span>'+ + '<b>message mentions <span class="mention mention--self badge badge-info" data-uri="xmpp:romeo@montague.lit">romeo</span></b>'+ + '<span class="styling-directive">*</span>'); + })); + + it("will not have styling applied to mentioned nicknames themselves", + mock.initConverse(['chatBoxesFetched'], {}, + async function (_converse) { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + const msg_text = "x_y_z_ hello"; + const msg = $msg({ + from: 'lounge@montague.lit/gibson', + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(msg_text).up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'6', 'type':'mention', 'uri':'xmpp:xyz@montague.lit'}).nodeTree; + await view.model.handleMessageStanza(msg); + const message = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(message.classList.length).toEqual(1); + + const msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop(); + expect(msg_el.innerText).toBe(msg_text); + await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') === + '<span class="mention" data-uri="xmpp:xyz@montague.lit">x_y_z_</span> hello'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js new file mode 100644 index 0000000..d0d2db2 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/toolbar.js @@ -0,0 +1,18 @@ +/*global mock, converse */ + +const { u } = converse.env; + +describe('The visible_toolbar_buttons configuration setting', function () { + + it("can be used to show a participants toggle in a MUC's toolbar", + mock.initConverse([], { 'visible_toolbar_buttons': { 'toggle_occupants': true } }, + async (_converse) => { + + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => view.querySelector('converse-chat-toolbar .toggle_occupants')); + expect(1).toBe(1); + }) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js new file mode 100644 index 0000000..a7494af --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/unfurls.js @@ -0,0 +1,489 @@ +/*global mock, converse */ + +const { Strophe, u } = converse.env; + +describe("A Groupchat Message", function () { + + it("will render an unfurl based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"; + const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${unfurl_image_src}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(unfurl_image_src); + expect(unfurl.querySelector('.card-img-top').getAttribute('href')).toBe(unfurl_url); + })); + + it("will render an unfurl with limited OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + /* Some sites don't include ogp data such as title, description and + * url. This test is to check that we fall back gracefully */ + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://mempool.space</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://mempool.space'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://conversejs.org/dist/images/custom_emojis/converse.png" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:type" content="image/png" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1000" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="500" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe('https://conversejs.org/dist/images/custom_emojis/converse.png'); + expect(unfurl.querySelector('.card-body')).toBe(null); + })); + + it("will render an unfurl containing a GIF", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + const unfurl_url = "https://giphy.com/gifs/giphyqa-4YY4DnqeUDBXNTcYMu"; + const gif_url = "https://media4.giphy.com/media/4YY4DnqeUDBXNTcYMu/giphy.gif?foo=bar"; + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>${unfurl_url}</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe(unfurl_url); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Animated GIF" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Alright then, keep your secrets" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${gif_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:type" content="image/gif" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="360" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="302" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(gif_url); + })); + + it("will render multiple unfurls based on OGP data", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ and https://duckduckgo.com'); + + let metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 1); + + metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://duckduckgo.com" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="DuckDuckGo" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://duckduckgo.com/assets/logo_social-media.png" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="DuckDuckGo - Privacy, simplified." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl').length === 2); + })); + + it("will not render an unfurl received from a MUC participant", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + spyOn(view.model, 'handleMetadataFastening').and.callThrough(); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}/arzu" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.model.handleMetadataFastening.calls.count()); + expect(view.model.handleMetadataFastening.calls.first().returnValue).toBe(false); + expect(view.querySelector('converse-message-unfurl')).toBe(null); + })); + + it("will not render an unfurl based on OGP data if render_media is false", + mock.initConverse(['chatBoxesFetched'], + { 'render_media': false }, + async function (_converse) { + + const { api } = _converse; + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + spyOn(view.model, 'handleMetadataFastening').and.callThrough(); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + expect(view.querySelector('converse-message-unfurl')).toBe(null); + + api.settings.set('render_media', true); + await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + + let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); + expect(button.textContent.trim()).toBe('Hide media'); + button.click(); + + await u.waitUntil(() => !view.querySelector('converse-message-unfurl'), 1000); + button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); + expect(button.textContent.trim()).toBe('Show media'); + })); + + it("will only render a single unfurl when receiving the same OGP data multiple times", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + spyOn(view.model, 'handleMetadataFastening').and.callThrough(); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.model.handleMetadataFastening.calls.count()); + const unfurls = await u.waitUntil(() => view.querySelectorAll('converse-message-unfurl')); + expect(unfurls.length).toBe(1); + })); + + it("will not render an unfurl image if the domain is not in allowed_image_domains", + mock.initConverse(['chatBoxesFetched'], + {'allowed_image_domains': []}, + async function (_converse) { + + const { api } = _converse; + + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => !view.querySelector('converse-message-unfurl')); + + api.settings.set('allowed_image_domains', null); + await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + })); + + it("lets the user hide an unfurl", + mock.initConverse(['chatBoxesFetched'], + {'render_media': true}, + async function (_converse) { + + const { api } = _converse; + + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const message_stanza = u.toStanza(` + <message xmlns="jabber:client" type="groupchat" from="${muc_jid}/arzu" xml:lang="en" to="${_converse.jid}" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <body>https://www.youtube.com/watch?v=dQw4w9WgXcQ</body> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="${muc_jid}" id="8f7613cc-27d4-40ca-9488-da25c4baf92a"/> + <markable xmlns="urn:xmpp:chat-markers:0"/> + </message>`); + _converse.connection._dataRecv(mock.createRequest(message_stanza)); + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe('https://www.youtube.com/watch?v=dQw4w9WgXcQ'); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="eda6c790-b4f3-4c07-b5e2-13fff99e6c04"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="https://www.youtube.com/watch?v=dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + let button = await u.waitUntil(() => view.querySelector('.chat-msg__content .chat-msg__action-hide-previews')); + expect(button.textContent.trim()).toBe('Hide media'); + button.click(); + await u.waitUntil(() => view.querySelector('converse-message-unfurl') === null, 750); + button = view.querySelector('.chat-msg__content .chat-msg__action-hide-previews'); + expect(button.textContent.trim()).toBe('Show media'); + button.click(); + await u.waitUntil(() => view.querySelector('converse-message-unfurl'), 750); + + // Check that the image doesn't render if the domain is not allowed + expect(view.querySelector('converse-message-unfurl .chat-image')).not.toBe(null); + api.settings.set('allowed_image_domains', []); + await u.waitUntil(() => view.querySelector('converse-message-unfurl .chat-image') === null); + api.settings.set('allowed_image_domains', undefined); + await u.waitUntil(() => view.querySelector('converse-message-unfurl .chat-image') !== null); + })); + + it("will not render an unfurl that has been removed in a subsequent correction", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + const nick = 'romeo'; + const muc_jid = 'lounge@muc.montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick); + const view = _converse.chatboxviews.get(muc_jid); + + const unfurl_image_src = "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg"; + const unfurl_url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + + spyOn(_converse.connection, 'send').and.callThrough(); + + const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea')); + const message_form = view.querySelector('converse-muc-message-form'); + textarea.value = unfurl_url; + const enter_event = { + 'target': textarea, + 'preventDefault': function preventDefault () {}, + 'stopPropagation': function stopPropagation () {}, + 'keyCode': 13 // Enter + } + message_form.onKeyDown(enter_event); + + await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe(unfurl_url); + + let msg = _converse.connection.send.calls.all()[1].args[0]; + expect(Strophe.serialize(msg)) + .toBe( + `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<body>${unfurl_url}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + + const el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(el.textContent).toBe(unfurl_url); + + const metadata_stanza = u.toStanza(` + <message xmlns="jabber:client" from="${muc_jid}" to="${_converse.jid}" type="groupchat"> + <apply-to xmlns="urn:xmpp:fasten:0" id="${msg.getAttribute('id')}"> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:site_name" content="YouTube" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:url" content="${unfurl_url}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:title" content="Rick Astley - Never Gonna Give You Up (Video)" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image" content="${unfurl_image_src}" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:image:height" content="720" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:description" content="Rick Astley&#39;s official music video for "Never Gonna Give You Up" Listen to Rick Astley: https://RickAstley.lnk.to/_listenYD Subscribe to the official Rick Ast..." /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:type" content="video.other" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:secure_url" content="https://www.youtube.com/embed/dQw4w9WgXcQ" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:type" content="text/html" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:width" content="1280" /> + <meta xmlns="http://www.w3.org/1999/xhtml" property="og:video:height" content="720" /> + </apply-to> + </message>`); + _converse.connection._dataRecv(mock.createRequest(metadata_stanza)); + + const unfurl = await u.waitUntil(() => view.querySelector('converse-message-unfurl')); + expect(unfurl.querySelector('.card-img-top').getAttribute('src')).toBe(unfurl_image_src); + expect(unfurl.querySelector('.card-img-top').getAttribute('href')).toBe(unfurl_url); + + // Modify the message to use a different URL + expect(textarea.value).toBe(''); + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe(unfurl_url); + textarea.value = "never mind"; + message_form.onKeyDown(enter_event); + + const getSentMessages = () => _converse.connection.send.calls.all().map(c => c.args[0]).filter(s => s.nodeName === 'message'); + await u.waitUntil(() => getSentMessages().length == 2); + msg = getSentMessages().pop(); + expect(Strophe.serialize(msg)) + .toBe( + `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<body>never mind</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<replace id="${msg.querySelector('replace')?.getAttribute('id')}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `</message>`); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js new file mode 100644 index 0000000..35d062e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/tests/xss.js @@ -0,0 +1,54 @@ +/*global mock, converse */ + +const $pres = converse.env.$pres; +const u = converse.env.utils; + +describe("XSS", function () { + describe("A Groupchat", function () { + + it("escapes occupant nicknames when rendering them, to avoid JS-injection attacks", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + /* <presence xmlns="jabber:client" to="jc@chat.example.org/converse.js-17184538" + * from="oo@conference.chat.example.org/<img src="x" onerror="alert(123)"/>"> + * <x xmlns="http://jabber.org/protocol/muc#user"> + * <item jid="jc@chat.example.org/converse.js-17184538" affiliation="owner" role="moderator"/> + * <status code="110"/> + * </x> + * </presence>" + */ + const presence = $pres({ + to:'romeo@montague.lit/pda', + from:"lounge@montague.lit/<img src="x" onerror="alert(123)"/>" + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + jid: 'someone@montague.lit', + role: 'moderator', + }).up() + .c('status').attrs({code:'110'}).nodeTree; + + _converse.connection._dataRecv(mock.createRequest(presence)); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => view.querySelectorAll('.occupant-list .occupant-nick').length === 2); + const occupants = view.querySelectorAll('.occupant-list li .occupant-nick'); + expect(occupants.length).toBe(2); + expect(occupants[0].textContent.trim()).toBe("<img src="x" onerror="alert(123)"/>"); + })); + + it("escapes the subject before rendering it, to avoid JS-injection attacks", + mock.initConverse([], {}, async function (_converse) { + + await mock.openAndEnterChatRoom(_converse, 'jdev@conference.jabber.org', 'jc'); + spyOn(window, 'alert'); + const subject = '<img src="x" onerror="alert(\'XSS\');"/>'; + const view = _converse.chatboxviews.get('jdev@conference.jabber.org'); + view.model.set({'subject': { + 'text': subject, + 'author': 'ralphm' + }}); + const text = await u.waitUntil(() => view.querySelector('.chat-head__desc')?.textContent.trim()); + expect(text).toBe(subject); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js new file mode 100644 index 0000000..b03b5d3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/muc-views/utils.js @@ -0,0 +1,341 @@ +import './modals/occupant.js'; +import './modals/moderator-tools.js'; +import log from "@converse/headless/log"; +import tplSpinner from 'templates/spinner.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; +import { html } from "lit"; +import { setAffiliation } from '@converse/headless/plugins/muc/affiliations/utils.js'; + +const { Strophe, u } = converse.env; + +const COMMAND_TO_AFFILIATION = { + 'admin': 'admin', + 'ban': 'outcast', + 'member': 'member', + 'owner': 'owner', + 'revoke': 'none' +}; +const COMMAND_TO_ROLE = { + 'deop': 'participant', + 'kick': 'none', + 'mute': 'visitor', + 'op': 'moderator', + 'voice': 'participant' +}; + +/** + * Presents a confirmation modal to the user asking them to accept or decline a + * MUC invitation. + * @async + */ +export function confirmDirectMUCInvitation ({ contact, jid, reason }) { + if (!reason) { + return api.confirm(__('%1$s has invited you to join a groupchat: %2$s', contact, jid)); + } else { + return api.confirm( + __( + '%1$s has invited you to join a groupchat: %2$s, and left the following reason: "%3$s"', + contact, + jid, + reason + ) + ); + } +} + +export function clearHistory (jid) { + if (_converse.router.history.getFragment() === `converse/room?jid=${jid}`) { + _converse.router.navigate(''); + } +} + +export async function destroyMUC (model) { + const messages = [__('Are you sure you want to destroy this groupchat?')]; + let fields = [ + { + 'name': 'challenge', + 'label': __('Please enter the XMPP address of this groupchat to confirm'), + 'challenge': model.get('jid'), + 'placeholder': __('name@example.org'), + 'required': true + }, + { + 'name': 'reason', + 'label': __('Optional reason for destroying this groupchat'), + 'placeholder': __('Reason') + }, + { + 'name': 'newjid', + 'label': __('Optional XMPP address for a new groupchat that replaces this one'), + 'placeholder': __('replacement@example.org') + } + ]; + try { + fields = await api.confirm(__('Confirm'), messages, fields); + const reason = fields.filter(f => f.name === 'reason').pop()?.value; + const newjid = fields.filter(f => f.name === 'newjid').pop()?.value; + return model.sendDestroyIQ(reason, newjid).then(() => model.close()); + } catch (e) { + log.error(e); + } +} + +export function getNicknameRequiredTemplate (model) { + const jid = model.get('jid'); + if (api.settings.get('muc_show_logs_before_join')) { + return html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>`; + } else { + return html`<converse-muc-nickname-form jid="${jid}"></converse-muc-nickname-form>`; + } +} + +export function getChatRoomBodyTemplate (o) { + const view = o.model.session.get('view'); + const jid = o.model.get('jid'); + const RS = converse.ROOMSTATUS; + const conn_status = o.model.session.get('connection_status'); + + if (view === converse.MUC.VIEWS.CONFIG) { + return html`<converse-muc-config-form class="muc-form-container" jid="${jid}"></converse-muc-config-form>`; + } else { + return html` + ${ conn_status == RS.PASSWORD_REQUIRED ? html`<converse-muc-password-form class="muc-form-container" jid="${jid}"></converse-muc-password-form>` : '' } + ${ conn_status == RS.ENTERED ? html`<converse-muc-chatarea jid="${jid}"></converse-muc-chatarea>` : '' } + ${ conn_status == RS.CONNECTING ? tplSpinner() : '' } + ${ conn_status == RS.NICKNAME_REQUIRED ? getNicknameRequiredTemplate(o.model) : '' } + ${ conn_status == RS.DISCONNECTED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' } + ${ conn_status == RS.BANNED ? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>` : '' } + ${ conn_status == RS.DESTROYED ? html`<converse-muc-destroyed jid="${jid}"></converse-muc-destroyed>` : '' } + `; + } +} + +export function getAutoCompleteListItem (text, input) { + input = input.trim(); + const element = document.createElement('li'); + element.setAttribute('aria-selected', 'false'); + + if (api.settings.get('muc_mention_autocomplete_show_avatar')) { + const img = document.createElement('img'); + let dataUri = 'data:' + _converse.DEFAULT_IMAGE_TYPE + ';base64,' + _converse.DEFAULT_IMAGE; + + if (_converse.vcards) { + const vcard = _converse.vcards.findWhere({ 'nickname': text }); + if (vcard) dataUri = 'data:' + vcard.get('image_type') + ';base64,' + vcard.get('image'); + } + + img.setAttribute('src', dataUri); + img.setAttribute('width', '22'); + img.setAttribute('class', 'avatar avatar-autocomplete'); + element.appendChild(img); + } + + const regex = new RegExp('(' + input + ')', 'ig'); + const parts = input ? text.split(regex) : [text]; + + parts.forEach(txt => { + if (input && txt.match(regex)) { + const match = document.createElement('mark'); + match.textContent = txt; + element.appendChild(match); + } else { + element.appendChild(document.createTextNode(txt)); + } + }); + + return element; +} + +export async function getAutoCompleteList () { + const models = [...(await api.rooms.get()), ...(await api.contacts.get())]; + const jids = [...new Set(models.map(o => Strophe.getDomainFromJid(o.get('jid'))))]; + return jids; +} + +function setRole (muc, command, args, required_affiliations = [], required_roles = []) { + const role = COMMAND_TO_ROLE[command]; + if (!role) { + throw Error(`ChatRoomView#setRole called with invalid command: ${command}`); + } + if (!muc.verifyAffiliations(required_affiliations) || !muc.verifyRoles(required_roles)) { + return false; + } + if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + const reason = args.split(nick_or_jid, 2)[1].trim(); + // We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs + const occupant = muc.getOccupant(nick_or_jid); + muc.setRole(occupant, role, reason, undefined, e => muc.onCommandError(e)); + return true; +} + + +function verifyAndSetAffiliation (muc, command, args, required_affiliations) { + const affiliation = COMMAND_TO_AFFILIATION[command]; + if (!affiliation) { + throw Error(`verifyAffiliations called with invalid command: ${command}`); + } + if (!muc.verifyAffiliations(required_affiliations)) { + return false; + } + if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) { + return false; + } + const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args); + if (!nick_or_jid) { + return false; + } + + let jid; + const reason = args.split(nick_or_jid, 2)[1].trim(); + const occupant = muc.getOccupant(nick_or_jid); + if (occupant) { + jid = occupant.get('jid'); + } else { + if (u.isValidJID(nick_or_jid)) { + jid = nick_or_jid; + } else { + const message = __( + "Couldn't find a participant with that nickname. " + 'They might have left the groupchat.' + ); + muc.createMessage({ message, 'type': 'error' }); + return; + } + } + const attrs = { jid, reason }; + if (occupant && api.settings.get('auto_register_muc_nickname')) { + attrs['nick'] = occupant.get('nick'); + } + + setAffiliation(affiliation, muc.get('jid'), [attrs]) + .then(() => muc.occupants.fetchMembers()) + .catch(err => muc.onCommandError(err)); +} + + +export function showModeratorToolsModal (muc, affiliation) { + if (!muc.verifyRoles(['moderator'])) { + return; + } + let modal = api.modal.get('converse-modtools-modal'); + if (modal) { + modal.affiliation = affiliation; + modal.render(); + } else { + modal = api.modal.create('converse-modtools-modal', { affiliation, 'jid': muc.get('jid') }); + } + modal.show(); +} + + +export function showOccupantModal (ev, occupant) { + api.modal.show('converse-muc-occupant-modal', { 'model': occupant }, ev); +} + + +export function parseMessageForMUCCommands (data, handled) { + const model = data.model; + if (handled || + model.get('type') !== _converse.CHATROOMS_TYPE || ( + api.settings.get('muc_disable_slash_commands') && + !Array.isArray(api.settings.get('muc_disable_slash_commands')) + )) { + return handled; + } + + let text = data.text; + text = text.replace(/^\s*/, ''); + const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase(); + if (!command) { + return false; + } + + const args = text.slice(('/' + command).length + 1).trim(); + const allowed_commands = model.getAllowedCommands() ?? []; + + if (command === 'admin' && allowed_commands.includes(command)) { + verifyAndSetAffiliation(model, command, args, ['owner']); + return true; + } else if (command === 'ban' && allowed_commands.includes(command)) { + verifyAndSetAffiliation(model, command, args, ['admin', 'owner']); + return true; + } else if (command === 'modtools' && allowed_commands.includes(command)) { + showModeratorToolsModal(model, args); + return true; + } else if (command === 'deop' && allowed_commands.includes(command)) { + // FIXME: /deop only applies to setting a moderators + // role to "participant" (which only admin/owner can + // do). Moderators can however set non-moderator's role + // to participant (e.g. visitor => participant). + // Currently we don't distinguish between these two + // cases. + setRole(model, command, args, ['admin', 'owner']); + return true; + } else if (command === 'destroy' && allowed_commands.includes(command)) { + if (!model.verifyAffiliations(['owner'])) { + return true; + } + destroyMUC(model).catch(e => model.onCommandError(e)); + return true; + } else if (command === 'help' && allowed_commands.includes(command)) { + model.set({ 'show_help_messages': false }, { 'silent': true }); + model.set({ 'show_help_messages': true }); + return true; + } else if (command === 'kick' && allowed_commands.includes(command)) { + setRole(model, command, args, [], ['moderator']); + return true; + } else if (command === 'mute' && allowed_commands.includes(command)) { + setRole(model, command, args, [], ['moderator']); + return true; + } else if (command === 'member' && allowed_commands.includes(command)) { + verifyAndSetAffiliation(model, command, args, ['admin', 'owner']); + return true; + } else if (command === 'nick' && allowed_commands.includes(command)) { + if (!model.verifyRoles(['visitor', 'participant', 'moderator'])) { + return true; + } else if (args.length === 0) { + // e.g. Your nickname is "coolguy69" + const message = __('Your nickname is "%1$s"', model.get('nick')); + model.createMessage({ message, 'type': 'error' }); + } else { + model.setNickname(args); + } + return true; + } else if (command === 'owner' && allowed_commands.includes(command)) { + verifyAndSetAffiliation(model, command, args, ['owner']); + return true; + } else if (command === 'op' && allowed_commands.includes(command)) { + setRole(model, command, args, ['admin', 'owner']); + return true; + } else if (command === 'register' && allowed_commands.includes(command)) { + if (args.length > 1) { + model.createMessage({ + 'message': __('Error: invalid number of arguments'), + 'type': 'error' + }); + } else { + model.registerNickname().then(err_msg => { + err_msg && model.createMessage({ 'message': err_msg, 'type': 'error' }); + }); + } + return true; + } else if (command === 'revoke' && allowed_commands.includes(command)) { + verifyAndSetAffiliation(model, command, args, ['admin', 'owner']); + return true; + } else if (command === 'topic' && allowed_commands.includes(command) || + command === 'subject' && allowed_commands.includes(command)) { + model.setSubject(args); + return true; + } else if (command === 'voice' && allowed_commands.includes(command)) { + setRole(model, command, args, [], ['moderator']); + return true; + } else { + return false; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js b/roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js new file mode 100644 index 0000000..642ac4b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/notifications/index.js @@ -0,0 +1,53 @@ +/** + * @module converse-notification + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import { _converse, api, converse } from '@converse/headless/core'; +import { + clearFavicon, + handleChatStateNotification, + handleContactRequestNotification, + handleFeedback, + handleMessageNotification, + requestPermission, + updateUnreadFavicon +} from './utils.js'; + +converse.plugins.add('converse-notification', { + dependencies: ['converse-chatboxes'], + + initialize () { + api.settings.extend({ + // ^ a list of JIDs to ignore concerning chat state notifications + chatstate_notification_blacklist: [], + notification_delay: 5000, + notification_icon: '/images/logo/conversejs-filled.svg', + notify_all_room_messages: false, + notify_nicknames_without_references: false, + play_sounds: true, + show_chat_state_notifications: false, + show_desktop_notifications: true, + show_tab_notifications: true, + sounds_path: api.settings.get('assets_path') + '/sounds/' + }); + + /************************ Event Handlers ************************/ + api.listen.on('clearSession', clearFavicon); // Needed for tests + + api.waitUntil('chatBoxesInitialized').then(() => + _converse.chatboxes.on('change:num_unread', updateUnreadFavicon) + ); + + api.listen.on('pluginsInitialized', function () { + // We only register event handlers after all plugins are + // registered, because other plugins might override some of our + // handlers. + api.listen.on('contactRequest', handleContactRequestNotification); + api.listen.on('contactPresenceChanged', handleChatStateNotification); + api.listen.on('message', handleMessageNotification); + api.listen.on('feedback', handleFeedback); + api.listen.on('connected', requestPermission); + }); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js b/roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js new file mode 100644 index 0000000..a9e76d4 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/notifications/tests/notification.js @@ -0,0 +1,330 @@ +/*global mock, converse */ + +const { Strophe } = converse.env; +const $msg = converse.env.$msg; +const u = converse.env.utils; + +describe("Notifications", function () { + // Implement the protocol defined in https://xmpp.org/extensions/xep-0313.html#config + + describe("When show_desktop_notifications is set to true", function () { + describe("And the desktop is not focused", function () { + describe("an HTML5 Notification", function () { + + it("is shown when a new private message is received", + mock.initConverse([], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current'); + const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + spyOn(window, 'Notification').and.returnValue(stub); + + const message = 'This message will show a desktop notification'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', + msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree(); + await _converse.handleMessageStanza(msg); // This will emit 'message' + await u.waitUntil(() => _converse.chatboxviews.get(sender_jid)); + expect(window.Notification).toHaveBeenCalled(); + })); + + it("is shown when you are mentioned in a groupchat", + mock.initConverse([], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current'); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + spyOn(window, 'Notification').and.returnValue(stub); + + // Test mention with setting false + const nick = mock.chatroom_names[0]; + const makeMsg = text => $msg({ + from: 'lounge@montague.lit/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(text).tree(); + _converse.connection._dataRecv(mock.createRequest(makeMsg('romeo: this will NOT show a notification'))); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(window.Notification).not.toHaveBeenCalled(); + + // Test reference + const message_with_ref = $msg({ + from: 'lounge@montague.lit/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('romeo: this will show a notification').up() + .c('reference', {'xmlns':'urn:xmpp:reference:0', 'begin':'0', 'end':'5', 'type':'mention', 'uri':'xmpp:romeo@montague.lit'}).tree(); + _converse.connection._dataRecv(mock.createRequest(message_with_ref)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(window.Notification.calls.count()).toBe(1); + + // Test mention with setting true + _converse.api.settings.set('notify_all_room_messages', true); + _converse.connection._dataRecv(mock.createRequest(makeMsg('romeo: this will show a notification'))); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(window.Notification.calls.count()).toBe(2); + })); + + it("is shown for headline messages", mock.initConverse([], {}, async (_converse) => { + const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + spyOn(window, 'Notification').and.returnValue(stub); + + await mock.waitForRoster(_converse, 'current', 0); + const stanza = $msg({ + 'type': 'headline', + 'from': 'notify.example.com', + 'to': 'romeo@montague.lit', + 'xml:lang': 'en' + }) + .c('subject').t('SIEVE').up() + .c('body').t('<juliet@example.com> You got mail.').up() + .c('x', {'xmlns': 'jabber:x:oob'}) + .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => _converse.chatboxviews.keys().length === 2); + expect(_converse.chatboxviews.keys().includes('notify.example.com')).toBeTruthy(); + expect(window.Notification).toHaveBeenCalled(); + })); + + it("is not shown for full JID headline messages if allow_non_roster_messaging is false", + mock.initConverse([], {'allow_non_roster_messaging': false}, (_converse) => { + + const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + spyOn(window, 'Notification').and.returnValue(stub); + const stanza = $msg({ + 'type': 'headline', + 'from': 'someone@notify.example.com', + 'to': 'romeo@montague.lit', + 'xml:lang': 'en' + }) + .c('subject').t('SIEVE').up() + .c('body').t('<juliet@example.com> You got mail.').up() + .c('x', {'xmlns': 'jabber:x:oob'}) + .c('url').t('imap://romeo@example.com/INBOX;UIDVALIDITY=385759043/;UID=18'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(_converse.chatboxviews.keys().includes('someone@notify.example.com')).toBeFalsy(); + expect(window.Notification).not.toHaveBeenCalled(); + })); + + it("is shown when a user changes their chat state (if show_chat_state_notifications is true)", + mock.initConverse([], {show_chat_state_notifications: true}, + async (_converse) => { + + await mock.waitForRoster(_converse, 'current', 3); + const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + spyOn(window, 'Notification').and.returnValue(stub); + const jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'dnd'); + expect(window.Notification).toHaveBeenCalled(); + })); + }); + }); + + describe("When a new contact request is received", function () { + it("an HTML5 Notification is received", mock.initConverse((_converse) => { + const stub = jasmine.createSpyObj('MyNotification', ['onclick', 'close']); + spyOn(window, 'Notification').and.returnValue(stub); + _converse.api.trigger('contactRequest', {'getDisplayName': () => 'Peter Parker'}); + expect(window.Notification).toHaveBeenCalled(); + })); + }); + }); + + describe("When play_sounds is set to true", function () { + describe("A notification sound", function () { + + it("is played when the current user is mentioned in a groupchat", mock.initConverse([], {}, async (_converse) => { + + await mock.waitForRoster(_converse, 'current'); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + const { api } = _converse; + api.settings.set('play_sounds', true); + + const stub = jasmine.createSpyObj('MyAudio', ['play', 'canPlayType']); + spyOn(window, 'Audio').and.returnValue(stub); + + const view = _converse.chatboxviews.get('lounge@montague.lit'); + if (!view.querySelectorAll('.chat-area').length) { + view.renderChatArea(); + } + let text = 'This message will play a sound because it mentions romeo'; + let message = $msg({ + from: 'lounge@montague.lit/otheruser', + id: '1', + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(text); + _converse.api.settings.set('notify_all_room_messages', true); + await view.model.handleMessageStanza(message.nodeTree); + await u.waitUntil(() => window.Audio.calls.count()); + expect(window.Audio).toHaveBeenCalled(); + + text = "This message won't play a sound"; + message = $msg({ + from: 'lounge@montague.lit/otheruser', + id: '2', + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(text); + await view.model.handleMessageStanza(message.nodeTree); + expect(window.Audio, 1); + api.settings.set('play_sounds', false); + + text = "This message won't play a sound because it is sent by romeo"; + message = $msg({ + from: 'lounge@montague.lit/romeo', + id: '3', + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(text); + await view.model.handleMessageStanza(message.nodeTree); + expect(window.Audio, 1); + })); + }); + }); + + + describe("A Favicon Message Counter", function () { + + it("is incremented when the message is received and the window is not focused", + mock.initConverse([], {'show_tab_notifications': false}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const favico = jasmine.createSpyObj('favico', ['badge']); + spyOn(converse.env, 'Favico').and.returnValue(favico); + + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const previous_state = _converse.windowState; + const msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('This message will increment the message counter').up() + .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + _converse.windowState = 'hidden'; + + spyOn(_converse.api, "trigger").and.callThrough(); + + await _converse.handleMessageStanza(msg); + expect(_converse.api.trigger).toHaveBeenCalledWith('message', jasmine.any(Object)); + + expect(favico.badge.calls.count()).toBe(0); + + _converse.api.settings.set('show_tab_notifications', true); + const msg2 = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t('This message increment the message counter AND update the page title').up() + .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + + await _converse.handleMessageStanza(msg2); + await u.waitUntil(() => favico.badge.calls.count() === 1); + expect(favico.badge.calls.mostRecent().args.pop()).toBe(2); + + const view = _converse.chatboxviews.get(sender_jid); + expect(view.model.get('num_unread')).toBe(2); + + // Check that it's cleared when the window is focused + _converse.windowState = 'hidden'; + u.saveWindowState({'type': 'focus'}); + await u.waitUntil(() => favico.badge.calls.count() === 2); + expect(favico.badge.calls.mostRecent().args.pop()).toBe(0); + + expect(view.model.get('num_unread')).toBe(0); + _converse.windowSate = previous_state; + })); + + it("is not incremented when the message is received and the window is focused", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const favico = jasmine.createSpyObj('favico', ['badge']); + spyOn(converse.env, 'Favico').and.returnValue(favico); + + u.saveWindowState({'type': 'focus'}); + const message = 'This message will not increment the message counter'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', + msg = $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }).c('body').t(message).up() + .c('active', {'xmlns': Strophe.NS.CHATSTATES}).tree(); + await _converse.handleMessageStanza(msg); + + const promise = u.getOpenPromise(); + setTimeout(() => { + const view = _converse.chatboxviews.get(sender_jid); + expect(view.model.get('num_unread')).toBe(0); + expect(favico.badge.calls.count()).toBe(0); + promise.resolve(); + }, 500); + return promise; + })); + + it("is incremented from zero when chatbox was closed after viewing previously received messages and the window is not focused now", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + const favico = jasmine.createSpyObj('favico', ['badge']); + spyOn(converse.env, 'Favico').and.returnValue(favico); + const message = 'This message will always increment the message counter from zero'; + const sender_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const msgFactory = () => $msg({ + from: sender_jid, + to: _converse.connection.jid, + type: 'chat', + id: u.getUniqueId() + }) + .c('body').t(message).up() + .c('active', {'xmlns': Strophe.NS.CHATSTATES}) + .tree(); + + // leave converse-chat page + _converse.windowState = 'hidden'; + await _converse.handleMessageStanza(msgFactory()); + let view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => favico.badge.calls.count() === 1, 1000); + expect(favico.badge.calls.mostRecent().args.pop()).toBe(1); + expect(view.model.get('num_unread')).toBe(1); + + // come back to converse-chat page + u.saveWindowState({'type': 'focus'}); + + + await u.waitUntil(() => u.isVisible(view)); + expect(view.model.get('num_unread')).toBe(0); + + await u.waitUntil(() => favico.badge.calls.count() === 2); + expect(favico.badge.calls.mostRecent().args.pop()).toBe(0); + + // close chatbox and leave converse-chat page again + view.close(); + _converse.windowState = 'hidden'; + + // check that msg_counter is incremented from zero again + await _converse.handleMessageStanza(msgFactory()); + view = _converse.chatboxviews.get(sender_jid); + await u.waitUntil(() => u.isVisible(view)); + await u.waitUntil(() => favico.badge.calls.count() === 3); + expect(favico.badge.calls.mostRecent().args.pop()).toBe(1); + })); + }); + +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js new file mode 100644 index 0000000..4ea90c0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/notifications/utils.js @@ -0,0 +1,334 @@ +import Favico from 'favico.js-slevomat'; +import log from '@converse/headless/log'; +import { __ } from 'i18n'; +import { _converse, api, converse } from '@converse/headless/core.js'; +import { isEmptyMessage } from '@converse/headless/utils/core.js'; + +const { Strophe } = converse.env; +const supports_html5_notification = 'Notification' in window; + +converse.env.Favico = Favico; + +let favicon; + + +export function isMessageToHiddenChat (attrs) { + return _converse.isTestEnv() || (_converse.chatboxes.get(attrs.from)?.isHidden() ?? false); +} + +export function areDesktopNotificationsEnabled () { + return _converse.isTestEnv() || ( + supports_html5_notification && + api.settings.get('show_desktop_notifications') && + Notification.permission === 'granted' + ); +} + +export function clearFavicon () { + favicon = null; + navigator.clearAppBadge?.() + .catch(e => log.error("Could not clear unread count in app badge " + e)); +} + +export function updateUnreadFavicon () { + if (api.settings.get('show_tab_notifications')) { + favicon = favicon ?? new converse.env.Favico({ type: 'circle', animation: 'pop' }); + const chats = _converse.chatboxes.models; + const num_unread = chats.reduce((acc, chat) => acc + (chat.get('num_unread') || 0), 0); + favicon.badge(num_unread); + navigator.setAppBadge?.(num_unread) + .catch(e => log.error("Could set unread count in app badge - " + e)); + } +} + + +function isReferenced (references, muc_jid, nick) { + const check = r => [_converse.bare_jid, `${muc_jid}/${nick}`].includes(r.uri.replace(/^xmpp:/, '')); + return references.reduce((acc, r) => acc || check(r), false); +} + + +/** + * Is this a group message for which we should notify the user? + * @private + * @param { MUCMessageAttributes } attrs + */ +export async function shouldNotifyOfGroupMessage (attrs) { + if (!attrs?.body && !attrs?.message) { + // attrs.message is used by 'info' messages + return false; + } + const jid = attrs.from; + const muc_jid = attrs.from_muc; + const notify_all = api.settings.get('notify_all_room_messages'); + const room = _converse.chatboxes.get(muc_jid); + const resource = Strophe.getResourceFromJid(jid); + const sender = (resource && Strophe.unescapeNode(resource)) || ''; + let is_mentioned = false; + const nick = room.get('nick'); + + if (api.settings.get('notify_nicknames_without_references')) { + is_mentioned = new RegExp(`\\b${nick}\\b`).test(attrs.body); + } + + const is_not_mine = sender !== nick; + const should_notify_user = + notify_all === true || + (Array.isArray(notify_all) && notify_all.includes(muc_jid)) || + isReferenced(attrs.references, muc_jid, nick) || + is_mentioned; + + if (is_not_mine && !!should_notify_user) { + /** + * *Hook* which allows plugins to run further logic to determine + * whether a notification should be sent out for this message. + * @event _converse#shouldNotifyOfGroupMessage + * @example + * api.listen.on('shouldNotifyOfGroupMessage', (should_notify) => { + * return should_notify && flurb === floob; + * }); + */ + const should_notify = await api.hook('shouldNotifyOfGroupMessage', attrs, true); + return should_notify; + } + return false; +} + +async function shouldNotifyOfInfoMessage (attrs) { + if (!attrs.from_muc) { + return false; + } + const room = await api.rooms.get(attrs.from_muc); + if (!room) { + return false; + } + const nick = room.get('nick'); + const muc_jid = attrs.from_muc; + const notify_all = api.settings.get('notify_all_room_messages'); + return ( + notify_all === true || + (Array.isArray(notify_all) && notify_all.includes(muc_jid)) || + isReferenced(attrs.references, muc_jid, nick) + ); +} + +/** + * @private + * @async + * @method shouldNotifyOfMessage + * @param { MessageData|MUCMessageData } data + */ +function shouldNotifyOfMessage (data) { + const { attrs } = data; + if (!attrs || attrs.is_forwarded) { + return false; + } + if (attrs['type'] === 'groupchat') { + return shouldNotifyOfGroupMessage(attrs); + } else if (attrs['type'] === 'info') { + return shouldNotifyOfInfoMessage(attrs); + } else if (attrs.is_headline) { + // We want to show notifications for headline messages. + return isMessageToHiddenChat(attrs); + } + const is_me = Strophe.getBareJidFromJid(attrs.from) === _converse.bare_jid; + return ( + !isEmptyMessage(attrs) && + !is_me && + (api.settings.get('show_desktop_notifications') === 'all' || isMessageToHiddenChat(attrs)) + ); +} + +export function showFeedbackNotification (data) { + if (data.klass === 'error' || data.klass === 'warn') { + const n = new Notification(data.subject, { + body: data.message, + lang: _converse.locale, + icon: api.settings.get('notification_icon') + }); + setTimeout(n.close.bind(n), 5000); + } +} + +/** + * Creates an HTML5 Notification to inform of a change in a + * contact's chat state. + */ +function showChatStateNotification (contact) { + if (api.settings.get('chatstate_notification_blacklist')?.includes(contact.jid)) { + // Don't notify if the user is being ignored. + return; + } + const chat_state = contact.presence.get('show'); + let message = null; + if (chat_state === 'offline') { + message = __('has gone offline'); + } else if (chat_state === 'away') { + message = __('has gone away'); + } else if (chat_state === 'dnd') { + message = __('is busy'); + } else if (chat_state === 'online') { + message = __('has come online'); + } + if (message === null) { + return; + } + const n = new Notification(contact.getDisplayName(), { + body: message, + lang: _converse.locale, + icon: api.settings.get('notification_icon') + }); + setTimeout(() => n.close(), 5000); +} + + +/** + * Shows an HTML5 Notification with the passed in message + * @private + * @param { MessageData|MUCMessageData } data + */ +function showMessageNotification (data) { + const { attrs } = data; + if (attrs.is_error) { + return; + } + + if (!areDesktopNotificationsEnabled()) { + return; + } + let title, roster_item; + const full_from_jid = attrs.from; + const from_jid = Strophe.getBareJidFromJid(full_from_jid); + if (attrs.type == 'info') { + title = attrs.message; + } else if (attrs.type === 'headline') { + if (!from_jid.includes('@') || api.settings.get('allow_non_roster_messaging')) { + title = __('Notification from %1$s', from_jid); + } else { + return; + } + } else if (!from_jid.includes('@')) { + // workaround for Prosody which doesn't give type "headline" + title = __('Notification from %1$s', from_jid); + } else if (attrs.type === 'groupchat') { + title = __('%1$s says', Strophe.getResourceFromJid(full_from_jid)); + } else { + if (_converse.roster === undefined) { + log.error('Could not send notification, because roster is undefined'); + return; + } + roster_item = _converse.roster.get(from_jid); + if (roster_item !== undefined) { + title = __('%1$s says', roster_item.getDisplayName()); + } else { + if (api.settings.get('allow_non_roster_messaging')) { + title = __('%1$s says', from_jid); + } else { + return; + } + } + } + + let body; + if (attrs.type == 'info') { + body = attrs.reason; + } else { + body = attrs.is_encrypted ? attrs.plaintext : attrs.body; + if (!body) { + return; + } + } + + const n = new Notification(title, { + 'body': body, + 'lang': _converse.locale, + 'icon': api.settings.get('notification_icon'), + 'requireInteraction': !api.settings.get('notification_delay') + }); + if (api.settings.get('notification_delay')) { + setTimeout(() => n.close(), api.settings.get('notification_delay')); + } + n.onclick = function (event) { + event.preventDefault(); + window.focus(); + const chat = _converse.chatboxes.get(from_jid); + chat.maybeShow(true); + } +} + +function playSoundNotification () { + if (api.settings.get('play_sounds') && window.Audio !== undefined) { + const audioOgg = new Audio(api.settings.get('sounds_path') + 'msg_received.ogg'); + const canPlayOgg = audioOgg.canPlayType('audio/ogg'); + if (canPlayOgg === 'probably') { + return audioOgg.play(); + } + const audioMp3 = new Audio(api.settings.get('sounds_path') + 'msg_received.mp3'); + const canPlayMp3 = audioMp3.canPlayType('audio/mp3'); + if (canPlayMp3 === 'probably') { + audioMp3.play(); + } else if (canPlayOgg === 'maybe') { + audioOgg.play(); + } else if (canPlayMp3 === 'maybe') { + audioMp3.play(); + } + } +} + +/** + * Event handler for the on('message') event. Will call methods + * to play sounds and show HTML5 notifications. + */ +export async function handleMessageNotification (data) { + if (!await shouldNotifyOfMessage(data)) { + return false; + } + /** + * Triggered when a notification (sound or HTML5 notification) for a new + * message has will be made. + * @event _converse#messageNotification + * @type { MessageData|MUCMessageData} + * @example _converse.api.listen.on('messageNotification', data => { ... }); + */ + api.trigger('messageNotification', data); + playSoundNotification(); + showMessageNotification(data); +} + +export function handleFeedback (data) { + if (areDesktopNotificationsEnabled(true)) { + showFeedbackNotification(data); + } +} + +/** + * Event handler for on('contactPresenceChanged'). + * Will show an HTML5 notification to indicate that the chat status has changed. + */ +export function handleChatStateNotification (contact) { + if (areDesktopNotificationsEnabled() && api.settings.get('show_chat_state_notifications')) { + showChatStateNotification(contact); + } +} + +function showContactRequestNotification (contact) { + const n = new Notification(contact.getDisplayName(), { + body: __('wants to be your contact'), + lang: _converse.locale, + icon: api.settings.get('notification_icon') + }); + setTimeout(() => n.close(), 5000); +} + +export function handleContactRequestNotification (contact) { + if (areDesktopNotificationsEnabled(true)) { + showContactRequestNotification(contact); + } +} + +export function requestPermission () { + if (supports_html5_notification && !['denied', 'granted'].includes(Notification.permission)) { + // Ask user to enable HTML5 notifications + Notification.requestPermission(); + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js new file mode 100644 index 0000000..1179f6d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/api.js @@ -0,0 +1,83 @@ +import { _converse, api } from '@converse/headless/core'; +import { generateFingerprint } from './utils.js'; + +export default { + /** + * The "omemo" namespace groups methods relevant to OMEMO + * encryption. + * + * @namespace _converse.api.omemo + * @memberOf _converse.api + */ + 'omemo': { + /** + * Returns the device ID of the current device. + */ + async getDeviceID () { + await api.waitUntil('OMEMOInitialized'); + return _converse.omemo_store.get('device_id'); + }, + + /** + * The "devicelists" namespace groups methods related to OMEMO device lists + * + * @namespace _converse.api.omemo.devicelists + * @memberOf _converse.api.omemo + */ + 'devicelists': { + /** + * Returns the {@link _converse.DeviceList} for a particular JID. + * The device list will be created if it doesn't exist already. + * @method _converse.api.omemo.devicelists.get + * @param { String } jid - The Jabber ID for which the device list will be returned. + * @param { bool } create=false - Set to `true` if the device list + * should be created if it cannot be found. + */ + async get (jid, create=false) { + const list = _converse.devicelists.get(jid) || + (create ? _converse.devicelists.create({ jid }) : null); + await list?.initialized; + return list; + } + }, + + /** + * The "bundle" namespace groups methods relevant to the user's + * OMEMO bundle. + * + * @namespace _converse.api.omemo.bundle + * @memberOf _converse.api.omemo + */ + 'bundle': { + /** + * Lets you generate a new OMEMO device bundle + * + * @method _converse.api.omemo.bundle.generate + * @returns {promise} Promise which resolves once we have a result from the server. + */ + 'generate': async () => { + await api.waitUntil('OMEMOInitialized'); + // Remove current device + const devicelist = await api.omemo.devicelists.get(_converse.bare_jid); + + const device_id = _converse.omemo_store.get('device_id'); + if (device_id) { + const device = devicelist.devices.get(device_id); + _converse.omemo_store.unset(device_id); + if (device) { + await new Promise(done => device.destroy({ 'success': done, 'error': done })); + } + devicelist.devices.trigger('remove'); + } + // Generate new device bundle and publish + // https://xmpp.org/extensions/attic/xep-0384-0.3.0.html#usecases-announcing + await _converse.omemo_store.generateBundle(); + await devicelist.publishDevices(); + const device = devicelist.devices.get(_converse.omemo_store.get('device_id')); + const fp = generateFingerprint(device); + await _converse.omemo_store.publishBundle(); + return fp; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js new file mode 100644 index 0000000..a5d0ef6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/consts.js @@ -0,0 +1,10 @@ +export const UNDECIDED = 0; +export const TRUSTED = 1; +export const UNTRUSTED = -1; + +export const TAG_LENGTH = 128; + +export const KEY_ALGO = { + 'name': 'AES-GCM', + 'length': 128 +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js new file mode 100644 index 0000000..05b76ec --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/device.js @@ -0,0 +1,69 @@ +import log from '@converse/headless/log'; +import { IQError } from './errors.js'; +import { Model } from '@converse/skeletor/src/model.js'; +import { UNDECIDED } from './consts.js'; +import { _converse, api, converse } from '@converse/headless/core.js'; +import { getRandomInt } from '@converse/headless/utils/core.js'; +import { parseBundle } from './utils.js'; + +const { Strophe, sizzle, $iq } = converse.env; + + +/** + * @class + * @namespace _converse.Device + * @memberOf _converse + */ +const Device = Model.extend({ + defaults: { + 'trusted': UNDECIDED, + 'active': true + }, + + getRandomPreKey () { + // XXX: assumes that the bundle has already been fetched + const bundle = this.get('bundle'); + return bundle.prekeys[getRandomInt(bundle.prekeys.length)]; + }, + + async fetchBundleFromServer () { + const stanza = $iq({ + 'type': 'get', + 'from': _converse.bare_jid, + 'to': this.get('jid') + }).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB }) + .c('items', { 'node': `${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}` }); + + let iq; + try { + iq = await api.sendIQ(stanza); + } catch (iq) { + log.error(`Could not fetch bundle for device ${this.get('id')} from ${this.get('jid')}`); + log.error(iq); + return null; + } + if (iq.querySelector('error')) { + throw new IQError('Could not fetch bundle', iq); + } + const publish_el = sizzle(`items[node="${Strophe.NS.OMEMO_BUNDLES}:${this.get('id')}"]`, iq).pop(); + const bundle_el = sizzle(`bundle[xmlns="${Strophe.NS.OMEMO}"]`, publish_el).pop(); + const bundle = parseBundle(bundle_el); + this.save('bundle', bundle); + return bundle; + }, + + /** + * Fetch and save the bundle information associated with + * this device, if the information is not cached already. + * @method _converse.Device#getBundle + */ + getBundle () { + if (this.get('bundle')) { + return Promise.resolve(this.get('bundle'), this); + } else { + return this.fetchBundleFromServer(); + } + } +}); + +export default Device; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js new file mode 100644 index 0000000..8960875 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelist.js @@ -0,0 +1,134 @@ +import log from '@converse/headless/log'; +import { Model } from '@converse/skeletor/src/model.js'; +import { _converse, api, converse } from '@converse/headless/core'; +import { getOpenPromise } from '@converse/openpromise'; +import { initStorage } from '@converse/headless/utils/storage.js'; +import { restoreOMEMOSession } from './utils.js'; + +const { Strophe, $build, $iq, sizzle } = converse.env; + +/** + * @class + * @namespace _converse.DeviceList + * @memberOf _converse + */ +const DeviceList = Model.extend({ + idAttribute: 'jid', + + async initialize () { + this.initialized = getOpenPromise(); + await this.initDevices(); + this.initialized.resolve(); + }, + + initDevices () { + this.devices = new _converse.Devices(); + const id = `converse.devicelist-${_converse.bare_jid}-${this.get('jid')}`; + initStorage(this.devices, id); + return this.fetchDevices(); + }, + + async onDevicesFound (collection) { + if (collection.length === 0) { + let ids = []; + try { + ids = await this.fetchDevicesFromServer(); + } catch (e) { + if (e === null) { + log.error(`Timeout error while fetching devices for ${this.get('jid')}`); + } else { + log.error(`Could not fetch devices for ${this.get('jid')}`); + log.error(e); + } + this.destroy(); + } + if (this.get('jid') === _converse.bare_jid) { + this.publishCurrentDevice(ids); + } + } + }, + + fetchDevices () { + if (this._devices_promise === undefined) { + this._devices_promise = new Promise(resolve => { + this.devices.fetch({ + 'success': c => resolve(this.onDevicesFound(c)), + 'error': (_, e) => { + log.error(e); + resolve(); + } + }); + }); + } + return this._devices_promise; + }, + + async getOwnDeviceId () { + let device_id = _converse.omemo_store.get('device_id'); + if (!this.devices.get(device_id)) { + // Generate a new bundle if we cannot find our device + await _converse.omemo_store.generateBundle(); + device_id = _converse.omemo_store.get('device_id'); + } + return device_id; + }, + + async publishCurrentDevice (device_ids) { + if (this.get('jid') !== _converse.bare_jid) { + return; // We only publish for ourselves. + } + await restoreOMEMOSession(); + + if (!_converse.omemo_store) { + // Happens during tests. The connection gets torn down + // before publishCurrentDevice has time to finish. + log.warn('publishCurrentDevice: omemo_store is not defined, likely a timing issue'); + return; + } + if (!device_ids.includes(await this.getOwnDeviceId())) { + return this.publishDevices(); + } + }, + + async fetchDevicesFromServer () { + const stanza = $iq({ + 'type': 'get', + 'from': _converse.bare_jid, + 'to': this.get('jid') + }).c('pubsub', { 'xmlns': Strophe.NS.PUBSUB }) + .c('items', { 'node': Strophe.NS.OMEMO_DEVICELIST }); + + const iq = await api.sendIQ(stanza); + const selector = `list[xmlns="${Strophe.NS.OMEMO}"] device`; + const device_ids = sizzle(selector, iq).map(d => d.getAttribute('id')); + const jid = this.get('jid'); + return Promise.all(device_ids.map(id => this.devices.create({ id, jid }, { 'promise': true }))); + }, + + /** + * Send an IQ stanza to the current user's "devices" PEP node to + * ensure that all devices are published for potential chat partners to see. + * See: https://xmpp.org/extensions/xep-0384.html#usecases-announcing + */ + publishDevices () { + const item = $build('item', { 'id': 'current' }).c('list', { 'xmlns': Strophe.NS.OMEMO }); + this.devices.filter(d => d.get('active')).forEach(d => item.c('device', { 'id': d.get('id') }).up()); + const options = { 'pubsub#access_model': 'open' }; + return api.pubsub.publish(null, Strophe.NS.OMEMO_DEVICELIST, item, options, false); + }, + + async removeOwnDevices (device_ids) { + if (this.get('jid') !== _converse.bare_jid) { + throw new Error("Cannot remove devices from someone else's device list"); + } + await Promise.all(device_ids.map(id => this.devices.get(id)).map(d => + new Promise(resolve => d.destroy({ + 'success': resolve, + 'error': (_, e) => { log.error(e); resolve(); } + })) + )); + return this.publishDevices(); + } +}); + +export default DeviceList; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js new file mode 100644 index 0000000..a42462a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devicelists.js @@ -0,0 +1,11 @@ +import DeviceList from './devicelist.js'; +import { Collection } from '@converse/skeletor/src/collection'; + +/** + * @class + * @namespace _converse.DeviceLists + * @memberOf _converse + */ +const DeviceLists = Collection.extend({ model: DeviceList }); + +export default DeviceLists; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js new file mode 100644 index 0000000..991fed1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/devices.js @@ -0,0 +1,4 @@ +import Device from './device.js'; +import { Collection } from '@converse/skeletor/src/collection'; + +export default Collection.extend({ model: Device }); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js new file mode 100644 index 0000000..b99c501 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/errors.js @@ -0,0 +1,7 @@ +export class IQError extends Error { + constructor (message, iq) { + super(message, iq); + this.name = 'IQError'; + this.iq = iq; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js new file mode 100644 index 0000000..00b9f86 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/fingerprints.js @@ -0,0 +1,34 @@ +import tplFingerprints from './templates/fingerprints.js'; +import { CustomElement } from 'shared/components/element.js'; +import { api } from "@converse/headless/core"; + +export class Fingerprints extends CustomElement { + + static get properties () { + return { + 'jid': { type: String } + } + } + + async initialize () { + this.devicelist = await api.omemo.devicelists.get(this.jid, true); + this.listenTo(this.devicelist.devices, 'change:bundle', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'change:trusted', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'remove', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'add', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'reset', () => this.requestUpdate()); + this.requestUpdate(); + } + + render () { + return this.devicelist ? tplFingerprints(this) : ''; + } + + toggleDeviceTrust (ev) { + const radio = ev.target; + const device = this.devicelist.devices.get(radio.getAttribute('name')); + device.save('trusted', parseInt(radio.value, 10)); + } +} + +api.elements.define('converse-omemo-fingerprints', Fingerprints); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js new file mode 100644 index 0000000..7cdb4b7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/index.js @@ -0,0 +1,119 @@ +/** + * @copyright The Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './fingerprints.js'; +import './profile.js'; +import 'shared/modals/user-details.js'; +import ConverseMixins from './mixins/converse.js'; +import Device from './device.js'; +import DeviceList from './devicelist.js'; +import DeviceLists from './devicelists.js'; +import Devices from './devices.js'; +import OMEMOStore from './store.js'; +import log from '@converse/headless/log'; +import omemo_api from './api.js'; +import { _converse, api, converse } from '@converse/headless/core.js'; +import { shouldClearCache } from '@converse/headless/utils/core.js'; +import { + createOMEMOMessageStanza, + encryptFile, + getOMEMOToolbarButton, + getOutgoingMessageAttributes, + handleEncryptedFiles, + handleMessageSendError, + initOMEMO, + omemo, + onChatBoxesInitialized, + onChatInitialized, + parseEncryptedMessage, + registerPEPPushHandler, + setEncryptedFileURL, +} from './utils.js'; + +const { Strophe } = converse.env; + +converse.env.omemo = omemo; + +Strophe.addNamespace('OMEMO_DEVICELIST', Strophe.NS.OMEMO + '.devicelist'); +Strophe.addNamespace('OMEMO_VERIFICATION', Strophe.NS.OMEMO + '.verification'); +Strophe.addNamespace('OMEMO_WHITELISTED', Strophe.NS.OMEMO + '.whitelisted'); +Strophe.addNamespace('OMEMO_BUNDLES', Strophe.NS.OMEMO + '.bundles'); + + +converse.plugins.add('converse-omemo', { + enabled (_converse) { + return ( + window.libsignal && + _converse.config.get('trusted') && + !api.settings.get('clear_cache_on_logout') && + !_converse.api.settings.get('blacklisted_plugins').includes('converse-omemo') + ); + }, + + dependencies: ['converse-chatview', 'converse-pubsub', 'converse-profile'], + + initialize () { + api.settings.extend({ 'omemo_default': false }); + api.promises.add(['OMEMOInitialized']); + + _converse.NUM_PREKEYS = 100; // Set here so that tests can override + + Object.assign(_converse, ConverseMixins); + Object.assign(_converse.api, omemo_api); + + _converse.OMEMOStore = OMEMOStore; + _converse.Device = Device; + _converse.Devices = Devices; + _converse.DeviceList = DeviceList; + _converse.DeviceLists = DeviceLists; + + /******************** Event Handlers ********************/ + api.waitUntil('chatBoxesInitialized').then(onChatBoxesInitialized); + + api.listen.on('getOutgoingMessageAttributes', getOutgoingMessageAttributes); + + api.listen.on('createMessageStanza', async (chat, data) => { + try { + data = await createOMEMOMessageStanza(chat, data); + } catch (e) { + handleMessageSendError(e, chat); + } + return data; + }); + + api.listen.on('afterFileUploaded', (msg, attrs) => msg.file.xep454_ivkey ? setEncryptedFileURL(msg, attrs) : attrs); + api.listen.on('beforeFileUpload', (chat, file) => chat.get('omemo_active') ? encryptFile(file) : file); + + api.listen.on('parseMessage', parseEncryptedMessage); + api.listen.on('parseMUCMessage', parseEncryptedMessage); + + api.listen.on('chatBoxViewInitialized', onChatInitialized); + api.listen.on('chatRoomViewInitialized', onChatInitialized); + + api.listen.on('connected', registerPEPPushHandler); + api.listen.on('getToolbarButtons', getOMEMOToolbarButton); + + api.listen.on('statusInitialized', initOMEMO); + api.listen.on('addClientFeatures', () => api.disco.own.features.add(`${Strophe.NS.OMEMO_DEVICELIST}+notify`)); + + api.listen.on('afterMessageBodyTransformed', handleEncryptedFiles); + + api.listen.on('userDetailsModalInitialized', contact => { + const jid = contact.get('jid'); + _converse.generateFingerprints(jid).catch(e => log.error(e)); + }); + + api.listen.on('profileModalInitialized', () => { + _converse.generateFingerprints(_converse.bare_jid).catch(e => log.error(e)); + }); + + api.listen.on('clearSession', () => { + delete _converse.omemo_store + if (shouldClearCache() && _converse.devicelists) { + _converse.devicelists.clearStore(); + delete _converse.devicelists; + } + }); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js new file mode 100644 index 0000000..575f13e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/mixins/converse.js @@ -0,0 +1,22 @@ +import { generateFingerprint, getDevicesForContact, } from '../utils.js'; + + +const ConverseMixins = { + + async generateFingerprints (jid) { + const devices = await getDevicesForContact(jid); + return Promise.all(devices.map(d => generateFingerprint(d))); + }, + + getDeviceForContact (jid, device_id) { + return getDevicesForContact(jid).then(devices => devices.get(device_id)); + }, + + async contactHasOMEMOSupport (jid) { + /* Checks whether the contact advertises any OMEMO-compatible devices. */ + const devices = await getDevicesForContact(jid); + return devices.length > 0; + } +} + +export default ConverseMixins; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js new file mode 100644 index 0000000..fb5a135 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/profile.js @@ -0,0 +1,78 @@ +import log from '@converse/headless/log'; +import tplProfile from './templates/profile.js'; +import tplSpinner from "templates/spinner.js"; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; + +const { Strophe, sizzle, u } = converse.env; + + +export class Profile extends CustomElement { + + async initialize () { + this.devicelist = await api.omemo.devicelists.get(_converse.bare_jid, true); + await this.setAttributes(); + this.listenTo(this.devicelist.devices, 'change:bundle', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'reset', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'reset', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'remove', () => this.requestUpdate()); + this.listenTo(this.devicelist.devices, 'add', () => this.requestUpdate()); + this.requestUpdate(); + } + + async setAttributes () { + this.device_id = await api.omemo.getDeviceID(); + this.current_device = this.devicelist.devices.get(this.device_id); + this.other_devices = this.devicelist.devices.filter(d => d.get('id') !== this.device_id); + } + + render () { + return this.devicelist ? tplProfile(this) : tplSpinner(); + } + + selectAll (ev) { // eslint-disable-line class-methods-use-this + let sibling = u.ancestor(ev.target, 'li'); + while (sibling) { + sibling.querySelector('input[type="checkbox"]').checked = ev.target.checked; + sibling = sibling.nextElementSibling; + } + } + + async removeSelectedFingerprints (ev) { + ev.preventDefault(); + ev.stopPropagation(); + ev.target.querySelector('.select-all').checked = false; + const device_ids = sizzle('.fingerprint-removal-item input[type="checkbox"]:checked', ev.target).map( + c => c.value + ); + + try { + await this.devicelist.removeOwnDevices(device_ids); + } catch (err) { + log.error(err); + _converse.api.alert(Strophe.LogLevel.ERROR, __('Error'), [ + __('Sorry, an error occurred while trying to remove the devices.') + ]); + } + await this.setAttributes(); + this.requestUpdate(); + } + + async generateOMEMODeviceBundle (ev) { + ev.preventDefault(); + + const result = await api.confirm(__( + 'Are you sure you want to generate new OMEMO keys? ' + + 'This will remove your old keys and all previously ' + + 'encrypted messages will no longer be decryptable on this device.')); + + if (result) { + await api.omemo.bundle.generate(); + await this.setAttributes(); + this.requestUpdate(); + } + } +} + +api.elements.define('converse-omemo-profile', Profile); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js new file mode 100644 index 0000000..3ffa279 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/store.js @@ -0,0 +1,305 @@ +/* global libsignal */ +import difference from 'lodash-es/difference'; +import invokeMap from 'lodash-es/invokeMap'; +import log from '@converse/headless/log'; +import range from 'lodash-es/range'; +import omit from 'lodash-es/omit'; +import { Model } from '@converse/skeletor/src/model.js'; +import { generateDeviceID } from './utils.js'; +import { _converse, api, converse } from '@converse/headless/core'; + +const { Strophe, $build, u } = converse.env; + + +const OMEMOStore = Model.extend({ + Direction: { + SENDING: 1, + RECEIVING: 2 + }, + + getIdentityKeyPair () { + const keypair = this.get('identity_keypair'); + return Promise.resolve({ + 'privKey': u.base64ToArrayBuffer(keypair.privKey), + 'pubKey': u.base64ToArrayBuffer(keypair.pubKey) + }); + }, + + getLocalRegistrationId () { + return Promise.resolve(parseInt(this.get('device_id'), 10)); + }, + + isTrustedIdentity (identifier, identity_key, direction) { // eslint-disable-line no-unused-vars + if (identifier === null || identifier === undefined) { + throw new Error("Can't check identity key for invalid key"); + } + if (!(identity_key instanceof ArrayBuffer)) { + throw new Error('Expected identity_key to be an ArrayBuffer'); + } + const trusted = this.get('identity_key' + identifier); + if (trusted === undefined) { + return Promise.resolve(true); + } + return Promise.resolve(u.arrayBufferToBase64(identity_key) === trusted); + }, + + loadIdentityKey (identifier) { + if (identifier === null || identifier === undefined) { + throw new Error("Can't load identity_key for invalid identifier"); + } + return Promise.resolve(u.base64ToArrayBuffer(this.get('identity_key' + identifier))); + }, + + saveIdentity (identifier, identity_key) { + if (identifier === null || identifier === undefined) { + throw new Error("Can't save identity_key for invalid identifier"); + } + const address = new libsignal.SignalProtocolAddress.fromString(identifier); + const existing = this.get('identity_key' + address.getName()); + const b64_idkey = u.arrayBufferToBase64(identity_key); + this.save('identity_key' + address.getName(), b64_idkey); + + if (existing && b64_idkey !== existing) { + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } + }, + + getPreKeys () { + return this.get('prekeys') || {}; + }, + + loadPreKey (key_id) { + const res = this.getPreKeys()[key_id]; + if (res) { + return Promise.resolve({ + 'privKey': u.base64ToArrayBuffer(res.privKey), + 'pubKey': u.base64ToArrayBuffer(res.pubKey) + }); + } + return Promise.resolve(); + }, + + storePreKey (key_id, key_pair) { + const prekey = {}; + prekey[key_id] = { + 'pubKey': u.arrayBufferToBase64(key_pair.pubKey), + 'privKey': u.arrayBufferToBase64(key_pair.privKey) + }; + this.save('prekeys', Object.assign(this.getPreKeys(), prekey)); + return Promise.resolve(); + }, + + removePreKey (key_id) { + this.save('prekeys', omit(this.getPreKeys(), key_id)); + return Promise.resolve(); + }, + + loadSignedPreKey (keyId) { // eslint-disable-line no-unused-vars + const res = this.get('signed_prekey'); + if (res) { + return Promise.resolve({ + 'privKey': u.base64ToArrayBuffer(res.privKey), + 'pubKey': u.base64ToArrayBuffer(res.pubKey) + }); + } + return Promise.resolve(); + }, + + storeSignedPreKey (spk) { + if (typeof spk !== 'object') { + // XXX: We've changed the signature of this method from the + // example given in InMemorySignalProtocolStore. + // Should be fine because the libsignal code doesn't + // actually call this method. + throw new Error('storeSignedPreKey: expected an object'); + } + this.save('signed_prekey', { + 'id': spk.keyId, + 'privKey': u.arrayBufferToBase64(spk.keyPair.privKey), + 'pubKey': u.arrayBufferToBase64(spk.keyPair.pubKey), + // XXX: The InMemorySignalProtocolStore does not pass + // in or store the signature, but we need it when we + // publish our bundle and this method isn't called from + // within libsignal code, so we modify it to also store + // the signature. + 'signature': u.arrayBufferToBase64(spk.signature) + }); + return Promise.resolve(); + }, + + removeSignedPreKey (key_id) { + if (this.get('signed_prekey')['id'] === key_id) { + this.unset('signed_prekey'); + this.save(); + } + return Promise.resolve(); + }, + + loadSession (identifier) { + return Promise.resolve(this.get('session' + identifier)); + }, + + storeSession (identifier, record) { + return Promise.resolve(this.save('session' + identifier, record)); + }, + + removeSession (identifier) { + return Promise.resolve(this.unset('session' + identifier)); + }, + + removeAllSessions (identifier) { + const keys = Object.keys(this.attributes).filter(key => + key.startsWith('session' + identifier) ? key : false + ); + const attrs = {}; + keys.forEach(key => { attrs[key] = undefined; }); + this.save(attrs); + return Promise.resolve(); + }, + + publishBundle () { + const signed_prekey = this.get('signed_prekey'); + const node = `${Strophe.NS.OMEMO_BUNDLES}:${this.get('device_id')}`; + const item = $build('item') + .c('bundle', { 'xmlns': Strophe.NS.OMEMO }) + .c('signedPreKeyPublic', { 'signedPreKeyId': signed_prekey.id }) + .t(signed_prekey.pubKey).up() + .c('signedPreKeySignature') + .t(signed_prekey.signature).up() + .c('identityKey') + .t(this.get('identity_keypair').pubKey).up() + .c('prekeys'); + + Object.values(this.get('prekeys')).forEach((prekey, id) => + item + .c('preKeyPublic', { 'preKeyId': id }) + .t(prekey.pubKey) + .up() + ); + const options = { 'pubsub#access_model': 'open' }; + return api.pubsub.publish(null, node, item, options, false); + }, + + async generateMissingPreKeys () { + const missing_keys = difference( + invokeMap(range(0, _converse.NUM_PREKEYS), Number.prototype.toString), + Object.keys(this.getPreKeys()) + ); + if (missing_keys.length < 1) { + log.warn('No missing prekeys to generate for our own device'); + return Promise.resolve(); + } + const keys = await Promise.all( + missing_keys.map(id => libsignal.KeyHelper.generatePreKey(parseInt(id, 10))) + ); + keys.forEach(k => this.storePreKey(k.keyId, k.keyPair)); + const marshalled_keys = Object.keys(this.getPreKeys()).map(k => ({ + 'id': k.keyId, + 'key': u.arrayBufferToBase64(k.pubKey) + })); + const devicelist = await api.omemo.devicelists.get(_converse.bare_jid); + const device = devicelist.devices.get(this.get('device_id')); + const bundle = await device.getBundle(); + device.save('bundle', Object.assign(bundle, { 'prekeys': marshalled_keys })); + }, + + /** + * Generates, stores and then returns pre-keys. + * + * Pre-keys are one half of a X3DH key exchange and are published as part + * of the device bundle. + * + * For a new contact or device to establish an encrypted session, it needs + * to use a pre-key, which it chooses randomly from the list of available + * ones. + */ + async generatePreKeys () { + const amount = _converse.NUM_PREKEYS; + const { KeyHelper } = libsignal; + const keys = await Promise.all( + range(0, amount).map(id => KeyHelper.generatePreKey(id)) + ); + + keys.forEach(k => this.storePreKey(k.keyId, k.keyPair)); + + return keys.map(k => ({ + 'id': k.keyId, + 'key': u.arrayBufferToBase64(k.keyPair.pubKey) + })); + }, + + /** + * Generate the cryptographic data used by the X3DH key agreement protocol + * in order to build a session with other devices. + * + * By generating a bundle, and publishing it via PubSub, we allow other + * clients to download it and start asynchronous encrypted sessions with us, + * even if we're offline at that time. + */ + async generateBundle () { + // The first thing that needs to happen if a client wants to + // start using OMEMO is they need to generate an IdentityKey + // and a Device ID. + + // The IdentityKey is a Curve25519 public/private Key pair. + const identity_keypair = await libsignal.KeyHelper.generateIdentityKeyPair(); + const identity_key = u.arrayBufferToBase64(identity_keypair.pubKey); + + // The Device ID is a randomly generated integer between 1 and 2^31 - 1. + const device_id = await generateDeviceID(); + + this.save({ + 'device_id': device_id, + 'identity_keypair': { + 'privKey': u.arrayBufferToBase64(identity_keypair.privKey), + 'pubKey': identity_key + }, + 'identity_key': identity_key + }); + + const signed_prekey = await libsignal.KeyHelper.generateSignedPreKey(identity_keypair, 0); + this.storeSignedPreKey(signed_prekey); + + const prekeys = await this.generatePreKeys(); + + const bundle = { identity_key, device_id, prekeys }; + bundle['signed_prekey'] = { + 'id': signed_prekey.keyId, + 'public_key': u.arrayBufferToBase64(signed_prekey.keyPair.pubKey), + 'signature': u.arrayBufferToBase64(signed_prekey.signature) + }; + + const devicelist = await api.omemo.devicelists.get(_converse.bare_jid); + const device = await devicelist.devices.create( + { 'id': bundle.device_id, 'jid': _converse.bare_jid }, + { 'promise': true } + ); + device.save('bundle', bundle); + }, + + fetchSession () { + if (this._setup_promise === undefined) { + this._setup_promise = new Promise((resolve, reject) => { + this.fetch({ + 'success': () => { + if (!this.get('device_id')) { + this.generateBundle().then(resolve).catch(reject); + } else { + resolve(); + } + }, + 'error': (model, resp) => { + log.warn("Could not fetch OMEMO session from cache, we'll generate a new one."); + log.warn(resp); + this.generateBundle().then(resolve).catch(reject); + } + }); + }); + } + return this._setup_promise; + } +}); + +export default OMEMOStore; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js new file mode 100644 index 0000000..c30493d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/fingerprints.js @@ -0,0 +1,46 @@ +import { __ } from 'i18n'; +import { html } from 'lit'; +import { formatFingerprint } from '../utils.js'; + +const device_fingerprint = (el, device) => { + const i18n_trusted = __('Trusted'); + const i18n_untrusted = __('Untrusted'); + if (device.get('bundle') && device.get('bundle').fingerprint) { + return html` + <li class="list-group-item"> + <form class="fingerprint-trust"> + <div class="btn-group btn-group-toggle"> + <label class="btn btn--small ${(device.get('trusted') === 1) ? 'btn-primary active' : 'btn-secondary'}" + @click=${el.toggleDeviceTrust}> + <input type="radio" name="${device.get('id')}" value="1" + ?checked=${device.get('trusted') !== -1}>${i18n_trusted} + </label> + <label class="btn btn--small ${(device.get('trusted') === -1) ? 'btn-primary active' : 'btn-secondary'}" + @click=${el.toggleDeviceTrust}> + <input type="radio" name="${device.get('id')}" value="-1" + ?checked=${device.get('trusted') === -1}>${i18n_untrusted} + </label> + </div> + <code class="fingerprint">${formatFingerprint(device.get('bundle').fingerprint)}</code> + </form> + </li> + `; + } else { + return '' + } +} + +export default (el) => { + const i18n_fingerprints = __('OMEMO Fingerprints'); + const i18n_no_devices = __("No OMEMO-enabled devices found"); + const devices = el.devicelist.devices; + return html` + <hr/> + <ul class="list-group fingerprints"> + <li class="list-group-item active">${i18n_fingerprints}</li> + ${ devices.length ? + devices.map(device => device_fingerprint(el, device)) : + html`<li class="list-group-item"> ${i18n_no_devices} </li>` } + </ul> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js new file mode 100644 index 0000000..42278c1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/templates/profile.js @@ -0,0 +1,81 @@ +import spinner from "templates/spinner.js"; +import { formatFingerprint } from 'plugins/omemo/utils.js'; +import { html } from "lit"; +import { __ } from 'i18n'; + + +const fingerprint = (el) => html` + <span class="fingerprint">${formatFingerprint(el.current_device.get('bundle').fingerprint)}</span>`; + + +const device_with_fingerprint = (el) => { + const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following fingerprint'); + return html` + <li class="fingerprint-removal-item list-group-item"> + <label> + <input type="checkbox" value="${el.device.get('id')}" + aria-label="${i18n_fingerprint_checkbox_label}"/> + <span class="fingerprint">${formatFingerprint(el.device.get('bundle').fingerprint)}</span> + </label> + </li> + `; +} + + +const device_without_fingerprint = (el) => { + const i18n_device_without_fingerprint = __('Device without a fingerprint'); + const i18n_fingerprint_checkbox_label = __('Checkbox for selecting the following device'); + return html` + <li class="fingerprint-removal-item list-group-item"> + <label> + <input type="checkbox" value="${el.device.get('id')}" + aria-label="${i18n_fingerprint_checkbox_label}"/> + <span>${i18n_device_without_fingerprint}</span> + </label> + </li> + `; +} + + +const device_item = (el) => html` + ${(el.device.get('bundle') && el.device.get('bundle').fingerprint) ? device_with_fingerprint(el) : device_without_fingerprint(el) } +`; + + +const device_list = (el) => { + const i18n_other_devices = __('Other OMEMO-enabled devices'); + const i18n_other_devices_label = __('Checkbox to select fingerprints of all other OMEMO devices'); + const i18n_remove_devices = __('Remove checked devices and close'); + const i18n_select_all = __('Select all'); + return html` + <ul class="list-group fingerprints"> + <li class="list-group-item active"> + <label> + <input type="checkbox" class="select-all" @change=${el.selectAll} title="${i18n_select_all}" aria-label="${i18n_other_devices_label}"/> + ${i18n_other_devices} + </label> + </li> + ${ el.other_devices?.map(device => device_item(Object.assign({device}, el))) } + </ul> + <div class="form-group"><button type="submit" class="save-form btn btn-primary">${i18n_remove_devices}</button></div> + `; +} + + +export default (el) => { + const i18n_fingerprint = __("This device's OMEMO fingerprint"); + const i18n_generate = __('Generate new keys and fingerprint'); + return html` + <form class="converse-form fingerprint-removal" @submit=${el.removeSelectedFingerprints}> + <ul class="list-group fingerprints"> + <li class="list-group-item active">${i18n_fingerprint}</li> + <li class="list-group-item"> + ${ (el.current_device && el.current_device.get('bundle') && el.current_device.get('bundle').fingerprint) ? fingerprint(el) : spinner() } + </li> + </ul> + <div class="form-group"> + <button type="button" class="generate-bundle btn btn-danger" @click=${el.generateOMEMODeviceBundle}>${i18n_generate}</button> + </div> + ${ el.other_devices?.length ? device_list(el) : '' } + </form>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js new file mode 100644 index 0000000..0faddc0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/corrections.js @@ -0,0 +1,464 @@ +/*global mock, converse */ + +const { Strophe, $iq, $msg, $pres, u, omemo } = converse.env; + +describe("An OMEMO encrypted message", function() { + + it("can be edited", 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.initializedOMEMO(_converse); + await mock.openChatBoxFor(_converse, contact_jid); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + let stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + const devicelist = _converse.devicelists.get({'jid': contact_jid}); + await u.waitUntil(() => devicelist.devices.length === 1); + + const view = _converse.chatboxviews.get(contact_jid); + view.model.set('omemo_active', true); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'But soft, what light through yonder airlock breaks?'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up() + .c('signedPreKeySignature').t(btoa('2222')).up() + .c('identityKey').t(btoa('3333')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + expect(view.querySelectorAll('.chat-msg').length).toBe(1); + expect(view.querySelector('.chat-msg__text').textContent) + .toBe('But soft, what light through yonder airlock breaks?'); + + await u.waitUntil(() => textarea.value === ''); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder airlock breaks?'); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + + const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'}); + + const newer_text = 'But soft, what light through yonder door breaks?'; + textarea.value = newer_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === newer_text); + + await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 3); + const msg = _converse.connection.sent_stanzas.pop(); + const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo'; + + expect(Strophe.serialize(msg)) + .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+ + `to="mercutio@montague.lit" type="chat" `+ + `xmlns="jabber:client">`+ + `<body>${fallback_text}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `<encrypted xmlns="eu.siacs.conversations.axolotl">`+ + `<header sid="123456789">`+ + `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+ + `<key rid="555">YzFwaDNSNzNYNw==</key>`+ + `<iv>${msg.querySelector('header iv').textContent}</iv>`+ + `</header>`+ + `<payload>${msg.querySelector('payload').textContent}</payload>`+ + `</encrypted>`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+ + `</message>`); + + let older_versions = first_msg.get('older_versions'); + let keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?'); + expect(first_msg.get('plaintext')).toBe(newer_text); + expect(first_msg.get('is_encrypted')).toBe(true); + expect(first_msg.get('body')).toBe(fallback_text); + expect(first_msg.get('message')).toBe(fallback_text); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe('But soft, what light through yonder door breaks?'); + + const newest_text = 'But soft, what light through yonder window breaks?'; + textarea.value = newest_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === newest_text); + + keys = Object.keys(older_versions); + expect(keys.length).toBe(2); + expect(older_versions[keys[0]]).toBe('But soft, what light through yonder airlock breaks?'); + expect(older_versions[keys[1]]).toBe('But soft, what light through yonder door breaks?'); + + const first_rcvd_msg_id = u.getUniqueId(); + let obj = await omemo.encryptMessage('This is an encrypted message from the contact') + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': first_rcvd_msg_id + }).c('body').t(fallback_text).up() + .c('origin-id', {'id': first_rcvd_msg_id, 'xmlns': 'urn:xmpp:sid:0'}).up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload))); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.model.messages.length).toBe(2); + expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()) + .toBe('This is an encrypted message from the contact'); + + const msg_id = u.getUniqueId(); + obj = await omemo.encryptMessage('This is an edited encrypted message from the contact') + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': msg_id + }).c('body').t(fallback_text).up() + .c('replace', {'id': first_rcvd_msg_id, 'xmlns': 'urn:xmpp:message-correct:0'}).up() + .c('origin-id', {'id': msg_id, 'xmlns': 'urn:xmpp:sid:0'}).up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload))); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.model.messages.length).toBe(2); + expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()) + .toBe('This is an edited encrypted message from the contact'); + + const message = view.model.messages.at(1); + older_versions = message.get('older_versions'); + keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('This is an encrypted message from the contact'); + expect(message.get('plaintext')).toBe('This is an edited encrypted message from the contact'); + expect(message.get('is_encrypted')).toBe(true); + expect(message.get('body')).toBe(fallback_text); + expect(message.get('message')).toBe(fallback_text); + expect(message.get('msgid')).toBe(first_rcvd_msg_id); + })); +}); + +describe("An OMEMO encrypted MUC message", function() { + + it("can be edited", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // MEMO encryption works only in members only conferences + // that are non-anonymous. + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + const nick = 'romeo'; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + const view = _converse.chatboxviews.get(muc_jid); + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + const omemo_toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); + omemo_toggle.click(); + expect(view.model.get('omemo_active')).toBe(true); + + // newguy enters the room + const contact_jid = 'newguy@montague.lit'; + let stanza = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // Wait for Converse to fetch newguy's device list + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + // The server returns his device list + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up() + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(2); + + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + const devicelist = _converse.devicelists.get(contact_jid); + expect(devicelist.devices.length).toBe(1); + expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + expect(view.model.get('omemo_active')).toBe(true); + + const original_text = 'This message will be encrypted'; + const textarea = view.querySelector('.chat-textarea'); + textarea.value = original_text; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000); + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up() + .c('signedPreKeySignature').t(btoa('2222')).up() + .c('identityKey').t(btoa('3333')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + + spyOn(_converse.connection, 'send').and.callThrough(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.connection.send.calls.count(), 1000); + const sent_stanza = _converse.connection.send.calls.all()[0].args[0]; + + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="lounge@montague.lit" `+ + `type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `<encrypted xmlns="eu.siacs.conversations.axolotl">`+ + `<header sid="123456789">`+ + `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+ + `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+ + `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+ + `</header>`+ + `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+ + `</encrypted>`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+ + `</message>`); + + await u.waitUntil(() => textarea.value === ''); + + const first_msg = view.model.messages.findWhere({'message': original_text}); + + message_form.onKeyDown({ + target: textarea, + keyCode: 38 // Up arrow + }); + expect(textarea.value).toBe(original_text); + expect(view.model.messages.at(0).get('correcting')).toBe(true); + + const new_text = 'This is an edit of the encrypted message'; + textarea.value = new_text; + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + await u.waitUntil(() => view.querySelector('.chat-msg__text').textContent.replace(/<!-.*?->/g, '') === new_text); + + const fallback_text = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo'; + let older_versions = first_msg.get('older_versions'); + let keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe(original_text); + expect(first_msg.get('plaintext')).toBe(new_text); + expect(first_msg.get('is_encrypted')).toBe(true); + expect(first_msg.get('body')).toBe(fallback_text); + expect(first_msg.get('message')).toBe(fallback_text); + + await u.waitUntil(() => _converse.connection.sent_stanzas.filter(s => s.nodeName === 'message').length === 2); + const msg = _converse.connection.sent_stanzas.pop(); + + expect(Strophe.serialize(msg)) + .toBe(`<message from="${_converse.jid}" id="${msg.getAttribute("id")}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+ + `<body>${fallback_text}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+ + `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+ + `<encrypted xmlns="eu.siacs.conversations.axolotl">`+ + `<header sid="123456789">`+ + `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+ + `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+ + `<iv>${msg.querySelector("iv").textContent}</iv>`+ + `</header>`+ + `<payload>${msg.querySelector("payload").textContent}</payload>`+ + `</encrypted>`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+ + `</message>`); + + + // Test reception of an encrypted message + const first_received_id = _converse.connection.getUniqueId() + const first_received_message = 'This is an encrypted message from the contact'; + const first_obj = await omemo.encryptMessage(first_received_message) + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': `${muc_jid}/newguy`, + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': first_received_id + }).c('body').t(fallback_text).up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(first_obj.key_and_tag)).up() + .c('iv').t(first_obj.iv) + .up().up() + .c('payload').t(first_obj.payload))); + + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.model.messages.length).toBe(2); + expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()).toBe(first_received_message); + expect(_converse.devicelists.length).toBe(2); + expect(_converse.devicelists.at(0).get('jid')).toBe(_converse.bare_jid); + expect(_converse.devicelists.at(1).get('jid')).toBe(contact_jid); + + const second_received_message = 'This is an edited encrypted message from the contact'; + const second_obj = await omemo.encryptMessage(second_received_message) + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': `${muc_jid}/newguy`, + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': _converse.connection.getUniqueId() + }).c('body').t(fallback_text).up() + .c('replace', {'id':first_received_id, 'xmlns': 'urn:xmpp:message-correct:0'}) + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(second_obj.key_and_tag)).up() + .c('iv').t(second_obj.iv) + .up().up() + .c('payload').t(second_obj.payload))); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + + expect(view.model.messages.length).toBe(2); + expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()).toBe(second_received_message); + + const message = view.model.messages.at(1); + older_versions = message.get('older_versions'); + keys = Object.keys(older_versions); + expect(keys.length).toBe(1); + expect(older_versions[keys[0]]).toBe('This is an encrypted message from the contact'); + expect(message.get('plaintext')).toBe('This is an edited encrypted message from the contact'); + expect(message.get('is_encrypted')).toBe(true); + expect(message.get('body')).toBe(fallback_text); + expect(message.get('msgid')).toBe(first_received_id); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js new file mode 100644 index 0000000..46b4b48 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/media-sharing.js @@ -0,0 +1,155 @@ +/*global mock, converse */ + +const { $iq, Strophe, u } = converse.env; + + +describe("The OMEMO module", function() { + + it("implements XEP-0454 to encrypt uploaded files", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const base_url = 'https://example.org/'; + await mock.waitUntilDiscoConfirmed( + _converse, _converse.domain, + [{'category': 'server', 'type':'IM'}], + ['http://jabber.org/protocol/disco#items'], [], 'info'); + + const send_backup = XMLHttpRequest.prototype.send; + const IQ_stanzas = _converse.connection.IQ_stanzas; + + await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items'); + await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []); + await mock.waitForRoster(_converse, 'current', 3); + const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + await mock.openChatBoxFor(_converse, contact_jid); + + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + let stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + const devicelist = _converse.devicelists.get({'jid': contact_jid}); + await u.waitUntil(() => devicelist.devices.length === 1); + + const view = _converse.chatboxviews.get(contact_jid); + const file = new File(['secret'], 'secret.txt', { type: 'text/plain' }) + view.model.set('omemo_active', true); + view.model.sendFiles([file]); + + await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[to="upload.montague.tld"] request')).length); + const iq = IQ_stanzas.pop(); + const url = base_url+"/secret.txt"; + stanza = u.toStanza(` + <iq from="upload.montague.tld" + id="${iq.getAttribute("id")}" + to="romeo@montague.lit/orchard" + type="result"> + <slot xmlns="urn:xmpp:http:upload:0"> + <put url="https://upload.montague.tld/4a771ac1-f0b2-4a4a-9700-f2a26fa2bb67/secret.txt"> + <header name="Authorization">Basic Base64String==</header> + <header name="Cookie">foo=bar; user=romeo</header> + </put> + <get url="${url}" /> + </slot> + </iq>`); + + spyOn(XMLHttpRequest.prototype, 'send').and.callFake(async function () { + const message = view.model.messages.at(0); + message.set('progress', 1); + await u.waitUntil(() => view.querySelector('.chat-content progress')?.getAttribute('value') === '1') + message.save({ + 'upload': _converse.SUCCESS, + 'oob_url': message.get('get'), + 'body': message.get('get') + }); + await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length); + }); + let sent_stanza; + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up() + .c('signedPreKeySignature').t(btoa('2222')).up() + .c('identityKey').t(btoa('3333')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + + spyOn(_converse.connection, 'send').and.callFake(stanza => (sent_stanza = stanza)); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => sent_stanza); + + const fallback = 'This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo'; + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="lady.montague@montague.lit" `+ + `type="chat" `+ + `xmlns="jabber:client">`+ + `<body>${fallback}</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `<encrypted xmlns="eu.siacs.conversations.axolotl">`+ + `<header sid="123456789">`+ + `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+ + `<key rid="555">YzFwaDNSNzNYNw==</key>`+ + `<iv>${sent_stanza.querySelector('header iv').textContent}</iv>`+ + `</header>`+ + `<payload>${sent_stanza.querySelector('payload').textContent}</payload>`+ + `</encrypted>`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+ + `</message>`); + + const link_el = await u.waitUntil(() => view.querySelector('.chat-msg__text')); + expect(link_el.textContent.trim()).toBe(url); + + const message = view.model.messages.at(0); + expect(message.get('is_encrypted')).toBe(true); + + XMLHttpRequest.prototype.send = send_backup; + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js new file mode 100644 index 0000000..65aa687 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/muc.js @@ -0,0 +1,478 @@ +/*global mock, converse */ + +const { $iq, $msg, $pres, Strophe, omemo } = converse.env; +const u = converse.env.utils; + +describe("The OMEMO module", function() { + + it("enables encrypted groupchat messages to be sent and received", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // MEMO encryption works only in members only conferences + // that are non-anonymous. + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + const muc_jid = 'lounge@montague.lit'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + const el = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); + el.click(); + expect(view.model.get('omemo_active')).toBe(true); + + // newguy enters the room + const contact_jid = 'newguy@montague.lit'; + let stanza = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // Wait for Converse to fetch newguy's device list + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + // The server returns his device list + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up() + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(2); + + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + const devicelist = _converse.devicelists.get(contact_jid); + expect(devicelist.devices.length).toBe(1); + expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + expect(view.model.get('omemo_active')).toBe(true); + + const icon = toolbar.querySelector('.toggle-omemo converse-icon'); + expect(u.hasClass('fa-unlock', icon)).toBe(false); + expect(u.hasClass('fa-lock', icon)).toBe(true); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'This message will be encrypted'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228'), 1000); + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up() + .c('signedPreKeySignature').t(btoa('2222')).up() + .c('identityKey').t(btoa('3333')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe'), 1000); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + + spyOn(_converse.connection, 'send'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.connection.send.calls.count(), 1000); + const sent_stanza = _converse.connection.send.calls.all()[0].args[0]; + + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" `+ + `id="${sent_stanza.getAttribute("id")}" `+ + `to="lounge@montague.lit" `+ + `type="groupchat" `+ + `xmlns="jabber:client">`+ + `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `<encrypted xmlns="eu.siacs.conversations.axolotl">`+ + `<header sid="123456789">`+ + `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+ + `<key rid="4e30f35051b7b8b42abe083742187228">YzFwaDNSNzNYNw==</key>`+ + `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+ + `</header>`+ + `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+ + `</encrypted>`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+ + `</message>`); + + // Test reception of an encrypted message + const obj = await omemo.encryptMessage('This is an encrypted message from the contact') + // XXX: Normally the key will be encrypted via libsignal. + // However, we're mocking libsignal in the tests, so we include it as plaintext in the message. + stanza = $msg({ + 'from': `${muc_jid}/newguy`, + 'to': _converse.connection.jid, + 'type': 'groupchat', + 'id': _converse.connection.getUniqueId() + }).c('body').t('This is a fallback message').up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.model.messages.length).toBe(2); + expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()) + .toBe('This is an encrypted message from the contact'); + + expect(_converse.devicelists.length).toBe(2); + expect(_converse.devicelists.at(0).get('jid')).toBe(_converse.bare_jid); + expect(_converse.devicelists.at(1).get('jid')).toBe(contact_jid); + })); + + it("gracefully handles auth errors when trying to send encrypted groupchat messages", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + // MEMO encryption works only in members only conferences + // that are non-anonymous. + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + const contact_jid = 'newguy@montague.lit'; + let stanza = $pres({ + 'to': 'romeo@montague.lit/orchard', + 'from': 'lounge@montague.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + const toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); + toggle.click(); + expect(view.model.get('omemo_active')).toBe(true); + expect(view.model.get('omemo_supported')).toBe(true); + + const textarea = await u.waitUntil(() => view.querySelector('.chat-textarea')); + textarea.value = 'This message will be encrypted'; + const message_form = view.querySelector('converse-muc-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up() + + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(2); + + const devicelist = _converse.devicelists.get(contact_jid); + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(devicelist.devices.length).toBe(1); + expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '4e30f35051b7b8b42abe083742187228')); + + /* <iq xmlns="jabber:client" to="jc@opkode.com/converse.js-34183907" type="error" id="945c8ab3-b561-4d8a-92da-77c226bb1689:sendIQ" from="joris@konuro.net"> + * <pubsub xmlns="http://jabber.org/protocol/pubsub"> + * <items node="eu.siacs.conversations.axolotl.bundles:7580"/> + * </pubsub> + * <error code="401" type="auth"> + * <presence-subscription-required xmlns="http://jabber.org/protocol/pubsub#errors"/> + * <not-authorized xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/> + * </error> + * </iq> + */ + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': 'http://jabber.org/protocol/pubsub'}) + .c('items', {'node': "eu.siacs.conversations.axolotl.bundles:4e30f35051b7b8b42abe083742187228"}).up().up() + .c('error', {'code': '401', 'type': 'auth'}) + .c('presence-subscription-required', {'xmlns':"http://jabber.org/protocol/pubsub#errors" }).up() + .c('not-authorized', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => document.querySelectorAll('.alert-danger').length, 2000); + const header = document.querySelector('.alert-danger .modal-title'); + expect(header.textContent).toBe("Error"); + expect(u.ancestor(header, '.modal-content').querySelector('.modal-body p').textContent.trim()) + .toBe("Sorry, we're unable to send an encrypted message because newguy@montague.lit requires you "+ + "to be subscribed to their presence in order to see their OMEMO information"); + + expect(view.model.get('omemo_supported')).toBe(false); + expect(view.querySelector('.chat-textarea').value).toBe('This message will be encrypted'); + })); + + + it("adds a toolbar button for starting an encrypted groupchat session", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + // MEMO encryption works only in members-only conferences that are non-anonymous. + const features = [ + 'http://jabber.org/protocol/muc', + 'jabber:iq:register', + 'muc_passwordprotected', + 'muc_hidden', + 'muc_temporary', + 'muc_membersonly', + 'muc_unmoderated', + 'muc_nonanonymous' + ]; + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo', features); + const view = _converse.chatboxviews.get('lounge@montague.lit'); + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + + const toolbar = await u.waitUntil(() => view.querySelector('.chat-toolbar')); + let toggle = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')); + expect(view.model.get('omemo_active')).toBe(undefined); + expect(view.model.get('omemo_supported')).toBe(true); + await u.waitUntil(() => toggle.dataset.disabled === "false"); + + let icon = toolbar.querySelector('.toggle-omemo converse-icon'); + expect(u.hasClass('fa-unlock', icon)).toBe(true); + expect(u.hasClass('fa-lock', icon)).toBe(false); + + toggle.click(); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(toggle.dataset.disabled).toBe("false"); + expect(view.model.get('omemo_active')).toBe(true); + expect(view.model.get('omemo_supported')).toBe(true); + + await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))); + expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true); + + let contact_jid = 'newguy@montague.lit'; + let stanza = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'lounge@montague.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up() + .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(2); + + await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + const devicelist = _converse.devicelists.get(contact_jid); + expect(devicelist.devices.length).toBe(2); + expect(devicelist.devices.at(0).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + expect(devicelist.devices.at(1).get('id')).toBe('ae890ac52d0df67ed7cfdf51b644e901'); + + expect(view.model.get('omemo_active')).toBe(true); + toggle = toolbar.querySelector('.toggle-omemo'); + expect(toggle === null).toBe(false); + expect(toggle.dataset.disabled).toBe("false"); + expect(view.model.get('omemo_supported')).toBe(true); + + await u.waitUntil(() => !u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))); + expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true); + + // Test that the button gets disabled when the room becomes + // anonymous or semi-anonymous + view.model.features.save({'nonanonymous': false, 'semianonymous': true}); + await u.waitUntil(() => !view.model.get('omemo_supported')); + await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "true"); + + view.model.features.save({'nonanonymous': true, 'semianonymous': false}); + await u.waitUntil(() => view.model.get('omemo_supported')); + await u.waitUntil(() => view.querySelector('.toggle-omemo') !== null); + expect(u.hasClass('fa-unlock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(true); + expect(u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))).toBe(false); + expect(view.querySelector('.toggle-omemo').dataset.disabled).toBe("false"); + + // Test that the button gets disabled when the room becomes open + view.model.features.save({'membersonly': false, 'open': true}); + await u.waitUntil(() => !view.model.get('omemo_supported')); + await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "true"); + + view.model.features.save({'membersonly': true, 'open': false}); + await u.waitUntil(() => view.model.get('omemo_supported')); + await u.waitUntil(() => view.querySelector('.toggle-omemo').dataset.disabled === "false"); + + expect(u.hasClass('fa-unlock', view.querySelector('.toggle-omemo converse-icon'))).toBe(true); + expect(u.hasClass('fa-lock', view.querySelector('.toggle-omemo converse-icon'))).toBe(false); + + expect(view.model.get('omemo_supported')).toBe(true); + expect(view.model.get('omemo_active')).toBe(false); + + view.querySelector('.toggle-omemo').click(); + expect(view.model.get('omemo_active')).toBe(true); + + // Someone enters the room who doesn't have OMEMO support, while we + // have OMEMO activated... + contact_jid = 'oldguy@montague.lit'; + stanza = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'lounge@montague.lit/oldguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': `${contact_jid}/_converse.js-290929788`, + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'error' + }).c('error', {'type': 'cancel'}) + .c('item-not-found', {'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas"}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => !view.model.get('omemo_supported')); + await u.waitUntil(() => view.querySelector('.chat-error .chat-info__message')?.textContent.trim() === + "oldguy doesn't appear to have a client that supports OMEMO. "+ + "Encrypted chat will no longer be possible in this grouchat." + ); + + await u.waitUntil(() => toolbar.querySelector('.toggle-omemo').dataset.disabled === "true"); + icon = view.querySelector('.toggle-omemo converse-icon'); + expect(u.hasClass('fa-unlock', icon)).toBe(true); + expect(u.hasClass('fa-lock', icon)).toBe(false); + expect(toolbar.querySelector('.toggle-omemo').title).toBe('This groupchat needs to be members-only and non-anonymous in order to support OMEMO encrypted messages'); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js new file mode 100644 index 0000000..0ed7138 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/tests/omemo.js @@ -0,0 +1,1130 @@ +/*global mock, converse */ + +const { $iq, $msg, omemo, Strophe } = converse.env; +const u = converse.env.utils; + +describe("The OMEMO module", function() { + + it("adds methods for encrypting and decrypting messages via AES GCM", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + const message = 'This message will be encrypted' + await mock.waitForRoster(_converse, 'current', 1); + const payload = await omemo.encryptMessage(message); + const result = await omemo.decryptMessage(payload); + expect(result).toBe(message); + })); + + it("enables encrypted messages to be sent and received", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + let sent_stanza; + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.initializedOMEMO(_converse); + await mock.openChatBoxFor(_converse, contact_jid); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + let stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + const devicelist = _converse.devicelists.get({'jid': contact_jid}); + await u.waitUntil(() => devicelist.devices.length === 1); + + const view = _converse.chatboxviews.get(contact_jid); + view.model.set('omemo_active', true); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'This message will be encrypted'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up() + .c('signedPreKeySignature').t(btoa('2222')).up() + .c('identityKey').t(btoa('3333')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '482886413b977930064a5888b92134fe')); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:482886413b977930064a5888b92134fe"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('100000')).up() + .c('signedPreKeySignature').t(btoa('200000')).up() + .c('identityKey').t(btoa('300000')).up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1991')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1992')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1993')); + + spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => sent_stanza); + expect(Strophe.serialize(sent_stanza)).toBe( + `<message from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute("id")}" `+ + `to="mercutio@montague.lit" `+ + `type="chat" xmlns="jabber:client">`+ + `<body>This is an OMEMO encrypted message which your client doesn’t seem to support. Find more information on https://conversations.im/omemo</body>`+ + `<active xmlns="http://jabber.org/protocol/chatstates"/>`+ + `<request xmlns="urn:xmpp:receipts"/>`+ + `<origin-id id="${sent_stanza.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+ + `<encrypted xmlns="eu.siacs.conversations.axolotl">`+ + `<header sid="123456789">`+ + `<key rid="482886413b977930064a5888b92134fe">YzFwaDNSNzNYNw==</key>`+ + `<key rid="555">YzFwaDNSNzNYNw==</key>`+ + `<iv>${sent_stanza.querySelector("iv").textContent}</iv>`+ + `</header>`+ + `<payload>${sent_stanza.querySelector("payload").textContent}</payload>`+ + `</encrypted>`+ + `<store xmlns="urn:xmpp:hints"/>`+ + `<encryption namespace="eu.siacs.conversations.axolotl" xmlns="urn:xmpp:eme:0"/>`+ + `</message>`); + + // Test reception of an encrypted message + let obj = await omemo.encryptMessage('This is an encrypted message from the contact') + // XXX: Normally the key will be encrypted via libsignal. + // However, we're mocking libsignal in the tests, so we include it as plaintext in the message. + stanza = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': _converse.connection.getUniqueId() + }).c('body').t('This is a fallback message').up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.model.messages.length).toBe(2); + expect(view.querySelectorAll('.chat-msg__body')[1].textContent.trim()) + .toBe('This is an encrypted message from the contact'); + + // #1193 Check for a received message without <body> tag + obj = await omemo.encryptMessage('Another received encrypted message without fallback') + stanza = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': _converse.connection.getUniqueId() + }).c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}).t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + await u.waitUntil(() => view.model.messages.length > 1); + expect(view.model.messages.length).toBe(3); + expect(view.querySelectorAll('.chat-msg__body')[2].textContent.trim()) + .toBe('Another received encrypted message without fallback'); + })); + + it("properly handles an already decrypted message being received 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.initializedOMEMO(_converse); + await mock.openChatBoxFor(_converse, contact_jid); + const iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + let stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => _converse.omemo_store); + + const view = _converse.chatboxviews.get(contact_jid); + view.model.set('omemo_active', true); + + // Test reception of an encrypted message + const msg_txt = 'This is an encrypted message from the contact'; + const obj = await omemo.encryptMessage(msg_txt) + const id = _converse.connection.getUniqueId(); + stanza = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + id + }).c('body').t('This is a fallback message').up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}) + .t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + // Test reception of the same message, but the decryption fails. + // The properly decrypted message should still show to the user. + // See issue: https://github.com/conversejs/converse.js/issues/2733#issuecomment-1035493594 + stanza = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + id + }).c('body').t('This is a fallback message').up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', {'rid': _converse.omemo_store.get('device_id')}) + .t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload+'x'); // Hack to break decryption. + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => view.querySelector('.chat-msg__text')?.textContent.trim() === msg_txt); + + expect(view.model.messages.length).toBe(1); + const msg = view.model.messages.at(0); + expect(msg.get('is_ephemeral')).toBe(false) + expect(msg.getDisplayName()).toBe('Mercutio'); + expect(msg.get('is_error')).toBe(false); + })); + + it("will create a new device based on a received carbon message", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed(_converse, _converse.bare_jid, [], [Strophe.NS.SID]); + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + await mock.openChatBoxFor(_converse, contact_jid); + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + const my_devicelist = _converse.devicelists.get({'jid': _converse.bare_jid}); + expect(my_devicelist.devices.length).toBe(2); + + const stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + + const contact_devicelist = _converse.devicelists.get({'jid': contact_jid}); + await u.waitUntil(() => contact_devicelist.devices.length === 1); + + const view = _converse.chatboxviews.get(contact_jid); + view.model.set('omemo_active', true); + + // Test reception of an encrypted carbon message + const obj = await omemo.encryptMessage('This is an encrypted carbon message from another device of mine') + const carbon = u.toStanza(` + <message xmlns="jabber:client" to="romeo@montague.lit/orchard" from="romeo@montague.lit" type="chat"> + <sent xmlns="urn:xmpp:carbons:2"> + <forwarded xmlns="urn:xmpp:forward:0"> + <message xmlns="jabber:client" + from="romeo@montague.lit/gajim.HE02SW1L" + xml:lang="en" + to="${contact_jid}/gajim.0LATM5V2" + type="chat" id="87141781-61d6-4eb3-9a31-429935a61b76"> + + <archived xmlns="urn:xmpp:mam:tmp" by="romeo@montague.lit" id="1554033877043470"/> + <stanza-id xmlns="urn:xmpp:sid:0" by="romeo@montague.lit" id="1554033877043470"/> + <request xmlns="urn:xmpp:receipts"/> + <active xmlns="http://jabber.org/protocol/chatstates"/> + <origin-id xmlns="urn:xmpp:sid:0" id="87141781-61d6-4eb3-9a31-429935a61b76"/> + <encrypted xmlns="eu.siacs.conversations.axolotl"> + <header sid="988349631"> + <key rid="${_converse.omemo_store.get('device_id')}" + prekey="true">${u.arrayBufferToBase64(obj.key_and_tag)}</key> + <iv>${obj.iv}</iv> + </header> + <payload>${obj.payload}</payload> + </encrypted> + <encryption xmlns="urn:xmpp:eme:0" namespace="eu.siacs.conversations.axolotl" name="OMEMO"/> + <store xmlns="urn:xmpp:hints"/> + </message> + </forwarded> + </sent> + </message> + `); + _converse.connection.IQ_stanzas = []; + _converse.connection._dataRecv(mock.createRequest(carbon)); + + // The message received is a prekey message, so missing prekeys are + // generated and a new bundle published. + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); + const result_iq = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(result_iq)); + + await new Promise(resolve => view.model.messages.once('rendered', resolve)); + expect(view.model.messages.length).toBe(1); + + expect(view.querySelector('.chat-msg__text').textContent.trim()) + .toBe('This is an encrypted carbon message from another device of mine'); + + expect(contact_devicelist.devices.length).toBe(1); + + // Check that the new device id has been added to my devices + expect(my_devicelist.devices.length).toBe(3); + expect(my_devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe'); + expect(my_devicelist.devices.at(1).get('id')).toBe('123456789'); + expect(my_devicelist.devices.at(2).get('id')).toBe('988349631'); + expect(my_devicelist.devices.get('988349631').get('active')).toBe(true); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'This is an encrypted message from this device'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 // Enter + }); + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, _converse.bare_jid, '988349631')); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${_converse.bare_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.bundles:988349631"/>`+ + `</pubsub>`+ + `</iq>`); + })); + + it("can receive a PreKeySignalMessage", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + _converse.NUM_PREKEYS = 5; // Restrict to 5, otherwise the resulting stanza is too large to easily test + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + await u.waitUntil(() => mock.initializedOMEMO(_converse)); + const obj = await omemo.encryptMessage('This is an encrypted message from the contact'); + // XXX: Normally the key will be encrypted via libsignal. + // However, we're mocking libsignal in the tests, so we include + // it as plaintext in the message. + let stanza = $msg({ + 'from': contact_jid, + 'to': _converse.connection.jid, + 'type': 'chat', + 'id': 'qwerty' + }).c('body').t('This is a fallback message').up() + .c('encrypted', {'xmlns': Strophe.NS.OMEMO}) + .c('header', {'sid': '555'}) + .c('key', { + 'prekey': 'true', + 'rid': _converse.omemo_store.get('device_id') + }).t(u.arrayBufferToBase64(obj.key_and_tag)).up() + .c('iv').t(obj.iv) + .up().up() + .c('payload').t(obj.payload); + + const generateMissingPreKeys = _converse.omemo_store.generateMissingPreKeys; + spyOn(_converse.omemo_store, 'generateMissingPreKeys').and.callFake(() => { + // Since it's difficult to override + // decryptPreKeyWhisperMessage, where a prekey will be + // removed from the store, we do it here, before the + // missing prekeys are generated. + _converse.omemo_store.removePreKey(1); + return generateMissingPreKeys.apply(_converse.omemo_store, arguments); + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + let iq_stanza = await mock.deviceListFetched(_converse, contact_jid); + stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + + // XXX: the bundle gets published twice, we want to make sure + // that we wait for the 2nd, so we clear all the already sent + // stanzas. + _converse.connection.IQ_stanzas = []; + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse), 1000); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<publish node="eu.siacs.conversations.axolotl.bundles:123456789">`+ + `<item>`+ + `<bundle xmlns="eu.siacs.conversations.axolotl">`+ + `<signedPreKeyPublic signedPreKeyId="0">${btoa("1234")}</signedPreKeyPublic>`+ + `<signedPreKeySignature>${btoa("11112222333344445555")}</signedPreKeySignature>`+ + `<identityKey>${btoa("1234")}</identityKey>`+ + `<prekeys>`+ + `<preKeyPublic preKeyId="0">${btoa("1234")}</preKeyPublic>`+ + `<preKeyPublic preKeyId="1">${btoa("1234")}</preKeyPublic>`+ + `<preKeyPublic preKeyId="2">${btoa("1234")}</preKeyPublic>`+ + `<preKeyPublic preKeyId="3">${btoa("1234")}</preKeyPublic>`+ + `<preKeyPublic preKeyId="4">${btoa("1234")}</preKeyPublic>`+ + `</prekeys>`+ + `</bundle>`+ + `</item>`+ + `</publish>`+ + `<publish-options>`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+ + `</field>`+ + `<field var="pubsub#access_model">`+ + `<value>open</value>`+ + `</field>`+ + `</x>`+ + `</publish-options>`+ + `</pubsub>`+ + `</iq>`) + const own_device = _converse.devicelists.get(_converse.bare_jid).devices.get(_converse.omemo_store.get('device_id')); + expect(own_device.get('bundle').prekeys.length).toBe(5); + expect(_converse.omemo_store.generateMissingPreKeys).toHaveBeenCalled(); + })); + + it("updates device lists based on PEP messages", + mock.initConverse([], {'allow_non_roster_messaging': true}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + // Wait until own devices are fetched + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + let stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.chatboxes.length).toBe(1); + expect(_converse.devicelists.length).toBe(1); + const devicelist = _converse.devicelists.get(_converse.bare_jid); + expect(devicelist.devices.length).toBe(2); + expect(devicelist.devices.at(0).get('id')).toBe('555'); + expect(devicelist.devices.at(1).get('id')).toBe('123456789'); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); + + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await _converse.api.waitUntil('OMEMOInitialized'); + + + // A PEP message is received with a device list. + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': contact_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_01', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'}) + .c('item') + .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('device', {'id': '1234'}).up() + .c('device', {'id': '4223'}) + )); + + // Since we haven't yet fetched any devices for this user, the + // devicelist model for them isn't yet initialized. + // It will be created and then automatically the devices will + // be requested from the server via IQ stanza. + // + // This is perhaps a bit wasteful since we're already (AFIAK) getting the info we need + // from the PEP headline message, but the code is simpler this way. + const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + _converse.connection._dataRecv(mock.createRequest($iq({ + 'from': contact_jid, + 'id': iq_devicelist_get.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '1234'}).up() + .c('device', {'id': '4223'}) + )); + + await u.waitUntil(() => _converse.devicelists.length === 2); + + const list = _converse.devicelists.get(contact_jid); + await list.initialized; + await u.waitUntil(() => list.devices.length === 2); + + let devices = list.devices; + expect(list.devices.length).toBe(2); + expect(list.devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223'); + + stanza = $msg({ + 'from': contact_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_02', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'}) + .c('item') + .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('device', {'id': '4223'}).up() + .c('device', {'id': '4224'}) + _converse.connection._dataRecv(mock.createRequest(stanza)); + + expect(_converse.devicelists.length).toBe(2); + await u.waitUntil(() => list.devices.length === 3); + expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('1234,4223,4224'); + expect(devices.get('1234').get('active')).toBe(false); + expect(devices.get('4223').get('active')).toBe(true); + expect(devices.get('4224').get('active')).toBe(true); + + // Check that own devicelist gets updated + stanza = $msg({ + 'from': _converse.bare_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_03', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'}) + .c('item') + .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('device', {'id': '123456789'}) + .c('device', {'id': '555'}) + .c('device', {'id': '777'}) + _converse.connection._dataRecv(mock.createRequest(stanza)); + + expect(_converse.devicelists.length).toBe(2); + devices = _converse.devicelists.get(_converse.bare_jid).devices; + await u.waitUntil(() => devices.length === 3); + expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('123456789,555,777'); + expect(devices.get('123456789').get('active')).toBe(true); + expect(devices.get('555').get('active')).toBe(true); + expect(devices.get('777').get('active')).toBe(true); + + _converse.connection.IQ_stanzas = []; + + // Check that own device gets re-added + stanza = $msg({ + 'from': _converse.bare_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_04', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.devicelist'}) + .c('item') + .c('list', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('device', {'id': '444'}) + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); + // Check that our own device is added again, but that removed + // devices are not added. + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute(`id`)}" type="set" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<publish node="eu.siacs.conversations.axolotl.devicelist">`+ + `<item id="current">`+ + `<list xmlns="eu.siacs.conversations.axolotl">`+ + `<device id="123456789"/>`+ + `<device id="444"/>`+ + `</list>`+ + `</item>`+ + `</publish>`+ + `<publish-options>`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+ + `</field>`+ + `<field var="pubsub#access_model">`+ + `<value>open</value>`+ + `</field>`+ + `</x>`+ + `</publish-options>`+ + `</pubsub>`+ + `</iq>`); + expect(_converse.devicelists.length).toBe(2); + devices = _converse.devicelists.get(_converse.bare_jid).devices; + // The device id for this device (123456789) was also generated and added to the list, + // which is why we have 2 devices now. + expect(devices.length).toBe(4); + expect(devices.models.map(d => d.attributes.id).sort().join()).toBe('123456789,444,555,777'); + expect(devices.get('123456789').get('active')).toBe(true); + expect(devices.get('444').get('active')).toBe(true); + expect(devices.get('555').get('active')).toBe(false); + expect(devices.get('777').get('active')).toBe(false); + })); + + + it("updates device bundles based on PEP messages", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current'); + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + const contact_jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + _converse.connection._dataRecv(mock.createRequest($iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}) + )); + + await await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(1); + const own_device_list = _converse.devicelists.get(_converse.bare_jid); + expect(own_device_list.devices.length).toBe(2); + expect(own_device_list.devices.at(0).get('id')).toBe('555'); + expect(own_device_list.devices.at(1).get('id')).toBe('123456789'); + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); + let stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await _converse.api.waitUntil('OMEMOInitialized'); + + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': contact_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_01', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:1234'}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t('1111').up() + .c('signedPreKeySignature').t('2222').up() + .c('identityKey').t('3333').up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1001'}).up() + .c('preKeyPublic', {'preKeyId': '1002'}).up() + .c('preKeyPublic', {'preKeyId': '1003'}) + )); + + // Since we haven't yet fetched any devices for this user, the + // devicelist model for them isn't yet initialized. + // It will be created and then automatically the devices will + // be requested from the server via IQ stanza. + const iq_devicelist_get = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + _converse.connection._dataRecv(mock.createRequest($iq({ + 'from': contact_jid, + 'id': iq_devicelist_get.getAttribute('id'), + 'to': _converse.connection.jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '1234'}) + )); + + await u.waitUntil(() => _converse.devicelists.length === 2); + const list = _converse.devicelists.get(contact_jid); + await list.initialized; + await u.waitUntil(() => list.devices.length); + let device = list.devices.at(0); + expect(device.get('bundle').identity_key).toBe('3333'); + expect(device.get('bundle').signed_prekey.public_key).toBe('1111'); + expect(device.get('bundle').signed_prekey.id).toBe(4223); + expect(device.get('bundle').signed_prekey.signature).toBe('2222'); + expect(device.get('bundle').prekeys.length).toBe(3); + expect(device.get('bundle').prekeys[0].id).toBe(1001); + expect(device.get('bundle').prekeys[1].id).toBe(1002); + expect(device.get('bundle').prekeys[2].id).toBe(1003); + + stanza = $msg({ + 'from': contact_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_02', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:1234'}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t('5555').up() + .c('signedPreKeySignature').t('6666').up() + .c('identityKey').t('7777').up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '2001'}).up() + .c('preKeyPublic', {'preKeyId': '2002'}).up() + .c('preKeyPublic', {'preKeyId': '2003'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + expect(_converse.devicelists.length).toBe(2); + expect(list.devices.length).toBe(1); + device = list.devices.at(0); + + await u.waitUntil(() => device.get('bundle').identity_key === '7777'); + expect(device.get('bundle').signed_prekey.public_key).toBe('5555'); + expect(device.get('bundle').signed_prekey.id).toBe(4223); + expect(device.get('bundle').signed_prekey.signature).toBe('6666'); + expect(device.get('bundle').prekeys.length).toBe(3); + expect(device.get('bundle').prekeys[0].id).toBe(2001); + expect(device.get('bundle').prekeys[1].id).toBe(2002); + expect(device.get('bundle').prekeys[2].id).toBe(2003); + + _converse.connection._dataRecv(mock.createRequest($msg({ + 'from': _converse.bare_jid, + 'to': _converse.bare_jid, + 'type': 'headline', + 'id': 'update_03', + }).c('event', {'xmlns': 'http://jabber.org/protocol/pubsub#event'}) + .c('items', {'node': 'eu.siacs.conversations.axolotl.bundles:555'}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '9999'}).t('8888').up() + .c('signedPreKeySignature').t('3333').up() + .c('identityKey').t('1111').up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '3001'}).up() + .c('preKeyPublic', {'preKeyId': '3002'}).up() + .c('preKeyPublic', {'preKeyId': '3003'}) + )); + + expect(_converse.devicelists.length).toBe(2); + expect(own_device_list.devices.length).toBe(2); + expect(own_device_list.devices.at(0).get('id')).toBe('555'); + expect(own_device_list.devices.at(1).get('id')).toBe('123456789'); + device = own_device_list.devices.at(0); + await u.waitUntil(() => device.get('bundle')?.identity_key === '1111'); + expect(device.get('bundle').signed_prekey.public_key).toBe('8888'); + expect(device.get('bundle').signed_prekey.id).toBe(9999); + expect(device.get('bundle').signed_prekey.signature).toBe('3333'); + expect(device.get('bundle').prekeys.length).toBe(3); + expect(device.get('bundle').prekeys[0].id).toBe(3001); + expect(device.get('bundle').prekeys[1].id).toBe(3002); + expect(device.get('bundle').prekeys[2].id).toBe(3003); + })); + + it("publishes a bundle with which an encrypted session can be created", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + _converse.NUM_PREKEYS = 2; // Restrict to 2, otherwise the resulting stanza is too large to easily test + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); + let stanza = $iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '482886413b977930064a5888b92134fe'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(_converse.devicelists.length).toBe(1); + await mock.openChatBoxFor(_converse, contact_jid); + iq_stanza = await mock.ownDeviceHasBeenPublished(_converse); + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + iq_stanza = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" type="set" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<publish node="eu.siacs.conversations.axolotl.bundles:123456789">`+ + `<item>`+ + `<bundle xmlns="eu.siacs.conversations.axolotl">`+ + `<signedPreKeyPublic signedPreKeyId="0">${btoa("1234")}</signedPreKeyPublic>`+ + `<signedPreKeySignature>${btoa("11112222333344445555")}</signedPreKeySignature>`+ + `<identityKey>${btoa("1234")}</identityKey>`+ + `<prekeys>`+ + `<preKeyPublic preKeyId="0">${btoa("1234")}</preKeyPublic>`+ + `<preKeyPublic preKeyId="1">${btoa("1234")}</preKeyPublic>`+ + `</prekeys>`+ + `</bundle>`+ + `</item>`+ + `</publish>`+ + `<publish-options>`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+ + `</field>`+ + `<field var="pubsub#access_model">`+ + `<value>open</value>`+ + `</field>`+ + `</x>`+ + `</publish-options>`+ + `</pubsub>`+ + `</iq>`) + + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await _converse.api.waitUntil('OMEMOInitialized'); + })); + + + it("adds a toolbar button for starting an encrypted chat session", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + await mock.waitForRoster(_converse, 'current', 1); + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, _converse.bare_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="romeo@montague.lit" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + let stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '482886413b977930064a5888b92134fe'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => _converse.omemo_store); + expect(_converse.devicelists.length).toBe(1); + let devicelist = _converse.devicelists.get(_converse.bare_jid); + expect(devicelist.devices.length).toBe(2); + expect(devicelist.devices.at(0).get('id')).toBe('482886413b977930064a5888b92134fe'); + expect(devicelist.devices.at(1).get('id')).toBe('123456789'); + // Check that own device was published + iq_stanza = await u.waitUntil(() => mock.ownDeviceHasBeenPublished(_converse)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute(`id`)}" type="set" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<publish node="eu.siacs.conversations.axolotl.devicelist">`+ + `<item id="current">`+ + `<list xmlns="eu.siacs.conversations.axolotl">`+ + `<device id="482886413b977930064a5888b92134fe"/>`+ + `<device id="123456789"/>`+ + `</list>`+ + `</item>`+ + `</publish>`+ + `<publish-options>`+ + `<x type="submit" xmlns="jabber:x:data">`+ + `<field type="hidden" var="FORM_TYPE">`+ + `<value>http://jabber.org/protocol/pubsub#publish-options</value>`+ + `</field>`+ + `<field var="pubsub#access_model">`+ + `<value>open</value>`+ + `</field>`+ + `</x>`+ + `</publish-options>`+ + `</pubsub>`+ + `</iq>`); + + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + const iq_el = await u.waitUntil(() => mock.bundleHasBeenPublished(_converse)); + expect(iq_el.getAttributeNames().sort().join()).toBe(["from", "type", "xmlns", "id"].sort().join()); + expect(iq_el.querySelector('prekeys').childNodes.length).toBe(100); + + const signed_prekeys = iq_el.querySelectorAll('signedPreKeyPublic'); + expect(signed_prekeys.length).toBe(1); + const signed_prekey = signed_prekeys[0]; + expect(signed_prekey.getAttribute('signedPreKeyId')).toBe('0') + expect(iq_el.querySelectorAll('signedPreKeySignature').length).toBe(1); + expect(iq_el.querySelectorAll('identityKey').length).toBe(1); + + stanza = $iq({ + 'from': _converse.bare_jid, + 'id': iq_el.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await _converse.api.waitUntil('OMEMOInitialized', 1000); + await mock.openChatBoxFor(_converse, contact_jid); + + iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="${contact_jid}" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.devicelist"/>`+ + `</pubsub>`+ + `</iq>`); + + _converse.connection._dataRecv(mock.createRequest($iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '368866411b877c30064a5f62b917cffe'}).up() + .c('device', {'id': '3300659945416e274474e469a1f0154c'}).up() + .c('device', {'id': '4e30f35051b7b8b42abe083742187228'}).up() + .c('device', {'id': 'ae890ac52d0df67ed7cfdf51b644e901'}) + )); + + devicelist = _converse.devicelists.get(contact_jid); + await u.waitUntil(() => devicelist.devices.length); + expect(_converse.devicelists.length).toBe(2); + devicelist = _converse.devicelists.get(contact_jid); + expect(devicelist.devices.length).toBe(4); + expect(devicelist.devices.at(0).get('id')).toBe('368866411b877c30064a5f62b917cffe'); + expect(devicelist.devices.at(1).get('id')).toBe('3300659945416e274474e469a1f0154c'); + expect(devicelist.devices.at(2).get('id')).toBe('4e30f35051b7b8b42abe083742187228'); + expect(devicelist.devices.at(3).get('id')).toBe('ae890ac52d0df67ed7cfdf51b644e901'); + await u.waitUntil(() => _converse.chatboxviews.get(contact_jid).querySelector('.chat-toolbar')); + const view = _converse.chatboxviews.get(contact_jid); + const toolbar = view.querySelector('.chat-toolbar'); + expect(view.model.get('omemo_active')).toBe(undefined); + const toggle = toolbar.querySelector('.toggle-omemo'); + expect(toggle === null).toBe(false); + expect(u.hasClass('fa-unlock', toggle.querySelector('converse-icon'))).toBe(true); + expect(u.hasClass('fa-lock', toggle.querySelector('.converse-icon'))).toBe(false); + toolbar.querySelector('.toggle-omemo').click(); + expect(view.model.get('omemo_active')).toBe(true); + + await u.waitUntil(() => u.hasClass('fa-lock', toolbar.querySelector('.toggle-omemo converse-icon'))); + let icon = toolbar.querySelector('.toggle-omemo converse-icon'); + expect(u.hasClass('fa-unlock', icon)).toBe(false); + expect(u.hasClass('fa-lock', icon)).toBe(true); + + const textarea = view.querySelector('.chat-textarea'); + textarea.value = 'This message will be sent encrypted'; + const message_form = view.querySelector('converse-message-form'); + message_form.onKeyDown({ + target: textarea, + preventDefault: function preventDefault () {}, + keyCode: 13 + }); + + view.model.save({'omemo_supported': false}); + await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')?.dataset.disabled === "true"); + icon = await u.waitUntil(() => toolbar.querySelector('.toggle-omemo converse-icon')); + expect(u.hasClass('fa-lock', icon)).toBe(false); + expect(u.hasClass('fa-unlock', icon)).toBe(true); + + view.model.save({'omemo_supported': true}); + await u.waitUntil(() => toolbar.querySelector('.toggle-omemo')?.dataset.disabled === "false"); + icon = toolbar.querySelector('.toggle-omemo converse-icon'); + expect(u.hasClass('fa-lock', icon)).toBe(false); + expect(u.hasClass('fa-unlock', icon)).toBe(true); + })); + + it("shows OMEMO device fingerprints in the user details modal", + mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type': 'pep'}], + ['http://jabber.org/protocol/pubsub#publish-options'] + ); + + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.openChatBoxFor(_converse, contact_jid) + // We simply emit, to avoid doing all the setup work + _converse.api.trigger('OMEMOInitialized'); + + const view = _converse.chatboxviews.get(contact_jid); + const show_modal_button = view.querySelector('.show-user-details-modal'); + show_modal_button.click(); + const modal = _converse.api.modal.get('converse-user-details-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + let iq_stanza = await u.waitUntil(() => mock.deviceListFetched(_converse, contact_jid)); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub"><items node="eu.siacs.conversations.axolotl.devicelist"/></pubsub>`+ + `</iq>`); + + _converse.connection._dataRecv(mock.createRequest($iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', {'xmlns': "http://jabber.org/protocol/pubsub"}) + .c('items', {'node': "eu.siacs.conversations.axolotl.devicelist"}) + .c('item', {'xmlns': "http://jabber.org/protocol/pubsub"}) // TODO: must have an id attribute + .c('list', {'xmlns': "eu.siacs.conversations.axolotl"}) + .c('device', {'id': '555'}) + )); + + await u.waitUntil(() => u.isVisible(modal), 1000); + + iq_stanza = await u.waitUntil(() => mock.bundleFetched(_converse, contact_jid, '555')); + expect(Strophe.serialize(iq_stanza)).toBe( + `<iq from="romeo@montague.lit" id="${iq_stanza.getAttribute("id")}" to="mercutio@montague.lit" type="get" xmlns="jabber:client">`+ + `<pubsub xmlns="http://jabber.org/protocol/pubsub">`+ + `<items node="eu.siacs.conversations.axolotl.bundles:555"/>`+ + `</pubsub>`+ + `</iq>`); + + _converse.connection._dataRecv(mock.createRequest($iq({ + 'from': contact_jid, + 'id': iq_stanza.getAttribute('id'), + 'to': _converse.bare_jid, + 'type': 'result', + }).c('pubsub', { + 'xmlns': 'http://jabber.org/protocol/pubsub' + }).c('items', {'node': "eu.siacs.conversations.axolotl.bundles:555"}) + .c('item') + .c('bundle', {'xmlns': 'eu.siacs.conversations.axolotl'}) + .c('signedPreKeyPublic', {'signedPreKeyId': '4223'}).t(btoa('1111')).up() + .c('signedPreKeySignature').t(btoa('2222')).up() + .c('identityKey').t('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI').up() + .c('prekeys') + .c('preKeyPublic', {'preKeyId': '1'}).t(btoa('1001')).up() + .c('preKeyPublic', {'preKeyId': '2'}).t(btoa('1002')).up() + .c('preKeyPublic', {'preKeyId': '3'}).t(btoa('1003')) + )); + + await u.waitUntil(() => modal.querySelectorAll('.fingerprints .fingerprint').length); + expect(modal.querySelectorAll('.fingerprints .fingerprint').length).toBe(1); + const el = modal.querySelector('.fingerprints .fingerprint'); + expect(el.textContent.trim()).toBe( + omemo.formatFingerprint(u.arrayBufferToHex(u.base64ToArrayBuffer('BQmHEOHjsYm3w5M8VqxAtqJmLCi7CaxxsdZz6G0YpuMI'))) + ); + expect(modal.querySelectorAll('input[type="radio"]').length).toBe(2); + + const devicelist = _converse.devicelists.get(contact_jid); + expect(devicelist.devices.get('555').get('trusted')).toBe(0); + + let trusted_radio = modal.querySelector('input[type="radio"][name="555"][value="1"]'); + expect(trusted_radio.checked).toBe(true); + + let untrusted_radio = modal.querySelector('input[type="radio"][name="555"][value="-1"]'); + expect(untrusted_radio.checked).toBe(false); + + // Test that the device can be set to untrusted + untrusted_radio.click(); + trusted_radio = document.querySelector('input[type="radio"][name="555"][value="1"]'); + + await u.waitUntil(() => !trusted_radio.hasAttribute('checked')); + expect(devicelist.devices.get('555').get('trusted')).toBe(-1); + + untrusted_radio = document.querySelector('input[type="radio"][name="555"][value="-1"]'); + expect(untrusted_radio.hasAttribute('checked')).toBe(true); + + trusted_radio.click(); + expect(devicelist.devices.get('555').get('trusted')).toBe(1); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js new file mode 100644 index 0000000..0605758 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/omemo/utils.js @@ -0,0 +1,866 @@ +/* global libsignal */ +import concat from 'lodash-es/concat'; +import difference from 'lodash-es/difference'; +import log from '@converse/headless/log'; +import tplAudio from 'templates/audio.js'; +import tplFile from 'templates/file.js'; +import tplImage from 'templates/image.js'; +import tplVideo from 'templates/video.js'; +import { KEY_ALGO, UNTRUSTED, TAG_LENGTH } from './consts.js'; +import { MIMETYPES_MAP } from 'utils/file.js'; +import { __ } from 'i18n'; +import { _converse, converse, api } from '@converse/headless/core'; +import { html } from 'lit'; +import { initStorage } from '@converse/headless/utils/storage.js'; +import { isError } from '@converse/headless/utils/core.js'; +import { isAudioURL, isImageURL, isVideoURL, getURI } from '@converse/headless/utils/url.js'; +import { until } from 'lit/directives/until.js'; +import { + appendArrayBuffer, + arrayBufferToBase64, + arrayBufferToHex, + arrayBufferToString, + base64ToArrayBuffer, + hexToArrayBuffer, + stringToArrayBuffer +} from '@converse/headless/utils/arraybuffer.js'; + +const { Strophe, URI, sizzle, u } = converse.env; + +export function formatFingerprint (fp) { + fp = fp.replace(/^05/, ''); + for (let i=1; i<8; i++) { + const idx = i*8+i-1; + fp = fp.slice(0, idx) + ' ' + fp.slice(idx); + } + return fp; +} + +export function handleMessageSendError (e, chat) { + if (e.name === 'IQError') { + chat.save('omemo_supported', false); + + const err_msgs = []; + if (sizzle(`presence-subscription-required[xmlns="${Strophe.NS.PUBSUB_ERROR}"]`, e.iq).length) { + err_msgs.push( + __( + "Sorry, we're unable to send an encrypted message because %1$s " + + 'requires you to be subscribed to their presence in order to see their OMEMO information', + e.iq.getAttribute('from') + ) + ); + } else if (sizzle(`remote-server-not-found[xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"]`, e.iq).length) { + err_msgs.push( + __( + "Sorry, we're unable to send an encrypted message because the remote server for %1$s could not be found", + e.iq.getAttribute('from') + ) + ); + } else { + err_msgs.push(__('Unable to send an encrypted message due to an unexpected error.')); + err_msgs.push(e.iq.outerHTML); + } + api.alert('error', __('Error'), err_msgs); + } else if (e.user_facing) { + api.alert('error', __('Error'), [e.message]); + } + throw e; +} + +export function getOutgoingMessageAttributes (chat, attrs) { + if (chat.get('omemo_active') && attrs.body) { + attrs['is_encrypted'] = true; + attrs['plaintext'] = attrs.body; + attrs['body'] = __( + 'This is an OMEMO encrypted message which your client doesn’t seem to support. ' + + 'Find more information on https://conversations.im/omemo' + ); + } + return attrs; +} + +async function encryptMessage (plaintext) { + // The client MUST use fresh, randomly generated key/IV pairs + // with AES-128 in Galois/Counter Mode (GCM). + + // For GCM a 12 byte IV is strongly suggested as other IV lengths + // will require additional calculations. In principle any IV size + // can be used as long as the IV doesn't ever repeat. NIST however + // suggests that only an IV size of 12 bytes needs to be supported + // by implementations. + // + // https://crypto.stackexchange.com/questions/26783/ciphertext-and-tag-size-and-iv-transmission-with-aes-in-gcm-mode + const iv = crypto.getRandomValues(new window.Uint8Array(12)); + const key = await crypto.subtle.generateKey(KEY_ALGO, true, ['encrypt', 'decrypt']); + const algo = { + 'name': 'AES-GCM', + 'iv': iv, + 'tagLength': TAG_LENGTH + }; + const encrypted = await crypto.subtle.encrypt(algo, key, stringToArrayBuffer(plaintext)); + const length = encrypted.byteLength - ((128 + 7) >> 3); + const ciphertext = encrypted.slice(0, length); + const tag = encrypted.slice(length); + const exported_key = await crypto.subtle.exportKey('raw', key); + return { + 'key': exported_key, + 'tag': tag, + 'key_and_tag': appendArrayBuffer(exported_key, tag), + 'payload': arrayBufferToBase64(ciphertext), + 'iv': arrayBufferToBase64(iv) + }; +} + +async function decryptMessage (obj) { + const key_obj = await crypto.subtle.importKey('raw', obj.key, KEY_ALGO, true, ['encrypt', 'decrypt']); + const cipher = appendArrayBuffer(base64ToArrayBuffer(obj.payload), obj.tag); + const algo = { + 'name': 'AES-GCM', + 'iv': base64ToArrayBuffer(obj.iv), + 'tagLength': TAG_LENGTH + }; + return arrayBufferToString(await crypto.subtle.decrypt(algo, key_obj, cipher)); +} + +export async function encryptFile (file) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256, }, true, ['encrypt', 'decrypt']); + const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, }, key, await file.arrayBuffer()); + const exported_key = await window.crypto.subtle.exportKey('raw', key); + const encrypted_file = new File([encrypted], file.name, { type: file.type, lastModified: file.lastModified }); + encrypted_file.xep454_ivkey = arrayBufferToHex(iv) + arrayBufferToHex(exported_key); + return encrypted_file; +} + +export function setEncryptedFileURL (message, attrs) { + const url = attrs.oob_url.replace(/^https?:/, 'aesgcm:') + '#' + message.file.xep454_ivkey; + return Object.assign(attrs, { + 'oob_url': null, // Since only the body gets encrypted, we don't set the oob_url + 'message': url, + 'body': url + }); +} + +async function decryptFile (iv, key, cipher) { + const key_obj = await crypto.subtle.importKey('raw', hexToArrayBuffer(key), 'AES-GCM', false, ['decrypt']); + const algo = { + 'name': 'AES-GCM', + 'iv': hexToArrayBuffer(iv), + }; + return crypto.subtle.decrypt(algo, key_obj, cipher); +} + +async function downloadFile(url) { + let response; + try { + response = await fetch(url) + } catch(e) { + log.error(`${e.name}: Failed to download encrypted media: ${url}`); + log.error(e); + return null; + } + + if (response.status >= 200 && response.status < 400) { + return response.arrayBuffer(); + } +} + +async function getAndDecryptFile (uri) { + const protocol = (window.location.hostname === 'localhost' && uri.domain() === 'localhost') ? 'http' : 'https'; + const http_url = uri.toString().replace(/^aesgcm/, protocol); + const cipher = await downloadFile(http_url); + if (cipher === null) { + log.error(`Could not decrypt a received encrypted file ${uri.toString()} since it could not be downloaded`); + return new Error(__('Error: could not decrypt a received encrypted file, because it could not be downloaded')); + } + + const hash = uri.hash().slice(1); + const key = hash.substring(hash.length-64); + const iv = hash.replace(key, ''); + let content; + try { + content = await decryptFile(iv, key, cipher); + } catch (e) { + log.error(`Could not decrypt file ${uri.toString()}`); + log.error(e); + return null; + } + const [filename, extension] = uri.filename().split('.'); + const mimetype = MIMETYPES_MAP[extension]; + try { + const file = new File([content], filename, { 'type': mimetype }); + return URL.createObjectURL(file); + } catch (e) { + log.error(`Could not decrypt file ${uri.toString()}`); + log.error(e); + return null; + } +} + +function getTemplateForObjectURL (uri, obj_url, richtext) { + if (isError(obj_url)) { + return html`<p class="error">${obj_url.message}</p>`; + } + + const file_url = uri.toString(); + if (isImageURL(file_url)) { + return tplImage({ + 'src': obj_url, + 'onClick': richtext.onImgClick, + 'onLoad': richtext.onImgLoad + }); + } else if (isAudioURL(file_url)) { + return tplAudio(obj_url); + } else if (isVideoURL(file_url)) { + return tplVideo(obj_url); + } else { + return tplFile(obj_url, uri.filename()); + } + +} + +function addEncryptedFiles(text, offset, richtext) { + const objs = []; + try { + const parse_options = { 'start': /\b(aesgcm:\/\/)/gi }; + URI.withinString( + text, + (url, start, end) => { + objs.push({ url, start, end }); + return url; + }, + parse_options + ); + } catch (error) { + log.debug(error); + return; + } + objs.forEach(o => { + const uri = getURI(text.slice(o.start, o.end)); + const promise = getAndDecryptFile(uri) + .then(obj_url => getTemplateForObjectURL(uri, obj_url, richtext)); + + const template = html`${until(promise, '')}`; + richtext.addTemplateResult(o.start + offset, o.end + offset, template); + }); +} + +export function handleEncryptedFiles (richtext) { + if (!_converse.config.get('trusted')) { + return; + } + richtext.addAnnotations((text, offset) => addEncryptedFiles(text, offset, richtext)); +} + +/** + * Hook handler for { @link parseMessage } and { @link parseMUCMessage }, which + * parses the passed in `message` stanza for OMEMO attributes and then sets + * them on the attrs object. + * @param { Element } stanza - The message stanza + * @param { (MUCMessageAttributes|MessageAttributes) } attrs + * @returns (MUCMessageAttributes|MessageAttributes) + */ +export async function parseEncryptedMessage (stanza, attrs) { + if (api.settings.get('clear_cache_on_logout') || + !attrs.is_encrypted || + attrs.encryption_namespace !== Strophe.NS.OMEMO) { + return attrs; + } + const encrypted_el = sizzle(`encrypted[xmlns="${Strophe.NS.OMEMO}"]`, stanza).pop(); + const header = encrypted_el.querySelector('header'); + attrs.encrypted = { 'device_id': header.getAttribute('sid') }; + + const device_id = await api.omemo?.getDeviceID(); + const key = device_id && sizzle(`key[rid="${device_id}"]`, encrypted_el).pop(); + if (key) { + Object.assign(attrs.encrypted, { + 'iv': header.querySelector('iv').textContent, + 'key': key.textContent, + 'payload': encrypted_el.querySelector('payload')?.textContent || null, + 'prekey': ['true', '1'].includes(key.getAttribute('prekey')) + }); + } else { + return Object.assign(attrs, { + 'error_condition': 'not-encrypted-for-this-device', + 'error_type': 'Decryption', + 'is_ephemeral': true, + 'is_error': true, + 'type': 'error' + }); + } + // https://xmpp.org/extensions/xep-0384.html#usecases-receiving + if (attrs.encrypted.prekey === true) { + return decryptPrekeyWhisperMessage(attrs); + } else { + return decryptWhisperMessage(attrs); + } +} + +export function onChatBoxesInitialized () { + _converse.chatboxes.on('add', chatbox => { + checkOMEMOSupported(chatbox); + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + chatbox.occupants.on('add', o => onOccupantAdded(chatbox, o)); + chatbox.features.on('change', () => checkOMEMOSupported(chatbox)); + } + }); +} + +export function onChatInitialized (el) { + el.listenTo(el.model.messages, 'add', message => { + if (message.get('is_encrypted') && !message.get('is_error')) { + el.model.save('omemo_supported', true); + } + }); + el.listenTo(el.model, 'change:omemo_supported', () => { + if (!el.model.get('omemo_supported') && el.model.get('omemo_active')) { + el.model.set('omemo_active', false); + } else { + // Manually trigger an update, setting omemo_active to + // false above will automatically trigger one. + el.querySelector('converse-chat-toolbar')?.requestUpdate(); + } + }); + el.listenTo(el.model, 'change:omemo_active', () => { + el.querySelector('converse-chat-toolbar').requestUpdate(); + }); +} + +export function getSessionCipher (jid, id) { + const address = new libsignal.SignalProtocolAddress(jid, id); + return new window.libsignal.SessionCipher(_converse.omemo_store, address); +} + +function getJIDForDecryption (attrs) { + const from_jid = attrs.from_muc ? attrs.from_real_jid : attrs.from; + if (!from_jid) { + Object.assign(attrs, { + 'error_text': __("Sorry, could not decrypt a received OMEMO "+ + "message because we don't have the XMPP address for that user."), + 'error_type': 'Decryption', + 'is_ephemeral': true, + 'is_error': true, + 'type': 'error' + }); + throw new Error("Could not find JID to decrypt OMEMO message for"); + } + return from_jid; +} + +async function handleDecryptedWhisperMessage (attrs, key_and_tag) { + const from_jid = getJIDForDecryption(attrs); + const devicelist = await api.omemo.devicelists.get(from_jid, true); + const encrypted = attrs.encrypted; + let device = devicelist.devices.get(encrypted.device_id); + if (!device) { + device = await devicelist.devices.create({ 'id': encrypted.device_id, 'jid': from_jid }, { 'promise': true }); + } + if (encrypted.payload) { + const key = key_and_tag.slice(0, 16); + const tag = key_and_tag.slice(16); + const result = await omemo.decryptMessage(Object.assign(encrypted, { 'key': key, 'tag': tag })); + device.save('active', true); + return result; + } +} + +function getDecryptionErrorAttributes (e) { + return { + 'error_text': + __('Sorry, could not decrypt a received OMEMO message due to an error.') + ` ${e.name} ${e.message}`, + 'error_condition': e.name, + 'error_message': e.message, + 'error_type': 'Decryption', + 'is_ephemeral': true, + 'is_error': true, + 'type': 'error' + }; +} + +async function decryptPrekeyWhisperMessage (attrs) { + const from_jid = getJIDForDecryption(attrs); + const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10)); + const key = base64ToArrayBuffer(attrs.encrypted.key); + let key_and_tag; + try { + key_and_tag = await session_cipher.decryptPreKeyWhisperMessage(key, 'binary'); + } catch (e) { + // TODO from the XEP: + // There are various reasons why decryption of an + // OMEMOKeyExchange or an OMEMOAuthenticatedMessage + // could fail. One reason is if the message was + // received twice and already decrypted once, in this + // case the client MUST ignore the decryption failure + // and not show any warnings/errors. In all other cases + // of decryption failure, clients SHOULD respond by + // forcibly doing a new key exchange and sending a new + // OMEMOKeyExchange with a potentially empty SCE + // payload. By building a new session with the original + // sender this way, the invalid session of the original + // sender will get overwritten with this newly created, + // valid session. + log.error(`${e.name} ${e.message}`); + return Object.assign(attrs, getDecryptionErrorAttributes(e)); + } + // TODO from the XEP: + // When a client receives the first message for a given + // ratchet key with a counter of 53 or higher, it MUST send + // a heartbeat message. Heartbeat messages are normal OMEMO + // encrypted messages where the SCE payload does not include + // any elements. These heartbeat messages cause the ratchet + // to forward, thus consequent messages will have the + // counter restarted from 0. + try { + const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag); + await _converse.omemo_store.generateMissingPreKeys(); + await _converse.omemo_store.publishBundle(); + if (plaintext) { + return Object.assign(attrs, { 'plaintext': plaintext }); + } else { + return Object.assign(attrs, { 'is_only_key': true }); + } + } catch (e) { + log.error(`${e.name} ${e.message}`); + return Object.assign(attrs, getDecryptionErrorAttributes(e)); + } +} + +async function decryptWhisperMessage (attrs) { + const from_jid = getJIDForDecryption(attrs); + const session_cipher = getSessionCipher(from_jid, parseInt(attrs.encrypted.device_id, 10)); + const key = base64ToArrayBuffer(attrs.encrypted.key); + try { + const key_and_tag = await session_cipher.decryptWhisperMessage(key, 'binary'); + const plaintext = await handleDecryptedWhisperMessage(attrs, key_and_tag); + return Object.assign(attrs, { 'plaintext': plaintext }); + } catch (e) { + log.error(`${e.name} ${e.message}`); + return Object.assign(attrs, getDecryptionErrorAttributes(e)); + } +} + +export function addKeysToMessageStanza (stanza, dicts, iv) { + for (const i in dicts) { + if (Object.prototype.hasOwnProperty.call(dicts, i)) { + const payload = dicts[i].payload; + const device = dicts[i].device; + const prekey = 3 == parseInt(payload.type, 10); + + stanza.c('key', { 'rid': device.get('id') }).t(btoa(payload.body)); + if (prekey) { + stanza.attrs({ 'prekey': prekey }); + } + stanza.up(); + if (i == dicts.length - 1) { + stanza.c('iv').t(iv).up().up(); + } + } + } + return Promise.resolve(stanza); +} + +/** + * Given an XML element representing a user's OMEMO bundle, parse it + * and return a map. + */ +export function parseBundle (bundle_el) { + const signed_prekey_public_el = bundle_el.querySelector('signedPreKeyPublic'); + const signed_prekey_signature_el = bundle_el.querySelector('signedPreKeySignature'); + const prekeys = sizzle(`prekeys > preKeyPublic`, bundle_el).map(el => ({ + 'id': parseInt(el.getAttribute('preKeyId'), 10), + 'key': el.textContent + })); + return { + 'identity_key': bundle_el.querySelector('identityKey').textContent.trim(), + 'signed_prekey': { + 'id': parseInt(signed_prekey_public_el.getAttribute('signedPreKeyId'), 10), + 'public_key': signed_prekey_public_el.textContent, + 'signature': signed_prekey_signature_el.textContent + }, + 'prekeys': prekeys + }; +} + +export async function generateFingerprint (device) { + if (device.get('bundle')?.fingerprint) { + return; + } + const bundle = await device.getBundle(); + bundle['fingerprint'] = arrayBufferToHex(base64ToArrayBuffer(bundle['identity_key'])); + device.save('bundle', bundle); + device.trigger('change:bundle'); // Doesn't get triggered automatically due to pass-by-reference +} + +export async function getDevicesForContact (jid) { + await api.waitUntil('OMEMOInitialized'); + const devicelist = await api.omemo.devicelists.get(jid, true); + await devicelist.fetchDevices(); + return devicelist.devices; +} + +export async function generateDeviceID () { + /* Generates a device ID, making sure that it's unique */ + const devicelist = await api.omemo.devicelists.get(_converse.bare_jid, true); + const existing_ids = devicelist.devices.pluck('id'); + let device_id = libsignal.KeyHelper.generateRegistrationId(); + + // Before publishing a freshly generated device id for the first time, + // a device MUST check whether that device id already exists, and if so, generate a new one. + let i = 0; + while (existing_ids.includes(device_id)) { + device_id = libsignal.KeyHelper.generateRegistrationId(); + i++; + if (i === 10) { + throw new Error('Unable to generate a unique device ID'); + } + } + return device_id.toString(); +} + +async function buildSession (device) { + const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')); + const sessionBuilder = new libsignal.SessionBuilder(_converse.omemo_store, address); + const prekey = device.getRandomPreKey(); + const bundle = await device.getBundle(); + + return sessionBuilder.processPreKey({ + 'registrationId': parseInt(device.get('id'), 10), + 'identityKey': base64ToArrayBuffer(bundle.identity_key), + 'signedPreKey': { + 'keyId': bundle.signed_prekey.id, // <Number> + 'publicKey': base64ToArrayBuffer(bundle.signed_prekey.public_key), + 'signature': base64ToArrayBuffer(bundle.signed_prekey.signature) + }, + 'preKey': { + 'keyId': prekey.id, // <Number> + 'publicKey': base64ToArrayBuffer(prekey.key) + } + }); +} + +export async function getSession (device) { + if (!device.get('bundle')) { + log.error(`Could not build an OMEMO session for device ${device.get('id')} because we don't have its bundle`); + return null; + } + const address = new libsignal.SignalProtocolAddress(device.get('jid'), device.get('id')); + const session = await _converse.omemo_store.loadSession(address.toString()); + if (session) { + return session; + } else { + try { + const session = await buildSession(device); + return session; + } catch (e) { + log.error(`Could not build an OMEMO session for device ${device.get('id')}`); + log.error(e); + return null; + } + } +} + +async function updateBundleFromStanza (stanza) { + const items_el = sizzle(`items`, stanza).pop(); + if (!items_el || !items_el.getAttribute('node').startsWith(Strophe.NS.OMEMO_BUNDLES)) { + return; + } + const device_id = items_el.getAttribute('node').split(':')[1]; + const jid = stanza.getAttribute('from'); + const bundle_el = sizzle(`item > bundle`, items_el).pop(); + const devicelist = await api.omemo.devicelists.get(jid, true); + const device = devicelist.devices.get(device_id) || devicelist.devices.create({ 'id': device_id, jid }); + device.save({ 'bundle': parseBundle(bundle_el) }); +} + +async function updateDevicesFromStanza (stanza) { + const items_el = sizzle(`items[node="${Strophe.NS.OMEMO_DEVICELIST}"]`, stanza).pop(); + if (!items_el) { + return; + } + const device_selector = `item list[xmlns="${Strophe.NS.OMEMO}"] device`; + const device_ids = sizzle(device_selector, items_el).map(d => d.getAttribute('id')); + const jid = stanza.getAttribute('from'); + const devicelist = await api.omemo.devicelists.get(jid, true); + const devices = devicelist.devices; + const removed_ids = difference(devices.pluck('id'), device_ids); + + removed_ids.forEach(id => { + if (jid === _converse.bare_jid && id === _converse.omemo_store.get('device_id')) { + return; // We don't set the current device as inactive + } + devices.get(id).save('active', false); + }); + device_ids.forEach(device_id => { + const device = devices.get(device_id); + if (device) { + device.save('active', true); + } else { + devices.create({ 'id': device_id, 'jid': jid }); + } + }); + if (u.isSameBareJID(jid, _converse.bare_jid)) { + // Make sure our own device is on the list + // (i.e. if it was removed, add it again). + devicelist.publishCurrentDevice(device_ids); + } +} + +export function registerPEPPushHandler () { + // Add a handler for devices pushed from other connected clients + _converse.connection.addHandler( + async (message) => { + try { + if (sizzle(`event[xmlns="${Strophe.NS.PUBSUB}#event"]`, message).length) { + await api.waitUntil('OMEMOInitialized'); + await updateDevicesFromStanza(message); + await updateBundleFromStanza(message); + } + } catch (e) { + log.error(e.message); + } + return true; + }, + null, + 'message', + 'headline' + ); +} + +export async function restoreOMEMOSession () { + if (_converse.omemo_store === undefined) { + const id = `converse.omemosession-${_converse.bare_jid}`; + _converse.omemo_store = new _converse.OMEMOStore({ id }); + initStorage(_converse.omemo_store, id); + } + await _converse.omemo_store.fetchSession(); +} + +async function fetchDeviceLists () { + _converse.devicelists = new _converse.DeviceLists(); + const id = `converse.devicelists-${_converse.bare_jid}`; + initStorage(_converse.devicelists, id); + await new Promise(resolve => { + _converse.devicelists.fetch({ + 'success': resolve, + 'error': (_m, e) => { log.error(e); resolve(); } + }) + }); + // Call API method to wait for our own device list to be fetched from the + // server or to be created. If we have no pre-existing OMEMO session, this + // will cause a new device and bundle to be generated and published. + await api.omemo.devicelists.get(_converse.bare_jid, true); +} + +export async function initOMEMO (reconnecting) { + if (reconnecting) { + return; + } + if (!_converse.config.get('trusted') || api.settings.get('clear_cache_on_logout')) { + log.warn('Not initializing OMEMO, since this browser is not trusted or clear_cache_on_logout is set to true'); + return; + } + try { + await fetchDeviceLists(); + await restoreOMEMOSession(); + await _converse.omemo_store.publishBundle(); + } catch (e) { + log.error('Could not initialize OMEMO support'); + log.error(e); + return; + } + /** + * Triggered once OMEMO support has been initialized + * @event _converse#OMEMOInitialized + * @example _converse.api.listen.on('OMEMOInitialized', () => { ... }); + */ + api.trigger('OMEMOInitialized'); +} + +async function onOccupantAdded (chatroom, occupant) { + if (occupant.isSelf() || !chatroom.features.get('nonanonymous') || !chatroom.features.get('membersonly')) { + return; + } + if (chatroom.get('omemo_active')) { + const supported = await _converse.contactHasOMEMOSupport(occupant.get('jid')); + if (!supported) { + chatroom.createMessage({ + 'message': __( + "%1$s doesn't appear to have a client that supports OMEMO. " + + 'Encrypted chat will no longer be possible in this grouchat.', + occupant.get('nick') + ), + 'type': 'error' + }); + chatroom.save({ 'omemo_active': false, 'omemo_supported': false }); + } + } +} + +async function checkOMEMOSupported (chatbox) { + let supported; + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + await api.waitUntil('OMEMOInitialized'); + supported = chatbox.features.get('nonanonymous') && chatbox.features.get('membersonly'); + } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) { + supported = await _converse.contactHasOMEMOSupport(chatbox.get('jid')); + } + chatbox.set('omemo_supported', supported); + if (supported && api.settings.get('omemo_default')) { + chatbox.set('omemo_active', true); + } +} + +function toggleOMEMO (ev) { + ev.stopPropagation(); + ev.preventDefault(); + const toolbar_el = u.ancestor(ev.target, 'converse-chat-toolbar'); + if (!toolbar_el.model.get('omemo_supported')) { + let messages; + if (toolbar_el.model.get('type') === _converse.CHATROOMS_TYPE) { + messages = [ + __( + 'Cannot use end-to-end encryption in this groupchat, ' + + 'either the groupchat has some anonymity or not all participants support OMEMO.' + ) + ]; + } else { + messages = [ + __( + "Cannot use end-to-end encryption because %1$s uses a client that doesn't support OMEMO.", + toolbar_el.model.contact.getDisplayName() + ) + ]; + } + return api.alert('error', __('Error'), messages); + } + toolbar_el.model.save({ 'omemo_active': !toolbar_el.model.get('omemo_active') }); +} + +export function getOMEMOToolbarButton (toolbar_el, buttons) { + const model = toolbar_el.model; + const is_muc = model.get('type') === _converse.CHATROOMS_TYPE; + let title; + if (model.get('omemo_supported')) { + const i18n_plaintext = __('Messages are being sent in plaintext'); + const i18n_encrypted = __('Messages are sent encrypted'); + title = model.get('omemo_active') ? i18n_encrypted : i18n_plaintext; + } else if (is_muc) { + title = __( + 'This groupchat needs to be members-only and non-anonymous in ' + + 'order to support OMEMO encrypted messages' + ); + } else { + title = __('OMEMO encryption is not supported'); + } + + let color; + if (model.get('omemo_supported')) { + if (model.get('omemo_active')) { + color = is_muc ? `var(--muc-color)` : `var(--chat-toolbar-btn-color)`; + } else { + color = `var(--error-color)`; + } + } else { + color = `var(--muc-toolbar-btn-disabled-color)`; + } + buttons.push(html` + <button class="toggle-omemo" title="${title}" data-disabled=${!model.get('omemo_supported')} @click=${toggleOMEMO}> + <converse-icon + class="fa ${model.get('omemo_active') ? `fa-lock` : `fa-unlock`}" + path-prefix="${api.settings.get('assets_path')}" + size="1em" + color="${color}" + ></converse-icon> + </button> + `); + return buttons; +} + + +async function getBundlesAndBuildSessions (chatbox) { + const no_devices_err = __('Sorry, no devices found to which we can send an OMEMO encrypted message.'); + let devices; + if (chatbox.get('type') === _converse.CHATROOMS_TYPE) { + const collections = await Promise.all(chatbox.occupants.map(o => getDevicesForContact(o.get('jid')))); + devices = collections.reduce((a, b) => concat(a, b.models), []); + } else if (chatbox.get('type') === _converse.PRIVATE_CHAT_TYPE) { + const their_devices = await getDevicesForContact(chatbox.get('jid')); + if (their_devices.length === 0) { + const err = new Error(no_devices_err); + err.user_facing = true; + throw err; + } + const own_list = await api.omemo.devicelists.get(_converse.bare_jid) + const own_devices = own_list.devices; + devices = [...own_devices.models, ...their_devices.models]; + } + // Filter out our own device + const id = _converse.omemo_store.get('device_id'); + devices = devices.filter(d => d.get('id') !== id); + // Fetch bundles if necessary + await Promise.all(devices.map(d => d.getBundle())); + + const sessions = devices.filter(d => d).map(d => getSession(d)); + await Promise.all(sessions); + if (sessions.includes(null)) { + // We couldn't build a session for certain devices. + devices = devices.filter(d => sessions[devices.indexOf(d)]); + if (devices.length === 0) { + const err = new Error(no_devices_err); + err.user_facing = true; + throw err; + } + } + return devices; +} + +function encryptKey (key_and_tag, device) { + return getSessionCipher(device.get('jid'), device.get('id')) + .encrypt(key_and_tag) + .then(payload => ({ 'payload': payload, 'device': device })); +} + +export async function createOMEMOMessageStanza (chat, data) { + let { stanza } = data; + const { message } = data; + if (!message.get('is_encrypted')) { + return data; + } + if (!message.get('body')) { + throw new Error('No message body to encrypt!'); + } + const devices = await getBundlesAndBuildSessions(chat); + + // An encrypted header is added to the message for + // each device that is supposed to receive it. + // These headers simply contain the key that the + // payload message is encrypted with, + // and they are separately encrypted using the + // session corresponding to the counterpart device. + stanza.c('encrypted', { 'xmlns': Strophe.NS.OMEMO }) + .c('header', { 'sid': _converse.omemo_store.get('device_id') }); + + const { key_and_tag, iv, payload } = await omemo.encryptMessage(message.get('plaintext')); + + // The 16 bytes key and the GCM authentication tag (The tag + // SHOULD have at least 128 bit) are concatenated and for each + // intended recipient device, i.e. both own devices as well as + // devices associated with the contact, the result of this + // concatenation is encrypted using the corresponding + // long-standing SignalProtocol session. + const dicts = await Promise.all(devices + .filter(device => device.get('trusted') != UNTRUSTED && device.get('active')) + .map(device => encryptKey(key_and_tag, device))); + + stanza = await addKeysToMessageStanza(stanza, dicts, iv); + stanza.c('payload').t(payload).up().up(); + stanza.c('store', { 'xmlns': Strophe.NS.HINTS }).up(); + stanza.c('encryption', { 'xmlns': Strophe.NS.EME, namespace: Strophe.NS.OMEMO }); + return { message, stanza }; +} + +export const omemo = { + decryptMessage, + encryptMessage, + formatFingerprint +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/index.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/index.js new file mode 100644 index 0000000..3b75578 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/index.js @@ -0,0 +1,26 @@ +/** + * @copyright The Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import '../modal/index.js'; +import './modals/chat-status.js'; +import './modals/profile.js'; +import './modals/user-settings.js'; +import './statusview.js'; +import '@converse/headless/plugins/status'; +import '@converse/headless/plugins/vcard'; +import { api, converse } from '@converse/headless/core'; + +converse.plugins.add('converse-profile', { + dependencies: [ + 'converse-status', + 'converse-modal', + 'converse-vcard', + 'converse-chatboxviews', + 'converse-adhoc-views', + ], + + initialize () { + api.settings.extend({ 'show_client_info': true }); + }, +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js new file mode 100644 index 0000000..16cf74c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/chat-status.js @@ -0,0 +1,49 @@ +import BaseModal from "plugins/modal/modal.js"; +import tplChatStatusModal from "../templates/chat-status-modal.js"; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; + +const u = converse.env.utils; + + +export default class ChatStatusModal extends BaseModal { + + initialize () { + super.initialize(); + this.render(); + this.addEventListener('shown.bs.modal', () => { + this.querySelector('input[name="status_message"]').focus(); + }, false); + } + + renderModal () { + return tplChatStatusModal(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Change chat status'); + } + + clearStatusMessage (ev) { + if (ev && ev.preventDefault) { + ev.preventDefault(); + u.hideElement(this.querySelector('.clear-input')); + } + const roster_filter = this.querySelector('input[name="status_message"]'); + roster_filter.value = ''; + } + + onFormSubmitted (ev) { + ev.preventDefault(); + const data = new FormData(ev.target); + this.model.save({ + 'status_message': data.get('status_message'), + 'status': data.get('chat_status') + }); + this.modal.hide(); + } +} + +_converse.ChatStatusModal = ChatStatusModal; + +api.elements.define('converse-chat-status-modal', ChatStatusModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js new file mode 100644 index 0000000..51da14b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/profile.js @@ -0,0 +1,92 @@ +import BaseModal from "plugins/modal/modal.js"; +import log from "@converse/headless/log"; +import tplProfileModal from "../templates/profile_modal.js"; +import Compress from 'client-compress'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import '../password-reset.js'; + +const compress = new Compress({ + targetSize: 0.1, + quality: 0.75, + maxWidth: 256, + maxHeight: 256 +}); + +export default class ProfileModal extends BaseModal { + + constructor (options) { + super(options); + this.tab = 'profile'; + } + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change', this.render); + /** + * Triggered when the _converse.ProfileModal has been created and initialized. + * @event _converse#profileModalInitialized + * @type { _converse.XMPPStatus } + * @example _converse.api.listen.on('profileModalInitialized', status => { ... }); + */ + api.trigger('profileModalInitialized', this.model); + } + + renderModal () { + return tplProfileModal(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Your Profile'); + } + + async setVCard (data) { + try { + await api.vcard.set(_converse.bare_jid, data); + } catch (err) { + log.fatal(err); + this.alert([ + __("Sorry, an error happened while trying to save your profile data."), + __("You can check your browser's developer console for any error output.") + ].join(" ")); + return; + } + this.modal.hide(); + } + + onFormSubmitted (ev) { + ev.preventDefault(); + const reader = new FileReader(); + const form_data = new FormData(ev.target); + const image_file = form_data.get('image'); + const data = { + 'fn': form_data.get('fn'), + 'nickname': form_data.get('nickname'), + 'role': form_data.get('role'), + 'email': form_data.get('email'), + 'url': form_data.get('url'), + }; + if (!image_file.size) { + Object.assign(data, { + 'image': this.model.vcard.get('image'), + 'image_type': this.model.vcard.get('image_type') + }); + this.setVCard(data); + } else { + const files = [image_file]; + compress.compress(files).then((conversions) => { + const { photo, } = conversions[0]; + reader.onloadend = () => { + Object.assign(data, { + 'image': btoa(reader.result), + 'image_type': image_file.type + }); + this.setVCard(data); + }; + reader.readAsBinaryString(photo.data); + }); + } + } +} + +api.elements.define('converse-profile-modal', ProfileModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss new file mode 100644 index 0000000..813671e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/styles/profile.scss @@ -0,0 +1,38 @@ +converse-profile-modal { + .profile-form { + label { + font-weight: bold; + } + } + + .fingerprint-removal { + label { + display: flex; + padding: 0.75rem 1.25rem; + } + } + + .list-group-item { + display: flex; + justify-content: left; + font-size: 95%; + + input[type="checkbox"] { + margin-right: 1em; + } + } + + .fingerprints { + width: 100%; + margin-bottom: 1em; + } + + .fingerprint-trust { + display: flex; + justify-content: space-between; + font-size: 95%; + .fingerprint { + margin-left: 1em; + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js new file mode 100644 index 0000000..8dd6962 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/templates/user-settings.js @@ -0,0 +1,81 @@ +import DOMPurify from 'dompurify'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core.js"; +import { html } from "lit"; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; + + +const tplNavigation = (el) => { + const i18n_about = __('About'); + const i18n_commands = __('Commands'); + return html` + <ul class="nav nav-pills justify-content-center"> + <li role="presentation" class="nav-item"> + <a class="nav-link ${el.tab === "about" ? "active" : ""}" + id="about-tab" + href="#about-tabpanel" + aria-controls="about-tabpanel" + role="tab" + data-toggle="tab" + data-name="about" + @click=${ev => el.switchTab(ev)}>${i18n_about}</a> + </li> + <li role="presentation" class="nav-item"> + <a class="nav-link ${el.tab === "commands" ? "active" : ""}" + id="commands-tab" + href="#commands-tabpanel" + aria-controls="commands-tabpanel" + role="tab" + data-toggle="tab" + data-name="commands" + @click=${ev => el.switchTab(ev)}>${i18n_commands}</a> + </li> + </ul> + `; +} + + +export default (el) => { + const first_subtitle = __( + '%1$s Open Source %2$s XMPP chat client brought to you by %3$s Opkode %2$s', + '<a target="_blank" rel="nofollow" href="https://conversejs.org">', + '</a>', + '<a target="_blank" rel="nofollow" href="https://opkode.com">' + ); + + const second_subtitle = __( + '%1$s Translate %2$s it into your own language', + '<a target="_blank" rel="nofollow" href="https://hosted.weblate.org/projects/conversejs/#languages">', + '</a>' + ); + const show_client_info = api.settings.get('show_client_info'); + const allow_adhoc_commands = api.settings.get('allow_adhoc_commands'); + const show_both_tabs = show_client_info && allow_adhoc_commands; + + return html` + ${ show_both_tabs ? tplNavigation(el) : '' } + + <div class="tab-content"> + ${ show_client_info ? html` + <div class="tab-pane tab-pane--columns ${ el.tab === 'about' ? 'active' : ''}" + id="about-tabpanel" role="tabpanel" aria-labelledby="about-tab"> + + <span class="modal-alert"></span> + <br/> + <div class="container"> + <h6 class="brand-heading">Converse</h6> + <p class="brand-subtitle">${_converse.VERSION_NAME}</p> + <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(first_subtitle))}</p> + <p class="brand-subtitle">${unsafeHTML(DOMPurify.sanitize(second_subtitle))}</p> + </div> + </div>` : '' } + + ${ allow_adhoc_commands ? html` + <div class="tab-pane tab-pane--columns ${ el.tab === 'commands' ? 'active' : ''}" + id="commands-tabpanel" + role="tabpanel" + aria-labelledby="commands-tab"> + <converse-adhoc-commands/> + </div> ` : '' } + </div> +`}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js new file mode 100644 index 0000000..1e4df13 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/modals/user-settings.js @@ -0,0 +1,31 @@ +import BaseModal from "plugins/modal/modal.js"; +import tplUserSettingsModal from "./templates/user-settings.js"; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; + +export default class UserSettingsModal extends BaseModal { + + constructor (options) { + super(options); + + const show_client_info = api.settings.get('show_client_info'); + const allow_adhoc_commands = api.settings.get('allow_adhoc_commands'); + const show_both_tabs = show_client_info && allow_adhoc_commands; + + if (show_both_tabs || show_client_info) { + this.tab = 'about'; + } else if (allow_adhoc_commands) { + this.tab = 'commands'; + } + } + + renderModal () { + return tplUserSettingsModal(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Settings'); + } +} + +api.elements.define('converse-user-settings-modal', UserSettingsModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js new file mode 100644 index 0000000..58c9ffa --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/password-reset.js @@ -0,0 +1,83 @@ +import log from '@converse/headless/log'; +import tplPasswordReset from './templates/password-reset.js'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from '@converse/headless/core'; + +const { Strophe, $iq, sizzle, u } = converse.env; + + +class PasswordReset extends CustomElement { + + static get properties () { + return { + passwords_mismatched: { type: Boolean }, + alert_message: { type: String } + } + } + + initialize () { + this.passwords_mismatched = false; + this.alert_message = ''; + } + + render () { + return tplPasswordReset(this); + } + + checkPasswordsMatch (ev) { + const form_data = new FormData(ev.target.form ?? ev.target); + const password = form_data.get('password'); + const password_check = form_data.get('password_check'); + + this.passwords_mismatched = password && password !== password_check; + return this.passwords_mismatched + } + + async onSubmit (ev) { + ev.preventDefault(); + + if (this.checkPasswordsMatch(ev)) return; + + const iq = $iq({ 'type': 'get', 'to': _converse.domain }).c('query', { 'xmlns': Strophe.NS.REGISTER }); + const iq_response = await api.sendIQ(iq); + + if (iq_response === null) { + this.alert_message = __('Timeout error'); + return; + } else if (sizzle(`error service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, iq_response).length) { + this.alert_message = __('Your server does not support in-band password reset'); + return; + } else if (u.isErrorStanza(iq_response)) { + this.alert_message = __('Your server responded with an unknown error, check the console for details'); + log.error("Could not set password"); + log.error(iq_response); + return; + } + + const username = iq_response.querySelector('username').textContent; + + const data = new FormData(ev.target); + const password = data.get('password'); + + const reset_iq = $iq({ 'type': 'set', 'to': _converse.domain }) + .c('query', { 'xmlns': Strophe.NS.REGISTER }) + .c('username', {}, username) + .c('password', {}, password); + + const iq_result = await api.sendIQ(reset_iq); + if (iq_result === null) { + this.alert_message = __('Timeout error while trying to set your password'); + } else if (sizzle(`error not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, iq_result).length) { + this.alert_message = __('Your server does not allow in-band password reset'); + } else if (sizzle(`error forbidden[xmlns="${Strophe.NS.STANZAS}"]`, iq_result).length) { + this.alert_message = __('You are not allowed to change your password'); + } else if (u.isErrorStanza(iq_result)) { + this.alert_message = __('You are not allowed to change your password'); + } else { + api.alert('info', __('Success'), [__('Your new password has been set')]); + } + } +} + +api.elements.define('converse-change-password-form', PasswordReset); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js new file mode 100644 index 0000000..ab3c3b3 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/statusview.js @@ -0,0 +1,33 @@ +import tplProfile from './templates/profile.js'; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api } from '@converse/headless/core'; + +class Profile extends CustomElement { + initialize () { + this.model = _converse.xmppstatus; + this.listenTo(this.model, "change", () => this.requestUpdate()); + this.listenTo(this.model, "vcard:add", () => this.requestUpdate()); + this.listenTo(this.model, "vcard:change", () => this.requestUpdate()); + } + + render () { + return tplProfile(this); + } + + showProfileModal (ev) { + ev?.preventDefault(); + api.modal.show('converse-profile-modal', { model: this.model }, ev); + } + + showStatusChangeModal (ev) { + ev?.preventDefault(); + api.modal.show('converse-chat-status-modal', { model: this.model }, ev); + } + + showUserSettingsModal (ev) { + ev?.preventDefault(); + api.modal.show('converse-user-settings-modal', { model: this.model, _converse }, ev); + } +} + +api.elements.define('converse-user-profile', Profile); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js new file mode 100644 index 0000000..0afa4ef --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/chat-status-modal.js @@ -0,0 +1,52 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + + +export default (el) => { + const label_away = __('Away'); + const label_busy = __('Busy'); + const label_online = __('Online'); + const label_save = __('Save'); + const label_xa = __('Away for long'); + const placeholder_status_message = __('Personal status message'); + const status = el.model.get('status'); + const status_message = el.model.get('status_message'); + + return html` + <form class="converse-form set-xmpp-status" id="set-xmpp-status" @submit=${ev => el.onFormSubmitted(ev)}> + <div class="form-group"> + <div class="custom-control custom-radio"> + <input ?checked=${status === 'online'} + type="radio" id="radio-online" value="online" name="chat_status" class="custom-control-input"/> + <label class="custom-control-label" for="radio-online"> + <converse-icon size="1em" class="fa fa-circle chat-status chat-status--online"></converse-icon>${label_online}</label> + </div> + <div class="custom-control custom-radio"> + <input ?checked=${status === 'busy'} + type="radio" id="radio-busy" value="dnd" name="chat_status" class="custom-control-input"/> + <label class="custom-control-label" for="radio-busy"> + <converse-icon size="1em" class="fa fa-minus-circle chat-status chat-status--busy"></converse-icon>${label_busy}</label> + </div> + <div class="custom-control custom-radio"> + <input ?checked=${status === 'away'} + type="radio" id="radio-away" value="away" name="chat_status" class="custom-control-input"/> + <label class="custom-control-label" for="radio-away"> + <converse-icon size="1em" class="fa fa-circle chat-status chat-status--away"></converse-icon>${label_away}</label> + </div> + <div class="custom-control custom-radio"> + <input ?checked=${status === 'xa'} + type="radio" id="radio-xa" value="xa" name="chat_status" class="custom-control-input"/> + <label class="custom-control-label" for="radio-xa"> + <converse-icon size="1em" class="far fa-circle chat-status chat-status--xa"></converse-icon>${label_xa}</label> + </div> + </div> + <div class="form-group"> + <div class="btn-group w-100"> + <input name="status_message" type="text" class="form-control" autofocus + value="${status_message || ''}" placeholder="${placeholder_status_message}"/> + <converse-icon size="1em" class="fa fa-times clear-input ${status_message ? '' : 'hidden'}" @click=${ev => el.clearStatusMessage(ev)}></converse-icon> + </div> + </div> + <button type="submit" class="btn btn-primary">${label_save}</button> + </form>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js new file mode 100644 index 0000000..550493b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/password-reset.js @@ -0,0 +1,49 @@ +import { __ } from 'i18n'; +import { html } from 'lit'; + +export default el => { + const i18n_submit = __('Submit'); + const i18n_passwords_must_match = __('The new passwords must match'); + const i18n_new_password = __('New password'); + const i18n_confirm_password = __('Confirm new password'); + + return html`<form class="converse-form passwordreset-form" method="POST" @submit=${ev => el.onSubmit(ev)}> + ${el.alert_message ? html`<div class="alert alert-danger" role="alert">${el.alert_message}</div>` : ''} + + <div class="form-group"> + <label for="converse_password_reset_new">${i18n_new_password}</label> + <input + class="form-control ${el.passwords_mismatched ? 'error' : ''}" + type="password" + value="" + name="password" + required="required" + id="converse_password_reset_new" + autocomplete="new-password" + minlength="8" + ?disabled="${el.alert_message}" + /> + </div> + <div class="form-group"> + <label for="converse_password_reset_check">${i18n_confirm_password}</label> + <input + class="form-control ${el.passwords_mismatched ? 'error' : ''}" + type="password" + value="" + name="password_check" + required="required" + id="converse_password_reset_check" + autocomplete="new-password" + minlength="8" + ?disabled="${el.alert_message}" + @input=${ev => el.checkPasswordsMatch(ev)} + /> + ${el.passwords_mismatched ? html`<span class="error">${i18n_passwords_must_match}</span>` : ''} + </div> + + <input class="save-form btn btn-primary" + type="submit" + value=${i18n_submit} + ?disabled="${el.alert_message}" /> + </form>`; +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js new file mode 100644 index 0000000..a0ab723 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile.js @@ -0,0 +1,57 @@ +import 'shared/avatar/avatar.js'; +import { __ } from 'i18n'; +import { api } from "@converse/headless/core"; +import { getPrettyStatus, logOut } from '../utils.js'; +import { html } from "lit"; + + +function tplSignout () { + const i18n_logout = __('Log out'); + return html`<a class="controlbox-heading__btn logout align-self-center" title="${i18n_logout}" @click=${logOut}> + <converse-icon class="fa fa-sign-out-alt" size="1em"></converse-icon> + </a>` +} + +function tplUserSettingsButton (o) { + const i18n_details = __('Show details about this chat client'); + return html`<a class="controlbox-heading__btn show-client-info align-self-center" title="${i18n_details}" @click=${o.showUserSettingsModal}> + <converse-icon class="fa fa-cog" size="1em"></converse-icon> + </a>`; +} + +export default (el) => { + const chat_status = el.model.get('status') || 'offline'; + const status_message = el.model.get('status_message') || __("I am %1$s", getPrettyStatus(chat_status)); + const i18n_change_status = __('Click to change your chat status'); + const show_settings_button = api.settings.get('show_client_info') || api.settings.get('allow_adhoc_commands'); + let classes, color; + if (chat_status === 'online') { + [classes, color] = ['fa fa-circle chat-status', 'chat-status-online']; + } else if (chat_status === 'dnd') { + [classes, color] = ['fa fa-minus-circle chat-status', 'chat-status-busy']; + } else if (chat_status === 'away') { + [classes, color] = ['fa fa-circle chat-status', 'chat-status-away']; + } else { + [classes, color] = ['fa fa-circle chat-status', 'subdued-color']; + } + return html` + <div class="userinfo controlbox-padded"> + <div class="controlbox-section profile d-flex"> + <a class="show-profile" href="#" @click=${el.showProfileModal}> + <converse-avatar class="avatar align-self-center" + .data=${el.model.vcard?.attributes} + nonce=${el.model.vcard?.get('vcard_updated')} + height="40" width="40"></converse-avatar> + </a> + <span class="username w-100 align-self-center">${el.model.getDisplayName()}</span> + ${show_settings_button ? tplUserSettingsButton(el) : ''} + ${api.settings.get('allow_logout') ? tplSignout() : ''} + </div> + <div class="d-flex xmpp-status"> + <a class="change-status" title="${i18n_change_status}" data-toggle="modal" data-target="#changeStatusModal" @click=${el.showStatusChangeModal}> + <span class="${chat_status} w-100 align-self-center" data-value="${chat_status}"> + <converse-icon color="var(--${color})" style="margin-top: -0.1em" size="0.82em" class="${classes}"></converse-icon> ${status_message}</span> + </a> + </div> + </div>` +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js new file mode 100644 index 0000000..e67eb01 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/templates/profile_modal.js @@ -0,0 +1,120 @@ +import "shared/components/image-picker.js"; +import { __ } from 'i18n'; +import { _converse } from "@converse/headless/core"; +import { html } from "lit"; + + +const tplOmemoPage = (el) => html` + <div class="tab-pane ${ el.tab === 'omemo' ? 'active' : ''}" id="omemo-tabpanel" role="tabpanel" aria-labelledby="omemo-tab"> + ${ el.tab === 'omemo' ? html`<converse-omemo-profile></converse-omemo-profile>` : '' } + </div>`; + + +export default (el) => { + const o = { ...el.model.toJSON(), ...el.model.vcard.toJSON() }; + const i18n_email = __('Email'); + const i18n_fullname = __('Full Name'); + const i18n_jid = __('XMPP Address'); + const i18n_nickname = __('Nickname'); + const i18n_role = __('Role'); + const i18n_save = __('Save and close'); + const i18n_role_help = __('Use commas to separate multiple roles. Your roles are shown next to your name on your chat messages.'); + const i18n_url = __('URL'); + + const i18n_omemo = __('OMEMO'); + const i18n_profile = __('Profile'); + const ii18n_reset_password = __('Reset Password'); + + const navigation_tabs = [ + html`<li role="presentation" class="nav-item"> + <a class="nav-link ${el.tab === "profile" ? "active" : ""}" + id="profile-tab" + href="#profile-tabpanel" + aria-controls="profile-tabpanel" + role="tab" + @click=${ev => el.switchTab(ev)} + data-name="profile" + data-toggle="tab">${ i18n_profile }</a> + </li>` + ]; + + navigation_tabs.push( + html`<li role="presentation" class="nav-item"> + <a class="nav-link ${el.tab === "passwordreset" ? "active" : ""}" + id="passwordreset-tab" + href="#passwordreset-tabpanel" + aria-controls="passwordreset-tabpanel" + role="tab" + @click=${ev => el.switchTab(ev)} + data-name="passwordreset" + data-toggle="tab">${ ii18n_reset_password }</a> + </li>` + ); + + if (_converse.pluggable.plugins['converse-omemo']?.enabled(_converse)) { + navigation_tabs.push( + html`<li role="presentation" class="nav-item"> + <a class="nav-link ${el.tab === "omemo" ? "active" : ""}" + id="omemo-tab" + href="#omemo-tabpanel" + aria-controls="omemo-tabpanel" + role="tab" + @click=${ev => el.switchTab(ev)} + data-name="omemo" + data-toggle="tab">${ i18n_omemo }</a> + </li>` + ); + } + + return html` + <ul class="nav nav-pills justify-content-center">${navigation_tabs}</ul> + <div class="tab-content"> + <div class="tab-pane ${ el.tab === 'profile' ? 'active' : ''}" id="profile-tabpanel" role="tabpanel" aria-labelledby="profile-tab"> + <form class="converse-form converse-form--modal profile-form" action="#" @submit=${ev => el.onFormSubmitted(ev)}> + <div class="row"> + <div class="col-auto"> + <converse-image-picker .data="${{image: o.image, image_type: o.image_type}}" width="128" height="128"></converse-image-picker> + </div> + <div class="col"> + <div class="form-group"> + <label class="col-form-label">${i18n_jid}:</label> + <div>${o.jid}</div> + </div> + </div> + </div> + <div class="form-group"> + <label for="vcard-fullname" class="col-form-label">${i18n_fullname}:</label> + <input id="vcard-fullname" type="text" class="form-control" name="fn" value="${o.fullname || ''}"/> + </div> + <div class="form-group"> + <label for="vcard-nickname" class="col-form-label">${i18n_nickname}:</label> + <input id="vcard-nickname" type="text" class="form-control" name="nickname" value="${o.nickname || ''}"/> + </div> + <div class="form-group"> + <label for="vcard-url" class="col-form-label">${i18n_url}:</label> + <input id="vcard-url" type="url" class="form-control" name="url" value="${o.url || ''}"/> + </div> + <div class="form-group"> + <label for="vcard-email" class="col-form-label">${i18n_email}:</label> + <input id="vcard-email" type="email" class="form-control" name="email" value="${o.email || ''}"/> + </div> + <div class="form-group"> + <label for="vcard-role" class="col-form-label">${i18n_role}:</label> + <input id="vcard-role" type="text" class="form-control" name="role" value="${o.role || ''}" aria-describedby="vcard-role-help"/> + <small id="vcard-role-help" class="form-text text-muted">${i18n_role_help}</small> + </div> + <hr/> + <div class="form-group"> + <button type="submit" class="save-form btn btn-primary">${i18n_save}</button> + </div> + </form> + </div> + + <div class="tab-pane ${ el.tab === 'passwordreset' ? 'active' : ''}" id="passwordreset-tabpanel" role="tabpanel" aria-labelledby="passwordreset-tab"> + ${ el.tab === 'passwordreset' ? html`<converse-change-password-form></converse-change-password-form>` : '' } + </div> + + ${ _converse.pluggable.plugins['converse-omemo']?.enabled(_converse) ? tplOmemoPage(el) : '' } + </div> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js new file mode 100644 index 0000000..421f14d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/password-reset.js @@ -0,0 +1,158 @@ +/*global mock, converse */ + +const { Strophe, u } = converse.env; + +async function submitPasswordResetForm (_converse) { + await mock.openControlBox(_converse); + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('a.show-profile')?.click(); + const modal = _converse.api.modal.get('converse-profile-modal'); + await u.waitUntil(() => u.isVisible(modal)); + + modal.querySelector('#passwordreset-tab').click(); + const form = await u.waitUntil(() => modal.querySelector('.passwordreset-form')); + + const pw_input = form.querySelector('input[name="password"]'); + pw_input.value = 'secret-password'; + const pw_check_input = form.querySelector('input[name="password_check"]'); + pw_check_input.value = 'secret-password'; + form.querySelector('input[type="submit"]').click(); + + return modal; +} + + +describe('The profile modal', function () { + it( + 'allows you to reset your password', + mock.initConverse([], {}, async function (_converse) { + await submitPasswordResetForm(_converse); + + const sent_IQs = _converse.connection.IQ_stanzas; + const query_iq = await u.waitUntil(() => + sent_IQs.filter(iq => iq.querySelector('iq[type="get"] query[xmlns="jabber:iq:register"]')).pop() + ); + expect(Strophe.serialize(query_iq)).toBe( + `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` + + `<query xmlns="jabber:iq:register"/>` + + `</iq>` + ); + + _converse.connection._dataRecv( + mock.createRequest( + u.toStanza(` + <iq type='result' id='${query_iq.getAttribute('id')}'> + <query xmlns='jabber:iq:register'> + <username>romeo@montague.lit</username> + <password/> + </query> + </iq>`) + ) + ); + + const set_iq = await u.waitUntil(() => + sent_IQs.filter(iq => iq.querySelector('iq[type="set"] query[xmlns="jabber:iq:register"]')).pop() + ); + expect(Strophe.serialize(set_iq)).toBe( + `<iq id="${set_iq.getAttribute('id')}" to="${_converse.domain}" type="set" xmlns="jabber:client">` + + `<query xmlns="jabber:iq:register">` + + `<username>romeo@montague.lit</username>` + + `<password>secret-password</password>` + + `</query>` + + `</iq>` + ); + + _converse.connection._dataRecv( + mock.createRequest(u.toStanza(`<iq type='result' id='${set_iq.getAttribute('id')}'></iq>`)) + ); + + const alert = await u.waitUntil(() => document.querySelector('converse-alert-modal')); + await u.waitUntil(() => u.isVisible(alert)); + expect(alert.querySelector('.modal-title').textContent).toBe('Success'); + }) + ); + + it( + 'informs you if you cannot reset your password due to in-band registration not being supported', + mock.initConverse([], {}, async function (_converse) { + const modal = await submitPasswordResetForm(_converse); + + const sent_IQs = _converse.connection.IQ_stanzas; + const query_iq = await u.waitUntil(() => + sent_IQs.filter(iq => iq.querySelector('query[xmlns="jabber:iq:register"]')).pop() + ); + + expect(Strophe.serialize(query_iq)).toBe( + `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` + + `<query xmlns="jabber:iq:register"/>` + + `</iq>` + ); + + _converse.connection._dataRecv( + mock.createRequest( + u.toStanza(` + <iq type='result' id="${query_iq.getAttribute('id')}"> + <error type="cancel"><service-unavailable xmlns="${Strophe.NS.STANZAS}"/></error> + </iq>`) + ) + ); + + const alert = await u.waitUntil(() => modal.querySelector('.alert-danger')); + expect(alert.textContent).toBe('Your server does not support in-band password reset'); + }) + ); + + it( + 'informs you if you\'re not allowed to reset your password', + mock.initConverse([], {}, async function (_converse) { + const modal = await submitPasswordResetForm(_converse); + + const sent_IQs = _converse.connection.IQ_stanzas; + const query_iq = await u.waitUntil(() => + sent_IQs.filter(iq => iq.querySelector('query[xmlns="jabber:iq:register"]')).pop() + ); + + expect(Strophe.serialize(query_iq)).toBe( + `<iq id="${query_iq.getAttribute('id')}" to="${_converse.domain}" type="get" xmlns="jabber:client">` + + `<query xmlns="jabber:iq:register"/>` + + `</iq>` + ); + + _converse.connection._dataRecv( + mock.createRequest( + u.toStanza(` + <iq type='result' id='${query_iq.getAttribute('id')}'> + <query xmlns='jabber:iq:register'> + <username>romeo@montague.lit</username> + <password/> + </query> + </iq>`) + ) + ); + + const set_iq = await u.waitUntil(() => + sent_IQs.filter(iq => iq.querySelector('iq[type="set"] query[xmlns="jabber:iq:register"]')).pop() + ); + expect(Strophe.serialize(set_iq)).toBe( + `<iq id="${set_iq.getAttribute('id')}" to="${_converse.domain}" type="set" xmlns="jabber:client">` + + `<query xmlns="jabber:iq:register">` + + `<username>romeo@montague.lit</username>` + + `<password>secret-password</password>` + + `</query>` + + `</iq>` + ); + + _converse.connection._dataRecv( + mock.createRequest( + u.toStanza(` + <iq type='result' id="${set_iq.getAttribute('id')}"> + <error type="modify"><forbidden xmlns="${Strophe.NS.STANZAS}"/></error> + </iq>`) + ) + ); + + const alert = await u.waitUntil(() => modal.querySelector('.alert-danger')); + expect(alert.textContent).toBe('You are not allowed to change your password'); + }) + ); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js new file mode 100644 index 0000000..38e2a78 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/profile.js @@ -0,0 +1,17 @@ +/*global mock, converse */ + +const u = converse.env.utils; + +describe("The Controlbox", function () { + describe("The user profile", function () { + + it("shows the user's configured nickname", + mock.initConverse([], { blacklisted_plugins: ['converse-vcard'], nickname: 'nicky'}, + async function (_converse) { + + mock.openControlBox(_converse); + const el = await u.waitUntil(() => document.querySelector('converse-user-profile .username')); + expect(el.textContent).toBe('nicky'); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js new file mode 100644 index 0000000..18a2069 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/tests/status.js @@ -0,0 +1,69 @@ +/*global mock, converse */ + +const u = converse.env.utils; +const Strophe = converse.env.Strophe; + +describe("The Controlbox", function () { + describe("The Status Widget", function () { + + it("shows the user's chat status, which is online by default", + mock.initConverse([], {}, async function (_converse) { + mock.openControlBox(_converse); + const view = await u.waitUntil(() => document.querySelector('converse-user-profile')); + expect(u.hasClass('online', view.querySelector('.xmpp-status span:first-child'))).toBe(true); + expect(view.querySelector('.xmpp-status span.online').textContent.trim()).toBe('I am online'); + })); + + it("can be used to set the current user's chat status", + mock.initConverse([], {}, async function (_converse) { + + await mock.openControlBox(_converse); + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.change-status').click() + const modal = _converse.api.modal.get('converse-chat-status-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd" + modal.querySelector('[type="submit"]').click(); + const sent_stanzas = _converse.connection.sent_stanzas; + const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop()); + expect(Strophe.serialize(sent_presence)).toBe( + `<presence xmlns="jabber:client">`+ + `<show>dnd</show>`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`); + const view = await u.waitUntil(() => document.querySelector('converse-user-profile')); + const first_child = view.querySelector('.xmpp-status span:first-child'); + expect(u.hasClass('online', first_child)).toBe(false); + expect(u.hasClass('dnd', first_child)).toBe(true); + expect(view.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe('I am busy'); + })); + + it("can be used to set a custom status message", + mock.initConverse([], {}, async function (_converse) { + + await mock.openControlBox(_converse); + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.change-status').click() + const modal = _converse.api.modal.get('converse-chat-status-modal'); + + await u.waitUntil(() => u.isVisible(modal), 1000); + const msg = 'I am happy'; + modal.querySelector('input[name="status_message"]').value = msg; + modal.querySelector('[type="submit"]').click(); + const sent_stanzas = _converse.connection.sent_stanzas; + const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop()); + expect(Strophe.serialize(sent_presence)).toBe( + `<presence xmlns="jabber:client">`+ + `<status>I am happy</status>`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`); + + const view = await u.waitUntil(() => document.querySelector('converse-user-profile')); + const first_child = view.querySelector('.xmpp-status span:first-child'); + expect(u.hasClass('online', first_child)).toBe(true); + expect(view.querySelector('.xmpp-status span:first-child').textContent.trim()).toBe(msg); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js new file mode 100644 index 0000000..96b1d63 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/profile/utils.js @@ -0,0 +1,28 @@ +import { __ } from 'i18n'; +import { api, converse, _converse } from '@converse/headless/core'; + +const { Strophe, $iq, sizzle, u } = converse.env; + +export function getPrettyStatus (stat) { + if (stat === 'chat') { + return __('online'); + } else if (stat === 'dnd') { + return __('busy'); + } else if (stat === 'xa') { + return __('away for long'); + } else if (stat === 'away') { + return __('away'); + } else if (stat === 'offline') { + return __('offline'); + } else { + return __(stat) || __('online'); + } +} + +export async function logOut (ev) { + ev?.preventDefault(); + const result = await api.confirm(__("Are you sure you want to log out?")); + if (result) { + api.user.logout(); + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/push/index.js b/roles/reverseproxy/files/conversejs/src/plugins/push/index.js new file mode 100644 index 0000000..f74207c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/push/index.js @@ -0,0 +1,31 @@ +/** + * @description + * Converse.js plugin which add support for registering + * an "App Server" as defined in XEP-0357 + * @copyright 2021, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import { _converse, api, converse } from '@converse/headless/core'; +import { enablePush, onChatBoxAdded } from './utils.js'; + +const { Strophe } = converse.env; + +Strophe.addNamespace('PUSH', 'urn:xmpp:push:0'); + +converse.plugins.add('converse-push', { + initialize () { + /* The initialize function gets called as soon as the plugin is + * loaded by converse.js's plugin machinery. + */ + api.settings.extend({ + 'push_app_servers': [], + 'enable_muc_push': false, + }); + + api.listen.on('statusInitialized', () => enablePush()); + + if (api.settings.get('enable_muc_push')) { + api.listen.on('chatBoxesInitialized', () => _converse.chatboxes.on('add', onChatBoxAdded)); + } + }, +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js b/roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js new file mode 100644 index 0000000..db79324 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/push/tests/push.js @@ -0,0 +1,181 @@ +/*global mock, converse */ + +const $iq = converse.env.$iq; +const Strophe = converse.env.Strophe; +const sizzle = converse.env.sizzle; +const u = converse.env.utils; +const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("XEP-0357 Push Notifications", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + it("can be enabled", + mock.initConverse( + [], { + 'push_app_servers': [{ + 'jid': 'push-5@client.example', + 'node': 'yxs32uqsflafdk3iuqo' + }] + }, async function (_converse) { + + const { api } = _converse; + const IQ_stanzas = _converse.connection.IQ_stanzas; + expect(_converse.session.get('push_enabled')).toBeFalsy(); + + await mock.waitUntilDiscoConfirmed( + _converse, api.settings.get('push_app_servers')[0].jid, + [{'category': 'pubsub', 'type':'push'}], + ['urn:xmpp:push:0'], [], 'info'); + await mock.waitUntilDiscoConfirmed( + _converse, + _converse.bare_jid, + [{'category': 'account', 'type':'registered'}], + ['urn:xmpp:push:0'], [], 'info'); + const stanza = await u.waitUntil(() => + IQ_stanzas.filter(iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop() + ); + expect(Strophe.serialize(stanza)).toEqual( + `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+ + '</iq>' + ) + _converse.connection._dataRecv(mock.createRequest($iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': stanza.getAttribute('id') + }))); + await u.waitUntil(() => _converse.session.get('push_enabled')); + })); + + it("can be enabled for a MUC domain", + mock.initConverse( + [], { + 'enable_muc_push': true, + 'push_app_servers': [{ + 'jid': 'push-5@client.example', + 'node': 'yxs32uqsflafdk3iuqo' + }] + }, async function (_converse) { + + const { api } = _converse; + const IQ_stanzas = _converse.connection.IQ_stanzas; + await mock.waitUntilDiscoConfirmed( + _converse, api.settings.get('push_app_servers')[0].jid, + [{'category': 'pubsub', 'type':'push'}], + ['urn:xmpp:push:0'], [], 'info'); + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, [], + ['urn:xmpp:push:0']); + + let iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(`iq[type="set"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length).pop()); + + expect(Strophe.serialize(iq)).toBe( + `<iq id="${iq.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>`+ + `</iq>` + ); + const result = u.toStanza(`<iq type="result" id="${iq.getAttribute('id')}" to="romeo@montague.lit" />`); + _converse.connection._dataRecv(mock.createRequest(result)); + + await u.waitUntil(() => _converse.session.get('push_enabled')); + expect(_converse.session.get('push_enabled').length).toBe(1); + expect(_converse.session.get('push_enabled').includes('romeo@montague.lit')).toBe(true); + + mock.openAndEnterChatRoom(_converse, 'coven@chat.shakespeare.lit', 'oldhag'); + await mock.waitUntilDiscoConfirmed( + _converse, 'chat.shakespeare.lit', + [{'category': 'account', 'type':'registered'}], + ['urn:xmpp:push:0'], [], 'info'); + iq = await u.waitUntil(() => IQ_stanzas.filter(iq => sizzle(`iq[type="set"][to="chat.shakespeare.lit"] enable[xmlns="${Strophe.NS.PUSH}"]`, iq).length + ).pop()); + + expect(Strophe.serialize(iq)).toEqual( + `<iq id="${iq.getAttribute('id')}" to="chat.shakespeare.lit" type="set" xmlns="jabber:client">`+ + '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+ + '</iq>' + ); + _converse.connection._dataRecv(mock.createRequest($iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': iq.getAttribute('id') + }))); + await u.waitUntil(() => _converse.session.get('push_enabled').includes('chat.shakespeare.lit')); + })); + + it("can be disabled", + mock.initConverse( + ['chatBoxesFetched'], { + 'push_app_servers': [{ + 'jid': 'push-5@client.example', + 'node': 'yxs32uqsflafdk3iuqo', + 'disable': true + }] + }, async function (_converse) { + + const IQ_stanzas = _converse.connection.IQ_stanzas; + expect(_converse.session.get('push_enabled')).toBeFalsy(); + + await mock.waitUntilDiscoConfirmed( + _converse, + _converse.bare_jid, + [{'category': 'account', 'type':'registered'}], + ['urn:xmpp:push:0'], [], 'info'); + const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[type="set"] disable[xmlns="urn:xmpp:push:0"]')).pop()); + expect(Strophe.serialize(stanza)).toEqual( + `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + '<disable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0"/>'+ + '</iq>' + ); + _converse.connection._dataRecv(mock.createRequest($iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': stanza.getAttribute('id') + }))); + await u.waitUntil(() => _converse.session.get('push_enabled')) + })); + + + it("can require a secret token to be included", + mock.initConverse([], { + 'push_app_servers': [{ + 'jid': 'push-5@client.example', + 'node': 'yxs32uqsflafdk3iuqo', + 'secret': 'eruio234vzxc2kla-91' + }] + }, async function (_converse) { + + const { api } = _converse; + const IQ_stanzas = _converse.connection.IQ_stanzas; + expect(_converse.session.get('push_enabled')).toBeFalsy(); + + await mock.waitUntilDiscoConfirmed( + _converse, api.settings.get('push_app_servers')[0].jid, + [{'category': 'pubsub', 'type':'push'}], + ['urn:xmpp:push:0'], [], 'info'); + await mock.waitUntilDiscoConfirmed( + _converse, + _converse.bare_jid, + [{'category': 'account', 'type':'registered'}], + ['urn:xmpp:push:0'], [], 'info'); + + const stanza = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector('iq[type="set"] enable[xmlns="urn:xmpp:push:0"]')).pop()); + expect(Strophe.serialize(stanza)).toEqual( + `<iq id="${stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + '<enable jid="push-5@client.example" node="yxs32uqsflafdk3iuqo" xmlns="urn:xmpp:push:0">'+ + '<x type="submit" xmlns="jabber:x:data">'+ + '<field var="FORM_TYPE"><value>http://jabber.org/protocol/pubsub#publish-options</value></field>'+ + '<field var="secret"><value>eruio234vzxc2kla-91</value></field>'+ + '</x>'+ + '</enable>'+ + '</iq>' + ) + _converse.connection._dataRecv(mock.createRequest($iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': stanza.getAttribute('id') + }))); + await u.waitUntil(() => _converse.session.get('push_enabled')) + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/push/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/push/utils.js new file mode 100644 index 0000000..22995b1 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/push/utils.js @@ -0,0 +1,94 @@ +import log from "@converse/headless/log"; +import { _converse, api, converse } from "@converse/headless/core"; + +const { Strophe, $iq } = converse.env; + +async function disablePushAppServer (domain, push_app_server) { + if (!push_app_server.jid) { + return; + } + if (!(await api.disco.supports(Strophe.NS.PUSH, domain || _converse.bare_jid))) { + log.warn(`Not disabling push app server "${push_app_server.jid}", no disco support from your server.`); + return; + } + const stanza = $iq({'type': 'set'}); + if (domain !== _converse.bare_jid) { + stanza.attrs({'to': domain}); + } + stanza.c('disable', { + 'xmlns': Strophe.NS.PUSH, + 'jid': push_app_server.jid, + }); + if (push_app_server.node) { + stanza.attrs({'node': push_app_server.node}); + } + api.sendIQ(stanza) + .catch(e => { + log.error(`Could not disable push app server for ${push_app_server.jid}`); + log.error(e); + }); +} + +async function enablePushAppServer (domain, push_app_server) { + if (!push_app_server.jid || !push_app_server.node) { + return; + } + const identity = await api.disco.getIdentity('pubsub', 'push', push_app_server.jid); + if (!identity) { + return log.warn( + `Not enabling push the service "${push_app_server.jid}", it doesn't have the right disco identtiy.` + ); + } + const result = await Promise.all([ + api.disco.supports(Strophe.NS.PUSH, push_app_server.jid), + api.disco.supports(Strophe.NS.PUSH, domain) + ]); + if (!result[0] && !result[1]) { + log.warn(`Not enabling push app server "${push_app_server.jid}", no disco support from your server.`); + return; + } + const stanza = $iq({'type': 'set'}); + if (domain !== _converse.bare_jid) { + stanza.attrs({'to': domain}); + } + stanza.c('enable', { + 'xmlns': Strophe.NS.PUSH, + 'jid': push_app_server.jid, + 'node': push_app_server.node + }); + if (push_app_server.secret) { + stanza.c('x', {'xmlns': Strophe.NS.XFORM, 'type': 'submit'}) + .c('field', {'var': 'FORM_TYPE'}) + .c('value').t(`${Strophe.NS.PUBSUB}#publish-options`).up().up() + .c('field', {'var': 'secret'}) + .c('value').t(push_app_server.secret); + } + return api.sendIQ(stanza); +} + +export async function enablePush (domain) { + domain = domain || _converse.bare_jid; + const push_enabled = _converse.session.get('push_enabled') || []; + if (push_enabled.includes(domain)) { + return; + } + const enabled_services = api.settings.get('push_app_servers').filter(s => !s.disable); + const disabled_services = api.settings.get('push_app_servers').filter(s => s.disable); + const enabled = enabled_services.map(s => enablePushAppServer(domain, s)); + const disabled = disabled_services.map(s => disablePushAppServer(domain, s)); + try { + await Promise.all(enabled.concat(disabled)); + } catch (e) { + log.error('Could not enable or disable push App Server'); + if (e) log.error(e); + } finally { + push_enabled.push(domain); + } + _converse.session.save('push_enabled', push_enabled); +} + +export function onChatBoxAdded (model) { + if (model.get('type') == _converse.CHATROOMS_TYPE) { + enablePush(Strophe.getDomainFromJid(model.get('jid'))); + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/index.js b/roles/reverseproxy/files/conversejs/src/plugins/register/index.js new file mode 100644 index 0000000..89e046d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/index.js @@ -0,0 +1,54 @@ +/** + * @module converse-register + * @description + * This is a Converse.js plugin which add support for in-band registration + * as specified in XEP-0077. + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import './panel.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from '@converse/headless/core'; +import { setActiveForm } from './utils.js'; +import { CONNECTION_STATUS } from '@converse/headless/shared/constants'; + +// Strophe methods for building stanzas +const { Strophe } = converse.env; + +// Add Strophe Namespaces +Strophe.addNamespace('REGISTER', 'jabber:iq:register'); + +// Add Strophe Statuses +const i = Object.keys(Strophe.Status).reduce((max, k) => Math.max(max, Strophe.Status[k]), 0); +Strophe.Status.REGIFAIL = i + 1; +Strophe.Status.REGISTERED = i + 2; +Strophe.Status.CONFLICT = i + 3; +Strophe.Status.NOTACCEPTABLE = i + 5; + +converse.plugins.add('converse-register', { + + dependencies: ['converse-controlbox'], + + enabled () { + return true; + }, + + initialize () { + const { router } = _converse; + + CONNECTION_STATUS[Strophe.Status.REGIFAIL] = 'REGIFAIL'; + CONNECTION_STATUS[Strophe.Status.REGISTERED] = 'REGISTERED'; + CONNECTION_STATUS[Strophe.Status.CONFLICT] = 'CONFLICT'; + CONNECTION_STATUS[Strophe.Status.NOTACCEPTABLE] = 'NOTACCEPTABLE'; + + api.settings.extend({ + 'allow_registration': true, + 'domain_placeholder': __(' e.g. conversejs.org'), // Placeholder text shown in the domain input on the registration form + 'providers_link': 'https://compliance.conversations.im/', // Link to XMPP providers shown on registration page + 'registration_domain': '' + }); + + router.route('converse/login', () => setActiveForm('login')); + router.route('converse/register', () => setActiveForm('register')); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/panel.js b/roles/reverseproxy/files/conversejs/src/plugins/register/panel.js new file mode 100644 index 0000000..c05e27a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/panel.js @@ -0,0 +1,434 @@ +import log from "@converse/headless/log"; +import tplFormInput from "templates/form_input.js"; +import tplFormUrl from "templates/form_url.js"; +import tplFormUsername from "templates/form_username.js"; +import tplRegisterPanel from "./templates/register_panel.js"; +import { CONNECTION_STATUS } from '@converse/headless/shared/constants'; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core.js"; +import { initConnection } from '@converse/headless/utils/init.js'; +import { setActiveForm } from './utils.js'; +import { webForm2xForm } from "@converse/headless/utils/form"; + +import './styles/register.scss'; + +// Strophe methods for building stanzas +const { Strophe, sizzle, $iq } = converse.env; +const u = converse.env.utils; + + +const CHOOSE_PROVIDER = 0; +const FETCHING_FORM = 1; +const REGISTRATION_FORM = 2; +const REGISTRATION_FORM_ERROR = 3; + + +/** + * @class + * @namespace _converse.RegisterPanel + * @memberOf _converse + */ +class RegisterPanel extends CustomElement { + + static get properties () { + return { + status : { type: String }, + alert_message: { type: String }, + alert_type: { type: String }, + } + } + + constructor () { + super(); + this.alert_type = 'info'; + this.setErrorMessage = (m) => this.setMessage(m, 'danger'); + this.setFeedbackMessage = (m) => this.setMessage(m, 'info'); + } + + initialize () { + this.reset(); + this.listenTo(_converse, 'connectionInitialized', () => this.registerHooks()); + + const domain = api.settings.get('registration_domain'); + if (domain) { + this.fetchRegistrationForm(domain); + } else { + this.status = CHOOSE_PROVIDER; + } + } + + render () { + return tplRegisterPanel(this); + } + + setMessage(message, type) { + this.alert_type = type; + this.alert_message = message; + } + + /** + * Hook into Strophe's _connect_cb, so that we can send an IQ + * requesting the registration fields. + */ + registerHooks () { + const conn = _converse.connection; + const connect_cb = conn._connect_cb.bind(conn); + conn._connect_cb = (req, callback, raw) => { + if (!this._registering) { + connect_cb(req, callback, raw); + } else if (this.getRegistrationFields(req, callback)) { + this._registering = false; + } + }; + } + + /** + * Send an IQ stanza to the XMPP server asking for the registration fields. + * @method _converse.RegisterPanel#getRegistrationFields + * @param { Strophe.Request } req - The current request + * @param { Function } callback - The callback function + */ + getRegistrationFields (req, _callback) { + const conn = _converse.connection; + conn.connected = true; + + const body = conn._proto._reqToData(req); + if (!body) { return; } + if (conn._proto._connect_cb(body) === Strophe.Status.CONNFAIL) { + this.status = CHOOSE_PROVIDER; + this.setErrorMessage(__("Sorry, we're unable to connect to your chosen provider.")); + return false; + } + const register = body.getElementsByTagName("register"); + const mechanisms = body.getElementsByTagName("mechanism"); + if (register.length === 0 && mechanisms.length === 0) { + conn._proto._no_auth_received(_callback); + return false; + } + if (register.length === 0) { + conn._changeConnectStatus(Strophe.Status.REGIFAIL); + this.alert_type = 'danger'; + this.setErrorMessage( + __("Sorry, the given provider does not support in "+ + "band account registration. Please try with a "+ + "different provider.")); + return true; + } + // Send an IQ stanza to get all required data fields + conn._addSysHandler((s) => this.onRegistrationFields(s), null, "iq", null, null); + const stanza = $iq({type: "get"}).c("query", {xmlns: Strophe.NS.REGISTER}).tree(); + stanza.setAttribute("id", conn.getUniqueId("sendIQ")); + conn.send(stanza); + conn.connected = false; + return true; + } + + /** + * Handler for {@link _converse.RegisterPanel#getRegistrationFields} + * @method _converse.RegisterPanel#onRegistrationFields + * @param { Element } stanza - The query stanza. + */ + onRegistrationFields (stanza) { + if (stanza.getAttribute("type") === "error") { + this.reportErrors(stanza); + if (api.settings.get('registration_domain')) { + this.status = REGISTRATION_FORM_ERROR; + } else { + this.status = CHOOSE_PROVIDER; + } + return false; + } + this.setFields(stanza); + if (this.status === FETCHING_FORM) { + this.renderRegistrationForm(stanza); + } + return false; + } + + reset (settings) { + const defaults = { + fields: {}, + urls: [], + title: "", + instructions: "", + registered: false, + _registering: false, + domain: null, + form_type: null + }; + Object.assign(this, defaults); + if (settings) Object.assign(this, settings); + } + + /** + * Event handler when the #converse-register form is submitted. + * Depending on the available input fields, we delegate to other methods. + * @param { Event } ev + */ + onFormSubmission (ev) { + ev?.preventDefault?.(); + if (ev.target.querySelector('input[name=domain]') === null) { + this.submitRegistrationForm(ev.target); + } else { + this.onProviderChosen(ev.target); + } + + } + + /** + * Callback method that gets called when the user has chosen an XMPP provider + * @method _converse.RegisterPanel#onProviderChosen + * @param { HTMLElement } form - The form that was submitted + */ + onProviderChosen (form) { + const domain = form.querySelector('input[name=domain]')?.value; + if (domain) this.fetchRegistrationForm(domain.trim()); + } + + /** + * Fetch a registration form from the requested domain + * @method _converse.RegisterPanel#fetchRegistrationForm + * @param { String } domain_name - XMPP server domain + */ + fetchRegistrationForm (domain_name) { + this.status = FETCHING_FORM; + this.reset({ + 'domain': Strophe.getDomainFromJid(domain_name), + '_registering': true + }); + initConnection(this.domain); + // When testing, the test tears down before the async function + // above finishes. So we use optional chaining here + _converse.connection?.connect(this.domain, "", (s) => this.onConnectStatusChanged(s)); + return false; + } + + /** + * Callback function called by Strophe whenever the connection status changes. + * Passed to Strophe specifically during a registration attempt. + * @method _converse.RegisterPanel#onConnectStatusChanged + * @param { number } status_code - The Strophe.Status status code + */ + onConnectStatusChanged(status_code) { + log.debug('converse-register: onConnectStatusChanged'); + if ([Strophe.Status.DISCONNECTED, + Strophe.Status.CONNFAIL, + Strophe.Status.REGIFAIL, + Strophe.Status.NOTACCEPTABLE, + Strophe.Status.CONFLICT + ].includes(status_code)) { + + log.error( + `Problem during registration: Strophe.Status is ${CONNECTION_STATUS[status_code]}` + ); + this.abortRegistration(); + } else if (status_code === Strophe.Status.REGISTERED) { + log.debug("Registered successfully."); + _converse.connection.reset(); + + if (["converse/login", "converse/register"].includes(_converse.router.history.getFragment())) { + _converse.router.navigate('', {'replace': true}); + } + setActiveForm('login'); + + if (this.fields.password && this.fields.username) { + // automatically log the user in + _converse.connection.connect( + this.fields.username.toLowerCase()+'@'+this.domain.toLowerCase(), + this.fields.password, + _converse.onConnectStatusChanged + ); + this.setFeedbackMessage(__('Now logging you in')); + } else { + this.setFeedbackMessage(__('Registered successfully')); + } + this.reset(); + } + } + + getLegacyFormFields () { + const input_fields = Object.keys(this.fields).map(key => { + if (key === "username") { + return tplFormUsername({ + 'domain': ` @${this.domain}`, + 'name': key, + 'type': "text", + 'label': key, + 'value': '', + 'required': true + }); + } else { + return tplFormInput({ + 'label': key, + 'name': key, + 'placeholder': key, + 'required': true, + 'type': (key === 'password' || key === 'email') ? key : "text", + 'value': '' + }) + } + }); + const urls = this.urls.map(u => tplFormUrl({'label': '', 'value': u})); + return [...input_fields, ...urls]; + } + + getFormFields (stanza) { + if (this.form_type === 'xform') { + return Array.from(stanza.querySelectorAll('field')).map(field => + u.xForm2TemplateResult(field, stanza, {'domain': this.domain}) + ); + } else { + return this.getLegacyFormFields(); + } + } + + /** + * Renders the registration form based on the XForm fields + * received from the XMPP server. + * @method _converse.RegisterPanel#renderRegistrationForm + * @param { Element } stanza - The IQ stanza received from the XMPP server. + */ + renderRegistrationForm (stanza) { + this.form_fields = this.getFormFields(stanza); + this.status = REGISTRATION_FORM; + } + + /** + * Report back to the user any error messages received from the + * XMPP server after attempted registration. + * @method _converse.RegisterPanel#reportErrors + * @param { Element } stanza - The IQ stanza received from the XMPP server + */ + reportErrors (stanza) { + const errors = Array.from(stanza.querySelectorAll('error')); + if (errors.length) { + this.setErrorMessage(errors.reduce((result, e) => `${result}\n${e.textContent}`, '')); + } else { + this.setErrorMessage(__('The provider rejected your registration attempt. '+ + 'Please check the values you entered for correctness.')); + } + } + + renderProviderChoiceForm (ev) { + ev?.preventDefault?.(); + _converse.connection._proto._abortAllRequests(); + _converse.connection.reset(); + this.status = CHOOSE_PROVIDER; + } + + abortRegistration () { + _converse.connection._proto._abortAllRequests(); + _converse.connection.reset(); + if ([FETCHING_FORM, REGISTRATION_FORM].includes(this.status)) { + if (api.settings.get('registration_domain')) { + this.fetchRegistrationForm(api.settings.get('registration_domain')); + } + } else { + this.requestUpdate(); + } + } + + /** + * Handler, when the user submits the registration form. + * Provides form error feedback or starts the registration process. + * @method _converse.RegisterPanel#submitRegistrationForm + * @param { HTMLElement } form - The HTML form that was submitted + */ + submitRegistrationForm (form) { + const inputs = sizzle(':input:not([type=button]):not([type=submit])', form); + const iq = $iq({'type': 'set', 'id': u.getUniqueId()}) + .c("query", {xmlns:Strophe.NS.REGISTER}); + + if (this.form_type === 'xform') { + iq.c("x", {xmlns: Strophe.NS.XFORM, type: 'submit'}); + + const xml_nodes = inputs.map(i => webForm2xForm(i)).filter(n => n); + xml_nodes.forEach(n => iq.cnode(n).up()); + } else { + inputs.forEach(input => iq.c(input.getAttribute('name'), {}, input.value)); + } + _converse.connection._addSysHandler((iq) => this._onRegisterIQ(iq), null, "iq", null, null); + _converse.connection.send(iq); + this.setFields(iq.tree()); + } + + /** + * Stores the values that will be sent to the XMPP server during attempted registration. + * @method _converse.RegisterPanel#setFields + * @param { Element } stanza - the IQ stanza that will be sent to the XMPP server. + */ + setFields (stanza) { + const query = stanza.querySelector('query'); + const xform = sizzle(`x[xmlns="${Strophe.NS.XFORM}"]`, query); + if (xform.length > 0) { + this._setFieldsFromXForm(xform.pop()); + } else { + this._setFieldsFromLegacy(query); + } + } + + _setFieldsFromLegacy (query) { + [].forEach.call(query.children, field => { + if (field.tagName.toLowerCase() === 'instructions') { + this.instructions = Strophe.getText(field); + return; + } else if (field.tagName.toLowerCase() === 'x') { + if (field.getAttribute('xmlns') === 'jabber:x:oob') { + this.urls.concat(sizzle('url', field).map(u => u.textContent)); + } + return; + } + this.fields[field.tagName.toLowerCase()] = Strophe.getText(field); + }); + this.form_type = 'legacy'; + } + + _setFieldsFromXForm (xform) { + this.title = xform.querySelector('title')?.textContent ?? ''; + this.instructions = xform.querySelector('instructions')?.textContent ?? ''; + xform.querySelectorAll('field').forEach(field => { + const _var = field.getAttribute('var'); + if (_var) { + this.fields[_var.toLowerCase()] = field.querySelector('value')?.textContent ?? ''; + } else { + // TODO: other option seems to be type="fixed" + log.warn("Found field we couldn't parse"); + } + }); + this.form_type = 'xform'; + } + + /** + * Callback method that gets called when a return IQ stanza + * is received from the XMPP server, after attempting to + * register a new user. + * @method _converse.RegisterPanel#reportErrors + * @param { Element } stanza - The IQ stanza. + */ + _onRegisterIQ (stanza) { + if (stanza.getAttribute("type") === "error") { + log.error("Registration failed."); + this.reportErrors(stanza); + + let error = stanza.getElementsByTagName("error"); + if (error.length !== 1) { + _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, "unknown"); + return false; + } + error = error[0].firstElementChild.tagName.toLowerCase(); + if (error === 'conflict') { + _converse.connection._changeConnectStatus(Strophe.Status.CONFLICT, error); + } else if (error === 'not-acceptable') { + _converse.connection._changeConnectStatus(Strophe.Status.NOTACCEPTABLE, error); + } else { + _converse.connection._changeConnectStatus(Strophe.Status.REGIFAIL, error); + } + } else { + _converse.connection._changeConnectStatus(Strophe.Status.REGISTERED, null); + } + return false; + } +} + +api.elements.define('converse-register-panel', RegisterPanel); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss b/roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss new file mode 100644 index 0000000..aec3676 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/styles/register.scss @@ -0,0 +1,61 @@ +@import "shared/styles/_mixins.scss"; + +converse-register-panel { + .alert { + margin: auto; + max-width: 50vw; + } +} + +#converse-register { + @include fade-in; + background-color: var(--controlbox-pane-background-color); + + .title { + font-weight: bold; + } + + .input-group { + input { + height: auto; + } + .input-group-text { + color: var(--text-color); + background-color: var(--controlbox-pane-background-color); + } + } + + .info { + color: green; + font-size: 90%; + margin: 1.5em 0; + } + + .form-errors { + color: var(--error-color); + margin: 1em 0; + } + + .provider-title { + font-size: var(--font-size-huge); + margin: 0; + } + + .provider-score { + width: 178px; + margin-bottom: 8px; + } + + .form-help .url { + font-weight: bold; + color: var(--link-color); + } + + .instructions { + color: gray; + font-size: 85%; + &:hover { + color: var(--controlbox-text-color); + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js new file mode 100644 index 0000000..db56926 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/register_panel.js @@ -0,0 +1,86 @@ +import tplRegistrationForm from './registration_form.js'; +import tplSpinner from 'templates/spinner.js'; +import tplSwitchForm from './switch_form.js'; +import { __ } from 'i18n'; +import { api } from '@converse/headless/core'; +import { html } from 'lit'; + +const tplFormRequest = (el) => { + const default_domain = api.settings.get('registration_domain'); + const i18n_cancel = __('Cancel'); + return html` + <form id="converse-register" class="converse-form no-scrolling" @submit=${ev => el.onFormSubmission(ev)}> + ${tplSpinner({ 'classes': 'hor_centered' })} + ${default_domain + ? '' + : html` + <button class="btn btn-secondary button-cancel hor_centered" + @click=${ev => el.renderProviderChoiceForm(ev)}>${i18n_cancel}</button> + `} + </form> + `; +}; + +const tplDomainInput = () => { + const domain_placeholder = api.settings.get('domain_placeholder'); + const i18n_providers = __('Tip: A list of public XMPP providers is available'); + const i18n_providers_link = __('here'); + const href_providers = api.settings.get('providers_link'); + return html` + <input class="form-control" required="required" type="text" name="domain" placeholder="${domain_placeholder}" /> + <p class="form-text text-muted"> + ${i18n_providers} + <a href="${href_providers}" class="url" target="_blank" rel="noopener">${i18n_providers_link}</a>. + </p> + `; +}; + +const tplFetchFormButtons = () => { + const i18n_register = __('Fetch registration form'); + const i18n_existing_account = __('Already have a chat account?'); + const i18n_login = __('Log in here'); + return html` + <fieldset class="form-group buttons"> + <input class="btn btn-primary" type="submit" value="${i18n_register}" /> + </fieldset> + <div class="switch-form"> + <p>${i18n_existing_account}</p> + <p><a class="login-here toggle-register-login" href="#converse/login">${i18n_login}</a></p> + </div> + `; +}; + +const tplChooseProvider = (el) => { + const default_domain = api.settings.get('registration_domain'); + const i18n_create_account = __('Create your account'); + const i18n_choose_provider = __('Please enter the XMPP provider to register with:'); + const show_form_buttons = !default_domain && el.status === CHOOSE_PROVIDER; + + return html` + <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}> + <legend class="col-form-label">${i18n_create_account}</legend> + <div class="form-group"> + <label>${i18n_choose_provider}</label> + + ${default_domain ? default_domain : tplDomainInput()} + </div> + ${show_form_buttons ? tplFetchFormButtons() : ''} + </form> + `; +}; + +const CHOOSE_PROVIDER = 0; +const FETCHING_FORM = 1; +const REGISTRATION_FORM = 2; +const REGISTRATION_FORM_ERROR = 3; + +export default (el) => { + return html` + <converse-brand-logo></converse-brand-logo> + ${ el.alert_message ? html`<div class="alert alert-${el.alert_type}" role="alert">${el.alert_message}</div>` : '' } + ${el.status === CHOOSE_PROVIDER ? tplChooseProvider(el) : ''} + ${el.status === FETCHING_FORM ? tplFormRequest(el) : ''} + ${el.status === REGISTRATION_FORM ? tplRegistrationForm(el) : ''} + ${el.status === REGISTRATION_FORM_ERROR ? tplSwitchForm() : '' } + `; +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js new file mode 100644 index 0000000..b54a8b9 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/registration_form.js @@ -0,0 +1,40 @@ +import tplSwitchForm from './switch_form.js'; +import { __ } from 'i18n'; +import { api } from '@converse/headless/core'; +import { html } from 'lit'; + +export default (el) => { + const i18n_choose_provider = __('Choose a different provider'); + const i18n_legend = __('Account Registration:'); + const i18n_register = __('Register'); + const registration_domain = api.settings.get('registration_domain'); + + return html` + <form id="converse-register" class="converse-form" @submit=${ev => el.onFormSubmission(ev)}> + <legend class="col-form-label">${i18n_legend} ${el.domain}</legend> + <p class="title">${el.title}</p> + <p class="form-help instructions">${el.instructions}</p> + <div class="form-errors hidden"></div> + ${el.form_fields} + + <fieldset class="buttons form-group"> + ${el.fields + ? html` + <input type="submit" class="btn btn-primary" value="${i18n_register}" /> + ` + : ''} + ${registration_domain + ? '' + : html` + <input + type="button" + class="btn btn-secondary button-cancel" + value="${i18n_choose_provider}" + @click=${ev => el.renderProviderChoiceForm(ev)} + /> + `} + ${ tplSwitchForm() } + </fieldset> + </form> + `; +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js new file mode 100644 index 0000000..05aab6a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/templates/switch_form.js @@ -0,0 +1,12 @@ +import { __ } from 'i18n'; +import { html } from 'lit'; + +export default () => { + const i18n_has_account = __('Already have a chat account?'); + const i18n_login = __('Log in here'); + return html` + <div class="switch-form"> + <p>${i18n_has_account}</p> + <p><a class="login-here toggle-register-login" href="#converse/login">${i18n_login}</a></p> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js b/roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js new file mode 100644 index 0000000..2ed05cc --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/tests/register.js @@ -0,0 +1,553 @@ +/*global mock, converse */ + +const { stx, Strophe, $iq, sizzle, u } = converse.env; + + +describe("The Registration Panel", function () { + + afterEach(() => { + // Remove the hash + history.pushState("", document.title, window.location.pathname + window.location.search); + }); + + it("is not available unless allow_registration=true", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + allow_registration: false }, + async function (_converse) { + + await u.waitUntil(() => _converse.chatboxviews.get('controlbox')); + const cbview = _converse.api.controlbox.get(); + expect(cbview.querySelectorAll('a.register-account').length).toBe(0); + })); + + it("can be opened by clicking on the registration tab", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + allow_registration: true }, + async function (_converse) { + + const toggle = await u.waitUntil(() => document.querySelector(".toggle-controlbox")); + if (!u.isVisible(document.querySelector("#controlbox"))) { + if (!u.isVisible(toggle)) { + u.removeClass('hidden', toggle); + } + toggle.click(); + } + const cbview = _converse.chatboxviews.get('controlbox'); + expect(cbview.querySelector('converse-register-panel')).toBe(null); + + const register_link = await u.waitUntil(() => cbview.querySelector('a.register-account')); + expect(register_link.textContent).toBe("Create an account"); + register_link.click(); + + expect(cbview.querySelector('converse-register-panel')).toBeDefined(); + })); + + it("allows the user to choose an XMPP provider's domain", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + + const toggle = await u.waitUntil(() => document.querySelector(".toggle-controlbox")); + toggle.click(); + + const cbview = _converse.api.controlbox.get(); + await u.waitUntil(() => u.isVisible(cbview)); + + // Open the register panel + cbview.querySelector('.toggle-register-login').click(); + + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + spyOn(registerview, 'onProviderChosen').and.callThrough(); + spyOn(registerview, 'fetchRegistrationForm').and.callThrough(); + + // Check the form layout + const form = cbview.querySelector('#converse-register'); + expect(form.querySelectorAll('input').length).toEqual(2); + expect(form.querySelectorAll('input')[0].getAttribute('name')).toEqual('domain'); + expect(sizzle('input:last', form).pop().getAttribute('type')).toEqual('submit'); + // Check that the input[type=domain] input is required + const submit_button = form.querySelector('input[type=submit]'); + submit_button.click(); + expect(registerview.onProviderChosen).not.toHaveBeenCalled(); + + // Check that the form is accepted if input[type=domain] has a value + form.querySelector('input[name=domain]').value = 'conversejs.org'; + submit_button.click(); + expect(registerview.onProviderChosen).toHaveBeenCalled(); + expect(registerview.fetchRegistrationForm).toHaveBeenCalled(); + delete _converse.connection; + })); + + it("allows the user to choose an XMPP provider's domain in fullscreen view mode", + mock.initConverse( + ['chatBoxesInitialized'], { + auto_login: false, + view_mode: 'fullscreen', + discover_connection_methods: false, + allow_registration: true + }, + async function (_converse) { + + const cbview = _converse.api.controlbox.get(); + cbview.querySelector('.toggle-register-login').click(); + + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + spyOn(registerview, 'fetchRegistrationForm').and.callThrough(); + spyOn(registerview, 'onProviderChosen').and.callThrough(); + spyOn(registerview, 'getRegistrationFields').and.callThrough(); + spyOn(registerview, 'renderRegistrationForm').and.callThrough(); + + expect(registerview._registering).toBeFalsy(); + expect(_converse.api.connection.connected()).toBeFalsy(); + registerview.querySelector('input[name=domain]').value = 'conversejs.org'; + registerview.querySelector('input[type=submit]').click(); + expect(registerview.onProviderChosen).toHaveBeenCalled(); + expect(registerview._registering).toBeTruthy(); + + await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count()); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + + expect(registerview.getRegistrationFields).toHaveBeenCalled(); + + stanza = $iq({ + 'type': 'result', + 'id': 'reg1' + }).c('query', {'xmlns': 'jabber:iq:register'}) + .c('instructions') + .t('Please choose a username, password and provide your email address').up() + .c('username').up() + .c('password').up() + .c('email'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(registerview.renderRegistrationForm).toHaveBeenCalled(); + + await u.waitUntil(() => registerview.querySelectorAll('input').length === 5); + expect(registerview.querySelectorAll('input[type=submit]').length).toBe(1); + expect(registerview.querySelectorAll('input[type=button]').length).toBe(1); + })); + + it("will render a registration form as received from the XMPP provider", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + const toggle = await u.waitUntil(() => document.querySelector(".toggle-controlbox")); + toggle.click(); + + const cbview = _converse.api.controlbox.get(); + cbview.querySelector('.toggle-register-login').click(); + + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + spyOn(registerview, 'fetchRegistrationForm').and.callThrough(); + spyOn(registerview, 'onProviderChosen').and.callThrough(); + spyOn(registerview, 'getRegistrationFields').and.callThrough(); + spyOn(registerview, 'onRegistrationFields').and.callThrough(); + spyOn(registerview, 'renderRegistrationForm').and.callThrough(); + + expect(registerview._registering).toBeFalsy(); + expect(_converse.api.connection.connected()).toBeFalsy(); + registerview.querySelector('input[name=domain]').value = 'conversejs.org'; + registerview.querySelector('input[type=submit]').click(); + expect(registerview.onProviderChosen).toHaveBeenCalled(); + expect(registerview._registering).toBeTruthy(); + await u.waitUntil(() => registerview.fetchRegistrationForm.calls.count()); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + + expect(registerview.getRegistrationFields).toHaveBeenCalled(); + + stanza = $iq({ + 'type': 'result', + 'id': 'reg1' + }).c('query', {'xmlns': 'jabber:iq:register'}) + .c('instructions') + .t('Please choose a username, password and provide your email address').up() + .c('username').up() + .c('password').up() + .c('email'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(registerview.onRegistrationFields).toHaveBeenCalled(); + expect(registerview.renderRegistrationForm).toHaveBeenCalled(); + + await u.waitUntil(() => registerview.querySelectorAll('input').length === 5); + expect(registerview.querySelectorAll('input[type=submit]').length).toBe(1); + expect(registerview.querySelectorAll('input[type=button]').length).toBe(1); + })); + + it("will set form_type to legacy and submit it as legacy", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + const toggle = document.querySelector(".toggle-controlbox"); + if (!u.isVisible(document.querySelector("#controlbox"))) { + if (!u.isVisible(toggle)) { + u.removeClass('hidden', toggle); + } + toggle.click(); + } + const cbview = _converse.api.controlbox.get(); + cbview.querySelector('.toggle-register-login').click(); + + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + spyOn(registerview, 'onProviderChosen').and.callThrough(); + spyOn(registerview, 'getRegistrationFields').and.callThrough(); + spyOn(registerview, 'onRegistrationFields').and.callThrough(); + spyOn(registerview, 'renderRegistrationForm').and.callThrough(); + + registerview.querySelector('input[name=domain]').value = 'conversejs.org'; + registerview.querySelector('input[type=submit]').click(); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + stanza = $iq({ + 'type': 'result', + 'id': 'reg1' + }).c('query', {'xmlns': 'jabber:iq:register'}) + .c('instructions') + .t('Please choose a username, password and provide your email address').up() + .c('username').up() + .c('password').up() + .c('email'); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(registerview.form_type).toBe('legacy'); + + const username_input = await u.waitUntil(() => registerview.querySelector('input[name=username]')); + + username_input.value = 'testusername'; + registerview.querySelector('input[name=password]').value = 'testpassword'; + registerview.querySelector('input[name=email]').value = 'test@email.local'; + + spyOn(_converse.connection, 'send'); + registerview.querySelector('input[type=submit]').click(); + + expect(_converse.connection.send).toHaveBeenCalled(); + stanza = _converse.connection.send.calls.argsFor(0)[0].tree(); + expect(stanza.querySelector('query').childNodes.length).toBe(3); + expect(stanza.querySelector('query').firstElementChild.tagName).toBe('username'); + + delete _converse.connection; + })); + + it("will set form_type to xform and submit it as xform", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + const toggle = document.querySelector(".toggle-controlbox"); + if (!u.isVisible(document.querySelector("#controlbox"))) { + if (!u.isVisible(toggle)) { + u.removeClass('hidden', toggle); + } + toggle.click(); + } + const cbview = _converse.api.controlbox.get(); + cbview.querySelector('.toggle-register-login').click(); + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + spyOn(registerview, 'onProviderChosen').and.callThrough(); + spyOn(registerview, 'getRegistrationFields').and.callThrough(); + spyOn(registerview, 'onRegistrationFields').and.callThrough(); + spyOn(registerview, 'renderRegistrationForm').and.callThrough(); + + registerview.querySelector('input[name=domain]').value = 'conversejs.org'; + registerview.querySelector('input[type=submit]').click(); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + stanza = $iq({ + 'type': 'result', + 'id': 'reg1' + }).c('query', {'xmlns': 'jabber:iq:register'}) + .c('instructions') + .t('Using xform data').up() + .c('x', { 'xmlns': 'jabber:x:data', 'type': 'form' }) + .c('instructions').t('xform instructions').up() + .c('field', {'type': 'text-single', 'var': 'username'}).c('required').up().up() + .c('field', {'type': 'text-private', 'var': 'password'}).c('required').up().up() + .c('field', {'type': 'text-single', 'var': 'email'}).c('required').up().up(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(registerview.form_type).toBe('xform'); + + const username_input = await u.waitUntil(() => registerview.querySelector('input[name=username]')); + + username_input.value = 'testusername'; + registerview.querySelector('input[name=password]').value = 'testpassword'; + registerview.querySelector('input[name=email]').value = 'test@email.local'; + + spyOn(_converse.connection, 'send'); + + registerview.querySelector('input[type=submit]').click(); + + expect(_converse.connection.send).toHaveBeenCalled(); + stanza = _converse.connection.send.calls.argsFor(0)[0].tree(); + expect(Strophe.serialize(stanza).toLocaleString().trim().replace(/(\n|\s{2,})/g, '')).toEqual( + '<iq id="'+stanza.getAttribute('id')+'" type="set" xmlns="jabber:client">'+ + '<query xmlns="jabber:iq:register">'+ + '<x type="submit" xmlns="jabber:x:data">'+ + '<field var="username">'+ + '<value>testusername</value>'+ + '</field>'+ + '<field var="password">'+ + '<value>testpassword</value>'+ + '</field>'+ + '<field var="email">'+ + '<value>test@email.local</value>'+ + '</field>'+ + '</x>'+ + '</query>'+ + '</iq>' + ); + + delete _converse.connection; + })); + + it("renders the account registration form", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + const toggle = document.querySelector(".toggle-controlbox"); + if (!u.isVisible(document.querySelector("#controlbox"))) { + if (!u.isVisible(toggle)) { + u.removeClass('hidden', toggle); + } + toggle.click(); + } + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.toggle-register-login').click(); + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + registerview.querySelector('input[name=domain]').value = 'conversejs.org'; + registerview.querySelector('input[type=submit]').click(); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + + stanza = stx` + <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en"> + <query xmlns="jabber:iq:register"> + <x xmlns="jabber:x:data" type="form"> + <instructions>Choose a username and password to register with this server</instructions> + <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field> + <field var="username" type="text-single" label="User"><required/></field> + <field var="password" type="text-private" label="Password"><required/></field> + <field var="from" type="hidden"><value>conversations.im</value></field> + <field var="challenge" type="hidden"><value>15376320046808160053</value></field> + <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field> + <field var="ocr" type="text-single" label="Enter the text you see"> + <media xmlns="urn:xmpp:media-element"> + <uri type="image/png">cid:sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org</uri> + </media> + <required/> + </field> + </x> + <data xmlns="urn:xmpp:bob" cid="sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org" + type="image/png" + max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data> + <instructions>You need a client that supports x:data and CAPTCHA to register</instructions> + </query> + </iq>`; + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => registerview.querySelectorAll('#converse-register input[required]').length === 3); + expect(registerview.form_type).toBe('xform'); + + // Hide the controlbox so that we can see whether the test passed or failed + u.addClass('hidden', _converse.chatboxviews.get('controlbox')); + delete _converse.connection; + })); + + it("lets you choose a different provider", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + view_mode: 'fullscreen', + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + const toggle = document.querySelector(".toggle-controlbox"); + if (!u.isVisible(document.querySelector("#controlbox"))) { + if (!u.isVisible(toggle)) { + u.removeClass('hidden', toggle); + } + toggle.click(); + } + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.toggle-register-login').click(); + const registerview = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + + registerview.querySelector('input[name=domain]').value = 'conversejs.org'; + registerview.querySelector('input[type=submit]').click(); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + + stanza = stx` + <iq xmlns="jabber:client" type="result" from="conversations.im" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en"> + <query xmlns="jabber:iq:register"> + <x xmlns="jabber:x:data" type="form"> + <instructions>Choose a username and password to register with this server</instructions> + <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field> + <field var="username" type="text-single" label="User"><required/></field> + <field var="password" type="text-private" label="Password"><required/></field> + <field var="from" type="hidden"><value>conversations.im</value></field> + <field var="challenge" type="hidden"><value>15376320046808160053</value></field> + <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field> + </x> + </query> + </iq>`; + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await u.waitUntil(() => registerview.querySelectorAll('#converse-register input[required]').length === 2); + expect(registerview.form_type).toBe('xform'); + + const button = await u.waitUntil(() => registerview.querySelector('.btn-secondary')); + expect(button.value).toBe("Choose a different provider"); + button.click(); + + await u.waitUntil(() => registerview.querySelector('input[name="domain"]')); + expect(registerview.querySelectorAll('input[required]').length).toBe(1); + + // Hide the controlbox so that we can see whether the test passed or failed + u.addClass('hidden', _converse.chatboxviews.get('controlbox')); + delete _converse.connection; + })); + + it("renders errors", + mock.initConverse( + ['chatBoxesInitialized'], + { auto_login: false, + view_mode: 'fullscreen', + discover_connection_methods: false, + allow_registration: true }, + async function (_converse) { + + const toggle = document.querySelector(".toggle-controlbox"); + if (!u.isVisible(document.querySelector("#controlbox"))) { + if (!u.isVisible(toggle)) { + u.removeClass('hidden', toggle); + } + toggle.click(); + } + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.toggle-register-login').click(); + const view = await u.waitUntil(() => cbview.querySelector('converse-register-panel')); + + view.querySelector('input[name=domain]').value = 'conversejs.org'; + view.querySelector('input[type=submit]').click(); + + let stanza = new Strophe.Builder("stream:features", { + 'xmlns:stream': "http://etherx.jabber.org/streams", + 'xmlns': "jabber:client" + }) + .c('register', {xmlns: "http://jabber.org/features/iq-register"}).up() + .c('mechanisms', {xmlns: "urn:ietf:params:xml:ns:xmpp-sasl"}); + _converse.connection._connect_cb(mock.createRequest(stanza)); + + stanza = stx` + <iq xmlns="jabber:client" type="result" from="conversejs.org" id="ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ" xml:lang="en"> + <query xmlns="jabber:iq:register"> + <x xmlns="jabber:x:data" type="form"> + <instructions>Choose a username and password to register with this server</instructions> + <field var="FORM_TYPE" type="hidden"><value>urn:xmpp:captcha</value></field> + <field var="username" type="text-single" label="User"><required/></field> + <field var="password" type="text-private" label="Password"><required/></field> + <field var="from" type="hidden"><value>conversejs.org</value></field> + <field var="challenge" type="hidden"><value>15376320046808160053</value></field> + <field var="sid" type="hidden"><value>ad1e0d50-5adb-4397-a997-5feab56fe418:sendIQ</value></field> + <field var="ocr" type="text-single" label="Enter the text you see"> + <media xmlns="urn:xmpp:media-element"> + <uri type="image/png">cid:sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org</uri> + </media> + <required/> + </field> + </x> + <data xmlns="urn:xmpp:bob" cid="sha1+2df8c1b366f1e90ce60354f97d1fe75237290b8a@bob.xmpp.org" + type="image/png" + max-age="0">iVBORw0KGgoAAAANSUhEUgAAALQAAAA8BAMAAAA9AI20AAAAMFBMVEX///8AAADf39+fn59fX19/f3+/v78fHx8/Pz9PT08bGxsvLy9jY2NTU1MXFxcnJyc84bkWAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAERUlEQVRYhe1WTXMaRxDdDxY4JWpYvDinpVyxdATLin0MiRLlCHEi+7hYUcVHTSI7urhK6yr5//gn5N/4Z7inX89+CQkTcFUO6gOwS8/r7tdvesbzvoT5ROR5JJ9bB97xAK22XWAY1WznlnUr7QaAzSOsWufXQ6wH/FmO60b4D936LJr8TWRwW4SNgOsodZr8m4vZUoRt2xZ3xHXgna1FCE5+f5aWwPU//bXgg8eHjyqPp4aXJeOlwLUIt0O39zOvPWW3WfHmCCkli816FxlK0rnFGKZ484dN+eIXsw1R+G+JfjwgOpMnm+r5SxA63gS2Q8MchO1RLN8jSn4W4F5OPed2evhTthKLG3bsfjLL874XGBpWHLrU0953i/ev7JsfViHbhsWSQTunJDOppeAe0hVGokJUHBOphmjrbBlgabviJKXbIP0B//gKSBHZh2rvJnQp3wsapMFz+VsTPNhPr0Hn9N57YOjywaxFSU6S79fUF39KBDgnt6yjZOeSffk+4IXDZovbQl9E96m34EzQKMepQcbzijAGiBmDsO+LaqzqG3m3kEf+DQ2mY+vdk5c2n2Iaj5QGi6n59FHDmcuP4t8MGlRaF39P6ENyIaB2EXdpjLnQq9IgdVxfax3ilBc10u4gowX9K6BaKiZNmCC7CF/WpkJvWxN00OjuoqGYLqAnpILLE68Ymrt9M0S9hcznUJ8RykdlLalUfFaDjvA8pT2kxmsl5fuMaM6mSWUpUhDoudSucdhiZFDwphEHwsMwhEpH0jsm+/UBK2wCzFIiitalN7YjWkyIBgTNPgpDXX4rjk4UH+yPPgfK4HNZQCP/KZ0fGnrnKl8+pXl3X7FwZuwNUdwDGO+BjPUn6XaKtbkm+MJ6vtaXSnIz6wBT/m+VvZNIhz7ayabQLSeRQDmYkjt0KlmHDa555v9DzFxx+CCvCG4K3dbx6mTYtfPs1Dgdh0i3W+cl4lnnhblMKKBBA23X1Ezc3E5ZoPS5KHjPiU1rKTviYe1fTsa6e3UwXGWI4ykB8uiGqkmA6Cbf3K4JTH3LOBlbX+yPWll57LKVeH8CTEvyVPV2TXL8kPnPqtA51CaFYxOH2rJoZunSnvsSj48WiaDccl6KEgiMSarITsa+rWWBnqFloYlT1qWW2GKw9nPSbEvoVHFst967XgNQjxdA66Q6VFEUh488xfaSo7cHB52XYzA4eRlVteeT8ostWfuPea0oF6MwzlwgZE9gQI+uUV0gzK+WlpUrNI8juhhX/OyNwZnRrsDfxOqS1aDR+gC6NUPvJpvQeVZ9eiNr9aDUuddY3bLnA4tH4r/49UboznH1ia8PV/uP3WUB3dxtzj1uxfDZgbEbZx17Itwrf0Jyc8N4en+5dhivtKeYjGJ8yXgUzKvSU/uWJZmsuAYtseDku+K3zMHi4lC1h0suPmtZaEp2tm3hEV2lXwb6zu7szv6f9glF5rPGT5xR7AAAAABJRU5ErkJggg==</data> + <instructions>You need a client that supports x:data and CAPTCHA to register</instructions> + </query> + </iq>`; + _converse.connection._dataRecv(mock.createRequest(stanza)); + + spyOn(view, 'submitRegistrationForm').and.callThrough(); + + const username_input = await u.waitUntil(() => view.querySelector('[name="username"]')); + username_input.value = 'romeo'; + const password_input = view.querySelector('[name="password"]'); + password_input.value = 'secret'; + const ocr_input = view.querySelector('[name="ocr"]'); + ocr_input.value = '8m9D88'; + view.querySelector('[type="submit"]').click(); + expect(view.submitRegistrationForm).toHaveBeenCalled(); + + const response_IQ = stx` + <iq xml:lang='en' from='conversejs.org' type='error' id='d9917b7a-588f-4ef6-8a56-0d6d3ad538ae:sendIQ' xmlns="jabber:client"> + <query xmlns='jabber:iq:register'/> + <error code='500' type='wait'> + <resource-constraint xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/> + <text xml:lang='en' xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Too many CAPTCHA requests</text> + </error> + </iq>`; + _converse.connection._dataRecv(mock.createRequest(response_IQ)); + + const alert = await u.waitUntil(() => view.querySelector('.alert')); + expect(alert.textContent.trim()).toBe('Too many CAPTCHA requests'); + // Hide the controlbox so that we can see whether the test passed or failed + u.addClass('hidden', _converse.chatboxviews.get('controlbox')); + delete _converse.connection; + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/register/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/register/utils.js new file mode 100644 index 0000000..250b722 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/register/utils.js @@ -0,0 +1,7 @@ +import { _converse, api } from '@converse/headless/core'; + +export async function setActiveForm (value) { + await api.waitUntil('controlBoxInitialized'); + const controlbox = _converse.chatboxes.get('controlbox'); + controlbox.set({ 'active-form': value }); +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js new file mode 100644 index 0000000..3c422a7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/index.js @@ -0,0 +1,23 @@ +/** + * @description + * Converse.js plugin which shows a list of currently open + * rooms in the "Rooms Panel" of the ControlBox. + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import "@converse/headless/plugins/muc/index.js"; +import './view.js'; +import { converse } from "@converse/headless/core"; + + +converse.plugins.add('converse-roomslist', { + + dependencies: [ + "converse-singleton", + "converse-controlbox", + "converse-muc", + "converse-bookmarks" + ], + + initialize () { } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js new file mode 100644 index 0000000..e920f0d --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/model.js @@ -0,0 +1,27 @@ +import { Model } from '@converse/skeletor/src/model.js'; +import { _converse, api, converse } from "@converse/headless/core"; + +const { Strophe } = converse.env; + +const RoomsListModel = Model.extend({ + + defaults: function () { + return { + 'muc_domain': api.settings.get('muc_domain'), + 'nick': _converse.getDefaultMUCNickname(), + 'toggle_state': _converse.OPENED, + }; + }, + + initialize () { + api.settings.listen.on('change:muc_domain', (muc_domain) => this.setDomain(muc_domain)); + }, + + setDomain (jid) { + if (!api.settings.get('locked_muc_domain')) { + this.save('muc_domain', Strophe.getDomainFromJid(jid)); + } + } +}); + +export default RoomsListModel; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js new file mode 100644 index 0000000..2834b21 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/templates/roomslist.js @@ -0,0 +1,117 @@ +import 'plugins/muc-views/modals/add-muc.js'; +import 'plugins/muc-views/modals/muc-list.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import { html } from "lit"; +import { isUniView } from '@converse/headless/utils/core.js'; +import { addBookmarkViaEvent } from 'plugins/bookmark-views/utils.js'; + + +function isCurrentlyOpen (room) { + return isUniView() && !room.get('hidden'); +} + +function tplBookmark (room) { + const bm = room.get('bookmarked') ?? false; + const i18n_bookmark = __('Bookmark'); + return html` + <a class="list-item-action add-bookmark" + data-room-jid="${room.get('jid')}" + data-bookmark-name="${room.getDisplayName()}" + @click=${ev => addBookmarkViaEvent(ev)} + title="${ i18n_bookmark }"> + + <converse-icon class="fa ${bm ? 'fa-bookmark' : 'fa-bookmark-empty'}" + size="1.2em" + color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon> + </a>`; +} + + +const tplUnreadIndicator = (room) => html`<span class="list-item-badge badge badge--muc msgs-indicator">${ room.get('num_unread') }</span>`; + +const tplActivityIndicator = () => html`<span class="list-item-badge badge badge--muc msgs-indicator"></span>`; + + +function tplRoomItem (el, room) { + const i18n_leave_room = __('Leave this groupchat'); + const has_unread_msgs = room.get('num_unread_general') || room.get('has_activity'); + return html` + <div class="list-item controlbox-padded available-chatroom d-flex flex-row ${ isCurrentlyOpen(room) ? 'open' : '' } ${ has_unread_msgs ? 'unread-msgs' : '' }" + data-room-jid="${room.get('jid')}"> + + ${ room.get('num_unread') ? tplUnreadIndicator(room) : (room.get('has_activity') ? tplActivityIndicator() : '') } + + <a class="list-item-link open-room available-room w-100" + data-room-jid="${room.get('jid')}" + title="${__('Click to open this groupchat')}" + @click=${ev => el.openRoom(ev)}>${room.getDisplayName()}</a> + + ${ api.settings.get('allow_bookmarks') ? tplBookmark(room) : '' } + + <a class="list-item-action room-info" + data-room-jid="${room.get('jid')}" + title="${__('Show more information on this groupchat')}" + @click=${ev => el.showRoomDetailsModal(ev)}> + + <converse-icon class="fa fa-info-circle" size="1.2em" color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon> + </a> + + <a class="list-item-action close-room" + data-room-jid="${room.get('jid')}" + data-room-name="${room.getDisplayName()}" + title="${i18n_leave_room}" + @click=${ev => el.closeRoom(ev)}> + <converse-icon class="fa fa-sign-out-alt" size="1.2em" color="${ isCurrentlyOpen(room) ? 'var(--inverse-link-color)' : '' }"></converse-icon> + </a> + </div>`; +} + +export default (el) => { + const { chatboxes, CHATROOMS_TYPE, CLOSED } = _converse; + const rooms = chatboxes.filter(m => m.get('type') === CHATROOMS_TYPE); + rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1)); + + const i18n_desc_rooms = __('Click to toggle the list of open groupchats'); + const i18n_heading_chatrooms = __('Groupchats'); + const i18n_title_list_rooms = __('Query for groupchats'); + const i18n_title_new_room = __('Add a new groupchat'); + const i18n_show_bookmarks = __('Show bookmarked groupchats'); + const is_closed = el.model.get('toggle_state') === CLOSED; + return html` + <div class="d-flex controlbox-padded"> + <span class="w-100 controlbox-heading controlbox-heading--groupchats"> + <a class="list-toggle open-rooms-toggle" title="${i18n_desc_rooms}" @click=${ev => el.toggleRoomsList(ev)}> + <converse-icon + class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }" + size="1em" + color="var(--muc-color)"></converse-icon> + ${i18n_heading_chatrooms} + </a> + </span> + + <a class="controlbox-heading__btn show-bookmark-list-modal" + @click=${(ev) => api.modal.show('converse-bookmark-list-modal', { 'model': el.model }, ev)} + title="${i18n_show_bookmarks}" + data-toggle="modal"> + <converse-icon class="fa fa-bookmark right" size="1em"></converse-icon> + </a> + + <a class="controlbox-heading__btn show-list-muc-modal" + @click=${(ev) => api.modal.show('converse-muc-list-modal', { 'model': el.model }, ev)} + title="${i18n_title_list_rooms}" data-toggle="modal" data-target="#muc-list-modal"> + <converse-icon class="fa fa-list-ul right" size="1em"></converse-icon> + </a> + <a class="controlbox-heading__btn show-add-muc-modal" + @click=${(ev) => api.modal.show('converse-add-muc-modal', { 'model': el.model }, ev)} + title="${i18n_title_new_room}" data-toggle="modal" data-target="#add-chatrooms-modal"> + <converse-icon class="fa fa-plus right" size="1em"></converse-icon> + </a> + </div> + + <div class="list-container list-container--openrooms ${ rooms.length ? '' : 'hidden' }"> + <div class="items-list rooms-list open-rooms-list ${ is_closed ? 'collapsed' : '' }"> + ${ rooms.map(room => tplRoomItem(el, room)) } + </div> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js new file mode 100644 index 0000000..8ac7c4e --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/tests/roomslist.js @@ -0,0 +1,400 @@ +/* global mock, converse */ + +const { $msg, u } = converse.env; + + +describe("A list of open groupchats", function () { + + it("is shown in controlbox", mock.initConverse( + ['chatBoxesFetched'], + { allow_bookmarks: false // Makes testing easier, otherwise we + // have to mock stanza traffic. + }, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.openControlBox(_converse); + const controlbox = _converse.chatboxviews.get('controlbox'); + let list = controlbox.querySelector('.list-container--openrooms'); + expect(u.hasClass('hidden', list)).toBeTruthy(); + await mock.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC'); + + const lview = controlbox.querySelector('converse-rooms-list'); + await u.waitUntil(() => lview.querySelectorAll(".open-room").length); + let room_els = lview.querySelectorAll(".open-room"); + expect(room_els.length).toBe(1); + expect(room_els[0].innerText).toBe('room@conference.shakespeare.lit'); + + await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo'); + await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 1); + room_els = lview.querySelectorAll(".open-room"); + expect(room_els.length).toBe(2); + + let view = _converse.chatboxviews.get('room@conference.shakespeare.lit'); + await view.close(); + room_els = lview.querySelectorAll(".open-room"); + expect(room_els.length).toBe(1); + expect(room_els[0].innerText).toBe('lounge@montague.lit'); + list = controlbox.querySelector('.list-container--openrooms'); + u.waitUntil(() => Array.from(list.classList).includes('hidden')); + + view = _converse.chatboxviews.get('lounge@montague.lit'); + await view.close(); + room_els = lview.querySelectorAll(".open-room"); + expect(room_els.length).toBe(0); + + list = controlbox.querySelector('.list-container--openrooms'); + expect(Array.from(list.classList).includes('hidden')).toBeTruthy(); + })); + + it("shows the number of unread mentions received", + mock.initConverse( + [], {'allow_bookmarks': false}, + async function (_converse) { + + await mock.openControlBox(_converse); + const roomspanel = _converse.chatboxviews.get('controlbox').querySelector('converse-rooms-list'); + expect(roomspanel.querySelectorAll('.available-room').length).toBe(0); + + const muc_jid = 'kitchen@conference.shakespeare.lit'; + const message = 'fires: Your attention is required'; + await mock.openAndEnterChatRoom(_converse, muc_jid, 'fires'); + const view = _converse.chatboxviews.get(muc_jid); + await u.waitUntil(() => roomspanel.querySelectorAll('.available-room').length); + expect(roomspanel.querySelectorAll('.available-room').length).toBe(1); + expect(roomspanel.querySelectorAll('.msgs-indicator').length).toBe(0); + + view.model.set({'minimized': true}); + + const nick = mock.chatroom_names[0]; + await view.model.handleMessageStanza($msg({ + from: muc_jid+'/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t(message).tree()); + await u.waitUntil(() => view.model.messages.length); + expect(roomspanel.querySelectorAll('.available-room').length).toBe(1); + expect(roomspanel.querySelectorAll('.msgs-indicator').length).toBe(1); + expect(roomspanel.querySelector('.msgs-indicator').textContent.trim()).toBe('1'); + + await view.model.handleMessageStanza($msg({ + 'from': muc_jid+'/'+nick, + 'id': u.getUniqueId(), + 'to': 'romeo@montague.lit', + 'type': 'groupchat' + }).c('body').t(message).tree()); + await u.waitUntil(() => view.model.messages.length > 1); + expect(roomspanel.querySelectorAll('.available-room').length).toBe(1); + expect(roomspanel.querySelectorAll('.msgs-indicator').length).toBe(1); + expect(roomspanel.querySelector('.msgs-indicator').textContent.trim()).toBe('2'); + view.model.set({'minimized': false}); + expect(roomspanel.querySelectorAll('.available-room').length).toBe(1); + await u.waitUntil(() => roomspanel.querySelectorAll('.msgs-indicator').length === 0); + })); + + it("uses bookmarks to determine groupchat names", + mock.initConverse( + ['chatBoxesFetched'], + {'view_mode': 'fullscreen'}, + async function (_converse) { + + const { Strophe, $iq, $pres, sizzle } = converse.env; + const u = converse.env.utils; + + await mock.waitForRoster(_converse, 'current', 0); + await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); + let stanza = $pres({ + to: 'romeo@montague.lit/orchard', + from: 'lounge@montague.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }).tree(); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + spyOn(_converse.Bookmarks.prototype, 'fetchBookmarks').and.callThrough(); + + await mock.waitUntilDiscoConfirmed( + _converse, _converse.bare_jid, + [{'category': 'pubsub', 'type':'pep'}], + [`${Strophe.NS.PUBSUB}#publish-options`] + ); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil(() => IQ_stanzas.filter(s => sizzle('items[node="storage:bookmarks"]', s).length).pop()); + expect(Strophe.serialize(sent_stanza)).toBe( + `<iq from="romeo@montague.lit/orchard" id="${sent_stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + '<pubsub xmlns="http://jabber.org/protocol/pubsub">'+ + '<items node="storage:bookmarks"/>'+ + '</pubsub>'+ + '</iq>'); + + stanza = $iq({'to': _converse.connection.jid, 'type':'result', 'id':sent_stanza.getAttribute('id')}) + .c('pubsub', {'xmlns': Strophe.NS.PUBSUB}) + .c('items', {'node': 'storage:bookmarks'}) + .c('item', {'id': 'current'}) + .c('storage', {'xmlns': 'storage:bookmarks'}) + .c('conference', { + 'name': 'Bookmarked Lounge', + 'jid': 'lounge@montague.lit' + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + await _converse.api.waitUntil('roomsListInitialized'); + const controlbox = _converse.chatboxviews.get('controlbox'); + const list = controlbox.querySelector('.list-container--openrooms'); + expect(Array.from(list.classList).includes('hidden')).toBeFalsy(); + const items = list.querySelectorAll('.list-item'); + expect(items.length).toBe(1); + await u.waitUntil(() => list.querySelector('.list-item').textContent.trim() === 'Bookmarked Lounge'); + expect(_converse.bookmarks.fetchBookmarks).toHaveBeenCalled(); + })); +}); + +describe("A groupchat shown in the groupchats list", function () { + + it("is highlighted if it's currently open", mock.initConverse( + ['chatBoxesFetched'], + { view_mode: 'fullscreen', + allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic. + }, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const controlbox = _converse.chatboxviews.get('controlbox'); + const u = converse.env.utils; + const muc_jid = 'coven@chat.shakespeare.lit'; + await _converse.api.rooms.open(muc_jid, {'nick': 'some1'}, true); + const lview = controlbox.querySelector('converse-rooms-list'); + await u.waitUntil(() => lview.querySelectorAll(".open-room").length); + let room_els = lview.querySelectorAll(".available-chatroom"); + expect(room_els.length).toBe(1); + + let item = room_els[0]; + await u.waitUntil(() => _converse.chatboxes.get(muc_jid).get('hidden') === false); + await u.waitUntil(() => u.hasClass('open', item), 1000); + expect(item.textContent.trim()).toBe('coven@chat.shakespeare.lit'); + await _converse.api.rooms.open('balcony@chat.shakespeare.lit', {'nick': 'some1'}, true); + await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 1); + room_els = lview.querySelectorAll(".open-room"); + expect(room_els.length).toBe(2); + + room_els = lview.querySelectorAll(".available-chatroom.open"); + expect(room_els.length).toBe(1); + item = room_els[0]; + expect(item.textContent.trim()).toBe('balcony@chat.shakespeare.lit'); + })); + + it("has an info icon which opens a details modal when clicked", mock.initConverse( + ['chatBoxesFetched'], + { whitelisted_plugins: ['converse-roomslist'], + allow_bookmarks: false // Makes testing easier, otherwise we + // have to mock stanza traffic. + }, async function (_converse) { + + const { Strophe, $iq, $pres } = converse.env; + const u = converse.env.utils; + const IQ_stanzas = _converse.connection.IQ_stanzas; + const room_jid = 'coven@chat.shakespeare.lit'; + await mock.waitForRoster(_converse, 'current', 0); + await mock.openControlBox(_converse); + await _converse.api.rooms.open(room_jid, {'nick': 'some1'}); + + const selector = `iq[to="${room_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`; + const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop()); + const features_stanza = $iq({ + 'from': 'coven@chat.shakespeare.lit', + 'id': features_query.getAttribute('id'), + 'to': 'romeo@montague.lit/desktop', + 'type': 'result' + }) + .c('query', { 'xmlns': 'http://jabber.org/protocol/disco#info'}) + .c('identity', { + 'category': 'conference', + 'name': 'A Dark Cave', + 'type': 'text' + }).up() + .c('feature', {'var': 'http://jabber.org/protocol/muc'}).up() + .c('feature', {'var': 'muc_passwordprotected'}).up() + .c('feature', {'var': 'muc_hidden'}).up() + .c('feature', {'var': 'muc_temporary'}).up() + .c('feature', {'var': 'muc_open'}).up() + .c('feature', {'var': 'muc_unmoderated'}).up() + .c('feature', {'var': 'muc_nonanonymous'}).up() + .c('feature', {'var': 'urn:xmpp:mam:0'}).up() + .c('x', { 'xmlns':'jabber:x:data', 'type':'result'}) + .c('field', {'var':'FORM_TYPE', 'type':'hidden'}) + .c('value').t('http://jabber.org/protocol/muc#roominfo').up().up() + .c('field', {'type':'text-single', 'var':'muc#roominfo_description', 'label':'Description'}) + .c('value').t('This is the description').up().up() + .c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'}) + .c('value').t(0); + _converse.connection._dataRecv(mock.createRequest(features_stanza)); + + const view = _converse.chatboxviews.get(room_jid); + await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING) + let presence = $pres({ + to: _converse.connection.jid, + from: 'coven@chat.shakespeare.lit/some1', + id: 'DC352437-C019-40EC-B590-AF29E879AF97' + }).c('x').attrs({xmlns:'http://jabber.org/protocol/muc#user'}) + .c('item').attrs({ + affiliation: 'member', + jid: _converse.bare_jid, + role: 'participant' + }).up() + .c('status').attrs({code:'110'}); + _converse.connection._dataRecv(mock.createRequest(presence)); + + const rooms_list = document.querySelector('converse-rooms-list'); + await u.waitUntil(() => rooms_list.querySelectorAll(".open-room").length, 500); + const room_els = rooms_list.querySelectorAll(".open-room"); + expect(room_els.length).toBe(1); + const info_el = rooms_list.querySelector(".room-info"); + info_el.click(); + + const modal = _converse.api.modal.get('converse-muc-details-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + let els = modal.querySelectorAll('p.room-info'); + expect(els[0].textContent).toBe("Name: A Dark Cave") + + expect(els[1].querySelector('strong').textContent).toBe("XMPP address"); + expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe("xmpp:coven@chat.shakespeare.lit?join"); + expect(els[2].querySelector('strong').textContent).toBe("Description"); + expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description"); + + expect(els[3].textContent).toBe("Online users: 1") + const features_list = modal.querySelector('.features-list'); + expect(features_list.textContent.replace(/(\n|\s{2,})/g, '')).toBe( + 'Password protected - This groupchat requires a password before entry'+ + 'Hidden - This groupchat is not publicly searchable'+ + 'Open - Anyone can join this groupchat'+ + 'Temporary - This groupchat will disappear once the last person leaves'+ + 'Not anonymous - All other groupchat participants can see your XMPP address'+ + 'Not moderated - Participants entering this groupchat can write right away' + ); + presence = $pres({ + to: 'romeo@montague.lit/_converse.js-29092160', + from: 'coven@chat.shakespeare.lit/newguy' + }) + .c('x', {xmlns: Strophe.NS.MUC_USER}) + .c('item', { + 'affiliation': 'none', + 'jid': 'newguy@montague.lit/_converse.js-290929789', + 'role': 'participant' + }); + _converse.connection._dataRecv(mock.createRequest(presence)); + + els = modal.querySelectorAll('p.room-info'); + expect(els[3].textContent).toBe("Online users: 2") + + view.model.set({'subject': {'author': 'someone', 'text': 'Hatching dark plots'}}); + els = modal.querySelectorAll('p.room-info'); + expect(els[0].textContent).toBe("Name: A Dark Cave") + + expect(els[1].querySelector('strong').textContent).toBe("XMPP address"); + expect(els[1].querySelector('converse-rich-text').textContent.trim()).toBe("xmpp:coven@chat.shakespeare.lit?join"); + expect(els[2].querySelector('strong').textContent).toBe("Description"); + expect(els[2].querySelector('converse-rich-text').textContent).toBe("This is the description"); + expect(els[3].querySelector('strong').textContent).toBe("Topic"); + await u.waitUntil(() => els[3].querySelector('converse-rich-text').textContent === "Hatching dark plots"); + + expect(els[4].textContent).toBe("Topic author: someone") + expect(els[5].textContent).toBe("Online users: 2") + })); + + it("can be closed", mock.initConverse( + [], + { whitelisted_plugins: ['converse-roomslist'], + allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic. + }, + async function (_converse) { + + const u = converse.env.utils; + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + expect(_converse.chatboxes.length).toBe(1); + await mock.waitForRoster(_converse, 'current', 0); + await mock.openChatRoom(_converse, 'lounge', 'conference.shakespeare.lit', 'JC'); + expect(_converse.chatboxes.length).toBe(2); + + await mock.openControlBox(_converse); + const controlbox = _converse.chatboxviews.get('controlbox'); + const lview = controlbox.querySelector('converse-rooms-list'); + await u.waitUntil(() => lview.querySelectorAll(".open-room").length); + const room_els = lview.querySelectorAll(".open-room"); + expect(room_els.length).toBe(1); + const rooms_list = document.querySelector('converse-rooms-list'); + const close_el = rooms_list.querySelector(".close-room"); + close_el.click(); + expect(_converse.api.confirm).toHaveBeenCalledWith( + 'Are you sure you want to leave the groupchat lounge@conference.shakespeare.lit?'); + + await u.waitUntil(() => rooms_list.querySelectorAll(".open-room").length === 0); + expect(_converse.chatboxes.length).toBe(1); + })); + + it("shows unread messages directed at the user", mock.initConverse( + null, + { whitelisted_plugins: ['converse-roomslist'], + allow_bookmarks: false // Makes testing easier, otherwise we have to mock stanza traffic. + }, async (_converse) => { + + const { $msg } = converse.env; + const u = converse.env.utils; + await mock.openControlBox(_converse); + const room_jid = 'kitchen@conference.shakespeare.lit'; + const rooms_list = document.querySelector('converse-rooms-list'); + await u.waitUntil(() => rooms_list !== undefined, 500); + await mock.openAndEnterChatRoom(_converse, room_jid, 'romeo'); + const view = _converse.chatboxviews.get(room_jid); + view.model.set({'minimized': true}); + const nick = mock.chatroom_names[0]; + await view.model.handleMessageStanza( + $msg({ + from: room_jid+'/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('foo').tree()); + + // If the user isn't mentioned, the counter doesn't get incremented, but the text of the groupchat is bold + const controlbox = _converse.chatboxviews.get('controlbox'); + const lview = controlbox.querySelector('converse-rooms-list'); + let room_el = await u.waitUntil(() => lview.querySelector(".available-chatroom")); + expect(Array.from(room_el.classList).includes('unread-msgs')).toBeTruthy(); + + // If the user is mentioned, the counter also gets updated + await view.model.handleMessageStanza( + $msg({ + from: room_jid+'/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('romeo: Your attention is required').tree() + ); + + let indicator_el = await u.waitUntil(() => lview.querySelector(".msgs-indicator")); + expect(indicator_el.textContent).toBe('1'); + + spyOn(view.model, 'handleUnreadMessage').and.callThrough(); + await view.model.handleMessageStanza( + $msg({ + from: room_jid+'/'+nick, + id: u.getUniqueId(), + to: 'romeo@montague.lit', + type: 'groupchat' + }).c('body').t('romeo: and another thing...').tree() + ); + await u.waitUntil(() => view.model.handleUnreadMessage.calls.count()); + await u.waitUntil(() => lview.querySelector(".msgs-indicator").textContent === '2', 1000); + + // When the chat gets maximized again, the unread indicators are removed + view.model.set({'minimized': false}); + indicator_el = lview.querySelector(".msgs-indicator"); + expect(indicator_el === null); + room_el = lview.querySelector(".available-chatroom"); + await u.waitUntil(() => Array.from(room_el.classList).includes('unread-msgs') === false); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js new file mode 100644 index 0000000..dea738b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/roomslist/view.js @@ -0,0 +1,83 @@ +import 'plugins/muc-views/modals/muc-details.js'; +import RoomsListModel from './model.js'; +import tplRoomslist from "./templates/roomslist.js"; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api, converse } from "@converse/headless/core"; +import { initStorage } from '@converse/headless/utils/storage.js'; + +const { Strophe, u } = converse.env; + +export class RoomsList extends CustomElement { + + initialize () { + const id = `converse.roomspanel${_converse.bare_jid}`; + this.model = new RoomsListModel({ id }); + initStorage(this.model, id); + this.model.fetch(); + + this.listenTo(_converse.chatboxes, 'add', this.renderIfChatRoom); + this.listenTo(_converse.chatboxes, 'remove', this.renderIfChatRoom); + this.listenTo(_converse.chatboxes, 'destroy', this.renderIfChatRoom); + this.listenTo(_converse.chatboxes, 'change', this.renderIfRelevantChange); + this.listenTo(this.model, 'change', () => this.requestUpdate()); + + this.requestUpdate(); + } + + render () { + return tplRoomslist(this); + } + + renderIfChatRoom (model) { + u.isChatRoom(model) && this.requestUpdate(); + } + + renderIfRelevantChange (model) { + const attrs = ['bookmarked', 'hidden', 'name', 'num_unread', 'num_unread_general', 'has_activity']; + const changed = model.changed || {}; + if (u.isChatRoom(model) && Object.keys(changed).filter(m => attrs.includes(m)).length) { + this.requestUpdate(); + } + } + + showRoomDetailsModal (ev) { // eslint-disable-line class-methods-use-this + const jid = ev.currentTarget.getAttribute('data-room-jid'); + const room = _converse.chatboxes.get(jid); + ev.preventDefault(); + api.modal.show('converse-muc-details-modal', {'model': room}, ev); + } + + async openRoom (ev) { // eslint-disable-line class-methods-use-this + ev.preventDefault(); + const name = ev.target.textContent; + const jid = ev.target.getAttribute('data-room-jid'); + const data = { + 'name': name || Strophe.unescapeNode(Strophe.getNodeFromJid(jid)) || jid + } + await api.rooms.open(jid, data, true); + } + + async closeRoom (ev) { // eslint-disable-line class-methods-use-this + ev.preventDefault(); + const name = ev.currentTarget.getAttribute('data-room-name'); + const jid = ev.currentTarget.getAttribute('data-room-jid'); + const result = await api.confirm(__("Are you sure you want to leave the groupchat %1$s?", name)); + if (result) { + const room = await api.rooms.get(jid); + room.close(); + } + } + + toggleRoomsList (ev) { + ev?.preventDefault?.(); + const list_el = this.querySelector('.open-rooms-list'); + if (this.model.get('toggle_state') === _converse.CLOSED) { + u.slideOut(list_el).then(() => this.model.save({'toggle_state': _converse.OPENED})); + } else { + u.slideIn(list_el).then(() => this.model.save({'toggle_state': _converse.CLOSED})); + } + } +} + +api.elements.define('converse-rooms-list', RoomsList); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js new file mode 100644 index 0000000..97f6f6c --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/index.js @@ -0,0 +1,26 @@ +import ConverseRoot from './root.js'; +import { api, converse } from '@converse/headless/core'; +import { ensureElement } from './utils.js'; + + +converse.plugins.add('converse-rootview', { + + initialize () { + // Configuration values for this plugin + // ==================================== + // Refer to docs/source/configuration.rst for explanations of these + // configuration settings. + api.settings.extend({ + 'auto_insert': true, + 'theme': 'classic', + 'dark_theme': 'dracula', + }); + + api.listen.on('chatBoxesInitialized', ensureElement); + + // Only define the element now, otherwise it it's already in the DOM + // before `converse.initialized` has been called it will render too + // early. + api.elements.define('converse-root', ConverseRoot); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js new file mode 100644 index 0000000..4c8092f --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/root.js @@ -0,0 +1,40 @@ +import tplRoot from "./templates/root.js"; +import { api } from '@converse/headless/core'; +import { CustomElement } from 'shared/components/element.js'; +import { getAppSettings } from '@converse/headless/shared/settings/utils.js'; +import { getTheme } from './utils.js'; + +import './styles/root.scss'; + + +/** + * `converse-root` is an optional custom element which can be used to + * declaratively insert the Converse UI into the DOM. + * + * It can be inserted into the DOM before or after Converse has loaded or been + * initialized. + */ +export default class ConverseRoot extends CustomElement { + + render () { // eslint-disable-line class-methods-use-this + return tplRoot(); + } + + initialize () { + this.setAttribute('id', 'conversejs'); + this.setClasses(); + const settings = getAppSettings(); + this.listenTo(settings, 'change:view_mode', () => this.setClasses()) + this.listenTo(settings, 'change:singleton', () => this.setClasses()) + window.matchMedia('(prefers-color-scheme: dark)').addListener(() => this.setClasses()); + window.matchMedia('(prefers-color-scheme: light)').addListener(() => this.setClasses()); + } + + setClasses () { + this.className = ""; + this.classList.add('conversejs'); + this.classList.add(`converse-${api.settings.get('view_mode')}`); + this.classList.add(`theme-${getTheme()}`); + this.requestUpdate(); + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss b/roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss new file mode 100644 index 0000000..7c165c8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/styles/root.scss @@ -0,0 +1,16 @@ +converse-root.converse-js { + &.converse-fullpage, + &.converse-overlayed, + &.converse-mobile { + bottom: 0; + height: 100%; + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + position: fixed; + z-index: 1031; // One more than bootstrap navbar + } + + &.converse-embedded { + position: relative; + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js new file mode 100644 index 0000000..73efc63 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/templates/root.js @@ -0,0 +1,13 @@ +import 'shared/components/font-awesome.js'; +import { api } from '@converse/headless/core'; +import { html } from 'lit'; + +export default () => { + const extra_classes = api.settings.get('singleton') ? ['converse-singleton'] : []; + extra_classes.push(`converse-${api.settings.get('view_mode')}`); + return html` + <converse-chats class="converse-chatboxes row no-gutters ${extra_classes.join(' ')}"></converse-chats> + <div id="converse-modals" class="modals"></div> + <converse-fontawesome></converse-fontawesome> + `; +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js new file mode 100644 index 0000000..581f15a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/tests/root.js @@ -0,0 +1,16 @@ +/* global mock, converse */ + +const u = converse.env.utils; + +describe("Converse", function() { + + it("Can be inserted into a converse-root custom element after having been initialized", + mock.initConverse([], {'root': new DocumentFragment()}, async (_converse) => { + + const { api } = _converse; + expect(document.body.querySelector('#conversejs')).toBe(null); + expect(api.settings.get('root').firstElementChild.nodeName.toLowerCase()).toBe('converse-root'); + document.body.appendChild(document.createElement('converse-root')); + await u.waitUntil(() => document.body.querySelector('#conversejs') !== null); + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js new file mode 100644 index 0000000..9ee7790 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rootview/utils.js @@ -0,0 +1,25 @@ +import { api } from '@converse/headless/core'; + +export function getTheme() { + if (window.matchMedia('(prefers-color-scheme: dark)').matches) { + return api.settings.get('dark_theme'); + } else { + return api.settings.get('theme'); + } +} + +export function ensureElement () { + if (!api.settings.get('auto_insert')) { + return; + } + const root = api.settings.get('root'); + if (!root.querySelector('converse-root')) { + const el = document.createElement('converse-root'); + const body = root.querySelector('body'); + if (body) { + body.appendChild(el); + } else { + root.appendChild(el); // Perhaps inside a web component? + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js new file mode 100644 index 0000000..6d96c80 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/constants.js @@ -0,0 +1,10 @@ +import { __ } from 'i18n'; + +export const STATUSES = { + 'dnd': __('This contact is busy'), + 'online': __('This contact is online'), + 'offline': __('This contact is offline'), + 'unavailable': __('This contact is unavailable'), + 'xa': __('This contact is away for an extended period'), + 'away': __('This contact is away') +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js new file mode 100644 index 0000000..b4ac5fe --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/contactview.js @@ -0,0 +1,91 @@ +import log from "@converse/headless/log.js"; +import tplRequestingContact from "./templates/requesting_contact.js"; +import tplRosterItem from "./templates/roster_item.js"; +import { CustomElement } from 'shared/components/element.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; + + +export default class RosterContact extends CustomElement { + + static get properties () { + return { + model: { type: Object } + } + } + + initialize () { + this.listenTo(this.model, 'change', () => this.requestUpdate()); + this.listenTo(this.model, 'highlight', () => this.requestUpdate()); + this.listenTo(this.model, 'vcard:add', () => this.requestUpdate()); + this.listenTo(this.model, 'vcard:change', () => this.requestUpdate()); + this.listenTo(this.model, 'presenceChanged', () => this.requestUpdate()); + } + + render () { + if (this.model.get('requesting') === true) { + const display_name = this.model.getDisplayName(); + return tplRequestingContact( + Object.assign(this.model.toJSON(), { + display_name, + 'openChat': ev => this.openChat(ev), + 'acceptRequest': ev => this.acceptRequest(ev), + 'declineRequest': ev => this.declineRequest(ev), + 'desc_accept': __("Click to accept the contact request from %1$s", display_name), + 'desc_decline': __("Click to decline the contact request from %1$s", display_name), + }) + ); + } else { + return tplRosterItem(this, this.model); + } + } + + openChat (ev) { + ev?.preventDefault?.(); + this.model.openChat(); + } + + async removeContact (ev) { + ev?.preventDefault?.(); + if (!api.settings.get('allow_contact_removal')) { return; } + + const result = await api.confirm(__("Are you sure you want to remove this contact?")); + if (!result) return; + + try { + this.model.removeFromRoster(); + if (this.model.collection) { + // The model might have already been removed as + // result of a roster push. + this.model.destroy(); + } + } catch (e) { + log.error(e); + api.alert('error', __('Error'), + [__('Sorry, there was an error while trying to remove %1$s as a contact.', this.model.getDisplayName())] + ); + } + } + + async acceptRequest (ev) { + ev?.preventDefault?.(); + + await _converse.roster.sendContactAddIQ( + this.model.get('jid'), + this.model.getFullname(), + [] + ); + this.model.authorize().subscribe(); + } + + async declineRequest (ev) { + if (ev && ev.preventDefault) { ev.preventDefault(); } + const result = await api.confirm(__("Are you sure you want to decline this contact request?")); + if (result) { + this.model.unauthorize().destroy(); + } + return this; + } +} + +api.elements.define('converse-roster-contact', RosterContact); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js new file mode 100644 index 0000000..55f5208 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/filterview.js @@ -0,0 +1,92 @@ +import debounce from "lodash-es/debounce"; +import tplRosterFilter from "./templates/roster_filter.js"; +import { CustomElement } from 'shared/components/element.js'; +import { _converse, api } from "@converse/headless/core"; +import { ancestor } from 'utils/html.js'; + + +export class RosterFilterView extends CustomElement { + + async initialize () { + await api.waitUntil('rosterInitialized') + this.model = _converse.roster_filter; + + this.liveFilter = debounce(() => { + this.model.save({'filter_text': this.querySelector('.roster-filter').value}); + }, 250); + + this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate()); + this.listenTo(_converse.presences, 'change:show', () => this.requestUpdate()); + this.listenTo(_converse.roster, "add", () => this.requestUpdate()); + this.listenTo(_converse.roster, "destroy", () => this.requestUpdate()); + this.listenTo(_converse.roster, "remove", () => this.requestUpdate()); + this.listenTo(this.model, 'change', this.dispatchUpdateEvent); + this.listenTo(this.model, 'change', () => this.requestUpdate()); + + this.requestUpdate(); + } + + render () { + return this.model ? + tplRosterFilter( + Object.assign(this.model.toJSON(), { + visible: this.shouldBeVisible(), + changeChatStateFilter: ev => this.changeChatStateFilter(ev), + changeTypeFilter: ev => this.changeTypeFilter(ev), + clearFilter: ev => this.clearFilter(ev), + liveFilter: ev => this.liveFilter(ev), + submitFilter: ev => this.submitFilter(ev), + })) : ''; + } + + dispatchUpdateEvent () { + this.dispatchEvent(new CustomEvent('update', { 'detail': this.model.changed })); + } + + changeChatStateFilter (ev) { + ev && ev.preventDefault(); + this.model.save({'chat_state': this.querySelector('.state-type').value}); + } + + changeTypeFilter (ev) { + ev && ev.preventDefault(); + const type = ancestor(ev.target, 'converse-icon')?.dataset.type || 'contacts'; + if (type === 'state') { + this.model.save({ + 'filter_type': type, + 'chat_state': this.querySelector('.state-type').value + }); + } else { + this.model.save({ + 'filter_type': type, + 'filter_text': this.querySelector('.roster-filter').value + }); + } + } + + submitFilter (ev) { + ev && ev.preventDefault(); + this.liveFilter(); + } + + /** + * Returns true if the filter is enabled (i.e. if the user + * has added values to the filter). + * @private + * @method _converse.RosterFilterView#isActive + */ + isActive () { + return (this.model.get('filter_type') === 'state' || this.model.get('filter_text')); + } + + shouldBeVisible () { + return _converse.roster?.length >= 5 || this.isActive(); + } + + clearFilter (ev) { + ev && ev.preventDefault(); + this.model.save({'filter_text': ''}); + } +} + +api.elements.define('converse-roster-filter', RosterFilterView); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js new file mode 100644 index 0000000..53c5f82 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/index.js @@ -0,0 +1,46 @@ +/** + * @copyright 2022, the Converse.js contributors + * @license Mozilla Public License (MPLv2) + */ +import "../modal"; +import "@converse/headless/plugins/chatboxes/index.js"; +import "@converse/headless/plugins/roster/index.js"; +import "./modals/add-contact.js"; +import './rosterview.js'; +import RosterContactView from './contactview.js'; +import { RosterFilter } from '@converse/headless/plugins/roster/filter.js'; +import { RosterFilterView } from './filterview.js'; +import { _converse, api, converse } from "@converse/headless/core"; +import { highlightRosterItem } from './utils.js'; + +import 'shared/styles/status.scss'; +import './styles/roster.scss'; + + +converse.plugins.add('converse-rosterview', { + + dependencies: ["converse-roster", "converse-modal", "converse-chatboxviews"], + + initialize () { + api.settings.extend({ + 'autocomplete_add_contact': true, + 'allow_contact_removal': true, + 'hide_offline_users': false, + 'roster_groups': true, + 'xhr_user_search_url': null, + }); + api.promises.add('rosterViewInitialized'); + + _converse.RosterFilter = RosterFilter; + _converse.RosterFilterView = RosterFilterView; + _converse.RosterContactView = RosterContactView; + + /* -------- Event Handlers ----------- */ + api.listen.on('chatBoxesInitialized', () => { + _converse.chatboxes.on('destroy', chatbox => highlightRosterItem(chatbox)); + _converse.chatboxes.on('change:hidden', chatbox => highlightRosterItem(chatbox)); + }); + + api.listen.on('afterTearDown', () => _converse.rotergroups?.off().reset()); + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js new file mode 100644 index 0000000..dcfff99 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/add-contact.js @@ -0,0 +1,155 @@ +import 'shared/autocomplete/index.js'; +import BaseModal from "plugins/modal/modal.js"; +import api from '@converse/headless/shared/api'; +import compact from 'lodash-es/compact'; +import debounce from 'lodash-es/debounce'; +import tplAddContactModal from "./templates/add-contact.js"; +import { Strophe } from 'strophe.js/src/core.js'; +import { __ } from 'i18n'; +import { _converse } from "@converse/headless/core"; +import { addClass, removeClass } from 'utils/html.js'; + +export default class AddContactModal extends BaseModal { + + initialize () { + super.initialize(); + this.listenTo(this.model, 'change', () => this.render()); + this.render(); + this.addEventListener('shown.bs.modal', () => this.querySelector('input[name="jid"]')?.focus(), false); + } + + renderModal () { + return tplAddContactModal(this); + } + + getModalTitle () { // eslint-disable-line class-methods-use-this + return __('Add a Contact'); + } + + afterRender () { + if (typeof api.settings.get('xhr_user_search_url') === 'string') { + this.initXHRAutoComplete(); + } else { + this.initJIDAutoComplete(); + } + } + + initJIDAutoComplete () { + if (!api.settings.get('autocomplete_add_contact')) { + return; + } + const el = this.querySelector('.suggestion-box__jid').parentElement; + this.jid_auto_complete = new _converse.AutoComplete(el, { + 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`, + 'filter': _converse.FILTER_STARTSWITH, + 'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))] + }); + } + + initGroupAutoComplete () { + if (!api.settings.get('autocomplete_add_contact')) { + return; + } + const el = this.querySelector('.suggestion-box__jid').parentElement; + this.jid_auto_complete = new _converse.AutoComplete(el, { + 'data': (text, input) => `${input.slice(0, input.indexOf("@"))}@${text}`, + 'filter': _converse.FILTER_STARTSWITH, + 'list': [...new Set(_converse.roster.map(item => Strophe.getDomainFromJid(item.get('jid'))))] + }); + } + + initXHRAutoComplete () { + if (!api.settings.get('autocomplete_add_contact')) { + return this.initXHRFetch(); + } + const el = this.querySelector('.suggestion-box__name').parentElement; + this.name_auto_complete = new _converse.AutoComplete(el, { + 'auto_evaluate': false, + 'filter': _converse.FILTER_STARTSWITH, + 'list': [] + }); + const xhr = new window.XMLHttpRequest(); + // `open` must be called after `onload` for mock/testing purposes. + xhr.onload = () => { + if (xhr.responseText) { + const r = xhr.responseText; + this.name_auto_complete.list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid})); + this.name_auto_complete.auto_completing = true; + this.name_auto_complete.evaluate(); + } + }; + const input_el = this.querySelector('input[name="name"]'); + input_el.addEventListener('input', debounce(() => { + xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true); + xhr.send() + } , 300)); + this.name_auto_complete.on('suggestion-box-selectcomplete', ev => { + this.querySelector('input[name="name"]').value = ev.text.label; + this.querySelector('input[name="jid"]').value = ev.text.value; + }); + } + + initXHRFetch () { + this.xhr = new window.XMLHttpRequest(); + this.xhr.onload = () => { + if (this.xhr.responseText) { + const r = this.xhr.responseText; + const list = JSON.parse(r).map(i => ({'label': i.fullname || i.jid, 'value': i.jid})); + if (list.length !== 1) { + const el = this.querySelector('.invalid-feedback'); + el.textContent = __('Sorry, could not find a contact with that name') + addClass('d-block', el); + return; + } + const jid = list[0].value; + if (this.validateSubmission(jid)) { + const form = this.querySelector('form'); + const name = list[0].label; + this.afterSubmission(form, jid, name); + } + } + }; + } + + validateSubmission (jid) { + const el = this.querySelector('.invalid-feedback'); + if (!jid || compact(jid.split('@')).length < 2) { + addClass('is-invalid', this.querySelector('input[name="jid"]')); + addClass('d-block', el); + return false; + } else if (_converse.roster.get(Strophe.getBareJidFromJid(jid))) { + el.textContent = __('This contact has already been added') + addClass('d-block', el); + return false; + } + removeClass('d-block', el); + return true; + } + + afterSubmission (_form, jid, name, group) { + if (group && !Array.isArray(group)) { + group = [group]; + } + _converse.roster.addAndSubscribe(jid, name, group); + this.model.clear(); + this.modal.hide(); + } + + addContactFromForm (ev) { + ev.preventDefault(); + const data = new FormData(ev.target); + const jid = (data.get('jid') || '').trim(); + + if (!jid && typeof api.settings.get('xhr_user_search_url') === 'string') { + const input_el = this.querySelector('input[name="name"]'); + this.xhr.open("GET", `${api.settings.get('xhr_user_search_url')}q=${encodeURIComponent(input_el.value)}`, true); + this.xhr.send() + return; + } + if (this.validateSubmission(jid)) { + this.afterSubmission(ev.target, jid, data.get('name'), data.get('group')); + } + } +} + +api.elements.define('converse-add-contact-modal', AddContactModal); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js new file mode 100644 index 0000000..f18dc14 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/modals/templates/add-contact.js @@ -0,0 +1,48 @@ +import { __ } from 'i18n'; +import { api } from '@converse/headless/core.js'; +import { getGroupsAutoCompleteList } from '@converse/headless/plugins/roster/utils.js'; +import { html } from "lit"; + + +export default (el) => { + const i18n_add = __('Add'); + const i18n_contact_placeholder = __('name@example.org'); + const i18n_error_message = __('Please enter a valid XMPP address'); + const i18n_group = __('Group'); + const i18n_nickname = __('Name'); + const i18n_xmpp_address = __('XMPP Address'); + + return html` + <form class="converse-form add-xmpp-contact" @submit=${ev => el.addContactFromForm(ev)}> + <div class="modal-body"> + <span class="modal-alert"></span> + <div class="form-group add-xmpp-contact__jid"> + <label class="clearfix" for="jid">${i18n_xmpp_address}:</label> + <div class="suggestion-box suggestion-box__jid"> + <ul class="suggestion-box__results suggestion-box__results--below" hidden=""></ul> + <input type="text" name="jid" ?required=${(!api.settings.get('xhr_user_search_url'))} + value="${el.model.get('jid') || ''}" + class="form-control suggestion-box__input" + placeholder="${i18n_contact_placeholder}"/> + <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span> + </div> + </div> + + <div class="form-group add-xmpp-contact__name"> + <label class="clearfix" for="name">${i18n_nickname}:</label> + <div class="suggestion-box suggestion-box__name"> + <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul> + <input type="text" name="name" value="${el.model.get('nickname') || ''}" + class="form-control suggestion-box__input"/> + <span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span> + </div> + </div> + <div class="form-group add-xmpp-contact__group"> + <label class="clearfix" for="name">${i18n_group}:</label> + <converse-autocomplete .list=${getGroupsAutoCompleteList()} name="group"></converse-autocomplete> + </div> + <div class="form-group"><div class="invalid-feedback">${i18n_error_message}</div></div> + <button type="submit" class="btn btn-primary">${i18n_add}</button> + </div> + </form>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js new file mode 100644 index 0000000..f79da2b --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/rosterview.js @@ -0,0 +1,75 @@ +import tplRoster from "./templates/roster.js"; +import { CustomElement } from 'shared/components/element.js'; +import { Model } from '@converse/skeletor/src/model.js'; +import { _converse, api } from "@converse/headless/core"; +import { initStorage } from '@converse/headless/utils/storage.js'; +import { slideIn, slideOut } from 'utils/html.js'; + + +/** + * @class + * @namespace _converse.RosterView + * @memberOf _converse + */ +export default class RosterView extends CustomElement { + + async initialize () { + const id = `converse.contacts-panel${_converse.bare_jid}`; + this.model = new Model({ id }); + initStorage(this.model, id); + this.model.fetch(); + + await api.waitUntil('rosterInitialized') + + const { chatboxes, presences, roster } = _converse; + this.listenTo(_converse, 'rosterContactsFetched', () => this.requestUpdate()); + this.listenTo(presences, 'change:show', () => this.requestUpdate()); + this.listenTo(chatboxes, 'change:hidden', () => this.requestUpdate()); + this.listenTo(roster, 'add', () => this.requestUpdate()); + this.listenTo(roster, 'destroy', () => this.requestUpdate()); + this.listenTo(roster, 'remove', () => this.requestUpdate()); + this.listenTo(roster, 'change', () => this.requestUpdate()); + this.listenTo(roster.state, 'change', () => this.requestUpdate()); + this.listenTo(this.model, 'change', () => this.requestUpdate()); + /** + * Triggered once the _converse.RosterView instance has been created and initialized. + * @event _converse#rosterViewInitialized + * @example _converse.api.listen.on('rosterViewInitialized', () => { ... }); + */ + api.trigger('rosterViewInitialized'); + } + + render () { + return tplRoster(this); + } + + showAddContactModal (ev) { // eslint-disable-line class-methods-use-this + api.modal.show('converse-add-contact-modal', {'model': new Model()}, ev); + } + + async syncContacts (ev) { // eslint-disable-line class-methods-use-this + ev.preventDefault(); + const { roster } = _converse; + this.syncing_contacts = true; + this.requestUpdate(); + + roster.data.save('version', null); + await roster.fetchFromServer(); + api.user.presence.send(); + + this.syncing_contacts = false; + this.requestUpdate(); + } + + toggleRoster (ev) { + ev?.preventDefault?.(); + const list_el = this.querySelector('.list-container.roster-contacts'); + if (this.model.get('toggle_state') === _converse.CLOSED) { + slideOut(list_el).then(() => this.model.save({'toggle_state': _converse.OPENED})); + } else { + slideIn(list_el).then(() => this.model.save({'toggle_state': _converse.CLOSED})); + } + } +} + +api.elements.define('converse-roster', RosterView); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss new file mode 100644 index 0000000..207daff --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/styles/roster.scss @@ -0,0 +1,191 @@ +.conversejs { + + #controlbox { + .open-contacts-toggle, .open-contacts-toggle .fa { + color: var(--chat-color) !important; + &:hover { + color: var(--chat-color) !important; + } + } + + .open-contacts-toggle { + white-space: nowrap; + } + + } + + #converse-roster { + text-align: left; + width: 100%; + position: relative; + margin: 0; + height: var(--roster-height); + padding: 0; + overflow: hidden; + // XXX: FIXME + height: calc(100% - 70px); + + + /* Custom addition for CSP */ + #online-count { + display: none; + } + + .search-xmpp { + ul { + li.chat-info { + padding-left: 10px; + } + } + } + + .roster-filter-form { + width: 100%; + + .button-group { + padding: 0.2em; + } + + converse-icon { + padding: 0.25em; + } + + .roster-filter { + width: 100%; + margin: 0.2em; + font-size: calc(var(--font-size) - 2px); + } + + .state-type { + font-size: calc(var(--font-size) - 2px); + width: 100%; + } + } + + .roster-contacts { + padding: 0; + margin: 0 0 0.2em 0; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + color: var(--text-color); + + .roster-group-contacts { + .list-item { + &:hover { + .list-item-action { + opacity: 1; + } + } + } + } + + converse-roster-contact { + width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: flex; + justify-content: space-between; + + .list-item-action { + line-height: 2em; + } + + &:hover { + .list-item-action { + opacity: 1; + } + } + } + + .group-toggle { + font-family: var(--heading-font); + display: block; + width: 100%; + margin: 0.75em 0 0.25em 0; + } + + .group-toggle, .group-toggle .fa { + color: var(--chat-head-color-dark) !important; + &:hover { + color: var(--chat-head-color-darker) !important; + } + } + + .current-xmpp-contact { + margin: 0.25em 0; + } + + .list-item { + &.requesting-xmpp-contact { + a { + line-height: var(--line-height); + } + .req-contact-name { + padding: 0 0.2em 0 0; + } + } + + .open-chat { + margin: 0; + padding: 0; + &.unread-msgs { + font-weight: bold; + color: var(--unread-msgs-color); + .contact-name { + width: 70%; + } + } + + .msgs-indicator { + color: var(--text-color-invert); + background-color: var(--chat-color); + opacity: 1; + border-radius: 10%; + padding: 0.2em 0.4em; + font-size: var(--font-size-small); + margin-right: 0; + } + + .contact-name { + padding: 0; + margin: 0; + max-width: 85%; + float: none; + height: 100%; + &.unread-msgs { + max-width: 60%; + } + &.contact-name--offline { + margin-left: 0.25em; + } + } + } + &.odd { + background-color: #DCEAC5; + /* Make this difference */ + } + a, span { + white-space: nowrap; + text-overflow: ellipsis; + } + .span { + display: inline-block; + } + .decline-xmpp-request { + margin-left: 5px; + } + &:hover { + background-color: var(--controlbox-pane-bg-hover-color); + } + } + } + span { + &.pending-contact-name { + line-height: var(--line-height); + width: 100%; + } + } + } +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js new file mode 100644 index 0000000..562bfe6 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/group.js @@ -0,0 +1,63 @@ +import 'shared/components/icons.js'; +import { __ } from 'i18n'; +import { _converse, converse } from "@converse/headless/core"; +import { html } from "lit"; +import { isUniView } from '@converse/headless/utils/core.js'; +import { repeat } from 'lit/directives/repeat.js'; +import { toggleGroup } from '../utils.js'; + +const { u } = converse.env; + + +function renderContact (contact) { + const jid = contact.get('jid'); + const extra_classes = []; + if (isUniView()) { + const chatbox = _converse.chatboxes.get(jid); + if (chatbox && !chatbox.get('hidden')) { + extra_classes.push('open'); + } + } + const ask = contact.get('ask'); + const requesting = contact.get('requesting'); + const subscription = contact.get('subscription'); + if ((ask === 'subscribe') || (subscription === 'from')) { + /* ask === 'subscribe' + * Means we have asked to subscribe to them. + * + * subscription === 'from' + * They are subscribed to us, but not vice versa. + * We assume that there is a pending subscription + * from us to them (otherwise we're in a state not + * supported by converse.js). + * + * So in both cases the user is a "pending" contact. + */ + extra_classes.push('pending-xmpp-contact'); + } else if (requesting === true) { + extra_classes.push('requesting-xmpp-contact'); + } else if (subscription === 'both' || subscription === 'to' || u.isSameBareJID(jid, _converse.connection.jid)) { + extra_classes.push('current-xmpp-contact'); + extra_classes.push(subscription); + extra_classes.push(contact.presence.get('show')); + } + return html` + <li class="list-item d-flex controlbox-padded ${extra_classes.join(' ')}" data-status="${contact.presence.get('show')}"> + <converse-roster-contact .model=${contact}></converse-roster-contact> + </li>`; +} + + +export default (o) => { + const i18n_title = __('Click to hide these contacts'); + const collapsed = _converse.roster.state.get('collapsed_groups'); + return html` + <div class="roster-group" data-group="${o.name}"> + <a href="#" class="list-toggle group-toggle controlbox-padded" title="${i18n_title}" @click=${ev => toggleGroup(ev, o.name)}> + <converse-icon color="var(--chat-head-color-dark)" size="1em" class="fa ${ (collapsed.includes(o.name)) ? 'fa-caret-right' : 'fa-caret-down' }"></converse-icon> ${o.name} + </a> + <ul class="items-list roster-group-contacts ${ (collapsed.includes(o.name)) ? 'collapsed' : '' }" data-group="${o.name}"> + ${ repeat(o.contacts, (c) => c.get('jid'), renderContact) } + </ul> + </div>`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js new file mode 100644 index 0000000..513fb46 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/requesting_contact.js @@ -0,0 +1,19 @@ +import { html } from "lit"; + +export default (o) => html` + <a class="open-chat w-100" href="#" @click=${o.openChat}> + <span class="req-contact-name w-100" title="JID: ${o.jid}">${o.display_name}</span> + </a> + <a class="accept-xmpp-request list-item-action list-item-action--visible" + @click=${o.acceptRequest} + aria-label="${o.desc_accept}" title="${o.desc_accept}" href="#"> + + <converse-icon class="fa fa-check" size="1em"></converse-icon> + </a> + + <a class="decline-xmpp-request list-item-action list-item-action--visible" + @click=${o.declineRequest} + aria-label="${o.desc_decline}" title="${o.desc_decline}" href="#"> + + <converse-icon class="fa fa-times" size="1em"></converse-icon> + </a>`; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js new file mode 100644 index 0000000..d347f2a --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster.js @@ -0,0 +1,57 @@ +import tplGroup from "./group.js"; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import { contactsComparator, groupsComparator } from '@converse/headless/plugins/roster/utils.js'; +import { html } from "lit"; +import { repeat } from 'lit/directives/repeat.js'; +import { shouldShowContact, shouldShowGroup, populateContactsMap } from '../utils.js'; + + +export default (el) => { + const i18n_heading_contacts = __('Contacts'); + const i18n_toggle_contacts = __('Click to toggle contacts'); + const i18n_title_add_contact = __('Add a contact'); + const i18n_title_sync_contacts = __('Re-sync your contacts'); + const roster = _converse.roster || []; + const contacts_map = roster.reduce((acc, contact) => populateContactsMap(acc, contact), {}); + const groupnames = Object.keys(contacts_map).filter(shouldShowGroup); + const is_closed = el.model.get('toggle_state') === _converse.CLOSED; + groupnames.sort(groupsComparator); + + return html` + <div class="d-flex controlbox-padded"> + <span class="w-100 controlbox-heading controlbox-heading--contacts"> + <a class="list-toggle open-contacts-toggle" title="${i18n_toggle_contacts}" @click=${el.toggleRoster}> + <converse-icon + class="fa ${ is_closed ? 'fa-caret-right' : 'fa-caret-down' }" + size="1em" + color="var(--chat-color)"></converse-icon> + ${i18n_heading_contacts} + </a> + </span> + <a class="controlbox-heading__btn sync-contacts" + @click=${ev => el.syncContacts(ev)} + title="${i18n_title_sync_contacts}"> + + <converse-icon class="fa fa-sync right ${el.syncing_contacts ? 'fa-spin' : ''}" size="1em"></converse-icon> + </a> + ${ api.settings.get('allow_contact_requests') ? html` + <a class="controlbox-heading__btn add-contact" + @click=${ev => el.showAddContactModal(ev)} + title="${i18n_title_add_contact}" + data-toggle="modal" + data-target="#add-contact-modal"> + <converse-icon class="fa fa-user-plus right" size="1.25em"></converse-icon> + </a>` : '' } + </div> + + <div class="list-container roster-contacts ${ is_closed ? 'hidden' : '' }"> + <converse-roster-filter @update=${() => el.requestUpdate()}></converse-roster-filter> + ${ repeat(groupnames, (n) => n, (name) => { + const contacts = contacts_map[name].filter(c => shouldShowContact(c, name)); + contacts.sort(contactsComparator); + return contacts.length ? tplGroup({ contacts, name }) : ''; + }) } + </div> + `; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js new file mode 100644 index 0000000..44e7ce8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_filter.js @@ -0,0 +1,50 @@ +import { html } from "lit"; +import { __ } from 'i18n'; + + +export default (o) => { + const i18n_placeholder = __('Filter'); + const title_contact_filter = __('Filter by contact name'); + const title_group_filter = __('Filter by group name'); + const title_status_filter = __('Filter by status'); + const label_any = __('Any'); + const label_unread_messages = __('Unread'); + const label_online = __('Online'); + const label_chatty = __('Chatty'); + const label_busy = __('Busy'); + const label_away = __('Away'); + const label_xa = __('Extended Away'); + const label_offline = __('Offline'); + + return html` + <form class="controlbox-padded roster-filter-form input-button-group ${ (!o.visible) ? 'hidden' : 'fade-in' }" + @submit=${o.submitFilter}> + <div class="form-inline flex-nowrap"> + <div class="filter-by d-flex flex-nowrap"> + <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-user clickable ${ (o.filter_type === 'contacts') ? 'selected' : '' }" data-type="contacts" title="${title_contact_filter}"></converse-icon> + <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-users clickable ${ (o.filter_type === 'groups') ? 'selected' : '' }" data-type="groups" title="${title_group_filter}"></converse-icon> + <converse-icon size="1em" @click=${o.changeTypeFilter} class="fa fa-circle clickable ${ (o.filter_type === 'state') ? 'selected' : '' }" data-type="state" title="${title_status_filter}"></converse-icon> + </div> + <div class="btn-group"> + <input .value="${o.filter_text || ''}" + @keydown=${o.liveFilter} + class="roster-filter form-control ${ (o.filter_type === 'state') ? 'hidden' : '' }" + placeholder="${i18n_placeholder}"/> + <converse-icon size="1em" class="fa fa-times clear-input ${ (!o.filter_text || o.filter_type === 'state') ? 'hidden' : '' }" + @click=${o.clearFilter}> + </converse-icon> + </div> + <select class="form-control state-type ${ (o.filter_type !== 'state') ? 'hidden' : '' }" + @change=${o.changeChatStateFilter}> + <option value="">${label_any}</option> + <option ?selected=${o.chat_state === 'unread_messages'} value="unread_messages">${label_unread_messages}</option> + <option ?selected=${o.chat_state === 'online'} value="online">${label_online}</option> + <option ?selected=${o.chat_state === 'chat'} value="chat">${label_chatty}</option> + <option ?selected=${o.chat_state === 'dnd'} value="dnd">${label_busy}</option> + <option ?selected=${o.chat_state === 'away'} value="away">${label_away}</option> + <option ?selected=${o.chat_state === 'xa'} value="xa">${label_xa}</option> + <option ?selected=${o.chat_state === 'offline'} value="offline">${label_offline}</option> + </select> + </div> + </form>` +}; diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js new file mode 100644 index 0000000..86b6b94 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/templates/roster_item.js @@ -0,0 +1,50 @@ +import { __ } from 'i18n'; +import { api } from "@converse/headless/core.js"; +import { html } from "lit"; +import { STATUSES } from '../constants.js'; + +const tplRemoveLink = (el, item) => { + const display_name = item.getDisplayName(); + const i18n_remove = __('Click to remove %1$s as a contact', display_name); + return html` + <a class="list-item-action remove-xmpp-contact" @click=${el.removeContact} title="${i18n_remove}" href="#"> + <converse-icon class="fa fa-trash-alt" size="1.5em"></converse-icon> + </a> + `; +} + +export default (el, item) => { + const show = item.presence.get('show') || 'offline'; + let classes, color; + if (show === 'online') { + [classes, color] = ['fa fa-circle', 'chat-status-online']; + } else if (show === 'dnd') { + [classes, color] = ['fa fa-minus-circle', 'chat-status-busy']; + } else if (show === 'away') { + [classes, color] = ['fa fa-circle', 'chat-status-away']; + } else { + [classes, color] = ['fa fa-circle', 'subdued-color']; + } + const desc_status = STATUSES[show]; + const num_unread = item.get('num_unread') || 0; + const display_name = item.getDisplayName(); + const i18n_chat = __('Click to chat with %1$s (XMPP address: %2$s)', display_name, el.model.get('jid')); + return html` + <a class="list-item-link cbox-list-item open-chat ${ num_unread ? 'unread-msgs' : '' }" title="${i18n_chat}" href="#" @click=${el.openChat}> + <span> + <converse-avatar + class="avatar" + .data=${el.model.vcard?.attributes} + nonce=${el.model.vcard?.get('vcard_updated')} + height="30" width="30"></converse-avatar> + <converse-icon + title="${desc_status}" + color="var(--${color})" + size="1em" + class="${classes} chat-status chat-status--avatar"></converse-icon> + </span> + ${ num_unread ? html`<span class="msgs-indicator">${ num_unread }</span>` : '' } + <span class="contact-name contact-name--${el.show} ${ num_unread ? 'unread-msgs' : ''}">${display_name}</span> + </a> + ${ api.settings.get('allow_contact_removal') ? tplRemoveLink(el, item) : '' }`; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js new file mode 100644 index 0000000..a809cd7 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/add-contact-modal.js @@ -0,0 +1,195 @@ +/*global mock, converse */ + +const u = converse.env.utils; +const Strophe = converse.env.Strophe; +const sizzle = converse.env.sizzle; + +describe("The 'Add Contact' widget", function () { + + it("opens up an add modal when you click on it", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'all'); + await mock.openControlBox(_converse); + + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.add-contact').click() + const modal = _converse.api.modal.get('converse-add-contact-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null); + + const input_jid = modal.querySelector('input[name="jid"]'); + const input_name = modal.querySelector('input[name="name"]'); + input_jid.value = 'someone@'; + + const evt = new Event('input'); + input_jid.dispatchEvent(evt); + expect(modal.querySelector('.suggestion-box li').textContent).toBe('someone@montague.lit'); + input_jid.value = 'someone@montague.lit'; + input_name.value = 'Someone'; + modal.querySelector('button[type="submit"]').click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop()); + expect(Strophe.serialize(sent_stanza)).toEqual( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit" name="Someone"/></query>`+ + `</iq>`); + })); + + it("can be configured to not provide search suggestions", + mock.initConverse([], {'autocomplete_add_contact': false}, async function (_converse) { + + await mock.waitForRoster(_converse, 'all', 0); + await mock.openControlBox(_converse); + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.add-contact').click() + const modal = _converse.api.modal.get('converse-add-contact-modal'); + expect(modal.jid_auto_complete).toBe(undefined); + expect(modal.name_auto_complete).toBe(undefined); + + await u.waitUntil(() => u.isVisible(modal), 1000); + expect(modal.querySelector('form.add-xmpp-contact')).not.toBe(null); + const input_jid = modal.querySelector('input[name="jid"]'); + input_jid.value = 'someone@montague.lit'; + modal.querySelector('button[type="submit"]').click(); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil( + () => IQ_stanzas.filter(s => sizzle(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`, s).length).pop() + ); + expect(Strophe.serialize(sent_stanza)).toEqual( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"><item jid="someone@montague.lit"/></query>`+ + `</iq>` + ); + })); + + it("integrates with xhr_user_search_url to search for contacts", + mock.initConverse([], { 'xhr_user_search_url': 'http://example.org/?' }, + async function (_converse) { + + await mock.waitForRoster(_converse, 'all', 0); + + class MockXHR extends XMLHttpRequest { + open () {} // eslint-disable-line + responseText = '' + send () { + this.responseText = JSON.stringify([ + {"jid": "marty@mcfly.net", "fullname": "Marty McFly"}, + {"jid": "doc@brown.com", "fullname": "Doc Brown"} + ]); + this.onload(); + } + } + const XMLHttpRequestBackup = window.XMLHttpRequest; + window.XMLHttpRequest = MockXHR; + + await mock.openControlBox(_converse); + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.add-contact').click() + const modal = _converse.api.modal.get('converse-add-contact-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + // We only have autocomplete for the name input + expect(modal.jid_auto_complete).toBe(undefined); + expect(modal.name_auto_complete instanceof _converse.AutoComplete).toBe(true); + + const input_el = modal.querySelector('input[name="name"]'); + input_el.value = 'marty'; + input_el.dispatchEvent(new Event('input')); + await u.waitUntil(() => modal.querySelector('.suggestion-box li'), 1000); + expect(modal.querySelectorAll('.suggestion-box li').length).toBe(1); + const suggestion = modal.querySelector('.suggestion-box li'); + expect(suggestion.textContent).toBe('Marty McFly'); + + // Mock selection + modal.name_auto_complete.select(suggestion); + + expect(input_el.value).toBe('Marty McFly'); + expect(modal.querySelector('input[name="jid"]').value).toBe('marty@mcfly.net'); + modal.querySelector('button[type="submit"]').click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop()); + expect(Strophe.serialize(sent_stanza)).toEqual( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+ + `</iq>`); + window.XMLHttpRequest = XMLHttpRequestBackup; + })); + + it("can be configured to not provide search suggestions for XHR search results", + mock.initConverse([], + { 'autocomplete_add_contact': false, + 'xhr_user_search_url': 'http://example.org/?' }, + async function (_converse) { + + await mock.waitForRoster(_converse, 'all'); + await mock.openControlBox(_converse); + + class MockXHR extends XMLHttpRequest { + open () {} // eslint-disable-line + responseText = '' + send () { + const value = modal.querySelector('input[name="name"]').value; + if (value === 'existing') { + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + this.responseText = JSON.stringify([{"jid": contact_jid, "fullname": mock.cur_names[0]}]); + } else if (value === 'romeo') { + this.responseText = JSON.stringify([{"jid": "romeo@montague.lit", "fullname": "Romeo Montague"}]); + } else if (value === 'ambiguous') { + this.responseText = JSON.stringify([ + {"jid": "marty@mcfly.net", "fullname": "Marty McFly"}, + {"jid": "doc@brown.com", "fullname": "Doc Brown"} + ]); + } else if (value === 'insufficient') { + this.responseText = JSON.stringify([]); + } else { + this.responseText = JSON.stringify([{"jid": "marty@mcfly.net", "fullname": "Marty McFly"}]); + } + this.onload(); + } + } + + const XMLHttpRequestBackup = window.XMLHttpRequest; + window.XMLHttpRequest = MockXHR; + + const cbview = _converse.chatboxviews.get('controlbox'); + cbview.querySelector('.add-contact').click() + const modal = _converse.api.modal.get('converse-add-contact-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + + expect(modal.jid_auto_complete).toBe(undefined); + expect(modal.name_auto_complete).toBe(undefined); + + const input_el = modal.querySelector('input[name="name"]'); + input_el.value = 'ambiguous'; + modal.querySelector('button[type="submit"]').click(); + let feedback_el = modal.querySelector('.invalid-feedback'); + expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name'); + feedback_el.textContent = ''; + + input_el.value = 'insufficient'; + modal.querySelector('button[type="submit"]').click(); + feedback_el = modal.querySelector('.invalid-feedback'); + expect(feedback_el.textContent).toBe('Sorry, could not find a contact with that name'); + feedback_el.textContent = ''; + + input_el.value = 'existing'; + modal.querySelector('button[type="submit"]').click(); + feedback_el = modal.querySelector('.invalid-feedback'); + expect(feedback_el.textContent).toBe('This contact has already been added'); + + input_el.value = 'Marty McFly'; + modal.querySelector('button[type="submit"]').click(); + + const sent_IQs = _converse.connection.IQ_stanzas; + const sent_stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector(`iq[type="set"] query[xmlns="${Strophe.NS.ROSTER}"]`)).pop()); + expect(Strophe.serialize(sent_stanza)).toEqual( + `<iq id="${sent_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"><item jid="marty@mcfly.net" name="Marty McFly"/></query>`+ + `</iq>`); + window.XMLHttpRequest = XMLHttpRequestBackup; + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js new file mode 100644 index 0000000..2ef07a0 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/presence.js @@ -0,0 +1,54 @@ +/*global mock, converse */ + +const original_timeout = jasmine.DEFAULT_TIMEOUT_INTERVAL; + +describe("A sent presence stanza", function () { + + beforeEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = 7000)); + afterEach(() => (jasmine.DEFAULT_TIMEOUT_INTERVAL = original_timeout)); + + it("includes the saved status message", + mock.initConverse([], {}, async (_converse) => { + + const { u, Strophe } = converse.env; + mock.openControlBox(_converse); + spyOn(_converse.connection, 'send').and.callThrough(); + + const cbview = _converse.chatboxviews.get('controlbox'); + const change_status_el = await u.waitUntil(() => cbview.querySelector('.change-status')); + change_status_el.click() + let modal = _converse.api.modal.get('converse-chat-status-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + const msg = 'My custom status'; + modal.querySelector('input[name="status_message"]').value = msg; + modal.querySelector('[type="submit"]').click(); + + const sent_stanzas = _converse.connection.sent_stanzas; + let sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop()); + expect(Strophe.serialize(sent_presence)) + .toBe(`<presence xmlns="jabber:client">`+ + `<status>My custom status</status>`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`) + await u.waitUntil(() => modal.getAttribute('aria-hidden') === "true"); + await u.waitUntil(() => !u.isVisible(modal)); + + cbview.querySelector('.change-status').click() + modal = _converse.api.modal.get('converse-chat-status-modal'); + await u.waitUntil(() => modal.getAttribute('aria-hidden') === "false", 1000); + modal.querySelector('label[for="radio-busy"]').click(); // Change status to "dnd" + modal.querySelector('[type="submit"]').click(); + + await u.waitUntil(() => sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).length === 2); + sent_presence = sent_stanzas.filter(s => Strophe.serialize(s).match('presence')).pop(); + expect(Strophe.serialize(sent_presence)) + .toBe( + `<presence xmlns="jabber:client">`+ + `<show>dnd</show>`+ + `<status>My custom status</status>`+ + `<priority>0</priority>`+ + `<c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/>`+ + `</presence>`) + })); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js new file mode 100644 index 0000000..01439c5 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/protocol.js @@ -0,0 +1,537 @@ +/*global mock, converse */ + +// See: https://xmpp.org/rfcs/rfc3921.html + +const { Strophe, stx } = converse.env; + +describe("The Protocol", function () { + + beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza })); + + describe("Integration of Roster Items and Presence Subscriptions", function () { + /* Some level of integration between roster items and presence + * subscriptions is normally expected by an instant messaging user + * regarding the user's subscriptions to and from other contacts. This + * section describes the level of integration that MUST be supported + * within an XMPP instant messaging applications. + * + * There are four primary subscription states: + * + * None -- the user does not have a subscription to the contact's + * presence information, and the contact does not have a subscription + * to the user's presence information + * To -- the user has a subscription to the contact's presence + * information, but the contact does not have a subscription to the + * user's presence information + * From -- the contact has a subscription to the user's presence + * information, but the user does not have a subscription to the + * contact's presence information + * Both -- both the user and the contact have subscriptions to each + * other's presence information (i.e., the union of 'from' and 'to') + * + * Each of these states is reflected in the roster of both the user and + * the contact, thus resulting in durable subscription states. + * + * The 'from' and 'to' addresses are OPTIONAL in roster pushes; if + * included, their values SHOULD be the full JID of the resource for + * that session. A client MUST acknowledge each roster push with an IQ + * stanza of type "result". + */ + it("Subscribe to contact, contact accepts and subscribes back", + mock.initConverse([], { roster_groups: false }, async function (_converse) { + + const { u, $iq, $pres, sizzle, Strophe } = converse.env; + let stanza; + await mock.waitForRoster(_converse, 'current', 0); + await mock.waitUntilDiscoConfirmed(_converse, 'montague.lit', [], ['vcard-temp']); + await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'), 300); + /* The process by which a user subscribes to a contact, including + * the interaction between roster items and subscription states. + */ + mock.openControlBox(_converse); + const cbview = _converse.chatboxviews.get('controlbox'); + + spyOn(_converse.roster, "addAndSubscribe").and.callThrough(); + spyOn(_converse.roster, "addContactToRoster").and.callThrough(); + spyOn(_converse.roster, "sendContactAddIQ").and.callThrough(); + spyOn(_converse.api.vcard, "get").and.callThrough(); + + cbview.querySelector('.add-contact').click() + const modal = _converse.api.modal.get('converse-add-contact-modal'); + await u.waitUntil(() => u.isVisible(modal), 1000); + modal.delegateEvents(); + + // Fill in the form and submit + const form = modal.querySelector('form.add-xmpp-contact'); + form.querySelector('input[name="jid"]').value = 'contact@example.org'; + form.querySelector('input[name="name"]').value = 'Chris Contact'; + form.querySelector('input[name="group"]').value = 'My Buddies'; + form.querySelector('[type="submit"]').click(); + + /* In preparation for being able to render the contact in the + * user's client interface and for the server to keep track of the + * subscription, the user's client SHOULD perform a "roster set" + * for the new roster item. + */ + expect(_converse.roster.addAndSubscribe).toHaveBeenCalled(); + expect(_converse.roster.addContactToRoster).toHaveBeenCalled(); + + /* The request consists of sending an IQ + * stanza of type='set' containing a <query/> element qualified by + * the 'jabber:iq:roster' namespace, which in turn contains an + * <item/> element that defines the new roster item; the <item/> + * element MUST possess a 'jid' attribute, MAY possess a 'name' + * attribute, MUST NOT possess a 'subscription' attribute, and MAY + * contain one or more <group/> child elements: + * + * <iq type='set' id='set1'> + * <query xmlns='jabber:iq:roster'> + * <item + * jid='contact@example.org' + * name='MyContact'> + * <group>MyBuddies</group> + * </item> + * </query> + * </iq> + */ + await mock.waitForRoster(_converse, 'all', 0); + expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled(); + + const IQ_stanzas = _converse.connection.IQ_stanzas; + const roster_set_stanza = IQ_stanzas.filter(s => sizzle('query[xmlns="jabber:iq:roster"]', s)).pop(); + + expect(Strophe.serialize(roster_set_stanza)).toBe( + `<iq id="${roster_set_stanza.getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster">`+ + `<item jid="contact@example.org" name="Chris Contact">`+ + `<group>My Buddies</group>`+ + `</item>`+ + `</query>`+ + `</iq>` + ); + + const sent_stanzas = []; + let sent_stanza; + spyOn(_converse.connection, 'send').and.callFake(function (stanza) { + sent_stanza = stanza; + sent_stanzas.push(stanza); + }); + + /* As a result, the user's server (1) MUST initiate a roster push + * for the new roster item to all available resources associated + * with the user that have requested the roster, setting the + * 'subscription' attribute to a value of "none"; and (2) MUST + * reply to the sending resource with an IQ result indicating the + * success of the roster set: + * + * <iq type='set'> + * <query xmlns='jabber:iq:roster'> + * <item + * jid='contact@example.org' + * subscription='none' + * name='MyContact'> + * <group>MyBuddies</group> + * </item> + * </query> + * </iq> + */ + _converse.connection._dataRecv(mock.createRequest( + $iq({'type': 'set'}) + .c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'none', + 'name': 'Chris Contact' + }).c('group').t('My Buddies') + )); + + _converse.connection._dataRecv(mock.createRequest( + $iq({'type': 'result', 'id': roster_set_stanza.getAttribute('id')}) + )); + + await u.waitUntil(() => _converse.roster.length === 1); + + // A contact should now have been created + const contact = _converse.roster.at(0); + expect(contact.get('jid')).toBe('contact@example.org'); + expect(contact.get('nickname')).toBe('Chris Contact'); + expect(contact.get('groups')).toEqual(['My Buddies']); + await u.waitUntil(() => contact.initialized); + + /* To subscribe to the contact's presence information, + * the user's client MUST send a presence stanza of + * type='subscribe' to the contact: + * + * <presence to='contact@example.org' type='subscribe'/> + */ + const sent_presence = await u.waitUntil(() => sent_stanzas.filter(s => s.matches('presence')).pop()); + expect(sent_presence).toEqualStanza(stx` + <presence to="contact@example.org" type="subscribe" xmlns="jabber:client"> + <nick xmlns="http://jabber.org/protocol/nick">Romeo</nick> + <priority>0</priority> + <c hash="sha-1" node="https://conversejs.org" ver="TfHz9vOOfqIG0Z9lW5CuPaWGnrQ=" xmlns="http://jabber.org/protocol/caps"/> + </presence> + `); + + /* As a result, the user's server MUST initiate a second roster + * push to all of the user's available resources that have + * requested the roster, setting the contact to the pending + * sub-state of the 'none' subscription state; The pending + * sub-state is denoted by the inclusion of the ask='subscribe' + * attribute in the roster item: + * + * <iq type='set'> + * <query xmlns='jabber:iq:roster'> + * <item + * jid='contact@example.org' + * subscription='none' + * ask='subscribe' + * name='MyContact'> + * <group>MyBuddies</group> + * </item> + * </query> + * </iq> + */ + _converse.connection._dataRecv(mock.createRequest( + $iq({'type': 'set', 'from': _converse.bare_jid}) + .c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'none', + 'ask': 'subscribe', + 'name': 'Chris Contact' + }).c('group').t('My Buddies') + )); + + const rosterview = document.querySelector('converse-roster'); + + // Check that the user is now properly shown as a pending contact in the roster. + await u.waitUntil(() => { + const header = sizzle('a:contains("Pending contacts")', rosterview).pop(); + const contacts = Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible); + return contacts.length; + }, 600); + + let header = sizzle('a:contains("Pending contacts")', rosterview).pop(); + let contacts = header.parentElement.querySelectorAll('li'); + expect(contacts.length).toBe(1); + expect(u.isVisible(contacts[0])).toBe(true); + sent_stanza = ""; // Reset + + spyOn(contact, "ackSubscribe").and.callThrough(); + + /* Here we assume the "happy path" that the contact + * approves the subscription request + * + * <presence + * to='user@example.com' + * from='contact@example.org' + * type='subscribed'/> + */ + _converse.connection._dataRecv(mock.createRequest( + stanza = $pres({ + 'to': _converse.bare_jid, + 'from': 'contact@example.org', + 'type': 'subscribed' + }) + )); + + /* Upon receiving the presence stanza of type "subscribed", + * the user SHOULD acknowledge receipt of that + * subscription state notification by sending a presence + * stanza of type "subscribe". + */ + expect(contact.ackSubscribe).toHaveBeenCalled(); + expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec) + `<presence to="contact@example.org" type="subscribe" xmlns="jabber:client"/>` + ); + + /* The user's server MUST initiate a roster push to all of the user's + * available resources that have requested the roster, + * containing an updated roster item for the contact with + * the 'subscription' attribute set to a value of "to"; + * + * <iq type='set'> + * <query xmlns='jabber:iq:roster'> + * <item + * jid='contact@example.org' + * subscription='to' + * name='MyContact'> + * <group>MyBuddies</group> + * </item> + * </query> + * </iq> + */ + const IQ_id = _converse.connection.getUniqueId('roster'); + stanza = $iq({'type': 'set', 'id': IQ_id}) + .c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'to', + 'name': 'Nicky'}); + + _converse.connection._dataRecv(mock.createRequest(stanza)); + // Check that the IQ set was acknowledged. + expect(Strophe.serialize(sent_stanza)).toBe( // Strophe adds the xmlns attr (although not in spec) + `<iq from="romeo@montague.lit/orchard" id="${IQ_id}" type="result" xmlns="jabber:client"/>` + ); + + // The contact should now be visible as an existing contact (but still offline). + await u.waitUntil(() => { + const header = sizzle('a:contains("My contacts")', rosterview).pop(); + return sizzle('li', header?.parentNode).filter(l => u.isVisible(l)).length; + }, 600); + header = sizzle('a:contains("My contacts")', rosterview); + expect(header.length).toBe(1); + expect(u.isVisible(header[0])).toBeTruthy(); + contacts = header[0].parentNode.querySelectorAll('li'); + expect(contacts.length).toBe(1); + // Check that it has the right classes and text + expect(u.hasClass('to', contacts[0])).toBeTruthy(); + expect(u.hasClass('both', contacts[0])).toBeFalsy(); + expect(u.hasClass('current-xmpp-contact', contacts[0])).toBeTruthy(); + + await u.waitUntil(() => contacts[0].textContent.trim() === 'Nicky'); + + expect(contact.presence.get('show')).toBe('offline'); + + /* <presence + * from='contact@example.org/resource' + * to='user@example.com/resource'/> + */ + stanza = $pres({'to': _converse.bare_jid, 'from': 'contact@example.org/resource'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + // Now the contact should also be online. + expect(contact.presence.get('show')).toBe('online'); + + /* Section 8.3. Creating a Mutual Subscription + * + * If the contact wants to create a mutual subscription, + * the contact MUST send a subscription request to the + * user. + * + * <presence from='contact@example.org' to='user@example.com' type='subscribe'/> + */ + spyOn(contact, 'authorize').and.callThrough(); + spyOn(_converse.roster, 'handleIncomingSubscription').and.callThrough(); + stanza = $pres({ + 'to': _converse.bare_jid, + 'from': 'contact@example.org/resource', + 'type': 'subscribe'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + expect(_converse.roster.handleIncomingSubscription).toHaveBeenCalled(); + + /* The user's client MUST send a presence stanza of type + * "subscribed" to the contact in order to approve the + * subscription request. + * + * <presence to='contact@example.org' type='subscribed'/> + */ + expect(contact.authorize).toHaveBeenCalled(); + expect(Strophe.serialize(sent_stanza)).toBe( + `<presence to="contact@example.org" type="subscribed" xmlns="jabber:client"/>` + ); + + /* As a result, the user's server MUST initiate a + * roster push containing a roster item for the + * contact with the 'subscription' attribute set to + * a value of "both". + * + * <iq type='set'> + * <query xmlns='jabber:iq:roster'> + * <item + * jid='contact@example.org' + * subscription='both' + * name='MyContact'> + * <group>MyBuddies</group> + * </item> + * </query> + * </iq> + */ + _converse.connection._dataRecv(mock.createRequest( + $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'both', + 'name': 'contact@example.org'}) + )); + + // The class on the contact will now have switched. + await u.waitUntil(() => !u.hasClass('to', contacts[0])); + expect(u.hasClass('both', contacts[0])).toBe(true); + + })); + + it("Alternate Flow: Contact Declines Subscription Request", + mock.initConverse([], {}, async function (_converse) { + + const { $iq, $pres } = converse.env; + /* The process by which a user subscribes to a contact, including + * the interaction between roster items and subscription states. + */ + var contact, stanza, sent_stanza, sent_IQ; + await mock.waitForRoster(_converse, 'current', 0); + mock.openControlBox(_converse); + // Add a new roster contact via roster push + stanza = $iq({'type': 'set'}).c('query', {'xmlns': 'jabber:iq:roster'}) + .c('item', { + 'jid': 'contact@example.org', + 'subscription': 'none', + 'ask': 'subscribe', + 'name': 'contact@example.org'}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + // A pending contact should now exist. + contact = _converse.roster.get('contact@example.org'); + expect(_converse.roster.get('contact@example.org') instanceof _converse.RosterContact).toBeTruthy(); + spyOn(contact, "ackUnsubscribe").and.callThrough(); + + spyOn(_converse.connection, 'send').and.callFake(stanza => { sent_stanza = stanza }); + spyOn(_converse.connection, 'sendIQ').and.callFake(iq => { sent_IQ = iq }); + /* We now assume the contact declines the subscription + * requests. + * + * Upon receiving the presence stanza of type "unsubscribed" + * addressed to the user, the user's server (1) MUST deliver + * that presence stanza to the user and (2) MUST initiate a + * roster push to all of the user's available resources that + * have requested the roster, containing an updated roster + * item for the contact with the 'subscription' attribute + * set to a value of "none" and with no 'ask' attribute: + * + * <presence + * from='contact@example.org' + * to='user@example.com' + * type='unsubscribed'/> + * + * <iq type='set'> + * <query xmlns='jabber:iq:roster'> + * <item + * jid='contact@example.org' + * subscription='none' + * name='MyContact'> + * <group>MyBuddies</group> + * </item> + * </query> + * </iq> + */ + // FIXME: also add the <iq> + stanza = $pres({ + 'to': _converse.bare_jid, + 'from': 'contact@example.org', + 'type': 'unsubscribed' + }); + _converse.connection._dataRecv(mock.createRequest(stanza)); + + /* Upon receiving the presence stanza of type "unsubscribed", + * the user SHOULD acknowledge receipt of that subscription + * state notification through either "affirming" it by + * sending a presence stanza of type "unsubscribe + */ + expect(contact.ackUnsubscribe).toHaveBeenCalled(); + expect(Strophe.serialize(sent_stanza)).toBe( + `<presence to="contact@example.org" type="unsubscribe" xmlns="jabber:client"/>` + ); + + /* _converse.js will then also automatically remove the + * contact from the user's roster. + */ + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster">`+ + `<item jid="contact@example.org" subscription="remove"/>`+ + `</query>`+ + `</iq>` + ); + })); + + it("Unsubscribe to a contact when subscription is mutual", + mock.initConverse([], { roster_groups: false }, async function (_converse) { + + const { u, $iq, sizzle, Strophe } = converse.env; + const jid = 'abram@montague.lit'; + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + // We now have a contact we want to remove + expect(_converse.roster.get(jid) instanceof _converse.RosterContact).toBeTruthy(); + + const rosterview = document.querySelector('converse-roster'); + const header = sizzle('a:contains("My contacts")', rosterview).pop(); + await u.waitUntil(() => header.parentElement.querySelectorAll('li').length); + + // remove the first user + header.parentElement.querySelector('li .remove-xmpp-contact').click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + + /* Section 8.6 Removing a Roster Item and Cancelling All + * Subscriptions + * + * First the user is removed from the roster + * Because there may be many steps involved in completely + * removing a roster item and cancelling subscriptions in + * both directions, the roster management protocol includes + * a "shortcut" method for doing so. The process may be + * initiated no matter what the current subscription state + * is by sending a roster set containing an item for the + * contact with the 'subscription' attribute set to a value + * of "remove": + * + * <iq type='set' id='remove1'> + * <query xmlns='jabber:iq:roster'> + * <item jid='contact@example.org' subscription='remove'/> + * </query> + * </iq> + */ + const iq_stanzas = _converse.connection.IQ_stanzas; + await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) === + `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster">`+ + `<item jid="abram@montague.lit" subscription="remove"/>`+ + `</query>`+ + `</iq>`); + const sent_iq = iq_stanzas.at(-1); + + // Receive confirmation from the contact's server + // <iq type='result' id='remove1'/> + const stanza = $iq({'type': 'result', 'id': sent_iq.getAttribute('id')}); + _converse.connection._dataRecv(mock.createRequest(stanza)); + // Our contact has now been removed + await u.waitUntil(() => typeof _converse.roster.get(jid) === "undefined"); + })); + + it("Receiving a subscription request", mock.initConverse( + [], {}, async function (_converse) { + + const { u, $pres, sizzle, Strophe } = converse.env; + spyOn(_converse.api, "trigger").and.callThrough(); + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + /* <presence + * from='user@example.com' + * to='contact@example.org' + * type='subscribe'/> + */ + const stanza = $pres({ + 'to': _converse.bare_jid, + 'from': 'contact@example.org', + 'type': 'subscribe' + }).c('nick', { + 'xmlns': Strophe.NS.NICK, + }).t('Clint Contact'); + + + _converse.connection._dataRecv(mock.createRequest(stanza)); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => { + const header = sizzle('a:contains("Contact requests")', rosterview).pop(); + return Array.from(header?.parentElement.querySelectorAll('li') ?? []).filter(u.isVisible)?.length; + }, 500); + expect(_converse.api.trigger).toHaveBeenCalledWith('contactRequest', jasmine.any(Object)); + + const header = sizzle('a:contains("Contact requests")', rosterview).pop(); + expect(u.isVisible(header)).toBe(true); + const contacts = header.nextElementSibling.querySelectorAll('li'); + expect(contacts.length).toBe(1); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js new file mode 100644 index 0000000..e52c833 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/tests/roster.js @@ -0,0 +1,1365 @@ +/*global mock, converse, _ */ + +const $iq = converse.env.$iq; +const $pres = converse.env.$pres; +const Strophe = converse.env.Strophe; +const sizzle = converse.env.sizzle; +const u = converse.env.utils; + +const checkHeaderToggling = async function (group) { + const toggle = group.querySelector('a.group-toggle'); + expect(u.isVisible(group)).toBeTruthy(); + expect(group.querySelectorAll('ul.collapsed').length).toBe(0); + expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy(); + expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy(); + toggle.click(); + + await u.waitUntil(() => group.querySelectorAll('ul.collapsed').length === 1); + expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeTruthy(); + expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeFalsy(); + toggle.click(); + await u.waitUntil(() => group.querySelectorAll('li').length === _.filter(group.querySelectorAll('li'), u.isVisible).length); + expect(u.hasClass('fa-caret-right', toggle.firstElementChild)).toBeFalsy(); + expect(u.hasClass('fa-caret-down', toggle.firstElementChild)).toBeTruthy(); +}; + + +describe("The Contacts Roster", function () { + + it("verifies the origin of roster pushes", mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) { + // See: https://gultsch.de/gajim_roster_push_and_message_interception.html + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + await mock.waitForRoster(_converse, 'current', 1); + expect(_converse.roster.models.length).toBe(1); + expect(_converse.roster.at(0).get('jid')).toBe(contact_jid); + + spyOn(converse.env.log, 'warn'); + let roster_push = u.toStanza(` + <iq type="set" to="${_converse.jid}" from="eve@siacs.eu"> + <query xmlns='jabber:iq:roster'> + <item subscription="remove" jid="${contact_jid}"/> + </query> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(roster_push)); + expect(converse.env.log.warn.calls.count()).toBe(1); + expect(converse.env.log.warn).toHaveBeenCalledWith( + `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}` + ); + roster_push = u.toStanza(` + <iq type="set" to="${_converse.jid}" from="eve@siacs.eu"> + <query xmlns='jabber:iq:roster'> + <item subscription="both" jid="eve@siacs.eu" name="${mock.cur_names[0]}" /> + </query> + </iq>`); + _converse.connection._dataRecv(mock.createRequest(roster_push)); + expect(converse.env.log.warn.calls.count()).toBe(2); + expect(converse.env.log.warn).toHaveBeenCalledWith( + `Ignoring roster illegitimate roster push message from ${roster_push.getAttribute('from')}` + ); + expect(_converse.roster.models.length).toBe(1); + expect(_converse.roster.at(0).get('jid')).toBe(contact_jid); + })); + + it("is populated once we have registered a presence handler", mock.initConverse([], {}, async function (_converse) { + const IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil( + () => _.filter(IQs, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()); + + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"/>`+ + `</iq>`); + const result = $iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': stanza.getAttribute('id') + }).c('query', { + 'xmlns': 'jabber:iq:roster' + }).c('item', {'jid': 'nurse@example.com'}).up() + .c('item', {'jid': 'romeo@example.com'}) + _converse.connection._dataRecv(mock.createRequest(result)); + await u.waitUntil(() => _converse.promises['rosterContactsFetched'].isResolved === true); + })); + + it("supports roster versioning", mock.initConverse([], {}, async function (_converse) { + const IQ_stanzas = _converse.connection.IQ_stanzas; + let stanza = await u.waitUntil( + () => _.filter(IQ_stanzas, iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop() + ); + expect(_converse.roster.data.get('version')).toBeUndefined(); + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"/>`+ + `</iq>`); + let result = $iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': stanza.getAttribute('id') + }).c('query', { + 'xmlns': 'jabber:iq:roster', + 'ver': 'ver7' + }).c('item', {'jid': 'nurse@example.com'}).up() + .c('item', {'jid': 'romeo@example.com'}) + _converse.connection._dataRecv(mock.createRequest(result)); + + await u.waitUntil(() => _converse.roster.models.length > 1); + expect(_converse.roster.data.get('version')).toBe('ver7'); + expect(_converse.roster.models.length).toBe(2); + + _converse.roster.fetchFromServer(); + stanza = _converse.connection.IQ_stanzas.pop(); + expect(Strophe.serialize(stanza)).toBe( + `<iq id="${stanza.getAttribute('id')}" type="get" xmlns="jabber:client">`+ + `<query ver="ver7" xmlns="jabber:iq:roster"/>`+ + `</iq>`); + + result = $iq({ + 'to': _converse.connection.jid, + 'type': 'result', + 'id': stanza.getAttribute('id') + }); + _converse.connection._dataRecv(mock.createRequest(result)); + + const roster_push = $iq({ + 'to': _converse.connection.jid, + 'type': 'set', + }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'}) + .c('item', {'jid': 'romeo@example.com', 'subscription': 'remove'}); + _converse.connection._dataRecv(mock.createRequest(roster_push)); + expect(_converse.roster.data.get('version')).toBe('ver34'); + expect(_converse.roster.models.length).toBe(1); + expect(_converse.roster.at(0).get('jid')).toBe('nurse@example.com'); + })); + + it("also contains contacts with subscription of none", mock.initConverse( + [], {}, async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()); + _converse.connection._dataRecv(mock.createRequest($iq({ + to: _converse.connection.jid, + type: 'result', + id: stanza.getAttribute('id') + }).c('query', { + xmlns: 'jabber:iq:roster', + }).c('item', { + jid: 'juliet@example.net', + name: 'Juliet', + subscription:'both' + }).c('group').t('Friends').up().up() + .c('item', { + jid: 'mercutio@example.net', + name: 'Mercutio', + subscription: 'from' + }).c('group').t('Friends').up().up() + .c('item', { + jid: 'lord.capulet@example.net', + name: 'Lord Capulet', + subscription:'none' + }).c('group').t('Acquaintences'))); + + while (sent_IQs.length) sent_IQs.pop(); + + await u.waitUntil(() => _converse.roster.length === 3); + expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net', 'lord.capulet@example.net']); + expect(_converse.roster.get('lord.capulet@example.net').get('subscription')).toBe('none'); + })); + + it("can be refreshed", mock.initConverse( + [], {}, async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + let stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()); + _converse.connection._dataRecv(mock.createRequest($iq({ + to: _converse.connection.jid, + type: 'result', + id: stanza.getAttribute('id') + }).c('query', { + xmlns: 'jabber:iq:roster', + }).c('item', { + jid: 'juliet@example.net', + name: 'Juliet', + subscription:'both' + }).c('group').t('Friends').up().up() + .c('item', { + jid: 'mercutio@example.net', + name: 'Mercutio', + subscription:'from' + }).c('group').t('Friends'))); + + while (sent_IQs.length) sent_IQs.pop(); + + await u.waitUntil(() => _converse.roster.length === 2); + expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'mercutio@example.net']); + + const rosterview = document.querySelector('converse-roster'); + const sync_button = rosterview.querySelector('.sync-contacts'); + sync_button.click(); + + stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()); + _converse.connection._dataRecv(mock.createRequest($iq({ + to: _converse.connection.jid, + type: 'result', + id: stanza.getAttribute('id') + }).c('query', { + xmlns: 'jabber:iq:roster', + }).c('item', { + jid: 'juliet@example.net', + name: 'Juliet', + subscription:'both' + }).c('group').t('Friends').up().up() + .c('item', { + jid: 'lord.capulet@example.net', + name: 'Lord Capulet', + subscription:'from' + }).c('group').t('Acquaintences'))); + + await u.waitUntil(() => _converse.roster.pluck('jid').includes('lord.capulet@example.net')); + expect(_converse.roster.pluck('jid')).toEqual(['juliet@example.net', 'lord.capulet@example.net']); + })); + + it("will also show contacts added afterwards", mock.initConverse([], {}, async function (_converse) { + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + + const rosterview = document.querySelector('converse-roster'); + const filter = rosterview.querySelector('.roster-filter'); + const roster = rosterview.querySelector('.roster-contacts'); + + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 800); + filter.value = "la"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 4), 800); + + // Five roster contact is now visible + const visible_contacts = sizzle('li', roster).filter(u.isVisible); + expect(visible_contacts.length).toBe(4); + let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle')); + expect(visible_groups.length).toBe(4); + expect(visible_groups[0].textContent.trim()).toBe('Colleagues'); + expect(visible_groups[1].textContent.trim()).toBe('Family'); + expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences'); + expect(visible_groups[3].textContent.trim()).toBe('ænemies'); + + _converse.roster.create({ + jid: 'lad@montague.lit', + subscription: 'both', + ask: null, + groups: ['newgroup'], + fullname: 'Lad' + }); + await u.waitUntil(() => sizzle('.roster-group[data-group="newgroup"] li', roster).length, 300); + visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle')); + expect(visible_groups.length).toBe(5); + expect(visible_groups[0].textContent.trim()).toBe('Colleagues'); + expect(visible_groups[1].textContent.trim()).toBe('Family'); + expect(visible_groups[2].textContent.trim()).toBe('friends & acquaintences'); + expect(visible_groups[3].textContent.trim()).toBe('newgroup'); + expect(visible_groups[4].textContent.trim()).toBe('ænemies'); + expect(roster.querySelectorAll('.roster-group').length).toBe(5); + })); + + describe("The live filter", function () { + + it("will only appear when roster contacts flow over the visible area", + mock.initConverse([], {}, async function (_converse) { + + expect(document.querySelector('converse-roster')).toBe(null); + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + + const view = _converse.chatboxviews.get('controlbox'); + const flyout = view.querySelector('.box-flyout'); + const panel = flyout.querySelector('.controlbox-pane'); + function hasScrollBar (el) { + return el.isConnected && flyout.offsetHeight < panel.scrollHeight; + } + const rosterview = document.querySelector('converse-roster'); + const filter = rosterview.querySelector('.roster-filter'); + const el = rosterview.querySelector('.roster-contacts'); + await u.waitUntil(() => hasScrollBar(el) ? u.isVisible(filter) : !u.isVisible(filter), 900); + })); + + it("can be used to filter the contacts shown", + mock.initConverse( + [], {'roster_groups': true}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + let filter = rosterview.querySelector('.roster-filter'); + const roster = rosterview.querySelector('.roster-contacts'); + + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600); + expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5); + filter.value = "juliet"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 1), 600); + // Only one roster contact is now visible + let visible_contacts = sizzle('li', roster).filter(u.isVisible); + expect(visible_contacts.length).toBe(1); + expect(visible_contacts.pop().textContent.trim()).toBe('Juliet Capulet'); + // Only one foster group is still visible + expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(1); + const visible_group = sizzle('.roster-group', roster).filter(u.isVisible).pop(); + expect(visible_group.querySelector('a.group-toggle').textContent.trim()).toBe('friends & acquaintences'); + + filter = rosterview.querySelector('.roster-filter'); + filter.value = "j"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 2), 700); + + visible_contacts = sizzle('li', roster).filter(u.isVisible); + expect(visible_contacts.length).toBe(2); + + let visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle')); + expect(visible_groups.length).toBe(2); + expect(visible_groups[0].textContent.trim()).toBe('friends & acquaintences'); + expect(visible_groups[1].textContent.trim()).toBe('Ungrouped'); + + filter = rosterview.querySelector('.roster-filter'); + filter.value = "xxx"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 0), 600); + visible_groups = sizzle('.roster-group', roster).filter(u.isVisible).map(el => el.querySelector('a.group-toggle')); + expect(visible_groups.length).toBe(0); + + filter = rosterview.querySelector('.roster-filter'); + filter.value = ""; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600); + expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5); + })); + + it("can be used to filter the groups shown", mock.initConverse([], {'roster_groups': true}, async function (_converse) { + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + const rosterview = document.querySelector('converse-roster'); + const roster = rosterview.querySelector('.roster-contacts'); + + const button = rosterview.querySelector('converse-icon[data-type="groups"]'); + button.click(); + + await u.waitUntil(() => (sizzle('li', roster).filter(u.isVisible).length === 17), 600); + expect(sizzle('.roster-group', roster).filter(u.isVisible).length).toBe(5); + + let filter = rosterview.querySelector('.roster-filter'); + filter.value = "colleagues"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + + await u.waitUntil(() => (sizzle('div.roster-group:not(.collapsed)', roster).length === 1), 600); + expect(sizzle('div.roster-group:not(.collapsed)', roster).pop().firstElementChild.textContent.trim()).toBe('Colleagues'); + expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(u.isVisible).length).toBe(6); + // Check that all contacts under the group are shown + expect(sizzle('div.roster-group:not(.collapsed) li', roster).filter(l => !u.isVisible(l)).length).toBe(0); + + filter = rosterview.querySelector('.roster-filter'); + filter.value = "xxx"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + + await u.waitUntil(() => (roster.querySelectorAll('.roster-group').length === 0), 700); + + filter = rosterview.querySelector('.roster-filter'); + filter.value = ""; // Check that groups are shown again, when the filter string is cleared. + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + await u.waitUntil(() => (roster.querySelectorAll('div.roster-group.collapsed').length === 0), 700); + expect(sizzle('div.roster-group', roster).length).toBe(0); + })); + + it("has a button with which its contents can be cleared", + mock.initConverse([], {'roster_groups': true}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + + const rosterview = document.querySelector('converse-roster'); + const filter = rosterview.querySelector('.roster-filter'); + filter.value = "xxx"; + u.triggerEvent(filter, "keydown", "KeyboardEvent"); + expect(_.includes(filter.classList, "x")).toBeFalsy(); + expect(u.hasClass('hidden', rosterview.querySelector('.roster-filter-form .clear-input'))).toBeTruthy(); + + const isHidden = (el) => u.hasClass('hidden', el); + await u.waitUntil(() => !isHidden(rosterview.querySelector('.roster-filter-form .clear-input')), 900); + rosterview.querySelector('.clear-input').click(); + await u.waitUntil(() => document.querySelector('.roster-filter').value == ''); + })); + + // Disabling for now, because since recently this test consistently + // fails on Travis and I couldn't get it to pass there. + xit("can be used to filter contacts by their chat state", + mock.initConverse( + [], {}, + async function (_converse) { + + mock.waitForRoster(_converse, 'all'); + let jid = mock.cur_names[3].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'online'); + jid = mock.cur_names[4].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'dnd'); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + const button = rosterview.querySelector('span[data-type="state"]'); + button.click(); + const roster = rosterview.querySelector('.roster-contacts'); + await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 15, 900); + const filter = rosterview.querySelector('.state-type'); + expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(5); + filter.value = "online"; + u.triggerEvent(filter, 'change'); + + await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).length === 1, 900); + expect(sizzle('li', roster).filter(u.isVisible).pop().textContent.trim()).toBe('Lord Montague'); + await u.waitUntil(() => sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length === 1, 900); + const ul = sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).pop(); + expect(ul.parentElement.firstElementChild.textContent.trim()).toBe('friends & acquaintences'); + filter.value = "dnd"; + u.triggerEvent(filter, 'change'); + await u.waitUntil(() => sizzle('li', roster).filter(u.isVisible).pop().textContent.trim() === 'Friar Laurence', 900); + expect(sizzle('ul.roster-group-contacts', roster).filter(u.isVisible).length).toBe(1); + })); + }); + + describe("A Roster Group", function () { + + it("is created to show contacts with unread messages", + mock.initConverse( + [], {'roster_groups': true}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'all'); + await mock.createContacts(_converse, 'requesting'); + + // Check that the groups appear alphabetically and that + // requesting and pending contacts are last. + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6); + let group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim()); + expect(group_titles).toEqual([ + "Contact requests", + "Colleagues", + "Family", + "friends & acquaintences", + "ænemies", + "Ungrouped", + ]); + + const contact_jid = mock.cur_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact = await _converse.api.contacts.get(contact_jid); + contact.save({'num_unread': 5}); + + await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 7); + group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim()); + + expect(group_titles).toEqual([ + "New messages", + "Contact requests", + "Colleagues", + "Family", + "friends & acquaintences", + "ænemies", + "Ungrouped" + ]); + const contacts = sizzle('.roster-group[data-group="New messages"] li', rosterview); + expect(contacts.length).toBe(1); + expect(contacts[0].querySelector('.contact-name').textContent).toBe("Mercutio"); + expect(contacts[0].querySelector('.msgs-indicator').textContent).toBe("5"); + + contact.save({'num_unread': 0}); + await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6); + group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim()); + expect(group_titles).toEqual([ + "Contact requests", + "Colleagues", + "Family", + "friends & acquaintences", + "ænemies", + "Ungrouped" + ]); + })); + + + it("can be used to organize existing contacts", + mock.initConverse( + [], {'roster_groups': true}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'all'); + await mock.createContacts(_converse, 'requesting'); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 6); + const group_titles = sizzle('.roster-group a.group-toggle', rosterview).map(o => o.textContent.trim()); + expect(group_titles).toEqual([ + "Contact requests", + "Colleagues", + "Family", + "friends & acquaintences", + "ænemies", + "Ungrouped", + ]); + // Check that usernames appear alphabetically per group + Object.keys(mock.groups).forEach(name => { + const contacts = sizzle('.roster-group[data-group="'+name+'"] ul', rosterview); + const names = contacts.map(o => o.textContent.trim()); + expect(names).toEqual(_.clone(names).sort()); + }); + })); + + it("gets created when a contact's \"groups\" attribute changes", + mock.initConverse([], {'roster_groups': true}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + + _converse.roster.create({ + jid: 'groupchanger@montague.lit', + subscription: 'both', + ask: null, + groups: ['firstgroup'], + fullname: 'George Groupchanger' + }); + + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group a.group-toggle', rosterview).length === 1); + let group_titles = await u.waitUntil(() => { + const toggles = sizzle('.roster-group a.group-toggle', rosterview); + if (toggles.reduce((result, t) => result && u.isVisible(t), true)) { + return toggles.map(o => o.textContent.trim()); + } else { + return false; + } + }, 1000); + expect(group_titles).toEqual(['firstgroup']); + + const contact = _converse.roster.get('groupchanger@montague.lit'); + contact.set({'groups': ['secondgroup']}); + await u.waitUntil(() => sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview).length); + group_titles = await u.waitUntil(() => { + const toggles = sizzle('.roster-group[data-group="secondgroup"] a.group-toggle', rosterview); + if (toggles.reduce((result, t) => result && u.isVisible(t), true)) { + return toggles.map(o => o.textContent.trim()); + } else { + return false; + } + }, 1000); + expect(group_titles).toEqual(['secondgroup']); + })); + + it("can share contacts with other roster groups", + mock.initConverse( [], {'roster_groups': true}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + const groups = ['Colleagues', 'friends']; + await mock.openControlBox(_converse); + for (let i=0; i<mock.cur_names.length; i++) { + _converse.roster.create({ + jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'both', + ask: null, + groups: groups, + fullname: mock.cur_names[i] + }); + } + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => (sizzle('li', rosterview).filter(u.isVisible).length === 30)); + // Check that usernames appear alphabetically per group + groups.forEach(name => { + const contacts = sizzle('.roster-group[data-group="'+name+'"] ul li', rosterview); + const names = contacts.map(o => o.textContent.trim()); + expect(names).toEqual(_.clone(names).sort()); + expect(names.length).toEqual(mock.cur_names.length); + }); + })); + + it("remembers whether it is closed or opened", + mock.initConverse([], {}, async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.openControlBox(_converse); + + let i=0, j=0; + const groups = { + 'Colleagues': 3, + 'friends & acquaintences': 3, + 'Ungrouped': 2 + }; + Object.keys(groups).forEach(function (name) { + j = i; + for (i=j; i<j+groups[name]; i++) { + _converse.roster.create({ + jid: mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'both', + ask: null, + groups: name === 'ungrouped'? [] : [name], + fullname: mock.cur_names[i] + }); + } + }); + + const state = _converse.roster.state; + expect(state.get('collapsed_groups')).toEqual([]); + const rosterview = document.querySelector('converse-roster'); + const toggle = await u.waitUntil(() => rosterview.querySelector('a.group-toggle')); + toggle.click(); + await u.waitUntil(() => state.get('collapsed_groups').length); + expect(state.get('collapsed_groups')).toEqual(['Colleagues']); + toggle.click(); + expect(state.get('collapsed_groups')).toEqual([]); + })); + }); + + describe("Pending Contacts", function () { + + it("can be collapsed under their own header (if roster_groups is false)", + mock.initConverse([], {'roster_groups': false}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'all'); + await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname')))); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000); + await checkHeaderToggling.apply(_converse, [rosterview.querySelector('[data-group="Pending contacts"]')]); + })); + + it("can be added to the roster", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'all', 0); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + _converse.roster.create({ + jid: mock.pend_names[0].replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'none', + ask: 'subscribe', + fullname: mock.pend_names[0] + }); + expect(u.isVisible(rosterview)).toBe(true); + await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length === 1); + })); + + it("are shown in the roster when hide_offline_users", + mock.initConverse( + [], {'hide_offline_users': true}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'pending'); + await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname')))); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500) + expect(u.isVisible(rosterview)).toBe(true); + expect(sizzle('li', rosterview).filter(u.isVisible).length).toBe(3); + expect(sizzle('ul.roster-group-contacts', rosterview).filter(u.isVisible).length).toBe(1); + })); + + it("can be removed by the user", mock.initConverse([], {'roster_groups': false}, async function (_converse) { + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'all'); + await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname')))); + const name = mock.pend_names[0]; + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact = _converse.roster.get(jid); + spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true)); + spyOn(contact, 'unauthorize').and.callFake(function () { return contact; }); + spyOn(contact, 'removeFromRoster').and.callThrough(); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500); + let sent_IQ; + spyOn(_converse.connection, 'sendIQ').and.callFake(function (iq, callback) { + sent_IQ = iq; + callback(); + }); + sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click(); + await u.waitUntil(() => !sizzle(`.pending-xmpp-contact .contact-name:contains("${name}")`, rosterview).length, 500); + expect(_converse.api.confirm).toHaveBeenCalled(); + expect(contact.removeFromRoster).toHaveBeenCalled(); + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster">`+ + `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+ + `</query>`+ + `</iq>`); + })); + + it("do not have a header if there aren't any", + mock.initConverse( + ['VCardsInitialized'], {'roster_groups': false}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const name = mock.pend_names[0]; + _converse.roster.create({ + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'none', + ask: 'subscribe', + fullname: name + }); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => { + const el = rosterview.querySelector(`ul[data-group="Pending contacts"]`); + return u.isVisible(el) && Array.from(el.querySelectorAll('li')).filter(li => u.isVisible(li)).length; + }, 700) + + const remove_el = await u.waitUntil(() => sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop()); + spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true)); + remove_el.click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + + const iq_stanzas = _converse.connection.IQ_stanzas; + await u.waitUntil(() => Strophe.serialize(iq_stanzas.at(-1)) === + `<iq id="${iq_stanzas.at(-1).getAttribute('id')}" type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster">`+ + `<item jid="lord.capulet@montague.lit" subscription="remove"/>`+ + `</query>`+ + `</iq>`); + + const iq = iq_stanzas.at(-1); + const stanza = u.toStanza(`<iq id="${iq.getAttribute('id')}" to="romeo@montague.lit/orchard" type="result"/>`); + _converse.connection._dataRecv(mock.createRequest(stanza)); + await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null); + })); + + it("can be removed by the user", + mock.initConverse([], {'roster_groups': false}, async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'all'); + await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname')))); + await u.waitUntil(() => _converse.roster.at(0).vcard.get('fullname')) + const rosterview = document.querySelector('converse-roster'); + spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true)); + for (let i=0; i<mock.pend_names.length; i++) { + const name = mock.pend_names[i]; + sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click(); + } + await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`) === null); + })); + + it("can be added to the roster and they will be sorted alphabetically", + mock.initConverse( + [], {'roster_groups': false}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current'); + await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname')))); + let i; + for (i=0; i<mock.pend_names.length; i++) { + _converse.roster.create({ + jid: mock.pend_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'none', + ask: 'subscribe', + fullname: mock.pend_names[i] + }); + } + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('li', rosterview.querySelector(`ul[data-group="Pending contacts"]`)).filter(u.isVisible).length); + // Check that they are sorted alphabetically + const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Pending contacts"]`)); + const spans = el.querySelectorAll('.pending-xmpp-contact span'); + + await u.waitUntil( + () => Array.from(spans).reduce((result, value) => result + value.textContent?.trim(), '') === + mock.pend_names.slice(0,i+1).sort().join('') + ); + expect(true).toBe(true); + })); + }); + + describe("Existing Contacts", function () { + async function _addContacts (_converse) { + await mock.waitForRoster(_converse, 'current'); + await mock.openControlBox(_converse); + await Promise.all(_converse.roster.map(contact => u.waitUntil(() => contact.vcard.get('fullname')))); + } + + it("can be collapsed under their own header", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500); + await checkHeaderToggling.apply(_converse, [rosterview.querySelector('.roster-group')]); + })); + + it("will be hidden when appearing under a collapsed group", + mock.initConverse( + [], {'roster_groups': false}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('li', rosterview).filter(u.isVisible).length, 500); + rosterview.querySelector('.group-toggle').click(); + const name = "Romeo Montague"; + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.create({ + ask: null, + fullname: name, + jid: jid, + requesting: false, + subscription: 'both' + }); + await u.waitUntil(() => u.hasClass('collapsed', rosterview.querySelector(`ul[data-group="My contacts"]`)) === true); + expect(true).toBe(true); + })); + + it("will have their online statuses shown correctly", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 1); + await mock.openControlBox(_converse); + const icon_el = document.querySelector('converse-roster-contact converse-icon'); + expect(icon_el.getAttribute('color')).toBe('var(--subdued-color)'); + + let pres = $pres({from: 'mercutio@montague.lit/resource'}); + _converse.connection._dataRecv(mock.createRequest(pres)); + await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-online)'); + + pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'away'); + _converse.connection._dataRecv(mock.createRequest(pres)); + await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-away)'); + + pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'xa'); + _converse.connection._dataRecv(mock.createRequest(pres)); + await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)'); + + pres = $pres({from: 'mercutio@montague.lit/resource'}).c('show', 'dnd'); + _converse.connection._dataRecv(mock.createRequest(pres)); + await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--chat-status-busy)'); + + pres = $pres({from: 'mercutio@montague.lit/resource', type: 'unavailable'}); + _converse.connection._dataRecv(mock.createRequest(pres)); + await u.waitUntil(() => icon_el.getAttribute('color') === 'var(--subdued-color)'); + })); + + it("can be added to the roster and they will be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await Promise.all(mock.cur_names.map(name => { + const contact = _converse.roster.create({ + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'both', + ask: null, + fullname: name + }); + return u.waitUntil(() => contact.initialized); + })); + await u.waitUntil(() => sizzle('li', rosterview).length); + // Check that they are sorted alphabetically + const els = sizzle('.current-xmpp-contact.offline a.open-chat', rosterview) + const t = els.reduce((result, value) => (result + value.textContent.trim()), ''); + expect(t).toEqual(mock.cur_names.slice(0,mock.cur_names.length).sort().join('')); + })); + + it("can be removed by the user", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('li').length); + const name = mock.cur_names[0]; + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact = _converse.roster.get(jid); + spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true)); + spyOn(contact, 'removeFromRoster').and.callThrough(); + + let sent_IQ; + spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => { + sent_IQ = iq; + callback(); + }); + sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + await u.waitUntil(() => sent_IQ); + + expect(Strophe.serialize(sent_IQ)).toBe( + `<iq type="set" xmlns="jabber:client">`+ + `<query xmlns="jabber:iq:roster"><item jid="mercutio@montague.lit" subscription="remove"/></query>`+ + `</iq>`); + expect(contact.removeFromRoster).toHaveBeenCalled(); + await u.waitUntil(() => sizzle(".open-chat:contains('"+name+"')", rosterview).length === 0); + })); + + it("do not have a header if there aren't any", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + const name = mock.cur_names[0]; + const contact = _converse.roster.create({ + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'both', + ask: null, + fullname: name + }); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length, 1000); + spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true)); + spyOn(contact, 'removeFromRoster').and.callThrough(); + spyOn(_converse.connection, 'sendIQ').and.callFake((iq, callback) => callback?.()); + expect(u.isVisible(rosterview.querySelector('.roster-group'))).toBe(true); + sizzle(`.remove-xmpp-contact[title="Click to remove ${name} as a contact"]`, rosterview).pop().click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + await u.waitUntil(() => _converse.connection.sendIQ.calls.count()); + expect(contact.removeFromRoster).toHaveBeenCalled(); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group').length === 0); + })); + + it("can change their status to online and be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length, 700); + const roster = rosterview; + const groups = roster.querySelectorAll('.roster-group'); + const groupnames = Array.from(groups).map(g => g.getAttribute('data-group')); + expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped"); + for (let i=0; i<mock.cur_names.length; i++) { + const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'online'); + // Check that they are sorted alphabetically + for (let j=0; j<groups.length; j++) { + const group = groups[j]; + const groupname = groupnames[j]; + const els = [...group.querySelectorAll('.current-xmpp-contact.online a.open-chat')]; + const t = els.reduce((result, value) => result + value.textContent?.trim(), ''); + expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join('')); + } + } + })); + + it("can change their status to busy and be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700); + const roster = rosterview; + const groups = roster.querySelectorAll('.roster-group'); + const groupnames = Array.from(groups).map(g => g.getAttribute('data-group')); + expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped"); + for (let i=0; i<mock.cur_names.length; i++) { + const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'dnd'); + // Check that they are sorted alphabetically + for (let j=0; j<groups.length; j++) { + const group = groups[j]; + const groupname = groupnames[j]; + const els = [...group.querySelectorAll('.current-xmpp-contact.dnd a.open-chat')]; + const t = els.reduce((result, value) => result + value.textContent.trim(), ''); + expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join('')); + } + } + })); + + it("can change their status to away and be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700); + const roster = rosterview; + const groups = roster.querySelectorAll('.roster-group'); + const groupnames = Array.from(groups).map(g => g.getAttribute('data-group')); + expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped"); + for (let i=0; i<mock.cur_names.length; i++) { + const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'away'); + // Check that they are sorted alphabetically + for (let j=0; j<groups.length; j++) { + const group = groups[j]; + const groupname = groupnames[j]; + const els = [...group.querySelectorAll('.current-xmpp-contact.away a.open-chat')]; + const t = els.reduce((result, value) => result + value.textContent.trim(), ''); + expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join('')); + } + } + })); + + it("can change their status to xa and be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700); + const roster = rosterview; + const groups = roster.querySelectorAll('.roster-group'); + const groupnames = Array.from(groups).map(g => g.getAttribute('data-group')); + expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped"); + for (let i=0; i<mock.cur_names.length; i++) { + const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'xa'); + // Check that they are sorted alphabetically + for (let j=0; j<groups.length; j++) { + const group = groups[j]; + const groupname = groupnames[j]; + const els = [...group.querySelectorAll('.current-xmpp-contact.xa a.open-chat')]; + const t = els.reduce((result, value) => result + value.textContenc?.trim(), ''); + expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join('')); + } + } + })); + + it("can change their status to unavailable and be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 500) + const roster = rosterview; + const groups = roster.querySelectorAll('.roster-group'); + const groupnames = Array.from(groups).map(g => g.getAttribute('data-group')); + expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped"); + for (let i=0; i<mock.cur_names.length; i++) { + const jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'unavailable'); + // Check that they are sorted alphabetically + for (let j=0; j<groups.length; j++) { + const group = groups[j]; + const groupname = groupnames[j]; + const els = [...group.querySelectorAll('.current-xmpp-contact.unavailable a.open-chat')]; + const t = els.reduce((result, value) => result + value.textContent.trim(), ''); + expect(t).toEqual(mock.groups_map[groupname].slice(0, els.length).sort().join('')); + } + } + })); + + it("are ordered according to status: online, busy, away, xa, unavailable, offline", + mock.initConverse( + [], {}, + async function (_converse) { + + await _addContacts(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700); + let i, jid; + for (i=0; i<3; i++) { + jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'online'); + } + for (i=3; i<6; i++) { + jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'dnd'); + } + for (i=6; i<9; i++) { + jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'away'); + } + for (i=9; i<12; i++) { + jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'xa'); + } + for (i=12; i<15; i++) { + jid = mock.cur_names[i].replace(/ /g,'.').toLowerCase() + '@montague.lit'; + _converse.roster.get(jid).presence.set('show', 'unavailable'); + } + + await u.waitUntil(() => u.isVisible(rosterview.querySelector('li:first-child')), 900); + const roster = rosterview; + const groups = roster.querySelectorAll('.roster-group'); + const groupnames = Array.from(groups).map(g => g.getAttribute('data-group')); + expect(groupnames.join(' ')).toBe("Colleagues Family friends & acquaintences ænemies Ungrouped"); + + const group = groups[0]; + const els = Array.from(group.querySelectorAll('.current-xmpp-contact')); + await u.waitUntil(() => els.map(e => e.getAttribute('data-status')).join(" ") === "online online away xa xa xa"); + + for (let j=0; j<groups.length; j++) { + const group = groups[j]; + const groupname = groupnames[j]; + const els = Array.from(group.querySelectorAll('.current-xmpp-contact')); + expect(els.length).toBe(mock.groups_map[groupname].length); + + if (groupname === "Colleagues") { + + const statuses = els.map(e => e.getAttribute('data-status')); + const subscription_classes = els.map(e => e.classList[4]); + const status_classes = els.map(e => e.classList[5]); + expect(statuses.join(" ")).toBe("online online away xa xa xa"); + expect(status_classes.join(" ")).toBe("online online away xa xa xa"); + expect(subscription_classes.join(" ")).toBe("both both both both both both"); + } else if (groupname === "friends & acquaintences") { + const statuses = els.map(e => e.getAttribute('data-status')); + const subscription_classes = els.map(e => e.classList[4]); + const status_classes = els.map(e => e.classList[5]); + expect(statuses.join(" ")).toBe("online online dnd dnd away unavailable"); + expect(status_classes.join(" ")).toBe("online online dnd dnd away unavailable"); + expect(subscription_classes.join(" ")).toBe("both both both both both both"); + } else if (groupname === "Family") { + const statuses = els.map(e => e.getAttribute('data-status')); + const subscription_classes = els.map(e => e.classList[4]); + const status_classes = els.map(e => e.classList[5]); + expect(statuses.join(" ")).toBe("online dnd"); + expect(status_classes.join(" ")).toBe("online dnd"); + expect(subscription_classes.join(" ")).toBe("both both"); + } else if (groupname === "ænemies") { + const statuses = els.map(e => e.getAttribute('data-status')); + const subscription_classes = els.map(e => e.classList[4]); + const status_classes = els.map(e => e.classList[5]); + expect(statuses.join(" ")).toBe("away"); + expect(status_classes.join(" ")).toBe("away"); + expect(subscription_classes.join(" ")).toBe("both"); + } else if (groupname === "Ungrouped") { + const statuses = els.map(e => e.getAttribute('data-status')); + const subscription_classes = els.map(e => e.classList[4]); + const status_classes = els.map(e => e.classList[5]); + expect(statuses.join(" ")).toBe("unavailable unavailable"); + expect(status_classes.join(" ")).toBe("unavailable unavailable"); + expect(subscription_classes.join(" ")).toBe("both both"); + } + } + })); + }); + + describe("Requesting Contacts", function () { + + it("can be added to the roster and they will be sorted alphabetically", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, "current", 0); + await mock.openControlBox(_converse); + let names = []; + const addName = function (item) { + if (!u.hasClass('request-actions', item)) { + names.push(item.textContent.replace(/^\s+|\s+$/g, '')); + } + }; + const rosterview = document.querySelector('converse-roster'); + await Promise.all(mock.req_names.map(name => { + const contact = _converse.roster.create({ + jid: name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + subscription: 'none', + ask: null, + requesting: true, + nickname: name + }); + return u.waitUntil(() => contact.initialized); + })); + await u.waitUntil(() => rosterview.querySelectorAll(`ul[data-group="Contact requests"] li`).length, 700); + // Check that they are sorted alphabetically + const children = rosterview.querySelector(`ul[data-group="Contact requests"]`).querySelectorAll('.requesting-xmpp-contact span'); + names = []; + Array.from(children).forEach(addName); + expect(names.join('')).toEqual(mock.req_names.slice(0,mock.req_names.length+1).sort().join('')); + })); + + it("do not have a header if there aren't any", mock.initConverse([], {}, async function (_converse) { + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, "current", 0); + const name = mock.req_names[0]; + spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true)); + _converse.roster.create({ + 'jid': name.replace(/ /g,'.').toLowerCase() + '@montague.lit', + 'subscription': 'none', + 'ask': null, + 'requesting': true, + 'nickname': name + }); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 900); + expect(u.isVisible(rosterview.querySelector(`ul[data-group="Contact requests"]`))).toEqual(true); + expect(sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li')).length).toBe(1); + sizzle('.roster-group', rosterview).filter(u.isVisible).map(e => e.querySelector('li .decline-xmpp-request'))[0].click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`) === null); + })); + + it("can be collapsed under their own header", mock.initConverse([], {}, async function (_converse) { + await mock.waitForRoster(_converse, 'current', 0); + mock.createContacts(_converse, 'requesting'); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group', rosterview).filter(u.isVisible).length, 700); + const el = await u.waitUntil(() => rosterview.querySelector(`ul[data-group="Contact requests"]`)); + await checkHeaderToggling.apply(_converse, [el.parentElement]); + })); + + it("can have their requests accepted by the user", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.openControlBox(_converse); + await mock.waitForRoster(_converse, 'current', 0); + await mock.createContacts(_converse, 'requesting'); + const name = mock.req_names.sort()[0]; + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact = _converse.roster.get(jid); + spyOn(contact, 'authorize').and.callFake(() => contact); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => rosterview.querySelectorAll('.roster-group li').length) + // TODO: Testing can be more thorough here, the user is + // actually not accepted/authorized because of + // mock_connection. + spyOn(_converse.roster, 'sendContactAddIQ').and.callFake(() => Promise.resolve()); + const req_contact = sizzle(`.req-contact-name:contains("${contact.getDisplayName()}")`, rosterview).pop(); + req_contact.parentElement.parentElement.querySelector('.accept-xmpp-request').click(); + expect(_converse.roster.sendContactAddIQ).toHaveBeenCalled(); + await u.waitUntil(() => contact.authorize.calls.count()); + expect(contact.authorize).toHaveBeenCalled(); + })); + + it("can have their requests denied by the user", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.createContacts(_converse, 'requesting'); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700); + const name = mock.req_names.sort()[1]; + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const contact = _converse.roster.get(jid); + spyOn(_converse.api, 'confirm').and.returnValue(Promise.resolve(true)); + spyOn(contact, 'unauthorize').and.callFake(function () { return contact; }); + const req_contact = await u.waitUntil(() => sizzle(".req-contact-name:contains('"+name+"')", rosterview).pop()); + req_contact.parentElement.parentElement.querySelector('.decline-xmpp-request').click(); + expect(_converse.api.confirm).toHaveBeenCalled(); + await u.waitUntil(() => contact.unauthorize.calls.count()); + // There should now be one less contact + expect(_converse.roster.length).toEqual(mock.req_names.length-1); + })); + + it("are persisted even if other contacts' change their presence ", mock.initConverse( + [], {}, async function (_converse) { + + const sent_IQs = _converse.connection.IQ_stanzas; + const stanza = await u.waitUntil(() => sent_IQs.filter(iq => iq.querySelector('iq query[xmlns="jabber:iq:roster"]')).pop()); + // Taken from the spec + // https://xmpp.org/rfcs/rfc3921.html#rfc.section.7.3 + const result = $iq({ + to: _converse.connection.jid, + type: 'result', + id: stanza.getAttribute('id') + }).c('query', { + xmlns: 'jabber:iq:roster', + }).c('item', { + jid: 'juliet@example.net', + name: 'Juliet', + subscription:'both' + }).c('group').t('Friends').up().up() + .c('item', { + jid: 'mercutio@example.org', + name: 'Mercutio', + subscription:'from' + }).c('group').t('Friends').up().up() + _converse.connection._dataRecv(mock.createRequest(result)); + + const pres = $pres({from: 'data@enterprise/resource', type: 'subscribe'}); + _converse.connection._dataRecv(mock.createRequest(pres)); + + expect(_converse.roster.pluck('jid').length).toBe(1); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('a:contains("Contact requests")', rosterview).length, 700); + expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy(); + + const roster_push = $iq({ + 'to': _converse.connection.jid, + 'type': 'set', + }).c('query', {'xmlns': 'jabber:iq:roster', 'ver': 'ver34'}) + .c('item', { + jid: 'benvolio@example.org', + name: 'Benvolio', + subscription:'both' + }).c('group').t('Friends'); + _converse.connection._dataRecv(mock.createRequest(roster_push)); + expect(_converse.roster.data.get('version')).toBe('ver34'); + expect(_converse.roster.models.length).toBe(4); + expect(_converse.roster.pluck('jid').includes('data@enterprise')).toBeTruthy(); + })); + }); + + describe("All Contacts", function () { + + it("are saved to, and can be retrieved from browserStorage", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 0); + await mock.createContacts(_converse, 'requesting'); + await mock.openControlBox(_converse); + var new_attrs, old_attrs, attrs; + var num_contacts = _converse.roster.length; + var new_roster = new _converse.RosterContacts(); + // Roster items are yet to be fetched from browserStorage + expect(new_roster.length).toEqual(0); + new_roster.browserStorage = _converse.roster.browserStorage; + await new Promise(success => new_roster.fetch({success})); + expect(new_roster.length).toEqual(num_contacts); + // Check that the roster items retrieved from browserStorage + // have the same attributes values as the original ones. + attrs = ['jid', 'fullname', 'subscription', 'ask']; + for (var i=0; i<attrs.length; i++) { + new_attrs = new_roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line + old_attrs = _converse.roster.models.map(m => m.attributes[attrs[i]]); // eslint-disable-line + // Roster items in storage are not necessarily sorted, + // so we have to sort them here to do a proper + // comparison + expect(new_attrs.sort()).toEqual(old_attrs.sort()); + } + })); + + it("will show fullname and jid properties on tooltip", + mock.initConverse( + [], {}, + async function (_converse) { + + await mock.waitForRoster(_converse, 'current', 'all'); + await mock.createContacts(_converse, 'requesting'); + await mock.openControlBox(_converse); + const rosterview = document.querySelector('converse-roster'); + await u.waitUntil(() => sizzle('.roster-group li', rosterview).length, 700); + await Promise.all(mock.cur_names.map(async name => { + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop()); + const child = el.firstElementChild.firstElementChild; + expect(child.textContent.trim()).toBe(name); + expect(child.getAttribute('title')).toContain(name); + expect(child.getAttribute('title')).toContain(jid); + })); + await Promise.all(mock.req_names.map(async name => { + const jid = name.replace(/ /g,'.').toLowerCase() + '@montague.lit'; + const el = await u.waitUntil(() => sizzle("li:contains('"+name+"')", rosterview).pop()); + const child = el.firstElementChild.firstElementChild; + expect(child.textContent.trim()).toBe(name); + expect(child.firstElementChild.getAttribute('title')).toContain(jid); + })); + })); + }); +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js new file mode 100644 index 0000000..8b5c694 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/rosterview/utils.js @@ -0,0 +1,114 @@ +import log from "@converse/headless/log"; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; + +export function removeContact (contact) { + contact.removeFromRoster( + () => contact.destroy(), + (e) => { + e && log.error(e); + api.alert('error', __('Error'), [ + __('Sorry, there was an error while trying to remove %1$s as a contact.', + contact.getDisplayName()) + ]); + } + ); +} + +export function highlightRosterItem (chatbox) { + _converse.roster?.get(chatbox.get('jid'))?.trigger('highlight'); +} + +export function toggleGroup (ev, name) { + ev?.preventDefault?.(); + const collapsed = _converse.roster.state.get('collapsed_groups'); + if (collapsed.includes(name)) { + _converse.roster.state.save('collapsed_groups', collapsed.filter(n => n !== name)); + } else { + _converse.roster.state.save('collapsed_groups', [...collapsed, name]); + } +} + +export function isContactFiltered (contact, groupname) { + const filter = _converse.roster_filter; + const type = filter.get('filter_type'); + const q = (type === 'state') ? + filter.get('chat_state').toLowerCase() : + filter.get('filter_text').toLowerCase(); + + if (!q) return false; + + if (type === 'state') { + const sticky_groups = [_converse.HEADER_REQUESTING_CONTACTS, _converse.HEADER_UNREAD]; + if (sticky_groups.includes(groupname)) { + // When filtering by chat state, we still want to + // show sticky groups, even though they don't + // match the state in question. + return false; + } else if (q === 'unread_messages') { + return contact.get('num_unread') === 0; + } else if (q === 'online') { + return ["offline", "unavailable"].includes(contact.presence.get('show')); + } else { + return !contact.presence.get('show').includes(q); + } + } else if (type === 'contacts') { + return !contact.getFilterCriteria().includes(q); + } +} + +export function shouldShowContact (contact, groupname) { + const chat_status = contact.presence.get('show'); + if (api.settings.get('hide_offline_users') && chat_status === 'offline') { + // If pending or requesting, show + if ((contact.get('ask') === 'subscribe') || + (contact.get('subscription') === 'from') || + (contact.get('requesting') === true)) { + return !isContactFiltered(contact, groupname); + } + return false; + } + return !isContactFiltered(contact, groupname); +} + +export function shouldShowGroup (group) { + const filter = _converse.roster_filter; + const type = filter.get('filter_type'); + if (type === 'groups') { + const q = filter.get('filter_text')?.toLowerCase(); + if (!q) { + return true; + } + if (!group.toLowerCase().includes(q)) { + return false; + } + } + return true; +} + +export function populateContactsMap (contacts_map, contact) { + if (contact.get('requesting')) { + const name = _converse.HEADER_REQUESTING_CONTACTS; + contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]); + } else { + let contact_groups; + if (api.settings.get('roster_groups')) { + contact_groups = contact.get('groups'); + contact_groups = (contact_groups.length === 0) ? [_converse.HEADER_UNGROUPED] : contact_groups; + } else { + if (contact.get('ask') === 'subscribe') { + contact_groups = [_converse.HEADER_PENDING_CONTACTS]; + } else { + contact_groups = [_converse.HEADER_CURRENT_CONTACTS]; + } + } + for (const name of contact_groups) { + contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]); + } + } + if (contact.get('num_unread')) { + const name = _converse.HEADER_UNREAD; + contacts_map[name] ? contacts_map[name].push(contact) : (contacts_map[name] = [contact]); + } + return contacts_map; +} diff --git a/roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js b/roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js new file mode 100644 index 0000000..e9ad110 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/singleton/index.js @@ -0,0 +1,41 @@ +/** + * @copyright JC Brand + * @license Mozilla Public License (MPLv2) + * @description A plugin which restricts Converse to only one chat. + */ +import { api, converse } from "@converse/headless/core"; + +import './singleton.scss'; + + +converse.plugins.add('converse-singleton', { + + enabled (_converse) { + return _converse.api.settings.get("singleton"); + }, + + initialize () { + api.settings.extend({ + 'allow_logout': false, // No point in logging out when we have auto_login as true. + 'allow_muc_invitations': false, // Doesn't make sense to allow because only + // roster contacts can be invited + 'hide_muc_server': true + }); + + const auto_join_rooms = api.settings.get('auto_join_rooms'); + const auto_join_private_chats = api.settings.get('auto_join_private_chats'); + + if (!Array.isArray(auto_join_rooms) && !Array.isArray(auto_join_private_chats)) { + throw new Error("converse-singleton: auto_join_rooms must be an Array"); + } + if (auto_join_rooms.length === 0 && auto_join_private_chats.length === 0) { + throw new Error("If you set singleton set to true, you need "+ + "to specify auto_join_rooms or auto_join_private_chats"); + } + if (auto_join_rooms.length > 0 && auto_join_private_chats.length > 0) { + throw new Error("It doesn't make sense to have singleton set to true and " + + "auto_join_rooms or auto_join_private_chats set to more then one, " + + "since only one chat room may be open at any time."); + } + } +}); diff --git a/roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss b/roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss new file mode 100644 index 0000000..a0ae8d8 --- /dev/null +++ b/roles/reverseproxy/files/conversejs/src/plugins/singleton/singleton.scss @@ -0,0 +1,49 @@ +@import "bootstrap/scss/functions"; +@import "bootstrap/scss/variables"; +@import "bootstrap/scss/mixins"; +@import "bootstrap/scss/media"; +@import "shared/styles/_variables.scss"; +@import "shared/styles/_mixins.scss"; + + +.conversejs { + converse-chats.converse-embedded, + converse-chats.converse-fullscreen { + &.converse-singleton { + .flyout { + border: none !important; + } + .chat-head { + padding: 0.5em; + } + .chatbox { + margin: 0; + position: relative; + margin-left: -15px; + @media screen and (max-width: $mobile-portrait-length) { + margin-left: 0; + } + @include media-breakpoint-down(sm) { + margin-left: 0; + } + } + } + } + + converse-chats.converse-fullscreen { + &.converse-singleton { + .chatbox { + @include make-col-ready(); + @include media-breakpoint-up(md) { + @include make-col(12); + } + @include media-breakpoint-up(lg) { + @include make-col(12); + } + @include media-breakpoint-up(xl) { + @include make-col(12); + } + } + } + } +} |