Commit b5d57f0e authored by JC Brand's avatar JC Brand

Handle and render chat state notifications separately from messages

parent 283a810d
......@@ -230,10 +230,21 @@
width: 100%
}
}
.chat-content-sendbutton {
height: calc(100% - (var(--chat-textarea-height) + var(--send-button-height) + 2 * var(--send-button-margin)));
}
.chat-state-notifications {
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;
}
.dropdown { /* status dropdown styles */
background-color: var(--light-background-color);
dd {
......
This diff is collapsed.
......@@ -1108,21 +1108,8 @@
.c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree());
await new Promise(resolve => view.once('messageInserted', resolve));
jasmine.clock().tick(1000);
// Insert <composing> message, to also check that
// text messages are inserted correctly with
// temporary chat events in the chat contents.
_converse.handleMessageStanza($msg({
'id': 'aeb219',
'to': _converse.bare_jid,
'xmlns': 'jabber:client',
'from': sender_jid,
'type': 'chat'})
.c('composing', {'xmlns': Strophe.NS.CHATSTATES}).up()
.tree());
await new Promise(resolve => view.once('messageInserted', resolve));
jasmine.clock().tick(1*ONE_MINUTE_LATER);
_converse.handleMessageStanza($msg({
'from': sender_jid,
'to': _converse.connection.jid,
......
This diff is collapsed.
......@@ -197,13 +197,14 @@ converse.plugins.add('converse-chatview', {
this.initDebounced();
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.messages, 'change:edited', this.onMessageEdited);
this.listenTo(this.model.messages, 'rendered', this.scrollDown);
this.model.messages.on('reset', () => {
this.content.innerHTML = '';
this.removeAll();
});
this.listenTo(this.model.csn, 'change', this.renderChatStateNotification);
this.listenTo(this.model, 'change:status', this.onStatusMessageChanged);
this.listenTo(this.model, 'destroy', this.remove);
this.listenTo(this.model, 'show', this.show);
......@@ -248,11 +249,25 @@ converse.plugins.add('converse-chatview', {
);
render(result, this.el);
this.content = this.el.querySelector('.chat-content');
this.csn = this.el.querySelector('.chat-state-notifications');
this.renderChatStateNotification();
this.renderMessageForm();
this.renderHeading();
return this;
},
renderChatStateNotification () {
if (this.model.csn.get('chat_state') === _converse.COMPOSING) {
this.csn.innerText = __('%1$s is typing', this.model.getDisplayName());
} else if (this.model.csn.get('chat_state') === _converse.PAUSED) {
this.csn.innerText = __('%1$s has stopped typing', this.model.getDisplayName());
} else if (this.model.csn.get('chat_state') === _converse.GONE) {
this.csn.innerText = __('%1$s has gone away', this.model.getDisplayName());
} else {
this.csn.innerText = '';
}
},
renderToolbar () {
if (!_converse.show_toolbar) {
return this;
......@@ -729,7 +744,6 @@ converse.plugins.add('converse-chatview', {
await message.initialized;
const view = this.add(message.get('id'), new _converse.MessageView({'model': message}));
await view.render();
this.clearChatStateForSender(message.get('from'));
this.insertMessage(view);
this.insertDayIndicator(view.el);
this.setScrollPosition(view.el);
......@@ -741,7 +755,7 @@ converse.plugins.add('converse-chatview', {
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.model.get('scrolled', true) && !u.isOnlyChatStateNotification(message)) {
} else if (this.model.get('scrolled', true)) {
this.showNewMessagesIndicator();
}
}
......@@ -784,16 +798,6 @@ converse.plugins.add('converse-chatview', {
});
},
/**
* Handler that gets called when a message object has been edited via LMC.
* @private
* @method _converse.ChatBoxView#onMessageEdited
* @param { object } message - The updated message object.
*/
onMessageEdited (message) {
this.clearChatStateForSender(message.get('from'));
},
parseMessageForCommands (text) {
const match = text.replace(/^\s*/, "").match(/^\/(.*)\s*$/);
if (match) {
......@@ -1086,16 +1090,6 @@ converse.plugins.add('converse-chatview', {
return this;
},
/**
* Remove chat state notifications for a given sender JID.
* @private
* @method _converse.ChatBoxView#clearChatStateForSender
* @param {string} sender - The sender of the chat state
*/
clearChatStateForSender (sender) {
sizzle(`.chat-state-notification[data-csn="${sender}"]`, this.content).forEach(u.removeElement);
},
/**
* Insert a particular string value into the textarea of this chat box.
* @private
......
......@@ -12,7 +12,6 @@ import { debounce } from 'lodash'
import { render } from "lit-html";
import filesize from "filesize";
import log from "@converse/headless/log";
import tpl_csn from "templates/csn.html";
import tpl_file_progress from "templates/file_progress.html";
import tpl_info from "templates/info.html";
import tpl_message from "templates/message.html";
......@@ -119,9 +118,7 @@ converse.plugins.add('converse-message-view', {
async render () {
const is_followup = u.hasClass('chat-msg--followup', this.el);
if (this.model.isOnlyChatStateNotification()) {
this.renderChatStateNotification()
} else if (this.model.get('file') && !this.model.get('oob_url')) {
if (this.model.get('file') && !this.model.get('oob_url')) {
if (!this.model.file) {
log.error("Attempted to render a file upload message with no file data");
return this.el;
......@@ -327,38 +324,6 @@ converse.plugins.add('converse-message-view', {
return this.replaceElement(msg);
},
renderChatStateNotification () {
let text;
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') {
text = __('Typing from another device');
} else {
text = __('%1$s is typing', name);
}
} else if (this.model.get('chat_state') === _converse.PAUSED) {
if (this.model.get('sender') === 'me') {
text = __('Stopped typing on the other device');
} else {
text = __('%1$s has stopped typing', name);
}
} else if (this.model.get('chat_state') === _converse.GONE) {
text = __('%1$s has gone away', name);
} else {
return;
}
const isodate = (new Date()).toISOString();
this.replaceElement(
u.stringToElement(
tpl_csn({
'message': text,
'from': from,
'isodate': isodate
})));
},
renderFileUploadProgresBar () {
const msg = u.stringToElement(tpl_file_progress(
Object.assign(this.model.toJSON(), {
......
......@@ -15,7 +15,6 @@ import { __ } from '@converse/headless/i18n';
import converse from "@converse/headless/converse-core";
import log from "@converse/headless/log";
import tpl_add_chatroom_modal from "templates/add_chatroom_modal.js";
import tpl_chatarea from "templates/chatarea.html";
import tpl_chatroom from "templates/chatroom.js";
import tpl_chatroom_bottom_panel from "templates/chatroom_bottom_panel.html";
import tpl_chatroom_destroyed from "templates/chatroom_destroyed.html";
......@@ -708,8 +707,10 @@ converse.plugins.add('converse-muc-views', {
this.removeAll();
});
this.listenTo(this.model, 'change', this.renderHeading);
this.listenTo(this.model.csn, 'change', this.renderChatStateNotifications);
this.listenTo(this.model.session, 'change:connection_status', this.onConnectionStatusChanged);
this.listenTo(this.model, 'change', this.renderHeading);
this.listenTo(this.model, 'change:hidden_occupants', this.updateOccupantsToggle);
this.listenTo(this.model, 'change:subject', this.setChatRoomSubject);
this.listenTo(this.model, 'configurationNeeded', this.getAndRenderConfigurationForm);
......@@ -745,10 +746,14 @@ converse.plugins.add('converse-muc-views', {
render () {
this.el.setAttribute('id', this.model.get('box_id'));
render(tpl_chatroom(), this.el);
render(tpl_chatroom({
'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
'show_send_button': _converse.show_send_button
}), this.el);
this.renderHeading();
this.renderChatArea();
this.renderBottomPanel();
this.content = this.el.querySelector('.chat-content');
this.csn = this.el.querySelector('.chat-state-notifications');
if (!_converse.muc_show_logs_before_join) {
this.model.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED && this.showSpinner();
}
......@@ -758,6 +763,47 @@ converse.plugins.add('converse-muc-views', {
return this;
},
renderChatStateNotifications () {
const actors_per_state = this.model.csn.toJSON();
const message = converse.CHAT_STATES.reduce((result, state) => {
const existing_actors = actors_per_state[state];
if (!existing_actors) {
return result;
}
const actors = existing_actors
.map(a => this.model.getOccupant(a))
.filter(a => a)
.map(a => a.getDisplayName());
if (actors.length === 1) {
if (state === 'composing') {
return `${result} ${__('%1$s is typing', actors[0])}\n`;
} else if (state === 'paused') {
return `${result} ${__('%1$s has stopped typing', actors[0])}\n`;
} else if (state === _converse.GONE) {
return `${result} ${__('%1$s has gone away', actors[0])}\n`;
}
} else if (actors.length > 1) {
let actors_str;
if (actors.length > 3) {
actors_str = `${Array.from(actors).slice(0, 2).join(', ')} and others`;
} else {
const last_actor = actors.pop();
actors_str = __('%1$s and %2$s', actors.join(', '), last_actor);
}
if (state === 'composing') {
return `${result} ${__('%1$s are typing', actors_str)}\n`;
} else if (state === 'paused') {
return `${result} ${__('%1$s have stopped typing', actors_str)}\n`;
} else if (state === _converse.GONE) {
return `${result} ${__('%1$s have gone away', actors_str)}\n`;
}
}
return result;
}, '');
this.csn.innerHTML = message;
},
/**
* Renders the MUC heading if any relevant attributes have changed.
* @private
......@@ -780,23 +826,6 @@ converse.plugins.add('converse-muc-views', {
}
},
renderChatArea () {
// 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(
'beforeend',
tpl_chatarea({
__,
'muc_show_logs_before_join': _converse.muc_show_logs_before_join,
'show_send_button': _converse.show_send_button
})
);
this.content = this.el.querySelector('.chat-content');
}
return this;
},
createSidebarView () {
this.model.occupants.chatroomview = this;
this.sidebar_view = new _converse.MUCSidebar({'model': this.model.occupants});
......
......@@ -150,12 +150,8 @@ converse.plugins.add('converse-chat', {
}
},
isOnlyChatStateNotification () {
return u.isOnlyChatStateNotification(this);
},
isEphemeral () {
return this.get('is_ephemeral') || u.isOnlyChatStateNotification(this);
return this.get('is_ephemeral');
},
getDisplayName () {
......@@ -325,6 +321,7 @@ converse.plugins.add('converse-chat', {
}
this.set({'box_id': `box-${btoa(jid)}`});
this.initMessages();
this.initCSN();
if (this.get('type') === _converse.PRIVATE_CHAT_TYPE) {
this.presence = _converse.presences.findWhere({'jid': jid}) || _converse.presences.create({'jid': jid});
......@@ -357,6 +354,10 @@ converse.plugins.add('converse-chat', {
});
},
initCSN () {
this.csn = new Model();
},
afterMessagesFetched () {
/**
* Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
......@@ -409,8 +410,13 @@ converse.plugins.add('converse-chat', {
return;
}
this.setEditable(attrs, attrs.time, stanza);
if (attrs['chat_state'] && attrs.sender === 'them') {
this.csn.set('chat_state', attrs.chat_state);
}
if (u.shouldCreateMessage(attrs)) {
const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
this.csn.set({'chat_state': null});
this.incrementUnreadMsgCounter(msg);
}
}
......@@ -945,12 +951,18 @@ converse.plugins.add('converse-chat', {
}
},
/**
* @async
* @private
* @method _converse.ChatBox#createMessage
*/
createMessage (attrs, options) {
return this.messages.create(attrs, Object.assign({'wait': true, 'promise':true}, options));
},
/**
* Responsible for sending off a text message inside an ongoing chat conversation.
* @private
* @method _converse.ChatBox#sendMessage
* @memberOf _converse.ChatBox
* @param { String } text - The chat message text
......@@ -1073,7 +1085,6 @@ converse.plugins.add('converse-chat', {
},
maybeShow () {
// Returns the chatbox
return this.trigger("show");
},
......
......@@ -1691,6 +1691,9 @@ window.converse = window.converse || {};
* @namespace converse
*/
Object.assign(window.converse, {
CHAT_STATES: ['active', 'composing', 'gone', 'inactive', 'paused'],
keycodes: {
TAB: 9,
ENTER: 13,
......
......@@ -354,6 +354,7 @@ converse.plugins.add('converse-muc', {
this.debouncedRejoin = debounce(this.rejoin, 250);
this.set('box_id', `box-${btoa(this.get('jid'))}`);
this.initMessages();
this.initCSN();
this.initOccupants();
this.initDiscoModels(); // sendChatState depends on this.features
this.registerHandlers();
......@@ -1818,6 +1819,36 @@ converse.plugins.add('converse-muc', {
}
},
removeCSNFor (actor, state) {
const actors_per_state = this.csn.toJSON();
const existing_actors = Array.from(actors_per_state[state]) || [];
if (existing_actors.includes(actor)) {
const idx = existing_actors.indexOf(actor);
existing_actors.splice(idx, 1);
this.csn.set(state, Array.from(existing_actors));
}
},
updateCSN (attrs) {
const actor = attrs.nick;
const state = attrs.chat_state;
const actors_per_state = this.csn.toJSON();
const existing_actors = actors_per_state[state] || [];
if (existing_actors.includes(actor)) {
return;
}
const new_actors_per_state = converse.CHAT_STATES.reduce((out, s) => {
if (s === state) {
out[s] = [...existing_actors, actor];
} else {
out[s] = (actors_per_state[s] || []).filter(a => a !== actor);
}
return out;
}, {});
this.csn.set(new_actors_per_state);
window.setTimeout(() => this.removeCSNFor(actor, state), 10000);
},
/**
* Handler for all MUC messages sent to this groupchat. This method
* shouldn't be called directly, instead {@link _converse.ChatRoom#queueMessage}
......@@ -1865,7 +1896,9 @@ converse.plugins.add('converse-muc', {
}
this.setEditable(attrs, attrs.time);
if (u.shouldCreateGroupchatMessage(attrs)) {
if (attrs['chat_state']) {
this.updateCSN(attrs);
} else if (u.shouldCreateGroupchatMessage(attrs)) {
const msg = this.handleCorrection(attrs) || await this.createMessage(attrs);
this.incrementUnreadMsgCounter(msg);
}
......
......@@ -124,8 +124,7 @@ u.isNewMessage = function (message) {
};
u.shouldCreateMessage = function (attrs) {
return attrs['chat_state'] ||
attrs['retracted'] || // Retraction received *before* the message
return attrs['retracted'] || // Retraction received *before* the message
!u.isEmptyMessage(attrs);
}
......
<div class="chat-area col">
<div class="chat-content {[ if (o.show_send_button) { ]}chat-content-sendbutton{[ } ]}" aria-live="polite">
{[ if (o.muc_show_logs_before_join) { ]}
<div class="empty-history-feedback"><span>{{{o.__('No message history available.')}}}</span></div>
{[ } ]}
</div>
<div class="bottom-panel"></div>
</div>
......@@ -4,9 +4,8 @@ export default (o) => html`
<div class="flyout box-flyout">
<div class="chat-head chat-head-chatbox row no-gutters"></div>
<div class="chat-body">
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }"
@scroll=${o.markScrolled}
aria-live="polite"></div>
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" @scroll=${o.markScrolled} aria-live="polite"></div>
<div class="chat-state-notifications"></div>
<div class="bottom-panel">
<div class="emoji-picker__container dropup"></div>
<div class="message-form-container">
......
import { html } from "lit-html";
import { __ } from '@converse/headless/i18n';
const i18n_no_history = __('No message history available.');
export default () => html`
export default (o) => html`
<div class="flyout box-flyout">
<div class="chat-head chat-head-chatroom row no-gutters"></div>
<div class="chat-body chatroom-body row no-gutters">
<div class="chat-area col">
<div class="chat-content ${ o.show_send_button ? 'chat-content-sendbutton' : '' }" aria-live="polite">
${ o.muc_show_logs_before_join ? html`<div class="empty-history-feedback"><span>${ i18n_no_history }</span></div>` : '' }
</div>
<div class="chat-state-notifications"></div>
<div class="bottom-panel"></div>
</div>
<div class="disconnect-container hidden"></div>
</div>
</div>
......
<div class="message chat-info chat-state-notification"
data-isodate="{{{o.isodate}}}"
data-csn="{{{o.from}}}">{{{o.message}}}</div>
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