Add a placeholder to indicate a gap in the message history

The user can click the placeholder to fill in the gap.
This commit is contained in:
JC Brand 2021-06-15 12:17:02 +02:00
parent 14f0ed43c5
commit dc711d494f
24 changed files with 458 additions and 62 deletions

View File

@ -26,6 +26,7 @@
- Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html) - Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)
- Add a Description Of A Project (DOAP) file - Add a Description Of A Project (DOAP) file
- Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`. - Add ability to deregister nickname when closing a MUC by setting `auto_register_muc_nickname` to `'unregister'`.
- Show a gap placeholder when there are gaps in the chat history. The user can click these to fill the gaps.
### Breaking Changes ### Breaking Changes

View File

@ -234,4 +234,4 @@ doc: node_modules docsdev apidoc
PHONY: apidoc PHONY: apidoc
apidoc: apidoc:
$(JSDOC) --private --readme docs/source/jsdoc_intro.md -c docs/source/conf.json -d docs/html/api src/templates/**/*.js src/*.js src/**/*.js src/headless/**/*.js src/shared/**/*.js $(JSDOC) --private --readme docs/source/jsdoc_intro.md -c docs/source/conf.json -d docs/html/api src/templates/*.js src/*.js src/**/*.js src/headless/**/*.js src/shared/**/*.js

View File

@ -55,6 +55,7 @@ module.exports = function(config) {
{ pattern: "src/plugins/controlbox/tests/login.js", type: 'module' }, { pattern: "src/plugins/controlbox/tests/login.js", type: 'module' },
{ pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' }, { pattern: "src/plugins/headlines-view/tests/headline.js", type: 'module' },
{ pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' }, { pattern: "src/plugins/mam-views/tests/mam.js", type: 'module' },
{ pattern: "src/plugins/mam-views/tests/placeholder.js", type: 'module' },
{ pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' }, { pattern: "src/plugins/minimize/tests/minchats.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' }, { pattern: "src/plugins/muc-views/tests/autocomplete.js", type: 'module' },
{ pattern: "src/plugins/muc-views/tests/component.js", type: 'module' }, { pattern: "src/plugins/muc-views/tests/component.js", type: 'module' },

1
package-lock.json generated
View File

@ -2941,6 +2941,7 @@
"dev": true, "dev": true,
"requires": { "requires": {
"@converse/skeletor": "github:conversejs/skeletor#f354bc530493a17d031f6f9c524cc34e073908e3", "@converse/skeletor": "github:conversejs/skeletor#f354bc530493a17d031f6f9c524cc34e073908e3",
"dayjs": "1.10.4",
"filesize": "^6.1.0", "filesize": "^6.1.0",
"localforage": "^1.9.0", "localforage": "^1.9.0",
"localforage-driver-memory": "^1.0.5", "localforage-driver-memory": "^1.0.5",

View File

@ -1,4 +1,5 @@
import ModelWithContact from './model-with-contact.js'; import ModelWithContact from './model-with-contact.js';
import dayjs from 'dayjs';
import log from '../../log.js'; import log from '../../log.js';
import { _converse, api, converse } from '../../core.js'; import { _converse, api, converse } from '../../core.js';
import { getOpenPromise } from '@converse/openpromise'; import { getOpenPromise } from '@converse/openpromise';
@ -102,10 +103,52 @@ const MessageMixin = {
} }
}, },
/**
* Returns a boolean indicating whether this message is ephemeral,
* meaning it will get automatically removed after ten seconds.
* @returns { boolean }
*/
isEphemeral () { isEphemeral () {
return this.get('is_ephemeral'); return this.get('is_ephemeral');
}, },
/**
* Returns a boolean indicating whether this message is a XEP-0245 /me command.
* @returns { boolean }
*/
isMeCommand () {
const text = this.getMessageText();
if (!text) {
return false;
}
return text.startsWith('/me ');
},
/**
* Returns a boolean indicating whether this message is considered a followup
* message from the previous one. Followup messages are shown grouped together
* under one author heading.
* A message is considered a followup of it's predecessor when it's a chat
* message from the same author, within 10 minutes.
* @returns { boolean }
*/
isFollowup () {
const messages = this.collection.models;
const idx = messages.indexOf(this);
const prev_model = idx ? messages[idx-1] : null;
if (prev_model === null) {
return false;
}
const date = dayjs(this.get('time'));
return this.get('from') === prev_model.get('from') &&
!this.isMeCommand() &&
!prev_model.isMeCommand() &&
this.get('type') !== 'info' &&
prev_model.get('type') !== 'info' &&
date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
!!this.get('is_encrypted') === !!prev_model.get('is_encrypted');
},
getDisplayName () { getDisplayName () {
if (this.get('type') === 'groupchat') { if (this.get('type') === 'groupchat') {
return this.get('nick'); return this.get('nick');
@ -126,14 +169,6 @@ const MessageMixin = {
return this.get('message'); return this.get('message');
}, },
isMeCommand () {
const text = this.getMessageText();
if (!text) {
return false;
}
return text.startsWith('/me ');
},
/** /**
* Send out an IQ stanza to request a file upload slot. * Send out an IQ stanza to request a file upload slot.
* https://xmpp.org/extensions/xep-0363.html#request * https://xmpp.org/extensions/xep-0363.html#request

View File

@ -3,8 +3,9 @@
* @copyright 2020, the Converse.js contributors * @copyright 2020, the Converse.js contributors
* @license Mozilla Public License (MPLv2) * @license Mozilla Public License (MPLv2)
*/ */
import mam_api from './api.js';
import '../disco/index.js'; import '../disco/index.js';
import MAMPlaceholderMessage from './placeholder.js';
import mam_api from './api.js';
import { import {
onMAMError, onMAMError,
onMAMPreferences, onMAMPreferences,
@ -31,7 +32,7 @@ converse.plugins.add('converse-mam', {
Object.assign(api, mam_api); Object.assign(api, mam_api);
// This is mainly done to aid with tests // This is mainly done to aid with tests
Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult }); Object.assign(_converse, { onMAMError, onMAMPreferences, handleMAMResult, MAMPlaceholderMessage });
/************************ Event Handlers ************************/ /************************ Event Handlers ************************/
api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM)); api.listen.on('addClientFeatures', () => api.disco.own.features.add(NS.MAM));

View File

@ -0,0 +1,14 @@
import { Model } from '@converse/skeletor/src/model.js';
import { converse } from '../../core.js';
const u = converse.env.utils;
export default class MAMPlaceholderMessage extends Model {
defaults () { // eslint-disable-line class-methods-use-this
return {
'msgid': u.getUniqueId(),
'is_ephemeral': false
};
}
}

View File

@ -1,8 +1,9 @@
import MAMPlaceholderMessage from './placeholder.js';
import log from '@converse/headless/log'; import log from '@converse/headless/log';
import sizzle from 'sizzle'; import sizzle from 'sizzle';
import { _converse, api, converse } from '@converse/headless/core';
import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers'; import { parseMUCMessage } from '@converse/headless/plugins/muc/parsers';
import { parseMessage } from '@converse/headless/plugins/chat/parsers'; import { parseMessage } from '@converse/headless/plugins/chat/parsers';
import { _converse, api, converse } from '@converse/headless/core';
const { Strophe, $iq } = converse.env; const { Strophe, $iq } = converse.env;
const { NS } = Strophe; const { NS } = Strophe;
@ -97,8 +98,8 @@ export async function handleMAMResult (model, result, query, options, should_pag
} }
/** /**
* Fetch XEP-0313 archived messages based on the passed in criteria. * @typedef { Object } MAMOptions
* @param { Object } options * A map of MAM related options that may be passed to fetchArchivedMessages
* @param { integer } [options.max] - The maximum number of items to return. * @param { integer } [options.max] - The maximum number of items to return.
* Defaults to "archived_messages_page_size" * Defaults to "archived_messages_page_size"
* @param { string } [options.after] - The XEP-0359 stanza ID of a message * @param { string } [options.after] - The XEP-0359 stanza ID of a message
@ -112,10 +113,17 @@ export async function handleMAMResult (model, result, query, options, should_pag
* @param { string } [options.with] - The JID of the entity with * @param { string } [options.with] - The JID of the entity with
* which messages were exchanged. * which messages were exchanged.
* @param { boolean } [options.groupchat] - True if archive in groupchat. * @param { boolean } [options.groupchat] - True if archive in groupchat.
* @param { ('forwards'|'backwards'|null)} [should_page=null] - Determines whether this function should
* recursively page through the entire result set if a limited number of results were returned.
*/ */
export async function fetchArchivedMessages (model, options = {}, should_page=null) {
/**
* Fetch XEP-0313 archived messages based on the passed in criteria.
* @param { _converse.ChatBox | _converse.ChatRoom } model
* @param { MAMOptions } [options]
* @param { ('forwards'|'backwards'|null)} [should_page=null] - Determines whether
* this function should recursively page through the entire result set if a limited
* number of results were returned.
*/
export async function fetchArchivedMessages (model, options = {}, should_page = null) {
if (model.disable_mam) { if (model.disable_mam) {
return; return;
} }
@ -146,16 +154,49 @@ export async function fetchArchivedMessages (model, options = {}, should_page=nu
} }
return fetchArchivedMessages(model, options, should_page); return fetchArchivedMessages(model, options, should_page);
} else { } else {
// TODO: Add a special kind of message which will createPlaceholder(model, options, result);
// render as a link to fetch further messages, either
// to fetch older messages or to fill in a gap.
} }
} }
} }
/**
* Create a placeholder message which is used to indicate gaps in the history.
* @param { _converse.ChatBox | _converse.ChatRoom } model
* @param { MAMOptions } options
* @param { object } result - The RSM result object
*/
async function createPlaceholder (model, options, result) {
if (options.before == '' && (model.messages.length === 0 || !options.start)) {
// Fetching the latest MAM messages with an empty local cache
return;
}
if (options.before && !options.start) {
// Infinite scrolling upward
return;
}
if (options.before == null) { // eslint-disable-line no-eq-null
// Adding placeholders when paging forwards is not supported yet,
// since currently with standard Converse, we only page forwards
// when fetching the entire history (i.e. no gaps should arise).
return;
}
const msgs = await Promise.all(result.messages);
const { rsm } = result;
const key = `stanza_id ${model.get('jid')}`;
const adjacent_message = msgs.find(m => m[key] === rsm.result.first);
const msg_data = {
'template_hook': 'getMessageTemplate',
'time': new Date(new Date(adjacent_message['time']) - 1).toISOString(),
'before': rsm.result.first,
'start': options.start
}
model.messages.add(new MAMPlaceholderMessage(msg_data));
}
/** /**
* Fetches messages that might have been archived *after* * Fetches messages that might have been archived *after*
* the last archived message in our local cache. * the last archived message in our local cache.
* @param { _converse.ChatBox | _converse.ChatRoom }
*/ */
export function fetchNewestMessages (model) { export function fetchNewestMessages (model) {
if (model.disable_mam) { if (model.disable_mam) {

View File

@ -386,7 +386,7 @@ describe("XEP-0363: HTTP File Upload", function () {
const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit'; const contact_jid = mock.cur_names[2].replace(/ /g,'.').toLowerCase() + '@montague.lit';
await mock.openChatBoxFor(_converse, contact_jid); await mock.openChatBoxFor(_converse, contact_jid);
const view = _converse.chatboxviews.get(contact_jid); const view = _converse.chatboxviews.get(contact_jid);
var file = { const file = {
'type': 'image/jpeg', 'type': 'image/jpeg',
'size': '5242881', 'size': '5242881',
'lastModifiedDate': "", 'lastModifiedDate': "",

View File

@ -741,7 +741,6 @@ describe("A Chat Message", function () {
expect(u.hasClass('chat-msg--followup', view.querySelector(nth_child(2)))).toBe(false); 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(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(u.hasClass('chat-msg--followup', view.querySelector(nth_child(3)))).toBe(true);
expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe( expect(view.querySelector(`${nth_child(3)} .chat-msg__text`).textContent).toBe(
"Another message 3 minutes later"); "Another message 3 minutes later");
@ -1188,7 +1187,6 @@ describe("A Chat Message", function () {
})); }));
}); });
it("will cause the chat area to be scrolled down only if it was at the bottom originally", it("will cause the chat area to be scrolled down only if it was at the bottom originally",
mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) { mock.initConverse(['chatBoxesFetched'], {}, async function (done, _converse) {

View File

@ -3,8 +3,9 @@
* @copyright 2021, the Converse.js contributors * @copyright 2021, the Converse.js contributors
* @license Mozilla Public License (MPLv2) * @license Mozilla Public License (MPLv2)
*/ */
import './placeholder.js';
import { api, converse } from '@converse/headless/core'; import { api, converse } from '@converse/headless/core';
import { fetchMessagesOnScrollUp } from './utils.js'; import { fetchMessagesOnScrollUp, getPlaceholderTemplate } from './utils.js';
converse.plugins.add('converse-mam-views', { converse.plugins.add('converse-mam-views', {
@ -12,5 +13,6 @@ converse.plugins.add('converse-mam-views', {
initialize () { initialize () {
api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp); api.listen.on('chatBoxScrolledUp', fetchMessagesOnScrollUp);
api.listen.on('getMessageTemplate', getPlaceholderTemplate);
} }
}); });

View File

@ -0,0 +1,33 @@
import { CustomElement } from 'shared/components/element.js';
import tpl_placeholder 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 tpl_placeholder(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);

View File

@ -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;
}
}
}

View File

@ -0,0 +1,10 @@
import tpl_spinner from 'templates/spinner.js';
import { __ } from 'i18n';
import { html } from 'lit-html';
export default (el) => {
return el.model.get('fetching') ? tpl_spinner({'classes': 'hor_centered'}) :
html`<a @click="${(ev) => el.fetchMissingMessages(ev)}" title="${__('Click to load missing messages')}">
<div class="message mam-placeholder"></div>
</a>`;
}

View File

@ -18,7 +18,6 @@ describe("Message Archive Management", function () {
describe("The XEP-0313 Archive", function () { describe("The XEP-0313 Archive", function () {
it("is queried when the user scrolls up", it("is queried when the user scrolls up",
mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (done, _converse) { mock.initConverse(['discoInitialized'], {'archived_messages_page_size': 2}, async function (done, _converse) {
@ -920,6 +919,7 @@ describe("Message Archive Management", function () {
IQ_id = sendIQ.bind(this)(iq, callback, errback); IQ_id = sendIQ.bind(this)(iq, callback, errback);
}); });
const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'}); const promise = _converse.api.archive.query({'with': 'romeo@capulet.lit', 'max':'10'});
await u.waitUntil(() => sent_stanza); await u.waitUntil(() => sent_stanza);
const queryid = sent_stanza.querySelector('query').getAttribute('queryid'); const queryid = sent_stanza.querySelector('query').getAttribute('queryid');

View File

@ -0,0 +1,219 @@
/*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 (done, _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);
done();
}));
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 (done, _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);
done();
}));
});
});

View File

@ -1,5 +1,16 @@
import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils'; import MAMPlaceholderMessage from '@converse/headless/plugins/mam/placeholder.js';
import { _converse, api } from '@converse/headless/core'; import { _converse, api } from '@converse/headless/core';
import { fetchArchivedMessages } from '@converse/headless/plugins/mam/utils';
import { html } from 'lit-html';
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) { export async function fetchMessagesOnScrollUp (view) {
if (view.model.messages.length) { if (view.model.messages.length) {
@ -17,7 +28,6 @@ export async function fetchMessagesOnScrollUp (view) {
if (api.settings.get('allow_url_history_change')) { if (api.settings.get('allow_url_history_change')) {
_converse.router.history.navigate(`#${oldest_message.get('msgid')}`); _converse.router.history.navigate(`#${oldest_message.get('msgid')}`);
} }
setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250); setTimeout(() => view.model.ui.set('chat-content-spinner-top', false), 250);
} }
} }

