Selaa lähdekoodia

Muc domain grouping (#3283)

* Add option to group MUCs by their domain using collapsible lists

* Document muc_grouped_by_domain

* Add this change to CHANGES.md

* Move muc domain group functions to their own file

* Don’t use muc-domain-group class for distinct things

This template was done in a similar way to rosterview’s group.js template,
which uses the "roster-group" class for the group, and "roster-group-contacts"
for the list.
This commit changes MUC domain groups to use the "muc-domain-group" class
for the group, and "muc-domain-group-rooms" for the list.

* Attempt to add tests for MUC domain groups

* Don’t focus MUC domain group tests

* Fix focused test from master

This allows CI tests to run properly on this merge request.
It can be skipped if it’s fixed somewhere else on master first.

* Add MUC domain group scss, attempting consistency with roster groups

* Don’t use !important for MUC group headers

Unlike(?) group-toggle (roster-groups) and open-rooms-toggle,
it doesn’t seem necessary for the color to show.
BetaRays 1 vuosi sitten
vanhempi
commit
59753231b6

+ 1 - 0
CHANGES.md

@@ -3,6 +3,7 @@
 ## 11.0.0 (Unreleased)
 
 - #2716: Fix issue with chat display when opening via URL
+- #3033: Add the `muc_grouped_by_domain` option to display MUCs on the same domain in collapsible groups
 - Add an occupants filter to the MUC sidebar
 
 ### Breaking changes:

+ 8 - 0
docs/source/configuration.rst

@@ -1278,6 +1278,14 @@ By fetching member lists, Converse.js will always show these users as
 participants of the MUC, giving them a permanent "presence" in the MUC.
 
 
+muc_grouped_by_domain
+---------------------
+
+* Default: ``false``
+
+If ``true``, displays MUCS of a same domain together, in collapsible groups.
+
+
 muc_history_max_stanzas
 -----------------------
 

+ 1 - 0
karma.conf.js

@@ -111,6 +111,7 @@ module.exports = function(config) {
       { pattern: "src/plugins/push/tests/push.js", type: 'module' },
       { pattern: "src/plugins/register/tests/register.js", type: 'module' },
       { pattern: "src/plugins/roomslist/tests/roomslist.js", type: 'module' },
+      { pattern: "src/plugins/roomslist/tests/grouplists.js", type: 'module' },
       { pattern: "src/plugins/rootview/tests/root.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/add-contact-modal.js", type: 'module' },
       { pattern: "src/plugins/rosterview/tests/presence.js", type: 'module' },

+ 1 - 1
src/plugins/muc-views/tests/occupants-filter.js

@@ -4,7 +4,7 @@ const { $pres, u } = converse.env;
 
 describe("The MUC occupants filter", function () {
 
-    fit("can be used to filter which occupants are shown",
+    it("can be used to filter which occupants are shown",
         mock.initConverse(
             [], {},
             async function (_converse) {

+ 6 - 2
src/plugins/roomslist/index.js

@@ -7,7 +7,7 @@
  */
 import "@converse/headless/plugins/muc/index.js";
 import './view.js';
-import { converse } from "@converse/headless";
+import { api, converse } from "@converse/headless";
 
 
 converse.plugins.add('converse-roomslist', {
@@ -19,5 +19,9 @@ converse.plugins.add('converse-roomslist', {
         "converse-bookmarks"
     ],
 
-    initialize () { }
+    initialize () {
+        api.settings.extend({
+            'muc_grouped_by_domain': false,
+        });
+    }
 });

+ 1 - 0
src/plugins/roomslist/model.js

@@ -10,6 +10,7 @@ class RoomsListModel extends Model {
             'muc_domain': api.settings.get('muc_domain'),
             'nick': _converse.getDefaultMUCNickname(),
             'toggle_state':  _converse.OPENED,
+            'collapsed_domains': [],
         };
     }
 

+ 14 - 0
src/plugins/roomslist/styles/roomsgroups.scss

@@ -0,0 +1,14 @@
+.conversejs {
+    #chatrooms {
+        .muc-domain-group-toggle {
+            margin: 0.75em 0 0.25em 0;
+        }
+
+        .muc-domain-group-toggle, .muc-domain-group-toggle .fa {
+            color: var(--groupchats-header-color);
+            &:hover {
+                color: var(--chatroom-head-bg-color-dark);
+            }
+        }
+    }
+}

+ 41 - 0
src/plugins/roomslist/templates/groups.js

@@ -0,0 +1,41 @@
+import { __ } from 'i18n';
+import { html } from "lit";
+import { tplRoomItem } from 'plugins/roomslist/templates/roomslist.js'
+
+import '../styles/roomsgroups.scss';
+
+function tplRoomDomainGroup (el, domain, rooms) {
+    const i18n_title = __('Click to hide these rooms');
+    const collapsed = el.model.get('collapsed_domains');
+    const is_collapsed = collapsed.includes(domain);
+    return html`
+    <div class="muc-domain-group" data-domain="${domain}">
+        <a href="#" class="list-toggle muc-domain-group-toggle controlbox-padded" title="${i18n_title}" @click=${ev => el.toggleDomainList(ev, domain)}>
+            <converse-icon
+                class="fa ${ is_collapsed ? 'fa-caret-right' : 'fa-caret-down' }"
+                size="1em"
+                color="var(--groupchats-header-color)"></converse-icon>
+            ${domain}
+        </a>
+        <ul class="items-list muc-domain-group-rooms ${ is_collapsed ? 'collapsed' : '' }" data-domain="${domain}">
+            ${ rooms.map(room => tplRoomItem(el, room)) }
+        </ul>
+    </div>`;
+}
+
+export function tplRoomDomainGroupList (el, rooms) {
+    // The rooms should stay sorted as they are iterated and added in order
+    const grouped_rooms = new Map();
+    for (const room of rooms) {
+        const roomdomain = room.get('jid').split('@').at(-1).toLowerCase();
+        if (grouped_rooms.has(roomdomain)) {
+            grouped_rooms.get(roomdomain).push(room);
+        } else {
+            grouped_rooms.set(roomdomain, [room]);
+        }
+    }
+    const sorted_domains = Array.from(grouped_rooms.keys());
+    sorted_domains.sort();
+
+    return sorted_domains.map(domain => tplRoomDomainGroup(el, domain, grouped_rooms.get(domain)))
+}

+ 7 - 2
src/plugins/roomslist/templates/roomslist.js

@@ -5,6 +5,7 @@ import { _converse, api } from "@converse/headless";
 import { html } from "lit";
 import { isUniView } from '@converse/headless/utils/session.js';
 import { addBookmarkViaEvent } from 'plugins/bookmark-views/utils.js';
+import { tplRoomDomainGroupList } from 'plugins/roomslist/templates/groups.js';
 
 
 function isCurrentlyOpen (room) {
@@ -33,7 +34,7 @@ const tplUnreadIndicator = (room) => html`<span class="list-item-badge badge bad
 const tplActivityIndicator = () => html`<span class="list-item-badge badge badge--muc msgs-indicator"></span>`;
 
 
-function tplRoomItem (el, room) {
+export function tplRoomItem (el, room) {
     const i18n_leave_room = __('Leave this groupchat');
     const has_unread_msgs = room.get('num_unread_general') || room.get('has_activity');
     return html`
@@ -69,6 +70,7 @@ function tplRoomItem (el, room) {
 
 export default (el) => {
     const { chatboxes, CHATROOMS_TYPE, CLOSED } = _converse;
+    const group_by_domain = api.settings.get('muc_grouped_by_domain');
     const rooms = chatboxes.filter(m => m.get('type') === CHATROOMS_TYPE);
     rooms.sort((a, b) => (a.getDisplayName().toLowerCase() <= b.getDisplayName().toLowerCase() ? -1 : 1));
 
@@ -111,7 +113,10 @@ export default (el) => {
 
         <div class="list-container list-container--openrooms ${ rooms.length ? '' : 'hidden' }">
             <div class="items-list rooms-list open-rooms-list ${ is_closed ? 'collapsed' : '' }">
-                ${ rooms.map(room => tplRoomItem(el, room)) }
+                ${ group_by_domain ?
+                    tplRoomDomainGroupList(el, rooms) :
+                    rooms.map(room => tplRoomItem(el, room))
+                }
             </div>
         </div>`;
 }

+ 115 - 0
src/plugins/roomslist/tests/grouplists.js

@@ -0,0 +1,115 @@
+/* global mock, converse */
+
+const { $msg, u } = converse.env;
+
+
+describe("The list of MUC domains", function () {
+    it("is shown in controlbox", mock.initConverse(
+            ['chatBoxesFetched'],
+            { muc_grouped_by_domain: true,
+              allow_bookmarks: false // Makes testing easier, otherwise we
+                                     // have to mock stanza traffic.
+            }, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.openControlBox(_converse);
+        const controlbox = _converse.chatboxviews.get('controlbox');
+        let list = controlbox.querySelector('.list-container--openrooms');
+        expect(u.hasClass('hidden', list)).toBeTruthy();
+        await mock.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC');
+
+        const lview = controlbox.querySelector('converse-rooms-list');
+        // Check that the group is shown
+        await u.waitUntil(() => lview.querySelectorAll(".muc-domain-group").length);
+        let group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(1);
+        // .children[0] should give the a tag with the domain in it
+        // there might be a more robust way to do this
+        // (select for ".muc-domain-group-toggle"?)
+        // .trim() because there is a space for the arrow/triangle icon first
+        expect(group_els[0].children[0].innerText.trim()).toBe('conference.shakespeare.lit');
+        // Check that the room is shown
+        await u.waitUntil(() => lview.querySelectorAll(".open-room").length);
+        let room_els = lview.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+        expect(room_els[0].innerText).toBe('room@conference.shakespeare.lit');
+
+        // Check that a second room in the same domain is shown in the same
+        // domain group.
+        await mock.openChatRoom(_converse, 'secondroom', 'conference.shakespeare.lit', 'JC');
+        await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 1);
+        group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(1); // still only one group
+        expect(group_els[0].children[0].innerText.trim()).toBe('conference.shakespeare.lit');
+        room_els = lview.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(2); // but two rooms inside it
+
+
+        await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
+        await u.waitUntil(() => lview.querySelectorAll(".open-room").length > 2);
+        room_els = lview.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(3);
+        group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(2);
+
+        let view = _converse.chatboxviews.get('room@conference.shakespeare.lit');
+        await view.close();
+        room_els = lview.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(2);
+        group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(2);
+        view = _converse.chatboxviews.get('secondroom@conference.shakespeare.lit');
+        await view.close();
+        room_els = lview.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(1);
+        group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(1);
+        expect(room_els[0].innerText).toBe('lounge@montague.lit');
+        expect(group_els[0].children[0].innerText.trim()).toBe('montague.lit');
+        list = controlbox.querySelector('.list-container--openrooms');
+        u.waitUntil(() => Array.from(list.classList).includes('hidden'));
+
+        view = _converse.chatboxviews.get('lounge@montague.lit');
+        await view.close();
+        room_els = lview.querySelectorAll(".open-room");
+        expect(room_els.length).toBe(0);
+        group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(0);
+
+        list = controlbox.querySelector('.list-container--openrooms');
+        expect(Array.from(list.classList).includes('hidden')).toBeTruthy();
+    }));
+});
+
+describe("A MUC domain group", function () {
+    it("is collapsible", mock.initConverse(
+            ['chatBoxesFetched'],
+            { muc_grouped_by_domain: true,
+              allow_bookmarks: false // Makes testing easier, otherwise we
+                                     // have to mock stanza traffic.
+            }, async function (_converse) {
+
+        await mock.waitForRoster(_converse, 'current', 0);
+        await mock.openControlBox(_converse);
+        const controlbox = _converse.chatboxviews.get('controlbox');
+        let list = controlbox.querySelector('.list-container--openrooms');
+        await mock.openChatRoom(_converse, 'room', 'conference.shakespeare.lit', 'JC');
+
+        const lview = controlbox.querySelector('converse-rooms-list');
+        await u.waitUntil(() => lview.querySelectorAll(".muc-domain-group").length);
+        expect(u.hasClass('hidden', list)).toBeFalsy();
+        let group_els = lview.querySelectorAll(".muc-domain-group");
+        expect(group_els.length).toBe(1);
+        expect(group_els[0].children[0].innerText.trim()).toBe('conference.shakespeare.lit');
+
+        // I would have liked to use u.isVisible on the room (.open-room) here,
+        // but it didn’t seem to work.
+        expect(u.hasClass('collapsed', lview.querySelector(".muc-domain-group-rooms"))).toBe(false);
+        lview.querySelector('.muc-domain-group-toggle').click();
+        await u.waitUntil(() => u.hasClass('collapsed', lview.querySelector(".muc-domain-group-rooms")) === true);
+        expect(u.hasClass('collapsed', lview.querySelector(".muc-domain-group-rooms"))).toBe(true);
+        lview.querySelector('.muc-domain-group-toggle').click();
+        await u.waitUntil(() => u.hasClass('collapsed', lview.querySelector(".muc-domain-group-rooms")) === false);
+        expect(u.hasClass('collapsed', lview.querySelector(".muc-domain-group-rooms"))).toBe(false);
+    }));
+});

+ 10 - 0
src/plugins/roomslist/view.js

@@ -79,6 +79,16 @@ export class RoomsList extends CustomElement {
             u.slideIn(list_el).then(() => this.model.save({'toggle_state': _converse.CLOSED}));
         }
     }
+
+    toggleDomainList (ev, domain) {
+        ev?.preventDefault?.();
+        const collapsed = this.model.get('collapsed_domains');
+        if (collapsed.includes(domain)) {
+            this.model.save({'collapsed_domains': collapsed.filter(d => d !== domain)});
+        } else {
+            this.model.save({'collapsed_domains': [...collapsed, domain]});
+        }
+    }
 }
 
 api.elements.define('converse-rooms-list', RoomsList);