Browse Source

Disable chatting with users not in MUC

Updates #698
JC Brand 7 months ago
parent
commit
054ff150dc

+ 1 - 1
src/headless/plugins/chat/utils.js

@@ -163,7 +163,7 @@ export async function handleMessageStanza (stanza) {
 
     // XXX: Need to take XEP-428 <fallback> into consideration
     const has_body = !!(body || plaintext)
-    const chatbox = await api.chats.get(contact_jid, { 'nickname': nick }, has_body);
+    const chatbox = await api.chats.get(contact_jid, { nickname: nick }, has_body);
     await chatbox?.queueMessage(attrs);
     /**
      * @typedef {Object} MessageData

+ 8 - 4
src/headless/plugins/roster/contacts.js

@@ -159,10 +159,10 @@ class RosterContacts extends Collection {
      * registers the contact on the XMPP server.
      * Returns a promise which is resolved once the XMPP server has responded.
      * @method _converse.RosterContacts#addContactToRoster
-     * @param { String } jid - The Jabber ID of the user being added and subscribed to.
-     * @param { String } name - The name of that user
-     * @param { Array<String> } groups - Any roster groups the user might belong to
-     * @param { Object } attributes - Any additional attributes to be stored on the user's model.
+     * @param {String} jid - The Jabber ID of the user being added and subscribed to.
+     * @param {String} name - The name of that user
+     * @param {Array<String>} groups - Any roster groups the user might belong to
+     * @param {Object} attributes - Any additional attributes to be stored on the user's model.
      */
     async addContactToRoster (jid, name, groups, attributes) {
         await api.waitUntil('rosterContactsFetched');
@@ -191,6 +191,10 @@ class RosterContacts extends Collection {
         );
     }
 
+    /**
+     * @param {String} bare_jid
+     * @param {Element} presence
+     */
     async subscribeBack (bare_jid, presence) {
         const contact = this.get(bare_jid);
         const { RosterContact } = _converse.exports;

+ 10 - 8
src/headless/shared/model-with-contact.js

@@ -48,15 +48,17 @@ export default function ModelWithContact(BaseModel) {
                 }
             }
 
-            this.listenTo(this.contact, 'change', (changed) => {
-                if (changed.nickname) {
-                    this.set('nickname', changed.nickname);
-                }
-                this.trigger('contact:change', changed);
-            });
+            if (this.contact) {
+                this.listenTo(this.contact, 'change', (changed) => {
+                    if (changed.nickname) {
+                        this.set('nickname', changed.nickname);
+                    }
+                    this.trigger('contact:change', changed);
+                });
 
-            this.rosterContactAdded.resolve();
-            this.trigger('contactAdded', this.contact);
+                this.rosterContactAdded.resolve();
+                this.trigger('contactAdded', this.contact);
+            }
         }
     }
 }

+ 1 - 1
src/headless/types/plugins/chat/utils.d.ts

@@ -12,7 +12,7 @@ export function registerMessageHandlers(): void;
  * Handler method for all incoming single-user chat "message" stanzas.
  * @param {Element|Builder} stanza
  */
-export function handleMessageStanza(stanza: Element | Builder): Promise<void>;
+export function handleMessageStanza(stanza: Element | Builder): Promise<true | void>;
 /**
  * Ask the XMPP server to enable Message Carbons
  * See [XEP-0280](https://xmpp.org/extensions/xep-0280.html#enabling)

+ 9 - 5
src/headless/types/plugins/roster/contacts.d.ts

@@ -47,13 +47,17 @@ declare class RosterContacts extends Collection {
      * registers the contact on the XMPP server.
      * Returns a promise which is resolved once the XMPP server has responded.
      * @method _converse.RosterContacts#addContactToRoster
-     * @param { String } jid - The Jabber ID of the user being added and subscribed to.
-     * @param { String } name - The name of that user
-     * @param { Array<String> } groups - Any roster groups the user might belong to
-     * @param { Object } attributes - Any additional attributes to be stored on the user's model.
+     * @param {String} jid - The Jabber ID of the user being added and subscribed to.
+     * @param {String} name - The name of that user
+     * @param {Array<String>} groups - Any roster groups the user might belong to
+     * @param {Object} attributes - Any additional attributes to be stored on the user's model.
      */
     addContactToRoster(jid: string, name: string, groups: Array<string>, attributes: any): Promise<any>;
