UNPKG

@azure/communication-react

Version:

React library for building modern communication user experiences utilizing Azure Communication Services

465 lines • 19.4 kB
// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { EventEmitter } from 'events'; import { enableMapSet, enablePatches, produce } from 'immer'; import { ChatError } from './ChatClientState'; import { createClientLogger, getLogLevel } from '@azure/logger'; import { _safeJSONStringify, toFlatCommunicationIdentifier } from "../../acs-ui-common/src"; import { Constants } from './constants'; import { chatStatefulLogger } from './Logger'; import { ResourceDownloadQueue, fetchImageSource } from './ResourceDownloadQueue'; enableMapSet(); // Needed to generate state diff for verbose logging. enablePatches(); /** * @internal */ export class ChatContext { constructor(maxListeners, credential, endpoint) { this._state = { userId: { id: '' }, displayName: '', threads: {}, latestErrors: {} }; this._batchMode = false; this.typingIndicatorInterval = undefined; this._inlineImageQueue = undefined; this._fullsizeImageQueue = undefined; this._logger = createClientLogger('communication-react:chat-context'); this._emitter = new EventEmitter(); if (credential) { this._inlineImageQueue = new ResourceDownloadQueue(this, { credential, endpoint: endpoint !== null && endpoint !== void 0 ? endpoint : '' }); this._fullsizeImageQueue = new ResourceDownloadQueue(this, { credential, endpoint: endpoint !== null && endpoint !== void 0 ? endpoint : '' }); } if (maxListeners) { this._emitter.setMaxListeners(maxListeners); } } getState() { return this._state; } modifyState(modifier) { const priorState = this._state; this._state = produce(this._state, modifier, (patches) => { if (getLogLevel() === 'verbose') { // Log to `info` because AzureLogger.verbose() doesn't show up in console. this._logger.info(`State change: ${_safeJSONStringify(patches)}`); } }); if (!this._batchMode && this._state !== priorState) { this._emitter.emit('stateChanged', this._state); } } dispose() { this.modifyState((draft) => { var _a, _b; (_a = this._inlineImageQueue) === null || _a === void 0 ? void 0 : _a.cancelAllRequests(); (_b = this._fullsizeImageQueue) === null || _b === void 0 ? void 0 : _b.cancelAllRequests(); Object.values(draft.threads).forEach(thread => { Object.values(thread.chatMessages).forEach(message => { const cache = message.resourceCache; if (cache) { Object.values(cache).forEach(resource => { if (resource.sourceUrl) { URL.revokeObjectURL(resource.sourceUrl); } }); } message.resourceCache = undefined; }); }); }); // Any item in queue should be removed. } downloadResourceToCache(threadId, messageId, resourceUrl) { return __awaiter(this, void 0, void 0, function* () { var _a; let message = (_a = this.getState().threads[threadId]) === null || _a === void 0 ? void 0 : _a.chatMessages[messageId]; if (message && this._fullsizeImageQueue) { if (!message.resourceCache) { message = Object.assign(Object.assign({}, message), { resourceCache: {} }); } // Need to discuss retry logic in case of failure this._fullsizeImageQueue.addMessage(message); yield this._fullsizeImageQueue.startQueue(threadId, fetchImageSource, { singleUrl: resourceUrl }); } }); } removeResourceFromCache(threadId, messageId, resourceUrl) { this.modifyState((draft) => { var _a, _b, _c; const message = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.chatMessages[messageId]; if (message && this._fullsizeImageQueue && this._fullsizeImageQueue.containsMessageWithSameAttachments(message)) { (_b = this._fullsizeImageQueue) === null || _b === void 0 ? void 0 : _b.cancelRequest(resourceUrl); } else if (message && this._inlineImageQueue && this._inlineImageQueue.containsMessageWithSameAttachments(message)) { (_c = this._inlineImageQueue) === null || _c === void 0 ? void 0 : _c.cancelRequest(resourceUrl); } if (message && message.resourceCache && message.resourceCache[resourceUrl]) { const resource = message.resourceCache[resourceUrl]; if (resource === null || resource === void 0 ? void 0 : resource.sourceUrl) { URL.revokeObjectURL(resource.sourceUrl); } delete message.resourceCache[resourceUrl]; } }); } setThread(threadId, threadState) { this.modifyState((draft) => { draft.threads[threadId] = threadState; }); } createThread(threadId, properties) { this.modifyState((draft) => { draft.threads[threadId] = { chatMessages: {}, threadId: threadId, properties: properties, participants: {}, readReceipts: [], typingIndicators: [], latestReadTime: new Date(0) }; }); } updateChatConfig(userId, displayName) { this.modifyState((draft) => { draft.displayName = displayName; draft.userId = userId; }); } createThreadIfNotExist(threadId, properties) { const exists = Object.prototype.hasOwnProperty.call(this.getState().threads, threadId); if (!exists) { this.createThread(threadId, properties); return true; } return false; } updateThread(threadId, properties) { this.modifyState((draft) => { const thread = draft.threads[threadId]; if (thread) { thread.properties = properties; } }); } updateThreadTopic(threadId, topic) { this.modifyState((draft) => { if (topic === undefined) { return; } const thread = draft.threads[threadId]; if (thread && !thread.properties) { thread.properties = { topic: topic }; } else if (thread && thread.properties) { thread.properties.topic = topic; } }); } deleteThread(threadId) { this.modifyState((draft) => { const thread = draft.threads[threadId]; if (thread) { delete draft.threads[threadId]; } }); } setChatMessages(threadId, messages) { this.modifyState((draft) => { const threadState = draft.threads[threadId]; if (threadState) { threadState.chatMessages = messages; } // remove typing indicator when receive messages const thread = draft.threads[threadId]; if (thread) { for (const message of Object.values(messages)) { this.filterTypingIndicatorForUser(thread, message.sender); } } }); } updateChatMessageContent(threadId, messagesId, content) { this.modifyState((draft) => { var _a; const chatMessage = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.chatMessages[messagesId]; if (chatMessage) { if (!chatMessage.content) { chatMessage.content = {}; } chatMessage.content.message = content; } }); } deleteLocalMessage(threadId, localId) { let localMessageDeleted = false; this.modifyState((draft) => { var _a; const chatMessages = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.chatMessages; const message = chatMessages ? chatMessages[localId] : undefined; if (chatMessages && message && message.clientMessageId) { delete chatMessages[message.clientMessageId]; localMessageDeleted = true; } }); return localMessageDeleted; } deleteMessage(threadId, id) { this.modifyState((draft) => { var _a; const chatMessages = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.chatMessages; if (chatMessages) { delete chatMessages[id]; } }); } setParticipant(threadId, participant) { this.modifyState((draft) => { var _a; const participants = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.participants; if (participants) { participants[toFlatCommunicationIdentifier(participant.id)] = participant; } }); } setParticipants(threadId, participants) { this.modifyState((draft) => { var _a; const participantsMap = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.participants; if (participantsMap) { for (const participant of participants) { participantsMap[toFlatCommunicationIdentifier(participant.id)] = participant; } } }); } deleteParticipants(threadId, participantIds) { this.modifyState((draft) => { var _a; const participants = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.participants; if (participants) { participantIds.forEach(id => { delete participants[toFlatCommunicationIdentifier(id)]; }); } }); } deleteParticipant(threadId, participantId) { this.modifyState((draft) => { var _a; const participants = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.participants; if (participants) { delete participants[toFlatCommunicationIdentifier(participantId)]; } }); } addReadReceipt(threadId, readReceipt) { this.modifyState((draft) => { const thread = draft.threads[threadId]; const readReceipts = thread === null || thread === void 0 ? void 0 : thread.readReceipts; if (thread && readReceipts) { // TODO(prprabhu): Replace `this.getState()` with `draft`? if (readReceipt.sender !== this.getState().userId && thread.latestReadTime < readReceipt.readOn) { thread.latestReadTime = readReceipt.readOn; } readReceipts.push(readReceipt); } }); } startTypingIndicatorCleanUp() { if (this.typingIndicatorInterval) { return; } this.typingIndicatorInterval = window.setInterval(() => { let isTypingActive = false; this.modifyState((draft) => { for (const thread of Object.values(draft.threads)) { const filteredTypingIndicators = thread.typingIndicators.filter(typingIndicator => { const timeGap = Date.now() - typingIndicator.receivedOn.getTime(); return timeGap < Constants.TYPING_INDICATOR_MAINTAIN_TIME; }); if (thread.typingIndicators.length !== filteredTypingIndicators.length) { thread.typingIndicators = filteredTypingIndicators; } if (thread.typingIndicators.length > 0) { isTypingActive = true; } } }); if (!isTypingActive && this.typingIndicatorInterval) { window.clearInterval(this.typingIndicatorInterval); this.typingIndicatorInterval = undefined; } }, 1000); } addTypingIndicator(threadId, typingIndicator) { this.modifyState((draft) => { const thread = draft.threads[threadId]; if (thread) { const typingIndicators = thread.typingIndicators; typingIndicators.push(typingIndicator); } }); // Make sure we only maintain a period of typing indicator for perf purposes this.startTypingIndicatorCleanUp(); } setChatMessage(threadId, message) { this.parseAttachments(threadId, message); const { id: messageId, clientMessageId } = message; if (messageId || clientMessageId) { this.modifyState((draft) => { var _a; const threadMessages = (_a = draft.threads[threadId]) === null || _a === void 0 ? void 0 : _a.chatMessages; const isLocalIdInMap = threadMessages && clientMessageId && threadMessages[clientMessageId]; const messageKey = !messageId || isLocalIdInMap ? clientMessageId : messageId; if (threadMessages && messageKey) { threadMessages[messageKey] = message; } // remove typing indicator when receive a message from a user const thread = draft.threads[threadId]; if (thread) { this.filterTypingIndicatorForUser(thread, message.sender); } }); } } parseAttachments(threadId, message) { var _a; const attachments = (_a = message.content) === null || _a === void 0 ? void 0 : _a.attachments; if (message.type === 'html' && attachments && attachments.length > 0) { if (this._inlineImageQueue && !this._inlineImageQueue.containsMessageWithSameAttachments(message) && message.resourceCache === undefined) { // Need to discuss retry logic in case of failure this._inlineImageQueue.addMessage(message); this._inlineImageQueue.startQueue(threadId, fetchImageSource); } } } /** * Tees any errors encountered in an async function to the state. * * @param f Async function to execute. * @param target The error target to tee error to. * @returns Result of calling `f`. Also re-raises any exceptions thrown from `f`. * @throws ChatError. Exceptions thrown from `f` are tagged with the failed `target. */ withAsyncErrorTeedToState(f, target) { return (...args) => __awaiter(this, void 0, void 0, function* () { try { return yield f(...args); } catch (error) { const chatError = toChatError(target, error); this.setLatestError(target, chatError); throw chatError; } }); } /** * Tees any errors encountered in an function to the state. * * @param f Function to execute. * @param target The error target to tee error to. * @returns Result of calling `f`. Also re-raises any exceptions thrown from `f`. * @throws ChatError. Exceptions thrown from `f` are tagged with the failed `target. */ withErrorTeedToState(f, target) { return (...args) => { try { chatStatefulLogger.info(`Chat stateful client target function called: ${target}`); return f(...args); } catch (error) { const chatError = toChatError(target, error); this.setLatestError(target, chatError); throw chatError; } }; } setLatestError(target, error) { this.modifyState((draft) => { draft.latestErrors[target] = error; }); } // This is a mutating function, only use it inside of a produce() function filterTypingIndicatorForUser(thread, userId) { if (!userId) { return; } const typingIndicators = thread.typingIndicators; const userIdAsKey = toFlatCommunicationIdentifier(userId); const filteredTypingIndicators = typingIndicators.filter(typingIndicator => toFlatCommunicationIdentifier(typingIndicator.sender) !== userIdAsKey); if (filteredTypingIndicators.length !== typingIndicators.length) { thread.typingIndicators = filteredTypingIndicators; } } /** * Batch updates to minimize `stateChanged` events across related operations. * * - A maximum of one `stateChanged` event is emitted, at the end of the operations. * - No `stateChanged` event is emitted if the state did not change through the operations. * - In case of an exception, state is reset to the prior value and no `stateChanged` event is emitted. * * All operations finished in this batch should be synchronous. * This function is not reentrant -- do not call batch() from within another batch(). */ batch(operations) { if (this._batchMode) { throw new Error('batch() called from within another batch()'); } this._batchMode = true; const priorState = this._state; try { operations(); if (this._state !== priorState) { this._emitter.emit('stateChanged', this._state); } } catch (e) { this._state = priorState; if (getLogLevel() === 'verbose') { this._logger.warning(`State rollback to: ${_safeJSONStringify(priorState)}`); } throw e; } finally { this._batchMode = false; } } onStateChange(handler) { this._emitter.on('stateChanged', handler); } offStateChange(handler) { this._emitter.off('stateChanged', handler); } } const toChatError = (target, error) => { if (error instanceof Error) { return new ChatError(target, error); } return new ChatError(target, new Error(`${error}`)); }; //# sourceMappingURL=ChatContext.js.map