View File

@ -4,6 +4,7 @@ import { api } from "@converse/headless/core";
import { getDayIndicator } from './utils.js'; import { getDayIndicator } from './utils.js';
import { html } from 'lit'; import { html } from 'lit';
import { repeat } from 'lit/directives/repeat.js'; import { repeat } from 'lit/directives/repeat.js';
import { until } from 'lit/directives/until.js';
export default class MessageHistory extends CustomElement { export default class MessageHistory extends CustomElement {
@ -17,20 +18,28 @@ export default class MessageHistory extends CustomElement {
render () { render () {
const msgs = this.messages; const msgs = this.messages;
return msgs.length ? html`${repeat(msgs, m => m.get('id'), m => this.renderMessage(m)) }` : ''; if (msgs.length) {
return repeat(msgs, m => m.get('id'), m => html`${this.renderMessage(m)}`)
} else {
return '';
}
} }
renderMessage (model) { renderMessage (model) {
if (model.get('dangling_retraction') || model.get('is_only_key')) { if (model.get('dangling_retraction') || model.get('is_only_key')) {
return ''; return '';
} }
const day = getDayIndicator(model); const template_hook = model.get('template_hook')
const templates = day ? [day] : []; if (typeof template_hook === 'string') {
const message = html`<converse-chat-message const template_promise = api.hook(template_hook, model, '');
jid="${this.model.get('jid')}" return until(template_promise, '');
mid="${model.get('id')}"></converse-chat-message>` } else {
const template = html`<converse-chat-message
return [...templates, message]; jid="${this.model.get('jid')}"
mid="${model.get('id')}"></converse-chat-message>`
const day = getDayIndicator(model);
return day ? [day, template] : template;
}
} }
} }

View File

@ -5,7 +5,6 @@ import 'shared/registry';
import MessageVersionsModal from 'modals/message-versions.js'; import MessageVersionsModal from 'modals/message-versions.js';
import OccupantModal from 'modals/occupant.js'; import OccupantModal from 'modals/occupant.js';
import UserDetailsModal from 'modals/user-details.js'; import UserDetailsModal from 'modals/user-details.js';
import dayjs from 'dayjs';
import filesize from 'filesize'; import filesize from 'filesize';
import tpl_message from './templates/message.js'; import tpl_message from './templates/message.js';
import tpl_spinner from 'templates/spinner.js'; import tpl_spinner from 'templates/spinner.js';
@ -16,7 +15,7 @@ import { getHats } from './utils.js';
import { html } from 'lit'; import { html } from 'lit';
import { renderAvatar } from 'shared/directives/avatar'; import { renderAvatar } from 'shared/directives/avatar';
const { Strophe } = converse.env; const { Strophe, dayjs } = converse.env;
const u = converse.env.utils; const u = converse.env.utils;
@ -137,23 +136,6 @@ export default class Message extends CustomElement {
this.parentElement.removeChild(this); this.parentElement.removeChild(this);
} }
isFollowup () {
const messages = this.model.collection.models;
const idx = messages.indexOf(this.model);
const prev_model = idx ? messages[idx-1] : null;
if (prev_model === null) {
return false;
}
const date = dayjs(this.model.get('time'));
return this.model.get('from') === prev_model.get('from') &&
!this.model.isMeCommand() &&
!prev_model.isMeCommand() &&
this.model.get('type') !== 'info' &&
prev_model.get('type') !== 'info' &&
date.isBefore(dayjs(prev_model.get('time')).add(10, 'minutes')) &&
!!this.model.get('is_encrypted') === !!prev_model.get('is_encrypted');
}
isRetracted () { isRetracted () {
return this.model.get('retracted') || this.model.get('moderated') === 'retracted'; return this.model.get('retracted') || this.model.get('moderated') === 'retracted';
} }
@ -173,7 +155,7 @@ export default class Message extends CustomElement {
getExtraMessageClasses () { getExtraMessageClasses () {
const extra_classes = [ const extra_classes = [
this.isFollowup() ? 'chat-msg--followup' : null, this.model.isFollowup() ? 'chat-msg--followup' : null,
this.model.get('is_delayed') ? 'delayed' : null, this.model.get('is_delayed') ? 'delayed' : null,
this.model.isMeCommand() ? 'chat-msg--action' : null, this.model.isMeCommand() ? 'chat-msg--action' : null,
this.isRetracted() ? 'chat-msg--retracted' : null, this.isRetracted() ? 'chat-msg--retracted' : null,

View File

@ -1,6 +1,7 @@
import { _converse, api } from '@converse/headless/core';
import dayjs from 'dayjs';
import tpl_new_day from "./templates/new-day.js"; import tpl_new_day from "./templates/new-day.js";
import { _converse, api, converse } from '@converse/headless/core';
const { dayjs } = converse.env;
export function onScrolledDown (model) { export function onScrolledDown (model) {
if (!model.isHidden()) { if (!model.isHidden()) {

View File

@ -1,5 +1,4 @@
/** /**
* @module icons.js
* @copyright Alfredo Medrano Sánchez and the Converse.js contributors * @copyright Alfredo Medrano Sánchez and the Converse.js contributors
* @description * @description
* Component inspired by the one from fa-icons * Component inspired by the one from fa-icons

View File

@ -366,6 +366,9 @@
} }
} }
.spinner__container {
width: 100%;
}
.spinner { .spinner {
animation: spin 2s infinite, linear; animation: spin 2s infinite, linear;
width: 1em; width: 1em;
@ -386,9 +389,8 @@
margin: auto; margin: auto;
} }
.hor_centered { .hor_centered {
width: 100%;
text-align: center; text-align: center;
display: block; display: block !important;
margin: 0 auto; margin: 0 auto;
clear: both; clear: both;
} }

View File

@ -1,3 +1,9 @@
import { html } from "lit"; import { html } from "lit";
export default (o={}) => html`<span class="spinner fa fa-spinner centered ${o.classes || ''}"/>` export default (o={}) => {
if (o.classes?.includes('hor_centered')) {
return html`<div class="spinner__container"><span class="spinner fa fa-spinner centered ${o.classes || ''}"/></div>`
} else {
return html`<span class="spinner fa fa-spinner centered ${o.classes || ''}"/>`
}
}

View File

@ -31,7 +31,7 @@
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',
muc_respect_autojoin: true, muc_respect_autojoin: true,