@azure/communication-react
Version:
React library for building modern communication user experiences utilizing Azure Communication Services
465 lines • 19.4 kB
JavaScript
// 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