Commit b4dafcc4 authored by JC Brand's avatar JC Brand

Add support for XEP-0424 and XEP-0425

- Add support for switching ephemerality after message creation
- Move more methods from ChatBox and ChatRoom to utils/stanza.js
- Rename 'ephemeral' to 'is_ephemeral' since it's a boolean
parent 4b3d427c
......@@ -2,10 +2,9 @@
## 6.0.0 (Unreleased)
- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now.
- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage
- #1691: Fix `collection.chatbox is undefined` errors
- #1772: `_converse.api.contact.add(jid, nick)` fails, says not a function
- Add support for [XEP-0424 Message Retraction](http://localhost:3080/extensions/xep-0424.html)
- Add support for [XEP-0425 Message Moderation](http://localhost:3080/extensions/xep-0425.html)
- Prevent editing of sent file uploads.
- Initial support for sending custom emojis. Currently only between Converse
instances. Still working out a wire protocol for compatibility with other clients.
To add custom emojis, edit the `emojis.json` file.
......@@ -14,6 +13,13 @@
- New config option [muc_mention_autocomplete_filter](https://conversejs.org/docs/html/configuration.html#muc_mention_autocomplete_filter)
- New config option [muc_mention_autocomplete_show_avatar](https://conversejs.org/docs/html/configuration.html#muc_mention_autocomplete_show_avatar)
- #129: Add support for XEP-0156: Disovering Alternative XMPP Connection Methods. Only XML is supported for now.
- #1105: Preliminary support for storing persistent data in IndexedDB instead of localStorage
- #1691: Fix `collection.chatbox is undefined` errors
- #1733: New message notifications for a minimized chat stack on top of each other
- #1757: Chats are hidden behind the controlbox on mobile
- #1772 `_converse.api.contact.add(jid, nick)` fails, says not a function
### Breaking changes
- In contrast to sessionStorage and localStorage, IndexedDB is an asynchronous database.
......
......@@ -39,9 +39,9 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
## Features
- Available as overlayed chat boxes or as a fullscreen application. See [inverse.chat](https://inverse.chat) for the fullscreen version.
- Custom status messages
- Desktop notifications
- A [plugin architecture](https://conversejs.org/docs/html/plugin_development.html) based on [pluggable.js](https://conversejs.github.io/pluggable.js/)
- Single-user and group chats
- Contacts and groups
- Multi-user chat rooms [XEP 45](https://xmpp.org/extensions/xep-0045.html)
- Chatroom bookmarks [XEP 48](https://xmpp.org/extensions/xep-0048.html)
- Direct invitations to chat rooms [XEP 249](https://xmpp.org/extensions/xep-0249.html)
......@@ -50,9 +50,7 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
- In-band registration [XEP 77](https://xmpp.org/extensions/xep-0077.html)
- Roster item exchange [XEP 144](https://xmpp.org/extensions/tmp/xep-0144-1.1.html)
- Chat statuses (online, busy, away, offline)
- Custom status messages
- Typing and state notifications [XEP 85](https://xmpp.org/extensions/xep-0085.html)
- Desktop notifications
- File sharing / HTTP File Upload [XEP 363](https://xmpp.org/extensions/xep-0363.html)
- Messages appear in all connnected chat clients / Message Carbons [XEP 280](https://xmpp.org/extensions/xep-0280.html)
- Third person "/me" messages [XEP 245](https://xmpp.org/extensions/xep-0245.html)
......@@ -62,8 +60,10 @@ which shows you how to use the CDN (content delivery network) to quickly get a d
- Client state indication [XEP 352](https://xmpp.org/extensions/xep-0352.html)
- Last Message Correction [XEP 308](https://xmpp.org/extensions/xep-0308.html)
- OMEMO encrypted messaging [XEP 384](https://xmpp.org/extensions/xep-0384.html")
- Supports anonymous logins, see the [anonymous login demo](https://conversejs.org/demo/anonymous.html).
- Translated into 28 languages
- Anonymous logins, see the [anonymous login demo](https://conversejs.org/demo/anonymous.html)
- Message Retractions [XEP-424](https://xmpp.org/extensions/xep-0424.html)
- Message Moderation [XEP-425](https://xmpp.org/extensions/xep-0425.html)
- Translated into over 30 languages
## Integration into other frameworks
......
......@@ -1475,6 +1475,26 @@ not fulfilled.
Requires the `src/converse-notification.js` plugin.
show_retraction_warning
-----------------------
* Default: ``true``
From `XEP-0424: Message Retraction <https://xmpp.org/extensions/xep-0424.html>`_:
::
Due to the federated and extensible nature of XMPP it's not possible to remove a message with
full certainty and a retraction can only be considered an unenforceable request for such removal.
Clients which don't support message retraction are not obligated to enforce the request and
people could have seen or copied the message contents already.
By default Converse shows a warning to users when they retract a message, to
inform them that they don't have a guarantee that the message will be removed
everywhere.
This warning isn't applicable to all deployments of Converse and can therefore
be turned off by setting this config variable to ``false``.
use_system_emojis
-----------------
* Default: ``true``
......
......@@ -171,8 +171,8 @@
See <a href="https://inverse.chat" target="_blank" rel="noopener">inverse.chat</a> for the fullscreen version.
</li>
<li>A <a href="https://conversejs.org/docs/html/plugin_development.html" target="_blank" rel="noopener">plugin architecture</a> based on <a href="https://conversejs.github.io/pluggable.js/" target="_blank" rel="noopener">pluggable.js</a></li>
<li>Single-user and group chat</li>
<li>Contacts and groups</li>
<li>Chat statuses (online, busy, away, offline)</li>
<li>Desktop notifications</li>
<li>Multi-user chatrooms (<a href="https://xmpp.org/extensions/xep-0045.html" target="_blank" rel="noopener">XEP 45</a>)</li>
<li>Chatroom bookmarks (<a href="https://xmpp.org/extensions/xep-0048.html" target="_blank" rel="noopener">XEP 48</a>)</li>
<li>Direct invitations to chat rooms (<a href="https://xmpp.org/extensions/xep-0249.html" target="_blank" rel="noopener">XEP 249</a>)</li>
......@@ -180,10 +180,8 @@
<li>Service discovery (<a href="https://xmpp.org/extensions/xep-0030.html" target="_blank" rel="noopener">XEP 30</a>)</li>
<li>In-band registration (<a href="https://xmpp.org/extensions/xep-0077.html" target="_blank" rel="noopener">XEP 77</a>)</li>
<li>Roster item exchange (<a href="https://xmpp.org/extensions/xep-0144.html" target="_blank" rel="noopener">XEP 144</a>)</li>
<li>Chat statuses (online, busy, away, offline)</li>
<li>Custom status messages</li>
<li>Typing and chat state notifications (<a href="https://xmpp.org/extensions/xep-0085.html" target="_blank" rel="noopener">XEP 85</a>)</li>
<li>Desktop notifications</li>
<li>File sharing / HTTP File Upload (<a href="https://xmpp.org/extensions/xep-0363.html" target="_blank" rel="noopener">XEP 363</a>)</li>
<li>Messages appear in all connected chat clients / Message Carbons (<a href="https://xmpp.org/extensions/xep-0280.html" target="_blank" rel="noopener">XEP 280</a>)</li>
<li>Third person "/me" messages (<a href="https://xmpp.org/extensions/xep-0245.html" target="_blank" rel="noopener">XEP 245</a>)</li>
......@@ -193,8 +191,10 @@
<li>Client state indication (<a href="https://xmpp.org/extensions/xep-0352.html" target="_blank" rel="noopener">XEP 352</a>)</li>
<li>Last Message Correction (<a href="https://xmpp.org/extensions/xep-0308.html" target="_blank" rel="noopener">XEP 308</a>)</li>
<li>OMEMO encrypted messaging (<a href="https://xmpp.org/extensions/xep-0384.html" target="_blank" rel="noopener">XEP 384</a>)</li>
<li>Supports anonymous logins, see the <a href="https://conversejs.org/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a>.</li>
<li>Translated into 29 languages</li>
<li>Anonymous logins, see the <a href="https://conversejs.org/demo/anonymous.html" target="_blank" rel="noopener">anonymous login demo</a></li>
<li>Message Retractions (<a href="https://xmpp.org/extensions/xep-0424.html" target="_blank" rel="noopener">XEP 424</a>)</li>
<li>Message Moderation (<a href="https://xmpp.org/extensions/xep-0425.html" target="_blank" rel="noopener">XEP 425</a>)</li>
<li>Translated into over 30 languages</li>
</ul>
</div>
</div>
......
......@@ -143,6 +143,9 @@
&.badge {
color: var(--chat-head-text-color);
}
&.chat-msg--retracted {
color: var(--subdued-color);
}
}
.disconnect-container {
margin: 1em;
......
......@@ -317,6 +317,16 @@ body.converse-fullscreen {
color: var(--gray-color);
}
q {
quotes: "“" "”" "‘" "’";
}
q:before {
content: open-quote;
}
q:after {
content: close-quote;
}
.modal {
background-color: rgba(0, 0, 0, 0.4);
......
......@@ -39,6 +39,12 @@
}
}
&.chat-msg--retracted {
.chat-msg__message {
color: var(--subdued-color);
}
}
&.chat-info {
color: var(--chat-head-color);
font-size: var(--message-font-size);
......@@ -46,6 +52,9 @@
font-size: 90%;
padding: 0.17rem 1rem;
&.chat-msg--followup {
margin-left: 2.75rem;
}
&.badge {
color: var(--chat-head-text-color);
}
......@@ -60,6 +69,9 @@
color: var(--error-color);
font-weight: bold;
}
.q {
font-style: italic;
}
}
.chat-image {
......@@ -225,7 +237,7 @@
height: var(--message-font-size);
font-size: var(--message-font-size);
padding: 0;
padding-left: 0.5em;
padding-left: 0.75em;
border: none;
opacity: 0;
background: transparent;
......@@ -336,6 +348,11 @@
}
}
}
&.chat-info {
&.chat-msg--followup {
margin-left: 0;
}
}
}
}
......
#conversejs {
#converse-modals {
.modal-body {
margin-bottom: 2em;
.confirm {
.form-group {
p:first-child {
font-size: 110%;
font-weight: bold;
}
}
}
}
.scrollable-container {
......
......@@ -720,7 +720,6 @@
await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
await test_utils.waitForRoster(_converse, 'current');
// Send a message from a different resource
spyOn(_converse, 'log');
const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
const msg = $msg({
......@@ -848,7 +847,6 @@
await u.waitUntil(() => _converse.xmppstatus.vcard.get('fullname'));
await test_utils.waitForRoster(_converse, 'current');
// Send a message from a different resource
spyOn(_converse, 'log');
const recipient_jid = mock.cur_names[5].replace(/ /g,'.').toLowerCase() + '@montague.lit';
const view = await test_utils.openChatBoxFor(_converse, recipient_jid);
const msg = $msg({
......
......@@ -1037,7 +1037,7 @@
const view = _converse.chatboxviews.get(contact_jid);
expect(view.model.messages.length).toBe(1);
expect(view.model.messages.at(0).get('ephemeral')).toBe(false);
expect(view.model.messages.at(0).get('is_ephemeral')).toBe(false);
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.');
......
......@@ -83,7 +83,7 @@
expect(textarea.value).toBe('');
const first_msg = view.model.messages.findWhere({'message': 'But soft, what light through yonder airlock breaks?'});
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
let action = view.el.querySelector('.chat-msg .chat-msg__action');
expect(action.getAttribute('title')).toBe('Edit this message');
......@@ -160,7 +160,7 @@
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree()
);
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(1);
expect(view.el.querySelectorAll('.chat-msg .chat-msg__action').length).toBe(2);
// Test confirmation dialog
spyOn(window, 'confirm').and.returnValue(true);
......
......@@ -5232,6 +5232,7 @@
const textarea = view.el.querySelector('.chat-textarea');
textarea.value = 'Hello world';
view.onFormSubmitted(new Event('submit'));
await new Promise(resolve => view.once('messageInserted', resolve));
const stanza = u.toStanza(`
<message xmlns="jabber:client" type="error" to="troll@montague.lit/resource" from="trollbox@montague.lit">
......@@ -5240,6 +5241,7 @@
_converse.connection._dataRecv(test_utils.createRequest(stanza));
await new Promise(resolve => view.once('messageInserted', resolve));
expect(view.el.querySelector('.chat-error').textContent.trim()).toBe(
"Your message was not delivered because you're not allowed to send messages in this groupchat.");
done();
......
This diff is collapsed.
......@@ -65,6 +65,7 @@ converse.plugins.add('converse-chatview', {
'auto_focus': true,
'message_limit': 0,
'show_send_button': false,
'show_retraction_warning': true,
'show_toolbar': true,
'time_format': 'HH:mm',
'visible_toolbar_buttons': {
......@@ -226,6 +227,7 @@ converse.plugins.add('converse-chatview', {
events: {
'change input.fileupload': 'onFileSelection',
'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
'click .chatbox-navback': 'showControlBox',
'click .close-chatbox-button': 'close',
'click .new-msgs-indicator': 'viewUnreadMessages',
......@@ -622,8 +624,8 @@ converse.plugins.add('converse-chatview', {
return this.trigger('messageInserted', view.el);
}
}
const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date(),
previous_msg_date = this.getLastMessageDate(current_msg_date);
const current_msg_date = dayjs(view.model.get('time')).toDate() || new Date();
const previous_msg_date = this.getLastMessageDate(current_msg_date);
if (previous_msg_date === null) {
this.content.insertAdjacentElement('afterbegin', view.el);
......@@ -649,9 +651,8 @@ converse.plugins.add('converse-chatview', {
* followup message or not.
*
* Followup messages are subsequent ones written by the same
* author with no other conversation elements inbetween and
* posted within 10 minutes of one another.
*
* author with no other conversation elements in between and
* which were posted within 10 minutes of one another.
* @private
* @method _converse.ChatBoxView#markFollowups
* @param { HTMLElement } el - The message element
......@@ -730,11 +731,9 @@ converse.plugins.add('converse-chatview', {
// We already have a view for this message
return;
}
if (!u.isNewMessage(message) && u.isEmptyMessage(message)) {
// Ignore archived or delayed messages without any text to show.
return message.destroy();
}
if (!message.get('dangling_retraction')) {
await this.showMessage(message);
}
/**
* Triggered once a message has been added to a chatbox.
* @event _converse#messageAdded
......@@ -914,6 +913,45 @@ converse.plugins.add('converse-chatview', {
this.insertIntoTextArea('', true, false);
},
/**
* Retract one of your messages in this chat
* @private
* @method _converse.ChatBoxView#retractOwnMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
retractOwnMessage(message) {
this.model.sendRetractionMessage(message);
message.save({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id'),
'is_ephemeral': true
});
},
async onMessageRetractButtonClicked (ev) {
ev.preventDefault();
const msg_el = u.ancestor(ev.target, '.message');
const msgid = msg_el.getAttribute('data-msgid');
const time = msg_el.getAttribute('data-isodate');
const message = this.model.messages.findWhere({msgid, time});
if (message.get('sender') !== 'me') {
return log.error("onMessageEditButtonClicked called for someone else's message!");
}
const retraction_warning =
__("Be aware that other XMPP/Jabber clients (and servers) may "+
"not yet support retractions and that this message may not "+
"be removed everywhere.");
const messages = [__('Are you sure you want to retract this message?')];
if (_converse.show_retraction_warning) {
messages[1] = retraction_warning;
}
const result = await _converse.api.confirm(__('Confirm'), messages);
if (result) {
this.retractOwnMessage(message);
}
},
onMessageEditButtonClicked (ev) {
ev.preventDefault();
......
......@@ -21,7 +21,7 @@ import tpl_message_versions_modal from "templates/message_versions_modal.html";
import tpl_spinner from "templates/spinner.html";
import xss from "xss/dist/xss";
const { dayjs } = converse.env;
const { Strophe, dayjs } = converse.env;
const u = converse.env.utils;
......@@ -140,22 +140,20 @@ converse.plugins.add('converse-message-view', {
} else {
await this.renderChatMessage();
}
if (is_followup) {
u.addClass('chat-msg--followup', this.el);
}
is_followup && u.addClass('chat-msg--followup', this.el);
return this.el;
},
async onChanged (item) {
// Jot down whether it was edited because the `changed`
// attr gets removed when this.render() gets called further
// down.
// attr gets removed when this.render() gets called further down.
const edited = item.changed.edited;
if (this.model.changed.progress) {
return this.renderFileUploadProgresBar();
}
const isValidChange = prop => Object.prototype.hasOwnProperty.call(this.model.changed, prop);
if (['correcting', 'message', 'type', 'upload', 'received', 'editable'].filter(isValidChange).length) {
const props = ['moderated', 'retracted', 'correcting', 'message', 'type', 'upload', 'received', 'editable'];
if (props.filter(isValidChange).length) {
await this.debouncedRender();
}
if (edited) {
......@@ -243,19 +241,22 @@ converse.plugins.add('converse-message-view', {
const time = dayjs(this.model.get('time'));
const role = this.model.vcard ? this.model.vcard.get('role') : null;
const roles = role ? role.split(',') : [];
const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
const msg = u.stringToElement(tpl_message(
Object.assign(
this.model.toJSON(), {
'__': __,
__,
is_retracted,
'extra_classes': this.getExtraMessageClasses(),
'is_groupchat_message': this.model.get('type') === 'groupchat',
'occupant': this.model.occupant,
'is_me_message': this.model.isMeCommand(),
'roles': roles,
'label_show': __('Show more'),
'occupant': this.model.occupant,
'pretty_time': time.format(_converse.time_format),
'retraction_text': is_retracted ? this.getRetractionText() : null,
'roles': roles,
'time': time.toISOString(),
'extra_classes': this.getExtraMessageClasses(),
'label_show': __('Show more'),
'username': this.model.getDisplayName()
})
));
......@@ -265,12 +266,14 @@ converse.plugins.add('converse-message-view', {
msg.querySelector('.chat-msg__media').innerHTML = this.transformOOBURL(url);
}
if (!is_retracted) {
const text = this.model.getMessageText();
const msg_content = msg.querySelector('.chat-msg__text');
if (text && text !== url) {
msg_content.innerHTML = await this.transformBodyText(text);
await u.renderImageURLs(_converse, msg_content);
}
}
if (this.model.get('type') !== 'headline') {
this.renderAvatar(msg);
}
......@@ -292,6 +295,25 @@ converse.plugins.add('converse-message-view', {
return this.replaceElement(msg);
},
getRetractionText () {
const username = this.model.getDisplayName();
let retraction_text = __('A message by %1$s has been retracted', username);
if (this.model.get('type') === 'groupchat') {
const retracted_by_mod = this.model.get('moderated_by');
if (retracted_by_mod) {
const chatbox = this.model.collection.chatbox;
if (!this.model.mod) {
this.model.mod =
chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
}
const modname = this.model.mod ? this.model.mod.getDisplayName() : 'A moderator';
retraction_text = __('%1$s has retracted this message from %2$s', modname , username);
}
}
return retraction_text;
},
renderErrorMessage () {
const msg = u.stringToElement(
tpl_info(Object.assign(this.model.toJSON(), {
......@@ -304,8 +326,8 @@ converse.plugins.add('converse-message-view', {
renderChatStateNotification () {
let text;
const from = this.model.get('from'),
name = this.model.getDisplayName();
const from = this.model.get('from');
const name = this.model.getDisplayName();
if (this.model.get('chat_state') === _converse.COMPOSING) {
if (this.model.get('sender') === 'me') {
......@@ -354,8 +376,10 @@ converse.plugins.add('converse-message-view', {
},
getExtraMessageClasses () {
let extra_classes = this.model.get('is_delayed') && 'delayed' || '';
const is_retracted = this.model.get('retracted') || this.model.get('moderated') === 'retracted';
const extra_classes = [
...(this.model.get('is_delayed') ? ['delayed'] : []), ...(is_retracted ? ['chat-msg--retracted'] : [])
];
if (this.model.get('type') === 'groupchat') {
if (this.model.occupant) {
extra_classes += ` ${this.model.occupant.get('role') || ''} ${this.model.occupant.get('affiliation') || ''}`;
......
......@@ -12,6 +12,7 @@ import converse from "@converse/headless/converse-core";
import { isString } from "lodash";
import tpl_alert from "templates/alert.html";
import tpl_alert_modal from "templates/alert_modal.html";
import tpl_prompt from "templates/prompt.html";
const { Backbone, sizzle } = converse.env;
const u = converse.env.utils;
......@@ -21,6 +22,7 @@ converse.plugins.add('converse-modal', {
initialize () {
const { _converse } = this;
const { __ } = _converse;
_converse.BootstrapModal = Backbone.VDOMView.extend({
......@@ -79,18 +81,69 @@ converse.plugins.add('converse-modal', {
}
});
_converse.Alert = _converse.BootstrapModal.extend({
_converse.Confirm = _converse.BootstrapModal.extend({
events: {
'submit .confirm': 'onConfimation'
},
initialize () {
this.confirmation = u.getResolveablePromise();
_converse.BootstrapModal.prototype.initialize.apply(this, arguments);
this.listenTo(this.model, 'change', this.render)
this.el.addEventListener('closed.bs.modal', () => this.confirmation.reject(), false);
},
toHTML () {
return tpl_alert_modal(this.model.toJSON());
return tpl_prompt(Object.assign({__}, this.model.toJSON()));
},
afterRender () {
if (!this.close_handler_registered) {
this.el.addEventListener('closed.bs.modal', () => {
if (!this.confirmation.isResolved) {
this.confirmation.reject()
}
}, false);
this.close_handler_registered = true;
}
},
onConfimation (ev) {
ev.preventDefault();
this.confirmation.resolve(true);
this.modal.hide();
}
});
_converse.Prompt = _converse.Confirm.extend({
toHTML () {
return tpl_prompt(Object.assign({__}, this.model.toJSON()));
},
onConfimation (ev) {
ev.preventDefault();
const form_data = new FormData(ev.target);
this.confirmation.resolve(form_data.get('reason'));
this.modal.hide();
}
});
_converse.Alert = _converse.BootstrapModal.extend({
initialize () {
_converse.BootstrapModal.prototype.initialize.apply(this, arguments);
this.listenTo(this.model, 'change', this.render)
},
toHTML () {
return tpl_alert_modal(
Object.assign({__}, this.model.toJSON()));
}
});
/************************ BEGIN Event Listeners ************************/
_converse.api.listen.on('afterTearDown', () => {
if (!_converse.chatboxviews) {
return;
......@@ -104,40 +157,112 @@ converse.plugins.add('converse-modal', {
/************************ BEGIN API ************************/
// We extend the default converse.js API to add methods specific to MUC chat rooms.
let alert;
let alert, prompt, confirm;
Object.assign(_converse.api, {
/**
* Show a confirm modal to the user.
* @method _converse.api.confirm
* @param { String } title - The header text for the confirmation dialog
* @param { (String[]|String) } messages - The text to show to the user
* @returns { Promise } A promise which resolves with true or false
*/
async confirm (title, messages=[]) {
if (isString(messages)) {
messages = [messages];
}
if (confirm === undefined) {
const model = new Backbone.Model({
'title': title,
'messages': messages,
'type': 'confirm'
})
confirm = new _converse.Confirm({model});
} else {
confirm.model.set({
'title': title,
'messages': messages,
'type': 'confirm'
});
}
confirm.show();
try {
return await confirm.confirmation;
} catch (e) {
return false;
}
},
/**
* Show a prompt modal to the user.
* @method _converse.api.prompt
* @param { String } title - The header text for the prompt
* @param { (String[]|String) } messages - The prompt text to show to the user
* @param { String } placeholder - The placeholder text for the prompt input
* @returns { Promise } 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 (isString(messages)) {
messages = [messages];
}
if (prompt === undefined) {
const model = new Backbone.Model({
'title': title,
'messages': messages,
'placeholder': placeholder,
'type': 'prompt'
})
prompt = new _converse.Prompt({model});
} else {
prompt.model.set({
'title': title,
'messages': messages,
'type': 'prompt'
});
}
prompt.show();
try {
return await prompt.confirmation;
} catch (e) {
return false;
}
},
/**
* Show an alert modal to the user.
* @method _converse.api.alert
* @param { ('info'|'warn'|'error') } type - The type of alert.
* @returns { String } title - The header text for the alert.
* @returns { (String[]|String) } messages - The alert text to show to the user.
* @param { String } title - The header text for the alert.
* @param { (String[]|String) } messages - The alert text to show to the user.
*/
alert (type, title, messages) {
if (isString(messages)) {
messages = [messages];
}
let level;
if (type === 'error') {
type = 'alert-danger';
level = 'alert-danger';
} else if (type === 'info') {
type = 'alert-info';
level = 'alert-info';
} else if (type === 'warn') {
type = 'alert-warning';
level = 'alert-warning';
}
if (alert === undefined) {
const model = new Backbone.Model({
'title': title,
'messages': messages,
'type': type
'level': level,
'type': 'alert'
})
alert = new _converse.Alert({'model': model});
alert = new _converse.Alert({model});
} else {
alert.model.set({
'title': title,
'messages': messages,
'type': type
'level': level
});
}
alert.show();
......
......@@ -41,7 +41,6 @@ import tpl_rooms_results from "templates/rooms_results.html";
import tpl_spinner from "templates/spinner.html";
import xss from "xss/dist/xss";
const { Backbone, Strophe, sizzle, _, $iq, $pres } = converse.env;
const u = converse.env.utils;
......@@ -108,6 +107,7 @@ converse.plugins.add('converse-muc-views', {
'auto_list_rooms': false,
'cache_muc_messages': true,
'locked_muc_nickname': false,
'show_retraction_warning': true,
'muc_disable_slash_commands': false,
'muc_show_join_leave': true,
'muc_show_join_leave_status': true,
......@@ -630,6 +630,7 @@ converse.plugins.add('converse-muc-views', {
events: {
'change input.fileupload': 'onFileSelection',
'click .chat-msg__action-edit': 'onMessageEditButtonClicked',
'click .chat-msg__action-retract': 'onMessageRetractButtonClicked',
'click .chatbox-navback': 'showControlBox',
'click .close-chatbox-button': 'close',
'click .configure-chatroom-button': 'getAndRenderConfigurationForm',
......@@ -724,8 +725,7 @@ converse.plugins.add('converse-muc-views', {
},
renderChatArea () {
/* Render the UI container in which groupchat messages will appear.
*/
// Render the UI container in which groupchat messages will appear.
if (this.el.querySelector('.chat-area') === null) {
const container_el = this.el.querySelector('.chatroom-body');
container_el.insertAdjacentHTML(
......@@ -811,6 +811,101 @@ converse.plugins.add('converse-muc-views', {
return _converse.ChatBoxView.prototype.onKeyUp.call(this, ev);
},
async onMessageRetractButtonClicked (ev) {
ev.preventDefault();
const msg_el = u.ancestor(ev.target, '.message');
const msgid = msg_el.getAttribute('data-msgid');
const time = msg_el.getAttribute('data-isodate');
const message = this.model.messages.findWhere({msgid, time});
const retraction_warning =
__("Be aware that other XMPP/Jabber clients (and servers) may "+
"not yet support retractions and that this message may not "+
"be removed everywhere.");
if (message.get('sender') === 'me') {
const messages = [__('Are you sure you want to retract this message?')];
if (_converse.show_retraction_warning) {
messages[1] = retraction_warning;
}
const result = await _converse.api.confirm(__('Confirm'), messages);
if (result) {
this.retractOwnMessage(message);
}
} else {
let messages = [
__('You are about to retract this message.'),
__('You may optionally include a message, explaining the reason for the retraction.')
];
if (_converse.show_retraction_warning) {
messages = [messages[0], retraction_warning, messages[1]]
}
const reason = await _converse.api.prompt(
__('Message Retraction'),
messages,
__('Optional reason')
);
if (reason !== false) {
this.retractOtherMessage(message, reason);
}
}
},
/**
* Retract one of your messages in this groupchat.
* @private
* @method _converse.ChatRoomView#retractOwnMessage
* @param { _converse.Message } message - The message which we're retracting.
*/
retractOwnMessage(message) {
this.model.sendRetractionMessage(message)
.catch(e => {
message.save({
'retracted': undefined,
'retracted_id': undefined
});
const errmsg = __('Sorry, something went wrong while trying to retract your message.');
if (u.isErrorStanza(e)) {
this.showErrorMessage(errmsg);
} else {
this.showErrorMessage(errmsg);
this.showErrorMessage(e.message);
}
log.error(e);
});
message.save({
'retracted': (new Date()).toISOString(),
'retracted_id': message.get('origin_id')
});
},
/**
* Retract someone else's message in this groupchat.
* @private
* @method _converse.ChatRoomView#retractOtherMessage
* @param { _converse.Message } message - The message which we're retracting.
* @param { string } [reason] - The reason for retracting the message.
*/
async retractOtherMessage (message, reason) {
const result = await this.model.sendRetractionIQ(message, reason);
if (result === null) {
const err_msg = __(`A timeout occurred while trying to retract the message`);
_converse.api.alert('error', __('Error'), err_msg);
_converse.log(err_msg, Strophe.LogLevel.WARN);
} else if (u.isErrorStanza(result)) {
const err_msg = __(`Sorry, you're not allowed to retract this message.`);
_converse.api.alert('error', __('Error'), err_msg);
_converse.log(err_msg, Strophe.LogLevel.WARN);
_converse.log(result, Strophe.LogLevel.WARN);
} else {
message.save({
'moderated': 'retracted',
'moderated_by': _converse.bare_jid,
'moderated_id': message.get('msgid'),
'moderation_reason': reason
});
}
},
showModeratorToolsModal (affiliation) {
if (!this.verifyRoles(['moderator'])) {
return;
......@@ -2193,7 +2288,7 @@ converse.plugins.add('converse-muc-views', {
* @namespace _converse.api.roomviews
* @memberOf _converse.api
*/
'roomviews': {
roomviews: {
/**
* Retrieves a groupchat (aka chatroom) view. The chat should already be open.
*
......
......@@ -121,6 +121,7 @@ converse.plugins.add('converse-notification', {
_converse.areDesktopNotificationsEnabled = function () {
return _converse.supports_html5_notification &&
_converse.show_desktop_notifications &&
Notification.permission === "granted";
};
......
This diff is collapsed.
......@@ -37,16 +37,19 @@ Strophe.addNamespace('CARBONS', 'urn:xmpp:carbons:2');
Strophe.addNamespace('CHATSTATES', 'http://jabber.org/protocol/chatstates');
Strophe.addNamespace('CSI', 'urn:xmpp:csi:0');
Strophe.addNamespace('DELAY', 'urn:xmpp:delay');
Strophe.addNamespace('FASTEN', 'urn:xmpp:fasten:0');
Strophe.addNamespace('FORWARD', 'urn:xmpp:forward:0');
Strophe.addNamespace('HINTS', 'urn:xmpp:hints');
Strophe.addNamespace('HTTPUPLOAD', 'urn:xmpp:http:upload:0');
Strophe.addNamespace('IDLE', 'urn:xmpp:idle:1');
Strophe.addNamespace('MAM', 'urn:xmpp:mam:2');
Strophe.addNamespace('MODERATE', 'urn:xmpp:message-moderate:0');
Strophe.addNamespace('NICK', 'http://jabber.org/protocol/nick');
Strophe.addNamespace('OMEMO', 'eu.siacs.conversations.axolotl');
Strophe.addNamespace('OUTOFBAND', 'jabber:x:oob');
Strophe.addNamespace('PUBSUB', 'http://jabber.org/protocol/pubsub');
Strophe.addNamespace('REGISTER', 'jabber:iq:register');
Strophe.addNamespace('RETRACT', 'urn:xmpp:message-retract:0');
Strophe.addNamespace('ROSTERX', 'http://jabber.org/protocol/rosterx');
Strophe.addNamespace('RSM', 'http://jabber.org/protocol/rsm');
Strophe.addNamespace('SID', 'urn:xmpp:sid:0');
......@@ -92,8 +95,7 @@ const CORE_PLUGINS = [
'converse-rsm',
'converse-smacks',
'converse-status',
'converse-vcard',
'stanza-utils'
'converse-vcard'
];
......@@ -103,7 +105,7 @@ const CORE_PLUGINS = [
* @global
* @namespace _converse
*/
// XXX: Strictly speaking _converse is not a global, but we need to set it as
// Strictly speaking _converse is not a global, but we need to set it as
// such to get JSDoc to create the correct document site strucure.
const _converse = {
'templates': {},
......@@ -142,6 +144,10 @@ class TimeoutError extends Error {}
_converse.TimeoutError = TimeoutError;
class IllegalMessage extends Error {}
_converse.IllegalMessage = IllegalMessage;
// Make converse pluggable
pluggable.enable(_converse, '_converse', 'pluggable');
......@@ -187,7 +193,7 @@ _converse.LOGOUT = 'logout';
_converse.OPENED = 'opened';
_converse.PREBIND = 'prebind';
_converse.IQ_TIMEOUT = 20000;
_converse.STANZA_TIMEOUT = 10000;
_converse.CONNECTION_STATUS = {
0: 'ERROR',
......@@ -1694,7 +1700,7 @@ _converse.api = {
* or is rejected when we receive an `error` stanza.
*/
sendIQ (stanza, timeout, reject=true) {
timeout = timeout || _converse.IQ_TIMEOUT;
timeout = timeout || _converse.STANZA_TIMEOUT;
let promise;
if (reject) {
promise = new Promise((resolve, reject) => _converse.connection.sendIQ(stanza, resolve, reject, timeout));
......
......@@ -55,7 +55,6 @@ converse.plugins.add('converse-mam', {
});
const MAMEnabledChat = {
/**
* Fetches messages that might have been archived *after*
* the last archived message in our local cache.
......
This diff is collapsed.
This diff is collapsed.
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header {{{o.type}}}">
<div class="modal-header {{{o.level}}}">
<h5 class="modal-title">{{{o.title}}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
......
......@@ -15,12 +15,17 @@
</span>
<div class="chat-msg__body chat-msg__body--{{{o.type}}} {{{o.received ? 'chat-msg__body--received' : '' }}} {{{o.is_delayed ? 'chat-msg__body--delayed' : '' }}}">
<div class="chat-msg__message">
{[ if (o.is_retracted) { ]}
<div>{{{o.retraction_text}}}</div>
{[ if (o.moderation_reason) { ]}<q class="chat-msg--retracted__reason">{{{o.moderation_reason}}}</q>{[ } ]}
{[ } else { ]}
{[ if (o.is_spoiler) { ]}
<div class="chat-msg__spoiler-hint">
<span class="spoiler-hint">{{{o.spoiler_hint}}}</span>
<a class="badge badge-info spoiler-toggle" data-toggle-state="closed" href="#"><i class="fa fa-eye"></i>{{{o.label_show}}}</a>
</div>
{[ } ]}
{[ if (o.subject) { ]}
<div class="chat-msg__subject">{{{ o.subject }}}</div>
{[ } ]}
......@@ -28,14 +33,19 @@
{[ if (o.is_single_emoji) { ]} chat-msg__text--larger{[ } ]}
{[ if (o.is_spoiler) { ]} spoiler collapsed{[ } ]}"><!-- message gets added here via renderMessage --></div>
<div class="chat-msg__media"></div>
{[ } ]}
</div>
{[ if (o.received && !o.is_me_message && !o.is_groupchat_message) { ]} <span class="fa fa-check chat-msg__receipt"></span> {[ } ]}
{[ if (o.edited) { ]} <i title="{{{o.__('This message has been edited')}}}" class="fa fa-edit chat-msg__edit-modal"></i> {[ } ]}
{[ if (o.editable) { ]}
<div class="chat-msg__actions">
{[ if (o.editable) { ]}
<button class="chat-msg__action chat-msg__action-edit fa fa-pencil-alt" title="{{{o.__('Edit this message')}}}"></button>
</div>
{[ } ]}
<!-- FIXME -->
{[ if ((o.sender === 'me' || o.is_groupchat_message) && true) { ]}
<button class="chat-msg__action chat-msg__action-retract fa fa-trash-alt" title="{{{o.__('Retract this message')}}}"></button>
{[ } ]}
</div>
</div>
</div>
</div>
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header {{{o.level}}}">
<h5 class="modal-title">{{{o.title}}}</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<form class="converse-form converse-form--modal confirm" action="#">
<div class="form-group">
{[o.messages.forEach(function (message) { ]}
<p>{{{message}}}</p>
{[ }) ]}
</div>
{[ if (o.type === 'prompt') { ]}
<div class="form-group">
<input type="text" name="reason" class="form-control" placeholder="{{{o.placeholder}}}"/>
</div>
{[ } ]}
<div class="form-group">
<button type="submit" class="btn btn-primary">{{{o.__('OK')}}}</button>
<input type="button" class="btn btn-secondary" data-dismiss="modal" value="{{{o.__('Cancel')}}}"/>
</div>
</form>
</div>
</div>
</div>
</div>
......@@ -293,6 +293,8 @@ u.ancestor = function (el, selector) {
* Return the element's siblings until one matches the selector.
* @private
* @method u#nextUntil
* @param { HTMLElement } el
* @param { String } selector
*/
u.nextUntil = function (el, selector) {
const matches = [];
......
......@@ -55,6 +55,7 @@ var specs = [
"spec/user-details-modal",
"spec/messages",
"spec/muc_messages",
"spec/retractions",
"spec/muc",
"spec/modtools",
"spec/room_registration",
......
......@@ -18,17 +18,13 @@
});
converse.initialize({
auto_away: 300,
auto_login: true,
auto_register_muc_nickname: true,
bosh_service_url: 'http://chat.example.org:5380/http-bind/',
debug: true,
enable_smacks: true,
i18n: 'en',
jid: 'klaus.dresner@chat.example.org',
message_archiving: 'always',
muc_domain: 'conference.chat.example.org',
muc_respect_autojoin: true,
password: 'secret',
view_mode: 'fullscreen',
websocket_url: 'ws://chat.example.org:5380/xmpp-websocket',
whitelisted_plugins: ['converse-debug'],
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment