瀏覽代碼

Add new configuration setting `prune_messages_above`

If set to a positive integer, the chat history will be kept to that size
by removing older messages.

This happens as new messages come in (as long as the chat isn't scrolled up)
and when the user scrolls down.

Also add the `pruning_behavior` setting
JC Brand 4 年之前
父節點
當前提交
5ea9564cc3

+ 2 - 0
CHANGES.md

@@ -21,6 +21,8 @@
 - New configuration setting: [muc_clear_messages_on_leave](https://conversejs.org/docs/html/configuration.html#muc-clear-messages-on-leave)
 - New configuration setting: [send_chat_markers](https://conversejs.org/docs/html/configuration.html#send-chat-markers)
 - New configuration setting: [muc_show_ogp_unfurls](https://conversejs.org/docs/html/configuration.html#muc-show-ogp-unfurls)
+- New configuration setting: [prune-messages-above](https://conversejs.org/docs/html/configuration.html#prune-messages-above)
+- New configuration setting: [pruning_behavior](https://conversejs.org/docs/html/configuration.html#pruning-behavior)
 - #1823: New config options [mam_request_all_pages](https://conversejs.org/docs/html/configuration.html#mam-request-all-pages)
 - Use the MUC stanza id when sending XEP-0333 markers
 - Add support for rendering unfurls via [mod_ogp](https://modules.prosody.im/mod_ogp.html)

+ 1 - 0
dev.html

@@ -30,6 +30,7 @@
         auto_away: 300,
         enable_smacks: true,
         loglevel: 'debug',
+        prune_messages_above: 100,
         message_archiving: 'always',
         muc_respect_autojoin: true,
         muc_show_logs_before_join: true,

+ 30 - 0
docs/source/configuration.rst

@@ -302,6 +302,7 @@ available) and the amount returned will be no more than the page size.
 You will be able to query for even older messages by scrolling upwards in the chatbox or room
 (the so-called infinite scrolling pattern).
 
+
 autocomplete_add_contact
 ------------------------
 
@@ -1728,6 +1729,34 @@ Items in sync storage are synced by the browser and are available across all ins
 BrowserExtLocal represents the local storage area.
 Items in local storage are local to the machine the extension was installed on
 
+prune_messages_above
+--------------------
+
+* Default: ``undefined``
+* Valid options: Any integer value above 0.
+
+If this option is set to a positive integer, the chat history will be kept to
+that number. As new messages come in, older messages will be deleted to
+maintain the history size.
+
+.. note::
+  When deleting locally stored decrypted OMEMO messages, you will **not** be
+  able to decrypt them again after fetching them from the server archive.
+
+pruning_behavior
+----------------
+
+* Default: ``unscrolled``
+* Valid options: ``unscrolled``, ``scrolled``
+
+By default the chat history will only be pruned when the chat window isn't
+scrolled up (``'unscrolled'``).
+
+If set to ``'scrolled'``, then pruning will also happen when the chat is
+scrolled up. Be aware that this will interfere with MAM-based infinite
+scrolling, and this setting only makes sense when infinite scrolling with MAM
+is disabled.
+
 
 push_app_servers
 ----------------
@@ -1989,6 +2018,7 @@ This setting relates to `XEP-0198 <https://xmpp.org/extensions/xep-0198.html>`_
 and determines the number of stanzas to be sent before Converse will ask the
 server for acknowledgement of those stanzas.
 
+
 sounds_path
 -----------
 

+ 1 - 0
karma.conf.js

@@ -31,6 +31,7 @@ module.exports = function(config) {
       { pattern: "src/headless/plugins/chat/tests/api.js", type: 'module' },
       { pattern: "src/headless/plugins/disco/tests/disco.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/affiliations.js", type: 'module' },
+      { pattern: "src/headless/plugins/muc/tests/pruning.js", type: 'module' },
       { pattern: "src/headless/plugins/muc/tests/registration.js", type: 'module' },
       { pattern: "src/headless/plugins/ping/tests/ping.js", type: 'module' },
       { pattern: "src/headless/plugins/roster/tests/presence.js", type: 'module' },

+ 2 - 0
src/headless/plugins/chat/index.js

@@ -38,6 +38,8 @@ converse.plugins.add('converse-chat', {
             'auto_join_private_chats': [],
             'clear_messages_on_reconnection': false,
             'filter_by_resource': false,
+            'prune_messages_above': undefined,
+            'pruning_behavior': 'unscrolled',
             'send_chat_markers': ["received", "displayed", "acknowledged"],
             'send_chat_state_notifications': true,
         });

+ 36 - 6
src/headless/plugins/chat/model.js

@@ -8,6 +8,7 @@ import { Model } from '@converse/skeletor/src/model.js';
 import { _converse, api, converse } from "../../core.js";
 import { getOpenPromise } from '@converse/openpromise';
 import { initStorage } from '@converse/headless/shared/utils.js';
+import { debouncedPruneHistory, pruneHistory } from '@converse/headless/shared/chat/utils.js';
 import { parseMessage } from './parsers.js';
 import { sendMarker } from '@converse/headless/shared/actions';
 
@@ -64,7 +65,7 @@ const ChatBox = ModelWithContact.extend({
             this.presence.on('change:show', item => this.onPresenceChanged(item));
         }
         this.on('change:chat_state', this.sendChatState, this);
-        this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter());
+        this.on('change:scrolled', this.onScrolledChanged, this);
 
         await this.fetchMessages();
         /**
@@ -89,6 +90,7 @@ const ChatBox = ModelWithContact.extend({
         this.messages = this.getMessagesCollection();
         this.messages.fetched = getOpenPromise();
         this.messages.fetched.then(() => {
+            this.pruneHistoryWhenScrolledDown();
             /**
              * Triggered whenever a `_converse.ChatBox` instance has fetched its messages from
              * `sessionStorage` but **NOT** from the server.
@@ -101,11 +103,8 @@ const ChatBox = ModelWithContact.extend({
         this.messages.chatbox = this;
         initStorage(this.messages, this.getMessagesCacheKey());
 
-        this.listenTo(this.messages, 'change:upload', message => {
-            if (message.get('upload') === _converse.SUCCESS) {
-                api.send(this.createMessageStanza(message));
-            }
-        });
+        this.listenTo(this.messages, 'change:upload', this.onMessageUploadChanged, this);
+        this.listenTo(this.messages, 'add', this.onMessageAdded, this);
     },
 
     initUI () {
@@ -242,6 +241,21 @@ const ChatBox = ModelWithContact.extend({
         }
     },
 
+    onMessageUploadChanged (message) {
+        if (message.get('upload') === _converse.SUCCESS) {
+            api.send(this.createMessageStanza(message));
+        }
+    },
+
+    onMessageAdded (message) {
+        if (api.settings.get('prune_messages_above') &&
+            (api.settings.get('pruning_behavior') === 'scrolled' || !this.get('scrolled')) &&
+            !u.isEmptyMessage(message)
+        ) {
+            debouncedPruneHistory(this);
+        }
+    },
+
     async clearMessages () {
         try {
             await this.messages.clearStore();
@@ -316,6 +330,22 @@ const ChatBox = ModelWithContact.extend({
         text && this.createMessage({ 'message': text, 'type': 'info' });
     },
 
+    onScrolledChanged () {
+        if (!this.get('scrolled')) {
+            this.clearUnreadMsgCounter();
+            this.pruneHistoryWhenScrolledDown();
+        }
+    },
+
+    pruneHistoryWhenScrolledDown () {
+        if (!this.ui.get('scrolled') &&
+            api.settings.get('prune_messages_above') &&
+            api.settings.get('pruning_behavior') === 'unscrolled'
+        ) {
+            pruneHistory(this);
+        }
+    },
+
     validate (attrs) {
         if (!attrs.jid) {
             return 'Ignored ChatBox without JID';

+ 1 - 1
src/headless/plugins/muc/muc.js

@@ -97,7 +97,7 @@ const ChatRoomMixin = {
 
         this.on('change:chat_state', this.sendChatState, this);
         this.on('change:hidden', this.onHiddenChange, this);
-        this.on('change:scrolled', () => !this.get('scrolled') && this.clearUnreadMsgCounter());
+        this.on('change:scrolled', this.onScrolledChanged, this);
         this.on('destroy', this.removeHandlers, this);
 
         await this.restoreSession();

+ 52 - 0
src/headless/plugins/muc/tests/pruning.js

@@ -0,0 +1,52 @@
+/*global mock, converse */
+
+const {  u } = converse.env;
+
+describe("A Groupchat Message", function () {
+
+    it("will be pruned if it exceeds the prune_messages_above threshold",
+        mock.initConverse(
+            ['chatBoxesFetched'],
+            {'prune_messages_above': 3},
+            async function (done, _converse) {
+
+        const muc_jid = 'lounge@montague.lit';
+        const model = await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        expect(model.get('scrolled')).toBeFalsy();
+
+        model.sendMessage('1st message');
+        model.sendMessage('2nd message');
+        model.sendMessage('3rd message');
+        await u.waitUntil(() => model.messages.length === 3);
+        // Make sure pruneHistory fires
+        await new Promise(resolve => setTimeout(resolve, 550));
+
+        model.sendMessage('4th message');
+        await u.waitUntil(() => model.messages.length === 4);
+        await u.waitUntil(() => model.messages.length === 3, 550);
+
+        model.set('scrolled', true);
+        model.sendMessage('5th message');
+        model.sendMessage('6th message');
+        await u.waitUntil(() => model.messages.length === 5);
+
+        // Wait long enough to be sure the debounced pruneHistory method didn't fire.
+        await new Promise(resolve => setTimeout(resolve, 550));
+        expect(model.messages.length).toBe(5);
+        model.set('scrolled', false);
+        await u.waitUntil(() => model.messages.length === 3, 550);
+
+        // Test incoming messages
+        const stanza = u.toStanza(`
+            <message xmlns="jabber:client"
+                     from="${muc_jid}/juliet"
+                     to="${_converse.connection.jid}"
+                     type="groupchat">
+                <body>1st incoming</body>
+            </message>`);
+        _converse.connection._dataRecv(mock.createRequest(stanza));
+        await u.waitUntil(() => model.messages.length === 4);
+        await u.waitUntil(() => model.messages.length === 3, 550);
+        done();
+    }));
+});

+ 20 - 0
src/headless/shared/chat/utils.js

@@ -0,0 +1,20 @@
+import debounce from 'lodash-es/debounce.js';
+import { api, converse } from '@converse/headless/core.js';
+
+const { u } = converse.env;
+
+export function pruneHistory (model) {
+    const max_history = api.settings.get('prune_messages_above');
+    if (max_history && typeof max_history === 'number') {
+        if (model.messages.length > max_history) {
+            const non_empty_messages = model.messages.filter((m) => !u.isEmptyMessage(m));
+            if (non_empty_messages.length > max_history) {
+                while (non_empty_messages.length > max_history) {
+                    non_empty_messages.shift().destroy();
+                }
+            }
+        }
+    }
+}
+
+export const debouncedPruneHistory = debounce(pruneHistory, 250);

+ 4 - 1
src/shared/chat/utils.js

@@ -19,7 +19,10 @@ export function onScrolledDown (model) {
  * @param { _converse.Message }
  */
 export function getDayIndicator (message) {
-    const messages = message.collection.models;
+    const messages = message.collection?.models;
+    if (!messages) {
+        return;
+    }
     const idx = messages.indexOf(message);
     const prev_message =  messages[idx-1];
     if (!prev_message || dayjs(message.get('time')).isAfter(dayjs(prev_message.get('time')), 'day')) {