Declarative scrolling and rendering new messages indicator

- Increment `num_unread` when new messages appear while scrolled up
- Set scrolling state in model code (as opposed to view)
This commit is contained in:
JC Brand 2021-06-03 12:10:30 +02:00
parent ec93e2fff3
commit fe3e63d8c5
13 changed files with 91 additions and 113 deletions

View File

@ -1036,7 +1036,13 @@ const ChatBox = ModelWithContact.extend({
return return
} }
if (u.isNewMessage(message)) { if (u.isNewMessage(message)) {
if (this.isHidden()) { if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// when the user writes a message as opposed to when a
// message is received.
this.model.set('scrolled', false);
} else if (this.isHidden() || this.get('scrolled')) {
const settings = { const settings = {
'num_unread': this.get('num_unread') + 1 'num_unread': this.get('num_unread') + 1
}; };

View File

@ -2,7 +2,7 @@ import tpl_chatbox_message_form from './templates/chatbox_message_form.js';
import tpl_toolbar from './templates/toolbar.js'; import tpl_toolbar from './templates/toolbar.js';
import { ElementView } from '@converse/skeletor/src/element.js'; import { ElementView } from '@converse/skeletor/src/element.js';
import { __ } from 'i18n'; import { __ } from 'i18n';
import { _converse, api, converse } from "@converse/headless/core"; import { _converse, api, converse } from '@converse/headless/core';
import { html, render } from 'lit'; import { html, render } from 'lit';
import { clearMessages, parseMessageForCommands } from './utils.js'; import { clearMessages, parseMessageForCommands } from './utils.js';
@ -11,20 +11,24 @@ import './styles/chat-bottom-panel.scss';
const { u } = converse.env; const { u } = converse.env;
export default class ChatBottomPanel extends ElementView { export default class ChatBottomPanel extends ElementView {
events = { events = {
'click .send-button': 'onFormSubmitted', 'click .send-button': 'onFormSubmitted',
'click .toggle-clear': 'clearMessages', 'click .toggle-clear': 'clearMessages'
} };
async connectedCallback () { async connectedCallback () {
super.connectedCallback(); super.connectedCallback();
this.model = _converse.chatboxes.get(this.getAttribute('jid')); this.model = _converse.chatboxes.get(this.getAttribute('jid'));
this.listenTo(this.model, 'change:composing_spoiler', this.renderMessageForm); this.listenTo(this.model, 'change', o => this.onModelChanged(o.changed));
await this.model.initialized; await this.model.initialized;
this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting); this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
this.render(); this.render();
api.listen.on('chatBoxScrolledDown', () => this.hideNewMessagesIndicator()); }
onModelChanged (changed) {
if ('composing_spoiler' in changed || 'num_unread' in changed || 'scrolled' in changed) {
this.renderMessageForm();
}
} }
render () { render () {
@ -36,7 +40,8 @@ export default class ChatBottomPanel extends ElementView {
if (!api.settings.get('show_toolbar')) { if (!api.settings.get('show_toolbar')) {
return this; return this;
} }
const options = Object.assign({ const options = Object.assign(
{
'model': this.model, 'model': this.model,
'chatview': _converse.chatboxviews.get(this.getAttribute('jid')) 'chatview': _converse.chatboxviews.get(this.getAttribute('jid'))
}, },
@ -62,17 +67,12 @@ export default class ChatBottomPanel extends ElementView {
'onDrop': ev => this.onDrop(ev), 'onDrop': ev => this.onDrop(ev),
'hint_value': this.querySelector('.spoiler-hint')?.value, 'hint_value': this.querySelector('.spoiler-hint')?.value,
'inputChanged': ev => this.inputChanged(ev), 'inputChanged': ev => this.inputChanged(ev),
'label_message': this.model.get('composing_spoiler') ? __('Hidden message') : __('Message'),
'label_spoiler_hint': __('Optional hint'),
'message_value': this.querySelector('.chat-textarea')?.value, 'message_value': this.querySelector('.chat-textarea')?.value,
'onChange': ev => this.updateCharCounter(ev.target.value), 'onChange': ev => this.updateCharCounter(ev.target.value),
'onKeyDown': ev => this.onKeyDown(ev), 'onKeyDown': ev => this.onKeyDown(ev),
'onKeyUp': ev => this.onKeyUp(ev), 'onKeyUp': ev => this.onKeyUp(ev),
'onPaste': ev => this.onPaste(ev), 'onPaste': ev => this.onPaste(ev),
'show_send_button': api.settings.get('show_send_button'), 'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
'show_toolbar': api.settings.get('show_toolbar'),
'unread_msgs': __('You have unread messages'),
'viewUnreadMessages': ev => this.viewUnreadMessages(ev),
}) })
), ),
form_container form_container
@ -85,11 +85,6 @@ export default class ChatBottomPanel extends ElementView {
viewUnreadMessages (ev) { viewUnreadMessages (ev) {
ev?.preventDefault?.(); ev?.preventDefault?.();
this.model.save({ 'scrolled': false, 'scrollTop': null }); this.model.save({ 'scrolled': false, 'scrollTop': null });
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
}
hideNewMessagesIndicator () {
this.querySelector('.new-msgs-indicator')?.classList.add('hidden');
} }
onMessageCorrecting (message) { onMessageCorrecting (message) {
@ -244,7 +239,7 @@ export default class ChatBottomPanel extends ElementView {
} }
const ev = document.createEvent('HTMLEvents'); const ev = document.createEvent('HTMLEvents');
ev.initEvent('change', false, true); ev.initEvent('change', false, true);
textarea.dispatchEvent(ev) textarea.dispatchEvent(ev);
u.placeCaretAtEnd(textarea); u.placeCaretAtEnd(textarea);
} }

View File

@ -1,31 +1,39 @@
import { __ } from 'i18n';
import { api } from "@converse/headless/core";
import { html } from "lit"; import { html } from "lit";
export default (o) => html` export default (o) => {
<div class="new-msgs-indicator hidden" @click=${ev => o.viewUnreadMessages(ev)}> ${ o.unread_msgs } </div> const unread_msgs = __('You have unread messages');
<form class="setNicknameButtonForm hidden"> const label_message = o.composing_spoiler ? __('Hidden message') : __('Message');
<input type="submit" class="btn btn-primary" name="join" value="Join"/> const label_spoiler_hint = __('Optional hint');
</form> const show_send_button = api.settings.get('show_send_button');
<form class="sendXMPPMessage">
<span class="chat-toolbar no-text-select"></span>
<input type="text" placeholder="${o.label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
<div class="suggestion-box"> return html`
<ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul> ${ (o.scrolled && o.num_unread) ? html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
<textarea <form class="setNicknameButtonForm hidden">
autofocus <input type="submit" class="btn btn-primary" name="join" value="Join"/>
type="text" </form>
@drop=${o.onDrop} <form class="sendXMPPMessage">
@input=${o.inputChanged} <span class="chat-toolbar no-text-select"></span>
@keydown=${o.onKeyDown} <input type="text" placeholder="${label_spoiler_hint || ''}" value="${o.hint_value || ''}" class="${o.composing_spoiler ? '' : 'hidden'} spoiler-hint"/>
@keyup=${o.onKeyUp}
@paste=${o.onPaste} <div class="suggestion-box">
@change=${o.onChange} <ul class="suggestion-box__results suggestion-box__results--above" hidden=""></ul>
class="chat-textarea suggestion-box__input <textarea
${ o.show_send_button ? 'chat-textarea-send-button' : '' } autofocus
${ o.composing_spoile ? 'spoiler' : '' }" type="text"
placeholder="${o.label_message}">${ o.message_value || '' }</textarea> @drop=${o.onDrop}
<span class="suggestion-box__additions visually-hidden" role="status" aria-live="assertive" aria-relevant="additions"></span> @input=${o.inputChanged}
</div> @keydown=${o.onKeyDown}
</form> @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>`;
}

View File

@ -32,7 +32,6 @@ export default class ChatView extends BaseChatView {
this.render(); this.render();
// Need to be registered after render has been called. // Need to be registered after render has been called.
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages); this.listenTo(this.model, 'change:show_help_messages', this.renderHelpMessages);
await this.model.messages.fetched; await this.model.messages.fetched;

View File

@ -78,7 +78,6 @@ export default class MUCBottomPanel extends BottomPanel {
ev?.preventDefault?.(); ev?.preventDefault?.();
ev?.stopPropagation?.(); ev?.stopPropagation?.();
this.model.save({ 'hidden_occupants': true }); this.model.save({ 'hidden_occupants': true });
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown();
} }
onKeyDown (ev) { onKeyDown (ev) {

View File

@ -29,7 +29,6 @@ export default class MUCView extends BaseChatView {
await this.render(); await this.render();
// Need to be registered after render has been called. // Need to be registered after render has been called.
this.listenTo(this.model.messages, 'add', this.onMessageAdded);
this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification); this.listenTo(this.model.occupants, 'change:show', this.showJoinOrLeaveNotification);
this.updateAfterTransition(); this.updateAfterTransition();

View File

@ -39,8 +39,6 @@ export default class MUCSidebar extends CustomElement {
ev?.preventDefault?.(); ev?.preventDefault?.();
ev?.stopPropagation?.(); ev?.stopPropagation?.();
u.safeSave(this.model, { 'hidden_occupants': true }); u.safeSave(this.model, { 'hidden_occupants': true });
// FIXME: do this declaratively
_converse.chatboxviews.get(this.jid)?.scrollDown();
} }
onOccupantClicked (ev) { onOccupantClicked (ev) {

View File

@ -1934,7 +1934,6 @@ describe("Groupchats", function () {
const message = 'This message is received while the chat area is scrolled up'; const message = 'This message is received while the chat area is scrolled up';
await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo'); await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
const view = _converse.chatboxviews.get('lounge@montague.lit'); const view = _converse.chatboxviews.get('lounge@montague.lit');
spyOn(view, 'scrollDown').and.callThrough();
// Create enough messages so that there's a scrollbar. // Create enough messages so that there's a scrollbar.
const promises = []; const promises = [];
for (let i=0; i<20; i++) { for (let i=0; i<20; i++) {

View File

@ -9,7 +9,6 @@ export default class BaseChatView extends ElementView {
initDebounced () { initDebounced () {
this.markScrolled = debounce(this._markScrolled, 100); this.markScrolled = debounce(this._markScrolled, 100);
this.debouncedScrollDown = debounce(this.scrollDown, 100);
} }
disconnectedCallback () { disconnectedCallback () {
@ -18,13 +17,6 @@ export default class BaseChatView extends ElementView {
_converse.chatboxviews.remove(jid, this); _converse.chatboxviews.remove(jid, this);
} }
hideNewMessagesIndicator () {
const new_msgs_indicator = this.querySelector('.new-msgs-indicator');
if (new_msgs_indicator !== null) {
new_msgs_indicator.classList.add('hidden');
}
}
maybeFocus () { maybeFocus () {
api.settings.get('auto_focus') && this.focus(); api.settings.get('auto_focus') && this.focus();
} }
@ -77,20 +69,6 @@ export default class BaseChatView extends ElementView {
api.trigger('chatBoxFocused', this, ev); api.trigger('chatBoxFocused', this, ev);
} }
/**
* Scroll to the previously saved scrollTop position, or scroll
* down if it wasn't set.
*/
maintainScrollTop () {
const pos = this.model.get('scrollTop');
if (pos) {
const msgs_container = this.querySelector('.chat-content__messages');
msgs_container.scrollTop = pos;
} else {
this.scrollDown();
}
}
onStatusMessageChanged (item) { onStatusMessageChanged (item) {
this.renderHeading(); this.renderHeading();
/** /**
@ -107,24 +85,6 @@ export default class BaseChatView extends ElementView {
}); });
} }
showNewMessagesIndicator () {
u.showElement(this.querySelector('.new-msgs-indicator'));
}
onMessageAdded (message) {
if (u.isNewMessage(message)) {
if (message.get('sender') === 'me') {
// We remove the "scrolled" flag so that the chat area
// gets scrolled down. We always want to scroll down
// 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)) {
this.showNewMessagesIndicator();
}
}
}
getBottomPanel () { getBottomPanel () {
if (this.model.get('type') === _converse.CHATROOMS_TYPE) { if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
return this.querySelector('converse-muc-bottom-panel'); return this.querySelector('converse-muc-bottom-panel');
@ -183,12 +143,10 @@ export default class BaseChatView extends ElementView {
'scrollTop': null 'scrollTop': null
}); });
} }
this.querySelector('.chat-content__messages')?.scrollDown();
this.onScrolledDown(); this.onScrolledDown();
} }
onScrolledDown () { onScrolledDown () {
this.hideNewMessagesIndicator();
if (!this.model.isHidden()) { if (!this.model.isHidden()) {
this.model.clearUnreadMsgCounter(); this.model.clearUnreadMsgCounter();
if (api.settings.get('allow_url_history_change')) { if (api.settings.get('allow_url_history_change')) {
@ -197,14 +155,6 @@ export default class BaseChatView extends ElementView {
hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate(); hash && this.model.messages.get(hash.slice(1)) && _converse.router.history.navigate();
} }
} }
/**
* Triggered once the chat's message area has been scrolled down to the bottom.
* @event _converse#chatBoxScrolledDown
* @type {object}
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
* @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
*/
api.trigger('chatBoxScrolledDown', { 'chatbox': this.model }); // TODO: clean up
} }
onWindowStateChanged (data) { onWindowStateChanged (data) {

View File

@ -1,7 +1,7 @@
import "./message-history"; import './message-history';
import debounce from 'lodash-es/debounce'; import debounce from 'lodash/debounce';
import { CustomElement } from 'shared/components/element.js'; import { CustomElement } from 'shared/components/element.js';
import { _converse, api } from "@converse/headless/core"; import { _converse, api } from '@converse/headless/core';
import { html } from 'lit'; import { html } from 'lit';
export default class ChatContent extends CustomElement { export default class ChatContent extends CustomElement {
@ -14,14 +14,19 @@ export default class ChatContent extends CustomElement {
connectedCallback () { connectedCallback () {
super.connectedCallback(); super.connectedCallback();
this.debouncedScrolldown = debounce(this.scrollDown, 100); this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100);
this.model = _converse.chatboxes.get(this.jid); this.model = _converse.chatboxes.get(this.jid);
this.listenTo(this.model, 'change:hidden_occupants', this.requestUpdate);
this.listenTo(this.model, 'change:scrolled', this.requestUpdate);
this.listenTo(this.model.messages, 'add', this.requestUpdate); this.listenTo(this.model.messages, 'add', this.requestUpdate);
this.listenTo(this.model.messages, 'change', this.requestUpdate); this.listenTo(this.model.messages, 'change', this.requestUpdate);
this.listenTo(this.model.messages, 'remove', this.requestUpdate); this.listenTo(this.model.messages, 'remove', this.requestUpdate);
this.listenTo(this.model.messages, 'rendered', this.requestUpdate);
this.listenTo(this.model.messages, 'reset', this.requestUpdate); this.listenTo(this.model.messages, 'reset', this.requestUpdate);
this.listenTo(this.model.notifications, 'change', this.requestUpdate); this.listenTo(this.model.notifications, 'change', this.requestUpdate);
this.listenTo(this.model.ui, 'change', this.requestUpdate); this.listenTo(this.model.ui, 'change', this.requestUpdate);
if (this.model.occupants) { if (this.model.occupants) {
this.listenTo(this.model.occupants, 'change', this.requestUpdate); this.listenTo(this.model.occupants, 'change', this.requestUpdate);
} }
@ -31,7 +36,7 @@ export default class ChatContent extends CustomElement {
// didn't initiate the scrolling. // didn't initiate the scrolling.
this.was_scrolled_up = this.model.get('scrolled'); this.was_scrolled_up = this.model.get('scrolled');
this.addEventListener('imageLoaded', () => { this.addEventListener('imageLoaded', () => {
!this.was_scrolled_up && this.scrollDown(); this.debouncedMaintainScroll(this.was_scrolled_up);
}); });
} }
@ -47,7 +52,19 @@ export default class ChatContent extends CustomElement {
} }
updated () { updated () {
!this.model.get('scrolled') && this.debouncedScrolldown(); this.was_scrolled_up = this.model.get('scrolled');
this.debouncedMaintainScroll();
}
maintainScrollPosition () {
if (this.was_scrolled_up) {
const pos = this.model.get('scrollTop');
if (pos) {
this.scrollTop = pos;
}
} else {
this.scrollDown();
}
} }
scrollDown () { scrollDown () {
@ -57,6 +74,14 @@ export default class ChatContent extends CustomElement {
} else { } else {
this.scrollTop = this.scrollHeight; this.scrollTop = this.scrollHeight;
} }
/**
* Triggered once the converse-chat-content element has been scrolled down to the bottom.
* @event _converse#chatBoxScrolledDown
* @type {object}
* @property { _converse.ChatBox | _converse.ChatRoom } chatbox - The chat model
* @example _converse.api.listen.on('chatBoxScrolledDown', obj => { ... });
*/
api.trigger('chatBoxScrolledDown', { 'chatbox': this.model });
} }
} }

View File

@ -1,5 +1,5 @@
import { CustomElement } from 'shared/components/element.js'; import { CustomElement } from 'shared/components/element.js';
import { _converse, api } from "@converse/headless/core"; import { api } from "@converse/headless/core";
import tpl_unfurl from './templates/unfurl.js'; import tpl_unfurl from './templates/unfurl.js';
import './styles/unfurl.scss'; import './styles/unfurl.scss';
@ -29,7 +29,7 @@ export default class MessageUnfurl extends CustomElement {
} }
onImageLoad () { onImageLoad () {
_converse.chatboxviews.get(this.getAttribute('jid'))?.scrollDown(); this.dispatchEvent(new CustomEvent('imageLoaded', { detail: this, 'bubbles': true }));
} }
} }

View File

@ -28,7 +28,7 @@ class RichTextRenderer {
class RichTextDirective extends Directive { class RichTextDirective extends Directive {
render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this
const renderer = new RichTextRenderer(text, offset, mentions, options); const renderer = new RichTextRenderer(text, offset, mentions, options);
const result =renderer.render(); const result = renderer.render();
callback?.(); callback?.();
return result; return result;
} }

View File

@ -30,7 +30,7 @@
modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'], modtools_disable_assign: ['owner', 'moderator', 'participant', 'visitor'],
modtools_disable_query: ['moderator', 'participant', 'visitor'], modtools_disable_query: ['moderator', 'participant', 'visitor'],
enable_smacks: true, enable_smacks: true,
connection_options: { 'worker': '/dist/shared-connection-worker.js' }, // connection_options: { 'worker': '/dist/shared-connection-worker.js' },
persistent_store: 'IndexedDB', persistent_store: 'IndexedDB',
message_archiving: 'always', message_archiving: 'always',
muc_domain: 'conference.chat.example.org', muc_domain: 'conference.chat.example.org',