converse.js
Version:
Browser based XMPP chat client
390 lines (368 loc) • 13.9 kB
JavaScript
/**
* @typedef {import('@converse/headless/types/plugins/muc/muc.js').default} MUC
* @typedef {import("shared/avatar/avatar").default} Avatar
* @typedef {import("shared/autocomplete/suggestion").default} Suggestion
*/
import { html } from 'lit';
import { api, converse, log, constants } from '@converse/headless';
import './modals/occupant.js';
import './modals/moderator-tools.js';
import tplSpinner from 'templates/spinner.js';
import { __ } from 'i18n';
const { Strophe, u } = converse.env;
const { CHATROOMS_TYPE } = constants;
const COMMAND_TO_AFFILIATION = {
admin: 'admin',
ban: 'outcast',
member: 'member',
owner: 'owner',
revoke: 'none',
};
const COMMAND_TO_ROLE = {
deop: 'participant',
kick: 'none',
mute: 'visitor',
op: 'moderator',
voice: 'participant',
};
/**
* Presents a confirmation modal to the user asking them to accept or decline a
* MUC invitation.
* @async
*/
export function confirmDirectMUCInvitation({ contact, jid, reason }) {
if (!reason) {
return api.confirm(
__('Invitation to a groupchat'),
__('%1$s has invited you to join the groupchat "%2$s"', contact, jid)
);
} else {
return api.confirm(
__('Invitation to a groupchat'),
__(
'%1$s has invited you to join the groupchat "%2$s", and left the following reason: "%3$s"',
contact,
jid,
reason
)
);
}
}
/**
* @param {string} jid
*/
export function clearHistory(jid) {
if (location.hash === `converse/room?jid=${jid}`) {
history.pushState(null, '', window.location.pathname);
}
}
/**
* @param {MUC} model
*/
export async function destroyMUC(model) {
const messages = [__('Are you sure you want to destroy this groupchat?')];
let fields = [
{
'name': 'challenge',
'label': __('Please enter the XMPP address of this groupchat to confirm'),
'challenge': model.get('jid'),
'placeholder': __('name@example.org'),
'required': true,
},
{
'name': 'reason',
'label': __('Optional reason for destroying this groupchat'),
'placeholder': __('Reason'),
},
{
'name': 'newjid',
'label': __('Optional XMPP address for a new groupchat that replaces this one'),
'placeholder': __('replacement@example.org'),
},
];
try {
fields = await api.confirm(__('Confirm'), messages, fields);
const reason = fields.filter((f) => f.name === 'reason').pop()?.value;
const newjid = fields.filter((f) => f.name === 'newjid').pop()?.value;
return model.sendDestroyIQ(reason, newjid).then(() => model.close());
} catch (e) {
log.error(e);
}
}
/**
* @param {MUC} model
*/
export function getNicknameRequiredTemplate(model) {
const jid = model.get('jid');
if (api.settings.get('muc_show_logs_before_join')) {
return html`<converse-muc-chatarea class="row g-0" jid="${jid}"></converse-muc-chatarea>`;
} else {
return html`<converse-muc-nickname-form jid="${jid}"></converse-muc-nickname-form>`;
}
}
/**
* @param {MUC} model
*/
export function getChatRoomBodyTemplate(model) {
const view = model.session.get('view');
const jid = model.get('jid');
const RS = converse.ROOMSTATUS;
const conn_status = model.session.get('connection_status');
if (view === converse.MUC.VIEWS.CONFIG) {
return html`<converse-muc-config-form class="muc-form-container" jid="${jid}"></converse-muc-config-form>`;
} else {
return html`
${conn_status == RS.PASSWORD_REQUIRED
? html`<converse-muc-password-form
class="muc-form-container"
jid="${jid}"
></converse-muc-password-form>`
: ''}
${conn_status == RS.ENTERED
? html`<converse-muc-chatarea class="row g-0" jid="${jid}"></converse-muc-chatarea>`
: ''}
${conn_status == RS.CONNECTING ? tplSpinner({ class: 'vertically-centered' }) : ''}
${conn_status == RS.NICKNAME_REQUIRED ? getNicknameRequiredTemplate(model) : ''}
${conn_status == RS.DISCONNECTED
? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>`
: ''}
${conn_status == RS.BANNED
? html`<converse-muc-disconnected jid="${jid}"></converse-muc-disconnected>`
: ''}
${conn_status == RS.DESTROYED ? html`<converse-muc-destroyed jid="${jid}"></converse-muc-destroyed>` : ''}
`;
}
}
/**
* @param {MUC} muc
* @param {Suggestion} text
* @param {string} input
* @returns {import('lit').TemplateResult} The rendered HTML for the item.
*/
export function getAutoCompleteListItem(muc, text, input) {
input = input.trim();
let avatar_model;
const show_avatar = api.settings.get('muc_mention_autocomplete_show_avatar');
if (show_avatar) {
const t = text.label.toLowerCase();
avatar_model = muc.occupants.findWhere((o) => {
if (o.getDisplayName()?.toLowerCase()?.startsWith(t)) {
return o;
} else if (o.get('nickname')?.toLowerCase()?.startsWith(t)) {
return o;
} else if (o.get('jid')?.toLowerCase()?.startsWith(t)) {
return o;
}
});
}
const regex = new RegExp('(' + input + ')', 'ig');
const parts = input ? text.split(regex) : [text];
return html`
<li aria-selected="false">
${show_avatar
? html`<converse-avatar
.model=${avatar_model}
name="${avatar_model.getDisplayName()}"
height="22"
width="22"
class="avatar avatar-autocomplete"
></converse-avatar>`
: ''}
<span class="autocomplete-item">
${parts.map((txt) => (input && txt.match(regex) ? html`<mark>${txt}</mark>` : txt))}
</span>
</li>
`;
}
export async function getAutoCompleteList() {
const models = [...(await api.rooms.get()), ...(await api.contacts.get())];
const jids = [...new Set(models.map((o) => Strophe.getDomainFromJid(o.get('jid'))))];
return jids;
}
/**
* @param {MUC} muc
*/
function setRole(muc, command, args, required_affiliations = [], required_roles = []) {
const role = COMMAND_TO_ROLE[command];
if (!role) {
throw Error(`ChatRoomView#setRole called with invalid command: ${command}`);
}
if (!muc.verifyAffiliations(required_affiliations) || !muc.verifyRoles(required_roles)) {
return false;
}
if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) {
return false;
}
const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args);
if (!nick_or_jid) {
return false;
}
const reason = args.split(nick_or_jid, 2)[1].trim();
// We're guaranteed to have an occupant due to getNickOrJIDFromCommandArgs
const occupant = muc.getOccupant(nick_or_jid);
muc.setRole(occupant, role, reason, undefined, (e) => muc.onCommandError(e));
return true;
}
/**
* @param {MUC} muc
*/
function verifyAndSetAffiliation(muc, command, args, required_affiliations) {
const affiliation = COMMAND_TO_AFFILIATION[command];
if (!affiliation) {
throw Error(`verifyAffiliations called with invalid command: ${command}`);
}
if (!muc.verifyAffiliations(required_affiliations)) {
return false;
}
if (!muc.validateRoleOrAffiliationChangeArgs(command, args)) {
return false;
}
const nick_or_jid = muc.getNickOrJIDFromCommandArgs(args);
if (!nick_or_jid) {
return false;
}
let jid;
const reason = args.split(nick_or_jid, 2)[1].trim();
const occupant = muc.getOccupant(nick_or_jid);
if (occupant) {
jid = occupant.get('jid');
} else {
if (u.isValidJID(nick_or_jid)) {
jid = nick_or_jid;
} else {
const message = __(
"Couldn't find a participant with that nickname. " + 'They might have left the groupchat.'
);
muc.createMessage({ message, 'type': 'error' });
return;
}
}
const attrs = { jid, reason };
if (occupant && api.settings.get('auto_register_muc_nickname')) {
attrs['nick'] = occupant.get('nick');
}
u.muc
.setAffiliation(affiliation, muc.get('jid'), [attrs])
.then(() => muc.occupants.fetchMembers())
.catch((err) => muc.onCommandError(err));
}
/**
* @param {MUC} muc
* @param {string} [affiliation]
*/
export function showModeratorToolsModal(muc, affiliation) {
if (!muc.verifyRoles(['moderator'])) {
return;
}
let modal = api.modal.get('converse-modtools-modal');
if (modal) {
modal.affiliation = affiliation;
modal.render();
} else {
modal = api.modal.create('converse-modtools-modal', { affiliation, 'jid': muc.get('jid') });
}
modal.show();
}
export function showOccupantModal(ev, occupant) {
api.modal.show('converse-muc-occupant-modal', { 'model': occupant }, ev);
}
export function parseMessageForMUCCommands(data, handled) {
const model = data.model;
if (
handled ||
model.get('type') !== CHATROOMS_TYPE ||
(api.settings.get('muc_disable_slash_commands') &&
!Array.isArray(api.settings.get('muc_disable_slash_commands')))
) {
return handled;
}
let text = data.text;
text = text.replace(/^\s*/, '');
const command = (text.match(/^\/([a-zA-Z]*) ?/) || ['']).pop().toLowerCase();
if (!command) {
return false;
}
const args = text.slice(('/' + command).length + 1).trim();
const allowed_commands = model.getAllowedCommands() ?? [];
if (command === 'admin' && allowed_commands.includes(command)) {
verifyAndSetAffiliation(model, command, args, ['owner']);
return true;
} else if (command === 'ban' && allowed_commands.includes(command)) {
verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
return true;
} else if (command === 'modtools' && allowed_commands.includes(command)) {
showModeratorToolsModal(model, args);
return true;
} else if (command === 'deop' && allowed_commands.includes(command)) {
// FIXME: /deop only applies to setting a moderators
// role to "participant" (which only admin/owner can
// do). Moderators can however set non-moderator's role
// to participant (e.g. visitor => participant).
// Currently we don't distinguish between these two
// cases.
setRole(model, command, args, ['admin', 'owner']);
return true;
} else if (command === 'destroy' && allowed_commands.includes(command)) {
if (!model.verifyAffiliations(['owner'])) {
return true;
}
destroyMUC(model).catch((e) => model.onCommandError(e));
return true;
} else if (command === 'help' && allowed_commands.includes(command)) {
model.set({ 'show_help_messages': false }, { 'silent': true });
model.set({ 'show_help_messages': true });
return true;
} else if (command === 'kick' && allowed_commands.includes(command)) {
setRole(model, command, args, [], ['moderator']);
return true;
} else if (command === 'mute' && allowed_commands.includes(command)) {
setRole(model, command, args, [], ['moderator']);
return true;
} else if (command === 'member' && allowed_commands.includes(command)) {
verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
return true;
} else if (command === 'nick' && allowed_commands.includes(command)) {
if (!model.verifyRoles(['visitor', 'participant', 'moderator'])) {
return true;
} else if (args.length === 0) {
// e.g. Your nickname is "coolguy69"
const message = __('Your nickname is "%1$s"', model.get('nick'));
model.createMessage({ message, 'type': 'error' });
} else {
model.setNickname(args);
}
return true;
} else if (command === 'owner' && allowed_commands.includes(command)) {
verifyAndSetAffiliation(model, command, args, ['owner']);
return true;
} else if (command === 'op' && allowed_commands.includes(command)) {
setRole(model, command, args, ['admin', 'owner']);
return true;
} else if (command === 'register' && allowed_commands.includes(command)) {
if (args.length > 1) {
model.createMessage({
'message': __('Error: invalid number of arguments'),
'type': 'error',
});
} else {
model.registerNickname().then((err_msg) => {
err_msg && model.createMessage({ 'message': err_msg, 'type': 'error' });
});
}
return true;
} else if (command === 'revoke' && allowed_commands.includes(command)) {
verifyAndSetAffiliation(model, command, args, ['admin', 'owner']);
return true;
} else if (
(command === 'topic' && allowed_commands.includes(command)) ||
(command === 'subject' && allowed_commands.includes(command))
) {
model.setSubject(args);
return true;
} else if (command === 'voice' && allowed_commands.includes(command)) {
setRole(model, command, args, [], ['moderator']);
return true;
} else {
return false;
}
}