diff --git a/packages/mgt-chat/src/components/Chat/Chat.tsx b/packages/mgt-chat/src/components/Chat/Chat.tsx index 0fc57cc71a..3dce6875cb 100644 --- a/packages/mgt-chat/src/components/Chat/Chat.tsx +++ b/packages/mgt-chat/src/components/Chat/Chat.tsx @@ -1,19 +1,23 @@ import { FluentThemeProvider, MessageThread, SendBox, MessageThreadStyles } from '@azure/communication-react'; import { FluentTheme } from '@fluentui/react'; import { FluentProvider, makeStyles, shorthands, webLightTheme } from '@fluentui/react-components'; -import { Person, Spinner } from '@microsoft/mgt-react'; +import { Spinner } from '@microsoft/mgt-react'; +import { enableMapSet } from 'immer'; import React, { useEffect, useState } from 'react'; -import { StatefulGraphChatClient } from '../../statefulClient/StatefulGraphChatClient'; -import { useGraphChatClient } from '../../statefulClient/useGraphChatClient'; -import { onRenderMessage } from '../../utils/chat'; -import { renderMGTMention } from '../../utils/mentions'; -import { registerAppIcons } from '../styles/registerIcons'; +import { ChatAvatar } from '../ChatAvatar/ChatAvatar'; import { ChatHeader } from '../ChatHeader/ChatHeader'; +import { BotInfoContext } from '../Context/BotInfoContext'; import { Error } from '../Error/Error'; import { LoadingMessagesErrorIcon } from '../Error/LoadingMessageErrorIcon'; import { OpenTeamsLinkError } from '../Error/OpenTeams'; import { RequireValidChatId } from '../Error/RequireAValidChatId'; import { TypeANewMessage } from '../Error/TypeANewMessage'; +import { registerAppIcons } from '../styles/registerIcons'; +import { BotInfoClient } from '../../statefulClient/BotInfoClient'; +import { StatefulGraphChatClient } from '../../statefulClient/StatefulGraphChatClient'; +import { useGraphChatClient } from '../../statefulClient/useGraphChatClient'; +import { onRenderMessage } from '../../utils/chat'; +import { renderMGTMention } from '../../utils/mentions'; registerAppIcons(); @@ -79,7 +83,8 @@ const messageThreadStyles: MessageThreadStyles = { zIndex: 'unset', '& div[data-ui-status]': { display: 'inline-flex', - justifyContent: 'center' + justifyContent: 'center', + flexDirection: 'column' } } }, @@ -106,9 +111,13 @@ const messageThreadStyles: MessageThreadStyles = { }; export const Chat = ({ chatId }: IMgtChatProps) => { + useEffect(() => { + enableMapSet(); + }, []); const styles = useStyles(); const chatClient: StatefulGraphChatClient = useGraphChatClient(chatId); - const [chatState, setChatState] = useState(chatClient.getState()); + const [botInfoClient] = useState(() => new BotInfoClient()); + const [chatState, setChatState] = useState(() => chatClient.getState()); useEffect(() => { chatClient.onStateChange(setChatState); return () => { @@ -124,74 +133,74 @@ export const Chat = ({ chatId }: IMgtChatProps) => { const placeholderText = disabled ? 'You cannot send a message' : 'Type a message...'; return ( - - -
- - {chatState.userId && chatId && chatState.messages.length > 0 ? ( - <> -
- date.toISOString()} + + + +
+ + {chatState.userId && chatId && chatState.messages.length > 0 ? ( + <> +
+ date.toISOString()} - // current behavior for re-send is a delete call with the clientMessageId and the a new send call - onDeleteMessage={chatState.onDeleteMessage} - onSendMessage={chatState.onSendMessage} - onUpdateMessage={chatState.onUpdateMessage} - // render props - onRenderAvatar={(userId?: string) => { - return ( - - ); - }} - styles={messageThreadStyles} - mentionOptions={{ - displayOptions: { - onRenderMention: renderMGTMention(chatState) - } - }} - onRenderMessage={onRenderMessage} - /> -
-
- -
- - ) : ( - <> - {isLoading && ( -
-
- {chatState.status} + // current behavior for re-send is a delete call with the clientMessageId and the a new send call + onDeleteMessage={chatState.onDeleteMessage} + onSendMessage={chatState.onSendMessage} + onUpdateMessage={chatState.onUpdateMessage} + // render props + onRenderAvatar={(userId?: string) => { + return userId ? : <>; + }} + styles={messageThreadStyles} + mentionOptions={{ + displayOptions: { + onRenderMention: renderMGTMention(chatState) + } + }} + onRenderMessage={onRenderMessage} + /> +
+
+ +
+ + ) : ( + <> + {isLoading && ( +
+
+ {chatState.status} +
+ )} + {chatState.status === 'no messages' && ( + + )} + {chatState.status === 'no chat id' && ( + + )} + {chatState.status === 'error' && ( + + )} +
+
- )} - {chatState.status === 'no messages' && ( - - )} - {chatState.status === 'no chat id' && ( - - )} - {chatState.status === 'error' && ( - - )} -
- -
- - )} -
-
-
+ + )} +
+ + + ); }; diff --git a/packages/mgt-chat/src/components/ChatAvatar/BotAvatar.tsx b/packages/mgt-chat/src/components/ChatAvatar/BotAvatar.tsx new file mode 100644 index 0000000000..7790025bfe --- /dev/null +++ b/packages/mgt-chat/src/components/ChatAvatar/BotAvatar.tsx @@ -0,0 +1,36 @@ +import { Person, ViewType } from '@microsoft/mgt-react'; +import React, { FC, useEffect } from 'react'; +import { useBotInfo } from '../../statefulClient/useBotInfo'; +import { ChatAvatarProps } from './ChatAvatar'; + +type BotAvatarProps = ChatAvatarProps & { + view?: ViewType; +}; + +export const BotAvatar: FC = ({ chatId, avatarId, view = 'image' }) => { + const botInfo = useBotInfo(); + + useEffect(() => { + if (chatId && avatarId && !botInfo?.botInfo.has(avatarId)) { + void botInfo?.loadBotInfo(chatId, avatarId); + } + }, [chatId, avatarId, botInfo]); + + return ( +
+ +
+ ); +}; diff --git a/packages/mgt-chat/src/components/ChatAvatar/ChatAvatar.tsx b/packages/mgt-chat/src/components/ChatAvatar/ChatAvatar.tsx new file mode 100644 index 0000000000..2d459929f4 --- /dev/null +++ b/packages/mgt-chat/src/components/ChatAvatar/ChatAvatar.tsx @@ -0,0 +1,26 @@ +import { Person } from '@microsoft/mgt-react'; +import React, { FC, memo } from 'react'; +import { botPrefix } from '../../statefulClient/buildBotId'; +import { BotAvatar } from './BotAvatar'; + +export interface ChatAvatarProps { + /** + * The chat id + */ + chatId: string; + /** + * The id of the entity to get the avatar for + * for bots this is prefixed with 'botId::' + * + */ + avatarId: string; +} + +const AvatarSwitcher: FC = ({ chatId, avatarId }) => + avatarId.startsWith(botPrefix) ? ( + + ) : ( + + ); + +export const ChatAvatar = memo(AvatarSwitcher); diff --git a/packages/mgt-chat/src/components/ChatHeader/OneToOneChatHeader.tsx b/packages/mgt-chat/src/components/ChatHeader/OneToOneChatHeader.tsx index c64478dd2a..57ef5cc848 100644 --- a/packages/mgt-chat/src/components/ChatHeader/OneToOneChatHeader.tsx +++ b/packages/mgt-chat/src/components/ChatHeader/OneToOneChatHeader.tsx @@ -3,6 +3,8 @@ import { AadUserConversationMember, Chat } from '@microsoft/microsoft-graph-type import { Person } from '@microsoft/mgt-react'; import { ChatHeaderProps } from './ChatTitle'; import { makeStyles } from '@fluentui/react-components'; +import { useBotInfo } from '../../statefulClient/useBotInfo'; +import { BotAvatar } from '../ChatAvatar/BotAvatar'; const getOtherParticipantUserId = (chat?: Chat, currentUserId = '') => (chat?.members as AadUserConversationMember[])?.find(m => m.userId !== currentUserId)?.userId; @@ -14,16 +16,36 @@ const useStyles = makeStyles({ } }); export const OneToOneChatHeader = ({ chat, currentUserId }: ChatHeaderProps) => { + const botInfo = useBotInfo(); const styles = useStyles(); const id = getOtherParticipantUserId(chat, currentUserId); - return id ? ( - - ) : null; + if (!chat?.id) return null; + if (id) { + return ( + + ); + } else if (botInfo?.chatBots.has(chat.id)) { + return ( + <> + {Array.from(botInfo.chatBots.get(chat.id)!).map(bot => + bot.teamsAppDefinition?.bot?.id ? ( + + ) : null + )} + + ); + } + return null; }; diff --git a/packages/mgt-chat/src/components/Context/BotInfoContext.ts b/packages/mgt-chat/src/components/Context/BotInfoContext.ts new file mode 100644 index 0000000000..6dba5ec4fc --- /dev/null +++ b/packages/mgt-chat/src/components/Context/BotInfoContext.ts @@ -0,0 +1,4 @@ +import { createContext } from 'react'; +import { BotInfoClient } from '../../statefulClient/BotInfoClient'; + +export const BotInfoContext = createContext(undefined); diff --git a/packages/mgt-chat/src/statefulClient/BaseStatefulClient.ts b/packages/mgt-chat/src/statefulClient/BaseStatefulClient.ts new file mode 100644 index 0000000000..9003b2c5ba --- /dev/null +++ b/packages/mgt-chat/src/statefulClient/BaseStatefulClient.ts @@ -0,0 +1,75 @@ +import { BetaGraph, IGraph, Providers } from '@microsoft/mgt-element'; +import { produce } from 'immer'; +import { StatefulClient } from './StatefulClient'; +import { graph } from '../utils/graph'; +import { GraphConfig } from './GraphConfig'; + +export abstract class BaseStatefulClient implements StatefulClient { + private _graph: IGraph | undefined; + + protected get graph(): IGraph | undefined { + if (this._graph) return this._graph; + if (Providers.globalProvider?.graph) { + this._graph = graph('mgt-chat', GraphConfig.version); + } + return this._graph; + } + + protected set graph(value: IGraph | undefined) { + this._graph = value; + } + + protected get betaGraph(): BetaGraph | undefined { + const g = this.graph; + if (g) return BetaGraph.fromGraph(g); + + return undefined; + } + + private _subscribers: ((state: T) => void)[] = []; + /** + * Register a callback to receive state updates + * + * @param {(state: GraphChatClient) => void} handler + * @memberof StatefulGraphChatClient + */ + public onStateChange(handler: (state: T) => void): void { + if (!this._subscribers.includes(handler)) { + this._subscribers.push(handler); + } + } + + /** + * Unregister a callback from receiving state updates + * + * @param {(state: GraphChatClient) => void} handler + * @memberof StatefulGraphChatClient + */ + public offStateChange(handler: (state: T) => void): void { + const index = this._subscribers.indexOf(handler); + if (index !== -1) { + this._subscribers = this._subscribers.splice(index, 1); + } + } + + /** + * Calls each subscriber with the next state to be emitted + * + * @param recipe - a function which produces the next state to be emitted + */ + protected notifyStateChange(recipe: (draft: T) => void) { + this.state = produce(this.state, recipe); + this._subscribers.forEach(handler => handler(this.state)); + } + + protected abstract state: T; + /** + * Return the current state of the chat client + * + * @return {{GraphChatClient} + * @memberof StatefulGraphChatClient + */ + public getState(): T { + return this.state; + } +} diff --git a/packages/mgt-chat/src/statefulClient/BotInfoClient.ts b/packages/mgt-chat/src/statefulClient/BotInfoClient.ts new file mode 100644 index 0000000000..b41fb34f1a --- /dev/null +++ b/packages/mgt-chat/src/statefulClient/BotInfoClient.ts @@ -0,0 +1,56 @@ +import { TeamsAppInstallation } from '@microsoft/microsoft-graph-types-beta'; +import { BaseStatefulClient } from './BaseStatefulClient'; +import { loadBotInChat, loadBotIcon } from './graph.chat'; + +export interface BotInfo { + botInfo: Map; + chatBots: Map>; + botIcons: Map; + loadBotInfo: (chatId: string, botId: string) => Promise; +} + +const requestKey = (chatId: string, botId: string) => `${chatId}::${botId}`; + +export class BotInfoClient extends BaseStatefulClient { + private readonly infoRequestMap = new Map>(); + + public loadBotInfo = async (chatId: string, botId: string) => { + const beta = this.betaGraph; + if (!beta) return; + const key = requestKey(chatId, botId); + if (!this.infoRequestMap.has(key)) { + const requestPromise = loadBotInChat(beta, chatId, botId); + this.infoRequestMap.set(key, requestPromise); + const botInfo = await requestPromise; + if (botInfo?.value.length > 0) { + // update state with the text info + this.notifyStateChange((draft: BotInfo) => { + botInfo.value.forEach(app => { + if (app.teamsAppDefinition?.bot?.id) { + draft.botInfo.set(app.teamsAppDefinition?.bot?.id, app); + if (!draft.chatBots.has(chatId)) { + draft.chatBots.set(chatId, new Set()); + } + draft.chatBots.get(chatId)?.add(app); + } + }); + }); + // load the appIcon for each bot + for (const app of botInfo.value) { + const appIcon = await loadBotIcon(beta, app); + this.notifyStateChange((draft: BotInfo) => { + if (app.teamsAppDefinition?.bot?.id) { + draft.botIcons.set(app.teamsAppDefinition?.bot?.id, appIcon); + } + }); + } + } + } + }; + protected state: BotInfo = { + botInfo: new Map(), + chatBots: new Map>(), + botIcons: new Map(), + loadBotInfo: this.loadBotInfo + }; +} diff --git a/packages/mgt-chat/src/statefulClient/GraphNotificationClient.ts b/packages/mgt-chat/src/statefulClient/GraphNotificationClient.ts index 953c790e66..054bf9742c 100644 --- a/packages/mgt-chat/src/statefulClient/GraphNotificationClient.ts +++ b/packages/mgt-chat/src/statefulClient/GraphNotificationClient.ts @@ -63,7 +63,7 @@ export class GraphNotificationClient { return this._graph; } private get beta() { - return BetaGraph.fromGraph(this._graph); + return this._graph ? BetaGraph.fromGraph(this._graph) : undefined; } private get subscriptionGraph() { return GraphConfig.useCanary @@ -76,7 +76,7 @@ export class GraphNotificationClient { */ constructor( private readonly emitter: ThreadEventEmitter, - private readonly _graph: IGraph + private readonly _graph: IGraph | undefined ) { // start the cleanup timer when we create the notification client. this.startCleanupTimer(); @@ -198,8 +198,10 @@ export class GraphNotificationClient { log('subscribing to changes for ' + resourcePath); const subscriptionEndpoint = GraphConfig.subscriptionEndpoint; + const subscriptionGraph = this.subscriptionGraph; + if (!subscriptionGraph) return; // send subscription POST to Graph - const subscription: Subscription = (await this.subscriptionGraph + const subscription: Subscription = (await subscriptionGraph .api(subscriptionEndpoint) .post(subscriptionDefinition)) as Subscription; if (!subscription?.notificationUrl) throw new Error('Subscription not created'); @@ -277,10 +279,10 @@ export class GraphNotificationClient { public renewSubscription = async (subscriptionId: string, expirationDateTime: string): Promise => { // PATCH /subscriptions/{id} - const renewedSubscription = (await this.graph.api(`${GraphConfig.subscriptionEndpoint}/${subscriptionId}`).patch({ + const renewedSubscription = (await this.graph?.api(`${GraphConfig.subscriptionEndpoint}/${subscriptionId}`).patch({ expirationDateTime - })) as Subscription; - return this.cacheSubscription(renewedSubscription); + })) as Subscription | undefined; + if (renewedSubscription) return this.cacheSubscription(renewedSubscription); }; public async createSignalRConnection(notificationUrl: string) { @@ -311,7 +313,7 @@ export class GraphNotificationClient { private async deleteSubscription(id: string) { try { - await this.graph.api(`${GraphConfig.subscriptionEndpoint}/${id}`).delete(); + await this.graph?.api(`${GraphConfig.subscriptionEndpoint}/${id}`).delete(); } catch (e) { error(e); } diff --git a/packages/mgt-chat/src/statefulClient/StatefulClient.ts b/packages/mgt-chat/src/statefulClient/StatefulClient.ts new file mode 100644 index 0000000000..ac1fea1ad1 --- /dev/null +++ b/packages/mgt-chat/src/statefulClient/StatefulClient.ts @@ -0,0 +1,18 @@ +export interface StatefulClient { + /** + * Get the current state of the client + */ + getState(): T; + /** + * Register a callback to receive state updates + * + * @param handler Callback to receive state updates + */ + onStateChange(handler: (state: T) => void): void; + /** + * Remove a callback from receiving state updates + * + * @param handler Callback to be unregistered + */ + offStateChange(handler: (state: T) => void): void; +} diff --git a/packages/mgt-chat/src/statefulClient/StatefulGraphChatClient.ts b/packages/mgt-chat/src/statefulClient/StatefulGraphChatClient.ts index 3ea946eae6..dddf3a7bac 100644 --- a/packages/mgt-chat/src/statefulClient/StatefulGraphChatClient.ts +++ b/packages/mgt-chat/src/statefulClient/StatefulGraphChatClient.ts @@ -16,7 +16,6 @@ import { import { IDynamicPerson, getUserWithPhoto } from '@microsoft/mgt-components'; import { ActiveAccountChanged, - IGraph, LoginChangedEvent, ProviderState, Providers, @@ -35,7 +34,6 @@ import { ChatMessageMention, NullableOption } from '@microsoft/microsoft-graph-types'; -import { produce } from 'immer'; import { v4 as uuid } from 'uuid'; import { currentUserId, currentUserName } from '../utils/currentUser'; import { graph } from '../utils/graph'; @@ -60,6 +58,85 @@ import { import { updateMessageContentWithImage } from '../utils/updateMessageContentWithImage'; import { GraphChatClientStatus, isChatMessage } from '../utils/types'; import { rewriteEmojiContentToHTML } from '../utils/rewriteEmojiContent'; +import { buildBotId } from './buildBotId'; +import { BaseStatefulClient } from './BaseStatefulClient'; + +const hasUnsupportedContent = (content: string, attachments: ChatMessageAttachment[]): boolean => { + const unsupportedContentTypes = [ + 'application/vnd.microsoft.card.codesnippet', + 'application/vnd.microsoft.card.fluid', + 'application/vnd.microsoft.card.fluidEmbedCard', + 'reference' + ]; + const isUnsupported: boolean[] = []; + + if (attachments.length) { + for (const attachment of attachments) { + const contentType = attachment?.contentType ?? ''; + isUnsupported.push(unsupportedContentTypes.includes(contentType)); + } + } else { + // checking content with tags + const unsupportedContentRegex = /<\/?attachment>/gim; + const contentUnsupported = Boolean(content) && unsupportedContentRegex.test(content); + isUnsupported.push(contentUnsupported); + } + return isUnsupported.every(e => e === true); +}; + +const buildAcsMessage = ( + graphMessage: ChatMessage, + currentUser: string, + messageId: string, + content: string +): GraphChatMessage => { + const senderId = graphMessage.from?.user?.id ?? buildBotId(graphMessage.from?.application) ?? undefined; + const chatId = graphMessage?.chatId ?? ''; + const id = graphMessage?.id ?? ''; + const chatUrl = `https://teams.microsoft.com/l/message/${chatId}/${id}?context={"contextType":"chat"}`; + const attachments = graphMessage?.attachments ?? []; + + let messageData: GraphChatMessage = { + messageId, + contentType: graphMessage.body?.contentType ?? 'text', + messageType: 'chat', + content, + senderDisplayName: graphMessage.from?.user?.displayName ?? graphMessage.from?.application?.displayName ?? undefined, + createdOn: new Date(graphMessage.createdDateTime ?? Date.now()), + editedOn: graphMessage.lastEditedDateTime ? new Date(graphMessage.lastEditedDateTime) : undefined, + senderId, + mine: senderId === currentUser, + status: 'seen', + attached: 'top', + hasUnsupportedContent: hasUnsupportedContent(content, attachments), + rawChatUrl: chatUrl + }; + if (graphMessage?.policyViolation) { + messageData = Object.assign(messageData, { + messageType: 'blocked', + link: 'https://go.microsoft.com/fwlink/?LinkId=2132837' + }); + } + return messageData; +}; + +/** + * Teams mentions are in the pattern User. This replacement + * changes the mentions pattern to User + * which will trigger the `mentionOptions` prop to be called in MessageThread. + * + * @param content is the message with mentions. + * @returns string with replaced mention parts. + */ +const updateMentionsContent = (content: string): string => { + const msftMention = `$2`; + const atRegex = /([a-z0-9_.-\s]+)<\/at>/gim; + content = content + .replace(/  /gim, 'at>') + .replace(atRegex, msftMention); + return content; +}; // 1x1 grey pixel const placeholderImageContent = @@ -115,25 +192,6 @@ export type GraphChatClient = Pick< activeErrorMessages: Error[]; }; -interface StatefulClient { - /** - * Get the current state of the client - */ - getState(): T; - /** - * Register a callback to receive state updates - * - * @param handler Callback to receive state updates - */ - onStateChange(handler: (state: T) => void): void; - /** - * Remove a callback from receiving state updates - * - * @param handler Callback to be unregistered - */ - offStateChange(handler: (state: T) => void): void; -} - interface CreatedOn { createdOn: Date; } @@ -185,12 +243,10 @@ interface MessageConversion { */ const graphImageUrlRegex = /(]+)src=(["']https:\/\/graph\.microsoft\.com[^"']*["'])/; -class StatefulGraphChatClient implements StatefulClient { +class StatefulGraphChatClient extends BaseStatefulClient { private readonly _eventEmitter: ThreadEventEmitter; private readonly _cache: MessageCache; - private _graph: IGraph = Providers.globalProvider.graph; - private _notificationClient: GraphNotificationClient; - private _subscribers: ((state: GraphChatClient) => void)[] = []; + private _notificationClient: GraphNotificationClient | undefined; private get _messagesPerCall() { return 5; } @@ -199,12 +255,12 @@ class StatefulGraphChatClient implements StatefulClient { private _userDisplayName = ''; constructor() { + super(); this.updateUserInfo(); Providers.globalProvider.onStateChanged(this.onLoginStateChanged); Providers.globalProvider.onActiveAccountChanged(this.onActiveAccountChanged); this._eventEmitter = new ThreadEventEmitter(); this.registerEventListeners(); - this._notificationClient = new GraphNotificationClient(this._eventEmitter, this._graph); this._cache = new MessageCache(); } @@ -214,7 +270,7 @@ class StatefulGraphChatClient implements StatefulClient { * and won't throw a TypeError when you access graph.forComponent. */ private updateGraphNotificationClient() { - this._graph = graph('mgt-chat', GraphConfig.version); + this.graph = graph('mgt-chat', GraphConfig.version); this._notificationClient = new GraphNotificationClient(this._eventEmitter, this.graph); } @@ -225,51 +281,6 @@ class StatefulGraphChatClient implements StatefulClient { this._notificationClient?.tearDown(); } - /** - * Register a callback to receive state updates - * - * @param {(state: GraphChatClient) => void} handler - * @memberof StatefulGraphChatClient - */ - public onStateChange(handler: (state: GraphChatClient) => void): void { - if (!this._subscribers.includes(handler)) { - this._subscribers.push(handler); - } - } - - /** - * Unregister a callback from receiving state updates - * - * @param {(state: GraphChatClient) => void} handler - * @memberof StatefulGraphChatClient - */ - public offStateChange(handler: (state: GraphChatClient) => void): void { - const index = this._subscribers.indexOf(handler); - if (index !== -1) { - this._subscribers = this._subscribers.splice(index, 1); - } - } - - /** - * Calls each subscriber with the next state to be emitted - * - * @param recipe - a function which produces the next state to be emitted - */ - private notifyStateChange(recipe: (draft: GraphChatClient) => void) { - this._state = produce(this._state, recipe); - this._subscribers.forEach(handler => handler(this._state)); - } - - /** - * Return the current state of the chat client - * - * @return {{GraphChatClient} - * @memberof StatefulGraphChatClient - */ - public getState(): GraphChatClient { - return this._state; - } - /** * Update the state of the client when the Login state changes * @@ -444,8 +455,10 @@ class StatefulGraphChatClient implements StatefulClient { const tasks: Promise[] = [this.loadChatData()]; // subscribing to notifications will trigger the chatMessageNotificationsSubscribed event // this client will then load the chat and messages when that event listener is called - tasks.push(this._notificationClient.subscribeToChatNotifications(this._chatId, this._sessionId)); - await Promise.all(tasks); + if (this._notificationClient) { + tasks.push(this._notificationClient.subscribeToChatNotifications(this._chatId, this._sessionId)); + await Promise.all(tasks); + } } catch (e) { this.updateErrorState(e as Error); } @@ -458,6 +471,13 @@ class StatefulGraphChatClient implements StatefulClient { } private async loadChatData() { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } this.notifyStateChange((draft: GraphChatClient) => { draft.status = 'loading messages'; }); @@ -486,6 +506,13 @@ class StatefulGraphChatClient implements StatefulClient { } private async loadDeltaData(chatId: string, lastModified: string): Promise { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + throw new Error('Graph client not available'); + } const result: ChatMessage[] = []; let response = await loadChatThreadDelta(this.graph, chatId, lastModified, this._messagesPerCall); result.push(...response.value); @@ -597,6 +624,13 @@ class StatefulGraphChatClient implements StatefulClient { }; private async buildSystemContentMessage(message: ChatMessage): Promise { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + throw new Error('Graph client not available'); + } const eventDetail = message.eventDetail as ChatMessageEvents; let messageContent = ''; const awaits: Promise[] = []; @@ -669,6 +703,13 @@ detail: ${JSON.stringify(eventDetail)}`); if (!this._nextLink) { return true; } + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + throw new Error('Graph client not available'); + } const messages: MessageCollection = await loadMoreChatMessages(this.graph, this._nextLink); await this._cache.cacheMessages(this._chatId, messages.value, true, messages.nextLink); await this.writeMessagesToState(messages); @@ -685,6 +726,14 @@ detail: ${JSON.stringify(eventDetail)}`); public sendMessage = async (content: string) => { if (!content) return; + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } + const pendingId = uuid(); // add a pending message to the state. @@ -739,9 +788,17 @@ detail: ${JSON.stringify(eventDetail)}`); */ public deleteMessage = async (messageId: string): Promise => { if (!messageId) return; - const message = this._state.messages.find(m => m.messageId === messageId) as AcsChatMessage; + + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } + const message = this.state.messages.find(m => m.messageId === messageId) as AcsChatMessage; // only messages not persisted to graph should have a clientMessageId - const uncommitted = this._state.messages.find( + const uncommitted = this.state.messages.find( m => (m as AcsChatMessage).clientMessageId === messageId ) as AcsChatMessage; if (message?.mine) { @@ -775,7 +832,15 @@ detail: ${JSON.stringify(eventDetail)}`); */ public updateMessage = async (messageId: string, content: string) => { if (!messageId || !content) return; - const message = this._state.messages.find(m => m.messageId === messageId) as AcsChatMessage; + + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } + const message = this.state.messages.find(m => m.messageId === messageId) as AcsChatMessage; if (message?.mine && message.content) { this.notifyStateChange((draft: GraphChatClient) => { const updating = draft.messages.find(m => m.messageId === messageId) as AcsChatMessage; @@ -859,6 +924,13 @@ detail: ${JSON.stringify(eventDetail)}`); }; private readonly checkForMissedMessages = async () => { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } const messages: MessageCollection = await loadChatThread(this.graph, this.chatId, this._messagesPerCall); const messageConversions = messages.value // trying to filter out messages on the graph request causes a 400 @@ -887,7 +959,7 @@ detail: ${JSON.stringify(eventDetail)}`); }); } const hasOverlapWithExistingMessages = messages.value.some(m => - this._state.messages.find(sm => sm.messageId === m.id) + this.state.messages.find(sm => sm.messageId === m.id) ); if (!hasOverlapWithExistingMessages) { // TODO handle the case where there were a lot of missed messages and we ned to get the next page of messages. @@ -956,6 +1028,13 @@ detail: ${JSON.stringify(eventDetail)}`); } private readonly addChatMembers = async (userIds: string[], history?: Date): Promise => { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } await addChatMembers(this.graph, this.chatId, userIds, history); }; @@ -968,6 +1047,13 @@ detail: ${JSON.stringify(eventDetail)}`); */ private readonly removeChatMember = async (membershpId: string): Promise => { if (!membershpId) return; + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + return; + } const isPresent = this._chat?.members?.findIndex(m => m.id === membershpId) ?? -1; if (isPresent === -1) return; await removeChatMember(this.graph, this.chatId, membershpId); @@ -979,6 +1065,13 @@ detail: ${JSON.stringify(eventDetail)}`); } private processMessageContent(graphMessage: ChatMessage, currentUser: string): MessageConversion { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); + }); + throw new Error('Graph client not available'); + } const conversion: MessageConversion = {}; // using a record here lets us track which image in the content each request is for const futureImages: Record> = {}; @@ -998,7 +1091,7 @@ detail: ${JSON.stringify(eventDetail)}`); index++; match = this.graphImageMatch(messageResult); } - let placeholderMessage = this.buildAcsMessage(graphMessage, currentUser, messageId, messageResult); + let placeholderMessage = buildAcsMessage(graphMessage, currentUser, messageId, messageResult); conversion.currentValue = placeholderMessage; // local function to update the message with data from each of the resolved image requests const updateMessage = async () => { @@ -1029,96 +1122,26 @@ detail: ${JSON.stringify(eventDetail)}`); let result: MessageConversion = {}; content = rewriteEmojiContentToHTML(content); // Handle any mentions in the content - content = this.updateMentionsContent(content); + content = updateMentionsContent(content); const imageMatch = this.graphImageMatch(content ?? ''); if (imageMatch) { // if the message contains an image, we need to fetch the image and replace the placeholder result = this.processMessageContent(graphMessage, currentUser); } else { - result.currentValue = this.buildAcsMessage(graphMessage, currentUser, messageId, content); + result.currentValue = buildAcsMessage(graphMessage, currentUser, messageId, content); } return result; } - /** - * Teams mentions are in the pattern User. This replacement - * changes the mentions pattern to User - * which will trigger the `mentionOptions` prop to be called in MessageThread. - * - * @param content is the message with mentions. - * @returns string with replaced mention parts. - */ - private updateMentionsContent(content: string): string { - const msftMention = `$2`; - const atRegex = /([a-z0-9_.-\s]+)<\/at>/gim; - content = content - .replace(/  /gim, 'at>') - .replace(atRegex, msftMention); - return content; - } - - private hasUnsupportedContent(content: string, attachments: ChatMessageAttachment[]): boolean { - const unsupportedContentTypes = [ - 'application/vnd.microsoft.card.codesnippet', - 'application/vnd.microsoft.card.fluid', - 'application/vnd.microsoft.card.fluidEmbedCard', - 'reference' - ]; - const isUnsupported: boolean[] = []; - - if (attachments.length) { - for (const attachment of attachments) { - const contentType = attachment?.contentType ?? ''; - isUnsupported.push(unsupportedContentTypes.includes(contentType)); - } - } else { - // checking content with tags - const unsupportedContentRegex = /<\/?attachment>/gim; - const contentUnsupported = Boolean(content) && unsupportedContentRegex.test(content); - isUnsupported.push(contentUnsupported); - } - return isUnsupported.every(e => e === true); - } - - private buildAcsMessage( - graphMessage: ChatMessage, - currentUser: string, - messageId: string, - content: string - ): GraphChatMessage { - const senderId = graphMessage.from?.user?.id || undefined; - const chatId = graphMessage?.chatId ?? ''; - const id = graphMessage?.id ?? ''; - const chatUrl = `https://teams.microsoft.com/l/message/${chatId}/${id}?context={"contextType":"chat"}`; - const attachments = graphMessage?.attachments ?? []; - - let messageData: GraphChatMessage = { - messageId, - contentType: graphMessage.body?.contentType ?? 'text', - messageType: 'chat', - content, - senderDisplayName: graphMessage.from?.user?.displayName ?? undefined, - createdOn: new Date(graphMessage.createdDateTime ?? Date.now()), - editedOn: graphMessage.lastEditedDateTime ? new Date(graphMessage.lastEditedDateTime) : undefined, - senderId, - mine: senderId === currentUser, - status: 'seen', - attached: 'top', - hasUnsupportedContent: this.hasUnsupportedContent(content, attachments), - rawChatUrl: chatUrl - }; - if (graphMessage?.policyViolation) { - messageData = Object.assign(messageData, { - messageType: 'blocked', - link: 'https://go.microsoft.com/fwlink/?LinkId=2132837' + private readonly renameChat = async (topic: string | null): Promise => { + if (!this.graph) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'error'; + draft.activeErrorMessages.push(new Error('Graph client not available')); }); + return; } - return messageData; - } - - private readonly renameChat = async (topic: string | null): Promise => { await updateChatTopic(this.graph, this.chatId, topic); this.notifyStateChange(() => void (this._chat = { ...this._chat, ...{ topic } })); }; @@ -1162,18 +1185,6 @@ detail: ${JSON.stringify(eventDetail)}`); }); }; - /** - * Provided the graph instance for the component with the correct SDK version decoration - * - * @readonly - * @private - * @type {IGraph} - * @memberof StatefulGraphChatClient - */ - private get graph(): IGraph { - return graph('mgt-chat'); - } - private readonly _initialState: GraphChatClient = { status: 'initial', userId: '', @@ -1201,7 +1212,7 @@ detail: ${JSON.stringify(eventDetail)}`); * @type {GraphChatClient} * @memberof StatefulGraphChatClient */ - private _state: GraphChatClient = { ...this._initialState }; + protected readonly state: GraphChatClient = { ...this._initialState }; } export { StatefulGraphChatClient }; diff --git a/packages/mgt-chat/src/statefulClient/buildBotId.ts b/packages/mgt-chat/src/statefulClient/buildBotId.ts new file mode 100644 index 0000000000..8af7d7ebab --- /dev/null +++ b/packages/mgt-chat/src/statefulClient/buildBotId.ts @@ -0,0 +1,6 @@ +import { NullableOption, Identity } from '@microsoft/microsoft-graph-types'; + +export const botPrefix = 'botId::'; + +export const buildBotId = (application: NullableOption | undefined) => + application?.id ? `${botPrefix}${application.id}` : undefined; diff --git a/packages/mgt-chat/src/statefulClient/chatOperationScopes.tests.ts b/packages/mgt-chat/src/statefulClient/chatOperationScopes.tests.ts index 9b49e05cae..5db2cb343a 100644 --- a/packages/mgt-chat/src/statefulClient/chatOperationScopes.tests.ts +++ b/packages/mgt-chat/src/statefulClient/chatOperationScopes.tests.ts @@ -22,7 +22,9 @@ describe('chatOperationScopes tests', () => { 'Mail.ReadBasic', 'Contacts.Read', 'Chat.ReadWrite', - 'ChatMember.ReadWrite' + 'ChatMember.ReadWrite', + 'TeamsAppInstallation.ReadForChat', + 'AppCatalog.Read.All' ]; expect(allChatScopes).to.have.members(expectedScopes); }); diff --git a/packages/mgt-chat/src/statefulClient/chatOperationScopes.ts b/packages/mgt-chat/src/statefulClient/chatOperationScopes.ts index d34e234e1d..2f7a7c6da1 100644 --- a/packages/mgt-chat/src/statefulClient/chatOperationScopes.ts +++ b/packages/mgt-chat/src/statefulClient/chatOperationScopes.ts @@ -9,12 +9,17 @@ import { getMgtPersonCardScopes } from '@microsoft/mgt-components/dist/es6/expor /** * The lowest count of scopes required to perform all chat operations */ -export const minimalChatScopesList = ['ChatMember.ReadWrite', 'Chat.ReadWrite']; +export const minimalChatScopesList = [ + 'ChatMember.ReadWrite', + 'Chat.ReadWrite', + 'TeamsAppInstallation.ReadForChat', + 'AppCatalog.Read.All' +]; /** * Object mapping chat operations to the scopes required to perform them */ -export const chatOperationScopes: Record = { +export const chatOperationScopesMin: Record = { loadChat: ['Chat.ReadWrite'], loadChatMessages: ['Chat.ReadWrite'], loadChatImage: ['Chat.ReadWrite'], @@ -23,14 +28,16 @@ export const chatOperationScopes: Record = { deleteChatMessage: ['Chat.ReadWrite'], removeChatMember: ['ChatMember.ReadWrite'], addChatMember: ['ChatMember.ReadWrite'], - createChat: ['Chat.ReadWrite'] + createChat: ['Chat.ReadWrite'], + loadBotsInChat: ['TeamsAppInstallation.ReadForChat'], + loadBotIcon: ['AppCatalog.Read.All'] }; /** * Object mapping chat operations to the scopes required to perform them * This should be used when we migrate this code to use scope aware requests */ -export const chatOperationScopesFullListing: Record = { +export const chatOperationScopes: Record = { loadChat: ['Chat.ReadBasic', 'Chat.Read', 'Chat.ReadWrite'], loadChatMessages: ['Chat.Read', 'Chat.ReadWrite'], loadChatImage: ['ChannelMessage.Read.All', 'Chat.Read', 'Chat.ReadWrite', 'Group.Read.All', 'Group.ReadWrite.All'], @@ -39,7 +46,13 @@ export const chatOperationScopesFullListing: Record = { deleteChatMessage: ['ChannelMessage.ReadWrite', 'Chat.ReadWrite'], removeChatMember: ['ChatMember.ReadWrite'], addChatMember: ['ChatMember.ReadWrite', 'Chat.ReadWrite'], - createChat: ['Chat.Create', 'Chat.ReadWrite'] + createChat: ['Chat.Create', 'Chat.ReadWrite'], + loadBotsInChat: [ + 'TeamsAppInstallation.ReadForChat', + 'TeamsAppInstallation.ReadWriteSelfForChat', + 'TeamsAppInstallation.ReadWriteForChat' + ], + loadBotIcon: ['AppCatalog.Read.All', 'AppCatalog.ReadWrite.All', 'AppCatalog.Submit'] }; /** diff --git a/packages/mgt-chat/src/statefulClient/graph.chat.ts b/packages/mgt-chat/src/statefulClient/graph.chat.ts index 1bc71f1e4a..028277937e 100644 --- a/packages/mgt-chat/src/statefulClient/graph.chat.ts +++ b/packages/mgt-chat/src/statefulClient/graph.chat.ts @@ -12,10 +12,11 @@ import { storePhotoInCache, blobToBase64 } from '@microsoft/mgt-components'; -import { CacheService, IGraph, prepScopes } from '@microsoft/mgt-element'; +import { BetaGraph, CacheService, IGraph, prepScopes } from '@microsoft/mgt-element'; import { ResponseType } from '@microsoft/microsoft-graph-client'; import { AadUserConversationMember, Chat, ChatMessage } from '@microsoft/microsoft-graph-types'; import { chatOperationScopes } from './chatOperationScopes'; +import { TeamsAppInstallation } from '@microsoft/microsoft-graph-types-beta'; /** * Generic collection response from graph @@ -31,6 +32,8 @@ export interface GraphCollection { */ export type MessageCollection = GraphCollection; +export type AppCollection = GraphCollection; + /** * Load the specified chat from graph with the members expanded * @@ -69,6 +72,55 @@ export const loadChatThread = async ( return response; }; +/** + * Load the app information for the specified botId in a given chat + * + * @param graph {BetaGraph} - authenticated graph client from mgt for the beta endpoints + * @param chatId {string} - the id of the chat to load apps for + * @param botId {string} - the id of the bot to load apps for + * @returns {Promise} - a collection of apps installed in the chat + */ +export const loadBotInChat = async (graph: BetaGraph, chatId: string, botId: string): Promise => { + const response = (await graph + .api(`/chats/${chatId}/installedApps`) + .expand('teamsApp,teamsAppDefinition($expand=bot,colorIcon)') + .filter(`teamsAppDefinition/bot/id+eq+'${botId}'`) + .middlewareOptions(prepScopes(chatOperationScopes.loadBotsInChat)) + .get()) as AppCollection; + return response; +}; + +export const loadBotIcon = async (graph: BetaGraph, installedApp: TeamsAppInstallation): Promise => { + if (installedApp.teamsApp?.distributionMethod === 'store') { + return loadStoreBotIcon(installedApp); + } + return loadLobBotIcon(graph, installedApp); +}; + +const loadLobBotIcon = async (graph: BetaGraph, installedApp: TeamsAppInstallation): Promise => { + // GET /appCatalogs/teamsApps/{teams-app-id}/appDefinitions/{app-definition-id}/colorIcon/hostedContent/$value + if (installedApp.teamsApp?.id && installedApp.teamsAppDefinition?.id) { + const teamsAppId = installedApp.teamsApp.id; + const appDefinitionId = installedApp.teamsAppDefinition.id; + const response = (await graph + .api(`/appCatalogs/teamsApps/${teamsAppId}/appDefinitions/${appDefinitionId}/colorIcon/hostedContent/$value`) + .responseType(ResponseType.RAW) + .middlewareOptions(prepScopes(chatOperationScopes.loadBotIcon)) + .get()) as Response; + + return await blobToBase64(await response.blob()); + } + return ''; +}; + +const loadStoreBotIcon = async (installedApp: TeamsAppInstallation): Promise => { + if (!installedApp.teamsAppDefinition?.colorIcon?.webUrl) return ''; + + const response = await fetch(installedApp.teamsAppDefinition.colorIcon.webUrl); + + return await blobToBase64(await response.blob()); +}; + /** * Load the first page of messages from the specified chat which were modified after the specified timestamp * Will provide a nextLink to load more messages if there are more than the specified messageCount diff --git a/packages/mgt-chat/src/statefulClient/useBotInfo.ts b/packages/mgt-chat/src/statefulClient/useBotInfo.ts new file mode 100644 index 0000000000..7f30cd8fa2 --- /dev/null +++ b/packages/mgt-chat/src/statefulClient/useBotInfo.ts @@ -0,0 +1,14 @@ +import { useContext, useEffect, useState } from 'react'; +import { BotInfoContext } from '../components/Context/BotInfoContext'; + +export const useBotInfo = () => { + const botInfoClient = useContext(BotInfoContext); + const [botInfo, setBotInfo] = useState(() => botInfoClient?.getState()); + useEffect(() => { + if (botInfoClient) { + botInfoClient.onStateChange(setBotInfo); + return () => botInfoClient.offStateChange(setBotInfo); + } + }, [botInfoClient]); + return botInfo; +}; diff --git a/packages/mgt-chat/src/utils/reduceScopes.tests.ts b/packages/mgt-chat/src/utils/reduceScopes.tests.ts index df103372b4..be756e10e0 100644 --- a/packages/mgt-chat/src/utils/reduceScopes.tests.ts +++ b/packages/mgt-chat/src/utils/reduceScopes.tests.ts @@ -66,6 +66,11 @@ describe('reduceScopes tests', () => { const actual = reduceScopes(input); // NOTE: the idea result here is ['user.read', 'user.write'] // however the given result results in a higher scope count and not higher effective permissions - await expect(actual).to.eql(['Chat.ReadWrite', 'ChatMember.ReadWrite']); + await expect(actual).to.eql([ + 'Chat.ReadWrite', + 'ChatMember.ReadWrite', + 'TeamsAppInstallation.ReadForChat', + 'AppCatalog.Read.All' + ]); }); }); diff --git a/packages/mgt-chat/src/utils/rewriteEmojiContent.tests.ts b/packages/mgt-chat/src/utils/rewriteEmojiContent.tests.ts index a8424ae13a..3e34e0ecbf 100644 --- a/packages/mgt-chat/src/utils/rewriteEmojiContent.tests.ts +++ b/packages/mgt-chat/src/utils/rewriteEmojiContent.tests.ts @@ -12,20 +12,20 @@ describe('rewrite emoji to standard HTML', () => { it('rewrites an emoji correctly', async () => { const result = rewriteEmojiContentToHTML(``); await expect(result).to.be.equal( - `😎` + `😎` ); }); it('rewrites an emoji in a p tag correctly', async () => { const result = rewriteEmojiContentToHTML(`

`); await expect(result).to.be.equal( - `

😎

` + `

😎

` ); }); it('rewrites an emoji in a p tag with additional content correctly', async () => { const result = rewriteEmojiContentToHTML(`

Hello

`); await expect(result).to.be.equal( - `

Hello 😎

` + `

Hello 😎

` ); }); @@ -34,7 +34,7 @@ describe('rewrite emoji to standard HTML', () => { `

` ); await expect(result).to.be.equal( - `

😍

πŸ€ͺ

😎

` + `

😍

πŸ€ͺ

😎

` ); }); }); diff --git a/packages/mgt-chat/src/utils/rewriteEmojiContent.ts b/packages/mgt-chat/src/utils/rewriteEmojiContent.ts index 57b23434df..4e3e4bc80d 100644 --- a/packages/mgt-chat/src/utils/rewriteEmojiContent.ts +++ b/packages/mgt-chat/src/utils/rewriteEmojiContent.ts @@ -39,7 +39,6 @@ export const rewriteEmojiContentToHTML = (content: string): string => { const title = emoji.getAttribute('title') ?? ''; const span = document.createElement('span'); - span.setAttribute('contentEditable', 'false'); span.setAttribute('title', title); span.setAttribute('type', `(${id})`); span.setAttribute('class', `animated-emoticon-${size}-cool`); diff --git a/packages/mgt-element/src/Graph.ts b/packages/mgt-element/src/Graph.ts index 7b757d7ead..f8be913f04 100644 --- a/packages/mgt-element/src/Graph.ts +++ b/packages/mgt-element/src/Graph.ts @@ -147,7 +147,7 @@ export class Graph implements IGraph { * @returns {Graph} * @memberof Graph */ -export const createFromProvider = (provider: IProvider, version?: string, component?: Element | string): Graph => { +export const createFromProvider = (provider: IProvider, version?: string, component?: Element | string): IGraph => { const middleware: Middleware[] = [ new AuthenticationHandler(provider), new RetryHandler(new RetryHandlerOptions()),