Browse Source

Add support for sending, receiving and showing MUC private messages...

in the MUC sidebar.

Updates #698
JC Brand 7 months ago
parent
commit
a1ca03479e
50 changed files with 534 additions and 317 deletions
  1. 1 0
      CHANGES.md
  2. 7 2
      src/headless/plugins/chat/message.js
  3. 11 11
      src/headless/plugins/chat/model.js
  4. 5 1
      src/headless/plugins/chat/utils.js
  5. 1 1
      src/headless/plugins/muc/message.js
  6. 24 16
      src/headless/plugins/muc/muc.js
  7. 41 2
      src/headless/plugins/muc/occupant.js
  8. 5 1
      src/headless/plugins/vcard/utils.js
  9. 2 3
      src/headless/shared/model-with-messages.js
  10. 11 0
      src/headless/shared/parsers.js
  11. 2 1
      src/headless/types/plugins/chat/message.d.ts
  12. 0 1
      src/headless/types/plugins/muc/message.d.ts
  13. 5 0
      src/headless/types/plugins/muc/occupant.d.ts
  14. 0 1
      src/headless/types/shared/model-with-messages.d.ts
  15. 2 2
      src/headless/types/utils/form.d.ts
  16. 1 1
      src/headless/types/utils/index.d.ts
  17. 1 1
      src/plugins/adhoc-views/tests/adhoc.js
  18. 17 4
      src/plugins/chatview/bottom-panel.js
  19. 4 1
      src/plugins/chatview/message-form.js
  20. 1 1
      src/plugins/chatview/templates/chat.js
  21. 1 1
      src/plugins/chatview/tests/message-avatar.js
  22. 2 2
      src/plugins/chatview/tests/spoilers.js
  23. 13 13
      src/plugins/chatview/tests/styling.js
  24. 1 0
      src/plugins/muc-views/occupant.js
  25. 50 25
      src/plugins/muc-views/styles/muc-occupant.scss
  26. 1 1
      src/plugins/muc-views/templates/muc-chatarea.js
  27. 30 20
      src/plugins/muc-views/templates/muc-occupant.js
  28. 2 5
      src/plugins/muc-views/tests/actions.js
  29. 4 3
      src/plugins/muc-views/tests/corrections.js
  30. 5 3
      src/plugins/muc-views/tests/http-file-upload.js
  31. 46 43
      src/plugins/muc-views/tests/mentions.js
  32. 126 50
      src/plugins/muc-views/tests/muc-private-messages.js
  33. 2 2
      src/plugins/muc-views/tests/unfurls.js
  34. 2 2
      src/plugins/omemo/tests/corrections.js
  35. 3 2
      src/plugins/omemo/tests/muc.js
  36. 4 4
      src/shared/chat/emoji-picker-content.js
  37. 4 4
      src/shared/chat/emoji-picker.js
  38. 6 4
      src/shared/chat/message-actions.js
  39. 1 1
      src/shared/chat/message-body.js
  40. 12 6
      src/shared/chat/message-history.js
  41. 15 26
      src/shared/chat/message.js
  42. 4 2
      src/shared/chat/templates/message-text.js
  43. 1 1
      src/shared/chat/templates/message.js
  44. 34 32
      src/shared/styles/messages.scss
  45. 1 1
      src/shared/styles/themes/cyberpunk.scss
  46. 2 2
      src/shared/styles/themes/dracula.scss
  47. 10 1
      src/types/plugins/chatview/bottom-panel.d.ts
  48. 1 1
      src/types/shared/chat/emoji-picker.d.ts
  49. 4 1
      src/types/shared/chat/message-history.d.ts
  50. 6 9
      src/types/shared/chat/message.d.ts

+ 1 - 0
CHANGES.md

@@ -2,6 +2,7 @@
 
 
 ## 11.0.0 (Unreleased)
 ## 11.0.0 (Unreleased)
 
 
+- #698: Add support for MUC private messages
 - #1057: Removed the `mobile` view mode. Instead of setting `view_mode` to `mobile`, set it to `fullscreen`.
 - #1057: Removed the `mobile` view mode. Instead of setting `view_mode` to `mobile`, set it to `fullscreen`.
 - #1174: Show MUC avatars in the rooms list
 - #1174: Show MUC avatars in the rooms list
 - #1195: Add actions to quote and copy messages
 - #1195: Add actions to quote and copy messages

+ 7 - 2
src/headless/plugins/chat/message.js

