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
}
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 = {
'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 { ElementView } from '@converse/skeletor/src/element.js';
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 { clearMessages, parseMessageForCommands } from './utils.js';
@ -11,20 +11,24 @@ import './styles/chat-bottom-panel.scss';
const { u } = converse.env;
export default class ChatBottomPanel extends ElementView {
events = {
'click .send-button': 'onFormSubmitted',
'click .toggle-clear': 'clearMessages',
}
'click .toggle-clear': 'clearMessages'
};
async connectedCallback () {
super.connectedCallback();
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;
this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting);
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 () {
@ -36,7 +40,8 @@ export default class ChatBottomPanel extends ElementView {
if (!api.settings.get('show_toolbar')) {
return this;
}
const options = Object.assign({
const options = Object.assign(
{
'model': this.model,
'chatview': _converse.chatboxviews.get(this.getAttribute('jid'))
},
@ -62,17 +67,12 @@ export default class ChatBottomPanel extends ElementView {
'onDrop': ev => this.onDrop(ev),
'hint_value': this.querySelector('.spoiler-hint')?.value,
'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,
'onChange': ev => this.updateCharCounter(ev.target.value),
'onKeyDown': ev => this.onKeyDown(ev),
'onKeyUp': ev => this.onKeyUp(ev),
'onPaste': ev => this.onPaste(ev),
'show_send_button': api.settings.get('show_send_button'),
'show_toolbar': api.settings.get('show_toolbar'),
'unread_msgs': __('You have unread messages'),
'viewUnreadMessages': ev => this.viewUnreadMessages(ev),
'viewUnreadMessages': ev => this.viewUnreadMessages(ev)
})
),
form_container
@ -85,11 +85,6 @@ export default class ChatBottomPanel extends ElementView {
viewUnreadMessages (ev) {
ev?.preventDefault?.();
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) {
@ -244,7 +239,7 @@ export default class ChatBottomPanel extends ElementView {
}
const ev = document.createEvent('HTMLEvents');
ev.initEvent('change', false, true);
textarea.dispatchEvent(ev)
textarea.dispatchEvent(ev);
u.placeCaretAtEnd(textarea);
}

View File

@ -1,14 +1,22 @@
import { __ } from 'i18n';
import { api } from "@converse/headless/core";
import { html } from "lit";
export default (o) => html`
<div class="new-msgs-indicator hidden" @click=${ev => o.viewUnreadMessages(ev)}> ${ o.unread_msgs } </div>
export default (o) => {
const unread_msgs = __('You have unread messages');
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`
${ (o.scrolled && o.num_unread) ? html`<div class="new-msgs-indicator" @click=${ev => o.viewUnreadMessages(ev)}>▼ ${ unread_msgs } ▼</div>` : '' }
<form class="setNicknameButtonForm hidden">
<input type="submit" class="btn btn-primary" name="join" value="Join"/>
</form>
<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"/>
<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>
@ -22,10 +30,10 @@ export default (o) => html`
@paste=${o.onPaste}
@change=${o.onChange}
class="chat-textarea suggestion-box__input
${ o.show_send_button ? 'chat-textarea-send-button' : '' }
${ o.composing_spoile ? 'spoiler' : '' }"
placeholder="${o.label_message}">${ o.message_value || '' }</textarea>
${ 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>
`;
</form>`;
}

View File

@ -32,7 +32,6 @@ export default class ChatView extends BaseChatView {
this.render();
// 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);
await this.model.messages.fetched;

View File

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

View File

@ -29,7 +29,6 @@ export default class MUCView extends BaseChatView {
await this.render();
// 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.updateAfterTransition();

View File

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

View File

@ -1934,7 +1934,6 @@ describe("Groupchats", function () {
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');
spyOn(view, 'scrollDown').and.callThrough();
// Create enough messages so that there's a scrollbar.
const promises = [];
for (let i=0; i<20; i++) {

View File

@ -9,7 +9,6 @@ export default class BaseChatView extends ElementView {
initDebounced () {
this.markScrolled = debounce(this._markScrolled, 100);
this.debouncedScrollDown = debounce(this.scrollDown, 100);
}
disconnectedCallback () {
@ -18,13 +17,6 @@ export default class BaseChatView extends ElementView {
_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 () {
api.settings.get('auto_focus') && this.focus();
}
@ -77,20 +69,6 @@ export default class BaseChatView extends ElementView {
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) {
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 () {
if (this.model.get('type') === _converse.CHATROOMS_TYPE) {
return this.querySelector('converse-muc-bottom-panel');
@ -183,12 +143,10 @@ export default class BaseChatView extends ElementView {
'scrollTop': null
});
}
this.querySelector('.chat-content__messages')?.scrollDown();
this.onScrolledDown();
}
onScrolledDown () {
this.hideNewMessagesIndicator();
if (!this.model.isHidden()) {
this.model.clearUnreadMsgCounter();
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();
}
}
/**
* 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) {

View File

@ -1,7 +1,7 @@
import "./message-history";
import debounce from 'lodash-es/debounce';
import './message-history';
import debounce from 'lodash/debounce';
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';
export default class ChatContent extends CustomElement {
@ -14,14 +14,19 @@ export default class ChatContent extends CustomElement {
connectedCallback () {
super.connectedCallback();
this.debouncedScrolldown = debounce(this.scrollDown, 100);
this.debouncedMaintainScroll = debounce(this.maintainScrollPosition, 100);
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, 'change', 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.notifications, 'change', this.requestUpdate);
this.listenTo(this.model.ui, 'change', this.requestUpdate);
if (this.model.occupants) {
this.listenTo(this.model.occupants, 'change', this.requestUpdate);
}
@ -31,7 +36,7 @@ export default class ChatContent extends CustomElement {
// didn't initiate the scrolling.
this.was_scrolled_up = this.model.get('scrolled');
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 () {
!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 () {
@ -57,6 +74,14 @@ export default class ChatContent extends CustomElement {
} else {
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 { _converse, api } from "@converse/headless/core";
import { api } from "@converse/headless/core";
import tpl_unfurl from './templates/unfurl.js';
import './styles/unfurl.scss';
@ -29,7 +29,7 @@ export default class MessageUnfurl extends CustomElement {
}
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 {
render (text, offset, mentions, options, callback) { // eslint-disable-line class-methods-use-this
const renderer = new RichTextRenderer(text, offset, mentions, options);
const result =renderer.render();
const result = renderer.render();
callback?.();
return result;
}

View File

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