@pazznetwork/ngx-chat
Version:
[](https://travis-ci.com/pazznetwork/ngx-chat) [](https://coveralls.io/github/pazzn
994 lines (976 loc) • 280 kB
JavaScript
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