@@ -40,6 +40,7 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
     async initialize () {
     async initialize () {
         super.initialize();
         super.initialize();
         if (!this.checkValidity()) return;
         if (!this.checkValidity()) return;
+        this.chatbox = this.collection?.chatbox;
 
 
         this.initialized = getOpenPromise();
         this.initialized = getOpenPromise();
         if (this.get('file')) {
         if (this.get('file')) {
@@ -142,10 +143,14 @@ class Message extends ModelWithContact(ColorAwareModel(Model)) {
      * under one author heading.
      * under one author heading.
      * A message is considered a followup of it's predecessor when it's a chat
      * A message is considered a followup of it's predecessor when it's a chat
      * message from the same author, within 10 minutes.
      * message from the same author, within 10 minutes.
-     * @returns { boolean }
+     * @returns {boolean}
      */
      */
     isFollowup () {
     isFollowup () {
-        const messages = this.collection.models;
+        const messages = this.collection?.models;
+        if (!messages) {
+            // Happens during tests
+            return false;
+        }
         const idx = messages.indexOf(this);
         const idx = messages.indexOf(this);
         const prev_model = idx ? messages[idx-1] : null;
         const prev_model = idx ? messages[idx-1] : null;
         if (prev_model === null) {
         if (prev_model === null) {

+ 11 - 11
src/headless/plugins/chat/model.js

@@ -180,19 +180,19 @@ class ChatBox extends ModelWithMessages(ModelWithContact(ColorAwareModel(ChatBox
         const text = attrs?.body;
         const text = attrs?.body;
         const body = text ? u.shortnamesToUnicode(text) : undefined;
         const body = text ? u.shortnamesToUnicode(text) : undefined;
         attrs = Object.assign({}, attrs, {
         attrs = Object.assign({}, attrs, {
-            'from': _converse.session.get('bare_jid'),
-            'fullname': _converse.state.xmppstatus.get('fullname'),
-            'id': origin_id,
-            'jid': this.get('jid'),
-            'message': body,
-            'msgid': origin_id,
-            'nickname': this.get('nickname'),
-            'sender': 'me',
-            'time': (new Date()).toISOString(),
-            'type': this.get('message_type'),
             body,
             body,
+            from: _converse.session.get('jid'),
+            fullname: _converse.state.xmppstatus.get('fullname'),
+            id: origin_id,
             is_spoiler,
             is_spoiler,
-            origin_id
+            jid: this.get('jid'),
+            message: body,
+            msgid: origin_id,
+            nick: this.get('nickname'),
+            origin_id,
+            sender: 'me',
+            time: (new Date()).toISOString(),
+            type: this.get('message_type'),
         }, u.getMediaURLsMetadata(text));
         }, u.getMediaURLsMetadata(text));
 
 
         /**
         /**

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

@@ -11,7 +11,7 @@ import _converse from '../../shared/_converse.js';
 import api from '../../shared/api/index.js';
 import api from '../../shared/api/index.js';
 import converse from "../../shared/api/public.js";
 import converse from "../../shared/api/public.js";
 import log from '../../log.js';
 import log from '../../log.js';
-import { isArchived, isHeadline, isServerMessage, } from '../../shared/parsers';
+import { isArchived, isHeadline, isMUCPrivateMessage, isServerMessage, } from '../../shared/parsers';
 import { parseMessage } from './parsers.js';
 import { parseMessage } from './parsers.js';
 import { shouldClearCache } from '../../utils/session.js';
 import { shouldClearCache } from '../../utils/session.js';
 import { CONTROLBOX_TYPE, PRIVATE_CHAT_TYPE } from "../../shared/constants.js";
 import { CONTROLBOX_TYPE, PRIVATE_CHAT_TYPE } from "../../shared/constants.js";
@@ -143,6 +143,10 @@ export async function handleMessageStanza (stanza) {
         const from = stanza.getAttribute('from');
         const from = stanza.getAttribute('from');
         return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
         return log.info(`handleMessageStanza: Ignoring incoming server message from JID: ${from}`);
     }
     }
+    if (await isMUCPrivateMessage(stanza)) {
+        return true;
+    }
+
     let attrs;
     let attrs;
     try {
     try {
         attrs = await parseMessage(stanza);
         attrs = await parseMessage(stanza);

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

@@ -10,8 +10,8 @@ class MUCMessage extends Message {
      */
      */
 
 
     async initialize () { // eslint-disable-line require-await
     async initialize () { // eslint-disable-line require-await
-        this.chatbox = this.collection?.chatbox;
         if (!this.checkValidity()) return;
         if (!this.checkValidity()) return;
+        this.chatbox = this.collection?.chatbox;
 
 
         if (this.get('file')) {
         if (this.get('file')) {
             this.on('change:put', () => this.uploadFile());
             this.on('change:put', () => this.uploadFile());

+ 24 - 16
src/headless/plugins/muc/muc.js

@@ -480,6 +480,8 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
      */
      */
     async handleErrorMessageStanza (stanza) {
     async handleErrorMessageStanza (stanza) {
         const { __ } = _converse;
         const { __ } = _converse;
+
+
         const attrs_or_error = await parseMUCMessage(stanza, this);
         const attrs_or_error = await parseMUCMessage(stanza, this);
         if (u.isErrorObject(attrs_or_error)) {
         if (u.isErrorObject(attrs_or_error)) {
             const { stanza, message } = /** @type {StanzaParseError} */(attrs_or_error);
             const { stanza, message } = /** @type {StanzaParseError} */(attrs_or_error);
@@ -488,18 +490,24 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
         }
         }
 
 
         const attrs = /** @type {MessageAttributes} */(attrs_or_error);
         const attrs = /** @type {MessageAttributes} */(attrs_or_error);
+
         if (!(await this.shouldShowErrorMessage(attrs))) {
         if (!(await this.shouldShowErrorMessage(attrs))) {
             return;
             return;
         }
         }
 
 
-        const message = this.getMessageReferencedByError(attrs);
+        const nick = Strophe.getResourceFromJid(attrs.from);
+        const occupant = nick ? this.getOccupant(nick) : null;
+
+        const model = occupant ? occupant : this;
+
+        const message = model.getMessageReferencedByError(attrs);
         if (message) {
         if (message) {
             const new_attrs = {
             const new_attrs = {
-                'error': attrs.error,
-                'error_condition': attrs.error_condition,
-                'error_text': attrs.error_text,
-                'error_type': attrs.error_type,
-                'editable': false
+                error: attrs.error,
+                error_condition: attrs.error_condition,
+                error_text: attrs.error_text,
+                error_type: attrs.error_type,
+                editable: false
             };
             };
             if (attrs.msgid === message.get('retraction_id')) {
             if (attrs.msgid === message.get('retraction_id')) {
                 // The error message refers to a retraction
                 // The error message refers to a retraction
@@ -530,7 +538,7 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
             }
             }
             message.save(new_attrs);
             message.save(new_attrs);
         } else {
         } else {
-            this.createMessage(attrs);
+            model.createMessage(attrs);
         }
         }
     }
     }
 
 
@@ -1068,15 +1076,15 @@ class MUC extends ModelWithMessages(ColorAwareModel(ChatBoxBase)) {
             is_spoiler,
             is_spoiler,
             origin_id,
             origin_id,
             references,
             references,
-            'id': origin_id,
-            'msgid': origin_id,
-            'from': `${this.get('jid')}/${this.get('nick')}`,
-            'fullname': this.get('nick'),
-            'message': body,
-            'nick': this.get('nick'),
-            'sender': 'me',
-            'type': 'groupchat',
-            'original_text': text,
+            id: origin_id,
+            msgid: origin_id,
+            from: `${this.get('jid')}/${this.get('nick')}`,
+            fullname: this.get('nick'),
+            message: body,
+            nick: this.get('nick'),
+            sender: 'me',
+            type: 'groupchat',
+            original_text: text,
         }, u.getMediaURLsMetadata(text));
         }, u.getMediaURLsMetadata(text));
 
 
         /**
         /**

+ 41 - 2
src/headless/plugins/muc/occupant.js

@@ -6,7 +6,7 @@ import ColorAwareModel from '../../shared/color.js';
 import ModelWithMessages from '../../shared/model-with-messages.js';
 import ModelWithMessages from '../../shared/model-with-messages.js';
 import { AFFILIATIONS, ROLES } from './constants.js';
 import { AFFILIATIONS, ROLES } from './constants.js';
 import MUCMessages from './messages.js';
 import MUCMessages from './messages.js';
-import { isErrorObject } from '../../utils/index.js';
+import u, { isErrorObject } from '../../utils/index.js';
 import { shouldCreateGroupchatMessage } from './utils';
 import { shouldCreateGroupchatMessage } from './utils';
 
 
 /**
 /**
@@ -23,7 +23,7 @@ class MUCOccupant extends ModelWithMessages(ColorAwareModel(Model)) {
     }
     }
 
 
     async initialize() {
     async initialize() {
-        await super.initialize()
+        await super.initialize();
         await this.fetchMessages();
         await this.fetchMessages();
         this.on('change:nick', () => this.setColor());
         this.on('change:nick', () => this.setColor());
         this.on('change:jid', () => this.setColor());
         this.on('change:jid', () => this.setColor());
@@ -142,6 +142,45 @@ class MUCOccupant extends ModelWithMessages(ColorAwareModel(Model)) {
     isSelf() {
     isSelf() {
         return this.get('states').includes('110');
         return this.get('states').includes('110');
     }
     }
+
+    /**
+     * @param {MessageAttributes} [attrs]
+     * @return {Promise<MessageAttributes>}
+     */
+    async getOutgoingMessageAttributes (attrs) {
+        const origin_id = u.getUniqueId();
+        const text = attrs?.body;
+        const body = text ? u.shortnamesToUnicode(text) : undefined;
+        const muc = this.collection.chatroom;
+        const own_occupant = muc.getOwnOccupant();
+        attrs = Object.assign({}, attrs, {
+            body,
+            from: own_occupant.get('from'),
+            fullname: _converse.state.xmppstatus.get('fullname'),
+            id: origin_id,
+            jid: this.get('jid'),
+            message: body,
+            msgid: origin_id,
+            nick: own_occupant.get('nickname'),
+            origin_id,
+            sender: 'me',
+            time: (new Date()).toISOString(),
+            to: this.get('from') ?? `${muc.get('jid')}/${this.get('nick')}`,
+            type: 'chat',
+        }, u.getMediaURLsMetadata(text));
+
+        /**
+         * *Hook* which allows plugins to update the attributes of an outgoing message.
+         * These attributes get set on the {@link Message} and persisted.
+         * @event _converse#getOutgoingMessageAttributes
+         * @param {MUCOccupant} chat
+         *      The chat from which this message will be sent.
+         * @param {MessageAttributes} attrs
+         *      The message attributes, from which the stanza will be created.
+         */
+        attrs = await api.hook('getOutgoingMessageAttributes', this, attrs);
+        return attrs;
+    }
 }
 }
 
 
 export default MUCOccupant;
 export default MUCOccupant;

+ 5 - 1
src/headless/plugins/vcard/utils.js

@@ -80,12 +80,16 @@ export function onOccupantAvatarChanged (occupant) {
  * @param {InstanceType<ReturnType<ModelWithContact>>} model
  * @param {InstanceType<ReturnType<ModelWithContact>>} model
  */
  */
 export async function setVCardOnModel (model) {
 export async function setVCardOnModel (model) {
+    if (model instanceof _converse.exports.MUCMessage) {
+        return setVCardOnMUCMessage(/** @type {MUCMessage} */(model));
+    }
+
     let jid;
     let jid;
     if (model instanceof _converse.exports.Message) {
     if (model instanceof _converse.exports.Message) {
         if (['error', 'info'].includes(model.get('type'))) {
         if (['error', 'info'].includes(model.get('type'))) {
             return;
             return;
         }
         }
-        jid = model.get('from');
+        jid = Strophe.getBareJidFromJid(model.get('from'));
     } else {
     } else {
         jid = model.get('jid');
         jid = model.get('jid');
     }
     }

+ 2 - 3
src/headless/shared/model-with-messages.js

@@ -863,14 +863,13 @@ export default function ModelWithMessages(BaseModel) {
 
 
         /**
         /**
          * Given a {@link Message} return the XML stanza that represents it.
          * Given a {@link Message} return the XML stanza that represents it.
-         * @private
          * @method ChatBox#createMessageStanza
          * @method ChatBox#createMessageStanza
          * @param { Message } message - The message object
          * @param { Message } message - The message object
          */
          */
         async createMessageStanza(message) {
         async createMessageStanza(message) {
             const stanza = $msg({
             const stanza = $msg({
-                'from': api.connection.get().jid,
-                'to': this.get('jid'),
+                'from': message.get('from') || api.connection.get().jid,
+                'to': message.get('to') || this.get('jid'),
                 'type': this.get('message_type'),
                 'type': this.get('message_type'),
                 'id': (message.get('edited') && u.getUniqueId()) || message.get('msgid'),
                 'id': (message.get('edited') && u.getUniqueId()) || message.get('msgid'),
             })
             })

+ 11 - 0
src/headless/shared/parsers.js

@@ -324,6 +324,7 @@ export function getChatMarker (stanza) {
 
 
 /**
 /**
  * @param {Element} stanza
  * @param {Element} stanza
+ * @returns {boolean}
  */
  */
 export function isHeadline (stanza) {
 export function isHeadline (stanza) {
     return stanza.getAttribute('type') === 'headline';
     return stanza.getAttribute('type') === 'headline';
@@ -331,6 +332,16 @@ export function isHeadline (stanza) {
 
 
 /**
 /**
  * @param {Element} stanza
  * @param {Element} stanza
+ * @returns {Promise<boolean>}
+ */
+export async function isMUCPrivateMessage (stanza) {
+    const bare_jid = Strophe.getBareJidFromJid(stanza.getAttribute('from'));
+    return !!(await api.rooms.get(bare_jid));
+}
+
+/**
+ * @param {Element} stanza
+ * @returns {boolean}
  */
  */
 export function isServerMessage (stanza) {
 export function isServerMessage (stanza) {
     if (sizzle(`mentions[xmlns="${Strophe.NS.MENTIONS}"]`, stanza).pop()) {
     if (sizzle(`mentions[xmlns="${Strophe.NS.MENTIONS}"]`, stanza).pop()) {

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

@@ -158,6 +158,7 @@ declare class Message extends Message_base {
     };
     };
     file: any;
     file: any;
     initialize(): Promise<void>;
     initialize(): Promise<void>;
+    chatbox: any;
     initialized: any;
     initialized: any;
     setContact(): Promise<void>;
     setContact(): Promise<void>;
     /**
     /**
@@ -191,7 +192,7 @@ declare class Message extends Message_base {
      * under one author heading.
      * under one author heading.
      * A message is considered a followup of it's predecessor when it's a chat
      * A message is considered a followup of it's predecessor when it's a chat
      * message from the same author, within 10 minutes.
      * message from the same author, within 10 minutes.
-     * @returns { boolean }
+     * @returns {boolean}
      */
      */
     isFollowup(): boolean;
     isFollowup(): boolean;
     getDisplayName(): any;
     getDisplayName(): any;

+ 0 - 1
src/headless/types/plugins/muc/message.d.ts

@@ -1,6 +1,5 @@
 export default MUCMessage;
 export default MUCMessage;
 declare class MUCMessage extends Message {
 declare class MUCMessage extends Message {
-    chatbox: any;
     /**
     /**
      * Determines whether this messsage may be moderated,
      * Determines whether this messsage may be moderated,
      * based on configuration settings and server support.
      * based on configuration settings and server support.

+ 5 - 0
src/headless/types/plugins/muc/occupant.d.ts

@@ -234,6 +234,11 @@ declare class MUCOccupant extends MUCOccupant_base {
     isMember(): boolean;
     isMember(): boolean;
     isModerator(): boolean;
     isModerator(): boolean;
     isSelf(): any;
     isSelf(): any;
+    /**
+     * @param {MessageAttributes} [attrs]
+     * @return {Promise<MessageAttributes>}
+     */
+    getOutgoingMessageAttributes(attrs?: any): Promise<any>;
 }
 }
 import { Model } from '@converse/skeletor';
 import { Model } from '@converse/skeletor';
 import MUCMessages from './messages.js';
 import MUCMessages from './messages.js';

+ 0 - 1
src/headless/types/shared/model-with-messages.d.ts

@@ -210,7 +210,6 @@ export default function ModelWithMessages<T extends import("./types").ModelExten
         handleReceipt(attrs: import("../plugins/chat/parsers").MessageAttributes): boolean;
         handleReceipt(attrs: import("../plugins/chat/parsers").MessageAttributes): boolean;
         /**
         /**
          * Given a {@link Message} return the XML stanza that represents it.
          * Given a {@link Message} return the XML stanza that represents it.
-         * @private
          * @method ChatBox#createMessageStanza
          * @method ChatBox#createMessageStanza
          * @param { Message } message - The message object
          * @param { Message } message - The message object
          */
          */

+ 2 - 2
src/headless/types/utils/form.d.ts

@@ -12,13 +12,13 @@ export function webForm2xForm(field: HTMLInputElement | HTMLTextAreaElement | HT
 /**
 /**
  * Returns the current word being written in the input element
  * Returns the current word being written in the input element
  * @method u#getCurrentWord
  * @method u#getCurrentWord
- * @param {HTMLInputElement} input - The HTMLElement in which text is being entered
+ * @param {HTMLInputElement|HTMLTextAreaElement} input - The HTMLElement in which text is being entered
  * @param {number} [index] - An optional rightmost boundary index. If given, the text
  * @param {number} [index] - An optional rightmost boundary index. If given, the text
  *  value of the input element will only be considered up until this index.
  *  value of the input element will only be considered up until this index.
  * @param {string|RegExp} [delineator] - An optional string delineator to
  * @param {string|RegExp} [delineator] - An optional string delineator to
  *  differentiate between words.
  *  differentiate between words.
  */
  */
-export function getCurrentWord(input: HTMLInputElement, index?: number, delineator?: string | RegExp): string;
+export function getCurrentWord(input: HTMLInputElement | HTMLTextAreaElement, index?: number, delineator?: string | RegExp): string;
 /**
 /**
  * @param {string} s
  * @param {string} s
  */
  */

+ 1 - 1
src/headless/types/utils/index.d.ts

@@ -76,7 +76,7 @@ declare const _default: {
     decodeHTMLEntities(str: string): string;
     decodeHTMLEntities(str: string): string;
     getSelectValues(select: HTMLSelectElement): string[];
     getSelectValues(select: HTMLSelectElement): string[];
     webForm2xForm(field: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): Element;
     webForm2xForm(field: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement): Element;
-    getCurrentWord(input: HTMLInputElement, index?: number, delineator?: string | RegExp): string;
+    getCurrentWord(input: HTMLInputElement | HTMLTextAreaElement, index?: number, delineator?: string | RegExp): string;
     isMentionBoundary(s: string): boolean;
     isMentionBoundary(s: string): boolean;
     replaceCurrentWord(input: HTMLInputElement, new_value: string): void;
     replaceCurrentWord(input: HTMLInputElement, new_value: string): void;
     placeCaretAtEnd(textarea: HTMLTextAreaElement): void;
     placeCaretAtEnd(textarea: HTMLTextAreaElement): void;

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

@@ -471,7 +471,7 @@ describe("Ad-hoc commands", function () {
 
 
 describe("Ad-hoc commands consisting of multiple steps", function () {
 describe("Ad-hoc commands consisting of multiple steps", function () {
 
 
-    beforeEach(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
 
     it("can be queried and executed via a modal", mock.initConverse([], {}, async (_converse) => {
     it("can be queried and executed via a modal", mock.initConverse([], {}, async (_converse) => {
         const { api } = _converse;
         const { api } = _converse;

+ 17 - 4
src/plugins/chatview/bottom-panel.js

@@ -14,6 +14,17 @@ import './styles/chat-bottom-panel.scss';
 
 
 export default class ChatBottomPanel extends CustomElement {
 export default class ChatBottomPanel extends CustomElement {
 
 
+    constructor () {
+        super();
+        this.model = null;
+    }
+
+    static get properties () {
+        return {
+            model: { type: Object }
+        }
+    }
+
     async connectedCallback () {
     async connectedCallback () {
         super.connectedCallback();
         super.connectedCallback();
         await this.initialize();
         await this.initialize();
@@ -23,7 +34,6 @@ export default class ChatBottomPanel extends CustomElement {
     }
     }
 
 
     async initialize () {
     async initialize () {
-        this.model = await api.chatboxes.get(this.getAttribute('jid'));
         await this.model.initialized;
         await this.model.initialized;
         this.listenTo(this.model, 'change:num_unread', () => this.requestUpdate());
         this.listenTo(this.model, 'change:num_unread', () => this.requestUpdate());
         this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker);
         this.listenTo(this.model, 'emoji-picker-autocomplete', this.autocompleteInPicker);
@@ -65,10 +75,13 @@ export default class ChatBottomPanel extends CustomElement {
     }
     }
 
 
     /**
     /**
-     * @param {HTMLTextAreaElement} input
-     * @param {string} value
+     * @typedef {Object} AutocompleteInPickerEvent
+     * @property {HTMLTextAreaElement} input
+     * @property {string} value
+     * @param {AutocompleteInPickerEvent} ev
      */
      */
-    async autocompleteInPicker (input, value) {
+    async autocompleteInPicker (ev) {
+        const { input, value } = ev;
         await api.emojis.initialize();
         await api.emojis.initialize();
         const emoji_picker = /** @type {EmojiPicker} */(this.querySelector('converse-emoji-picker'));
         const emoji_picker = /** @type {EmojiPicker} */(this.querySelector('converse-emoji-picker'));
         if (emoji_picker) {
         if (emoji_picker) {

+ 4 - 1
src/plugins/chatview/message-form.js

@@ -146,7 +146,10 @@ export default class MessageForm extends CustomElement {
                 if (value.startsWith(':')) {
                 if (value.startsWith(':')) {
                     ev.preventDefault();
                     ev.preventDefault();
                     ev.stopPropagation();
                     ev.stopPropagation();
-                    this.model.trigger('emoji-picker-autocomplete', ev.target, value);
+                    this.model.trigger(
+                        'emoji-picker-autocomplete',
+                        { target: ev.target, value },
+                    );
                 }
                 }
             } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
             } else if (ev.keyCode === converse.keycodes.FORWARD_SLASH) {
                 // Forward slash is used to run commands. Nothing to do here.
                 // Forward slash is used to run commands. Nothing to do here.

+ 1 - 1
src/plugins/chatview/templates/chat.js

@@ -23,7 +23,7 @@ export default (o) => html`
                                 chat_type="${CHATROOMS_TYPE}"
                                 chat_type="${CHATROOMS_TYPE}"
                             ></converse-chat-help></div>` : '' }
                             ></converse-chat-help></div>` : '' }
                 </div>
                 </div>
-                <converse-chat-bottom-panel jid="${o.jid}" class="bottom-panel"> </converse-chat-bottom-panel>
+                <converse-chat-bottom-panel .model="${o.model}" class="bottom-panel"> </converse-chat-bottom-panel>
             </div>
             </div>
         ` : '' }
         ` : '' }
     </div>
     </div>

+ 1 - 1
src/plugins/chatview/tests/message-avatar.js

@@ -53,7 +53,7 @@ describe("A Chat Message", function () {
         await u.waitUntil(() => el.textContent === 'W');
         await u.waitUntil(() => el.textContent === 'W');
 
 
         // Change own nickname and see that it reflects
         // Change own nickname and see that it reflects
-        const own_jid = _converse.session.get('bare_jid');
+        const own_jid = _converse.session.get('jid');
         const { xmppstatus } = _converse.state;
         const { xmppstatus } = _converse.state;
 
 
         xmppstatus.vcard.set('fullname', 'Restless Romeo');
         xmppstatus.vcard.set('fullname', 'Restless Romeo');

+ 2 - 2
src/plugins/chatview/tests/spoilers.js

@@ -126,7 +126,7 @@ describe("A spoiler message", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13
             keyCode: 13
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await new Promise(resolve => api.listen.on('sendMessage', resolve));
 
 
         /* Test the XML stanza
         /* Test the XML stanza
          *
          *
@@ -207,7 +207,7 @@ describe("A spoiler message", function () {
             preventDefault: function preventDefault () {},
             preventDefault: function preventDefault () {},
             keyCode: 13
             keyCode: 13
         });
         });
-        await new Promise(resolve => view.model.messages.once('rendered', resolve));
+        await new Promise(resolve => api.listen.on('sendMessage', resolve));
 
 
         const stanza = api.connection.get().send.calls.argsFor(0)[0];
         const stanza = api.connection.get().send.calls.argsFor(0)[0];
         expect(Strophe.serialize(stanza)).toBe(
         expect(Strophe.serialize(stanza)).toBe(

+ 13 - 13
src/plugins/chatview/tests/styling.js

@@ -217,7 +217,7 @@ describe("An incoming chat Message", function () {
         msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``;
         msg_text = `Here's a code block: \n\`\`\`\nInside the code-block, <code>hello</code> we don't enable *styling hints* like ~these~\n\`\`\``;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text .block').length);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
             'Here\'s a code block: \n'+
             'Here\'s a code block: \n'+
@@ -228,7 +228,7 @@ describe("An incoming chat Message", function () {
         msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^";
         msg_text = "```\nignored\n(println \"Hello, world!\")\n```\nThis should show up as monospace, preformatted text ^";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text .block').length === 2);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
             '<div class="styling-directive">```</div>'+
             '<div class="styling-directive">```</div>'+
@@ -264,7 +264,7 @@ describe("An incoming chat Message", function () {
         msg_text = `> https://conversejs.org\n> https://conversejs.org`;
         msg_text = `> https://conversejs.org\n> https://conversejs.org`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 1);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 1);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
             '<blockquote>'+
             '<blockquote>'+
@@ -275,7 +275,7 @@ describe("An incoming chat Message", function () {
         msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`;
         msg_text = `> This is quoted text\n>This is also quoted\nThis is not quoted`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 2);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
             '<blockquote>This is quoted text\n\u200BThis is also quoted</blockquote>\nThis is not quoted');
             '<blockquote>This is quoted text\n\u200BThis is also quoted</blockquote>\nThis is not quoted');
@@ -283,7 +283,7 @@ describe("An incoming chat Message", function () {
         msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`;
         msg_text = `> This is *quoted* text\n>This is \`also _quoted_\`\nThis is not quoted`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 3);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 3);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
             '<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n\u200B'+
             '<blockquote>This is <span class="styling-directive">*</span><b>quoted</b><span class="styling-directive">*</span> text\n\u200B'+
@@ -293,21 +293,21 @@ describe("An incoming chat Message", function () {
         msg_text = `> > This is doubly quoted text`;
         msg_text = `> > This is doubly quoted text`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 4);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 5);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
 
 
         msg_text = `>> This is doubly quoted text`;
         msg_text = `>> This is doubly quoted text`;
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 5);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 7);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe("<blockquote><blockquote>This is doubly quoted text</blockquote></blockquote>");
 
 
         msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^";
         msg_text = ">```\n>ignored\n> <span></span> (println \"Hello, world!\")\n>```\n> This should show up as monospace, preformatted text ^";
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 6);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 8);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
             '<blockquote>'+
             '<blockquote>'+
@@ -320,7 +320,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!';
         msg_text = '> ```\n> (println "Hello, world!")\n\nThe entire blockquote is a preformatted text block, but this line is plaintext!';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 7);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 9);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
         expect(msg_el.innerHTML.replace(/<!-.*?->/g, '')).toBe(
             '<blockquote>```\n\u200B\u200B(println "Hello, world!")</blockquote>\n\n'+
             '<blockquote>```\n\u200B\u200B(println "Hello, world!")</blockquote>\n\n'+
@@ -329,7 +329,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
         msg_text = '> Also, icons.js is loaded from /dist, instead of dist.\nhttps://conversejs.org/docs/html/configuration.html#assets-path'
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 8);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 10);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
             '<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+
             '<blockquote>Also, icons.js is loaded from /dist, instead of dist.</blockquote>\n'+
@@ -338,7 +338,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
         msg_text = '> Where is it located?\ngeo:37.786971,-122.399677';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 9);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 11);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
             '<blockquote>Where is it located?</blockquote>\n'+
             '<blockquote>Where is it located?</blockquote>\n'+
@@ -348,7 +348,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> What do you think of it?\n :poop:';
         msg_text = '> What do you think of it?\n :poop:';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 10);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 12);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
             '<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>');
             '<blockquote>What do you think of it?</blockquote>\n <span title=":poop:">💩</span>');
@@ -356,7 +356,7 @@ describe("An incoming chat Message", function () {
         msg_text = '> What do you think of it?\n~hello~';
         msg_text = '> What do you think of it?\n~hello~';
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         msg = mock.createChatMessage(_converse, contact_jid, msg_text)
         await _converse.handleMessageStanza(msg);
         await _converse.handleMessageStanza(msg);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 11);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text blockquote').length === 13);
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         msg_el = Array.from(view.querySelectorAll('converse-chat-message-body')).pop();
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
         await u.waitUntil(() => msg_el.innerHTML.replace(/<!-.*?->/g, '') ===
             '<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>');
             '<blockquote>What do you think of it?</blockquote>\n<span class="styling-directive">~</span><del>hello</del><span class="styling-directive">~</span>');

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

@@ -8,6 +8,7 @@ const { u } = converse.env;
 import './styles/muc-occupant.scss';
 import './styles/muc-occupant.scss';
 
 
 export default class MUCOccupant extends CustomElement {
 export default class MUCOccupant extends CustomElement {
+
     constructor() {
     constructor() {
         super();
         super();
         this.muc_jid = null;
         this.muc_jid = null;

+ 50 - 25
src/plugins/muc-views/styles/muc-occupant.scss

@@ -2,37 +2,62 @@
     converse-muc-occupant {
     converse-muc-occupant {
         width: 100%;
         width: 100%;
 
 
-        .sidebar-heading {
+        .sidebar-occupant {
             display: flex;
             display: flex;
-            justify-content: space-between;
-            padding: 0.5em;
-            padding-top: 0.4em;
+            flex-direction: column;
+            height: 100%;
 
 
-            .back-button {
-                font-size: 0.75em;
+            .sidebar-heading {
+                display: flex;
+                justify-content: space-between;
                 padding: 0.5em;
                 padding: 0.5em;
-                padding-top: 0.2em;
-            }
-        }
+                padding-top: 0.4em;
 
 
-        .occupant-details {
-            padding-left: 0.3em;
-            li {
-                display: flex;
-                justify-content: center;
-                margin-bottom: 0.5em;
+                .back-button {
+                    font-size: 0.75em;
+                    padding: 0.5em;
+                    padding-top: 0.2em;
+                }
             }
             }
-            .row {
-                padding-left: 1em;
-            }
-            .occupant-details-nickname {
-                font-size: var(--font-size-huge);
-            }
-            .occupant-details-jid {
-                font-size: var(--font-size-small);
+
+            .occupant-details {
+                padding-left: 0.3em;
+                padding-bottom: 1em;
+                border-bottom: 0.05em solid var(--secondary-color);
+                li {
+                    display: flex;
+                    justify-content: center;
+                    margin-bottom: 0.5em;
+                }
+                .row {
+                    padding-left: 1em;
+                }
+                .occupant-details-nickname {
+                    font-size: var(--font-size-huge);
+                }
+                .occupant-details-jid {
+                    font-size: var(--font-size-small);
+                }
+                .badge {
+                    margin: 0.1em;
+                }
             }
             }
-            .badge {
-                margin: 0.1em;
+
+            .bottom-panel {
+                .chat-message-form {
+                    border-right: none;
+                    border-top: 0.2em solid var(--secondary-color);
+                    .send-button {
+                        background-color: var(--secondary-color);
+                    }
+                    .toolbar-buttons {
+                        converse-icon {
+                            svg {
+                                fill: var(--secondary-color) !important;
+                            }
+                        }
+                    }
+                }
             }
             }
         }
         }
     }
     }

+ 1 - 1
src/plugins/muc-views/templates/muc-chatarea.js

@@ -41,7 +41,7 @@ export default (el) => {
                             chat_type="${CHATROOMS_TYPE}"
                             chat_type="${CHATROOMS_TYPE}"
                         ></converse-chat-help></div>` : '' }
                         ></converse-chat-help></div>` : '' }
             </div>
             </div>
-            <converse-muc-bottom-panel jid="${el.jid}" class="bottom-panel"></converse-muc-bottom-panel>
+            <converse-muc-bottom-panel .model=${el.model} class="bottom-panel"></converse-muc-bottom-panel>
         </div>
         </div>
         ${el.model ? html`
         ${el.model ? html`
             <converse-split-resize></converse-split-resize>
             <converse-split-resize></converse-split-resize>

+ 30 - 20
src/plugins/muc-views/templates/muc-occupant.js

@@ -17,7 +17,8 @@ export default (el) => {
     const affiliation = u.firstCharToUpperCase(el.model?.get('affiliation'));
     const affiliation = u.firstCharToUpperCase(el.model?.get('affiliation'));
     const hats = el.model?.get('hats')?.length ? el.model.get('hats').map(({ title }) => title) : [];
     const hats = el.model?.get('hats')?.length ? el.model.get('hats').map(({ title }) => title) : [];
 
 
-    return html`<div class="sidebar-heading">
+    return html` <span class="sidebar-occupant">
+        <div class="sidebar-heading">
             <span
             <span
                 ><button
                 ><button
                     type="button"
                     type="button"
@@ -61,23 +62,32 @@ export default (el) => {
                   </div>
                   </div>
               </div>`
               </div>`
             : ''}
             : ''}
-        <div class="row">
-            <div class="col">
-                <ul class="occupant-details">
-                    ${el.model
-                        ? html` ${nick ? html`<li class="occupant-details-nickname">${nick}</li>` : ''}
-                              <li class="occupant-details-jid">
-                                  ${jid ? html`<a @click="${() => el.openChat(jid)}">${jid}</a>` : ''}
-                              </li>
-                              <li>
-                                  <span class="badge text-bg-primary">${affiliation}</span>
-                                  <span class="badge text-bg-secondary">${role}</span>
-                                  ${hats.length
-                                      ? html`${hats.map((h) => html`<span class="badge text-bg-info">${h}</span>`)}`
-                                      : ''}
-                              </li>`
-                        : html`<li>${i18n_no_occupant}</li>`}
-                </ul>
-            </div>
-        </div>`;
+        <ul class="occupant-details">
+            ${el.model
+                ? html` ${nick ? html`<li class="occupant-details-nickname">${nick}</li>` : ''}
+                      <li class="occupant-details-jid">
+                          ${jid ? html`<a @click="${() => el.openChat(jid)}">${jid}</a>` : ''}
+                      </li>
+                      <li>
+                          <span class="badge text-bg-primary">${affiliation}</span>
+                          <span class="badge text-bg-secondary">${role}</span>
+                          ${hats.length
+                              ? html`${hats.map((h) => html`<span class="badge text-bg-info">${h}</span>`)}`
+                              : ''}
+                      </li>`
+                : html`<li>${i18n_no_occupant}</li>`}
+        </ul>
+
+        ${el.model
+            ? html`<div class="chat-body">
+                  <div class="chat-content chat-content-sendbutton" aria-live="polite">
+                      <converse-chat-content
+                          class="chat-content__messages"
+                          .model="${el.model}"
+                      ></converse-chat-content>
+                  </div>
+                  <converse-chat-bottom-panel .model=${el.model} class="bottom-panel"> </converse-chat-bottom-panel>
+              </div>`
+            : ''}
+    </span>`;
 };
 };

+ 2 - 5
src/plugins/muc-views/tests/actions.js

@@ -37,8 +37,7 @@ describe("A Groupchat Message", function () {
                 xmlns="jabber:client">
                 xmlns="jabber:client">
                 <body>${firstMessageText}</body>
                 <body>${firstMessageText}</body>
             </message>`);
             </message>`);
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
-        let firstAction = view.querySelector('.chat-msg__action-copy');
+        let firstAction = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action-copy'));
         expect(firstAction).not.toBeNull();
         expect(firstAction).not.toBeNull();
         firstAction.click();
         firstAction.click();
         expect(spyClipboard).toHaveBeenCalledOnceWith(firstMessageText);
         expect(spyClipboard).toHaveBeenCalledOnceWith(firstMessageText);
@@ -136,9 +135,7 @@ describe("A Groupchat Message", function () {
                 <body>But soft, what light through yonder airlock breaks?</body>
                 <body>But soft, what light through yonder airlock breaks?</body>
             </message>`);
             </message>`);
 
 
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
-        // Quoting should be available before losing permission to speak
-        expect(view.querySelector('.chat-msg__action-quote')).not.toBeNull();
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg .chat-msg__action-quote').length === 1);
 
 
         const presence = stx`
         const presence = stx`
             <presence
             <presence

+ 4 - 3
src/plugins/muc-views/tests/corrections.js

@@ -173,8 +173,9 @@ describe("A Groupchat Message", function () {
     it("can be sent as a correction by using the up arrow",
     it("can be sent as a correction by using the up arrow",
             mock.initConverse([], {}, async function (_converse) {
             mock.initConverse([], {}, async function (_converse) {
 
 
+        const nick = 'romeo'
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
-        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+        await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
         const view = _converse.chatboxviews.get(muc_jid);
         const view = _converse.chatboxviews.get(muc_jid);
         const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
         const textarea = await u.waitUntil(() => view.querySelector('textarea.chat-textarea'));
         expect(textarea.value).toBe('');
         expect(textarea.value).toBe('');
@@ -219,8 +220,8 @@ describe("A Groupchat Message", function () {
 
 
         expect(_converse.api.connection.get().send).toHaveBeenCalled();
         expect(_converse.api.connection.get().send).toHaveBeenCalled();
         const msg = _converse.api.connection.get().send.calls.all()[0].args[0];
         const msg = _converse.api.connection.get().send.calls.all()[0].args[0];
-        expect(Strophe.serialize(msg))
-        .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+        expect(Strophe.serialize(msg)).toBe(
+            `<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
                 `to="lounge@montague.lit" type="groupchat" `+
                 `to="lounge@montague.lit" type="groupchat" `+
                 `xmlns="jabber:client">`+
                 `xmlns="jabber:client">`+
                     `<body>But soft, what light through yonder window breaks?</body>`+
                     `<body>But soft, what light through yonder window breaks?</body>`+

+ 5 - 3
src/plugins/muc-views/tests/http-file-upload.js

@@ -54,15 +54,17 @@ describe("XEP-0363: HTTP File Upload", function () {
                     const send_backup = XMLHttpRequest.prototype.send;
                     const send_backup = XMLHttpRequest.prototype.send;
                     const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
                     const IQ_stanzas = _converse.api.connection.get().IQ_stanzas;
 
 
+                    const muc_jid = 'lounge@montague.lit';
+                    const nick = 'romeo';
                     await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
                     await mock.waitUntilDiscoConfirmed(_converse, _converse.domain, [], [], ['upload.montague.tld'], 'items');
                     await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
                     await mock.waitUntilDiscoConfirmed(_converse, 'upload.montague.tld', [], [Strophe.NS.HTTPUPLOAD], []);
-                    await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
+                    await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
 
 
                     // Wait until MAM query has been sent out
                     // Wait until MAM query has been sent out
                     const sent_stanzas = _converse.api.connection.get().sent_stanzas;
                     const sent_stanzas = _converse.api.connection.get().sent_stanzas;
                     await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
                     await u.waitUntil(() => sent_stanzas.filter(s => sizzle(`[xmlns="${Strophe.NS.MAM}"]`, s).length).pop());
 
 
-                    const view = _converse.chatboxviews.get('lounge@montague.lit');
+                    const view = _converse.chatboxviews.get(muc_jid);
                     const file = {
                     const file = {
                         'type': 'image/jpeg',
                         'type': 'image/jpeg',
                         'size': '23456' ,
                         'size': '23456' ,
@@ -124,7 +126,7 @@ describe("XEP-0363: HTTP File Upload", function () {
                     await u.waitUntil(() => sent_stanza, 1000);
                     await u.waitUntil(() => sent_stanza, 1000);
                     expect(Strophe.serialize(sent_stanza)).toBe(
                     expect(Strophe.serialize(sent_stanza)).toBe(
                         `<message `+
                         `<message `+
-                            `from="romeo@montague.lit/orchard" `+
+                            `from="${muc_jid}/${nick}" `+
                             `id="${sent_stanza.getAttribute("id")}" `+
                             `id="${sent_stanza.getAttribute("id")}" `+
                             `to="lounge@montague.lit" `+
                             `to="lounge@montague.lit" `+
                             `type="groupchat" `+
                             `type="groupchat" `+

+ 46 - 43
src/plugins/muc-views/tests/mentions.js

@@ -74,7 +74,7 @@ describe("An incoming groupchat message", function () {
                 <reference xmlns="urn:xmpp:reference:0" begin="1" end="7" type="mention" uri="xmpp:gibson@montague.lit"/>
                 <reference xmlns="urn:xmpp:reference:0" begin="1" end="7" type="mention" uri="xmpp:gibson@montague.lit"/>
             </message>`.tree());
             </message>`.tree());
 
 
-        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text').length === 2);
+        await u.waitUntil(() => view.querySelectorAll('.chat-msg__text .mention').length === 4);
 
 
         message = sizzle('converse-chat-message:last .chat-msg__text', view).pop();
         message = sizzle('converse-chat-message:last .chat-msg__text', view).pop();
         expect(message.classList.length).toEqual(1);
         expect(message.classList.length).toEqual(1);
@@ -114,14 +114,14 @@ describe("An incoming groupchat message", function () {
         const sent_stanzas = _converse.api.connection.get().sent_stanzas;
         const sent_stanzas = _converse.api.connection.get().sent_stanzas;
         const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
         const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
         expect(Strophe.serialize(msg))
         expect(Strophe.serialize(msg))
-            .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
-                    `to="lounge@montague.lit" type="groupchat" `+
-                    `xmlns="jabber:client">`+
-                        `<body>hello ThUnD3r|Gr33n</body>`+
-                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                        `<reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>`+
-                        `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                    `</message>`);
+            .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
+                `to="lounge@montague.lit" type="groupchat" `+
+                `xmlns="jabber:client">`+
+                    `<body>hello ThUnD3r|Gr33n</body>`+
+                    `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                    `<reference begin="6" end="19" type="mention" uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n" xmlns="urn:xmpp:reference:0"/>`+
+                    `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                `</message>`);
 
 
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         const message = await u.waitUntil(() => view.querySelector('.chat-msg__text'));
         expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('hello <span class="mention" data-uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n">ThUnD3r|Gr33n</span>');
         expect(message.innerHTML.replace(/<!-.*?->/g, '')).toBe('hello <span class="mention" data-uri="xmpp:lounge@montague.lit/ThUnD3r%7CGr33n">ThUnD3r|Gr33n</span>');
@@ -331,8 +331,9 @@ describe("A sent groupchat message", function () {
         it("properly encodes the URIs in sent out references",
         it("properly encodes the URIs in sent out references",
                 mock.initConverse([], {}, async function (_converse) {
                 mock.initConverse([], {}, async function (_converse) {
 
 
+            const nick = 'tom';
             const muc_jid = 'lounge@montague.lit';
             const muc_jid = 'lounge@montague.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'tom');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
             const view = _converse.chatboxviews.get(muc_jid);
             const view = _converse.chatboxviews.get(muc_jid);
             _converse.api.connection.get()._dataRecv(mock.createRequest(
             _converse.api.connection.get()._dataRecv(mock.createRequest(
                 stx`<presence
                 stx`<presence
@@ -359,7 +360,7 @@ describe("A sent groupchat message", function () {
             const sent_stanzas = _converse.api.connection.get().sent_stanzas;
             const sent_stanzas = _converse.api.connection.get().sent_stanzas;
             const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
             const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
             expect(Strophe.serialize(msg))
             expect(Strophe.serialize(msg))
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
+                    .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
                         `to="lounge@montague.lit" type="groupchat" `+
                         `to="lounge@montague.lit" type="groupchat" `+
                         `xmlns="jabber:client">`+
                         `xmlns="jabber:client">`+
                             `<body>hello Link Mauve</body>`+
                             `<body>hello Link Mauve</body>`+
@@ -372,6 +373,7 @@ describe("A sent groupchat message", function () {
         it("can get corrected and given new references",
         it("can get corrected and given new references",
                 mock.initConverse([], {}, async function (_converse) {
                 mock.initConverse([], {}, async function (_converse) {
 
 
+            const nick = 'tom';
             const muc_jid = 'lounge@montague.lit';
             const muc_jid = 'lounge@montague.lit';
 
 
             // Making the MUC non-anonymous so that real JIDs are included
             // Making the MUC non-anonymous so that real JIDs are included
@@ -426,16 +428,16 @@ describe("A sent groupchat message", function () {
             const sent_stanzas = _converse.api.connection.get().sent_stanzas;
             const sent_stanzas = _converse.api.connection.get().sent_stanzas;
             const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
             const msg = await u.waitUntil(() => sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop());
             expect(Strophe.serialize(msg))
             expect(Strophe.serialize(msg))
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
-                        `to="lounge@montague.lit" type="groupchat" `+
-                        `xmlns="jabber:client">`+
-                            `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                            `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                        `</message>`);
+                .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
+                    `to="lounge@montague.lit" type="groupchat" `+
+                    `xmlns="jabber:client">`+
+                        `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                        `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                        `<reference begin="18" end="26" type="mention" uri="xmpp:mr.robot@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                        `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                    `</message>`);
 
 
             const action = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action'));
             const action = await u.waitUntil(() => view.querySelector('.chat-msg .chat-msg__action'));
             action.style.opacity = 1;
             action.style.opacity = 1;
@@ -453,24 +455,25 @@ describe("A sent groupchat message", function () {
 
 
             const correction = sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop();
             const correction = sent_stanzas.filter(s => s.nodeName.toLowerCase() === 'message').pop();
             expect(Strophe.serialize(correction))
             expect(Strophe.serialize(correction))
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${correction.getAttribute("id")}" `+
-                        `to="lounge@montague.lit" type="groupchat" `+
-                        `xmlns="jabber:client">`+
-                            `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
-                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                            `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
-                            `<replace id="${msg.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
-                            `<origin-id id="${correction.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                        `</message>`);
+                .toBe(`<message from="${muc_jid}/${nick}" id="${correction.getAttribute("id")}" `+
+                    `to="lounge@montague.lit" type="groupchat" `+
+                    `xmlns="jabber:client">`+
+                        `<body>hello z3r0 gibson sw0rdf1sh, how are you?</body>`+
+                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<reference begin="6" end="10" type="mention" uri="xmpp:z3r0@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                        `<reference begin="11" end="17" type="mention" uri="xmpp:gibson@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                        `<reference begin="18" end="27" type="mention" uri="xmpp:sw0rdf1sh@montague.lit" xmlns="urn:xmpp:reference:0"/>`+
+                        `<replace id="${msg.getAttribute("id")}" xmlns="urn:xmpp:message-correct:0"/>`+
+                        `<origin-id id="${correction.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                    `</message>`);
         }));
         }));
 
 
         it("includes a XEP-0372 references to that person",
         it("includes a XEP-0372 references to that person",
                 mock.initConverse([], {}, async function (_converse) {
                 mock.initConverse([], {}, async function (_converse) {
 
 
+            const nick = 'romeo';
             const muc_jid = 'lounge@montague.lit';
             const muc_jid = 'lounge@montague.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
+            await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
             const view = _converse.chatboxviews.get(muc_jid);
             const view = _converse.chatboxviews.get(muc_jid);
 
 
             ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
             ['z3r0', 'mr.robot', 'gibson', 'sw0rdf1sh'].forEach((nick) => {
@@ -501,16 +504,16 @@ describe("A sent groupchat message", function () {
 
 
             const msg = _converse.api.connection.get().send.calls.all()[1].args[0];
             const msg = _converse.api.connection.get().send.calls.all()[1].args[0];
             expect(Strophe.serialize(msg))
             expect(Strophe.serialize(msg))
-                .toBe(`<message from="romeo@montague.lit/orchard" id="${msg.getAttribute("id")}" `+
-                        `to="lounge@montague.lit" type="groupchat" `+
-                        `xmlns="jabber:client">`+
-                            `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
-                            `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
-                            `<reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>`+
-                            `<reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>`+
-                            `<reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>`+
-                            `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
-                        `</message>`);
+                .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" `+
+                    `to="lounge@montague.lit" type="groupchat" `+
+                    `xmlns="jabber:client">`+
+                        `<body>hello z3r0 gibson mr.robot, how are you?</body>`+
+                        `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
+                        `<reference begin="6" end="10" type="mention" uri="xmpp:${muc_jid}/z3r0" xmlns="urn:xmpp:reference:0"/>`+
+                        `<reference begin="11" end="17" type="mention" uri="xmpp:${muc_jid}/gibson" xmlns="urn:xmpp:reference:0"/>`+
+                        `<reference begin="18" end="26" type="mention" uri="xmpp:${muc_jid}/mr.robot" xmlns="urn:xmpp:reference:0"/>`+
+                        `<origin-id id="${msg.querySelector('origin-id').getAttribute("id")}" xmlns="urn:xmpp:sid:0"/>`+
+                    `</message>`);
         }));
         }));
     });
     });
 
 

+ 126 - 50
src/plugins/muc-views/tests/muc-private-messages.js

@@ -1,63 +1,139 @@
 /*global mock, converse */
 /*global mock, converse */
 const { stx, u } = converse.env;
 const { stx, u } = converse.env;
 
 
-describe('When receiving a MUC private message', function () {
-    it(
-        "doesn't appear in the main MUC chatarea",
-        mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
-            const muc_jid = 'coven@chat.shakespeare.lit';
-            await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
-            const view = _converse.chatboxviews.get(muc_jid);
-
-            _converse.api.connection.get()._dataRecv(
-                mock.createRequest(stx`
-                    <presence
-                        from="${muc_jid}/firstwitch"
-                        id="${u.getUniqueId()}"
-                        to="${_converse.jid}"
-                        xmlns="jabber:client">
-                    <x xmlns="http://jabber.org/protocol/muc#user">
-                        <item affiliation="owner" role="moderator"/>
-                    </x>
-                    </presence>`)
-            );
+describe('MUC Private Messages', () => {
+    beforeAll(() => jasmine.addMatchers({ toEqualStanza: jasmine.toEqualStanza }));
 
 
-            await u.waitUntil(() => view.model.occupants.length === 2);
+    describe('When receiving a MUC private message', () => {
+        it(
+            "doesn't appear in the main MUC chatarea",
+            mock.initConverse(['chatBoxesFetched'], { view_mode: 'fullscreen' }, async (_converse) => {
+                const nick = 'romeo';
+                const muc_jid = 'coven@chat.shakespeare.lit';
+                await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+                const view = _converse.chatboxviews.get(muc_jid);
 
 
-            _converse.api.connection.get()._dataRecv(
-                mock.createRequest(stx`
-                    <message from="${muc_jid}/firstwitch"
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                        <presence
+                            from="${muc_jid}/firstwitch"
                             id="${u.getUniqueId()}"
                             id="${u.getUniqueId()}"
                             to="${_converse.jid}"
                             to="${_converse.jid}"
-                            type="chat"
                             xmlns="jabber:client">
                             xmlns="jabber:client">
-                        <body>I'll give thee a wind.</body>
-                        <x xmlns="http://jabber.org/protocol/muc#user" />
-                    </message>
-                `)
-            );
+                        <x xmlns="http://jabber.org/protocol/muc#user">
+                            <item affiliation="owner" role="moderator"/>
+                        </x>
+                        </presence>`)
+                );
+                await u.waitUntil(() => view.model.occupants.length === 2);
 
 
-            _converse.api.connection.get()._dataRecv(
-                mock.createRequest(stx`
-                    <message from="coven@chat.shakespeare.lit/thirdwitch"
-                            id="${u.getUniqueId()}"
-                            to="${_converse.jid}"
-                            type="groupchat"
-                            xmlns="jabber:client">
-                        <body>Harpier cries: "tis time, "tis time.</body>
-                    </message>
-                `)
-            );
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                        <message from="${muc_jid}/firstwitch"
+                                id="${u.getUniqueId()}"
+                                to="${_converse.jid}"
+                                type="chat"
+                                xmlns="jabber:client">
+                            <body>I'll give thee a wind.</body>
+                            <x xmlns="http://jabber.org/protocol/muc#user" />
+                        </message>
+                    `)
+                );
+
+                _converse.api.connection.get()._dataRecv(
+                    mock.createRequest(stx`
+                        <message from="coven@chat.shakespeare.lit/thirdwitch"
+                                id="${u.getUniqueId()}"
+                                to="${_converse.jid}"
+                                type="groupchat"
+                                xmlns="jabber:client">
+                            <body>Harpier cries: "tis time, "tis time.</body>
+                        </message>
+                    `)
+                );
+
+                await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+
+                expect(view.model.messages.length).toBe(1);
+                expect(view.model.messages.pop().get('message')).toBe('Harpier cries: "tis time, "tis time.');
+
+                const occupant = view.model.occupants.findOccupant({ nick: 'firstwitch' });
+                expect(occupant.get('num_unread')).toBe(1);
+                expect(occupant.messages.length).toBe(1);
+                expect(occupant.messages.pop().get('message')).toBe("I'll give thee a wind.");
+            })
+        );
+    });
 
 
-            await u.waitUntil(() => view.querySelectorAll('.chat-msg').length === 1);
+    describe('When sending a MUC private message', () => {
+        describe('And an error is returned', () => {
+            it(
+                'is correctly shown with the sent message',
+                mock.initConverse(['chatBoxesFetched'], { view_mode: 'fullscreen' }, async (_converse) => {
+                    const { api } = _converse;
+                    const nick = 'romeo';
+                    const muc_jid = 'coven@chat.shakespeare.lit';
+                    await mock.openAndEnterChatRoom(_converse, muc_jid, nick);
+                    const view = _converse.chatboxviews.get(muc_jid);
 
 
-            expect(view.model.messages.length).toBe(1);
-            expect(view.model.messages.pop().get('message')).toBe('Harpier cries: "tis time, "tis time.');
+                    _converse.api.connection.get()._dataRecv(
+                        mock.createRequest(stx`
+                            <presence
+                                from="${muc_jid}/firstwitch"
+                                id="${u.getUniqueId()}"
+                                to="${_converse.jid}"
+                                xmlns="jabber:client">
+                            <x xmlns="http://jabber.org/protocol/muc#user">
+                                <item affiliation="owner" role="moderator"/>
+                            </x>
+                            </presence>`)
+                    );
+                    await u.waitUntil(() => view.querySelectorAll('.occupant-list converse-avatar').length === 2);
 
 
-            const occupant = view.model.occupants.findOccupant({ nick: 'firstwitch' });
-            expect(occupant.get('num_unread')).toBe(1);
-            expect(occupant.messages.length).toBe(1);
-            expect(occupant.messages.pop().get('message')).toBe("I'll give thee a wind.");
-        })
-    );
+                    // Open the occupant view in the sidebar
+                    view.querySelector('.occupant-list converse-avatar[name="firstwitch"]').click();
+
+                    const occupant = view.model.getOccupant('firstwitch');
+                    occupant.sendMessage({ body: 'hello world' });
+
+                    await u.waitUntil(
+                        () => api.connection.get().sent_stanzas.filter((s) => s.nodeName === 'message').length
+                    );
+
+                    const sent_stanza = api.connection.get().sent_stanzas.pop();
+
+                    expect(sent_stanza).toEqualStanza(stx`
+                        <message from="${muc_jid}/${nick}"
+                                to="${muc_jid}/firstwitch"
+                                id="${sent_stanza.getAttribute('id')}"
+                                xmlns="jabber:client">
+                            <body>hello world</body>
+                            <active xmlns="http://jabber.org/protocol/chatstates"/>
+                            <request xmlns="urn:xmpp:receipts"/>
+                            <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`
+                        <message xmlns="jabber:client"
+                            id="${sent_stanza.getAttribute('id')}"
+                            to="${_converse.session.get('jid')}"
+                            type="error"
+                            from="${muc_jid}/firstwitch">
+
+                            <error type="cancel" by="${muc_jid}">
+                                <item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+                                <text xmlns="urn:ietf:params:xml:ns:xmpp-stanzas">${err_msg_text}</text>
+                            </error>
+                        </message>`)
+                    );
+
+                    expect(await u.waitUntil(() => view.querySelector('.chat-msg__error')?.textContent?.trim()))
+                        .toBe(`Message delivery failed: "${err_msg_text}"`);
+                })
+            );
+        });
+    });
 });
 });

+ 2 - 2
src/plugins/muc-views/tests/unfurls.js

@@ -431,7 +431,7 @@ describe("A Groupchat Message", function () {
         let msg = _converse.api.connection.get().send.calls.all()[1].args[0];
         let msg = _converse.api.connection.get().send.calls.all()[1].args[0];
         expect(Strophe.serialize(msg))
         expect(Strophe.serialize(msg))
         .toBe(
         .toBe(
-            `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+            `<message from="${muc_jid}/${nick}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
                 `<body>${unfurl_url}</body>`+
                 `<body>${unfurl_url}</body>`+
                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                 `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
                 `<origin-id id="${msg.querySelector('origin-id')?.getAttribute('id')}" xmlns="urn:xmpp:sid:0"/>`+
@@ -479,7 +479,7 @@ describe("A Groupchat Message", function () {
         msg = getSentMessages().pop();
         msg = getSentMessages().pop();
         expect(Strophe.serialize(msg))
         expect(Strophe.serialize(msg))
         .toBe(
         .toBe(
-            `<message from="${_converse.jid}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+            `<message from="${muc_jid}/${nick}" id="${msg.getAttribute('id')}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
                 `<body>never mind</body>`+
                 `<body>never mind</body>`+
                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                 `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                 `<replace id="${msg.querySelector('replace')?.getAttribute('id')}" xmlns="urn:xmpp:message-correct:0"/>`+
                 `<replace id="${msg.querySelector('replace')?.getAttribute('id')}" xmlns="urn:xmpp:message-correct:0"/>`+

+ 2 - 2
src/plugins/omemo/tests/corrections.js

@@ -335,7 +335,7 @@ describe("An OMEMO encrypted MUC message", function() {
         const sent_stanza = _converse.api.connection.get().send.calls.all()[0].args[0];
         const sent_stanza = _converse.api.connection.get().send.calls.all()[0].args[0];
 
 
         expect(Strophe.serialize(sent_stanza)).toBe(
         expect(Strophe.serialize(sent_stanza)).toBe(
-            `<message from="romeo@montague.lit/orchard" `+
+            `<message from="${muc_jid}/${nick}" `+
                      `id="${sent_stanza.getAttribute("id")}" `+
                      `id="${sent_stanza.getAttribute("id")}" `+
                      `to="lounge@montague.lit" `+
                      `to="lounge@montague.lit" `+
                      `type="groupchat" `+
                      `type="groupchat" `+
@@ -389,7 +389,7 @@ describe("An OMEMO encrypted MUC message", function() {
         const msg = _converse.api.connection.get().sent_stanzas.pop();
         const msg = _converse.api.connection.get().sent_stanzas.pop();
 
 
         expect(Strophe.serialize(msg))
         expect(Strophe.serialize(msg))
-            .toBe(`<message from="${_converse.jid}" id="${msg.getAttribute("id")}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
+            .toBe(`<message from="${muc_jid}/${nick}" id="${msg.getAttribute("id")}" to="${muc_jid}" type="groupchat" xmlns="jabber:client">`+
                     `<body>${fallback_text}</body>`+
                     `<body>${fallback_text}</body>`+
                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                     `<active xmlns="http://jabber.org/protocol/chatstates"/>`+
                     `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+
                     `<replace id="${first_msg.get("msgid")}" xmlns="urn:xmpp:message-correct:0"/>`+

+ 3 - 2
src/plugins/omemo/tests/muc.js

@@ -20,8 +20,9 @@ describe("The OMEMO module", function() {
             'muc_unmoderated',
             'muc_unmoderated',
             'muc_nonanonymous'
             'muc_nonanonymous'
         ];
         ];
+        const nick = 'romeo';
         const muc_jid = 'lounge@montague.lit';
         const muc_jid = 'lounge@montague.lit';
-        await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', features);
+        await mock.openAndEnterChatRoom(_converse, muc_jid, nick, features);
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         const view = _converse.chatboxviews.get('lounge@montague.lit');
         await u.waitUntil(() => mock.initializedOMEMO(_converse));
         await u.waitUntil(() => mock.initializedOMEMO(_converse));
 
 
@@ -131,7 +132,7 @@ describe("The OMEMO module", function() {
         const sent_stanza = _converse.api.connection.get().send.calls.all()[0].args[0];
         const sent_stanza = _converse.api.connection.get().send.calls.all()[0].args[0];
 
 
         expect(Strophe.serialize(sent_stanza)).toBe(
         expect(Strophe.serialize(sent_stanza)).toBe(
-            `<message from="romeo@montague.lit/orchard" `+
+            `<message from="${muc_jid}/${nick}" `+
                      `id="${sent_stanza.getAttribute("id")}" `+
                      `id="${sent_stanza.getAttribute("id")}" `+
                      `to="lounge@montague.lit" `+
                      `to="lounge@montague.lit" `+
                      `type="groupchat" `+
                      `type="groupchat" `+

+ 4 - 4
src/shared/chat/emoji-picker-content.js

@@ -1,9 +1,9 @@
 /**
 /**
  * @typedef {module:emoji-picker.EmojiPicker} EmojiPicker
  * @typedef {module:emoji-picker.EmojiPicker} EmojiPicker
  */
  */
-import { CustomElement } from 'shared/components/element.js';
-import { converse, api } from '@converse/headless';
 import { html } from 'lit';
 import { html } from 'lit';
+import { converse, api, u } from '@converse/headless';
+import { CustomElement } from 'shared/components/element.js';
 import { tplAllEmojis, tplSearchResults } from './templates/emoji-picker.js';
 import { tplAllEmojis, tplSearchResults } from './templates/emoji-picker.js';
 import { getTonedEmojis } from './utils.js';
 import { getTonedEmojis } from './utils.js';
 import { FILTER_CONTAINS } from 'shared/autocomplete/utils.js';
 import { FILTER_CONTAINS } from 'shared/autocomplete/utils.js';
@@ -75,7 +75,7 @@ export default class EmojiPickerContent extends CustomElement {
             const category = current.target.getAttribute('data-category');
             const category = current.target.getAttribute('data-category');
             if (category !== this.model.get('current_category')) {
             if (category !== this.model.get('current_category')) {
                 /** @type {EmojiPicker} */(this.parentElement).preserve_scroll = true;
                 /** @type {EmojiPicker} */(this.parentElement).preserve_scroll = true;
-                this.model.save({ 'current_category': category });
+                u.safeSave(this.model, { 'current_category': category });
             }
             }
         }
         }
     }
     }
@@ -88,7 +88,7 @@ export default class EmojiPickerContent extends CustomElement {
         ev.stopPropagation();
         ev.stopPropagation();
         const target = /** @type {HTMLElement} */(ev.target);
         const target = /** @type {HTMLElement} */(ev.target);
         const emoji_el = target.nodeName === 'IMG' ? target.parentElement : target;
         const emoji_el = target.nodeName === 'IMG' ? target.parentElement : target;
-        /** @type EmojiPicker */(this.parentElement).insertIntoTextArea(emoji_el.getAttribute('data-emoji'));
+        /** @type EmojiPicker */(this.parentElement).selectEmoji(emoji_el.getAttribute('data-emoji'));
     }
     }
 
 
     /**
     /**

+ 4 - 4
src/shared/chat/emoji-picker.js

@@ -175,7 +175,7 @@ export default class EmojiPicker extends CustomElement {
     /**
     /**
      * @param {string} value
      * @param {string} value
      */
      */
-    insertIntoTextArea (value) {
+    selectEmoji (value) {
         const autocompleting = this.state.get('autocompleting');
         const autocompleting = this.state.get('autocompleting');
         const ac_position = this.state.get('ac_position');
         const ac_position = this.state.get('ac_position');
         this.state.set({'autocompleting': null, 'query': '', 'ac_position': null});
         this.state.set({'autocompleting': null, 'query': '', 'ac_position': null});
@@ -248,11 +248,11 @@ export default class EmojiPicker extends CustomElement {
         ev.stopPropagation();
         ev.stopPropagation();
         const target = /** @type {HTMLInputElement} */(ev.target);
         const target = /** @type {HTMLInputElement} */(ev.target);
         if (converse.emojis.shortnames.includes(target.value)) {
         if (converse.emojis.shortnames.includes(target.value)) {
-            this.insertIntoTextArea(target.value);
+            this.selectEmoji(target.value);
         } else if (this.search_results.length === 1) {
         } else if (this.search_results.length === 1) {
-            this.insertIntoTextArea(this.search_results[0].sn);
+            this.selectEmoji(this.search_results[0].sn);
         } else if (this.navigator.selected && this.navigator.selected.matches('.insert-emoji')) {
         } else if (this.navigator.selected && this.navigator.selected.matches('.insert-emoji')) {
-            this.insertIntoTextArea(this.navigator.selected.getAttribute('data-emoji'));
+            this.selectEmoji(this.navigator.selected.getAttribute('data-emoji'));
         } else if (this.navigator.selected && this.navigator.selected.matches('.emoji-category')) {
         } else if (this.navigator.selected && this.navigator.selected.matches('.emoji-category')) {
             this.chooseCategory(new MouseEvent('click', {relatedTarget: this.navigator.selected}));
             this.chooseCategory(new MouseEvent('click', {relatedTarget: this.navigator.selected}));
         }
         }

+ 6 - 4
src/shared/chat/message-actions.js

@@ -44,12 +44,14 @@ class MessageActions extends CustomElement {
         this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
         this.listenTo(settings, 'change:allowed_video_domains', () => this.requestUpdate());
         this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
         this.listenTo(settings, 'change:render_media', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
+
         // This may change the ability to send messages, and therefore the presence of the quote button.
         // This may change the ability to send messages, and therefore the presence of the quote button.
         // See plugins/muc-views/bottom-panel.js
         // See plugins/muc-views/bottom-panel.js
-        this.listenTo(this.model.collection.chatbox.features, 'change:moderated', () => this.requestUpdate());
-        this.listenTo(this.model.collection.chatbox.occupants, 'add', this.updateIfOwnOccupant);
-        this.listenTo(this.model.collection.chatbox.occupants, 'change:role', this.updateIfOwnOccupant);
-        this.listenTo(this.model.collection.chatbox.session, 'change:connection_status', () => this.requestUpdate());
+        this.listenTo(this.model.chatbox.features, 'change:moderated', () => this.requestUpdate());
+        this.listenTo(this.model.chatbox.occupants, 'add', this.updateIfOwnOccupant);
+        this.listenTo(this.model.chatbox.occupants, 'change:role', this.updateIfOwnOccupant);
+        this.listenTo(this.model.chatbox.session, 'change:connection_status', () => this.requestUpdate());
+
     }
     }
 
 
     updateIfOwnOccupant (o) {
     updateIfOwnOccupant (o) {

+ 1 - 1
src/shared/chat/message-body.js

@@ -50,7 +50,7 @@ export default class MessageBody extends CustomElement {
         const options = {
         const options = {
             'media_urls': this.model.get('media_urls'),
             'media_urls': this.model.get('media_urls'),
             'mentions': this.model.get('references'),
             'mentions': this.model.get('references'),
-            'nick': this.model.collection.chatbox.get('nick'),
+            'nick': this.model.chatbox.get('nick'),
             'onImgClick': (ev) => this.onImgClick(ev),
             'onImgClick': (ev) => this.onImgClick(ev),
             'onImgLoad': () => this.onImgLoad(),
             'onImgLoad': () => this.onImgLoad(),
             'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'),
             'render_styling': !this.model.get('is_unstyled') && api.settings.get('allow_message_styling'),

+ 12 - 6
src/shared/chat/message-history.js

@@ -1,13 +1,16 @@
-import "./message";
-import { CustomElement } from 'shared/components/element.js';
-import { api } from "@converse/headless";
-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';
 import { until } from 'lit/directives/until.js';
+import { api } from "@converse/headless";
+import { CustomElement } from 'shared/components/element.js';
+import { getDayIndicator } from './utils.js';
+import "./message";
 
 
 
 
 export default class MessageHistory extends CustomElement {
 export default class MessageHistory extends CustomElement {
+    /**
+     * @typedef {import('@converse/headless/types/plugins/chat/message').default} Message
+     */
 
 
     constructor () {
     constructor () {
         super();
         super();
@@ -31,6 +34,9 @@ export default class MessageHistory extends CustomElement {
         }
         }
     }
     }
 
 
+    /**
+     * @param {(Message)} model
+     */
     renderMessage (model) {
     renderMessage (model) {
         if (model.get('dangling_retraction') || model.get('dangling_moderation') ||  model.get('is_only_key')) {
         if (model.get('dangling_retraction') || model.get('dangling_moderation') ||  model.get('is_only_key')) {
             return '';
             return '';
@@ -41,8 +47,8 @@ export default class MessageHistory extends CustomElement {
             return until(template_promise, '');
             return until(template_promise, '');
         } else {
         } else {
             const template = html`<converse-chat-message
             const template = html`<converse-chat-message
-                jid="${this.model.get('jid')}"
-                mid="${model.get('id')}"></converse-chat-message>`
+                .model_with_messages=${this.model}
+                .model=${model}></converse-chat-message>`
             const day = getDayIndicator(model);
             const day = getDayIndicator(model);
             return day ? [day, template] : template;
             return day ? [day, template] : template;
         }
         }

+ 15 - 26
src/shared/chat/message.js

@@ -25,24 +25,21 @@ export default class Message extends CustomElement {
 
 
     constructor () {
     constructor () {
         super();
         super();
-        this.jid = null;
-        this.mid = null;
+        this.model_with_messages = null;
+        this.model = null;
     }
     }
 
 
     static get properties () {
     static get properties () {
         return {
         return {
-            jid: { type: String },
-            mid: { type: String }
+            model_with_messages: { type: Object },
+            model: { type: Object }
         }
         }
     }
     }
 
 
     async initialize () {
     async initialize () {
-        await this.setModels();
-        if (!this.model) {
-            // Happen during tests due to a race condition
-            log.error('Could not find module for converse-chat-message');
-            return;
-        }
+        super.initialize();
+        await this.model_with_messages.initialized;
+        await this.model_with_messages.messages.fetched;
 
 
         const settings = api.settings.get();
         const settings = api.settings.get();
         this.listenTo(settings, 'change:render_media', () => {
         this.listenTo(settings, 'change:render_media', () => {
@@ -51,20 +48,13 @@ export default class Message extends CustomElement {
             this.requestUpdate();
             this.requestUpdate();
         });
         });
 
 
-        this.listenTo(this.chatbox, 'change:first_unread_id', () => this.requestUpdate());
+        this.listenTo(this.model_with_messages, 'change:first_unread_id', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
         this.listenTo(this.model, 'change', () => this.requestUpdate());
         this.listenTo(this.model, 'contact:change', () => this.requestUpdate());
         this.listenTo(this.model, 'contact:change', () => this.requestUpdate());
         this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
         this.listenTo(this.model, 'vcard:change', () => this.requestUpdate());
         this.listenTo(this.model, 'occupant:change', () => this.requestUpdate());
         this.listenTo(this.model, 'occupant:change', () => this.requestUpdate());
         this.listenTo(this.model, 'occupant:add', () => this.requestUpdate());
         this.listenTo(this.model, 'occupant:add', () => this.requestUpdate());
-    }
-
-    async setModels () {
-        this.chatbox = await api.chatboxes.get(this.jid);
-        await this.chatbox.initialized;
-        await this.chatbox.messages.fetched;
-        this.model = this.chatbox.messages.get(this.mid);
-        this.model && this.requestUpdate();
+        this.requestUpdate();
     }
     }
 
 
     render () {
     render () {
@@ -147,7 +137,7 @@ export default class Message extends CustomElement {
 
 
     hasMentions () {
     hasMentions () {
         const is_groupchat = this.model.get('type') === 'groupchat';
         const is_groupchat = this.model.get('type') === 'groupchat';
-        return is_groupchat && this.model.get('sender') === 'them' && this.chatbox.isUserMentioned(this.model);
+        return is_groupchat && this.model.get('sender') === 'them' && this.model_with_messages.isUserMentioned(this.model);
     }
     }
 
 
     getOccupantAffiliation () {
     getOccupantAffiliation () {
@@ -185,7 +175,7 @@ export default class Message extends CustomElement {
             'pretty_time': dayjs(this.model.get('edited') || this.model.get('time')).format(format),
             'pretty_time': dayjs(this.model.get('edited') || this.model.get('time')).format(format),
             'has_mentions': this.hasMentions(),
             'has_mentions': this.hasMentions(),
             'hats': getHats(this.model),
             'hats': getHats(this.model),
-            'is_first_unread': this.chatbox.get('first_unread_id') === this.model.get('id'),
+            'is_first_unread': this.model_with_messages.get('first_unread_id') === this.model.get('id'),
             'is_me_message': this.model.isMeCommand(),
             'is_me_message': this.model.isMeCommand(),
             'is_retracted': this.isRetracted(),
             'is_retracted': this.isRetracted(),
             'username': this.model.getDisplayName(),
             'username': this.model.getDisplayName(),
@@ -197,11 +187,11 @@ export default class Message extends CustomElement {
     getRetractionText () {
     getRetractionText () {
         if (['groupchat', 'mep'].includes(this.model.get('type')) && this.model.get('moderated_by')) {
         if (['groupchat', 'mep'].includes(this.model.get('type')) && this.model.get('moderated_by')) {
             const retracted_by_mod = this.model.get('moderated_by');
             const retracted_by_mod = this.model.get('moderated_by');
-            const chatbox = this.model.collection.chatbox;
             if (!this.model.mod) {
             if (!this.model.mod) {
+                const { occupants } = this.model_with_messages;
                 this.model.mod =
                 this.model.mod =
-                    chatbox.occupants.findOccupant({'jid': retracted_by_mod}) ||
-                    chatbox.occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
+                    occupants.findOccupant({'jid': retracted_by_mod}) ||
+                    occupants.findOccupant({'nick': Strophe.getResourceFromJid(retracted_by_mod)});
             }
             }
             const modname = this.model.mod ? this.model.mod.getDisplayName() : __('A moderator');
             const modname = this.model.mod ? this.model.mod.getDisplayName() : __('A moderator');
             return __('%1$s has removed this message', modname);
             return __('%1$s has removed this message', modname);
@@ -218,8 +208,7 @@ export default class Message extends CustomElement {
             api.modal.show('converse-muc-occupant-modal', { 'model': this.model.getOccupant(), 'message': this.model }, ev);
             api.modal.show('converse-muc-occupant-modal', { 'model': this.model.getOccupant(), 'message': this.model }, ev);
         } else {
         } else {
             ev.preventDefault();
             ev.preventDefault();
-            const chatbox = this.model.collection.chatbox;
-            api.modal.show('converse-user-details-modal', { model: chatbox }, ev);
+            api.modal.show('converse-user-details-modal', { model: this.model_with_messages }, ev);
         }
         }
     }
     }
 
 

+ 4 - 2
src/shared/chat/templates/message-text.js

@@ -16,6 +16,8 @@ export default (el) => {
     const i18n_show = __('Show more');
     const i18n_show = __('Show more');
     const is_groupchat_message = (el.model.get('type') === 'groupchat');
     const is_groupchat_message = (el.model.get('type') === 'groupchat');
     const i18n_show_less = __('Show less');
     const i18n_show_less = __('Show less');
+    const error_text = el.model.get('error_text') || el.model.get('error');
+    const i18n_error = __('Message delivery failed: "%1$s"', error_text);
 
 
     const tplSpoilerHint = html`
     const tplSpoilerHint = html`
         <div class="chat-msg__spoiler-hint">
         <div class="chat-msg__spoiler-hint">
@@ -34,7 +36,7 @@ export default (el) => {
     return html`
     return html`
         ${ el.model.get('is_spoiler') ? tplSpoilerHint : '' }
         ${ el.model.get('is_spoiler') ? tplSpoilerHint : '' }
         ${ el.model.get('subject') ? html`<div class="chat-msg__subject">${el.model.get('subject')}</div>` : '' }
         ${ el.model.get('subject') ? html`<div class="chat-msg__subject">${el.model.get('subject')}</div>` : '' }
-        <span class="chat-msg__body--wrapper">
+        <span class="chat-msg__body--wrapper ${error_text ? 'error' : ''}">
             <converse-chat-message-body
             <converse-chat-message-body
                 class="chat-msg__text ${el.model.get('is_only_emojis') ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
                 class="chat-msg__text ${el.model.get('is_only_emojis') ? 'chat-msg__text--larger' : ''} ${spoiler_classes}"
                 .model="${el.model}"
                 .model="${el.model}"
@@ -45,6 +47,6 @@ export default (el) => {
             ${ (el.model.get('edited')) ? tplEditedIcon(el) : '' }
             ${ (el.model.get('edited')) ? tplEditedIcon(el) : '' }
         </span>
         </span>
         ${ show_oob ? html`<div class="chat-msg__media">${getOOBURLMarkup(el.model.get('oob_url'))}</div>` : '' }
         ${ show_oob ? html`<div class="chat-msg__media">${getOOBURLMarkup(el.model.get('oob_url'))}</div>` : '' }
-        <div class="chat-msg__error">${ el.model.get('error_text') || el.model.get('error') }</div>
+        ${ error_text ? html`<div class="chat-msg__error">${ i18n_error }</div>` : '' }
     `;
     `;
 }
 }

+ 1 - 1
src/shared/chat/templates/message.js

@@ -83,7 +83,7 @@ export default (el, o) => {
                     return html`<converse-message-unfurl
                     return html`<converse-message-unfurl
                         @animationend="${el.onUnfurlAnimationEnd}"
                         @animationend="${el.onUnfurlAnimationEnd}"
                         class="${el.model.get('url_preview_transition')}"
                         class="${el.model.get('url_preview_transition')}"
-                        jid="${el.chatbox?.get('jid')}"
+                        jid="${el.model_with_messages?.get('jid')}"
                         description="${m['og:description'] || ''}"
                         description="${m['og:description'] || ''}"
                         title="${m['og:title'] || ''}"
                         title="${m['og:title'] || ''}"
                         image="${m['og:image'] || ''}"
                         image="${m['og:image'] || ''}"

+ 34 - 32
src/shared/styles/messages.scss

@@ -11,7 +11,7 @@
     .message {
     .message {
         .show-msg-author-modal {
         .show-msg-author-modal {
             align-self: flex-start; // Don't expand height to that of largest sibling
             align-self: flex-start; // Don't expand height to that of largest sibling
-                                    // https://stackoverflow.com/questions/27575779/prevent-a-flex-items-height-from-expanding-to-match-other-flex-items/40156422#40156422
+            // https://stackoverflow.com/questions/27575779/prevent-a-flex-items-height-from-expanding-to-match-other-flex-items/40156422#40156422
             color: var(--link-color) !important;
             color: var(--link-color) !important;
         }
         }
 
 
@@ -163,6 +163,38 @@
                 width: 100%;
                 width: 100%;
                 overflow-wrap: break-word;
                 overflow-wrap: break-word;
                 .chat-msg__body--wrapper {
                 .chat-msg__body--wrapper {
+                    .chat-msg__text {
+                        color: var(--foreground-color);
+                        padding: 0;
+                        white-space: pre-wrap;
+                        word-wrap: break-word;
+                        word-break: break-word;
+                        a {
+                            word-wrap: break-word;
+                            word-break: break-all;
+                            display: inline;
+                            &.chat-image__link {
+                                width: fit-content;
+                                display: block;
+                            }
+                        }
+                        img {
+                            &.emoji {
+                                height: 1.5em;
+                                width: 1.5em;
+                                margin: 0 0.05em 0 0.1em;
+                                vertical-align: -0.1em;
+                            }
+                        }
+                        .emojione {
+                            margin-bottom: -6px;
+                        }
+                    }
+                    &.error {
+                        .chat-msg__text {
+                            color: var(--disabled-color);
+                        }
+                    }
                     display: flex;
                     display: flex;
                 }
                 }
             }
             }
@@ -177,34 +209,6 @@
                 clear: right;
                 clear: right;
             }
             }
 
 
-            .chat-msg__text {
-                color: var(--foreground-color);
-                padding: 0;
-                white-space: pre-wrap;
-                word-wrap: break-word;
-                word-break: break-word;
-                a {
-                    word-wrap: break-word;
-                    word-break: break-all;
-                    display: inline;
-                    &.chat-image__link {
-                        width: fit-content;
-                        display: block;
-                    }
-                }
-                img {
-                    &.emoji {
-                        height: 1.5em;
-                        width: 1.5em;
-                        margin: 0 .05em 0 .1em;
-                        vertical-align: -0.1em;
-                    }
-                }
-                .emojione {
-                    margin-bottom: -6px;
-                }
-            }
-
             .chat-msg__text--larger {
             .chat-msg__text--larger {
                 font-size: 1.6em;
                 font-size: 1.6em;
                 padding-top: 0.25em;
                 padding-top: 0.25em;
@@ -294,7 +298,6 @@
                 }
                 }
             }
             }
 
 
-
             .chat-msg__receipt {
             .chat-msg__receipt {
                 margin-left: 0.5em;
                 margin-left: 0.5em;
                 margin-right: 0.5em;
                 margin-right: 0.5em;
@@ -348,9 +351,8 @@
     }
     }
 }
 }
 
 
-
 @media screen and (max-width: 767px) {
 @media screen and (max-width: 767px) {
-    converse-chats:not(.converse-embedded)  {
+    converse-chats:not(.converse-embedded) {
         .message {
         .message {
             &.chat-msg {
             &.chat-msg {
                 .chat-msg__author {
                 .chat-msg__author {

+ 1 - 1
src/shared/styles/themes/cyberpunk.scss

@@ -38,7 +38,7 @@
     --chat-status-online: var(--green);
     --chat-status-online: var(--green);
 
 
     --controlbox-color: var(--purple);
     --controlbox-color: var(--purple);
-    --disabled-color: var(--secondary-color);
+    --disabled-color: gray;
     --error-color: var(--red);
     --error-color: var(--red);
     --focus-color: var(--secondary-color);
     --focus-color: var(--secondary-color);
     --heading-color: var(--purple);
     --heading-color: var(--purple);

+ 2 - 2
src/shared/styles/themes/dracula.scss

@@ -30,11 +30,11 @@
     // Online status indicators
     // Online status indicators
     --chat-status-away: var(--orange);
     --chat-status-away: var(--orange);
     --chat-status-busy: var(--red);
     --chat-status-busy: var(--red);
-    --chat-status-offline: gray;
+    --chat-status-offline: var(--gray);
     --chat-status-online: var(--green);
     --chat-status-online: var(--green);
 
 
     --controlbox-color: var(--purple);
     --controlbox-color: var(--purple);
-    --disabled-color: var(--secondary-color);
+    --disabled-color: var(--gray);
     --error-color: var(--red);
     --error-color: var(--red);
     --focus-color: var(--pink);
     --focus-color: var(--pink);
     --heading-color: var(--purple);
     --heading-color: var(--purple);

+ 10 - 1
src/types/plugins/chatview/bottom-panel.d.ts

@@ -12,7 +12,16 @@ export default class ChatBottomPanel extends CustomElement {
     viewUnreadMessages(ev: any): void;
     viewUnreadMessages(ev: any): void;
     onDragOver(ev: any): void;
     onDragOver(ev: any): void;
     clearMessages(ev: any): void;
     clearMessages(ev: any): void;
-    autocompleteInPicker(input: any, value: any): Promise<void>;
+    /**
+     * @typedef {Object} AutocompleteInPickerEvent
+     * @property {HTMLTextAreaElement} input
+     * @property {string} value
+     * @param {AutocompleteInPickerEvent} ev
+     */
+    autocompleteInPicker(ev: {
+        input: HTMLTextAreaElement;
+        value: string;
+    }): Promise<void>;
 }
 }
 export type EmojiPicker = import("shared/chat/emoji-picker.js").default;
 export type EmojiPicker = import("shared/chat/emoji-picker.js").default;
 export type EmojiDropdown = import("shared/chat/emoji-dropdown.js").default;
 export type EmojiDropdown = import("shared/chat/emoji-dropdown.js").default;

+ 1 - 1
src/types/shared/chat/emoji-picker.d.ts

@@ -52,7 +52,7 @@ export default class EmojiPicker extends CustomElement {
     /**
     /**
      * @param {string} value
      * @param {string} value
      */
      */
-    insertIntoTextArea(value: string): void;
+    selectEmoji(value: string): void;
     /**
     /**
      * @param {MouseEvent} ev
      * @param {MouseEvent} ev
      */
      */

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

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

+ 6 - 9
src/types/shared/chat/message.d.ts

@@ -1,18 +1,15 @@
 export default class Message extends CustomElement {
 export default class Message extends CustomElement {
     static get properties(): {
     static get properties(): {
-        jid: {
-            type: StringConstructor;
+        model_with_messages: {
+            type: ObjectConstructor;
         };
         };
-        mid: {
-            type: StringConstructor;
+        model: {
+            type: ObjectConstructor;
         };
         };
     };
     };
-    jid: any;
-    mid: any;
-    initialize(): Promise<void>;
-    setModels(): Promise<void>;
-    chatbox: any;
+    model_with_messages: any;
     model: any;
     model: any;
+    initialize(): Promise<void>;
     render(): import("lit").TemplateResult<1> | "";
     render(): import("lit").TemplateResult<1> | "";
     getProps(): any;
     getProps(): any;
     renderRetraction(): import("lit").TemplateResult<1>;
     renderRetraction(): import("lit").TemplateResult<1>;