Fix embedded, singleton mode.

It's now necessary to add a `converse-root` element in the DOM where you
want Converse to render (previously it was any element with the id
`#conversejs`).

Also, turned `converse-chats` element into a Lit element and re-render
`converse-root` and `converse-chats` when the `view-mode` or `singleton`
settings change. This is a step towards being able to change the view
mode on the fly and have the entire chat re-render appropriately.

Fixes #2647
This commit is contained in:
JC Brand 2021-09-24 10:32:33 +02:00
parent 5ff57258ec
commit 84c6a0039c
21 changed files with 141 additions and 88 deletions

View File

@ -2,6 +2,8 @@
## 9.0.0 (Unreleased)
- #2647: Singleton mode doesn't work
- Emit a `change` event when a configuration setting changes
- 3 New configuration settings:
- [render_media](https://conversejs.org/docs/html/configuration.html#render-media)

View File

@ -72,7 +72,7 @@
</h1>
<p class="intro-text">Embedded MUC chat demo</p>
<div class="converse-container">
<div id="conversejs"></div>
<converse-root></converse-root>
</div>
</div>
</div>

View File

@ -828,6 +828,10 @@ Object.assign(converse, {
await initClientConfig(_converse);
await i18n.initialize();
initPlugins(_converse);
// Register all custom elements
api.elements.register();
registerGlobalEventHandlers(_converse);
try {

View File

@ -1,4 +1,4 @@
import _converse from '@converse/headless/shared/_converse';
import { _converse } from '@converse/headless/core.js';
import assignIn from 'lodash-es/assignIn';
import isEqual from "lodash-es/isEqual.js";
import isObject from 'lodash-es/isObject';
@ -14,7 +14,6 @@ let app_settings;
let init_settings = {}; // Container for settings passed in via converse.initialize
let user_settings; // User settings, populated via api.users.settings
export function getAppSettings () {
return app_settings;
}

View File

@ -205,7 +205,13 @@ describe("A chat room", function () {
[{'category': 'pubsub', 'type': 'pep'}],
['http://jabber.org/protocol/pubsub#publish-options']
);
await _converse.api.rooms.open(`lounge@montague.lit`);
const nick = 'romeo';
const muc_jid = 'lounge@montague.lit';
await _converse.api.rooms.open(muc_jid);
await mock.getRoomFeatures(_converse, muc_jid);
await mock.waitForReservedNick(_converse, muc_jid, nick);
const view = _converse.chatboxviews.get('lounge@montague.lit');
expect(view.querySelector('.chatbox-title__text .fa-bookmark')).toBe(null);
_converse.bookmarks.create({
@ -225,8 +231,12 @@ describe("A chat room", function () {
const { u, Strophe } = converse.env;
await mock.waitForRoster(_converse, 'current', 0);
await mock.waitUntilBookmarksReturned(_converse);
const nick = 'romeo';
const muc_jid = 'theplay@conference.shakespeare.lit';
await _converse.api.rooms.open(muc_jid);
await mock.getRoomFeatures(_converse, muc_jid);
await mock.waitForReservedNick(_converse, muc_jid, nick);
const view = _converse.chatboxviews.get(muc_jid);
await u.waitUntil(() => view.querySelector('.toggle-bookmark'));

View File

@ -17,11 +17,6 @@ converse.plugins.add('converse-chatboxviews', {
dependencies: ['converse-chatboxes', 'converse-vcard'],
initialize () {
/* The initialize function gets called as soon as the plugin is
* loaded by converse.js's plugin machinery.
*/
api.elements.register();
api.promises.add(['chatBoxViewsInitialized']);
// Configuration values for this plugin

View File

@ -36,7 +36,6 @@
bottom: auto;
height: 100%;
width: 100%;
margin-left: -15px;
}
}
}

View File

@ -1,26 +1,31 @@
import tpl_background_logo from '../../templates/background_logo.js';
import tpl_chats from './templates/chats.js';
import { ElementView } from '@converse/skeletor/src/element.js';
import { CustomElement } from 'shared/components/element.js';
import { api, _converse } from '@converse/headless/core';
import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
import { render } from 'lit';
class ConverseChats extends ElementView {
class ConverseChats extends CustomElement {
initialize () {
this.model = _converse.chatboxes;
this.listenTo(this.model, 'add', this.render);
this.listenTo(this.model, 'change:closed', this.render);
this.listenTo(this.model, 'change:hidden', this.render);
this.listenTo(this.model, 'change:jid', this.render);
this.listenTo(this.model, 'change:minimized', this.render);
this.listenTo(this.model, 'destroy', this.render);
this.listenTo(this.model, 'add', () => this.requestUpdate());
this.listenTo(this.model, 'change:closed', () => this.requestUpdate());
this.listenTo(this.model, 'change:hidden', () => this.requestUpdate());
this.listenTo(this.model, 'change:jid', () => this.requestUpdate());
this.listenTo(this.model, 'change:minimized', () => this.requestUpdate());
this.listenTo(this.model, 'destroy', () => this.requestUpdate());
// Use listenTo instead of api.listen.to so that event handlers
// automatically get deregistered when the component is dismounted
this.listenTo(_converse, 'connected', this.render);
this.listenTo(_converse, 'reconnected', this.render);
this.listenTo(_converse, 'disconnected', this.render);
this.listenTo(_converse, 'connected', () => this.requestUpdate());
this.listenTo(_converse, 'reconnected', () => this.requestUpdate());
this.listenTo(_converse, 'disconnected', () => this.requestUpdate());
const settings = getAppSettings();
this.listenTo(settings, 'change:view_mode', () => this.requestUpdate())
this.listenTo(settings, 'change:singleton', () => this.requestUpdate())
const bg = document.getElementById('conversejs-bg');
if (bg && !bg.innerHTML.trim()) {
@ -28,7 +33,6 @@ class ConverseChats extends ElementView {
}
const body = document.querySelector('body');
body.classList.add(`converse-${api.settings.get('view_mode')}`);
this.render();
/**
* Triggered once the _converse.ChatBoxViews view-colleciton has been initialized
@ -38,8 +42,8 @@ class ConverseChats extends ElementView {
api.trigger('chatBoxViewsInitialized');
}
render () {
render(tpl_chats(), this);
render () { // eslint-disable-line class-methods-use-this
return tpl_chats();
}
}

View File

@ -101,28 +101,6 @@
box-shadow: none;
overflow: hidden;
}
&:not(#controlbox) {
.box-flyout {
@include media-breakpoint-up(md) {
max-width: 66.666667%;
}
@include media-breakpoint-up(lg) {
max-width: 75%;
}
@include media-breakpoint-up(xl) {
max-width: 83.333333%;
}
}
}
@include media-breakpoint-up(md) {
@include make-col(8);
}
@include media-breakpoint-up(lg) {
@include make-col(9);
}
@include media-breakpoint-up(xl) {
@include make-col(10);
}
}
&.converse-singleton {
@ -134,6 +112,14 @@
}
.chatbox {
margin: 0;
position: relative;
}
}
}
converse-chats.converse-fullscreen {
&.converse-singleton {
.chatbox {
@include make-col-ready();
@include media-breakpoint-up(md) {
@include make-col(12);
@ -146,6 +132,34 @@
}
}
}
&:not(.converse-singleton) {
.chatbox {
@include media-breakpoint-up(md) {
@include make-col(8);
}
@include media-breakpoint-up(lg) {
@include make-col(9);
}
@include media-breakpoint-up(xl) {
@include make-col(10);
}
&:not(#controlbox) {
.box-flyout {
@include media-breakpoint-up(md) {
max-width: 66.666667%;
}
@include media-breakpoint-up(lg) {
max-width: 75%;
}
@include media-breakpoint-up(xl) {
max-width: 83.333333%;
}
}
}
}
}
}
converse-chats.converse-embedded {

View File

@ -9,12 +9,12 @@ const sizzle = converse.env.sizzle;
describe("The Controlbox", function () {
it("can be opened by clicking a DOM element with class 'toggle-controlbox'",
mock.initConverse([], {}, function (_converse) {
mock.initConverse([], {}, async function (_converse) {
spyOn(_converse.api, "trigger").and.callThrough();
document.querySelector('.toggle-controlbox').click();
expect(_converse.api.trigger).toHaveBeenCalledWith('controlBoxOpened', jasmine.any(Object));
const el = document.querySelector("#controlbox");
const el = await u.waitUntil(() => document.querySelector("#controlbox"));
expect(u.isVisible(el)).toBe(true);
}));

View File

@ -1,4 +1,5 @@
body.converse-fullscreen {
margin: 0;
background-color: var(--global-background-color);
overflow: hidden;
}

View File

@ -71,14 +71,15 @@ describe("A Groupchat", function () {
it("can be minimized by clicking a DOM element with class 'toggle-chatbox-button'",
mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
const view = _converse.chatboxviews.get('lounge@montague.lit');
const muc_jid = 'lounge@conference.shakespeare.lit';
await mock.openAndEnterChatRoom(_converse, muc_jid, 'romeo');
const view = _converse.chatboxviews.get(muc_jid);
spyOn(_converse.api, "trigger").and.callThrough();
const button = await u.waitUntil(() => view.querySelector('.toggle-chatbox-button'));
button.click();
expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
expect(u.isVisible(view)).toBeFalsy();
await u.waitUntil(() => !u.isVisible(view));
expect(view.model.get('minimized')).toBeTruthy();
const el = await u.waitUntil(() => document.querySelector("converse-minimized-chat a.restore-chat"));
el.click();
@ -107,7 +108,7 @@ describe("A Chatbox", function () {
expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxMinimized', jasmine.any(Object));
expect(_converse.api.trigger.calls.count(), 2);
expect(u.isVisible(chatview)).toBeFalsy();
await u.waitUntil(() => !u.isVisible(chatview));
expect(chatview.model.get('minimized')).toBeTruthy();
const restore_el = await u.waitUntil(() => document.querySelector("converse-minimized-chat a.restore-chat"));
restore_el.click();

View File

@ -100,7 +100,6 @@ describe("Groupchats", function () {
mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
const { api } = _converse;
// Mock 'getDiscoInfo', otherwise the room won't be
// displayed as it waits first for the features to be returned
// (when it's a new room being created).
@ -116,14 +115,14 @@ describe("Groupchats", function () {
let room = await _converse.api.rooms.open(jid);
// Test on groupchat that's not yet open
expect(room instanceof Model).toBeTruthy();
chatroomview = _converse.chatboxviews.get(jid);
chatroomview = await u.waitUntil(() => _converse.chatboxviews.get(jid));
expect(chatroomview.is_chatroom).toBeTruthy();
await u.waitUntil(() => u.isVisible(chatroomview));
// Test again, now that the room exists.
room = await _converse.api.rooms.open(jid);
expect(room instanceof Model).toBeTruthy();
chatroomview = _converse.chatboxviews.get(jid);
chatroomview = await u.waitUntil(() => _converse.chatboxviews.get(jid));
expect(chatroomview.is_chatroom).toBeTruthy();
expect(u.isVisible(chatroomview)).toBeTruthy();
await chatroomview.close();
@ -132,19 +131,19 @@ describe("Groupchats", function () {
jid = 'Leisure@montague.lit';
room = await _converse.api.rooms.open(jid);
expect(room instanceof Model).toBeTruthy();
chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
chatroomview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase()));
await u.waitUntil(() => u.isVisible(chatroomview));
jid = 'leisure@montague.lit';
room = await _converse.api.rooms.open(jid);
expect(room instanceof Model).toBeTruthy();
chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
chatroomview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase()));
await u.waitUntil(() => u.isVisible(chatroomview));
jid = 'leiSure@montague.lit';
room = await _converse.api.rooms.open(jid);
expect(room instanceof Model).toBeTruthy();
chatroomview = _converse.chatboxviews.get(jid.toLowerCase());
chatroomview = await u.waitUntil(() => _converse.chatboxviews.get(jid.toLowerCase()));
await u.waitUntil(() => u.isVisible(chatroomview));
chatroomview.close();
@ -168,7 +167,6 @@ describe("Groupchats", function () {
}
});
expect(room instanceof Model).toBeTruthy();
chatroomview = _converse.chatboxviews.get('room@conference.example.org');
// We pretend this is a new room, so no disco info is returned.
const features_stanza = $iq({
@ -247,6 +245,7 @@ describe("Groupchats", function () {
</query>
</iq>`);
chatroomview = _converse.chatboxviews.get('room@conference.example.org');
spyOn(chatroomview.model, 'sendConfiguration').and.callThrough();
_converse.connection._dataRecv(mock.createRequest(node));
await u.waitUntil(() => chatroomview.model.sendConfiguration.calls.count() === 1);

View File

@ -1214,7 +1214,7 @@ describe("Groupchats", function () {
});
await _converse.api.rooms.open('coven@chat.shakespeare.lit', {'nick': 'some1'});
const view = _converse.chatboxviews.get('coven@chat.shakespeare.lit');
const view = await u.waitUntil(() => _converse.chatboxviews.get('coven@chat.shakespeare.lit'));
await u.waitUntil(() => u.isVisible(view));
// We pretend this is a new room, so no disco info is returned.
const features_stanza = $iq({
@ -2275,7 +2275,7 @@ describe("Groupchats", function () {
* </presence>
*/
await mock.openAndEnterChatRoom(_converse, 'lounge@montague.lit', 'romeo');
var presence = $pres().attrs({
const presence = $pres().attrs({
from:'lounge@montague.lit/romeo',
to:'romeo@montague.lit/pda',
type:'unavailable'
@ -2335,16 +2335,16 @@ describe("Groupchats", function () {
it("can be closed again by clicking a DOM element with class 'close-chatbox-button'",
mock.initConverse(['chatBoxesFetched'], {}, async function (_converse) {
await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
const view = _converse.chatboxviews.get('lounge@montague.lit');
spyOn(view.model, 'close').and.callThrough();
const model = await mock.openChatRoom(_converse, 'lounge', 'montague.lit', 'romeo');
spyOn(model, 'close').and.callThrough();
spyOn(_converse.api, "trigger").and.callThrough();
spyOn(view.model, 'leave');
spyOn(model, 'leave');
spyOn(_converse.api, 'confirm').and.callFake(() => Promise.resolve(true));
const view = await u.waitUntil(() => _converse.chatboxviews.get('lounge@montague.lit'));
const button = await u.waitUntil(() => view.querySelector('.close-chatbox-button'));
button.click();
await u.waitUntil(() => view.model.close.calls.count());
expect(view.model.leave).toHaveBeenCalled();
await u.waitUntil(() => model.close.calls.count());
expect(model.leave).toHaveBeenCalled();
await u.waitUntil(() => _converse.api.trigger.calls.count());
expect(_converse.api.trigger).toHaveBeenCalledWith('chatBoxClosed', jasmine.any(Object));
}));
@ -3980,7 +3980,7 @@ describe("Groupchats", function () {
var new_list = [];
var old_list = [];
const muc_utils = converse.env.muc_utils;
var delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
let delta = muc_utils.computeAffiliationsDelta(exclude_existing, remove_absentees, new_list, old_list);
expect(delta.length).toBe(0);
new_list = [{'jid': 'wiccarocks@shakespeare.lit', 'affiliation': 'member'}];
@ -4272,7 +4272,7 @@ describe("Groupchats", function () {
rooms[4].querySelector('.open-room').click();
await u.waitUntil(() => _converse.chatboxes.length > 1);
expect(sizzle('.chatroom', _converse.el).filter(u.isVisible).length).toBe(1); // There should now be an open chatroom
var view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit');
const view = _converse.chatboxviews.get('inverness@chat.shakespeare.lit');
expect(view.querySelector('.chatbox-title__text').textContent.trim()).toBe("Macbeth's Castle");
}));
@ -4577,7 +4577,7 @@ describe("Groupchats", function () {
// See XEP-0085 https://xmpp.org/extensions/xep-0085.html#definitions
// <composing> state
var msg = $msg({
let msg = $msg({
from: muc_jid+'/newguy',
id: u.getUniqueId(),
to: 'romeo@montague.lit',

View File

@ -201,7 +201,6 @@ describe("A groupchat shown in the groupchats list", function () {
await mock.waitForRoster(_converse, 'current', 0);
await mock.openControlBox(_converse);
await _converse.api.rooms.open(room_jid, {'nick': 'some1'});
const view = _converse.chatboxviews.get(room_jid);
const selector = `iq[to="${room_jid}"] query[xmlns="http://jabber.org/protocol/disco#info"]`;
const features_query = await u.waitUntil(() => IQ_stanzas.filter(iq => iq.querySelector(selector)).pop());
@ -233,6 +232,8 @@ describe("A groupchat shown in the groupchats list", function () {
.c('field', {'type':'text-single', 'var':'muc#roominfo_occupants', 'label':'Number of occupants'})
.c('value').t(0);
_converse.connection._dataRecv(mock.createRequest(features_stanza));
const view = _converse.chatboxviews.get(room_jid);
await u.waitUntil(() => view.model.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING)
let presence = $pres({
to: _converse.connection.jid,

View File

@ -1,4 +1,4 @@
import './root.js';
import ConverseRoot from './root.js';
import { api, converse } from '@converse/headless/core';
import { ensureElement } from './utils.js';
@ -8,5 +8,10 @@ converse.plugins.add('converse-rootview', {
initialize () {
api.settings.extend({ 'auto_insert': true });
api.listen.on('chatBoxesInitialized', ensureElement);
// Only define the element now, otherwise it it's already in the DOM
// before `converse.initialized` has been called it will render too
// early.
api.elements.define('converse-root', ConverseRoot);
}
});

View File

@ -1,6 +1,9 @@
import tpl_root from "./templates/root.js";
import { api } from '@converse/headless/core';
import { CustomElement } from 'shared/components/element.js';
import { getAppSettings } from '@converse/headless/shared/settings/utils.js';
import './styles/root.scss';
/**
@ -10,19 +13,25 @@ import { CustomElement } from 'shared/components/element.js';
* It can be inserted into the DOM before or after Converse has loaded or been
* initialized.
*/
class ConverseRoot extends CustomElement {
export default class ConverseRoot extends CustomElement {
render () { // eslint-disable-line class-methods-use-this
return tpl_root();
}
connectedCallback () {
super.connectedCallback();
initialize () {
this.setAttribute('id', 'conversejs');
this.setClasses();
const settings = getAppSettings();
this.listenTo(settings, 'change:view_mode', () => this.setClasses())
this.listenTo(settings, 'change:singleton', () => this.setClasses())
}
setClasses () {
this.className = "";
this.classList.add('conversejs');
this.classList.add(`converse-${api.settings.get('view_mode')}`);
this.classList.add(`theme-${api.settings.get('theme')}`);
this.setAttribute('id', 'conversejs');
this.requestUpdate();
}
}
customElements.define('converse-root', ConverseRoot);

View File

@ -0,0 +1,16 @@
converse-root.converse-js {
&.converse-fullpage,
&.converse-overlayed,
&.converse-mobile {
bottom: 0;
height: 100%;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
position: fixed;
z-index: 1031; // One more than bootstrap navbar
}
&.converse-embedded {
position: relative;
}
}

View File

@ -3,10 +3,10 @@ import { api } from '@converse/headless/core';
import { html } from 'lit';
export default () => {
let extra_classes = api.settings.get('singleton') ? 'converse-singleton' : '';
extra_classes += `converse-${api.settings.get('view_mode')}`;
const extra_classes = api.settings.get('singleton') ? ['converse-singleton'] : [];
extra_classes.push(`converse-${api.settings.get('view_mode')}`);
return html`
<converse-chats class="converse-chatboxes row no-gutters ${extra_classes}"></converse-chats>
<converse-chats class="converse-chatboxes row no-gutters ${extra_classes.join(' ')}"></converse-chats>
<div id="converse-modals" class="modals"></div>
<converse-fontawesome></converse-fontawesome>
`;

View File

@ -6,7 +6,7 @@ export function ensureElement () {
return;
}
const root = api.settings.get('root');
if (!root.querySelector('converse-root#conversejs')) {
if (!root.querySelector('converse-root')) {
const el = document.createElement('converse-root');
const body = root.querySelector('body');
if (body) {

View File

@ -1,14 +1,8 @@
.conversejs {
bottom: 0;
height: 100%;
position: fixed;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
color: var(--text-color);
font-family: var(--normal-font);
font-size: var(--font-size);
direction: ltr;
z-index: 1031; // One more than bootstrap navbar
.flyout {
position: absolute;