UNPKG

@pazznetwork/ngx-chat

Version:

[![Build status](https://api.travis-ci.com/pazznetwork/ngx-chat.svg?branch=master)](https://travis-ci.com/pazznetwork/ngx-chat) [![Coverage](https://coveralls.io/repos/github/pazznetwork/ngx-chat/badge.svg?branch=master)](https://coveralls.io/github/pazzn

994 lines (976 loc) 280 kB
import { xml, jid as jid$1, client } from '@xmpp/client'; export { jid as parseJid } from '@xmpp/client'; import { jid } from '@xmpp/jid'; export { JID } from '@xmpp/jid'; import { __awaiter, __rest } from 'tslib'; import * as i0 from '@angular/core'; import { EventEmitter, Component, Output, Input, HostListener, InjectionToken, Inject, ViewChild, Injectable, ChangeDetectionStrategy, Optional, Directive, ViewChildren, NgZone, NgModule } from '@angular/core'; import * as i1 from '@angular/forms'; import { FormsModule } from '@angular/forms'; import * as i2 from '@angular/cdk/text-field'; import { TextFieldModule } from '@angular/cdk/text-field'; import { BehaviorSubject, Subject, ReplaySubject, combineLatest, merge, of } from 'rxjs'; import { filter, debounceTime, first, map, takeUntil, delay as delay$1, distinctUntilChanged, share, timeout as timeout$1, mergeMap, catchError } from 'rxjs/operators'; import * as i1$2 from '@angular/common/http'; import { HttpClient, HttpClientModule } from '@angular/common/http'; import * as i2$1 from '@angular/common'; import { CommonModule } from '@angular/common'; import * as i1$1 from '@angular/router'; import { RouterModule } from '@angular/router'; import { trigger, state, style, transition, animate } from '@angular/animations'; class FileDropComponent { constructor() { this.fileUpload = new EventEmitter(); this.enabled = true; this.isDropTarget = false; } onDragOver(event) { if (this.enabled) { event.preventDefault(); event.stopPropagation(); this.isDropTarget = true; } } onDragLeave(event) { event.preventDefault(); event.stopPropagation(); this.isDropTarget = false; } onDrop(event) { return __awaiter(this, void 0, void 0, function* () { if (this.enabled) { event.preventDefault(); event.stopPropagation(); this.isDropTarget = false; // tslint:disable-next-line:prefer-for-of for (let i = 0; i < event.dataTransfer.items.length; i++) { const dataTransferItem = event.dataTransfer.items[i]; if (dataTransferItem.kind === 'file') { this.fileUpload.emit(dataTransferItem.getAsFile()); } } } }); } } FileDropComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.9", ngImport: i0, type: FileDropComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); FileDropComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.9", type: FileDropComponent, selector: "ngx-chat-filedrop", inputs: { dropMessage: "dropMessage", enabled: "enabled" }, outputs: { fileUpload: "fileUpload" }, host: { listeners: { "dragover": "onDragOver($event)", "dragenter": "onDragOver($event)", "dragleave": "onDragLeave($event)", "dragexit": "onDragLeave($event)", "drop": "onDrop($event)" } }, ngImport: i0, template: "<div>\n <div class=\"drop-message\"\n [class.drop-message--visible]=\"isDropTarget\">\n {{dropMessage}}\n </div>\n <div>\n <ng-content></ng-content>\n </div>\n</div>\n", styles: [".drop-message{pointer-events:none;display:none}.drop-message--visible{position:absolute;inset:0;display:flex;justify-content:center;align-content:center;flex-direction:column;text-align:center;font-size:1.5em;z-index:999;background-color:#fff9;padding:1em}\n"] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.9", ngImport: i0, type: FileDropComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-chat-filedrop', template: "<div>\n <div class=\"drop-message\"\n [class.drop-message--visible]=\"isDropTarget\">\n {{dropMessage}}\n </div>\n <div>\n <ng-content></ng-content>\n </div>\n</div>\n", styles: [".drop-message{pointer-events:none;display:none}.drop-message--visible{position:absolute;inset:0;display:flex;justify-content:center;align-content:center;flex-direction:column;text-align:center;font-size:1.5em;z-index:999;background-color:#fff9;padding:1em}\n"] }] }], propDecorators: { fileUpload: [{ type: Output }], dropMessage: [{ type: Input }], enabled: [{ type: Input }], onDragOver: [{ type: HostListener, args: ['dragover', ['$event']] }, { type: HostListener, args: ['dragenter', ['$event']] }], onDragLeave: [{ type: HostListener, args: ['dragleave', ['$event']] }, { type: HostListener, args: ['dragexit', ['$event']] }], onDrop: [{ type: HostListener, args: ['drop', ['$event']] }] } }); /** * The chat service token gives you access to the main chat api and is implemented by default with an XMPP adapter, * you can always reuse the api and ui with a new service implementing the ChatService interface and providing the * said implementation with the token */ const CHAT_SERVICE_TOKEN = new InjectionToken('ngxChatService'); class ChatMessageInputComponent { constructor(chatService) { this.chatService = chatService; this.messageSent = new EventEmitter(); this.message = ''; } ngOnInit() { } onSendMessage($event) { if ($event) { $event.preventDefault(); } this.chatService.sendMessage(this.recipient, this.message); this.message = ''; this.messageSent.emit(); } focus() { this.chatInput.nativeElement.focus(); } } ChatMessageInputComponent.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "15.2.9", ngImport: i0, type: ChatMessageInputComponent, deps: [{ token: CHAT_SERVICE_TOKEN }], target: i0.ɵɵFactoryTarget.Component }); ChatMessageInputComponent.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "15.2.9", type: ChatMessageInputComponent, selector: "ngx-chat-message-input", inputs: { recipient: "recipient" }, outputs: { messageSent: "messageSent" }, viewQueries: [{ propertyName: "chatInput", first: true, predicate: ["chatInput"], descendants: true }], ngImport: i0, template: "<textarea class=\"chat-input\"\n #chatInput\n [(ngModel)]=\"message\"\n (keydown.enter)=\"onSendMessage($event)\"\n cdkTextareaAutosize\n cdkAutosizeMinRows=\"1\"\n cdkAutosizeMaxRows=\"5\"\n placeholder=\"{{chatService.translations.placeholder}}\"></textarea>\n", styles: ["@keyframes ngx-chat-message-in{0%{transform:translate(50px);opacity:0}to{transform:none;opacity:1}}@keyframes ngx-chat-message-out{0%{transform:translate(-50px);opacity:0}to{transform:none;opacity:1}}*{box-sizing:border-box;margin:0;padding:0;font-family:Helvetica,Arial,serif}.chat-input{border:none;width:100%;font-size:1em;padding:0;display:block;resize:none;overflow-x:hidden;outline:none}\n"], dependencies: [{ kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "directive", type: i2.CdkTextareaAutosize, selector: "textarea[cdkTextareaAutosize]", inputs: ["cdkAutosizeMinRows", "cdkAutosizeMaxRows", "cdkTextareaAutosize", "placeholder"], exportAs: ["cdkTextareaAutosize"] }] }); i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "15.2.9", ngImport: i0, type: ChatMessageInputComponent, decorators: [{ type: Component, args: [{ selector: 'ngx-chat-message-input', template: "<textarea class=\"chat-input\"\n #chatInput\n [(ngModel)]=\"message\"\n (keydown.enter)=\"onSendMessage($event)\"\n cdkTextareaAutosize\n cdkAutosizeMinRows=\"1\"\n cdkAutosizeMaxRows=\"5\"\n placeholder=\"{{chatService.translations.placeholder}}\"></textarea>\n", styles: ["@keyframes ngx-chat-message-in{0%{transform:translate(50px);opacity:0}to{transform:none;opacity:1}}@keyframes ngx-chat-message-out{0%{transform:translate(-50px);opacity:0}to{transform:none;opacity:1}}*{box-sizing:border-box;margin:0;padding:0;font-family:Helvetica,Arial,serif}.chat-input{border:none;width:100%;font-size:1em;padding:0;display:block;resize:none;overflow-x:hidden;outline:none}\n"] }] }], ctorParameters: function () { return [{ type: undefined, decorators: [{ type: Inject, args: [CHAT_SERVICE_TOKEN] }] }]; }, propDecorators: { recipient: [{ type: Input }], messageSent: [{ type: Output }], chatInput: [{ type: ViewChild, args: ['chatInput'] }] } }); var MessageState; (function (MessageState) { /** * Not yet sent */ MessageState["SENDING"] = "sending"; /** * Sent, but neither received nor seen by the recipient */ MessageState["SENT"] = "sent"; /** * The recipient client has received the message but the recipient has not seen it yet */ MessageState["RECIPIENT_RECEIVED"] = "recipientReceived"; /** * The message has been seen by the recipient */ MessageState["RECIPIENT_SEEN"] = "recipientSeen"; })(MessageState || (MessageState = {})); var Direction; (function (Direction) { Direction["in"] = "in"; Direction["out"] = "out"; })(Direction || (Direction = {})); class AbstractXmppPlugin { onBeforeOnline() { return Promise.resolve(); } onOffline() { } afterSendMessage(message, messageStanza) { return; } beforeSendMessage(messageStanza, message) { return; } handleStanza(stanza) { return false; } afterReceiveMessage(message, messageStanza, messageReceivedEvent) { return; } } /** * XEP-0191: Blocking Command * https://xmpp.org/extensions/xep-0191.html */ class BlockPlugin extends AbstractXmppPlugin { constructor(xmppChatAdapter, serviceDiscoveryPlugin) { super(); this.xmppChatAdapter = xmppChatAdapter; this.serviceDiscoveryPlugin = serviceDiscoveryPlugin; this.supportsBlock$ = new BehaviorSubject('unknown'); } onBeforeOnline() { return __awaiter(this, void 0, void 0, function* () { const supportsBlock = yield this.determineSupportForBlock(); this.supportsBlock$.next(supportsBlock); if (supportsBlock) { yield this.requestBlockedJids(); } }); } determineSupportForBlock() { return __awaiter(this, void 0, void 0, function* () { try { return yield this.serviceDiscoveryPlugin.supportsFeature(this.xmppChatAdapter.chatConnectionService.userJid.domain, 'urn:xmpp:blocking'); } catch (e) { return false; } }); } onOffline() { this.supportsBlock$.next('unknown'); this.xmppChatAdapter.blockedContactIds$.next(new Set()); } blockJid(jid) { return this.xmppChatAdapter.chatConnectionService.sendIq(xml('iq', { type: 'set' }, xml('block', { xmlns: 'urn:xmpp:blocking' }, xml('item', { jid })))); } unblockJid(jid) { return this.xmppChatAdapter.chatConnectionService.sendIq(xml('iq', { type: 'set' }, xml('unblock', { xmlns: 'urn:xmpp:blocking' }, xml('item', { jid })))); } requestBlockedJids() { return __awaiter(this, void 0, void 0, function* () { const blockListResponse = yield this.xmppChatAdapter.chatConnectionService.sendIq(xml('iq', { type: 'get' }, xml('blocklist', { xmlns: 'urn:xmpp:blocking' }))); const blockedJids = blockListResponse .getChild('blocklist') .getChildren('item') .map(e => e.attrs.jid); this.xmppChatAdapter.blockedContactIds$.next(new Set(blockedJids)); }); } handleStanza(stanza) { var _a; const { from } = stanza.attrs; if (from && from === ((_a = this.xmppChatAdapter.chatConnectionService.userJid) === null || _a === void 0 ? void 0 : _a.bare().toString())) { const blockPush = stanza.getChild('block', 'urn:xmpp:blocking'); const unblockPush = stanza.getChild('unblock', 'urn:xmpp:blocking'); const blockList = this.xmppChatAdapter.blockedContactIds$.getValue(); if (blockPush) { blockPush.getChildren('item') .map(e => e.attrs.jid) .forEach(jid => blockList.add(jid)); this.xmppChatAdapter.blockedContactIds$.next(blockList); return true; } else if (unblockPush) { const jidsToUnblock = unblockPush.getChildren('item').map(e => e.attrs.jid); if (jidsToUnblock.length === 0) { // unblock everyone blockList.clear(); } else { // unblock individually jidsToUnblock.forEach(jid => blockList.delete(jid)); } this.xmppChatAdapter.blockedContactIds$.next(blockList); return true; } } return false; } } class AbstractStanzaBuilder { } class XmppResponseError extends Error { constructor(errorStanza) { super(XmppResponseError.extractErrorTextFromErrorResponse(errorStanza, XmppResponseError.extractErrorDataFromErrorResponse(errorStanza))); this.errorStanza = errorStanza; const { code, type, condition } = XmppResponseError.extractErrorDataFromErrorResponse(errorStanza); this.errorCode = code; this.errorType = type; this.errorCondition = condition; } static extractErrorDataFromErrorResponse(stanza) { var _a; const errorElement = stanza.getChild('error'); const errorCode = Number(errorElement === null || errorElement === void 0 ? void 0 : errorElement.attrs.code) || undefined; const errorType = errorElement === null || errorElement === void 0 ? void 0 : errorElement.attrs.type; const errorCondition = (_a = errorElement === null || errorElement === void 0 ? void 0 : errorElement.children.filter(childElement => childElement.getName() !== 'text' && childElement.attrs.xmlns === XmppResponseError.ERROR_ELEMENT_NS)[0]) === null || _a === void 0 ? void 0 : _a.getName(); return { code: errorCode, type: errorType, condition: errorCondition, }; } static extractErrorTextFromErrorResponse(stanza, { code, type, condition }) { var _a; const additionalData = [ `errorCode: ${code !== null && code !== void 0 ? code : '[unknown]'}`, `errorType: ${type !== null && type !== void 0 ? type : '[unknown]'}`, `errorCondition: ${condition !== null && condition !== void 0 ? condition : '[unknown]'}`, ].join(', '); const errorText = ((_a = stanza.getChild('error')) === null || _a === void 0 ? void 0 : _a.getChildText('text', XmppResponseError.ERROR_ELEMENT_NS)) || 'Unknown error'; return `XmppResponseError: ${errorText}${additionalData ? ` (${additionalData})` : ''}`; } } XmppResponseError.ERROR_ELEMENT_NS = 'urn:ietf:params:xml:ns:xmpp-stanzas'; // implements https://xmpp.org/extensions/xep-0004.html const FORM_NS = 'jabber:x:data'; function parseStringValue([valueEl]) { return valueEl === null || valueEl === void 0 ? void 0 : valueEl.getText(); } function parseMultipleStringValues(valueEls) { return valueEls.map(el => parseStringValue([el])); } function parseJidValue([valueEl]) { return valueEl && jid(valueEl.getText()); } const valueParsers = { fixed: parseStringValue, boolean: ([valueEl]) => { if (!valueEl) { return false; } const value = valueEl.getText(); return value === '1' || value === 'true'; }, hidden: parseStringValue, 'jid-single': parseJidValue, 'jid-multi': (valueEls) => [ ...new Set(valueEls.map(el => parseStringValue([el]))), ] .map(jidStr => jid(jidStr)), 'list-single': parseStringValue, 'list-multi': parseMultipleStringValues, 'text-single': parseStringValue, 'text-private': parseStringValue, 'text-multi': parseMultipleStringValues, }; function parseForm(formEl) { var _a; if (formEl.name !== 'x' || formEl.getNS() !== FORM_NS) { throw new Error(`Provided element is not a form element: elementName=${formEl.name}, xmlns=${formEl.getNS()}, form=${formEl.toString()}`); } return { type: formEl.attrs.type, title: (_a = formEl.getChildText('title')) !== null && _a !== void 0 ? _a : undefined, instructions: formEl.getChildren('instructions').map(descEl => descEl.getText()), fields: formEl.getChildren('field') .map(fieldEl => { var _a; const rawType = fieldEl.attrs.type; const type = rawType in valueParsers ? rawType : 'text-single'; const { var: variable, label } = fieldEl.attrs; let options; if (type === 'list-single' || type === 'list-multi') { options = fieldEl.getChildren('option').map(optionEl => ({ value: optionEl.getChildText('value'), label: optionEl.attrs.label, })); } return { type, variable, label, description: (_a = fieldEl.getChildText('desc')) !== null && _a !== void 0 ? _a : undefined, required: fieldEl.getChild('required') != null, value: valueParsers[type](fieldEl.getChildren('value')), options, }; }), }; } function getField(form, variable) { var _a; return (_a = form.fields.find(field => field.variable === variable)) !== null && _a !== void 0 ? _a : undefined; } function setFieldValue(form, type, variable, value, createField = false) { let field = form.fields .find((f) => f.variable === variable); if (field) { if (field.type !== type) { throw new Error(`type mismatch setting field value: variable=${field.variable}, field.type=${field.type}, requested type=${type}`); } field.value = value; return; } if (createField) { field = { type, variable, value, }; form.fields.push(field); } else { throw new Error(`field for variable not found! variable=${variable}, type=${type}, value=${value}`); } } function serializeTextualField(field) { return field.value != null ? [field.value] : []; } function serializeTextualMultiField(field) { return field.value; } const valueSerializers = { fixed: serializeTextualField, boolean: (field) => field.value != null ? [String(field.value)] : [], hidden: serializeTextualField, 'jid-single': (field) => field.value ? [field.value.toString()] : [], 'jid-multi': (field) => field.value.map(jid => jid.toString()), 'list-single': serializeTextualField, 'list-multi': serializeTextualMultiField, 'text-single': serializeTextualField, 'text-private': serializeTextualField, 'text-multi': serializeTextualMultiField, }; function serializeToSubmitForm(form) { const serializedFields = form.fields .reduce((collectedFields, field) => { const serializer = valueSerializers[field.type]; if (!serializer) { throw new Error(`unknown field type: ${field.type}`); } const values = serializer(field); if (field.variable != null && values.length > 0) { collectedFields.push([field.variable, values]); } return collectedFields; }, []); return xml('x', { xmlns: FORM_NS, type: 'submit' }, ...serializedFields.map(([variable, values]) => xml('field', { var: variable }, ...values.map(value => xml('value', {}, value))))); } const PUBSUB_EVENT_XMLNS = 'http://jabber.org/protocol/pubsub#event'; class PublishStanzaBuilder extends AbstractStanzaBuilder { constructor(options) { super(); this.publishOptions = { persistItems: false, }; if (options) { this.publishOptions = Object.assign(Object.assign({}, this.publishOptions), options); } } toStanza() { const { node, id, persistItems } = this.publishOptions; // necessary as a 'event-only' publish is currently broken in ejabberd, see // https://github.com/processone/ejabberd/issues/2799 const data = this.publishOptions.data || xml('data'); return xml('iq', { type: 'set' }, xml('pubsub', { xmlns: 'http://jabber.org/protocol/pubsub' }, xml('publish', { node }, xml('item', { id }, data)), xml('publish-options', {}, serializeToSubmitForm({ type: 'submit', instructions: [], fields: [ { type: 'hidden', variable: 'FORM_TYPE', value: 'http://jabber.org/protocol/pubsub#publish-options' }, { type: 'boolean', variable: 'pubsub#persist_items', value: persistItems === true }, { type: 'list-single', variable: 'pubsub#access_model', value: 'whitelist' }, ], })))); } } class RetrieveDataStanzaBuilder extends AbstractStanzaBuilder { constructor(node) { super(); this.node = node; } toStanza() { return xml('iq', { type: 'get' }, xml('pubsub', { xmlns: 'http://jabber.org/protocol/pubsub' }, xml('items', { node: this.node }))); } } /** * XEP-0060 Publish Subscribe (https://xmpp.org/extensions/xep-0060.html) * XEP-0223 Persistent Storage of Private Data via PubSub (https://xmpp.org/extensions/xep-0223.html) */ class PublishSubscribePlugin extends AbstractXmppPlugin { constructor(xmppChatAdapter, serviceDiscoveryPlugin) { super(); this.xmppChatAdapter = xmppChatAdapter; this.serviceDiscoveryPlugin = serviceDiscoveryPlugin; this.publish$ = new Subject(); this.supportsPrivatePublish = new BehaviorSubject('unknown'); } onBeforeOnline() { return this.determineSupportForPrivatePublish(); } onOffline() { this.supportsPrivatePublish.next('unknown'); } storePrivatePayloadPersistent(node, id, data) { return new Promise((resolve, reject) => { this.supportsPrivatePublish .pipe(filter(support => support !== 'unknown')) .subscribe((support) => { if (!support) { reject(new Error('does not support private publish subscribe')); } else { resolve(this.xmppChatAdapter.chatConnectionService.sendIq(new PublishStanzaBuilder({ node, id, data, persistItems: true }).toStanza())); } }); }); } privateNotify(node, data, id) { return new Promise((resolve, reject) => { this.supportsPrivatePublish .pipe(filter(support => support !== 'unknown')) .subscribe((support) => { if (!support) { reject(new Error('does not support private publish subscribe')); } else { resolve(this.xmppChatAdapter.chatConnectionService.sendIq(new PublishStanzaBuilder({ node, id, data, persistItems: false }).toStanza())); } }); }); } handleStanza(stanza) { const eventElement = stanza.getChild('event', PUBSUB_EVENT_XMLNS); if (stanza.is('message') && eventElement) { this.publish$.next(eventElement); return true; } return false; } retrieveNodeItems(node) { return __awaiter(this, void 0, void 0, function* () { try { const iqResponseStanza = yield this.xmppChatAdapter.chatConnectionService.sendIq(new RetrieveDataStanzaBuilder(node).toStanza()); return iqResponseStanza.getChild('pubsub').getChild('items').getChildren('item'); } catch (e) { if (e instanceof XmppResponseError && (e.errorCondition === 'item-not-found' || e.errorCode === 404)) { return []; } throw e; } }); } determineSupportForPrivatePublish() { return __awaiter(this, void 0, void 0, function* () { let isSupported; try { const service = yield this.serviceDiscoveryPlugin.findService('pubsub', 'pep'); isSupported = service.features.includes('http://jabber.org/protocol/pubsub#publish-options'); } catch (e) { isSupported = false; } this.supportsPrivatePublish.next(isSupported); }); } } const MUC_SUB_FEATURE_ID = 'urn:xmpp:mucsub:0'; var MUC_SUB_EVENT_TYPE; (function (MUC_SUB_EVENT_TYPE) { MUC_SUB_EVENT_TYPE["presence"] = "urn:xmpp:mucsub:nodes:presence"; MUC_SUB_EVENT_TYPE["messages"] = "urn:xmpp:mucsub:nodes:messages"; MUC_SUB_EVENT_TYPE["affiliations"] = "urn:xmpp:mucsub:nodes:affiliations"; MUC_SUB_EVENT_TYPE["subscribers"] = "urn:xmpp:mucsub:nodes:subscribers"; MUC_SUB_EVENT_TYPE["config"] = "urn:xmpp:mucsub:nodes:config"; MUC_SUB_EVENT_TYPE["subject"] = "urn:xmpp:mucsub:nodes:subject"; MUC_SUB_EVENT_TYPE["system"] = "urn:xmpp:mucsub:nodes:system"; })(MUC_SUB_EVENT_TYPE || (MUC_SUB_EVENT_TYPE = {})); /** * support for https://docs.ejabberd.im/developer/xmpp-clients-bots/extensions/muc-sub/ */ class MucSubPlugin extends AbstractXmppPlugin { constructor(xmppChatAdapter, serviceDiscoveryPlugin) { super(); this.xmppChatAdapter = xmppChatAdapter; this.serviceDiscoveryPlugin = serviceDiscoveryPlugin; this.supportsMucSub$ = new BehaviorSubject('unknown'); } onBeforeOnline() { return this.determineSupportForMucSub(); } determineSupportForMucSub() { return __awaiter(this, void 0, void 0, function* () { let isSupported; try { const service = yield this.serviceDiscoveryPlugin.findService('conference', 'text'); isSupported = service.features.includes(MUC_SUB_FEATURE_ID); } catch (e) { isSupported = false; } this.supportsMucSub$.next(isSupported); }); } onOffline() { this.supportsMucSub$.next('unknown'); } subscribeRoom(roomJid, nodes = []) { return __awaiter(this, void 0, void 0, function* () { const nick = this.xmppChatAdapter.chatConnectionService.userJid.local; yield this.xmppChatAdapter.chatConnectionService.sendIq(makeSubscribeRoomStanza(roomJid, nick, nodes)); }); } unsubscribeRoom(roomJid) { return __awaiter(this, void 0, void 0, function* () { yield this.xmppChatAdapter.chatConnectionService.sendIq(makeUnsubscribeRoomStanza(roomJid)); }); } /** * A room moderator can unsubscribe others providing the their jid as attribute to the information query (iq) * see: https://docs.ejabberd.im/developer/xmpp-clients-bots/extensions/muc-sub/#unsubscribing-from-a-muc-room * @param roomJid for the room to be unsubscribed from * @param jid user id to be unsubscribed */ unsubscribeJidFromRoom(roomJid, jid) { this.xmppChatAdapter.chatConnectionService.sendIq(xml('iq', { type: 'set', to: roomJid }, xml('unsubscribe', { xmlns: 'urn:xmpp:mucsub:0', jid }))); } /** * A user can query the MUC service to get their list of subscriptions. * see: https://docs.ejabberd.im/developer/xmpp-clients-bots/extensions/muc-sub/#g dd ddetting-list-of-subscribed-rooms */ getSubscribedRooms() { return __awaiter(this, void 0, void 0, function* () { const { local, domain } = this.xmppChatAdapter.chatConnectionService.userJid; const from = `${local}@${domain}`; const subscriptions = yield this.xmppChatAdapter.chatConnectionService.sendIq(xml('iq', { type: 'get', from, to: 'muc.' + domain }, xml('subscriptions', { xmlns: 'urn:xmpp:mucsub:0' }))); return subscriptions.getChildren('subscription').map(sub => sub.getAttr('jid')); }); } /** * A subscriber or room moderator can get the list of subscribers by sending <subscriptions/> request directly to the room JID. * see: https://docs.ejabberd.im/developer/xmpp-clients-bots/extensions/muc-sub/#getting-list-of-subscribers-of-a-room * @param roomJid of the room the get a subscriber list from */ getSubscribers(roomJid) { this.xmppChatAdapter.chatConnectionService.sendIq(xml('iq', { type: 'get', to: roomJid }, xml('subscriptions', { xmlns: 'urn:xmpp:mucsub:0' }))); } retrieveSubscriptions() { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const service = yield this.serviceDiscoveryPlugin.findService('conference', 'text'); const result = yield this.xmppChatAdapter.chatConnectionService.sendIq(makeRetrieveSubscriptionsStanza(service.jid)); const subscriptions = (_b = (_a = result .getChild('subscriptions', MUC_SUB_FEATURE_ID)) === null || _a === void 0 ? void 0 : _a.getChildren('subscription')) === null || _b === void 0 ? void 0 : _b.map(subscriptionElement => { var _a, _b; const subscribedEvents = (_b = (_a = subscriptionElement .getChildren('event')) === null || _a === void 0 ? void 0 : _a.map(eventElement => eventElement.attrs.node)) !== null && _b !== void 0 ? _b : []; return [subscriptionElement.attrs.jid, subscribedEvents]; }); return new Map(subscriptions); }); } } function makeSubscribeRoomStanza(roomJid, nick, nodes) { return xml('iq', { type: 'set', to: roomJid }, xml('subscribe', { xmlns: MUC_SUB_FEATURE_ID, nick }, ...nodes.map(node => xml('event', { node })))); } function makeUnsubscribeRoomStanza(roomJid) { return xml('iq', { type: 'set', to: roomJid }, xml('unsubscribe', { xmlns: MUC_SUB_FEATURE_ID })); } function makeRetrieveSubscriptionsStanza(conferenceServiceJid) { return xml('iq', { type: 'get', to: conferenceServiceJid }, xml('subscriptions', { xmlns: MUC_SUB_FEATURE_ID })); } /** * https://xmpp.org/extensions/xep-0313.html * Message Archive Management */ class MessageArchivePlugin extends AbstractXmppPlugin { constructor(chatService, serviceDiscoveryPlugin, multiUserChatPlugin, logService, messagePlugin) { super(); this.chatService = chatService; this.serviceDiscoveryPlugin = serviceDiscoveryPlugin; this.multiUserChatPlugin = multiUserChatPlugin; this.logService = logService; this.messagePlugin = messagePlugin; this.mamMessageReceived$ = new Subject(); this.chatService.state$ .pipe(filter(state => state === 'online')) .subscribe(() => __awaiter(this, void 0, void 0, function* () { if (yield this.supportsMessageArchiveManagement()) { yield this.requestNewestMessages(); } })); // emit contacts to refresh contact list after receiving mam messages this.mamMessageReceived$ .pipe(debounceTime(10)) .subscribe(() => this.chatService.contacts$.next(this.chatService.contacts$.getValue())); } requestNewestMessages() { return __awaiter(this, void 0, void 0, function* () { yield this.chatService.chatConnectionService.sendIq(xml('iq', { type: 'set' }, xml('query', { xmlns: MessageArchivePlugin.MAM_NS }, xml('set', { xmlns: 'http://jabber.org/protocol/rsm' }, xml('max', {}, '250'), xml('before'))))); }); } loadMostRecentUnloadedMessages(recipient) { return __awaiter(this, void 0, void 0, function* () { // for user-to-user chats no to-attribute is necessary, in case of multi-user-chats it has to be set to the bare room jid const to = recipient.recipientType === 'room' ? recipient.roomJid.toString() : undefined; const form = { type: 'submit', instructions: [], fields: [ { type: 'hidden', variable: 'FORM_TYPE', value: MessageArchivePlugin.MAM_NS }, ...(recipient.recipientType === 'contact' ? [{ type: 'jid-single', variable: 'with', value: recipient.jidBare }] : []), ...(recipient.oldestMessage ? [{ type: 'text-single', variable: 'end', value: recipient.oldestMessage.datetime.toISOString() }] : []), ], }; const request = xml('iq', { type: 'set', to }, xml('query', { xmlns: MessageArchivePlugin.MAM_NS }, serializeToSubmitForm(form), xml('set', { xmlns: 'http://jabber.org/protocol/rsm' }, xml('max', {}, '100'), xml('before')))); yield this.chatService.chatConnectionService.sendIq(request); }); } loadAllMessages() { return __awaiter(this, void 0, void 0, function* () { if (!(yield this.supportsMessageArchiveManagement())) { throw new Error('message archive management not suppported'); } let lastMamResponse = yield this.chatService.chatConnectionService.sendIq(xml('iq', { type: 'set' }, xml('query', { xmlns: MessageArchivePlugin.MAM_NS }))); while (lastMamResponse.getChild('fin').attrs.complete !== 'true') { const lastReceivedMessageId = lastMamResponse.getChild('fin').getChild('set').getChildText('last'); lastMamResponse = yield this.chatService.chatConnectionService.sendIq(xml('iq', { type: 'set' }, xml('query', { xmlns: MessageArchivePlugin.MAM_NS }, xml('set', { xmlns: 'http://jabber.org/protocol/rsm' }, xml('max', {}, '250'), xml('after', {}, lastReceivedMessageId))))); } }); } supportsMessageArchiveManagement() { return __awaiter(this, void 0, void 0, function* () { const supportsMessageArchiveManagement = yield this.serviceDiscoveryPlugin.supportsFeature(this.chatService.chatConnectionService.userJid.bare().toString(), MessageArchivePlugin.MAM_NS); if (!supportsMessageArchiveManagement) { this.logService.info('server doesn\'t support MAM'); } return supportsMessageArchiveManagement; }); } handleStanza(stanza) { if (this.isMamMessageStanza(stanza)) { this.handleMamMessageStanza(stanza); return true; } return false; } isMamMessageStanza(stanza) { const result = stanza.getChild('result'); return stanza.name === 'message' && (result === null || result === void 0 ? void 0 : result.attrs.xmlns) === MessageArchivePlugin.MAM_NS; } handleMamMessageStanza(stanza) { const forwardedElement = stanza.getChild('result').getChild('forwarded'); const messageElement = forwardedElement.getChild('message'); const delayElement = forwardedElement.getChild('delay'); const eventElement = messageElement.getChild('event', PUBSUB_EVENT_XMLNS); if (messageElement.getAttr('type') == null && eventElement != null) { this.handlePubSubEvent(eventElement, delayElement); } else { this.handleArchivedMessage(messageElement, delayElement); } } handleArchivedMessage(messageElement, delayEl) { const type = messageElement.getAttr('type'); if (type === 'chat') { const messageHandled = this.messagePlugin.handleStanza(messageElement, delayEl); if (messageHandled) { this.mamMessageReceived$.next(); } } else if (type === 'groupchat' || this.multiUserChatPlugin.isRoomInvitationStanza(messageElement)) { this.multiUserChatPlugin.handleStanza(messageElement, delayEl); } else { throw new Error(`unknown archived message type: ${type}`); } } handlePubSubEvent(eventElement, delayElement) { const itemsElement = eventElement.getChild('items'); const itemsNode = itemsElement === null || itemsElement === void 0 ? void 0 : itemsElement.attrs.node; if (itemsNode !== MUC_SUB_EVENT_TYPE.messages) { this.logService.warn(`Handling of MUC/Sub message types other than ${MUC_SUB_EVENT_TYPE.messages} isn't implemented yet!`); return; } const itemElements = itemsElement.getChildren('item'); itemElements.forEach((itemEl) => this.handleArchivedMessage(itemEl.getChild('message'), delayElement)); } } MessageArchivePlugin.MAM_NS = 'urn:xmpp:mam:2'; /** * Optional injectable token to handle contact reports in the chat */ const REPORT_USER_INJECTION_TOKEN = new InjectionToken('ngxChatReportUserService'); const urlRegex = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim; function extractUrls(message) { return message.match(urlRegex) || []; } function id() { let i; while (!i) { i = Math.random() .toString(36) .substr(2, 12); } return i; } /** * https://xmpp.org/extensions/xep-0359.html */ class MessageUuidPlugin extends AbstractXmppPlugin { static extractIdFromStanza(messageStanza) { const originIdElement = messageStanza.getChild('origin-id'); const stanzaIdElement = messageStanza.getChild('stanza-id'); return messageStanza.attrs.id || (originIdElement && originIdElement.attrs.id) || (stanzaIdElement && stanzaIdElement.attrs.id); } beforeSendMessage(messageStanza, message) { const generatedId = id(); messageStanza.children.push(xml('origin-id', { xmlns: 'urn:xmpp:sid:0', id: generatedId })); if (message) { message.id = generatedId; } } afterSendMessage(message, messageStanza) { message.id = MessageUuidPlugin.extractIdFromStanza(messageStanza); } afterReceiveMessage(message, messageStanza) { message.id = MessageUuidPlugin.extractIdFromStanza(messageStanza); } } const STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES = 'ngxchat:contactmessagestates'; const wrapperNodeName$1 = 'entries'; const nodeName$1 = 'contact-message-state'; /** * Plugin using PubSub to persist message read states. * Custom not part of the XMPP Specification * Standardized implementation specification would be https://xmpp.org/extensions/xep-0184.html */ class MessageStatePlugin extends AbstractXmppPlugin { constructor(publishSubscribePlugin, xmppChatAdapter, chatMessageListRegistry, logService, entityTimePlugin) { super(); this.publishSubscribePlugin = publishSubscribePlugin; this.xmppChatAdapter = xmppChatAdapter; this.chatMessageListRegistry = chatMessageListRegistry; this.logService = logService; this.entityTimePlugin = entityTimePlugin; this.jidToMessageStateDate = new Map(); this.chatMessageListRegistry.openChats$ .pipe(filter(() => xmppChatAdapter.state$.getValue() === 'online')) .subscribe(contacts => { contacts.forEach((contact) => __awaiter(this, void 0, void 0, function* () { if (contact.mostRecentMessageReceived) { yield this.sendMessageStateNotification(contact.jidBare, contact.mostRecentMessageReceived.id, MessageState.RECIPIENT_SEEN); } })); }); this.publishSubscribePlugin.publish$ .subscribe((event) => this.handlePubSubEvent(event)); } onBeforeOnline() { return __awaiter(this, void 0, void 0, function* () { this.parseContactMessageStates().catch(err => this.logService.error('error parsing contact message states', err)); }); } parseContactMessageStates() { return __awaiter(this, void 0, void 0, function* () { const itemElements = yield this.publishSubscribePlugin.retrieveNodeItems(STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES); this.processPubSub(itemElements); }); } processPubSub(itemElements) { let results = []; if (itemElements.length === 1) { results = itemElements[0] .getChild(wrapperNodeName$1) .getChildren(nodeName$1) .map((contactMessageStateElement) => { const { lastRecipientReceived, lastRecipientSeen, lastSent, jid } = contactMessageStateElement.attrs; return [ jid, { lastRecipientSeen: new Date(+lastRecipientSeen || 0), lastRecipientReceived: new Date(+lastRecipientReceived || 0), lastSent: new Date(+lastSent || 0), } ]; }); } this.jidToMessageStateDate = new Map(results); } persistContactMessageStates() { return __awaiter(this, void 0, void 0, function* () { const messageStateElements = [...this.jidToMessageStateDate.entries()] .map(([jid, stateDates]) => xml(nodeName$1, { jid, lastRecipientReceived: String(stateDates.lastRecipientReceived.getTime()), lastRecipientSeen: String(stateDates.lastRecipientSeen.getTime()), lastSent: String(stateDates.lastSent.getTime()), })); yield this.publishSubscribePlugin.storePrivatePayloadPersistent(STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES, 'current', xml(wrapperNodeName$1, {}, ...messageStateElements)); }); } onOffline() { this.jidToMessageStateDate.clear(); } beforeSendMessage(messageStanza, message) { const { type } = messageStanza.attrs; if (type === 'chat' && message) { message.state = MessageState.SENDING; } } afterSendMessage(message, messageStanza) { return __awaiter(this, void 0, void 0, function* () { const { type, to } = messageStanza.attrs; if (type === 'chat') { this.updateContactMessageState(jid$1(to).bare().toString(), MessageState.SENT, new Date(yield this.entityTimePlugin.getNow())); delete message.state; } }); } afterReceiveMessage(messageReceived, stanza, messageReceivedEvent) { const messageStateElement = stanza.getChild('message-state', STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES); if (messageStateElement) { // we received a message state or a message via carbon from another resource, discard it messageReceivedEvent.discard = true; } else if (messageReceived.direction === Direction.in && !messageReceived.fromArchive && stanza.attrs.type !== 'groupchat') { this.acknowledgeReceivedMessage(stanza); } } acknowledgeReceivedMessage(stanza) { const { from } = stanza.attrs; const isChatWithContactOpen = this.chatMessageListRegistry.isChatOpen(this.xmppChatAdapter.getOrCreateContactById(from)); const state = isChatWithContactOpen ? MessageState.RECIPIENT_SEEN : MessageState.RECIPIENT_RECEIVED; const messageId = MessageUuidPlugin.extractIdFromStanza(stanza); this.sendMessageStateNotification(jid$1(from), messageId, state).catch(e => this.logService.error('error sending state notification', e)); } sendMessageStateNotification(recipient, messageId, state) { return __awaiter(this, void 0, void 0, function* () { const messageStateResponse = xml('message', { to: recipient.bare().toString(), from: this.xmppChatAdapter.chatConnectionService.userJid.toString(), type: 'chat' }, xml('message-state', { xmlns: STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES, messageId, date: new Date(yield this.entityTimePlugin.getNow()).toISOString(), state })); yield this.xmppChatAdapter.chatConnectionService.send(messageStateResponse); }); } handleStanza(stanza) { const { type, from } = stanza.attrs; const stateElement = stanza.getChild('message-state', STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES); if (type === 'chat' && stateElement) { this.handleStateNotificationStanza(stateElement, from); return true; } return false; } handleStateNotificationStanza(stateElement, from) { const { state, date } = stateElement.attrs; const contact = this.xmppChatAdapter.getOrCreateContactById(from); const stateDate = new Date(date); this.updateContactMessageState(contact.jidBare.toString(), state, stateDate); } updateContactMessageState(contactJid, state, stateDate) { const current = this.getContactMessageState(contactJid); let changed = false; if (state === MessageState.RECIPIENT_RECEIVED && current.lastRecipientReceived < stateDate) { current.lastRecipientReceived = stateDate; changed = true; } else if (state === MessageState.RECIPIENT_SEEN && current.lastRecipientSeen < stateDate) { current.lastRecipientReceived = stateDate; current.lastRecipientSeen = stateDate; changed = true; } else if (state === MessageState.SENT && current.lastSent < stateDate) { current.lastSent = stateDate; changed = true; } if (changed) { this.persistContactMessageStates().catch(err => this.logService.error('error persisting contact message states', err)); } } getContactMessageState(contactJid) { if (!this.jidToMessageStateDate.has(contactJid)) { this.jidToMessageStateDate.set(contactJid, { lastRecipientReceived: new Date(0), lastRecipientSeen: new Date(0), lastSent: new Date(0), }); } return this.jidToMessageStateDate.get(contactJid); } handlePubSubEvent(event) { const items = event.getChild('items'); const itemsNode = items === null || items === void 0 ? void 0 : items.attrs.node; const itemElements = items === null || items === void 0 ? void 0 : items.getChildren('item'); if (itemsNode === STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES && itemElements) { this.processPubSub(itemElements); } } } // tslint:disable const dummyAvatarContact = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNjAwIiBoZWlnaHQ9IjYwMCIgdmlld0JveD0iMCAwIDYwMCA2MDAiPgogIDxkZWZzPgogICAgPGNsaXBQYXRoIGlkPSJjbGlwLV8xIj4KICAgICAgPHJlY3Qgd2lkdGg9IjYwMCIgaGVpZ2h0PSI2MDAiLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgogIDxnIGlkPSJfMSIgZGF0YS1uYW1lPSIxIiBjbGlwLXBhdGg9InVybCgjY2xpcC1fMSkiPgogICAgPHJlY3Qgd2lkdGg9IjYwMCIgaGVpZ2h0PSI2MDAiIGZpbGw9IiNmZmYiLz4KICAgIDxnIGlkPSJHcnVwcGVfNzcxNyIgZGF0YS1uYW1lPSJHcnVwcGUgNzcxNyI+CiAgICAgIDxyZWN0IGlkPSJSZWNodGVja18xMzk3IiBkYXRhLW5hbWU9IlJlY2h0ZWNrIDEzOTciIHdpZHRoPSI2MDAiIGhlaWdodD0iNjAwIiBmaWxsPSIjZTVlNmU4Ii8+CiAgICAgIDxlbGxpcHNlIGlkPSJFbGxpcHNlXzI4MyIgZGF0YS1uYW1lPSJFbGxpcHNlIDI4MyIgY3g9IjExNi4yMzEiIGN5PSIxMjUuNjcxIiByeD0iMTE2LjIzMSIgcnk9IjEyNS42NzEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDE4NS4yMzEgMTExLjQ4NSkiIGZpbGw9IiNhZmI0YjgiLz4KICAgICAgPHBhdGggaWQ9IlBmYWRfMjQ5NjIiIGRhdGEtbmFtZT0iUGZhZCAyNDk2MiIgZD0iTTU0Ni4zNTksNTk1LjI3NnMwLTIxNy41NjMtMjQ0LjkwOS0yMTcuNTYzaC0xLjQ1N2MtMjQ0LjkwOSwwLTI0NC45MDksMjE3LjU2My0yNDQuOTA5LDIxNy41NjMiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDAgNC43MjQpIiBmaWxsPSIjYWZiNGI4Ii8+CiAgICA8L2c+CiAgPC9nPgo8L3N2Zz4K'; const dummyAvatarRoom = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNjAwIiBoZWlnaHQ9IjYwMCIgdmlld0JveD0iMCAwIDYwMCA2MDAiPgogIDxkZWZzPgogICAgPGNsaXBQYXRoIGlkPSJjbGlwLV8zIj4KICAgICAgPHJlY3Qgd2lkdGg9IjYwMCIgaGVpZ2h0PSI2MDAiLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgogIDxnIGlkPSJfMyIgZGF0YS1uYW1lPSIzIiBjbGlwLXBhdGg9InVybCgjY2xpcC1fMykiPgogICAgPHJlY3Qgd2lkdGg9IjYwMCIgaGVpZ2h0PSI2MDAiIGZpbGw9IiNmZmYiLz4KICAgIDxnIGlkPSJHcnVwcGVfNzcxOCIgZGF0YS1uYW1lPSJHcnVwcGUgNzcxOCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTc4MC42OTcgODgxLjUpIj4KICAgICAgPHJlY3QgaWQ9IlJlY2h0ZWNrXzEzOTgiIGRhdGEtbmFtZT0iUmVjaHRlY2sgMTM5OCIgd2lkdGg9IjYwMCIgaGVpZ2h0PSI1OTkuOTk1IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg3ODAuNjk3IC04ODEuNSkiIGZpbGw9IiNlNWU2ZTgiLz4KICAgICAgPGVsbGlwc2UgaWQ9IkVsbGlwc2VfMjg0IiBkYXRhLW5hbWU9IkVsbGlwc2UgMjg0IiBjeD0iMTE2