diff --git a/sass/_chatrooms.scss b/sass/_chatrooms.scss index b230c65c2..1c5c315bf 100644 --- a/sass/_chatrooms.scss +++ b/sass/_chatrooms.scss @@ -359,7 +359,7 @@ color: white; &.muc-bottom-panel--muted { - height: 8em; + height: 4em; width: 100%; } diff --git a/spec/modtools.js b/spec/modtools.js index 0ae2710e5..52cad7b49 100644 --- a/spec/modtools.js +++ b/spec/modtools.js @@ -13,8 +13,7 @@ async function openModtools (_converse, view) { const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); bottom_panel.onKeyDown(enter); - await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); - const modal = _converse.api.modal.get('converse-modtools-modal'); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); await u.waitUntil(() => u.isVisible(modal.el), 1000); return modal; } @@ -24,7 +23,6 @@ describe("The groupchat moderator tool", function () { it("allows you to set affiliations and roles", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; let members = [ @@ -143,7 +141,6 @@ describe("The groupchat moderator tool", function () { it("allows you to filter affiliation search results", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, @@ -197,11 +194,9 @@ describe("The groupchat moderator tool", function () { it("allows you to filter role search results", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', []); const view = _converse.chatboxviews.get(muc_jid); - _converse.connection._dataRecv(mock.createRequest( $pres({to: _converse.jid, from: `${muc_jid}/nomorenicks`}) .c('x', {xmlns: Strophe.NS.MUC_USER}) @@ -263,9 +258,8 @@ describe("The groupchat moderator tool", function () { const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); bottom_panel.onKeyDown(enter); - await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); - const modal = _converse.api.modal.get('converse-modtools-modal'); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); await u.waitUntil(() => u.isVisible(modal.el), 1000); const tab = modal.el.querySelector('#roles-tab'); @@ -307,7 +301,6 @@ describe("The groupchat moderator tool", function () { it("shows an error message if a particular affiliation list may not be retrieved", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, @@ -357,7 +350,6 @@ describe("The groupchat moderator tool", function () { it("shows an error message if a particular affiliation may not be set", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'gower@shakespeare.lit', 'nick': 'gower', 'affiliation': 'member'}, @@ -422,7 +414,6 @@ describe("The groupchat moderator tool", function () { it("doesn't allow admins to make more admins", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [ {'jid': 'hag66@shakespeare.lit', 'nick': 'witch', 'affiliation': 'member'}, @@ -457,7 +448,6 @@ describe("The groupchat moderator tool", function () { it("lets the assignable affiliations and roles be configured via modtools_disable_assign", mock.initConverse([], {}, async function (done, _converse) { - spyOn(_converse.ChatRoomView.prototype, 'showModeratorToolsModal').and.callThrough(); const muc_jid = 'lounge@montague.lit'; const members = [{'jid': 'romeo@montague.lit', 'nick': 'romeo', 'affiliation': 'owner'}]; await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo', [], members); @@ -467,9 +457,8 @@ describe("The groupchat moderator tool", function () { const enter = { 'target': textarea, 'preventDefault': function preventDefault () {}, 'keyCode': 13 }; const bottom_panel = view.querySelector('converse-muc-bottom-panel'); bottom_panel.onKeyDown(enter); - await u.waitUntil(() => view.showModeratorToolsModal.calls.count()); - const modal = _converse.api.modal.get('converse-modtools-modal'); + const modal = await u.waitUntil(() => _converse.api.modal.get('converse-modtools-modal')); const occupant = view.model.occupants.findWhere({'jid': _converse.bare_jid}); expect(modal.getAssignableAffiliations(occupant)).toEqual(['owner', 'admin', 'member', 'outcast', 'none']); diff --git a/spec/muc.js b/spec/muc.js index 6a693fc19..b82d5b0f5 100644 --- a/spec/muc.js +++ b/spec/muc.js @@ -1976,8 +1976,7 @@ describe("Groupchats", function () { const view = _converse.chatboxviews.get('lounge@montague.lit'); expect(view.model.getOwnAffiliation()).toBe('owner'); expect(view.model.features.get('open')).toBe(false); - - expect(view.querySelector('.open-invite-modal')).not.toBe(null); + await u.waitUntil(() => view.querySelector('.open-invite-modal')); // Members can't invite if the room isn't open view.model.getOwnOccupant().set('affiliation', 'member'); @@ -2474,7 +2473,7 @@ describe("Groupchats", function () { expect(view.model.features.get('temporary')).toBe(true); expect(view.model.features.get('unmoderated')).toBe(true); expect(view.model.features.get('unsecured')).toBe(false); - expect(view.querySelector('.chatbox-title__text').textContent.trim()).toBe('Room'); + await u.waitUntil(() => view.querySelector('.chatbox-title__text').textContent.trim() === 'Room'); view.querySelector('.configure-chatroom-button').click(); diff --git a/src/plugins/chatview/bottom_panel.js b/src/plugins/chatview/bottom_panel.js index 16dc307e9..8136150c8 100644 --- a/src/plugins/chatview/bottom_panel.js +++ b/src/plugins/chatview/bottom_panel.js @@ -76,36 +76,11 @@ export default class ChatBottomPanel extends ElementView { } emitFocused (ev) { - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - if (chatview.contains(document.activeElement) || chatview.contains(ev.relatedTarget)) { - // Something else in this chatbox was already focused - return; - } - /** - * Triggered when the focus has been moved to a particular chat. - * @event _converse#chatBoxFocused - * @type { _converse.ChatBoxView | _converse.ChatRoomView } - * @example _converse.api.listen.on('chatBoxFocused', (view, event) => { ... }); - */ - api.trigger('chatBoxFocused', this, ev); + _converse.chatboxviews.get(this.getAttribute('jid'))?.emitFocused(ev); } emitBlurred (ev) { - const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); - if (!chatview) { - return; - } - if (chatview.contains(document.activeElement) || chatview.contains(ev.relatedTarget)) { - // Something else in this chatbox is still focused - return; - } - /** - * Triggered when the focus has been removed from a particular chat. - * @event _converse#chatBoxBlurred - * @type { _converse.ChatBoxView | _converse.ChatRoomView } - * @example _converse.api.listen.on('chatBoxBlurred', (view, event) => { ... }); - */ - api.trigger('chatBoxBlurred', this, ev); + _converse.chatboxviews.get(this.getAttribute('jid'))?.emitBlurred(ev); } getToolbarOptions () { // eslint-disable-line class-methods-use-this diff --git a/src/plugins/chatview/heading.js b/src/plugins/chatview/heading.js new file mode 100644 index 000000000..bbfc95c8e --- /dev/null +++ b/src/plugins/chatview/heading.js @@ -0,0 +1,124 @@ +import UserDetailsModal from 'modals/user-details.js'; +import debounce from 'lodash/debounce'; +import tpl_chatbox_head from 'templates/chatbox_head.js'; +import { ElementView } from '@converse/skeletor/src/element.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import { getHeadingDropdownItem, getHeadingStandaloneButton } from 'plugins/chatview/utils.js'; +import { render } from 'lit-html'; + + +export default class ChatHeading extends ElementView { + + async render () { + const tpl = await this.generateHeadingTemplate(); + render(tpl, this); + } + + connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + this.debouncedRender = debounce(this.render, 100); + this.listenTo(this.model, 'vcard:change', this.debouncedRender); + if (this.model.contact) { + this.listenTo(this.model.contact, 'destroy', this.debouncedRender); + } + this.model.rosterContactAdded?.then(() => { + this.listenTo(this.model.contact, 'change:nickname', this.debouncedRender); + this.debouncedRender(); + }); + this.render(); + } + + showUserDetailsModal (ev) { + ev.preventDefault(); + api.modal.show(UserDetailsModal, { model: this.model }, ev); + } + + close () { + _converse.chatboxviews.get(this.getAttribute('jid'))?.close(); + } + + /** + * Returns a list of objects which represent buttons for the chat's header. + * @async + * @emits _converse#getHeadingButtons + */ + getHeadingButtons () { + const buttons = [ + { + 'a_class': 'show-user-details-modal', + 'handler': ev => this.showUserDetailsModal(ev), + 'i18n_text': __('Details'), + 'i18n_title': __('See more information about this person'), + 'icon_class': 'fa-id-card', + 'name': 'details', + 'standalone': api.settings.get('view_mode') === 'overlayed' + } + ]; + if (!api.settings.get('singleton')) { + buttons.push({ + 'a_class': 'close-chatbox-button', + 'handler': ev => this.close(ev), + 'i18n_text': __('Close'), + 'i18n_title': __('Close and end this conversation'), + 'icon_class': 'fa-times', + 'name': 'close', + 'standalone': api.settings.get('view_mode') === 'overlayed' + }); + } + /** + * *Hook* which allows plugins to add more buttons to a chat's heading. + * @event _converse#getHeadingButtons + * @example + * api.listen.on('getHeadingButtons', (view, buttons) => { + * buttons.push({ + * 'i18n_title': __('Foo'), + * 'i18n_text': __('Foo Bar'), + * 'handler': ev => alert('Foo!'), + * 'a_class': 'toggle-foo', + * 'icon_class': 'fa-foo', + * 'name': 'foo' + * }); + * return buttons; + * }); + */ + const chatview = _converse.chatboxviews.get(this.getAttribute('jid')); + if (chatview) { + return _converse.api.hook('getHeadingButtons', chatview, buttons); + } else { + return buttons; // Happens during tests + } + } + + async generateHeadingTemplate () { + const vcard = this.model?.vcard; + const vcard_json = vcard ? vcard.toJSON() : {}; + const i18n_profile = __("The User's Profile Image"); + const avatar_data = Object.assign( + { + 'alt_text': i18n_profile, + 'extra_classes': '', + 'height': 40, + 'width': 40 + }, + vcard_json + ); + const heading_btns = await this.getHeadingButtons(); + const standalone_btns = heading_btns.filter(b => b.standalone); + const dropdown_btns = heading_btns.filter(b => !b.standalone); + return tpl_chatbox_head( + Object.assign(this.model.toJSON(), { + avatar_data, + 'display_name': this.model.getDisplayName(), + 'dropdown_btns': dropdown_btns.map(b => getHeadingDropdownItem(b)), + 'showUserDetailsModal': ev => this.showUserDetailsModal(ev), + 'standalone_btns': standalone_btns.map(b => getHeadingStandaloneButton(b)) + }) + ); + } + + +} + +api.elements.define('converse-chat-heading', ChatHeading); diff --git a/src/plugins/chatview/utils.js b/src/plugins/chatview/utils.js new file mode 100644 index 000000000..8104c2313 --- /dev/null +++ b/src/plugins/chatview/utils.js @@ -0,0 +1,23 @@ +import { html } from 'lit-html'; + + +export async function getHeadingDropdownItem (promise_or_data) { + const data = await promise_or_data; + return html` + ${data.i18n_text} + `; +} + +export async function getHeadingStandaloneButton (promise_or_data) { + const data = await promise_or_data; + return html` + + `; +} diff --git a/src/plugins/chatview/view.js b/src/plugins/chatview/view.js index f17f4237e..295b2ebcd 100644 --- a/src/plugins/chatview/view.js +++ b/src/plugins/chatview/view.js @@ -1,8 +1,7 @@ +import 'plugins/chatview/heading.js'; import 'plugins/chatview/bottom_panel.js'; import BaseChatView from 'shared/chat/baseview.js'; -import UserDetailsModal from 'modals/user-details.js'; import tpl_chatbox from 'templates/chatbox.js'; -import tpl_chatbox_head from 'templates/chatbox_head.js'; import { __ } from 'i18n'; import { _converse, api, converse } from '@converse/headless/core'; import { render } from 'lit-html'; @@ -36,19 +35,7 @@ export default class ChatView extends BaseChatView { this.listenTo(_converse, 'windowStateChanged', this.onWindowStateChanged); this.listenTo(this.model, 'change:hidden', () => !this.model.get('hidden') && this.afterShown()); this.listenTo(this.model, 'change:status', this.onStatusMessageChanged); - this.listenTo(this.model, 'vcard:change', this.renderHeading); this.listenTo(this.model.messages, 'change:correcting', this.onMessageCorrecting); - - if (this.model.contact) { - this.listenTo(this.model.contact, 'destroy', this.renderHeading); - } - if (this.model.rosterContactAdded) { - this.model.rosterContactAdded.then(() => { - this.listenTo(this.model.contact, 'change:nickname', this.renderHeading); - this.renderHeading(); - }); - } - this.listenTo(this.model.presence, 'change:show', this.onPresenceChanged); this.render(); @@ -74,7 +61,6 @@ export default class ChatView extends BaseChatView { render(result, this); this.content = this.querySelector('.chat-content'); this.help_container = this.querySelector('.chat-content__help'); - this.renderHeading(); return this; } @@ -93,87 +79,6 @@ export default class ChatView extends BaseChatView { this.hide(); } - showUserDetailsModal (ev) { - ev.preventDefault(); - api.modal.show(UserDetailsModal, { model: this.model }, ev); - } - - async generateHeadingTemplate () { - const vcard = this.model?.vcard; - const vcard_json = vcard ? vcard.toJSON() : {}; - const i18n_profile = __("The User's Profile Image"); - const avatar_data = Object.assign( - { - 'alt_text': i18n_profile, - 'extra_classes': '', - 'height': 40, - 'width': 40 - }, - vcard_json - ); - const heading_btns = await this.getHeadingButtons(); - const standalone_btns = heading_btns.filter(b => b.standalone); - const dropdown_btns = heading_btns.filter(b => !b.standalone); - return tpl_chatbox_head( - Object.assign(this.model.toJSON(), { - avatar_data, - 'display_name': this.model.getDisplayName(), - 'dropdown_btns': dropdown_btns.map(b => this.getHeadingDropdownItem(b)), - 'showUserDetailsModal': ev => this.showUserDetailsModal(ev), - 'standalone_btns': standalone_btns.map(b => this.getHeadingStandaloneButton(b)) - }) - ); - } - - /** - * Returns a list of objects which represent buttons for the chat's header. - * @async - * @emits _converse#getHeadingButtons - * @private - * @method _converse.ChatBoxView#getHeadingButtons - */ - getHeadingButtons () { - const buttons = [ - { - 'a_class': 'show-user-details-modal', - 'handler': ev => this.showUserDetailsModal(ev), - 'i18n_text': __('Details'), - 'i18n_title': __('See more information about this person'), - 'icon_class': 'fa-id-card', - 'name': 'details', - 'standalone': api.settings.get('view_mode') === 'overlayed' - } - ]; - if (!api.settings.get('singleton')) { - buttons.push({ - 'a_class': 'close-chatbox-button', - 'handler': ev => this.close(ev), - 'i18n_text': __('Close'), - 'i18n_title': __('Close and end this conversation'), - 'icon_class': 'fa-times', - 'name': 'close', - 'standalone': api.settings.get('view_mode') === 'overlayed' - }); - } - /** - * *Hook* which allows plugins to add more buttons to a chat's heading. - * @event _converse#getHeadingButtons - * @example - * api.listen.on('getHeadingButtons', (view, buttons) => { - * buttons.push({ - * 'i18n_title': __('Foo'), - * 'i18n_text': __('Foo Bar'), - * 'handler': ev => alert('Foo!'), - * 'a_class': 'toggle-foo', - * 'icon_class': 'fa-foo', - * 'name': 'foo' - * }); - * return buttons; - * }); - */ - return _converse.api.hook('getHeadingButtons', this, buttons); - } - /** * Given a message element, determine wether it should be * marked as a followup message to the previous element. diff --git a/src/plugins/headlines-view/heading.js b/src/plugins/headlines-view/heading.js new file mode 100644 index 000000000..f5c5143ff --- /dev/null +++ b/src/plugins/headlines-view/heading.js @@ -0,0 +1,54 @@ +import ChatHeading from 'plugins/chatview/heading.js'; +import tpl_chat_head from './templates/chat-head.js'; +import { __ } from 'i18n'; +import { _converse, api } from "@converse/headless/core"; +import { getHeadingDropdownItem, getHeadingStandaloneButton } from 'plugins/chatview/utils.js'; + + +export default class HeadlinesHeading extends ChatHeading { + + async connectedCallback () { + super.connectedCallback(); + this.model = _converse.chatboxes.get(this.getAttribute('jid')); + await this.model.initialized; + this.render(); + } + + async generateHeadingTemplate () { + const heading_btns = await this.getHeadingButtons(); + const standalone_btns = heading_btns.filter(b => b.standalone); + const dropdown_btns = heading_btns.filter(b => !b.standalone); + return tpl_chat_head( + Object.assign(this.model.toJSON(), { + 'display_name': this.model.getDisplayName(), + 'dropdown_btns': dropdown_btns.map(b => getHeadingDropdownItem(b)), + 'standalone_btns': standalone_btns.map(b => getHeadingStandaloneButton(b)) + }) + ); + } + + /** + * Returns a list of objects which represent buttons for the headlines header. + * @async + * @emits _converse#getHeadingButtons + * @method HeadlinesHeading#getHeadingButtons + */ + getHeadingButtons () { + const buttons = []; + if (!api.settings.get('singleton')) { + buttons.push({ + 'a_class': 'close-chatbox-button', + 'handler': ev => this.close(ev), + 'i18n_text': __('Close'), + 'i18n_title': __('Close these announcements'), + 'icon_class': 'fa-times', + 'name': 'close', + 'standalone': api.settings.get('view_mode') === 'overlayed' + }); + } + return _converse.api.hook('getHeadingButtons', this, buttons); + } + +} + +api.elements.define('converse-headlines-heading', HeadlinesHeading); diff --git a/src/plugins/headlines-view/templates/headlines.js b/src/plugins/headlines-view/templates/headlines.js new file mode 100644 index 000000000..d557c918c --- /dev/null +++ b/src/plugins/headlines-view/templates/headlines.js @@ -0,0 +1,19 @@ +import '../heading.js'; +import { html } from "lit-html"; + +export default (o) => html` +