-    subscribeBack(bare_jid: any, presence: any): Promise<void>;
+    /**
+     * @param {String} bare_jid
+     * @param {Element} presence
+     */
+    subscribeBack(bare_jid: string, presence: Element): Promise<void>;
     /**
      * Handle roster updates from the XMPP server.
      * See: https://xmpp.org/rfcs/rfc6121.html#roster-syntax-actions-push

+ 7 - 0
src/headless/types/shared/parsers.d.ts

@@ -118,10 +118,17 @@ export function throwErrorIfInvalidForward(stanza: Element): void;
 export function getChatMarker(stanza: Element): Element;
 /**
  * @param {Element} stanza
+ * @returns {boolean}
  */
 export function isHeadline(stanza: Element): boolean;
 /**
  * @param {Element} stanza
+ * @returns {Promise<boolean>}
+ */
+export function isMUCPrivateMessage(stanza: Element): Promise<boolean>;
+/**
+ * @param {Element} stanza
+ * @returns {boolean}
  */
 export function isServerMessage(stanza: Element): boolean;
 /**

+ 2 - 2
src/plugins/chatview/bottom-panel.js

@@ -76,12 +76,12 @@ export default class ChatBottomPanel extends CustomElement {
 
     /**
      * @typedef {Object} AutocompleteInPickerEvent
-     * @property {HTMLTextAreaElement} input
+     * @property {HTMLTextAreaElement} target
      * @property {string} value
      * @param {AutocompleteInPickerEvent} ev
      */
     async autocompleteInPicker (ev) {
-        const { input, value } = ev;
+        const { target: input, value } = ev;
         await api.emojis.initialize();
         const emoji_picker = /** @type {EmojiPicker} */(this.querySelector('converse-emoji-picker'));
         if (emoji_picker) {

+ 13 - 9
src/plugins/chatview/tests/messages.js

@@ -1,6 +1,6 @@
 /*global mock, converse */
 
-const { Promise, Strophe, $msg, dayjs, sizzle, u } = converse.env;
+const { Promise, Strophe, $msg, dayjs, sizzle, stx, u } = converse.env;
 
 
 describe("A Chat Message", function () {
@@ -997,7 +997,7 @@ describe("A Chat Message", function () {
 
         describe("who is not on the roster", function () {
 
-            it("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
+            fit("will open a chatbox and be displayed inside it if allow_non_roster_messaging is true",
                 mock.initConverse(
                     [], {'allow_non_roster_messaging': false},
                     async function (_converse) {
@@ -1112,9 +1112,11 @@ describe("A Chat Message", function () {
                     .c('error', {'type': 'cancel'})
                     .c('remote-server-not-found', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" }).up()
                     .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
-                        .t('Server-to-server connection failed: Connecting failed: connection timeout');
+                        .t(error_txt);
                 api.connection.get()._dataRecv(mock.createRequest(stanza));
-                await u.waitUntil(() => view.querySelector('.chat-msg__error').textContent.trim() === error_txt);
+
+                let ui_error_txt = `Message delivery failed: "${error_txt}"`;
+                await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent.trim() === ui_error_txt);
 
                 const other_error_txt = 'Server-to-server connection failed: Connecting failed: connection timeout';
                 stanza = $msg({
@@ -1128,8 +1130,10 @@ describe("A Chat Message", function () {
                     .c('text', { 'xmlns': "urn:ietf:params:xml:ns:xmpp-stanzas" })
                         .t(other_error_txt);
                 api.connection.get()._dataRecv(mock.createRequest(stanza));
+
+                ui_error_txt = `Message delivery failed: "${other_error_txt}"`;
                 await u.waitUntil(() =>
-                    view.querySelector('converse-chat-message:last-child .chat-msg__error').textContent.trim() === other_error_txt);
+                    view.querySelector('converse-chat-message:last-child .chat-msg__error')?.textContent.trim() === ui_error_txt);
 
                 // We don't render duplicates
                 stanza = $msg({
@@ -1205,8 +1209,8 @@ describe("A Chat Message", function () {
                 }).c('active', {'xmlns': 'http://jabber.org/protocol/chatstates'}).tree();
                 await _converse.handleMessageStanza(msg);
 
-                api.connection.get()._dataRecv(mock.createRequest(u.toStanza(`
-                    <message xml:lang="en" type="error" from="${contact_jid}">
+                api.connection.get()._dataRecv(mock.createRequest(stx`
+                    <message xml:lang="en" type="error" from="${contact_jid}" xmlns="jabber:client">
                         <active xmlns="http://jabber.org/protocol/chatstates"/>
                         <no-store xmlns="urn:xmpp:hints"/>
                         <no-permanent-store xmlns="urn:xmpp:hints"/>
@@ -1214,9 +1218,9 @@ describe("A Chat Message", function () {
                         <service-unavailable xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
                         <text xml:lang="en" xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">User session not found</text></error>
                     </message>
-                `)));
+                `));
                 return new Promise(resolve => setTimeout(() => {
-                    expect(view.querySelector('.chat-msg__error').textContent).toBe('');
+                    expect(view.querySelector('.chat-msg__error')).toBe(null);
                     resolve();
                 }, 500));
             }));

+ 50 - 0
src/plugins/muc-views/occupant-bottom-panel.js

@@ -0,0 +1,50 @@
+import { _converse, api } from '@converse/headless';
+import { __ } from 'i18n';
+import 'shared/autocomplete/index.js';
+import BottomPanel from 'plugins/chatview/bottom-panel.js';
+import tplBottomPanel from './templates/occupant-bottom-panel.js';
+
+import './styles/occupant-bottom-panel.scss';
+
+export default class OccupantBottomPanel extends BottomPanel {
+    static get properties() {
+        return {
+            model: { type: Object, noAccessor: true },
+            muc: { type: Object },
+        };
+    }
+
+    constructor() {
+        super();
+        this.muc = null;
+    }
+
+    async initialize() {
+        await super.initialize();
+        this.listenTo(this.muc.session, 'change:connection_status', () => this.requestUpdate());
+    }
+
+    render() {
+        if (!this.model) return '';
+        return tplBottomPanel(this);
+    }
+
+    canPostMessages() {
+        return this.muc.isEntered() && this.model.get('show') !== 'offline';
+    }
+
+    openChat() {
+        const jid = this.model.get('jid');
+        return jid ? api.chats.open(jid, {}, true) : api.alert('error', __('Error'), 'Could not find XMPP address');
+    }
+
+    invite() {
+        const jid = this.model.get('jid');
+        api.listen.once('roomInviteSent', () =>
+            api.alert('info', __('Success'), __('The user has been invited to join this groupchat'))
+        );
+        return jid ? this.muc.directInvite(jid) : api.alert('error', __('Error'), 'Could not find XMPP address');
+    }
+}
+
+api.elements.define('converse-occupant-bottom-panel', OccupantBottomPanel);

+ 1 - 0
src/plugins/muc-views/occupant.js

@@ -2,6 +2,7 @@ import { Model } from '@converse/skeletor';
 import { _converse, api, converse } from '@converse/headless';
 import { CustomElement } from 'shared/components/element.js';
 import tplMUCOccupant from './templates/muc-occupant.js';
+import './occupant-bottom-panel.js';
 
 const { u } = converse.env;
 

+ 12 - 0
src/plugins/muc-views/styles/occupant-bottom-panel.scss

@@ -0,0 +1,12 @@
+.conversejs {
+    converse-occupant-bottom-panel {
+        .bottom-panel--muted {
+            padding: 1em;
+            padding-top: 0;
+            border-top: 0.2em solid var(--secondary-color);
+            .bottom-panel--muted__msg {
+                padding: 0.5em 0 !important;
+            }
+        }
+    }
+}

+ 1 - 12
src/plugins/muc-views/templates/muc-bottom-panel.js

@@ -5,17 +5,6 @@ import { __ } from 'i18n';
 import { api, converse } from '@converse/headless';
 import { html } from 'lit';
 
-/**
- * @param {import('../bottom-panel').default} el
- */
-const tpl_can_post = (el) => {
-    const unread_msgs = __('You have unread messages');
-    return html` ${el.model.ui.get('scrolled') && el.model.get('num_unread')
-            ? html`<div class="new-msgs-indicator" @click=${(ev) => el.viewUnreadMessages(ev)}>▼ ${unread_msgs} ▼</div>`
-            : ''}
-        <converse-muc-message-form .model=${el.model}></converse-muc-message-form>`;
-};
-
 /**
  * @param {import('../bottom-panel').default} el
  */
@@ -28,7 +17,7 @@ export default (el) => {
             ? html`<div class="new-msgs-indicator" @click=${(ev) => el.viewUnreadMessages(ev)}>▼ ${unread_msgs} ▼</div>`
             : ''}
         ${el.model.canPostMessages()
-            ? tpl_can_post(el)
+            ? html`<converse-muc-message-form .model=${el.model}></converse-muc-message-form>`
             : html`<span class="muc-bottom-panel muc-bottom-panel--muted">${i18n_not_allowed}</span>`}`;
     } else if (conn_status == converse.ROOMSTATUS.NICKNAME_REQUIRED) {
         if (api.settings.get('muc_show_logs_before_join')) {

+ 2 - 1
src/plugins/muc-views/templates/muc-occupant.js

@@ -86,7 +86,8 @@ export default (el) => {
                           .model="${el.model}"
                       ></converse-chat-content>
                   </div>
-                  <converse-chat-bottom-panel .model=${el.model} class="bottom-panel"> </converse-chat-bottom-panel>
+                  <converse-occupant-bottom-panel .model=${el.model} .muc=${el.muc} class="bottom-panel">
+                  </converse-occupant-bottom-panel>
               </div>`
             : ''}
     </span>`;

+ 49 - 0
src/plugins/muc-views/templates/occupant-bottom-panel.js

@@ -0,0 +1,49 @@
+import '../message-form.js';
+import '../nickname-form.js';
+import 'shared/chat/toolbar.js';
+import { __ } from 'i18n';
+import { converse } from '@converse/headless';
+import { html } from 'lit';
+
+/**
+ * @param {import('../occupant-bottom-panel').default} el
+ */
+export default (el) => {
+    const unread_msgs = __('You have unread messages');
+    const conn_status = el.muc.session.get('connection_status');
+    const i18n_not_allowed = __("This user is not currently in this groupchat and can't receive messages.");
+    const i18n_invite_tooltip = __('Invite this user to join this groupchat');
+    const i18n_open_chat_tooltip = __('Open a one-on-one chat with this user');
+    const i18n_open_chat = __('Open Chat');
+    const i18n_invite = __('Invite');
+    if (conn_status === converse.ROOMSTATUS.ENTERED) {
+        return html`${el.muc.ui.get('scrolled') && el.model.get('num_unread')
+            ? html`<div class="new-msgs-indicator" @click=${(ev) => el.viewUnreadMessages(ev)}>▼ ${unread_msgs} ▼</div>`
+            : ''}
+        ${el.canPostMessages()
+            ? html`<converse-muc-message-form .model=${el.muc}></converse-muc-message-form>`
+            : html`<div class="bottom-panel bottom-panel--muted">
+                  <p class="bottom-panel--muted__msg">${i18n_not_allowed}</p>
+                  ${el.model.get('jid')
+                      ? html` <button
+                                @click=${el.openChat}
+                                type="button"
+                                class="btn btn-primary"
+                                title="${i18n_open_chat_tooltip}"
+                            >
+                                ${i18n_open_chat}
+                            </button>
+                            <button
+                                @click=${el.invite}
+                                type="button"
+                                class="btn btn-secondary"
+                                title="${i18n_invite_tooltip}"
+                            >
+                                ${i18n_invite}
+                            </button>`
+                      : ''}
+              </div>`}`;
+    } else {
+        return '';
+    }
+};

+ 2 - 1
src/plugins/muc-views/tests/muc-messages.js

@@ -39,7 +39,8 @@ describe("A Groupchat Message", function () {
                     <body>hello world</body>
                 </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(error));
-            expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(err_msg_text);
+            const ui_error_txt = `Message delivery failed: "${err_msg_text}"`;
+            expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(ui_error_txt);
             expect(view.model.messages.length).toBe(1);
             const message = view.model.messages.at(0);
             expect(message.get('received')).toBeUndefined();

+ 27 - 3
src/plugins/muc-views/tests/muc-private-messages.js

@@ -4,6 +4,30 @@ const { stx, u } = converse.env;
 describe('MUC Private Messages', () => {
     beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
+    it(
+        'cannot be sent when the intended recipient is not in the MUC',
+        mock.initConverse(['chatBoxesFetched'], { view_mode: 'fullscreen' }, async (_converse) => {
+            await mock.waitForRoster(_converse, 'current', 0);
+            const nick = 'romeo';
+            const muc_jid = 'coven@chat.shakespeare.lit';
+
+            const members = [
+                {
+                    nick: 'firstwitch',
+                    jid: 'witch@wiccarocks.lit',
+                    affiliation: 'member',
+                },
+            ];
+            await mock.openAndEnterChatRoom(_converse, muc_jid, nick, [], members);
+
+            const view = _converse.chatboxviews.get(muc_jid);
+            await u.waitUntil(() => view.model.occupants.length === 2);
+
+            const avatar_el = await u.waitUntil(() => view.querySelector('.occupant-list converse-avatar[name="firstwitch"]'));
+            avatar_el.click();
+        })
+    );
+
     describe('When receiving a MUC private message', () => {
         it(
             "doesn't appear in the main MUC chatarea",
@@ -113,7 +137,6 @@ describe('MUC Private Messages', () => {
                             <origin-id xmlns="urn:xmpp:sid:0" id="${sent_stanza.querySelector('origin-id')?.getAttribute('id')}"/>
                         </message>`);
 
-
                     const err_msg_text = 'Recipient not in room';
                     api.connection.get()._dataRecv(
                         mock.createRequest(stx`
@@ -130,8 +153,9 @@ describe('MUC Private Messages', () => {
                         </message>`)
                     );
 
-                    expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim()))
-                        .toBe(`Message delivery failed: "${err_msg_text}"`);
+                    expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim())).toBe(
+                        `Message delivery failed: "${err_msg_text}"`
+                    );
                 })
             );
         });

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

@@ -1581,7 +1581,7 @@ describe("Groupchats", function () {
             modal.querySelector('input[type="submit"]').click();
 
             expect(view.model.directInvite).toHaveBeenCalled();
-            expect(Strophe.serialize(sent_stanza)).toBe(
+           expect(Strophe.serialize(sent_stanza)).toBe(
                 `<message from="romeo@montague.lit/orchard" `+
                         `id="${sent_stanza.getAttribute("id")}" `+
                         `to="balthasar@montague.lit" `+

+ 3 - 2
src/plugins/muc-views/tests/mute.js

@@ -27,8 +27,9 @@ describe("Groupchats", function () {
                 </message>`;
             _converse.api.connection.get()._dataRecv(mock.createRequest(stanza));
             await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent.trim(), 1000);
+
             expect(view.querySelector('.chat-msg__error').textContent.trim()).toBe(
-                "Your message was not delivered because you weren't allowed to send it.");
+                `Message delivery failed: "Your message was not delivered because you weren't allowed to send it."`);
 
             textarea.value = 'Hello again';
             message_form.onFormSubmitted(new Event('submit'));
@@ -49,7 +50,7 @@ describe("Groupchats", function () {
             await u.waitUntil(() => view.querySelectorAll('.chat-msg__error').length === 2);
             const sel = 'converse-message-history converse-chat-message:last-child .chat-msg__error';
             await u.waitUntil(() => view.querySelector(sel)?.textContent.trim());
-            expect(view.querySelector(sel).textContent.trim()).toBe('Thou shalt not!')
+            expect(view.querySelector(sel).textContent.trim()).toBe(`Message delivery failed: "Thou shalt not!"`);
         }));
 
         it("will see an explanatory message instead of a textarea",

+ 3 - 2
src/plugins/muc-views/tests/retractions.js

@@ -712,7 +712,7 @@ describe("Message Retractions", function () {
             expect(view.model.messages.at(0).get('editable')).toBe(false);
 
             const errmsg = view.querySelector('.chat-msg__error');
-            expect(errmsg.textContent.trim()).toBe("You're not allowed to retract your message.");
+            expect(errmsg.textContent.trim()).toBe(`Message delivery failed: "You're not allowed to retract your message."`);
         }));
 
         it("can be retracted by its author, causing a timeout error in response",
@@ -743,7 +743,8 @@ describe("Message Retractions", function () {
 
             const error_messages = view.querySelectorAll('.chat-msg__error');
             expect(error_messages.length).toBe(1);
-            expect(error_messages[0].textContent.trim()).toBe('A timeout happened while while trying to retract your message.');
+            expect(error_messages[0].textContent.trim()).toBe(
+                'Message delivery failed: "A timeout happened while while trying to retract your message."');
         }));
 
 

+ 1 - 1
src/types/plugins/muc-views/templates/muc-bottom-panel.d.ts

@@ -1,3 +1,3 @@
-declare function _default(o: any): import("lit").TemplateResult<1> | "";
+declare function _default(el: import("../bottom-panel").default): import("lit").TemplateResult<1> | "";
 export default _default;
 //# sourceMappingURL=muc-bottom-panel.d.ts.map

+ 1 - 1
src/types/shared/chat/message-history.d.ts

@@ -12,7 +12,7 @@ export default class MessageHistory extends CustomElement {
     /**
      * @param {(Message)} model
      */
-    renderMessage(model: (import("@converse/headless").Message)): import("lit/directive").DirectiveResult<typeof import("lit/directives/until.js").UntilDirective>;
+    renderMessage(model: (import("@converse/headless").Message)): import("lit/directive.js").DirectiveResult<typeof import("lit/directives/until.js").UntilDirective>;
 }
 import { CustomElement } from 'shared/components/element.js';
 //# sourceMappingURL=message-history.d.ts.map