muc.js 103 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650
  1. import debounce from 'lodash-es/debounce';
  2. import invoke from 'lodash-es/invoke';
  3. import isElement from 'lodash-es/isElement';
  4. import log from '../../log';
  5. import p from '../../utils/parse-helpers';
  6. import pick from 'lodash-es/pick';
  7. import sizzle from 'sizzle';
  8. import u from '../../utils/form';
  9. import zipObject from 'lodash-es/zipObject';
  10. import { Model } from '@converse/skeletor/src/model.js';
  11. import { Strophe, $build, $iq, $msg, $pres } from 'strophe.js/src/strophe';
  12. import { _converse, api, converse } from '../../core.js';
  13. import { computeAffiliationsDelta, setAffiliations, getAffiliationList } from './affiliations/utils.js';
  14. import { getOpenPromise } from '@converse/openpromise';
  15. import { initStorage } from '@converse/headless/utils/storage.js';
  16. import { isArchived, getMediaURLs } from '@converse/headless/shared/parsers';
  17. import { parseMUCMessage, parseMUCPresence } from './parsers.js';
  18. import { sendMarker } from '@converse/headless/shared/actions';
  19. const OWNER_COMMANDS = ['owner'];
  20. const ADMIN_COMMANDS = ['admin', 'ban', 'deop', 'destroy', 'member', 'op', 'revoke'];
  21. const MODERATOR_COMMANDS = ['kick', 'mute', 'voice', 'modtools'];
  22. const VISITOR_COMMANDS = ['nick'];
  23. const METADATA_ATTRIBUTES = [
  24. "og:article:author",
  25. "og:article:published_time",
  26. "og:description",
  27. "og:image",
  28. "og:image:height",
  29. "og:image:width",
  30. "og:site_name",
  31. "og:title",
  32. "og:type",
  33. "og:url",
  34. "og:video:height",
  35. "og:video:secure_url",
  36. "og:video:tag",
  37. "og:video:type",
  38. "og:video:url",
  39. "og:video:width"
  40. ];
  41. const ACTION_INFO_CODES = ['301', '303', '333', '307', '321', '322'];
  42. const MUCSession = Model.extend({
  43. defaults () {
  44. return {
  45. 'connection_status': converse.ROOMSTATUS.DISCONNECTED
  46. };
  47. }
  48. });
  49. /**
  50. * Represents an open/ongoing groupchat conversation.
  51. * @mixin
  52. * @namespace _converse.ChatRoom
  53. * @memberOf _converse
  54. */
  55. const ChatRoomMixin = {
  56. defaults () {
  57. return {
  58. 'bookmarked': false,
  59. 'chat_state': undefined,
  60. 'has_activity': false, // XEP-437
  61. 'hidden': _converse.isUniView() && !api.settings.get('singleton'),
  62. 'hidden_occupants': !!api.settings.get('hide_muc_participants'),
  63. 'message_type': 'groupchat',
  64. 'name': '',
  65. // For group chats, we distinguish between generally unread
  66. // messages and those ones that specifically mention the
  67. // user.
  68. //
  69. // To keep things simple, we reuse `num_unread` from
  70. // _converse.ChatBox to indicate unread messages which
  71. // mention the user and `num_unread_general` to indicate
  72. // generally unread messages (which *includes* mentions!).
  73. 'num_unread_general': 0,
  74. 'num_unread': 0,
  75. 'roomconfig': {},
  76. 'time_opened': this.get('time_opened') || new Date().getTime(),
  77. 'time_sent': new Date(0).toISOString(),
  78. 'type': _converse.CHATROOMS_TYPE
  79. };
  80. },
  81. async initialize () {
  82. this.initialized = getOpenPromise();
  83. this.debouncedRejoin = debounce(this.rejoin, 250);
  84. this.set('box_id', `box-${this.get('jid')}`);
  85. this.initNotifications();
  86. this.initMessages();
  87. this.initUI();
  88. this.initOccupants();
  89. this.initDiscoModels(); // sendChatState depends on this.features
  90. this.registerHandlers();
  91. this.on('change:chat_state', this.sendChatState, this);
  92. this.on('change:hidden', this.onHiddenChange, this);
  93. this.on('destroy', this.removeHandlers, this);
  94. this.ui.on('change:scrolled', this.onScrolledChanged, this);
  95. await this.restoreSession();
  96. this.session.on('change:connection_status', this.onConnectionStatusChanged, this);
  97. this.listenTo(this.occupants, 'add', this.onOccupantAdded);
  98. this.listenTo(this.occupants, 'remove', this.onOccupantRemoved);
  99. this.listenTo(this.occupants, 'change:show', this.onOccupantShowChanged);
  100. this.listenTo(this.occupants, 'change:affiliation', this.createAffiliationChangeMessage);
  101. this.listenTo(this.occupants, 'change:role', this.createRoleChangeMessage);
  102. const restored = await this.restoreFromCache();
  103. if (!restored) {
  104. this.join();
  105. }
  106. /**
  107. * Triggered once a {@link _converse.ChatRoom} has been created and initialized.
  108. * @event _converse#chatRoomInitialized
  109. * @type { _converse.ChatRoom }
  110. * @example _converse.api.listen.on('chatRoomInitialized', model => { ... });
  111. */
  112. await api.trigger('chatRoomInitialized', this, { 'Synchronous': true });
  113. this.initialized.resolve();
  114. },
  115. /**
  116. * Checks whether we're still joined and if so, restores the MUC state from cache.
  117. * @private
  118. * @method _converse.ChatRoom#restoreFromCache
  119. * @returns { Boolean } Returns `true` if we're still joined, otherwise returns `false`.
  120. */
  121. async restoreFromCache () {
  122. if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED && (await this.isJoined())) {
  123. // We've restored the room from cache and we're still joined.
  124. await new Promise(resolve => this.features.fetch({ 'success': resolve, 'error': resolve }));
  125. await this.fetchOccupants().catch(e => log.error(e));
  126. await this.fetchMessages().catch(e => log.error(e));
  127. return true;
  128. } else {
  129. this.session.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
  130. this.clearOccupantsCache();
  131. return false;
  132. }
  133. },
  134. /**
  135. * Join the MUC
  136. * @private
  137. * @method _converse.ChatRoom#join
  138. * @param { String } nick - The user's nickname
  139. * @param { String } [password] - Optional password, if required by the groupchat.
  140. * Will fall back to the `password` value stored in the room
  141. * model (if available).
  142. */
  143. async join (nick, password) {
  144. if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
  145. // We have restored a groupchat from session storage,
  146. // so we don't send out a presence stanza again.
  147. return this;
  148. }
  149. // Set this early, so we don't rejoin in onHiddenChange
  150. this.session.save('connection_status', converse.ROOMSTATUS.CONNECTING);
  151. await this.refreshDiscoInfo();
  152. nick = await this.getAndPersistNickname(nick);
  153. if (!nick) {
  154. u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED });
  155. if (api.settings.get('muc_show_logs_before_join')) {
  156. await this.fetchMessages();
  157. }
  158. return this;
  159. }
  160. api.send(await this.constructPresence(password));
  161. return this;
  162. },
  163. /**
  164. * Clear stale cache and re-join a MUC we've been in before.
  165. * @private
  166. * @method _converse.ChatRoom#rejoin
  167. */
  168. rejoin () {
  169. this.session.save('connection_status', converse.ROOMSTATUS.DISCONNECTED);
  170. this.registerHandlers();
  171. this.clearOccupantsCache();
  172. return this.join();
  173. },
  174. async constructPresence (password) {
  175. let stanza = $pres({
  176. 'from': _converse.connection.jid,
  177. 'to': this.getRoomJIDAndNick()
  178. }).c('x', { 'xmlns': Strophe.NS.MUC })
  179. .c('history', {
  180. 'maxstanzas': this.features.get('mam_enabled') ? 0 : api.settings.get('muc_history_max_stanzas')
  181. }).up();
  182. password = password || this.get('password');
  183. if (password) {
  184. stanza.cnode(Strophe.xmlElement('password', [], password));
  185. }
  186. stanza = await api.hook('constructedMUCPresence', null, stanza);
  187. return stanza;
  188. },
  189. clearOccupantsCache () {
  190. if (this.occupants.length) {
  191. // Remove non-members when reconnecting
  192. this.occupants.filter(o => !o.isMember()).forEach(o => o.destroy());
  193. } else {
  194. // Looks like we haven't restored occupants from cache, so we clear it.
  195. this.occupants.clearStore();
  196. }
  197. },
  198. /**
  199. * Given the passed in MUC message, send a XEP-0333 chat marker.
  200. * @param { _converse.MUCMessage } msg
  201. * @param { ('received'|'displayed'|'acknowledged') } [type='displayed']
  202. * @param { Boolean } force - Whether a marker should be sent for the
  203. * message, even if it didn't include a `markable` element.
  204. */
  205. sendMarkerForMessage (msg, type = 'displayed', force = false) {
  206. if (!msg || !api.settings.get('send_chat_markers').includes(type)) {
  207. return;
  208. }
  209. if (msg?.get('is_markable') || force) {
  210. const key = `stanza_id ${this.get('jid')}`;
  211. const id = msg.get(key);
  212. if (!id) {
  213. log.error(`Can't send marker for message without stanza ID: ${key}`);
  214. return;
  215. }
  216. const from_jid = Strophe.getBareJidFromJid(msg.get('from'));
  217. sendMarker(from_jid, id, type, msg.get('type'));
  218. }
  219. },
  220. /**
  221. * Ensures that the user is subscribed to XEP-0437 Room Activity Indicators
  222. * if `muc_subscribe_to_rai` is set to `true`.
  223. * Only affiliated users can subscribe to RAI, but this method doesn't
  224. * check whether the current user is affiliated because it's intended to be
  225. * called after the MUC has been left and we don't have that information
  226. * anymore.
  227. * @private
  228. * @method _converse.ChatRoom#enableRAI
  229. */
  230. enableRAI () {
  231. if (api.settings.get('muc_subscribe_to_rai')) {
  232. const muc_domain = Strophe.getDomainFromJid(this.get('jid'));
  233. api.user.presence.send(null, muc_domain, null, $build('rai', { 'xmlns': Strophe.NS.RAI }));
  234. }
  235. },
  236. /**
  237. * Handler that gets called when the 'hidden' flag is toggled.
  238. * @private
  239. * @method _converse.ChatRoom#onHiddenChange
  240. */
  241. async onHiddenChange () {
  242. const conn_status = this.session.get('connection_status');
  243. if (this.get('hidden')) {
  244. if (conn_status === converse.ROOMSTATUS.ENTERED &&
  245. api.settings.get('muc_subscribe_to_rai') &&
  246. this.getOwnAffiliation() !== 'none') {
  247. if (conn_status !== converse.ROOMSTATUS.DISCONNECTED) {
  248. this.sendMarkerForLastMessage('received', true);
  249. await this.leave();
  250. }
  251. this.enableRAI();
  252. }
  253. } else {
  254. if (conn_status === converse.ROOMSTATUS.DISCONNECTED) {
  255. this.rejoin();
  256. }
  257. this.clearUnreadMsgCounter();
  258. }
  259. },
  260. onOccupantAdded (occupant) {
  261. if (
  262. _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED) &&
  263. this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
  264. occupant.get('show') === 'online'
  265. ) {
  266. this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
  267. }
  268. },
  269. onOccupantRemoved (occupant) {
  270. if (
  271. _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED) &&
  272. this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED &&
  273. occupant.get('show') === 'online'
  274. ) {
  275. this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
  276. }
  277. },
  278. onOccupantShowChanged (occupant) {
  279. if (occupant.get('states').includes('303')) {
  280. return;
  281. }
  282. if (occupant.get('show') === 'offline' && _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.EXITED)) {
  283. this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.EXITED);
  284. } else if (occupant.get('show') === 'online' && _converse.isInfoVisible(converse.MUC_TRAFFIC_STATES.ENTERED)) {
  285. this.updateNotifications(occupant.get('nick'), converse.MUC_TRAFFIC_STATES.ENTERED);
  286. }
  287. },
  288. async onRoomEntered () {
  289. await this.occupants.fetchMembers();
  290. if (api.settings.get('clear_messages_on_reconnection')) {
  291. // Don't call this.clearMessages because we don't want to
  292. // recreate promises, since that will cause some existing
  293. // awaiters to never proceed.
  294. await this.messages.clearStore();
  295. // A bit hacky. No need to fetch messages after clearing
  296. this.messages.fetched.resolve();
  297. } else {
  298. await this.fetchMessages();
  299. }
  300. /**
  301. * Triggered when the user has entered a new MUC
  302. * @event _converse#enteredNewRoom
  303. * @type { _converse.ChatRoom}
  304. * @example _converse.api.listen.on('enteredNewRoom', model => { ... });
  305. */
  306. api.trigger('enteredNewRoom', this);
  307. if (
  308. api.settings.get('auto_register_muc_nickname') &&
  309. (await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid')))
  310. ) {
  311. this.registerNickname();
  312. }
  313. },
  314. async onConnectionStatusChanged () {
  315. if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
  316. if (this.get('hidden') && api.settings.get('muc_subscribe_to_rai') && this.getOwnAffiliation() !== 'none') {
  317. await this.leave();
  318. this.enableRAI();
  319. } else {
  320. await this.onRoomEntered();
  321. }
  322. }
  323. },
  324. async onReconnection () {
  325. await this.rejoin();
  326. this.announceReconnection();
  327. },
  328. getMessagesCollection () {
  329. return new _converse.ChatRoomMessages();
  330. },
  331. restoreSession () {
  332. const id = `muc.session-${_converse.bare_jid}-${this.get('jid')}`;
  333. this.session = new MUCSession({ id });
  334. initStorage(this.session, id, 'session');
  335. return new Promise(r => this.session.fetch({ 'success': r, 'error': r }));
  336. },
  337. initDiscoModels () {
  338. let id = `converse.muc-features-${_converse.bare_jid}-${this.get('jid')}`;
  339. this.features = new Model(
  340. Object.assign(
  341. { id },
  342. zipObject(
  343. converse.ROOM_FEATURES,
  344. converse.ROOM_FEATURES.map(() => false)
  345. )
  346. )
  347. );
  348. this.features.browserStorage = _converse.createStore(id, 'session');
  349. this.features.listenTo(_converse, 'beforeLogout', () => this.features.browserStorage.flush());
  350. id = `converse.muc-config-{_converse.bare_jid}-${this.get('jid')}`;
  351. this.config = new Model();
  352. this.config.browserStorage = _converse.createStore(id, 'session');
  353. this.config.listenTo(_converse, 'beforeLogout', () => this.config.browserStorage.flush());
  354. },
  355. initOccupants () {
  356. this.occupants = new _converse.ChatRoomOccupants();
  357. const id = `converse.occupants-${_converse.bare_jid}${this.get('jid')}`;
  358. this.occupants.browserStorage = _converse.createStore(id, 'session');
  359. this.occupants.chatroom = this;
  360. this.occupants.listenTo(_converse, 'beforeLogout', () => this.occupants.browserStorage.flush());
  361. },
  362. fetchOccupants () {
  363. this.occupants.fetched = new Promise(resolve => {
  364. this.occupants.fetch({
  365. 'add': true,
  366. 'silent': true,
  367. 'success': resolve,
  368. 'error': resolve
  369. });
  370. });
  371. return this.occupants.fetched;
  372. },
  373. handleAffiliationChangedMessage (stanza) {
  374. const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
  375. if (item) {
  376. const from = stanza.getAttribute('from');
  377. const type = stanza.getAttribute('type');
  378. const affiliation = item.getAttribute('affiliation');
  379. const jid = item.getAttribute('jid');
  380. const data = {
  381. from,
  382. type,
  383. affiliation,
  384. 'states': [],
  385. 'show': type == 'unavailable' ? 'offline' : 'online',
  386. 'role': item.getAttribute('role'),
  387. 'jid': Strophe.getBareJidFromJid(jid),
  388. 'resource': Strophe.getResourceFromJid(jid)
  389. };
  390. const occupant = this.occupants.findOccupant({ 'jid': data.jid });
  391. if (occupant) {
  392. occupant.save(data);
  393. } else {
  394. this.occupants.create(data);
  395. }
  396. }
  397. },
  398. async handleErrorMessageStanza (stanza) {
  399. const { __ } = _converse;
  400. const attrs = await parseMUCMessage(stanza, this, _converse);
  401. if (!(await this.shouldShowErrorMessage(attrs))) {
  402. return;
  403. }
  404. const message = this.getMessageReferencedByError(attrs);
  405. if (message) {
  406. const new_attrs = {
  407. 'error': attrs.error,
  408. 'error_condition': attrs.error_condition,
  409. 'error_text': attrs.error_text,
  410. 'error_type': attrs.error_type,
  411. 'editable': false
  412. };
  413. if (attrs.msgid === message.get('retraction_id')) {
  414. // The error message refers to a retraction
  415. new_attrs.retracted = undefined;
  416. new_attrs.retraction_id = undefined;
  417. new_attrs.retracted_id = undefined;
  418. if (!attrs.error) {
  419. if (attrs.error_condition === 'forbidden') {
  420. new_attrs.error = __("You're not allowed to retract your message.");
  421. } else if (attrs.error_condition === 'not-acceptable') {
  422. new_attrs.error = __(
  423. "Your retraction was not delivered because you're not present in the groupchat."
  424. );
  425. } else {
  426. new_attrs.error = __('Sorry, an error occurred while trying to retract your message.');
  427. }
  428. }
  429. } else if (!attrs.error) {
  430. if (attrs.error_condition === 'forbidden') {
  431. new_attrs.error = __("Your message was not delivered because you weren't allowed to send it.");
  432. } else if (attrs.error_condition === 'not-acceptable') {
  433. new_attrs.error = __("Your message was not delivered because you're not present in the groupchat.");
  434. } else {
  435. new_attrs.error = __('Sorry, an error occurred while trying to send your message.');
  436. }
  437. }
  438. message.save(new_attrs);
  439. } else {
  440. this.createMessage(attrs);
  441. }
  442. },
  443. /**
  444. * Handles incoming message stanzas from the service that hosts this MUC
  445. * @private
  446. * @method _converse.ChatRoom#handleMessageFromMUCHost
  447. * @param { XMLElement } stanza
  448. */
  449. handleMessageFromMUCHost (stanza) {
  450. const conn_status = this.session.get('connection_status');
  451. if (conn_status === converse.ROOMSTATUS.ENTERED) {
  452. // We're not interested in activity indicators when already joined to the room
  453. return;
  454. }
  455. const rai = sizzle(`rai[xmlns="${Strophe.NS.RAI}"]`, stanza).pop();
  456. const active_mucs = Array.from(rai?.querySelectorAll('activity') || []).map(m => m.textContent);
  457. if (active_mucs.includes(this.get('jid'))) {
  458. this.save({
  459. 'has_activity': true,
  460. 'num_unread_general': 0 // Either/or between activity and unreads
  461. });
  462. }
  463. },
  464. /**
  465. * Handles XEP-0452 MUC Mention Notification messages
  466. * @private
  467. * @method _converse.ChatRoom#handleForwardedMentions
  468. * @param { XMLElement } stanza
  469. */
  470. handleForwardedMentions (stanza) {
  471. if (this.session.get('connection_status') === converse.ROOMSTATUS.ENTERED) {
  472. // Avoid counting mentions twice
  473. return;
  474. }
  475. const msgs = sizzle(
  476. `mentions[xmlns="${Strophe.NS.MENTIONS}"] forwarded[xmlns="${Strophe.NS.FORWARD}"] message[type="groupchat"]`,
  477. stanza
  478. );
  479. const muc_jid = this.get('jid');
  480. const mentions = msgs.filter(m => Strophe.getBareJidFromJid(m.getAttribute('from')) === muc_jid);
  481. if (mentions.length) {
  482. this.save({
  483. 'has_activity': true,
  484. 'num_unread': this.get('num_unread') + mentions.length
  485. });
  486. mentions.forEach(async stanza => {
  487. const attrs = await parseMUCMessage(stanza, this, _converse);
  488. const data = { stanza, attrs, 'chatbox': this };
  489. api.trigger('message', data);
  490. });
  491. }
  492. },
  493. /**
  494. * Parses an incoming message stanza and queues it for processing.
  495. * @private
  496. * @method _converse.ChatRoom#handleMessageStanza
  497. * @param { XMLElement } stanza
  498. */
  499. async handleMessageStanza (stanza) {
  500. const type = stanza.getAttribute('type');
  501. if (type === 'error') {
  502. return this.handleErrorMessageStanza(stanza);
  503. }
  504. if (type === 'groupchat') {
  505. if (isArchived(stanza)) {
  506. // MAM messages are handled in converse-mam.
  507. // We shouldn't get MAM messages here because
  508. // they shouldn't have a `type` attribute.
  509. return log.warn(`Received a MAM message with type "groupchat"`);
  510. }
  511. this.createInfoMessages(stanza);
  512. this.fetchFeaturesIfConfigurationChanged(stanza);
  513. } else if (!type) {
  514. return this.handleForwardedMentions(stanza);
  515. }
  516. /**
  517. * @typedef { Object } MUCMessageData
  518. * An object containing the parsed { @link MUCMessageAttributes } and
  519. * current { @link ChatRoom }.
  520. * @property { MUCMessageAttributes } attrs
  521. * @property { ChatRoom } chatbox
  522. */
  523. let attrs;
  524. try {
  525. attrs = await parseMUCMessage(stanza, this, _converse);
  526. } catch (e) {
  527. return log.error(e.message);
  528. }
  529. const data = { stanza, attrs, 'chatbox': this };
  530. /**
  531. * Triggered when a groupchat message stanza has been received and parsed.
  532. * @event _converse#message
  533. * @type { object }
  534. * @property { module:converse-muc~MUCMessageData } data
  535. */
  536. api.trigger('message', data);
  537. return attrs && this.queueMessage(attrs);
  538. },
  539. /**
  540. * Register presence and message handlers relevant to this groupchat
  541. * @private
  542. * @method _converse.ChatRoom#registerHandlers
  543. */
  544. registerHandlers () {
  545. const muc_jid = this.get('jid');
  546. const muc_domain = Strophe.getDomainFromJid(muc_jid);
  547. this.removeHandlers();
  548. this.presence_handler = _converse.connection.addHandler(
  549. stanza => this.onPresence(stanza) || true,
  550. null,
  551. 'presence',
  552. null,
  553. null,
  554. muc_jid,
  555. { 'ignoreNamespaceFragment': true, 'matchBareFromJid': true }
  556. );
  557. this.domain_presence_handler = _converse.connection.addHandler(
  558. stanza => this.onPresenceFromMUCHost(stanza) || true,
  559. null,
  560. 'presence',
  561. null,
  562. null,
  563. muc_domain
  564. );
  565. this.message_handler = _converse.connection.addHandler(
  566. stanza => !!this.handleMessageStanza(stanza) || true,
  567. null,
  568. 'message',
  569. null,
  570. null,
  571. muc_jid,
  572. { 'matchBareFromJid': true }
  573. );
  574. this.domain_message_handler = _converse.connection.addHandler(
  575. stanza => this.handleMessageFromMUCHost(stanza) || true,
  576. null,
  577. 'message',
  578. null,
  579. null,
  580. muc_domain
  581. );
  582. this.affiliation_message_handler = _converse.connection.addHandler(
  583. stanza => this.handleAffiliationChangedMessage(stanza) || true,
  584. Strophe.NS.MUC_USER,
  585. 'message',
  586. null,
  587. null,
  588. muc_jid
  589. );
  590. },
  591. removeHandlers () {
  592. // Remove the presence and message handlers that were
  593. // registered for this groupchat.
  594. if (this.message_handler) {
  595. _converse.connection && _converse.connection.deleteHandler(this.message_handler);
  596. delete this.message_handler;
  597. }
  598. if (this.domain_message_handler) {
  599. _converse.connection && _converse.connection.deleteHandler(this.domain_message_handler);
  600. delete this.domain_message_handler;
  601. }
  602. if (this.presence_handler) {
  603. _converse.connection && _converse.connection.deleteHandler(this.presence_handler);
  604. delete this.presence_handler;
  605. }
  606. if (this.domain_presence_handler) {
  607. _converse.connection && _converse.connection.deleteHandler(this.domain_presence_handler);
  608. delete this.domain_presence_handler;
  609. }
  610. if (this.affiliation_message_handler) {
  611. _converse.connection && _converse.connection.deleteHandler(this.affiliation_message_handler);
  612. delete this.affiliation_message_handler;
  613. }
  614. return this;
  615. },
  616. invitesAllowed () {
  617. return (
  618. api.settings.get('allow_muc_invitations') &&
  619. (this.features.get('open') || this.getOwnAffiliation() === 'owner')
  620. );
  621. },
  622. getDisplayName () {
  623. const name = this.get('name');
  624. if (name) {
  625. return name;
  626. } else if (api.settings.get('locked_muc_domain') === 'hidden') {
  627. return Strophe.getNodeFromJid(this.get('jid'));
  628. } else {
  629. return this.get('jid');
  630. }
  631. },
  632. /**
  633. * Sends a message stanza to the XMPP server and expects a reflection
  634. * or error message within a specific timeout period.
  635. * @private
  636. * @method _converse.ChatRoom#sendTimedMessage
  637. * @param { _converse.Message|XMLElement } message
  638. * @returns { Promise<XMLElement>|Promise<_converse.TimeoutError> } Returns a promise
  639. * which resolves with the reflected message stanza or with an error stanza or {@link _converse.TimeoutError}.
  640. */
  641. sendTimedMessage (el) {
  642. if (typeof el.tree === 'function') {
  643. el = el.tree();
  644. }
  645. let id = el.getAttribute('id');
  646. if (!id) {
  647. // inject id if not found
  648. id = this.getUniqueId('sendIQ');
  649. el.setAttribute('id', id);
  650. }
  651. const promise = getOpenPromise();
  652. const timeoutHandler = _converse.connection.addTimedHandler(_converse.STANZA_TIMEOUT, () => {
  653. _converse.connection.deleteHandler(handler);
  654. const err = new _converse.TimeoutError('Timeout Error: No response from server');
  655. promise.resolve(err);
  656. return false;
  657. });
  658. const handler = _converse.connection.addHandler(
  659. stanza => {
  660. timeoutHandler && _converse.connection.deleteTimedHandler(timeoutHandler);
  661. promise.resolve(stanza);
  662. }, null, 'message', ['error', 'groupchat'], id);
  663. api.send(el);
  664. return promise;
  665. },
  666. /**
  667. * Retract one of your messages in this groupchat
  668. * @private
  669. * @method _converse.ChatRoom#retractOwnMessage
  670. * @param { _converse.Message } message - The message which we're retracting.
  671. */
  672. async retractOwnMessage (message) {
  673. const __ = _converse.__;
  674. const origin_id = message.get('origin_id');
  675. if (!origin_id) {
  676. throw new Error("Can't retract message without a XEP-0359 Origin ID");
  677. }
  678. const editable = message.get('editable');
  679. const stanza = $msg({
  680. 'id': u.getUniqueId(),
  681. 'to': this.get('jid'),
  682. 'type': 'groupchat'
  683. })
  684. .c('store', { xmlns: Strophe.NS.HINTS })
  685. .up()
  686. .c('apply-to', {
  687. 'id': origin_id,
  688. 'xmlns': Strophe.NS.FASTEN
  689. })
  690. .c('retract', { xmlns: Strophe.NS.RETRACT });
  691. // Optimistic save
  692. message.set({
  693. 'retracted': new Date().toISOString(),
  694. 'retracted_id': origin_id,
  695. 'retraction_id': stanza.nodeTree.getAttribute('id'),
  696. 'editable': false
  697. });
  698. const result = await this.sendTimedMessage(stanza);
  699. if (u.isErrorStanza(result)) {
  700. log.error(result);
  701. } else if (result instanceof _converse.TimeoutError) {
  702. log.error(result);
  703. message.save({
  704. editable,
  705. 'error_type': 'timeout',
  706. 'error': __('A timeout happened while while trying to retract your message.'),
  707. 'retracted': undefined,
  708. 'retracted_id': undefined,
  709. 'retraction_id': undefined
  710. });
  711. }
  712. },
  713. /**
  714. * Retract someone else's message in this groupchat.
  715. * @private
  716. * @method _converse.ChatRoom#retractOtherMessage
  717. * @param { _converse.Message } message - The message which we're retracting.
  718. * @param { string } [reason] - The reason for retracting the message.
  719. * @example
  720. * const room = await api.rooms.get(jid);
  721. * const message = room.messages.findWhere({'body': 'Get rich quick!'});
  722. * room.retractOtherMessage(message, 'spam');
  723. */
  724. async retractOtherMessage (message, reason) {
  725. const editable = message.get('editable');
  726. // Optimistic save
  727. message.save({
  728. 'moderated': 'retracted',
  729. 'moderated_by': _converse.bare_jid,
  730. 'moderated_id': message.get('msgid'),
  731. 'moderation_reason': reason,
  732. 'editable': false
  733. });
  734. const result = await this.sendRetractionIQ(message, reason);
  735. if (result === null || u.isErrorStanza(result)) {
  736. // Undo the save if something went wrong
  737. message.save({
  738. editable,
  739. 'moderated': undefined,
  740. 'moderated_by': undefined,
  741. 'moderated_id': undefined,
  742. 'moderation_reason': undefined
  743. });
  744. }
  745. return result;
  746. },
  747. /**
  748. * Sends an IQ stanza to the XMPP server to retract a message in this groupchat.
  749. * @private
  750. * @method _converse.ChatRoom#sendRetractionIQ
  751. * @param { _converse.Message } message - The message which we're retracting.
  752. * @param { string } [reason] - The reason for retracting the message.
  753. */
  754. sendRetractionIQ (message, reason) {
  755. const iq = $iq({ 'to': this.get('jid'), 'type': 'set' })
  756. .c('apply-to', {
  757. 'id': message.get(`stanza_id ${this.get('jid')}`),
  758. 'xmlns': Strophe.NS.FASTEN
  759. })
  760. .c('moderate', { xmlns: Strophe.NS.MODERATE })
  761. .c('retract', { xmlns: Strophe.NS.RETRACT })
  762. .up()
  763. .c('reason')
  764. .t(reason || '');
  765. return api.sendIQ(iq, null, false);
  766. },
  767. /**
  768. * Sends an IQ stanza to the XMPP server to destroy this groupchat. Not
  769. * to be confused with the {@link _converse.ChatRoom#destroy}
  770. * method, which simply removes the room from the local browser storage cache.
  771. * @private
  772. * @method _converse.ChatRoom#sendDestroyIQ
  773. * @param { string } [reason] - The reason for destroying the groupchat.
  774. * @param { string } [new_jid] - The JID of the new groupchat which replaces this one.
  775. */
  776. sendDestroyIQ (reason, new_jid) {
  777. const destroy = $build('destroy');
  778. if (new_jid) {
  779. destroy.attrs({ 'jid': new_jid });
  780. }
  781. const iq = $iq({
  782. 'to': this.get('jid'),
  783. 'type': 'set'
  784. })
  785. .c('query', { 'xmlns': Strophe.NS.MUC_OWNER })
  786. .cnode(destroy.node);
  787. if (reason && reason.length > 0) {
  788. iq.c('reason', reason);
  789. }
  790. return api.sendIQ(iq);
  791. },
  792. /**
  793. * Leave the groupchat.
  794. * @private
  795. * @method _converse.ChatRoom#leave
  796. * @param { string } [exit_msg] - Message to indicate your reason for leaving
  797. */
  798. async leave (exit_msg) {
  799. this.features.destroy();
  800. const disco_entity = _converse.disco_entities?.get(this.get('jid'));
  801. if (disco_entity) {
  802. await new Promise((success, error) => disco_entity.destroy({ success, error }));
  803. }
  804. if (api.connection.connected()) {
  805. api.user.presence.send('unavailable', this.getRoomJIDAndNick(), exit_msg);
  806. }
  807. u.safeSave(this.session, { 'connection_status': converse.ROOMSTATUS.DISCONNECTED });
  808. },
  809. async close (ev) {
  810. await this.leave();
  811. if (
  812. api.settings.get('auto_register_muc_nickname') === 'unregister' &&
  813. (await api.disco.supports(Strophe.NS.MUC_REGISTER, this.get('jid')))
  814. ) {
  815. this.unregisterNickname();
  816. }
  817. this.occupants.clearStore();
  818. if (ev?.name !== 'closeAllChatBoxes' && api.settings.get('muc_clear_messages_on_leave')) {
  819. this.clearMessages();
  820. }
  821. // Delete the session model
  822. await new Promise(resolve =>
  823. this.session.destroy({
  824. 'success': resolve,
  825. 'error': (m, e) => {
  826. log.error(e);
  827. resolve();
  828. }
  829. })
  830. );
  831. // Delete the features model
  832. await new Promise(resolve =>
  833. this.features.destroy({
  834. 'success': resolve,
  835. 'error': (m, e) => {
  836. log.error(e);
  837. resolve();
  838. }
  839. })
  840. );
  841. return _converse.ChatBox.prototype.close.call(this);
  842. },
  843. canModerateMessages () {
  844. const self = this.getOwnOccupant();
  845. return self && self.isModerator() && api.disco.supports(Strophe.NS.MODERATE, this.get('jid'));
  846. },
  847. /**
  848. * Return an array of unique nicknames based on all occupants and messages in this MUC.
  849. * @private
  850. * @method _converse.ChatRoom#getAllKnownNicknames
  851. * @returns { String[] }
  852. */
  853. getAllKnownNicknames () {
  854. return [
  855. ...new Set([...this.occupants.map(o => o.get('nick')), ...this.messages.map(m => m.get('nick'))])
  856. ].filter(n => n);
  857. },
  858. getAllKnownNicknamesRegex () {
  859. const longNickString = this.getAllKnownNicknames()
  860. .map(n => p.escapeRegexString(n))
  861. .join('|');
  862. return RegExp(`(?:\\p{P}|\\p{Z}|^)@(${longNickString})(?![\\w@-])`, 'uig');
  863. },
  864. getOccupantByJID (jid) {
  865. return this.occupants.findOccupant({ jid });
  866. },
  867. getOccupantByNickname (nick) {
  868. return this.occupants.findOccupant({ nick });
  869. },
  870. /**
  871. * Given a text message, look for `@` mentions and turn them into
  872. * XEP-0372 references
  873. * @param { String } text
  874. */
  875. parseTextForReferences (text) {
  876. const mentions_regex = /(\p{P}|\p{Z}|^)([@][\w_-]+(?:\.\w+)*)/giu;
  877. if (!text || !mentions_regex.test(text)) {
  878. return [text, []];
  879. }
  880. const getMatchingNickname = p.findFirstMatchInArray(this.getAllKnownNicknames());
  881. const uriFromNickname = nickname => {
  882. const jid = this.get('jid');
  883. const occupant = this.getOccupant(nickname) || this.getOccupant(jid);
  884. const uri = (this.features.get('nonanonymous') && occupant?.get('jid')) || `${jid}/${nickname}`;
  885. return encodeURI(`xmpp:${uri}`);
  886. };
  887. const matchToReference = match => {
  888. let at_sign_index = match[0].indexOf('@');
  889. if (match[0][at_sign_index + 1] === '@') {
  890. // edge-case
  891. at_sign_index += 1;
  892. }
  893. const begin = match.index + at_sign_index;
  894. const end = begin + match[0].length - at_sign_index;
  895. const value = getMatchingNickname(match[1]);
  896. const type = 'mention';
  897. const uri = uriFromNickname(value);
  898. return { begin, end, value, type, uri };
  899. };
  900. const regex = this.getAllKnownNicknamesRegex();
  901. const mentions = [...text.matchAll(regex)].filter(m => !m[0].startsWith('/'));
  902. const references = mentions.map(matchToReference);
  903. const [updated_message, updated_references] = p.reduceTextFromReferences(text, references);
  904. return [updated_message, updated_references];
  905. },
  906. getOutgoingMessageAttributes (attrs) {
  907. const is_spoiler = this.get('composing_spoiler');
  908. let text = '', references;
  909. if (attrs?.body) {
  910. [text, references] = this.parseTextForReferences(attrs.body);
  911. }
  912. const origin_id = u.getUniqueId();
  913. const body = text ? u.httpToGeoUri(u.shortnamesToUnicode(text), _converse) : undefined;
  914. return Object.assign({}, attrs, {
  915. body,
  916. is_spoiler,
  917. origin_id,
  918. references,
  919. 'id': origin_id,
  920. 'msgid': origin_id,
  921. 'from': `${this.get('jid')}/${this.get('nick')}`,
  922. 'fullname': this.get('nick'),
  923. 'is_only_emojis': text ? u.isOnlyEmojis(text) : false,
  924. 'message': body,
  925. 'nick': this.get('nick'),
  926. 'sender': 'me',
  927. 'type': 'groupchat'
  928. }, getMediaURLs(text));
  929. },
  930. /**
  931. * Utility method to construct the JID for the current user as occupant of the groupchat.
  932. * @private
  933. * @method _converse.ChatRoom#getRoomJIDAndNick
  934. * @returns {string} - The groupchat JID with the user's nickname added at the end.
  935. * @example groupchat@conference.example.org/nickname
  936. */
  937. getRoomJIDAndNick () {
  938. const nick = this.get('nick');
  939. const jid = Strophe.getBareJidFromJid(this.get('jid'));
  940. return jid + (nick !== null ? `/${nick}` : '');
  941. },
  942. /**
  943. * Sends a message with the current XEP-0085 chat state of the user
  944. * as taken from the `chat_state` attribute of the {@link _converse.ChatRoom}.
  945. * @private
  946. * @method _converse.ChatRoom#sendChatState
  947. */
  948. sendChatState () {
  949. if (
  950. !api.settings.get('send_chat_state_notifications') ||
  951. !this.get('chat_state') ||
  952. this.session.get('connection_status') !== converse.ROOMSTATUS.ENTERED ||
  953. (this.features.get('moderated') && this.getOwnRole() === 'visitor')
  954. ) {
  955. return;
  956. }
  957. const allowed = api.settings.get('send_chat_state_notifications');
  958. if (Array.isArray(allowed) && !allowed.includes(this.get('chat_state'))) {
  959. return;
  960. }
  961. const chat_state = this.get('chat_state');
  962. if (chat_state === _converse.GONE) {
  963. // <gone/> is not applicable within MUC context
  964. return;
  965. }
  966. api.send(
  967. $msg({ 'to': this.get('jid'), 'type': 'groupchat' })
  968. .c(chat_state, { 'xmlns': Strophe.NS.CHATSTATES })
  969. .up()
  970. .c('no-store', { 'xmlns': Strophe.NS.HINTS })
  971. .up()
  972. .c('no-permanent-store', { 'xmlns': Strophe.NS.HINTS })
  973. );
  974. },
  975. /**
  976. * Send a direct invitation as per XEP-0249
  977. * @private
  978. * @method _converse.ChatRoom#directInvite
  979. * @param { String } recipient - JID of the person being invited
  980. * @param { String } [reason] - Reason for the invitation
  981. */
  982. directInvite (recipient, reason) {
  983. if (this.features.get('membersonly')) {
  984. // When inviting to a members-only groupchat, we first add
  985. // the person to the member list by giving them an
  986. // affiliation of 'member' otherwise they won't be able to join.
  987. this.updateMemberLists([{ 'jid': recipient, 'affiliation': 'member', 'reason': reason }]);
  988. }
  989. const attrs = {
  990. 'xmlns': 'jabber:x:conference',
  991. 'jid': this.get('jid')
  992. };
  993. if (reason !== null) {
  994. attrs.reason = reason;
  995. }
  996. if (this.get('password')) {
  997. attrs.password = this.get('password');
  998. }
  999. const invitation = $msg({
  1000. 'from': _converse.connection.jid,
  1001. 'to': recipient,
  1002. 'id': u.getUniqueId()
  1003. }).c('x', attrs);
  1004. api.send(invitation);
  1005. /**
  1006. * After the user has sent out a direct invitation (as per XEP-0249),
  1007. * to a roster contact, asking them to join a room.
  1008. * @event _converse#chatBoxMaximized
  1009. * @type {object}
  1010. * @property {_converse.ChatRoom} room
  1011. * @property {string} recipient - The JID of the person being invited
  1012. * @property {string} reason - The original reason for the invitation
  1013. * @example _converse.api.listen.on('chatBoxMaximized', view => { ... });
  1014. */
  1015. api.trigger('roomInviteSent', {
  1016. 'room': this,
  1017. 'recipient': recipient,
  1018. 'reason': reason
  1019. });
  1020. },
  1021. /**
  1022. * Refresh the disco identity, features and fields for this {@link _converse.ChatRoom}.
  1023. * *features* are stored on the features {@link Model} attribute on this {@link _converse.ChatRoom}.
  1024. * *fields* are stored on the config {@link Model} attribute on this {@link _converse.ChatRoom}.
  1025. * @private
  1026. * @returns {Promise}
  1027. */
  1028. refreshDiscoInfo () {
  1029. return api.disco
  1030. .refresh(this.get('jid'))
  1031. .then(() => this.getDiscoInfo())
  1032. .catch(e => log.error(e));
  1033. },
  1034. /**
  1035. * Fetch the *extended* MUC info from the server and cache it locally
  1036. * https://xmpp.org/extensions/xep-0045.html#disco-roominfo
  1037. * @private
  1038. * @method _converse.ChatRoom#getDiscoInfo
  1039. * @returns {Promise}
  1040. */
  1041. getDiscoInfo () {
  1042. return api.disco
  1043. .getIdentity('conference', 'text', this.get('jid'))
  1044. .then(identity => this.save({ 'name': identity?.get('name') }))
  1045. .then(() => this.getDiscoInfoFields())
  1046. .then(() => this.getDiscoInfoFeatures())
  1047. .catch(e => log.error(e));
  1048. },
  1049. /**
  1050. * Fetch the *extended* MUC info fields from the server and store them locally
  1051. * in the `config` {@link Model} attribute.
  1052. * See: https://xmpp.org/extensions/xep-0045.html#disco-roominfo
  1053. * @private
  1054. * @method _converse.ChatRoom#getDiscoInfoFields
  1055. * @returns {Promise}
  1056. */
  1057. async getDiscoInfoFields () {
  1058. const fields = await api.disco.getFields(this.get('jid'));
  1059. const config = fields.reduce((config, f) => {
  1060. const name = f.get('var');
  1061. if (name && name.startsWith('muc#roominfo_')) {
  1062. config[name.replace('muc#roominfo_', '')] = f.get('value');
  1063. }
  1064. return config;
  1065. }, {});
  1066. this.config.save(config);
  1067. },
  1068. /**
  1069. * Use converse-disco to populate the features {@link Model} which
  1070. * is stored as an attibute on this {@link _converse.ChatRoom}.
  1071. * The results may be cached. If you want to force fetching the features from the
  1072. * server, call {@link _converse.ChatRoom#refreshDiscoInfo} instead.
  1073. * @private
  1074. * @returns {Promise}
  1075. */
  1076. async getDiscoInfoFeatures () {
  1077. const features = await api.disco.getFeatures(this.get('jid'));
  1078. const attrs = Object.assign(
  1079. zipObject(
  1080. converse.ROOM_FEATURES,
  1081. converse.ROOM_FEATURES.map(() => false)
  1082. ),
  1083. { 'fetched': new Date().toISOString() }
  1084. );
  1085. features.each(feature => {
  1086. const fieldname = feature.get('var');
  1087. if (!fieldname.startsWith('muc_')) {
  1088. if (fieldname === Strophe.NS.MAM) {
  1089. attrs.mam_enabled = true;
  1090. }
  1091. return;
  1092. }
  1093. attrs[fieldname.replace('muc_', '')] = true;
  1094. });
  1095. this.features.save(attrs);
  1096. },
  1097. /**
  1098. * Given a <field> element, return a copy with a <value> child if
  1099. * we can find a value for it in this rooms config.
  1100. * @private
  1101. * @method _converse.ChatRoom#addFieldValue
  1102. * @returns { Element }
  1103. */
  1104. addFieldValue (field) {
  1105. const type = field.getAttribute('type');
  1106. if (type === 'fixed') {
  1107. return field;
  1108. }
  1109. const fieldname = field.getAttribute('var').replace('muc#roomconfig_', '');
  1110. const config = this.get('roomconfig');
  1111. if (fieldname in config) {
  1112. let values;
  1113. switch (type) {
  1114. case 'boolean':
  1115. values = [config[fieldname] ? 1 : 0];
  1116. break;
  1117. case 'list-multi':
  1118. values = config[fieldname];
  1119. break;
  1120. default:
  1121. values = [config[fieldname]];
  1122. }
  1123. field.innerHTML = values.map(v => $build('value').t(v)).join('');
  1124. }
  1125. return field;
  1126. },
  1127. /**
  1128. * Automatically configure the groupchat based on this model's
  1129. * 'roomconfig' data.
  1130. * @private
  1131. * @method _converse.ChatRoom#autoConfigureChatRoom
  1132. * @returns { Promise<XMLElement> }
  1133. * Returns a promise which resolves once a response IQ has
  1134. * been received.
  1135. */
  1136. async autoConfigureChatRoom () {
  1137. const stanza = await this.fetchRoomConfiguration();
  1138. const fields = sizzle('field', stanza);
  1139. const configArray = fields.map(f => this.addFieldValue(f));
  1140. if (configArray.length) {
  1141. return this.sendConfiguration(configArray);
  1142. }
  1143. },
  1144. /**
  1145. * Send an IQ stanza to fetch the groupchat configuration data.
  1146. * Returns a promise which resolves once the response IQ
  1147. * has been received.
  1148. * @private
  1149. * @method _converse.ChatRoom#fetchRoomConfiguration
  1150. * @returns { Promise<XMLElement> }
  1151. */
  1152. fetchRoomConfiguration () {
  1153. return api.sendIQ($iq({ 'to': this.get('jid'), 'type': 'get' }).c('query', { xmlns: Strophe.NS.MUC_OWNER }));
  1154. },
  1155. /**
  1156. * Sends an IQ stanza with the groupchat configuration.
  1157. * @private
  1158. * @method _converse.ChatRoom#sendConfiguration
  1159. * @param { Array } config - The groupchat configuration
  1160. * @returns { Promise<XMLElement> } - A promise which resolves with
  1161. * the `result` stanza received from the XMPP server.
  1162. */
  1163. sendConfiguration (config = []) {
  1164. const iq = $iq({ to: this.get('jid'), type: 'set' })
  1165. .c('query', { xmlns: Strophe.NS.MUC_OWNER })
  1166. .c('x', { xmlns: Strophe.NS.XFORM, type: 'submit' });
  1167. config.forEach(node => iq.cnode(node).up());
  1168. return api.sendIQ(iq);
  1169. },
  1170. onCommandError (err) {
  1171. const { __ } = _converse;
  1172. log.fatal(err);
  1173. const message =
  1174. __('Sorry, an error happened while running the command.') +
  1175. ' ' +
  1176. __("Check your browser's developer console for details.");
  1177. this.createMessage({ message, 'type': 'error' });
  1178. },
  1179. getNickOrJIDFromCommandArgs (args) {
  1180. const { __ } = _converse;
  1181. if (u.isValidJID(args.trim())) {
  1182. return args.trim();
  1183. }
  1184. if (!args.startsWith('@')) {
  1185. args = '@' + args;
  1186. }
  1187. const [text, references] = this.parseTextForReferences(args); // eslint-disable-line no-unused-vars
  1188. if (!references.length) {
  1189. const message = __("Error: couldn't find a groupchat participant based on your arguments");
  1190. this.createMessage({ message, 'type': 'error' });
  1191. return;
  1192. }
  1193. if (references.length > 1) {
  1194. const message = __('Error: found multiple groupchat participant based on your arguments');
  1195. this.createMessage({ message, 'type': 'error' });
  1196. return;
  1197. }
  1198. const nick_or_jid = references.pop().value;
  1199. const reason = args.split(nick_or_jid, 2)[1];
  1200. if (reason && !reason.startsWith(' ')) {
  1201. const message = __("Error: couldn't find a groupchat participant based on your arguments");
  1202. this.createMessage({ message, 'type': 'error' });
  1203. return;
  1204. }
  1205. return nick_or_jid;
  1206. },
  1207. validateRoleOrAffiliationChangeArgs (command, args) {
  1208. const { __ } = _converse;
  1209. if (!args) {
  1210. const message = __(
  1211. 'Error: the "%1$s" command takes two arguments, the user\'s nickname and optionally a reason.',
  1212. command
  1213. );
  1214. this.createMessage({ message, 'type': 'error' });
  1215. return false;
  1216. }
  1217. return true;
  1218. },
  1219. getAllowedCommands () {
  1220. let allowed_commands = ['clear', 'help', 'me', 'nick', 'register'];
  1221. if (this.config.get('changesubject') || ['owner', 'admin'].includes(this.getOwnAffiliation())) {
  1222. allowed_commands = [...allowed_commands, ...['subject', 'topic']];
  1223. }
  1224. const occupant = this.occupants.findWhere({ 'jid': _converse.bare_jid });
  1225. if (this.verifyAffiliations(['owner'], occupant, false)) {
  1226. allowed_commands = allowed_commands.concat(OWNER_COMMANDS).concat(ADMIN_COMMANDS);
  1227. } else if (this.verifyAffiliations(['admin'], occupant, false)) {
  1228. allowed_commands = allowed_commands.concat(ADMIN_COMMANDS);
  1229. }
  1230. if (this.verifyRoles(['moderator'], occupant, false)) {
  1231. allowed_commands = allowed_commands.concat(MODERATOR_COMMANDS).concat(VISITOR_COMMANDS);
  1232. } else if (!this.verifyRoles(['visitor', 'participant', 'moderator'], occupant, false)) {
  1233. allowed_commands = allowed_commands.concat(VISITOR_COMMANDS);
  1234. }
  1235. allowed_commands.sort();
  1236. if (Array.isArray(api.settings.get('muc_disable_slash_commands'))) {
  1237. return allowed_commands.filter(c => !api.settings.get('muc_disable_slash_commands').includes(c));
  1238. } else {
  1239. return allowed_commands;
  1240. }
  1241. },
  1242. verifyAffiliations (affiliations, occupant, show_error = true) {
  1243. const { __ } = _converse;
  1244. if (!Array.isArray(affiliations)) {
  1245. throw new TypeError('affiliations must be an Array');
  1246. }
  1247. if (!affiliations.length) {
  1248. return true;
  1249. }
  1250. occupant = occupant || this.occupants.findWhere({ 'jid': _converse.bare_jid });
  1251. if (occupant) {
  1252. const a = occupant.get('affiliation');
  1253. if (affiliations.includes(a)) {
  1254. return true;
  1255. }
  1256. }
  1257. if (show_error) {
  1258. const message = __('Forbidden: you do not have the necessary affiliation in order to do that.');
  1259. this.createMessage({ message, 'type': 'error' });
  1260. }
  1261. return false;
  1262. },
  1263. verifyRoles (roles, occupant, show_error = true) {
  1264. const { __ } = _converse;
  1265. if (!Array.isArray(roles)) {
  1266. throw new TypeError('roles must be an Array');
  1267. }
  1268. if (!roles.length) {
  1269. return true;
  1270. }
  1271. occupant = occupant || this.occupants.findWhere({ 'jid': _converse.bare_jid });
  1272. if (occupant) {
  1273. const role = occupant.get('role');
  1274. if (roles.includes(role)) {
  1275. return true;
  1276. }
  1277. }
  1278. if (show_error) {
  1279. const message = __('Forbidden: you do not have the necessary role in order to do that.');
  1280. this.createMessage({ message, 'type': 'error' });
  1281. }
  1282. return false;
  1283. },
  1284. /**
  1285. * Returns the `role` which the current user has in this MUC
  1286. * @private
  1287. * @method _converse.ChatRoom#getOwnRole
  1288. * @returns { ('none'|'visitor'|'participant'|'moderator') }
  1289. */
  1290. getOwnRole () {
  1291. return this.getOwnOccupant()?.attributes?.role;
  1292. },
  1293. /**
  1294. * Returns the `affiliation` which the current user has in this MUC
  1295. * @private
  1296. * @method _converse.ChatRoom#getOwnAffiliation
  1297. * @returns { ('none'|'outcast'|'member'|'admin'|'owner') }
  1298. */
  1299. getOwnAffiliation () {
  1300. return this.getOwnOccupant()?.attributes?.affiliation || 'none';
  1301. },
  1302. /**
  1303. * Get the {@link _converse.ChatRoomOccupant} instance which
  1304. * represents the current user.
  1305. * @private
  1306. * @method _converse.ChatRoom#getOwnOccupant
  1307. * @returns { _converse.ChatRoomOccupant }
  1308. */
  1309. getOwnOccupant () {
  1310. return this.occupants.findWhere({ 'jid': _converse.bare_jid });
  1311. },
  1312. /**
  1313. * Send an IQ stanza to modify an occupant's role
  1314. * @private
  1315. * @method _converse.ChatRoom#setRole
  1316. * @param { _converse.ChatRoomOccupant } occupant
  1317. * @param { String } role
  1318. * @param { String } reason
  1319. * @param { function } onSuccess - callback for a succesful response
  1320. * @param { function } onError - callback for an error response
  1321. */
  1322. setRole (occupant, role, reason, onSuccess, onError) {
  1323. const item = $build('item', {
  1324. 'nick': occupant.get('nick'),
  1325. role
  1326. });
  1327. const iq = $iq({
  1328. 'to': this.get('jid'),
  1329. 'type': 'set'
  1330. })
  1331. .c('query', { xmlns: Strophe.NS.MUC_ADMIN })
  1332. .cnode(item.node);
  1333. if (reason !== null) {
  1334. iq.c('reason', reason);
  1335. }
  1336. return api
  1337. .sendIQ(iq)
  1338. .then(onSuccess)
  1339. .catch(onError);
  1340. },
  1341. /**
  1342. * @private
  1343. * @method _converse.ChatRoom#getOccupant
  1344. * @param { String } nickname_or_jid - The nickname or JID of the occupant to be returned
  1345. * @returns { _converse.ChatRoomOccupant }
  1346. */
  1347. getOccupant (nickname_or_jid) {
  1348. return u.isValidJID(nickname_or_jid)
  1349. ? this.getOccupantByJID(nickname_or_jid)
  1350. : this.getOccupantByNickname(nickname_or_jid);
  1351. },
  1352. /**
  1353. * Return an array of occupant models that have the required role
  1354. * @private
  1355. * @method _converse.ChatRoom#getOccupantsWithRole
  1356. * @param { String } role
  1357. * @returns { _converse.ChatRoomOccupant[] }
  1358. */
  1359. getOccupantsWithRole (role) {
  1360. return this.getOccupantsSortedBy('nick')
  1361. .filter(o => o.get('role') === role)
  1362. .map(item => {
  1363. return {
  1364. 'jid': item.get('jid'),
  1365. 'nick': item.get('nick'),
  1366. 'role': item.get('role')
  1367. };
  1368. });
  1369. },
  1370. /**
  1371. * Return an array of occupant models that have the required affiliation
  1372. * @private
  1373. * @method _converse.ChatRoom#getOccupantsWithAffiliation
  1374. * @param { String } affiliation
  1375. * @returns { _converse.ChatRoomOccupant[] }
  1376. */
  1377. getOccupantsWithAffiliation (affiliation) {
  1378. return this.getOccupantsSortedBy('nick')
  1379. .filter(o => o.get('affiliation') === affiliation)
  1380. .map(item => {
  1381. return {
  1382. 'jid': item.get('jid'),
  1383. 'nick': item.get('nick'),
  1384. 'affiliation': item.get('affiliation')
  1385. };
  1386. });
  1387. },
  1388. /**
  1389. * Return an array of occupant models, sorted according to the passed-in attribute.
  1390. * @private
  1391. * @method _converse.ChatRoom#getOccupantsSortedBy
  1392. * @param { String } attr - The attribute to sort the returned array by
  1393. * @returns { _converse.ChatRoomOccupant[] }
  1394. */
  1395. getOccupantsSortedBy (attr) {
  1396. return Array.from(this.occupants.models).sort((a, b) =>
  1397. a.get(attr) < b.get(attr) ? -1 : a.get(attr) > b.get(attr) ? 1 : 0
  1398. );
  1399. },
  1400. /**
  1401. * Fetch the lists of users with the given affiliations.
  1402. * Then compute the delta between those users and
  1403. * the passed in members, and if it exists, send the delta
  1404. * to the XMPP server to update the member list.
  1405. * @private
  1406. * @method _converse.ChatRoom#updateMemberLists
  1407. * @param { object } members - Map of member jids and affiliations.
  1408. * @returns { Promise }
  1409. * A promise which is resolved once the list has been
  1410. * updated or once it's been established there's no need
  1411. * to update the list.
  1412. */
  1413. async updateMemberLists (members) {
  1414. const muc_jid = this.get('jid');
  1415. const all_affiliations = ['member', 'admin', 'owner'];
  1416. const aff_lists = await Promise.all(all_affiliations.map(a => getAffiliationList(a, muc_jid)));
  1417. const old_members = aff_lists.reduce((acc, val) => (u.isErrorObject(val) ? acc : [...val, ...acc]), []);
  1418. await setAffiliations(muc_jid, computeAffiliationsDelta(true, false, members, old_members));
  1419. await this.occupants.fetchMembers();
  1420. },
  1421. /**
  1422. * Given a nick name, save it to the model state, otherwise, look
  1423. * for a server-side reserved nickname or default configured
  1424. * nickname and if found, persist that to the model state.
  1425. * @private
  1426. * @method _converse.ChatRoom#getAndPersistNickname
  1427. * @returns { Promise<string> } A promise which resolves with the nickname
  1428. */
  1429. async getAndPersistNickname (nick) {
  1430. nick = nick || this.get('nick') || (await this.getReservedNick()) || _converse.getDefaultMUCNickname();
  1431. if (nick) {
  1432. this.save({ nick }, { 'silent': true });
  1433. }
  1434. return nick;
  1435. },
  1436. /**
  1437. * Use service-discovery to ask the XMPP server whether
  1438. * this user has a reserved nickname for this groupchat.
  1439. * If so, we'll use that, otherwise we render the nickname form.
  1440. * @private
  1441. * @method _converse.ChatRoom#getReservedNick
  1442. * @returns { Promise<string> } A promise which resolves with the reserved nick or null
  1443. */
  1444. async getReservedNick () {
  1445. const stanza = $iq({
  1446. 'to': this.get('jid'),
  1447. 'from': _converse.connection.jid,
  1448. 'type': 'get'
  1449. }).c('query', {
  1450. 'xmlns': Strophe.NS.DISCO_INFO,
  1451. 'node': 'x-roomuser-item'
  1452. });
  1453. const result = await api.sendIQ(stanza, null, false);
  1454. if (u.isErrorObject(result)) {
  1455. throw result;
  1456. }
  1457. // Result might be undefined due to a timeout
  1458. const identity_el = result?.querySelector('query[node="x-roomuser-item"] identity');
  1459. return identity_el ? identity_el.getAttribute('name') : null;
  1460. },
  1461. /**
  1462. * Send an IQ stanza to the MUC to register this user's nickname.
  1463. * This sets the user's affiliation to 'member' (if they weren't affiliated
  1464. * before) and reserves the nickname for this user, thereby preventing other
  1465. * users from using it in this MUC.
  1466. * See https://xmpp.org/extensions/xep-0045.html#register
  1467. * @private
  1468. * @method _converse.ChatRoom#registerNickname
  1469. */
  1470. async registerNickname () {
  1471. const { __ } = _converse;
  1472. const nick = this.get('nick');
  1473. const jid = this.get('jid');
  1474. let iq, err_msg;
  1475. try {
  1476. iq = await api.sendIQ(
  1477. $iq({
  1478. 'to': jid,
  1479. 'type': 'get'
  1480. }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
  1481. );
  1482. } catch (e) {
  1483. if (sizzle(`not-allowed[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
  1484. err_msg = __("You're not allowed to register yourself in this groupchat.");
  1485. } else if (sizzle(`registration-required[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
  1486. err_msg = __("You're not allowed to register in this groupchat because it's members-only.");
  1487. }
  1488. log.error(e);
  1489. return err_msg;
  1490. }
  1491. const required_fields = sizzle('field required', iq).map(f => f.parentElement);
  1492. if (required_fields.length > 1 && required_fields[0].getAttribute('var') !== 'muc#register_roomnick') {
  1493. return log.error(`Can't register the user register in the groupchat ${jid} due to the required fields`);
  1494. }
  1495. try {
  1496. await api.sendIQ(
  1497. $iq({
  1498. 'to': jid,
  1499. 'type': 'set'
  1500. }).c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
  1501. .c('x', { 'xmlns': Strophe.NS.XFORM, 'type': 'submit' })
  1502. .c('field', { 'var': 'FORM_TYPE' })
  1503. .c('value').t('http://jabber.org/protocol/muc#register').up().up()
  1504. .c('field', { 'var': 'muc#register_roomnick' })
  1505. .c('value').t(nick)
  1506. );
  1507. } catch (e) {
  1508. if (sizzle(`service-unavailable[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
  1509. err_msg = __("Can't register your nickname in this groupchat, it doesn't support registration.");
  1510. } else if (sizzle(`bad-request[xmlns="${Strophe.NS.STANZAS}"]`, e).length) {
  1511. err_msg = __("Can't register your nickname in this groupchat, invalid data form supplied.");
  1512. }
  1513. log.error(err_msg);
  1514. log.error(e);
  1515. return err_msg;
  1516. }
  1517. },
  1518. /**
  1519. * Send an IQ stanza to the MUC to unregister this user's nickname.
  1520. * If the user had a 'member' affiliation, it'll be removed and their
  1521. * nickname will no longer be reserved and can instead be used (and
  1522. * registered) by other users.
  1523. * @private
  1524. * @method _converse.ChatRoom#unregisterNickname
  1525. */
  1526. unregisterNickname () {
  1527. const iq = $iq({ 'to': this.get('jid'), 'type': 'set' })
  1528. .c('query', { 'xmlns': Strophe.NS.MUC_REGISTER })
  1529. .c('remove');
  1530. return api.sendIQ(iq).catch(e => log.error(e));
  1531. },
  1532. /**
  1533. * Given a presence stanza, update the occupant model based on its contents.
  1534. * @private
  1535. * @method _converse.ChatRoom#updateOccupantsOnPresence
  1536. * @param { XMLElement } pres - The presence stanza
  1537. */
  1538. updateOccupantsOnPresence (pres) {
  1539. const data = parseMUCPresence(pres);
  1540. if (data.type === 'error' || (!data.jid && !data.nick)) {
  1541. return true;
  1542. }
  1543. const occupant = this.occupants.findOccupant(data);
  1544. // Destroy an unavailable occupant if this isn't a nick change operation and if they're not affiliated
  1545. if (
  1546. data.type === 'unavailable' &&
  1547. occupant &&
  1548. !data.states.includes(converse.MUC_NICK_CHANGED_CODE) &&
  1549. !['admin', 'owner', 'member'].includes(data['affiliation'])
  1550. ) {
  1551. // Before destroying we set the new data, so that we can show the disconnection message
  1552. occupant.set(data);
  1553. occupant.destroy();
  1554. return;
  1555. }
  1556. const jid = data.jid || '';
  1557. const attributes = Object.assign(data, {
  1558. 'jid': Strophe.getBareJidFromJid(jid) || occupant?.attributes?.jid,
  1559. 'resource': Strophe.getResourceFromJid(jid) || occupant?.attributes?.resource
  1560. });
  1561. if (occupant) {
  1562. occupant.save(attributes);
  1563. } else {
  1564. this.occupants.create(attributes);
  1565. }
  1566. },
  1567. fetchFeaturesIfConfigurationChanged (stanza) {
  1568. // 104: configuration change
  1569. // 170: logging enabled
  1570. // 171: logging disabled
  1571. // 172: room no longer anonymous
  1572. // 173: room now semi-anonymous
  1573. // 174: room now fully anonymous
  1574. const codes = ['104', '170', '171', '172', '173', '174'];
  1575. if (sizzle('status', stanza).filter(e => codes.includes(e.getAttribute('status'))).length) {
  1576. this.refreshDiscoInfo();
  1577. }
  1578. },
  1579. /**
  1580. * Given two JIDs, which can be either user JIDs or MUC occupant JIDs,
  1581. * determine whether they belong to the same user.
  1582. * @private
  1583. * @method _converse.ChatRoom#isSameUser
  1584. * @param { String } jid1
  1585. * @param { String } jid2
  1586. * @returns { Boolean }
  1587. */
  1588. isSameUser (jid1, jid2) {
  1589. const bare_jid1 = Strophe.getBareJidFromJid(jid1);
  1590. const bare_jid2 = Strophe.getBareJidFromJid(jid2);
  1591. const resource1 = Strophe.getResourceFromJid(jid1);
  1592. const resource2 = Strophe.getResourceFromJid(jid2);
  1593. if (u.isSameBareJID(jid1, jid2)) {
  1594. if (bare_jid1 === this.get('jid')) {
  1595. // MUC JIDs
  1596. return resource1 === resource2;
  1597. } else {
  1598. return true;
  1599. }
  1600. } else {
  1601. const occupant1 =
  1602. bare_jid1 === this.get('jid')
  1603. ? this.occupants.findOccupant({ 'nick': resource1 })
  1604. : this.occupants.findOccupant({ 'jid': bare_jid1 });
  1605. const occupant2 =
  1606. bare_jid2 === this.get('jid')
  1607. ? this.occupants.findOccupant({ 'nick': resource2 })
  1608. : this.occupants.findOccupant({ 'jid': bare_jid2 });
  1609. return occupant1 === occupant2;
  1610. }
  1611. },
  1612. async isSubjectHidden () {
  1613. const jids = await api.user.settings.get('mucs_with_hidden_subject', []);
  1614. return jids.includes(this.get('jid'));
  1615. },
  1616. async toggleSubjectHiddenState () {
  1617. const muc_jid = this.get('jid');
  1618. const jids = await api.user.settings.get('mucs_with_hidden_subject', []);
  1619. if (jids.includes(this.get('jid'))) {
  1620. api.user.settings.set(
  1621. 'mucs_with_hidden_subject',
  1622. jids.filter(jid => jid !== muc_jid)
  1623. );
  1624. } else {
  1625. api.user.settings.set('mucs_with_hidden_subject', [...jids, muc_jid]);
  1626. }
  1627. },
  1628. /**
  1629. * Handle a possible subject change and return `true` if so.
  1630. * @private
  1631. * @method _converse.ChatRoom#handleSubjectChange
  1632. * @param { object } attrs - Attributes representing a received
  1633. * message, as returned by {@link parseMUCMessage}
  1634. */
  1635. async handleSubjectChange (attrs) {
  1636. const __ = _converse.__;
  1637. if (typeof attrs.subject === 'string' && !attrs.thread && !attrs.message) {
  1638. // https://xmpp.org/extensions/xep-0045.html#subject-mod
  1639. // -----------------------------------------------------
  1640. // The subject is changed by sending a message of type "groupchat" to the <room@service>,
  1641. // where the <message/> MUST contain a <subject/> element that specifies the new subject but
  1642. // MUST NOT contain a <body/> element (or a <thread/> element).
  1643. const subject = attrs.subject;
  1644. const author = attrs.nick;
  1645. u.safeSave(this, { 'subject': { author, 'text': attrs.subject || '' } });
  1646. if (!attrs.is_delayed && author) {
  1647. const message = subject ? __('Topic set by %1$s', author) : __('Topic cleared by %1$s', author);
  1648. const prev_msg = this.messages.last();
  1649. if (
  1650. prev_msg?.get('nick') !== attrs.nick ||
  1651. prev_msg?.get('type') !== 'info' ||
  1652. prev_msg?.get('message') !== message
  1653. ) {
  1654. this.createMessage({ message, 'nick': attrs.nick, 'type': 'info' });
  1655. }
  1656. if (await this.isSubjectHidden()) {
  1657. this.toggleSubjectHiddenState();
  1658. }
  1659. }
  1660. return true;
  1661. }
  1662. return false;
  1663. },
  1664. /**
  1665. * Set the subject for this {@link _converse.ChatRoom}
  1666. * @private
  1667. * @method _converse.ChatRoom#setSubject
  1668. * @param { String } value
  1669. */
  1670. setSubject (value = '') {
  1671. api.send(
  1672. $msg({
  1673. to: this.get('jid'),
  1674. from: _converse.connection.jid,
  1675. type: 'groupchat'
  1676. })
  1677. .c('subject', { xmlns: 'jabber:client' })
  1678. .t(value)
  1679. .tree()
  1680. );
  1681. },
  1682. /**
  1683. * Is this a chat state notification that can be ignored,
  1684. * because it's old or because it's from us.
  1685. * @private
  1686. * @method _converse.ChatRoom#ignorableCSN
  1687. * @param { Object } attrs - The message attributes
  1688. */
  1689. ignorableCSN (attrs) {
  1690. return attrs.chat_state && !attrs.body && (attrs.is_delayed || this.isOwnMessage(attrs));
  1691. },
  1692. /**
  1693. * Determines whether the message is from ourselves by checking
  1694. * the `from` attribute. Doesn't check the `type` attribute.
  1695. * @private
  1696. * @method _converse.ChatRoom#isOwnMessage
  1697. * @param { Object|XMLElement|_converse.Message } msg
  1698. * @returns { boolean }
  1699. */
  1700. isOwnMessage (msg) {
  1701. let from;
  1702. if (isElement(msg)) {
  1703. from = msg.getAttribute('from');
  1704. } else if (msg instanceof _converse.Message) {
  1705. from = msg.get('from');
  1706. } else {
  1707. from = msg.from;
  1708. }
  1709. return Strophe.getResourceFromJid(from) == this.get('nick');
  1710. },
  1711. getUpdatedMessageAttributes (message, attrs) {
  1712. const new_attrs = _converse.ChatBox.prototype.getUpdatedMessageAttributes.call(this, message, attrs);
  1713. if (this.isOwnMessage(attrs)) {
  1714. const stanza_id_keys = Object.keys(attrs).filter(k => k.startsWith('stanza_id'));
  1715. Object.assign(new_attrs, pick(attrs, stanza_id_keys));
  1716. if (!message.get('received')) {
  1717. new_attrs.received = new Date().toISOString();
  1718. }
  1719. }
  1720. return new_attrs;
  1721. },
  1722. /**
  1723. * Send a MUC-0410 MUC Self-Ping stanza to room to determine
  1724. * whether we're still joined.
  1725. * @async
  1726. * @private
  1727. * @method _converse.ChatRoom#isJoined
  1728. * @returns {Promise<boolean>}
  1729. */
  1730. async isJoined () {
  1731. const jid = this.get('jid');
  1732. const ping = $iq({
  1733. 'to': `${jid}/${this.get('nick')}`,
  1734. 'type': 'get'
  1735. }).c('ping', { 'xmlns': Strophe.NS.PING });
  1736. try {
  1737. await api.sendIQ(ping);
  1738. } catch (e) {
  1739. if (e === null) {
  1740. log.warn(`isJoined: Timeout error while checking whether we're joined to MUC: ${jid}`);
  1741. } else {
  1742. log.warn(`isJoined: Apparently we're no longer connected to MUC: ${jid}`);
  1743. }
  1744. return false;
  1745. }
  1746. return true;
  1747. },
  1748. /**
  1749. * Check whether we're still joined and re-join if not
  1750. * @async
  1751. * @private
  1752. * @method _converse.ChatRoom#rejoinIfNecessary
  1753. */
  1754. async rejoinIfNecessary () {
  1755. if (!(await this.isJoined())) {
  1756. this.rejoin();
  1757. return true;
  1758. }
  1759. },
  1760. /**
  1761. * @private
  1762. * @method _converse.ChatRoom#shouldShowErrorMessage
  1763. * @returns {Promise<boolean>}
  1764. */
  1765. async shouldShowErrorMessage (attrs) {
  1766. if (attrs['error_condition'] === 'not-acceptable' && (await this.rejoinIfNecessary())) {
  1767. return false;
  1768. }
  1769. return _converse.ChatBox.prototype.shouldShowErrorMessage.call(this, attrs);
  1770. },
  1771. /**
  1772. * Looks whether we already have a moderation message for this
  1773. * incoming message. If so, it's considered "dangling" because
  1774. * it probably hasn't been applied to anything yet, given that
  1775. * the relevant message is only coming in now.
  1776. * @private
  1777. * @method _converse.ChatRoom#findDanglingModeration
  1778. * @param { object } attrs - Attributes representing a received
  1779. * message, as returned by {@link parseMUCMessage}
  1780. * @returns { _converse.ChatRoomMessage }
  1781. */
  1782. findDanglingModeration (attrs) {
  1783. if (!this.messages.length) {
  1784. return null;
  1785. }
  1786. // Only look for dangling moderation if there are newer
  1787. // messages than this one, since moderation come after.
  1788. if (this.messages.last().get('time') > attrs.time) {
  1789. // Search from latest backwards
  1790. const messages = Array.from(this.messages.models);
  1791. const stanza_id = attrs[`stanza_id ${this.get('jid')}`];
  1792. if (!stanza_id) {
  1793. return null;
  1794. }
  1795. messages.reverse();
  1796. return messages.find(
  1797. ({ attributes }) =>
  1798. attributes.moderated === 'retracted' &&
  1799. attributes.moderated_id === stanza_id &&
  1800. attributes.moderated_by
  1801. );
  1802. }
  1803. },
  1804. /**
  1805. * Handles message moderation based on the passed in attributes.
  1806. * @private
  1807. * @method _converse.ChatRoom#handleModeration
  1808. * @param { object } attrs - Attributes representing a received
  1809. * message, as returned by {@link parseMUCMessage}
  1810. * @returns { Boolean } Returns `true` or `false` depending on
  1811. * whether a message was moderated or not.
  1812. */
  1813. async handleModeration (attrs) {
  1814. const MODERATION_ATTRIBUTES = ['editable', 'moderated', 'moderated_by', 'moderated_id', 'moderation_reason'];
  1815. if (attrs.moderated === 'retracted') {
  1816. const query = {};
  1817. const key = `stanza_id ${this.get('jid')}`;
  1818. query[key] = attrs.moderated_id;
  1819. const message = this.messages.findWhere(query);
  1820. if (!message) {
  1821. attrs['dangling_moderation'] = true;
  1822. await this.createMessage(attrs);
  1823. return true;
  1824. }
  1825. message.save(pick(attrs, MODERATION_ATTRIBUTES));
  1826. return true;
  1827. } else {
  1828. // Check if we have dangling moderation message
  1829. const message = this.findDanglingModeration(attrs);
  1830. if (message) {
  1831. const moderation_attrs = pick(message.attributes, MODERATION_ATTRIBUTES);
  1832. const new_attrs = Object.assign({ 'dangling_moderation': false }, attrs, moderation_attrs);
  1833. delete new_attrs['id']; // Delete id, otherwise a new cache entry gets created
  1834. message.save(new_attrs);
  1835. return true;
  1836. }
  1837. }
  1838. return false;
  1839. },
  1840. getNotificationsText () {
  1841. const { __ } = _converse;
  1842. const actors_per_state = this.notifications.toJSON();
  1843. const role_changes = api.settings
  1844. .get('muc_show_info_messages')
  1845. .filter(role_change => converse.MUC_ROLE_CHANGES_LIST.includes(role_change));
  1846. const join_leave_events = api.settings
  1847. .get('muc_show_info_messages')
  1848. .filter(join_leave_event => converse.MUC_TRAFFIC_STATES_LIST.includes(join_leave_event));
  1849. const states = [...converse.CHAT_STATES, ...join_leave_events, ...role_changes];
  1850. return states.reduce((result, state) => {
  1851. const existing_actors = actors_per_state[state];
  1852. if (!existing_actors?.length) {
  1853. return result;
  1854. }
  1855. const actors = existing_actors.map(a => this.getOccupant(a)?.getDisplayName() || a);
  1856. if (actors.length === 1) {
  1857. if (state === 'composing') {
  1858. return `${result}${__('%1$s is typing', actors[0])}\n`;
  1859. } else if (state === 'paused') {
  1860. return `${result}${__('%1$s has stopped typing', actors[0])}\n`;
  1861. } else if (state === _converse.GONE) {
  1862. return `${result}${__('%1$s has gone away', actors[0])}\n`;
  1863. } else if (state === 'entered') {
  1864. return `${result}${__('%1$s has entered the groupchat', actors[0])}\n`;
  1865. } else if (state === 'exited') {
  1866. return `${result}${__('%1$s has left the groupchat', actors[0])}\n`;
  1867. } else if (state === 'op') {
  1868. return `${result}${__('%1$s is now a moderator', actors[0])}\n`;
  1869. } else if (state === 'deop') {
  1870. return `${result}${__('%1$s is no longer a moderator', actors[0])}\n`;
  1871. } else if (state === 'voice') {
  1872. return `${result}${__('%1$s has been given a voice', actors[0])}\n`;
  1873. } else if (state === 'mute') {
  1874. return `${result}${__('%1$s has been muted', actors[0])}\n`;
  1875. }
  1876. } else if (actors.length > 1) {
  1877. let actors_str;
  1878. if (actors.length > 3) {
  1879. actors_str = `${Array.from(actors)
  1880. .slice(0, 2)
  1881. .join(', ')} and others`;
  1882. } else {
  1883. const last_actor = actors.pop();
  1884. actors_str = __('%1$s and %2$s', actors.join(', '), last_actor);
  1885. }
  1886. if (state === 'composing') {
  1887. return `${result}${__('%1$s are typing', actors_str)}\n`;
  1888. } else if (state === 'paused') {
  1889. return `${result}${__('%1$s have stopped typing', actors_str)}\n`;
  1890. } else if (state === _converse.GONE) {
  1891. return `${result}${__('%1$s have gone away', actors_str)}\n`;
  1892. } else if (state === 'entered') {
  1893. return `${result}${__('%1$s have entered the groupchat', actors_str)}\n`;
  1894. } else if (state === 'exited') {
  1895. return `${result}${__('%1$s have left the groupchat', actors_str)}\n`;
  1896. } else if (state === 'op') {
  1897. return `${result}${__('%1$s are now moderators', actors[0])}\n`;
  1898. } else if (state === 'deop') {
  1899. return `${result}${__('%1$s are no longer moderators', actors[0])}\n`;
  1900. } else if (state === 'voice') {
  1901. return `${result}${__('%1$s have been given voices', actors[0])}\n`;
  1902. } else if (state === 'mute') {
  1903. return `${result}${__('%1$s have been muted', actors[0])}\n`;
  1904. }
  1905. }
  1906. return result;
  1907. }, '');
  1908. },
  1909. /**
  1910. * @param {String} actor - The nickname of the actor that caused the notification
  1911. * @param {String|Array<String>} states - The state or states representing the type of notificcation
  1912. */
  1913. removeNotification (actor, states) {
  1914. const actors_per_state = this.notifications.toJSON();
  1915. states = Array.isArray(states) ? states : [states];
  1916. states.forEach(state => {
  1917. const existing_actors = Array.from(actors_per_state[state] || []);
  1918. if (existing_actors.includes(actor)) {
  1919. const idx = existing_actors.indexOf(actor);
  1920. existing_actors.splice(idx, 1);
  1921. this.notifications.set(state, Array.from(existing_actors));
  1922. }
  1923. });
  1924. },
  1925. /**
  1926. * Update the notifications model by adding the passed in nickname
  1927. * to the array of nicknames that all match a particular state.
  1928. *
  1929. * Removes the nickname from any other states it might be associated with.
  1930. *
  1931. * The state can be a XEP-0085 Chat State or a XEP-0045 join/leave
  1932. * state.
  1933. * @param {String} actor - The nickname of the actor that causes the notification
  1934. * @param {String} state - The state representing the type of notificcation
  1935. */
  1936. updateNotifications (actor, state) {
  1937. const actors_per_state = this.notifications.toJSON();
  1938. const existing_actors = actors_per_state[state] || [];
  1939. if (existing_actors.includes(actor)) {
  1940. return;
  1941. }
  1942. const reducer = (out, s) => {
  1943. if (s === state) {
  1944. out[s] = [...existing_actors, actor];
  1945. } else {
  1946. out[s] = (actors_per_state[s] || []).filter(a => a !== actor);
  1947. }
  1948. return out;
  1949. };
  1950. const actors_per_chat_state = converse.CHAT_STATES.reduce(reducer, {});
  1951. const actors_per_traffic_state = converse.MUC_TRAFFIC_STATES_LIST.reduce(reducer, {});
  1952. const actors_per_role_change = converse.MUC_ROLE_CHANGES_LIST.reduce(reducer, {});
  1953. this.notifications.set(Object.assign(actors_per_chat_state, actors_per_traffic_state, actors_per_role_change));
  1954. window.setTimeout(() => this.removeNotification(actor, state), 10000);
  1955. },
  1956. handleMetadataFastening (attrs) {
  1957. if (!api.settings.get('muc_show_ogp_unfurls')) {
  1958. return false;
  1959. }
  1960. if (attrs.ogp_for_id) {
  1961. if (attrs.from !== this.get('jid')) {
  1962. // For now we only allow metadata from the MUC itself and not
  1963. // from individual users who are deemed less trustworthy.
  1964. return false;
  1965. }
  1966. const message = this.messages.findWhere({'origin_id': attrs.ogp_for_id});
  1967. if (message) {
  1968. const old_list = (message.get('ogp_metadata') || []);
  1969. if (old_list.filter(m => m['og:url'] === attrs['og:url']).length) {
  1970. // Don't add metadata for the same URL again
  1971. return false;
  1972. }
  1973. const list = [...old_list, pick(attrs, METADATA_ATTRIBUTES)];
  1974. message.save('ogp_metadata', list);
  1975. return true;
  1976. }
  1977. }
  1978. return false;
  1979. },
  1980. /**
  1981. * Given { @link MessageAttributes } look for XEP-0316 Room Notifications and create info
  1982. * messages for them.
  1983. * @param { XMLElement } stanza
  1984. */
  1985. handleMEPNotification (attrs) {
  1986. if (attrs.from !== this.get('jid') || !attrs.activities) {
  1987. return false;
  1988. }
  1989. attrs.activities?.forEach(activity_attrs => {
  1990. const data = Object.assign({ 'msgid': attrs.msgid, 'from_muc': attrs.from }, activity_attrs);
  1991. this.createMessage(data)
  1992. // Trigger so that notifications are shown
  1993. api.trigger('message', { 'attrs': data, 'chatbox': this });
  1994. });
  1995. return !!attrs.activities.length
  1996. },
  1997. /**
  1998. * Returns an already cached message (if it exists) based on the
  1999. * passed in attributes map.
  2000. * @method _converse.ChatRoom#getDuplicateMessage
  2001. * @param { object } attrs - Attributes representing a received
  2002. * message, as returned by { @link parseMUCMessage }
  2003. * @returns {Promise<_converse.Message>}
  2004. */
  2005. getDuplicateMessage (attrs) {
  2006. if (attrs.activities?.length) {
  2007. return this.messages.findWhere({'type': 'info', 'msgid': attrs.msgid});
  2008. } else {
  2009. return _converse.ChatBox.prototype.getDuplicateMessage.call(this, attrs);
  2010. }
  2011. },
  2012. /**
  2013. * Handler for all MUC messages sent to this groupchat. This method
  2014. * shouldn't be called directly, instead {@link _converse.ChatRoom#queueMessage}
  2015. * should be called.
  2016. * @method _converse.ChatRoom#onMessage
  2017. * @param { MessageAttributes } attrs - A promise which resolves to the message attributes.
  2018. */
  2019. async onMessage (attrs) {
  2020. attrs = await attrs;
  2021. if (u.isErrorObject(attrs)) {
  2022. attrs.stanza && log.error(attrs.stanza);
  2023. return log.error(attrs.message);
  2024. }
  2025. const message = this.getDuplicateMessage(attrs);
  2026. if (message) {
  2027. (message.get('type') === 'groupchat') && this.updateMessage(message, attrs);
  2028. return;
  2029. } else if (attrs.is_valid_receipt_request || attrs.is_marker || this.ignorableCSN(attrs)) {
  2030. return;
  2031. }
  2032. if (
  2033. this.handleMetadataFastening(attrs) ||
  2034. this.handleMEPNotification(attrs) ||
  2035. (await this.handleRetraction(attrs)) ||
  2036. (await this.handleModeration(attrs)) ||
  2037. (await this.handleSubjectChange(attrs))
  2038. ) {
  2039. attrs.nick && this.removeNotification(attrs.nick, ['composing', 'paused']);
  2040. return;
  2041. }
  2042. this.setEditable(attrs, attrs.time);
  2043. if (attrs['chat_state']) {
  2044. this.updateNotifications(attrs.nick, attrs.chat_state);
  2045. }
  2046. if (u.shouldCreateGroupchatMessage(attrs)) {
  2047. const msg = this.handleCorrection(attrs) || (await this.createMessage(attrs));
  2048. this.removeNotification(attrs.nick, ['composing', 'paused']);
  2049. this.handleUnreadMessage(msg);
  2050. }
  2051. },
  2052. handleModifyError (pres) {
  2053. const text = pres.querySelector('error text')?.textContent;
  2054. if (text) {
  2055. if (this.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING) {
  2056. this.setDisconnectionState(text);
  2057. } else {
  2058. const attrs = {
  2059. 'type': 'error',
  2060. 'message': text,
  2061. 'is_ephemeral': true
  2062. };
  2063. this.createMessage(attrs);
  2064. }
  2065. }
  2066. },
  2067. /**
  2068. * Handle a presence stanza that disconnects the user from the MUC
  2069. * @param { XMLElement } stanza
  2070. */
  2071. handleDisconnection (stanza) {
  2072. const is_self = stanza.querySelector("status[code='110']") !== null;
  2073. const x = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"]`, stanza).pop();
  2074. if (!x) {
  2075. return;
  2076. }
  2077. const disconnection_codes = Object.keys(_converse.muc.disconnect_messages);
  2078. const codes = sizzle('status', x)
  2079. .map(s => s.getAttribute('code'))
  2080. .filter(c => disconnection_codes.includes(c));
  2081. const disconnected = is_self && codes.length > 0;
  2082. if (!disconnected) {
  2083. return;
  2084. }
  2085. // By using querySelector we assume here there is
  2086. // one <item> per <x xmlns='http://jabber.org/protocol/muc#user'>
  2087. // element. This appears to be a safe assumption, since
  2088. // each <x/> element pertains to a single user.
  2089. const item = x.querySelector('item');
  2090. const reason = item ? item.querySelector('reason')?.textContent : undefined;
  2091. const actor = item ? invoke(item.querySelector('actor'), 'getAttribute', 'nick') : undefined;
  2092. const message = _converse.muc.disconnect_messages[codes[0]];
  2093. const status = codes.includes('301') ? converse.ROOMSTATUS.BANNED : converse.ROOMSTATUS.DISCONNECTED;
  2094. this.setDisconnectionState(message, reason, actor, status);
  2095. },
  2096. getActionInfoMessage (code, nick, actor) {
  2097. const __ = _converse.__;
  2098. if (code === '301') {
  2099. return actor ? __('%1$s has been banned by %2$s', nick, actor) : __('%1$s has been banned', nick);
  2100. } else if (code === '303') {
  2101. return __("%1$s's nickname has changed", nick);
  2102. } else if (code === '307') {
  2103. return actor ? __('%1$s has been kicked out by %2$s', nick, actor) : __('%1$s has been kicked out', nick);
  2104. } else if (code === '321') {
  2105. return __('%1$s has been removed because of an affiliation change', nick);
  2106. } else if (code === '322') {
  2107. return __('%1$s has been removed for not being a member', nick);
  2108. }
  2109. },
  2110. createAffiliationChangeMessage (occupant) {
  2111. const __ = _converse.__;
  2112. const previous_affiliation = occupant._previousAttributes.affiliation;
  2113. if (!previous_affiliation) {
  2114. // If no previous affiliation was set, then we don't
  2115. // interpret this as an affiliation change.
  2116. // For example, if muc_send_probes is true, then occupants
  2117. // are created based on incoming messages, in which case
  2118. // we don't yet know the affiliation
  2119. return;
  2120. }
  2121. const current_affiliation = occupant.get('affiliation');
  2122. if (previous_affiliation === 'admin' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXADMIN)) {
  2123. this.createMessage({
  2124. 'type': 'info',
  2125. 'message': __('%1$s is no longer an admin of this groupchat', occupant.get('nick'))
  2126. });
  2127. } else if (previous_affiliation === 'owner' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXOWNER)) {
  2128. this.createMessage({
  2129. 'type': 'info',
  2130. 'message': __('%1$s is no longer an owner of this groupchat', occupant.get('nick'))
  2131. });
  2132. } else if (
  2133. previous_affiliation === 'outcast' &&
  2134. _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXOUTCAST)
  2135. ) {
  2136. this.createMessage({
  2137. 'type': 'info',
  2138. 'message': __('%1$s is no longer banned from this groupchat', occupant.get('nick'))
  2139. });
  2140. }
  2141. if (
  2142. current_affiliation === 'none' &&
  2143. previous_affiliation === 'member' &&
  2144. _converse.isInfoVisible(converse.AFFILIATION_CHANGES.EXMEMBER)
  2145. ) {
  2146. this.createMessage({
  2147. 'type': 'info',
  2148. 'message': __('%1$s is no longer a member of this groupchat', occupant.get('nick'))
  2149. });
  2150. }
  2151. if (current_affiliation === 'member' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.MEMBER)) {
  2152. this.createMessage({
  2153. 'type': 'info',
  2154. 'message': __('%1$s is now a member of this groupchat', occupant.get('nick'))
  2155. });
  2156. } else if (
  2157. (current_affiliation === 'admin' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.ADMIN)) ||
  2158. (current_affiliation == 'owner' && _converse.isInfoVisible(converse.AFFILIATION_CHANGES.OWNER))
  2159. ) {
  2160. // For example: AppleJack is now an (admin|owner) of this groupchat
  2161. this.createMessage({
  2162. 'type': 'info',
  2163. 'message': __('%1$s is now an %2$s of this groupchat', occupant.get('nick'), current_affiliation)
  2164. });
  2165. }
  2166. },
  2167. createRoleChangeMessage (occupant, changed) {
  2168. if (changed === 'none' || occupant.changed.affiliation) {
  2169. // We don't inform of role changes if they accompany affiliation changes.
  2170. return;
  2171. }
  2172. const previous_role = occupant._previousAttributes.role;
  2173. if (previous_role === 'moderator' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.DEOP)) {
  2174. this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.DEOP);
  2175. } else if (previous_role === 'visitor' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.VOICE)) {
  2176. this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.VOICE);
  2177. }
  2178. if (occupant.get('role') === 'visitor' && _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.MUTE)) {
  2179. this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.MUTE);
  2180. } else if (occupant.get('role') === 'moderator') {
  2181. if (
  2182. !['owner', 'admin'].includes(occupant.get('affiliation')) &&
  2183. _converse.isInfoVisible(converse.MUC_ROLE_CHANGES.OP)
  2184. ) {
  2185. // Oly show this message if the user isn't already
  2186. // an admin or owner, otherwise this isn't new information.
  2187. this.updateNotifications(occupant.get('nick'), converse.MUC_ROLE_CHANGES.OP);
  2188. }
  2189. }
  2190. },
  2191. /**
  2192. * Create an info message based on a received MUC status code
  2193. * @private
  2194. * @method _converse.ChatRoom#createInfoMessage
  2195. * @param { string } code - The MUC status code
  2196. * @param { XMLElement } stanza - The original stanza that contains the code
  2197. * @param { Boolean } is_self - Whether this stanza refers to our own presence
  2198. */
  2199. createInfoMessage (code, stanza, is_self) {
  2200. const __ = _converse.__;
  2201. const data = { 'type': 'info' };
  2202. if (!_converse.isInfoVisible(code)) {
  2203. return;
  2204. }
  2205. if (code === '110' || (code === '100' && !is_self)) {
  2206. return;
  2207. } else if (code in _converse.muc.info_messages) {
  2208. data.message = _converse.muc.info_messages[code];
  2209. } else if (!is_self && ACTION_INFO_CODES.includes(code)) {
  2210. const nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
  2211. const item = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop();
  2212. data.actor = item ? item.querySelector('actor')?.getAttribute('nick') : undefined;
  2213. data.reason = item ? item.querySelector('reason')?.textContent : undefined;
  2214. data.message = this.getActionInfoMessage(code, nick, data.actor);
  2215. } else if (is_self && code in _converse.muc.new_nickname_messages) {
  2216. // XXX: Side-effect of setting the nick. Should ideally be refactored out of this method
  2217. let nick;
  2218. if (is_self && code === '210') {
  2219. nick = Strophe.getResourceFromJid(stanza.getAttribute('from'));
  2220. } else if (is_self && code === '303') {
  2221. nick = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] item`, stanza).pop().getAttribute('nick');
  2222. }
  2223. this.save('nick', nick);
  2224. data.message = __(_converse.muc.new_nickname_messages[code], nick);
  2225. }
  2226. if (data.message) {
  2227. if (code === '201' && this.messages.findWhere(data)) {
  2228. return;
  2229. } else if (
  2230. code in _converse.muc.info_messages &&
  2231. this.messages.length &&
  2232. this.messages.pop().get('message') === data.message
  2233. ) {
  2234. // XXX: very naive duplication checking
  2235. return;
  2236. }
  2237. this.createMessage(data);
  2238. }
  2239. },
  2240. /**
  2241. * Create info messages based on a received presence or message stanza
  2242. * @private
  2243. * @method _converse.ChatRoom#createInfoMessages
  2244. * @param { XMLElement } stanza
  2245. */
  2246. createInfoMessages (stanza) {
  2247. const codes = sizzle(`x[xmlns="${Strophe.NS.MUC_USER}"] status`, stanza).map(s => s.getAttribute('code'));
  2248. if (codes.includes('333') && codes.includes('307')) {
  2249. // See: https://github.com/xsf/xeps/pull/969/files#diff-ac5113766e59219806793c1f7d967f1bR4966
  2250. codes.splice(codes.indexOf('307'), 1);
  2251. }
  2252. const is_self = codes.includes('110');
  2253. codes.forEach(code => this.createInfoMessage(code, stanza, is_self));
  2254. },
  2255. /**
  2256. * Set parameters regarding disconnection from this room. This helps to
  2257. * communicate to the user why they were disconnected.
  2258. * @param { String } message - The disconnection message, as received from (or
  2259. * implied by) the server.
  2260. * @param { String } reason - The reason provided for the disconnection
  2261. * @param { String } actor - The person (if any) responsible for this disconnection
  2262. * @param { Integer } status - The status code (see `converse.ROOMSTATUS`)
  2263. */
  2264. setDisconnectionState (message, reason, actor, status=converse.ROOMSTATUS.DISCONNECTED) {
  2265. this.session.save({
  2266. 'connection_status': status,
  2267. 'disconnection_actor': actor,
  2268. 'disconnection_message': message,
  2269. 'disconnection_reason': reason,
  2270. });
  2271. },
  2272. onNicknameClash (presence) {
  2273. const __ = _converse.__;
  2274. if (api.settings.get('muc_nickname_from_jid')) {
  2275. const nick = presence.getAttribute('from').split('/')[1];
  2276. if (nick === _converse.getDefaultMUCNickname()) {
  2277. this.join(nick + '-2');
  2278. } else {
  2279. const del = nick.lastIndexOf('-');
  2280. const num = nick.substring(del + 1, nick.length);
  2281. this.join(nick.substring(0, del + 1) + String(Number(num) + 1));
  2282. }
  2283. } else {
  2284. this.save({
  2285. 'nickname_validation_message': __(
  2286. 'The nickname you chose is reserved or ' + 'currently in use, please choose a different one.'
  2287. )
  2288. });
  2289. this.session.save({ 'connection_status': converse.ROOMSTATUS.NICKNAME_REQUIRED });
  2290. }
  2291. },
  2292. /**
  2293. * Parses a <presence> stanza with type "error" and sets the proper
  2294. * `connection_status` value for this {@link _converse.ChatRoom} as
  2295. * well as any additional output that can be shown to the user.
  2296. * @private
  2297. * @param { XMLElement } stanza - The presence stanza
  2298. */
  2299. onErrorPresence (stanza) {
  2300. const __ = _converse.__;
  2301. const error = stanza.querySelector('error');
  2302. const error_type = error.getAttribute('type');
  2303. const reason = sizzle(`text[xmlns="${Strophe.NS.STANZAS}"]`, error).pop()?.textContent;
  2304. if (error_type === 'modify') {
  2305. this.handleModifyError(stanza);
  2306. } else if (error_type === 'auth') {
  2307. if (sizzle(`not-authorized[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
  2308. this.save({ 'password_validation_message': reason || __('Password incorrect') });
  2309. this.session.save({ 'connection_status': converse.ROOMSTATUS.PASSWORD_REQUIRED });
  2310. }
  2311. if (error.querySelector('registration-required')) {
  2312. const message = __('You are not on the member list of this groupchat.');
  2313. this.setDisconnectionState(message, reason);
  2314. } else if (error.querySelector('forbidden')) {
  2315. this.setDisconnectionState(
  2316. _converse.muc.disconnect_messages[301],
  2317. reason,
  2318. null,
  2319. converse.ROOMSTATUS.BANNED
  2320. );
  2321. }
  2322. } else if (error_type === 'cancel') {
  2323. if (error.querySelector('not-allowed')) {
  2324. const message = __('You are not allowed to create new groupchats.');
  2325. this.setDisconnectionState(message, reason);
  2326. } else if (error.querySelector('not-acceptable')) {
  2327. const message = __("Your nickname doesn't conform to this groupchat's policies.");
  2328. this.setDisconnectionState(message, reason);
  2329. } else if (sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error).length) {
  2330. const moved_jid = sizzle(`gone[xmlns="${Strophe.NS.STANZAS}"]`, error)
  2331. .pop()
  2332. ?.textContent.replace(/^xmpp:/, '')
  2333. .replace(/\?join$/, '');
  2334. this.save({ moved_jid, 'destroyed_reason': reason });
  2335. this.session.save({ 'connection_status': converse.ROOMSTATUS.DESTROYED });
  2336. } else if (error.querySelector('conflict')) {
  2337. this.onNicknameClash(stanza);
  2338. } else if (error.querySelector('item-not-found')) {
  2339. const message = __('This groupchat does not (yet) exist.');
  2340. this.setDisconnectionState(message, reason);
  2341. } else if (error.querySelector('service-unavailable')) {
  2342. const message = __('This groupchat has reached its maximum number of participants.');
  2343. this.setDisconnectionState(message, reason);
  2344. } else if (error.querySelector('remote-server-not-found')) {
  2345. const message = __('Remote server not found');
  2346. const feedback = reason ? __('The explanation given is: "%1$s".', reason) : undefined;
  2347. this.setDisconnectionState(message, feedback);
  2348. }
  2349. }
  2350. },
  2351. /**
  2352. * Listens for incoming presence stanzas from the service that hosts this MUC
  2353. * @private
  2354. * @method _converse.ChatRoom#onPresenceFromMUCHost
  2355. * @param { XMLElement } stanza - The presence stanza
  2356. */
  2357. onPresenceFromMUCHost (stanza) {
  2358. if (stanza.getAttribute('type') === 'error') {
  2359. const error = stanza.querySelector('error');
  2360. if (error?.getAttribute('type') === 'wait' && error?.querySelector('resource-constraint')) {
  2361. // If we get a <resource-constraint> error, we assume it's in context of XEP-0437 RAI.
  2362. // We remove this MUC's host from the list of enabled domains and rejoin the MUC.
  2363. if (this.session.get('connection_status') === converse.ROOMSTATUS.DISCONNECTED) {
  2364. this.rejoin();
  2365. }
  2366. }
  2367. }
  2368. },
  2369. /**
  2370. * Handles incoming presence stanzas coming from the MUC
  2371. * @private
  2372. * @method _converse.ChatRoom#onPresence
  2373. * @param { XMLElement } stanza
  2374. */
  2375. onPresence (stanza) {
  2376. if (stanza.getAttribute('type') === 'error') {
  2377. return this.onErrorPresence(stanza);
  2378. }
  2379. this.createInfoMessages(stanza);
  2380. if (stanza.querySelector("status[code='110']")) {
  2381. this.onOwnPresence(stanza);
  2382. if (
  2383. this.getOwnRole() !== 'none' &&
  2384. this.session.get('connection_status') === converse.ROOMSTATUS.CONNECTING
  2385. ) {
  2386. this.session.save('connection_status', converse.ROOMSTATUS.CONNECTED);
  2387. }
  2388. } else {
  2389. this.updateOccupantsOnPresence(stanza);
  2390. }
  2391. },
  2392. /**
  2393. * Handles a received presence relating to the current user.
  2394. *
  2395. * For locked groupchats (which are by definition "new"), the
  2396. * groupchat will either be auto-configured or created instantly
  2397. * (with default config) or a configuration groupchat will be
  2398. * rendered.
  2399. *
  2400. * If the groupchat is not locked, then the groupchat will be
  2401. * auto-configured only if applicable and if the current
  2402. * user is the groupchat's owner.
  2403. * @private
  2404. * @method _converse.ChatRoom#onOwnPresence
  2405. * @param { XMLElement } pres - The stanza
  2406. */
  2407. async onOwnPresence (stanza) {
  2408. await this.occupants.fetched;
  2409. const old_status = this.session.get('connection_status');
  2410. if (stanza.getAttribute('type') !== 'unavailable' && old_status !== converse.ROOMSTATUS.ENTERED) {
  2411. // Set connection_status before creating the occupant, but
  2412. // only trigger afterwards, so that plugins can access the
  2413. // occupant in their event handlers.
  2414. this.session.save('connection_status', converse.ROOMSTATUS.ENTERED, { 'silent': true });
  2415. this.updateOccupantsOnPresence(stanza);
  2416. this.session.trigger('change:connection_status', this.session, old_status);
  2417. } else {
  2418. this.updateOccupantsOnPresence(stanza);
  2419. }
  2420. if (stanza.getAttribute('type') === 'unavailable') {
  2421. this.handleDisconnection(stanza);
  2422. return;
  2423. } else {
  2424. const locked_room = stanza.querySelector("status[code='201']");
  2425. if (locked_room) {
  2426. if (this.get('auto_configure')) {
  2427. this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
  2428. } else if (api.settings.get('muc_instant_rooms')) {
  2429. // Accept default configuration
  2430. this.sendConfiguration().then(() => this.refreshDiscoInfo());
  2431. } else {
  2432. this.session.save({ 'view': converse.MUC.VIEWS.CONFIG });
  2433. return;
  2434. }
  2435. } else if (!this.features.get('fetched')) {
  2436. // The features for this groupchat weren't fetched.
  2437. // That must mean it's a new groupchat without locking
  2438. // (in which case Prosody doesn't send a 201 status),
  2439. // otherwise the features would have been fetched in
  2440. // the "initialize" method already.
  2441. if (this.getOwnAffiliation() === 'owner' && this.get('auto_configure')) {
  2442. this.autoConfigureChatRoom().then(() => this.refreshDiscoInfo());
  2443. } else {
  2444. this.getDiscoInfo();
  2445. }
  2446. }
  2447. }
  2448. this.session.save({ 'connection_status': converse.ROOMSTATUS.ENTERED });
  2449. },
  2450. /**
  2451. * Returns a boolean to indicate whether the current user
  2452. * was mentioned in a message.
  2453. * @private
  2454. * @method _converse.ChatRoom#isUserMentioned
  2455. * @param { String } - The text message
  2456. */
  2457. isUserMentioned (message) {
  2458. const nick = this.get('nick');
  2459. if (message.get('references').length) {
  2460. const mentions = message
  2461. .get('references')
  2462. .filter(ref => ref.type === 'mention')
  2463. .map(ref => ref.value);
  2464. return mentions.includes(nick);
  2465. } else {
  2466. return new RegExp(`\\b${nick}\\b`).test(message.get('message'));
  2467. }
  2468. },
  2469. incrementUnreadMsgsCounter (message) {
  2470. const settings = {
  2471. 'num_unread_general': this.get('num_unread_general') + 1
  2472. };
  2473. if (this.get('num_unread_general') === 0) {
  2474. settings['first_unread_id'] = message.get('id');
  2475. }
  2476. if (this.isUserMentioned(message)) {
  2477. settings.num_unread = this.get('num_unread') + 1;
  2478. }
  2479. this.save(settings);
  2480. },
  2481. clearUnreadMsgCounter () {
  2482. if (this.get('num_unread_general') > 0 || this.get('num_unread') > 0 || this.get('has_activity')) {
  2483. this.sendMarkerForMessage(this.messages.last());
  2484. }
  2485. u.safeSave(this, {
  2486. 'has_activity': false,
  2487. 'num_unread': 0,
  2488. 'num_unread_general': 0
  2489. });
  2490. }
  2491. };
  2492. export default ChatRoomMixin;