From 921f57396c61d70bf0b8722149d80adf20348b99 Mon Sep 17 00:00:00 2001 From: Dimitri B Date: Wed, 18 Aug 2021 16:15:51 +0200 Subject: [PATCH] feat(muc/sub): correctly handle archive messages delivered via MUC/Sub - extend mock interface with MUC/Sub handling and room creation features - add tests - minor refactorings and stylistic fixes --- .../chat-message-list.component.ts | 2 +- .../ngx-chat/src/lib/core/contact.spec.ts | 9 +- .../ngx-chat/src/lib/core/message.ts | 1 + .../ngx-chat/src/lib/ngx-chat.module.ts | 5 +- .../plugins/message-archive.plugin.spec.ts | 112 +++++++++++-- .../xmpp/plugins/message-archive.plugin.ts | 92 +++++----- .../xmpp/plugins/message-carbons.plugin.ts | 13 +- .../xmpp/plugins/message-state.plugin.ts | 27 ++- .../xmpp/plugins/message.plugin.spec.ts | 157 ++++++++++++++++-- .../adapters/xmpp/plugins/message.plugin.ts | 48 ++++-- .../adapters/xmpp/plugins/muc-sub.plugin.ts | 81 +++++++-- .../xmpp/plugins/multi-user-chat.plugin.ts | 88 +++++----- .../xmpp/plugins/publish-subscribe.plugin.ts | 11 +- .../xmpp/xmpp-chat-adapter.service.spec.ts | 9 +- .../multi-user-chat.component.html | 65 ++++++++ .../multi-user-chat.component.ts | 58 ++++++- src/app/routes/index/index.component.html | 2 +- src/app/routes/ui/ui.component.ts | 2 + src/styles.css | 8 + 19 files changed, 618 insertions(+), 172 deletions(-) diff --git a/projects/pazznetwork/ngx-chat/src/lib/components/chat-message-list/chat-message-list.component.ts b/projects/pazznetwork/ngx-chat/src/lib/components/chat-message-list/chat-message-list.component.ts index dd547b84..e4cbfc98 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/components/chat-message-list/chat-message-list.component.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/components/chat-message-list/chat-message-list.component.ts @@ -112,7 +112,7 @@ export class ChatMessageListComponent implements OnInit, OnDestroy, OnChanges, A filter(() => this.isNearBottom()), takeUntil(this.ngDestroy), ) - .subscribe((message) => this.scheduleScrollToLastMessage()); + .subscribe((_) => this.scheduleScrollToLastMessage()); if (this.recipient.messages.length < 10) { await this.loadMessages(); // in case insufficient old messages are displayed diff --git a/projects/pazznetwork/ngx-chat/src/lib/core/contact.spec.ts b/projects/pazznetwork/ngx-chat/src/lib/core/contact.spec.ts index 912329ae..abde4d7a 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/core/contact.spec.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/core/contact.spec.ts @@ -28,7 +28,8 @@ describe('contact', () => { body: '', direction: Direction.in, id: '1', - delayed: false + delayed: false, + fromArchive: false, }; contact.addMessage(message); contact.addMessage(message); @@ -41,7 +42,8 @@ describe('contact', () => { datetime: new Date(), body: '', direction: Direction.in, - delayed: false + delayed: false, + fromArchive: false, }; contact.addMessage(message); contact.addMessage(message); @@ -85,7 +87,8 @@ describe('contact', () => { datetime: new Date(new Date(date)), body: '', direction: Direction.in, - delayed: false + delayed: false, + fromArchive: false, }; } diff --git a/projects/pazznetwork/ngx-chat/src/lib/core/message.ts b/projects/pazznetwork/ngx-chat/src/lib/core/message.ts index 837da336..d979905d 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/core/message.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/core/message.ts @@ -23,6 +23,7 @@ export interface Message { datetime: Date; id?: string; delayed: boolean; + fromArchive: boolean; /** * if no explicit state is set for the message, use implicit contact message states instead. */ diff --git a/projects/pazznetwork/ngx-chat/src/lib/ngx-chat.module.ts b/projects/pazznetwork/ngx-chat/src/lib/ngx-chat.module.ts index 300658a9..0bf27cbc 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/ngx-chat.module.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/ngx-chat.module.ts @@ -131,11 +131,12 @@ export class NgxChatModule { const multiUserChatPlugin = new MultiUserChatPlugin(xmppChatAdapter, logService, serviceDiscoveryPlugin); const unreadMessageCountPlugin = new UnreadMessageCountPlugin( xmppChatAdapter, chatMessageListRegistryService, publishSubscribePlugin, entityTimePlugin, multiUserChatPlugin); + const messagePlugin = new MessagePlugin(xmppChatAdapter, logService); xmppChatAdapter.addPlugins([ new BookmarkPlugin(publishSubscribePlugin), - new MessageArchivePlugin(xmppChatAdapter, serviceDiscoveryPlugin, multiUserChatPlugin, logService), - new MessagePlugin(xmppChatAdapter, logService), + new MessageArchivePlugin(xmppChatAdapter, serviceDiscoveryPlugin, multiUserChatPlugin, logService, messagePlugin), + messagePlugin, new MessageUuidPlugin(), multiUserChatPlugin, publishSubscribePlugin, diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.spec.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.spec.ts index f10ab405..b10c21b2 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.spec.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.spec.ts @@ -11,6 +11,9 @@ import { XmppChatConnectionService } from '../xmpp-chat-connection.service'; import { XmppClientFactoryService } from '../xmpp-client-factory.service'; import { MessageArchivePlugin } from './message-archive.plugin'; import SpyObj = jasmine.SpyObj; +import { MessagePlugin } from './message.plugin'; +import { ServiceDiscoveryPlugin } from './service-discovery.plugin'; +import { MultiUserChatPlugin, Room } from './multi-user-chat.plugin'; describe('message archive plugin', () => { @@ -18,18 +21,40 @@ describe('message archive plugin', () => { let chatAdapter: XmppChatAdapter; let contactFactory: ContactFactoryService; let xmppClientMock: SpyObj; - let contact1: Contact; + let otherContact: Contact; + const otherContactJid = parseJid('someone@else.com'); const userJid = parseJid('me@example.com/myresource'); + const roomJid = parseJid('someroom@conference.example.com/mynick'); - const validArchiveStanza = + const chatArchiveStanza = xml('message', {}, xml('result', {xmlns: 'urn:xmpp:mam:2'}, xml('forwarded', {}, xml('delay', {stamp: '2018-07-18T08:47:44.233057Z'}), - xml('message', {to: userJid.toString(), from: 'someone@else.com/resource', type: 'chat'}, + xml('message', {from: userJid.toString(), to: otherContactJid.toString(), type: 'chat'}, xml('origin-id', {id: 'id'}), xml('body', {}, 'message text'))))); + const groupChatArchiveStanza = + xml('message', {}, + xml('result', {xmlns: 'urn:xmpp:mam:2'}, + xml('forwarded', {}, + xml('delay', {stamp: '2021-08-17T15:33:25.375401Z'}), + xml('message', {from: roomJid.bare().toString() + '/othernick', type: 'groupchat'}, + xml('body', {}, 'group chat!'))))); + + const mucSubArchiveStanza = + xml('message', {}, + xml('result', {xmlns: 'urn:xmpp:mam:2'}, + xml('forwarded', {}, + xml('delay', {stamp: '2021-08-17T15:33:25.375401Z'}), + xml('message', {}, + xml('event', {xmlns: 'http://jabber.org/protocol/pubsub#event'}, + xml('items', {node: 'urn:xmpp:mucsub:nodes:messages'}, + xml('item', {}, + xml('message', {from: roomJid.bare().toString() + '/othernick', type: 'groupchat'}, + xml('body', {}, 'group chat!'))))))))); // see: https://xkcd.com/297/ + beforeEach(() => { const mockClientFactory = new MockClientFactory(); xmppClientMock = mockClientFactory.clientInstance; @@ -49,31 +74,92 @@ describe('message archive plugin', () => { contactFactory = TestBed.inject(ContactFactoryService); chatAdapter = TestBed.inject(XmppChatAdapter); - contact1 = contactFactory.createContact('someone@else.com', 'jon doe'); + otherContact = contactFactory.createContact(otherContactJid.toString(), 'jon doe'); }); - it('should send a request, create contacts and add messages ', () => { - const messageArchivePlugin = new MessageArchivePlugin(chatAdapter, null, null, testLogService()); + it('should handle chat messages from archive by creating contacts and adding messages to contacts', () => { + const serviceDiscoveryPlugin = { + supportsFeature() { + return Promise.resolve(false); + } + } as any as ServiceDiscoveryPlugin; + const messagePlugin = new MessagePlugin(chatAdapter, testLogService()); + const messageArchivePlugin = new MessageArchivePlugin(chatAdapter, serviceDiscoveryPlugin, null, testLogService(), messagePlugin); chatAdapter.addPlugins([messageArchivePlugin]); chatConnectionService.onOnline(userJid); - chatConnectionService.onStanzaReceived(validArchiveStanza); + chatConnectionService.onStanzaReceived(chatArchiveStanza); const contacts = chatAdapter.contacts$.getValue(); - expect(contacts.length).toEqual(1); - expect(contacts[0].jidBare).toEqual(contact1.jidBare); + expect(contacts.length).toBe(1); + expect(contacts[0].jidBare).toEqual(otherContact.jidBare); const messages = contacts[0].messages; - expect(messages.length).toEqual(1); - expect(messages[0].body).toEqual('message text'); - expect(messages[0].direction).toEqual(Direction.in); + expect(messages.length).toBe(1); + expect(messages[0].body).toBe('message text'); + expect(messages[0].direction).toBe(Direction.out); expect(messages[0].datetime).toEqual(new Date('2018-07-18T08:47:44.233057Z')); + expect(messages[0].fromArchive).toBe(true); + }); + + it('should handle group chat messages by adding them to appropriate rooms', () => { + const serviceDiscoveryPlugin = { + supportsFeature() { + return Promise.resolve(false); + } + } as unknown as ServiceDiscoveryPlugin; + const logService = testLogService(); + const multiUserChatPlugin = new MultiUserChatPlugin(chatAdapter, logService, null); + const messageArchivePlugin = new MessageArchivePlugin(chatAdapter, serviceDiscoveryPlugin, multiUserChatPlugin, logService, null); + chatAdapter.addPlugins([messageArchivePlugin, multiUserChatPlugin]); + chatConnectionService.onOnline(userJid); + multiUserChatPlugin.rooms$.next([new Room(roomJid, logService)]); + + chatConnectionService.onStanzaReceived(groupChatArchiveStanza); + + const roomMessages = multiUserChatPlugin.rooms$.getValue()[0].messages; + + expect(roomMessages.length).toBe(1); + + const roomMessage = roomMessages[0]; + + expect(roomMessage.body).toBe('group chat!'); + expect(roomMessage.datetime).toEqual(new Date('2021-08-17T15:33:25.375401Z')); + expect(roomMessage.direction).toBe(Direction.in); + expect(roomMessage.fromArchive).toBe(true); + }); + + it('should handle MUC/Sub archive stanzas correctly', () => { + const serviceDiscoveryPlugin = { + supportsFeature() { + return Promise.resolve(false); + } + } as unknown as ServiceDiscoveryPlugin; + const logService = testLogService(); + const multiUserChatPlugin = new MultiUserChatPlugin(chatAdapter, logService, null); + const messageArchivePlugin = new MessageArchivePlugin(chatAdapter, serviceDiscoveryPlugin, multiUserChatPlugin, logService, null); + chatAdapter.addPlugins([messageArchivePlugin, multiUserChatPlugin]); + chatConnectionService.onOnline(userJid); + multiUserChatPlugin.rooms$.next([new Room(roomJid, logService)]); + + chatConnectionService.onStanzaReceived(mucSubArchiveStanza); + + const roomMessages = multiUserChatPlugin.rooms$.getValue()[0].messages; + + expect(roomMessages.length).toBe(1); + + const roomMessage = roomMessages[0]; + + expect(roomMessage.body).toBe('group chat!'); + expect(roomMessage.datetime).toEqual(new Date('2021-08-17T15:33:25.375401Z')); + expect(roomMessage.direction).toBe(Direction.in); + expect(roomMessage.fromArchive).toBe(true); }); it('should not request messages if message archive plugin is not set ', () => { chatConnectionService.onOnline(userJid); - chatConnectionService.onStanzaReceived(validArchiveStanza); + chatConnectionService.onStanzaReceived(chatArchiveStanza); expect(chatAdapter.contacts$.getValue()).toEqual([]); }); diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.ts index 8b638149..ccc20809 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-archive.plugin.ts @@ -1,15 +1,17 @@ -import { jid as parseJid, xml } from '@xmpp/client'; +import { xml } from '@xmpp/client'; +import { Element } from 'ltx'; import { Subject } from 'rxjs'; import { debounceTime, filter } from 'rxjs/operators'; -import { Direction } from '../../../../core/message'; import { Recipient } from '../../../../core/recipient'; -import { Stanza } from '../../../../core/stanza'; +import { IqResponseStanza, Stanza } from '../../../../core/stanza'; import { LogService } from '../../../log.service'; import { XmppChatAdapter } from '../xmpp-chat-adapter.service'; import { AbstractXmppPlugin } from './abstract-xmpp-plugin'; -import { MessageUuidPlugin } from './message-uuid.plugin'; import { MultiUserChatPlugin } from './multi-user-chat.plugin'; import { ServiceDiscoveryPlugin } from './service-discovery.plugin'; +import { PUBSUB_EVENT_XMLNS } from './publish-subscribe.plugin'; +import { MessagePlugin } from './message.plugin'; +import { MUC_SUB_EVENT_TYPE } from './muc-sub.plugin'; /** * https://xmpp.org/extensions/xep-0313.html @@ -17,13 +19,14 @@ import { ServiceDiscoveryPlugin } from './service-discovery.plugin'; */ export class MessageArchivePlugin extends AbstractXmppPlugin { - private mamMessageReceived$ = new Subject(); + private readonly mamMessageReceived$ = new Subject(); constructor( - private chatService: XmppChatAdapter, - private serviceDiscoveryPlugin: ServiceDiscoveryPlugin, - private multiUserChatPlugin: MultiUserChatPlugin, - private logService: LogService, + private readonly chatService: XmppChatAdapter, + private readonly serviceDiscoveryPlugin: ServiceDiscoveryPlugin, + private readonly multiUserChatPlugin: MultiUserChatPlugin, + private readonly logService: LogService, + private readonly messagePlugin: MessagePlugin, ) { super(); @@ -31,7 +34,7 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { .pipe(filter(state => state === 'online')) .subscribe(async () => { if (await this.supportsMessageArchiveManagement()) { - this.requestNewestMessages(); + await this.requestNewestMessages(); } }); @@ -41,8 +44,8 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { .subscribe(() => this.chatService.contacts$.next(this.chatService.contacts$.getValue())); } - private requestNewestMessages() { - this.chatService.chatConnectionService.sendIq( + private async requestNewestMessages(): Promise { + await this.chatService.chatConnectionService.sendIq( xml('iq', {type: 'set'}, xml('query', {xmlns: 'urn:xmpp:mam:2'}, xml('set', {xmlns: 'http://jabber.org/protocol/rsm'}, @@ -54,7 +57,7 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { ); } - async loadMostRecentUnloadedMessages(recipient: Recipient) { + async loadMostRecentUnloadedMessages(recipient: Recipient): Promise { // 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; @@ -86,7 +89,7 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { await this.chatService.chatConnectionService.sendIq(request); } - async loadAllMessages() { + async loadAllMessages(): Promise { if (!(await this.supportsMessageArchiveManagement())) { throw new Error('message archive management not suppported'); } @@ -112,7 +115,7 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { } } - private async supportsMessageArchiveManagement() { + private async supportsMessageArchiveManagement(): Promise { const supportsMessageArchiveManagement = await this.serviceDiscoveryPlugin.supportsFeature( this.chatService.chatConnectionService.userJid.bare().toString(), 'urn:xmpp:mam:2'); if (!supportsMessageArchiveManagement) { @@ -121,7 +124,7 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { return supportsMessageArchiveManagement; } - handleStanza(stanza: Stanza) { + handleStanza(stanza: Stanza): boolean { if (this.isMamMessageStanza(stanza)) { this.handleMamMessageStanza(stanza); return true; @@ -129,45 +132,48 @@ export class MessageArchivePlugin extends AbstractXmppPlugin { return false; } - private isMamMessageStanza(stanza: Stanza) { + private isMamMessageStanza(stanza: Stanza): boolean { const result = stanza.getChild('result'); - return stanza.name === 'message' && result && result.attrs.xmlns === 'urn:xmpp:mam:2'; + return stanza.name === 'message' && result?.attrs.xmlns === 'urn:xmpp:mam:2'; } - private handleMamMessageStanza(stanza: Stanza) { + private handleMamMessageStanza(stanza: Stanza): void { 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); + } + } + + private handleArchivedMessage(messageElement: Stanza, delayEl: Element): void { const type = messageElement.getAttr('type'); if (type === 'chat') { - // TODO: messagePlugin.handleMessage should be refactored so that it can - // handle messageElement like multiUserChatPlugin.handleRoomMessageStanza - // after refactoring just delegate to messagePlugin.handleMessage(messageElement, forwardedElement.getChild('delay') - const isAddressedToMe = this.chatService.chatConnectionService.userJid.bare() - .equals(parseJid(messageElement.attrs.to).bare()); - - const messageBody = messageElement.getChildText('body')?.trim(); - if (messageBody) { - const contactJid = isAddressedToMe ? messageElement.attrs.from : messageElement.attrs.to; - const contact = this.chatService.getOrCreateContactById(contactJid); - const datetime = new Date( - forwardedElement.getChild('delay').attrs.stamp, - ); - const direction = isAddressedToMe ? Direction.in : Direction.out; - - contact.addMessage({ - direction, - datetime, - body: messageBody, - id: MessageUuidPlugin.extractIdFromStanza(messageElement), - delayed: true, - }); + const messageHandled = this.messagePlugin.handleStanza(messageElement, delayEl); + if (messageHandled) { this.mamMessageReceived$.next(); } } else if (type === 'groupchat') { - this.multiUserChatPlugin.handleRoomMessageStanza(messageElement, forwardedElement.getChild('delay')); + this.multiUserChatPlugin.handleRoomMessageStanza(messageElement, delayEl); } else { - throw new Error('unknown archived message type: ' + type); + throw new Error(`unknown archived message type: ${type}`); } } + + private handlePubSubEvent(eventElement: Element, delayElement: Element): void { + const itemsElement = eventElement.getChild('items'); + const itemsNode = 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)); + } } diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-carbons.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-carbons.plugin.ts index a15c07e1..2ecb131e 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-carbons.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-carbons.plugin.ts @@ -1,7 +1,7 @@ import { xml } from '@xmpp/client'; import { Element } from 'ltx'; import { Direction } from '../../../../core/message'; -import { Stanza } from '../../../../core/stanza'; +import { IqResponseStanza, Stanza } from '../../../../core/stanza'; import { XmppChatAdapter } from '../xmpp-chat-adapter.service'; import { AbstractXmppPlugin } from './abstract-xmpp-plugin'; import { MessageReceivedEvent } from './message.plugin'; @@ -11,19 +11,19 @@ import { MessageReceivedEvent } from './message.plugin'; */ export class MessageCarbonsPlugin extends AbstractXmppPlugin { - constructor(private xmppChatAdapter: XmppChatAdapter) { + constructor(private readonly xmppChatAdapter: XmppChatAdapter) { super(); } - onBeforeOnline(): PromiseLike { - return this.xmppChatAdapter.chatConnectionService.sendIq( + async onBeforeOnline(): Promise { + return await this.xmppChatAdapter.chatConnectionService.sendIq( xml('iq', {type: 'set'}, xml('enable', {xmlns: 'urn:xmpp:carbons:2'}) ) ); } - handleStanza(stanza: Stanza) { + handleStanza(stanza: Stanza): boolean { const receivedOrSentElement = stanza.getChildByAttr('xmlns', 'urn:xmpp:carbons:2'); const forwarded = receivedOrSentElement && receivedOrSentElement.getChild('forwarded', 'urn:xmpp:forward:0'); const messageElement = forwarded && forwarded.getChild('message', 'jabber:client'); @@ -36,7 +36,7 @@ export class MessageCarbonsPlugin extends AbstractXmppPlugin { return false; } - private handleCarbonMessageStanza(messageElement: Element, receivedOrSent: Element) { + private handleCarbonMessageStanza(messageElement: Element, receivedOrSent: Element): boolean { const direction = receivedOrSent.is('received') ? Direction.in : Direction.out; const message = { @@ -44,6 +44,7 @@ export class MessageCarbonsPlugin extends AbstractXmppPlugin { direction, datetime: new Date(), // TODO: replace with entity time plugin delayed: false, + fromArchive: false, }; const messageReceivedEvent = new MessageReceivedEvent(); diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-state.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-state.plugin.ts index 807de5fb..e6a19b7d 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-state.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message-state.plugin.ts @@ -32,14 +32,14 @@ const nodeName = 'contact-message-state'; */ export class MessageStatePlugin extends AbstractXmppPlugin { - private jidToMessageStateDate: JidToMessageStateDate = {}; + private jidToMessageStateDate: JidToMessageStateDate = Object.create(null); constructor( - private publishSubscribePlugin: PublishSubscribePlugin, - private xmppChatAdapter: XmppChatAdapter, - private chatMessageListRegistry: ChatMessageListRegistryService, - private logService: LogService, - private entityTimePlugin: EntityTimePlugin, + private readonly publishSubscribePlugin: PublishSubscribePlugin, + private readonly xmppChatAdapter: XmppChatAdapter, + private readonly chatMessageListRegistry: ChatMessageListRegistryService, + private readonly logService: LogService, + private readonly entityTimePlugin: EntityTimePlugin, ) { super(); @@ -72,7 +72,7 @@ export class MessageStatePlugin extends AbstractXmppPlugin { } private processPubSub(itemElement: Element[]) { - const results: JidToMessageStateDate = {}; + const results: JidToMessageStateDate = Object.create(null); if (itemElement.length === 1) { for (const lastReadEntry of itemElement[0].getChild(wrapperNodeName).getChildren(nodeName)) { const {lastRecipientReceived, lastRecipientSeen, lastSent, jid} = lastReadEntry.attrs; @@ -89,17 +89,16 @@ export class MessageStatePlugin extends AbstractXmppPlugin { private async persistContactMessageStates() { const wrapperNode = xml(wrapperNodeName); - for (const jid in this.jidToMessageStateDate) { - if (this.jidToMessageStateDate.hasOwnProperty(jid)) { - const stateDates = this.jidToMessageStateDate[jid]; + Object + .entries(this.jidToMessageStateDate) + .forEach(([jid, stateDates]) => { wrapperNode.c(nodeName, { jid, lastRecipientReceived: stateDates.lastRecipientReceived.getTime(), lastRecipientSeen: stateDates.lastRecipientSeen.getTime(), lastSent: stateDates.lastSent.getTime(), }); - } - } + }); await this.publishSubscribePlugin.storePrivatePayloadPersistent( STORAGE_NGX_CHAT_CONTACT_MESSAGE_STATES, @@ -108,7 +107,7 @@ export class MessageStatePlugin extends AbstractXmppPlugin { } onOffline() { - this.jidToMessageStateDate = {}; + this.jidToMessageStateDate = Object.create(null); } beforeSendMessage(messageStanza: Element, message: Message): void { @@ -134,7 +133,7 @@ export class MessageStatePlugin extends AbstractXmppPlugin { 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 && stanza.attrs.type !== 'groupchat') { + } else if (messageReceived.direction === Direction.in && !messageReceived.fromArchive && stanza.attrs.type !== 'groupchat') { this.acknowledgeReceivedMessage(stanza); } } diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.spec.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.spec.ts index 96d2fb60..2bf93c92 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.spec.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { jid as parseJid, xml } from '@xmpp/client'; -import { first } from 'rxjs/operators'; +import { first, isEmpty, timeoutWith } from 'rxjs/operators'; import { testLogService } from '../../../../test/log-service'; import { MockClientFactory } from '../../../../test/xmppClientMock'; import { ChatServiceToken } from '../../../chat-service'; @@ -9,12 +9,17 @@ import { LogService } from '../../../log.service'; import { XmppChatAdapter } from '../xmpp-chat-adapter.service'; import { XmppChatConnectionService } from '../xmpp-chat-connection.service'; import { XmppClientFactoryService } from '../xmpp-client-factory.service'; -import { MessagePlugin } from './message.plugin'; +import { MessagePlugin, MessageReceivedEvent } from './message.plugin'; +import { Direction } from '../../../../core/message'; +import { AbstractXmppPlugin } from './abstract-xmpp-plugin'; +import Spy = jasmine.Spy; +import { EMPTY } from 'rxjs'; describe('message plugin', () => { - - let chatService: XmppChatAdapter; + let chatAdapter: XmppChatAdapter; let chatConnectionService: XmppChatConnectionService; + let messagePlugin: MessagePlugin; + let dummyPlugin: AbstractXmppPlugin; beforeEach(() => { const mockClientFactory = new MockClientFactory(); @@ -34,20 +39,144 @@ describe('message plugin', () => { chatConnectionService = TestBed.inject(XmppChatConnectionService); chatConnectionService.client = xmppClientMock; chatConnectionService.userJid = parseJid('me', 'example.com', 'something'); - chatService = TestBed.inject(ChatServiceToken) as XmppChatAdapter; - chatService.addPlugins([new MessagePlugin(chatService, logService)]); + chatAdapter = TestBed.inject(ChatServiceToken) as XmppChatAdapter; + messagePlugin = new MessagePlugin(chatAdapter, logService); + dummyPlugin = new (class DummyPlugin extends AbstractXmppPlugin { + })(); + chatAdapter.addPlugins([messagePlugin, dummyPlugin]); + }); + + it('should process received messages', async () => { + const currentTime = new Date().getTime(); + const someUserJid = parseJid('someone@example.com'); + spyOn(dummyPlugin, 'afterReceiveMessage').and.callThrough(); + + const contactFromMessageObservablePromise = chatAdapter.message$.pipe(first()).toPromise(); + + const messageStanza = xml('message', {from: someUserJid.toString(), to: chatConnectionService.userJid.toString()}, + xml('body', {}, 'message text')); + await chatConnectionService.onStanzaReceived(messageStanza); + + const contactFromMessageObservable = await contactFromMessageObservablePromise; + expect(contactFromMessageObservable.jidBare.equals(someUserJid)).toBeTrue(); + + const contacts = chatAdapter.contacts$.getValue(); + expect(contacts.length).toBe(1); + + const someContact = contacts[0]; + expect(someContact.jidBare.equals(someUserJid)).toBeTrue(); + expect(someContact).toBe(contactFromMessageObservable); + + const messages = someContact.messages; + expect(messages.length).toBe(1); + expect(messages[0].body).toBe('message text'); + expect(messages[0].direction).toBe(Direction.in); + expect(messages[0].datetime.getTime()).toBeGreaterThanOrEqual(currentTime); + expect(messages[0].datetime.getTime()).toBeLessThan(currentTime + 20, 'incoming message should be processed within 20ms'); + expect(messages[0].delayed).toBeFalse(); + expect(messages[0].fromArchive).toBeFalse(); + + expect(dummyPlugin.afterReceiveMessage).toHaveBeenCalledOnceWith(messages[0], messageStanza, jasmine.any(MessageReceivedEvent)); }); - it('should emit events on receiving a message', async (done) => { - chatConnectionService.stanzaUnknown$ - .pipe(first()) - .subscribe(async (stanza) => { - await expect(stanza.getChildText('body')).toEqual('message text'); - done(); - }); + it('should process received messages when they were delayed', async () => { + const delay = '2021-08-17T15:33:25.375401Z'; + const someUserJid = parseJid('someone@example.com'); + await chatConnectionService.onStanzaReceived( - xml('message', {from: 'someone@example.com'}, + xml('message', {from: someUserJid.toString(), to: chatConnectionService.userJid.toString()}, + xml('delay', {stamp: delay}), xml('body', {}, 'message text'))); + + const someContact = chatAdapter.contacts$.getValue()[0]; + expect(someContact.jidBare.equals(someUserJid)).toBeTrue(); + + const messages = someContact.messages; + expect(messages.length).toBe(1); + expect(messages[0].datetime).toEqual(new Date(delay)); + expect(messages[0].delayed).toBeTrue(); + expect(messages[0].fromArchive).toBeFalse(); + }); + + it('should discard messages if another plugin decides that they have to be discarded', async () => { + const someUserJid = parseJid('someone@example.com'); + + spyOn(messagePlugin, 'handleStanza').and.callThrough(); + + const messageStanza = xml('message', {from: someUserJid.toString(), to: chatConnectionService.userJid.toString()}, + xml('body', {}, 'message text')); + + spyOn(dummyPlugin, 'afterReceiveMessage').and.callFake((message, stanza, event) => { + if (stanza === messageStanza) { + event.discard = true; + } + }); + + await chatConnectionService.onStanzaReceived(messageStanza); + + expect(messagePlugin.handleStanza).toHaveBeenCalledOnceWith(messageStanza); + expect((messagePlugin.handleStanza as Spy).calls.mostRecent().returnValue).toBeTrue(); + expect(dummyPlugin.afterReceiveMessage) + .toHaveBeenCalledOnceWith(jasmine.any(Object), messageStanza, jasmine.any(MessageReceivedEvent)); + expect(chatAdapter.contacts$.getValue().length).toBe(0); + }); + + it('should ignore group chat messages', async (done) => { + const groupChatJid = parseJid('chatroom@conference.example.com/mynick'); + + spyOn(messagePlugin, 'handleStanza').and.callThrough(); + spyOn(dummyPlugin, 'afterReceiveMessage').and.callThrough(); + + const groupChatStanza = xml('message', { + from: groupChatJid.toString(), + to: chatConnectionService.userJid.toString(), + type: 'groupchat' + }, + xml('body', {}, 'message text')); + + chatConnectionService.stanzaUnknown$.pipe(first()).subscribe((unhandledStanza) => { + expect(unhandledStanza).toBe(groupChatStanza); + expect(messagePlugin.handleStanza).toHaveBeenCalledOnceWith(unhandledStanza); + expect((messagePlugin.handleStanza as Spy).calls.mostRecent().returnValue).toBeFalse(); + expect(dummyPlugin.afterReceiveMessage).not.toHaveBeenCalled(); + + done(); + }); + + await chatConnectionService.onStanzaReceived(groupChatStanza); }); + it('should process archived messages but don\'t add them to new chatAdapter.message$ observable', async () => { + const delay = '2021-08-17T15:33:25.375401Z'; + const someUserJid = parseJid('someone@example.com'); + spyOn(dummyPlugin, 'afterReceiveMessage').and.callThrough(); + + const messagesObservableIsEmpty = chatAdapter.message$ + .pipe(timeoutWith(50, EMPTY), isEmpty()) + .toPromise(); + + const messageStanza = xml('message', {from: someUserJid.toString(), to: chatConnectionService.userJid.toString()}, + xml('body', {}, 'message text')); + + const handled = messagePlugin.handleStanza(messageStanza, xml('delay', {stamp: delay})); + + expect(handled).toBeTrue(); + expect(await messagesObservableIsEmpty).toBeTrue(); + + const contacts = chatAdapter.contacts$.getValue(); + expect(contacts.length).toBe(1); + + const someContact = contacts[0]; + expect(someContact.jidBare.equals(someUserJid)).toBeTrue(); + + const messages = someContact.messages; + expect(messages.length).toBe(1); + expect(messages[0].body).toBe('message text'); + expect(messages[0].direction).toBe(Direction.in); + expect(messages[0].datetime).toEqual(new Date(delay)); + expect(messages[0].delayed).toBeTrue(); + expect(messages[0].fromArchive).toBeTrue(); + + expect(dummyPlugin.afterReceiveMessage).toHaveBeenCalledOnceWith(messages[0], messageStanza, jasmine.any(MessageReceivedEvent)); + }); }); diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.ts index 17b666f6..82d2ffc8 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/message.plugin.ts @@ -1,4 +1,4 @@ -import { xml } from '@xmpp/client'; +import { jid as parseJid, xml } from '@xmpp/client'; import { Contact } from '../../../../core/contact'; import { Direction, Message } from '../../../../core/message'; import { MessageWithBodyStanza, Stanza } from '../../../../core/stanza'; @@ -13,15 +13,15 @@ export class MessageReceivedEvent { export class MessagePlugin extends AbstractXmppPlugin { constructor( - private xmppChatAdapter: XmppChatAdapter, - private logService: LogService, + private readonly xmppChatAdapter: XmppChatAdapter, + private readonly logService: LogService, ) { super(); } - handleStanza(stanza: Stanza) { + handleStanza(stanza: Stanza, archiveDelayElement?: Stanza) { if (this.isMessageStanza(stanza)) { - this.handleMessageStanza(stanza); + this.handleMessageStanza(stanza, archiveDelayElement); return true; } return false; @@ -34,21 +34,42 @@ export class MessagePlugin extends AbstractXmppPlugin { && !!stanza.getChildText('body')?.trim(); } - private handleMessageStanza(messageStanza: MessageWithBodyStanza) { - this.logService.debug('message received <=', messageStanza.getChildText('body')); + private handleMessageStanza(messageStanza: MessageWithBodyStanza, archiveDelayElement?: Stanza) { + const isAddressedToMe = this.xmppChatAdapter.chatConnectionService.userJid.bare() + .equals(parseJid(messageStanza.attrs.to).bare()); + const messageDirection = isAddressedToMe ? Direction.in : Direction.out; + + const messageFromArchive = archiveDelayElement != null; + + const delay = archiveDelayElement ?? messageStanza.getChild('delay'); + const datetime = delay && delay.attrs.stamp + ? new Date(delay.attrs.stamp) + : new Date() /* TODO: replace with entity time plugin */; + + if (messageDirection === Direction.in && !messageFromArchive) { + this.logService.debug('message received <=', messageStanza.getChildText('body')); + } const message = { body: messageStanza.getChildText('body').trim(), - direction: Direction.in, - datetime: new Date(), // TODO: replace with entity time plugin - delayed: !!messageStanza.getChild('delay'), + direction: messageDirection, + datetime, + delayed: !!delay, + fromArchive: messageFromArchive }; const messageReceivedEvent = new MessageReceivedEvent(); this.xmppChatAdapter.plugins.forEach(plugin => plugin.afterReceiveMessage(message, messageStanza, messageReceivedEvent)); - if (!messageReceivedEvent.discard) { - const contact = this.xmppChatAdapter.getOrCreateContactById(messageStanza.attrs.from); - contact.addMessage(message); + + if (messageReceivedEvent.discard) { + return; + } + + const contactJid = isAddressedToMe ? messageStanza.attrs.from : messageStanza.attrs.to; + const contact = this.xmppChatAdapter.getOrCreateContactById(contactJid); + contact.addMessage(message); + + if (messageDirection === Direction.in && !messageFromArchive) { this.xmppChatAdapter.message$.next(contact); } } @@ -67,6 +88,7 @@ export class MessagePlugin extends AbstractXmppPlugin { body, datetime: new Date(), // TODO: replace with entity time plugin delayed: false, + fromArchive: false, }; this.xmppChatAdapter.plugins.forEach(plugin => plugin.beforeSendMessage(messageStanza, message)); contact.addMessage(message); diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/muc-sub.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/muc-sub.plugin.ts index 92bfae89..56b3604f 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/muc-sub.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/muc-sub.plugin.ts @@ -3,17 +3,29 @@ import { BehaviorSubject } from 'rxjs'; import { XmppChatAdapter } from '../xmpp-chat-adapter.service'; import { AbstractXmppPlugin } from './abstract-xmpp-plugin'; import { ServiceDiscoveryPlugin } from './service-discovery.plugin'; +import { Stanza } from '../../../../core/stanza'; + +export const MUC_SUB_FEATURE_ID = 'urn:xmpp:mucsub:0'; + +export const MUC_SUB_EVENT_TYPE = Object.freeze({ + presence: 'urn:xmpp:mucsub:nodes:presence', + messages: 'urn:xmpp:mucsub:nodes:messages', + affiliations: 'urn:xmpp:mucsub:nodes:affiliations', + subscribers: 'urn:xmpp:mucsub:nodes:subscribers', + config: 'urn:xmpp:mucsub:nodes:config', + subject: 'urn:xmpp:mucsub:nodes:subject', + system: 'urn:xmpp:mucsub:nodes:system' +} as const); /** * support for https://docs.ejabberd.im/developer/xmpp-clients-bots/extensions/muc-sub/ */ export class MucSubPlugin extends AbstractXmppPlugin { - - private supportsMucSub = new BehaviorSubject('unknown'); + private readonly supportsMucSub$ = new BehaviorSubject('unknown'); constructor( - private xmppChatAdapter: XmppChatAdapter, - private serviceDiscoveryPlugin: ServiceDiscoveryPlugin, + private readonly xmppChatAdapter: XmppChatAdapter, + private readonly serviceDiscoveryPlugin: ServiceDiscoveryPlugin, ) { super(); } @@ -26,26 +38,67 @@ export class MucSubPlugin extends AbstractXmppPlugin { let isSupported: boolean; try { const service = await this.serviceDiscoveryPlugin.findService('conference', 'text'); - isSupported = service.features.indexOf('urn:xmpp:mucsub:0') > -1; + isSupported = service.features.includes(MUC_SUB_FEATURE_ID); } catch (e) { isSupported = false; } - this.supportsMucSub.next(isSupported); + this.supportsMucSub$.next(isSupported); } onOffline() { - this.supportsMucSub.next('unknown'); + this.supportsMucSub$.next('unknown'); } - subscribeRoom(roomJid: string, nodes: string[] = []) { + async subscribeRoom(roomJid: string, nodes: string[] = []): Promise { const nick = this.xmppChatAdapter.chatConnectionService.userJid.local; - this.xmppChatAdapter.chatConnectionService.sendIq( - xml('iq', {type: 'set', to: roomJid}, - xml('subscribe', {xmlns: 'urn:xmpp:mucsub:0', nick}, - nodes.map(node => xml('event', {node})) - ) - ) + await this.xmppChatAdapter.chatConnectionService.sendIq( + makeSubscribeRoomStanza(roomJid, nick, nodes) ); } + async unsubscribeRoom(roomJid: string): Promise { + await this.xmppChatAdapter.chatConnectionService.sendIq( + makeUnsubscribeRoomStanza(roomJid) + ); + } + + async retrieveSubscriptions(): Promise> { + const service = await this.serviceDiscoveryPlugin.findService('conference', 'text'); + + const result = await this.xmppChatAdapter.chatConnectionService.sendIq( + makeRetrieveSubscriptionsStanza(service.jid) + ); + + const subscriptions = result + .getChild('subscriptions', MUC_SUB_FEATURE_ID) + ?.getChildren('subscription') + ?.map(subscriptionElement => { + const subscribedEvents: string[] = subscriptionElement + .getChildren('event') + ?.map(eventElement => eventElement.attrs.node) ?? []; + return [subscriptionElement.attrs.jid as string, subscribedEvents] as const; + }); + + return new Map(subscriptions); + } +} + +function makeSubscribeRoomStanza(roomJid: string, nick: string, nodes: readonly string[]): Stanza { + return xml('iq', {type: 'set', to: roomJid}, + xml('subscribe', {xmlns: MUC_SUB_FEATURE_ID, nick}, + nodes.map(node => xml('event', {node})) + ) + ); +} + +function makeUnsubscribeRoomStanza(roomJid: string): Stanza { + return xml('iq', {type: 'set', to: roomJid}, + xml('unsubscribe', {xmlns: MUC_SUB_FEATURE_ID}) + ); +} + +function makeRetrieveSubscriptionsStanza(conferenceServiceJid: string): Stanza { + return xml('iq', {type: 'get', to: conferenceServiceJid}, + xml('subscriptions', {xmlns: MUC_SUB_FEATURE_ID}) + ); } diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/multi-user-chat.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/multi-user-chat.plugin.ts index 8e243212..423b6fa6 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/multi-user-chat.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/multi-user-chat.plugin.ts @@ -44,7 +44,7 @@ export interface RoomMetadata { export class Room { readonly recipientType = 'room'; - roomJid: JID; + readonly roomJid: JID; occupantJid: JID; name: string; avatar = dummyAvatarRoom; @@ -74,23 +74,23 @@ export class Room { return this.messageStore.dateMessageGroups; } - get oldestMessage() { + get oldestMessage(): RoomMessage { return this.messageStore.oldestMessage; } - get mostRecentMessage() { + get mostRecentMessage(): RoomMessage { return this.messageStore.mostRecentMessage; } - get mostRecentMessageReceived() { + get mostRecentMessageReceived(): RoomMessage { return this.messageStore.mostRecentMessageReceived; } - get mostRecentMessageSent() { + get mostRecentMessageSent(): RoomMessage { return this.messageStore.mostRecentMessageSent; } - addMessage(message: RoomMessage) { + addMessage(message: RoomMessage): void { this.messageStore.addMessage(message); } @@ -106,7 +106,10 @@ export class Room { class RoomMessageStanzaBuilder extends AbstractStanzaBuilder { - constructor(private roomJid: string, private from: string, private body: string, private thread?: string) { + constructor(private readonly roomJid: string, + private readonly from: string, + private readonly body: string, + private readonly thread?: string) { super(); } @@ -134,7 +137,7 @@ export enum Affiliation { class QueryMemberListStanzaBuilder extends AbstractStanzaBuilder { - constructor(private roomJid: string, private affiliation: string) { + constructor(private readonly roomJid: string, private readonly affiliation: string) { super(); } @@ -165,11 +168,11 @@ export interface RoomSummary { class ModifyMemberListStanzaBuilder extends AbstractStanzaBuilder { - constructor(private roomJid: string, private modifications: MemberlistItem[]) { + constructor(private readonly roomJid: string, private readonly modifications: readonly MemberlistItem[]) { super(); } - static build(roomJid: string, modifications: MemberlistItem[]): Stanza { + static build(roomJid: string, modifications: readonly MemberlistItem[]): Stanza { return new ModifyMemberListStanzaBuilder(roomJid, modifications).toStanza(); } @@ -181,7 +184,7 @@ class ModifyMemberListStanzaBuilder extends AbstractStanzaBuilder { ); } - private buildItem(modification: MemberlistItem) { + private buildItem(modification: MemberlistItem): Element { const item = xml('item', {jid: modification.jid, affiliation: Affiliation[modification.affiliation]}); if (modification.nick) { item.attrs.nick = modification.nick; @@ -197,18 +200,18 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { readonly rooms$ = new BehaviorSubject([]); readonly message$ = new Subject(); - private roomJoinPromises: { [roomAndJid: string]: (stanza: Stanza) => void } = {}; + private roomJoinPromiseHandlers: { [roomAndJid: string]: (stanza: Stanza) => void } = Object.create(null); constructor( - private xmppChatAdapter: XmppChatAdapter, - private logService: LogService, - private serviceDiscoveryPlugin: ServiceDiscoveryPlugin, + private readonly xmppChatAdapter: XmppChatAdapter, + private readonly logService: LogService, + private readonly serviceDiscoveryPlugin: ServiceDiscoveryPlugin, ) { super(); } - onOffline() { - this.roomJoinPromises = {}; + onOffline(): void { + this.roomJoinPromiseHandlers = Object.create(null); this.rooms$.next([]); } @@ -221,18 +224,18 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { return false; } - private isRoomPresenceStanza(stanza: Stanza) { + private isRoomPresenceStanza(stanza: Stanza): boolean { return stanza.name === 'presence' && ( stanza.getChild('x', 'http://jabber.org/protocol/muc') || stanza.getChild('x', 'http://jabber.org/protocol/muc#user') - ); + ) != null; } private handleRoomPresenceStanza(stanza: Stanza): boolean { - const roomJoinPromises = this.roomJoinPromises[stanza.attrs.from]; - if (roomJoinPromises) { - delete this.roomJoinPromises[stanza.attrs.from]; - roomJoinPromises(stanza); + const handleStanza = this.roomJoinPromiseHandlers[stanza.attrs.from]; + if (handleStanza) { + delete this.roomJoinPromiseHandlers[stanza.attrs.from]; + handleStanza(stanza); return true; } return false; @@ -287,7 +290,7 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { } } - async destroyRoom(roomJid: JID) { + async destroyRoom(roomJid: JID): Promise { const roomDestroyedResponse = await this.xmppChatAdapter.chatConnectionService.sendIq( xml('iq', {type: 'set', to: roomJid.toString()}, xml('query', {xmlns: 'http://jabber.org/protocol/muc#owner'}, @@ -308,13 +311,13 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { return roomDestroyedResponse; } - private async joinRoomInternal(roomJid: JID, name?: string | undefined) { + private async joinRoomInternal(roomJid: JID, name?: string | undefined): Promise<{ presenceResponse: Stanza, room: Room }> { if (this.getRoomByJid(roomJid.bare())) { throw new Error('can not join room more than once: ' + roomJid.bare().toString()); } const userJid = this.xmppChatAdapter.chatConnectionService.userJid; const occupantJid = parseJid(roomJid.local, roomJid.domain, roomJid.resource || userJid.local); - const roomJoinedPromise = new Promise(resolve => this.roomJoinPromises[occupantJid.toString()] = resolve); + const roomJoinedPromise = new Promise(resolve => this.roomJoinPromiseHandlers[occupantJid.toString()] = resolve); await this.xmppChatAdapter.chatConnectionService.send( xml('presence', {from: userJid.toString(), to: occupantJid.toString()}, xml('x', {xmlns: 'http://jabber.org/protocol/muc'}), @@ -365,7 +368,7 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { ), ), ); - result.push(...await this.convertRoomQueryResponse(roomResponse)); + result.push(...this.convertRoomQueryResponse(roomResponse)); } return result; } @@ -421,7 +424,7 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { ); } - async sendMessage(room: Room, body: string, thread?: string) { + async sendMessage(room: Room, body: string, thread?: string): Promise { const from = this.xmppChatAdapter.chatConnectionService.userJid; const roomMessageStanza = new RoomMessageStanzaBuilder(room.roomJid.toString(), from.toString(), body, thread) .toStanza(); @@ -433,8 +436,8 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { return await this.xmppChatAdapter.chatConnectionService.send(roomMessageStanza); } - private convertConfiguration(configurationKeyValuePair: { [key: string]: string[] }) { - const configurationFields = []; + private convertConfiguration(configurationKeyValuePair: { [key: string]: string[] }): Element[] { + const configurationFields: Element[] = []; for (const configurationKey in configurationKeyValuePair) { if (configurationKeyValuePair.hasOwnProperty(configurationKey)) { const configurationValues = configurationKeyValuePair[configurationKey].map(value => xml('value', {}, value)); @@ -446,34 +449,33 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { return configurationFields; } - private extractDefaultConfiguration(fields: Element[]) { - const configuration: { [key: string]: string[] } = {}; + private extractDefaultConfiguration(fields: Element[]): Record { + const configuration: Record = {}; for (const field of fields) { configuration[field.attrs.var] = field.getChildren('value').map(value => value.getText()); } return configuration; } - private extractRoomCreationRequestConfiguration(request: RoomCreationOptions): { [key: string]: string[] } { - const configuration: { [key: string]: string[] } = {}; + private extractRoomCreationRequestConfiguration(request: RoomCreationOptions): Record { + const configuration: Record = {}; configuration['muc#roomconfig_whois'] = [request.nonAnonymous ? 'anyone' : 'moderators']; configuration['muc#roomconfig_publicroom'] = [request.public ? '1' : '0']; configuration['muc#roomconfig_membersonly'] = [request.membersOnly ? '1' : '0']; configuration['muc#roomconfig_persistentroom'] = [request.persistentRoom ? '1' : '0']; if (request.allowSubscription !== undefined) { - // tslint:disable-next-line:no-string-literal - configuration['allow_subscription'] = [request.allowSubscription === true ? '1' : '0']; + configuration.allow_subscription = [request.allowSubscription === true ? '1' : '0']; } return configuration; } - private isRoomMessageStanza(stanza: Stanza) { + private isRoomMessageStanza(stanza: Stanza): boolean { return stanza.name === 'message' && stanza.attrs.type === 'groupchat' && !!stanza.getChildText('body')?.trim(); } - handleRoomMessageStanza(messageStanza: Stanza, archiveDelayElement?: Stanza) { + handleRoomMessageStanza(messageStanza: Stanza, archiveDelayElement?: Stanza): boolean { let datetime; const delay = archiveDelayElement ?? messageStanza.getChild('delay'); if (delay && delay.attrs.stamp) { @@ -485,7 +487,14 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { const from = parseJid(messageStanza.attrs.from); const room = this.getRoomByJid(from.bare()); if (!room) { - throw new Error('received stanza for non-existent room: ' + from.bare().toString()); + // there are several reasons why we can receive a message for an unknown room: + // - this is a message delivered via MAM/MUCSub but the room it was stored for + // - is gone (was destroyed) + // - user was banned from room + // - room wasn't joined yet + // - this is some kind of error on developer's side + this.logService.warn(`received stanza for unknown room: ${from.bare().toString()}`); + return false; } const message: RoomMessage = { @@ -495,6 +504,7 @@ export class MultiUserChatPlugin extends AbstractXmppPlugin { from, direction: from.equals(room.occupantJid) ? Direction.out : Direction.in, delayed: !!delay, + fromArchive: archiveDelayElement != null }; const messageReceivedEvent = new MessageReceivedEvent(); diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/publish-subscribe.plugin.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/publish-subscribe.plugin.ts index 62b2dead..7d5250e3 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/publish-subscribe.plugin.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/plugins/publish-subscribe.plugin.ts @@ -19,7 +19,7 @@ interface PublishOptions { class PublishStanzaBuilder extends AbstractStanzaBuilder { - private publishOptions: PublishOptions = { + private readonly publishOptions: PublishOptions = { persistItems: false, }; @@ -63,7 +63,7 @@ class PublishStanzaBuilder extends AbstractStanzaBuilder { class RetrieveDataStanzaBuilder extends AbstractStanzaBuilder { - constructor(private node: string) { + constructor(private readonly node: string) { super(); } @@ -83,10 +83,11 @@ class RetrieveDataStanzaBuilder extends AbstractStanzaBuilder { */ export class PublishSubscribePlugin extends AbstractXmppPlugin { - publish$ = new Subject(); - private supportsPrivatePublish = new BehaviorSubject('unknown'); + readonly publish$ = new Subject(); + private readonly supportsPrivatePublish = new BehaviorSubject('unknown'); - constructor(private xmppChatAdapter: XmppChatAdapter, private serviceDiscoveryPlugin: ServiceDiscoveryPlugin) { + constructor(private readonly xmppChatAdapter: XmppChatAdapter, + private readonly serviceDiscoveryPlugin: ServiceDiscoveryPlugin) { super(); } diff --git a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/xmpp-chat-adapter.service.spec.ts b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/xmpp-chat-adapter.service.spec.ts index 9e355999..a06f5288 100644 --- a/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/xmpp-chat-adapter.service.spec.ts +++ b/projects/pazznetwork/ngx-chat/src/lib/services/adapters/xmpp/xmpp-chat-adapter.service.spec.ts @@ -97,7 +97,7 @@ describe('XmppChatAdapter', () => { done(); }); chatConnectionService.onStanzaReceived( - xml('message', {from: contact1.jidBare.toString()}, + xml('message', {from: contact1.jidBare.toString(), to: chatConnectionService.userJid.toString()}, xml('body', {}, 'message text')) as Stanza); }); @@ -120,7 +120,7 @@ describe('XmppChatAdapter', () => { done(); }); chatConnectionService.onStanzaReceived( - xml('message', {from: contact1.jidBare.toString()}, + xml('message', {from: contact1.jidBare.toString(), to: chatConnectionService.userJid.toString()}, xml('body', {}, 'message text')) as Stanza); }); @@ -145,7 +145,10 @@ describe('XmppChatAdapter', () => { done(); } }); - const sampleMessageStanzaWithId = xml('message', {from: contact1.jidBare.toString()}, + const sampleMessageStanzaWithId = xml('message', { + from: contact1.jidBare.toString(), + to: chatConnectionService.userJid.toString() + }, xml('origin-id', {id: 'id'}), xml('body', {}, 'message text')) as Stanza; chatConnectionService.onStanzaReceived(sampleMessageStanzaWithId); diff --git a/src/app/multi-user-chat/multi-user-chat.component.html b/src/app/multi-user-chat/multi-user-chat.component.html index 7b0d231b..d637e70c 100644 --- a/src/app/multi-user-chat/multi-user-chat.component.html +++ b/src/app/multi-user-chat/multi-user-chat.component.html @@ -19,14 +19,79 @@

Chat rooms:

+ + + + +
    +
  • + {{subscription.key}}: +
      +
    • + {{subscribedNode}} +
    • +
    +
  • +
+ +
+
{{room.name}}: {{room.jid}} + + +
+ +
+ + + +
+
+ +
+ Room id is required +
+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+ You need to be online. diff --git a/src/app/multi-user-chat/multi-user-chat.component.ts b/src/app/multi-user-chat/multi-user-chat.component.ts index 54854a80..a3179e69 100644 --- a/src/app/multi-user-chat/multi-user-chat.component.ts +++ b/src/app/multi-user-chat/multi-user-chat.component.ts @@ -1,5 +1,14 @@ import { Component, Inject, OnInit } from '@angular/core'; -import { ChatService, ChatServiceToken, MultiUserChatPlugin, Room, RoomSummary } from '@pazznetwork/ngx-chat'; +import { + ChatService, + ChatServiceToken, + MultiUserChatPlugin, + Room, + RoomSummary, + RoomCreationOptions, + MucSubPlugin, + MUC_SUB_EVENT_TYPE, +} from '@pazznetwork/ngx-chat'; import { jid } from '@xmpp/client'; @Component({ @@ -10,12 +19,16 @@ import { jid } from '@xmpp/client'; export class MultiUserChatComponent implements OnInit { multiUserChatPlugin: MultiUserChatPlugin; + mucSubPlugin: MucSubPlugin; roomJid: string; selectedRoom: Room; allRooms: RoomSummary[] = []; + newRoom?: RoomCreationOptions; + mucSubSubscriptions: Map = new Map(); constructor(@Inject(ChatServiceToken) public chatService: ChatService) { this.multiUserChatPlugin = chatService.getPlugin(MultiUserChatPlugin); + this.mucSubPlugin = chatService.getPlugin(MucSubPlugin); } ngOnInit() { @@ -26,7 +39,50 @@ export class MultiUserChatComponent implements OnInit { this.selectedRoom = await this.multiUserChatPlugin.joinRoom(occupantJid); } + async subscribeWithMucSub(roomJid: string): Promise { + await this.mucSubPlugin.subscribeRoom(roomJid, [MUC_SUB_EVENT_TYPE.messages]); + } + + async unsubscribeFromMucSub(roomJid: string): Promise { + await this.mucSubPlugin.unsubscribeRoom(roomJid); + } + + async getSubscriptions() { + this.mucSubSubscriptions = await this.mucSubPlugin.retrieveSubscriptions(); + } + + async destroyRoom(roomJid: string) { + const occupantJid = jid(roomJid); + await this.multiUserChatPlugin.destroyRoom(occupantJid); + await this.queryAllRooms(); + } + async queryAllRooms() { this.allRooms = await this.multiUserChatPlugin.queryAllRooms(); } + + createNewRoom(): void { + this.newRoom = { + roomId: '', + membersOnly: true, + nonAnonymous: false, + persistentRoom: true, + public: false, + allowSubscription: true, + }; + } + + cancelRoomCreation(): void { + this.newRoom = null; + } + + async createRoomOnServer() { + if (!this.newRoom?.roomId || this.newRoom.roomId === '') { + return; + } + + await this.multiUserChatPlugin.createRoom(this.newRoom); + + this.newRoom = undefined; + } } diff --git a/src/app/routes/index/index.component.html b/src/app/routes/index/index.component.html index 778fca09..74af2ec3 100644 --- a/src/app/routes/index/index.component.html +++ b/src/app/routes/index/index.component.html @@ -26,7 +26,7 @@ (e.g. test if test@jabber.example.com is your full JID)
- +
diff --git a/src/app/routes/ui/ui.component.ts b/src/app/routes/ui/ui.component.ts index 34dc06dc..d6abd88b 100644 --- a/src/app/routes/ui/ui.component.ts +++ b/src/app/routes/ui/ui.component.ts @@ -95,12 +95,14 @@ export class UiComponent implements OnInit { this.contact.addMessage({ ...message, delayed: false, + fromArchive: false, id: null, }); this.room.addMessage({ ...message, delayed: false, + fromArchive: false, from: message.direction === Direction.in ? this.otherContactJid : this.myJid, }); } diff --git a/src/styles.css b/src/styles.css index e2af02d9..4b836016 100644 --- a/src/styles.css +++ b/src/styles.css @@ -19,6 +19,14 @@ body.has-roster { padding: 0.5em 0; } +.ng-valid[required], .ng-valid.required { + border-left: 5px solid #42A948; /* green */ +} + +.ng-invalid:not(form) { + border-left: 5px solid #a94442; /* red */ +} + .container { margin: 0 auto; max-width: 700px;