diff --git a/packages/mgt-chat/src/components/Chat/Chat.tsx b/packages/mgt-chat/src/components/Chat/Chat.tsx index 26e536411b..7866e5d9c5 100644 --- a/packages/mgt-chat/src/components/Chat/Chat.tsx +++ b/packages/mgt-chat/src/components/Chat/Chat.tsx @@ -1,12 +1,14 @@ import React, { useEffect, useState } from 'react'; import { ErrorBar, FluentThemeProvider, MessageThread, SendBox } from '@azure/communication-react'; import { Person, PersonCardInteraction, Spinner } from '@microsoft/mgt-react'; -import { FluentTheme } from '@fluentui/react'; +import { FluentTheme, MessageBarType } from '@fluentui/react'; import { FluentProvider, makeStyles, shorthands, teamsLightTheme } from '@fluentui/react-components'; import { useGraphChatClient } from '../../statefulClient/useGraphChatClient'; import ChatHeader from '../ChatHeader/ChatHeader'; +import ChatMessageBar from '../ChatMessageBar/ChatMessageBar'; import { registerAppIcons } from '../styles/registerIcons'; import { ManageChatMembers } from '../ManageChatMembers/ManageChatMembers'; +import { StatefulGraphChatClient } from 'src/statefulClient/StatefulGraphChatClient'; registerAppIcons(); @@ -34,12 +36,18 @@ const useStyles = makeStyles({ }, fullHeight: { height: '100%' + }, + spinner: { + justifyContent: 'center', + display: 'flex', + alignItems: 'center', + height: '100%' } }); export const Chat = ({ chatId }: IMgtChatProps) => { const styles = useStyles(); - const chatClient = useGraphChatClient(chatId); + const chatClient: StatefulGraphChatClient = useGraphChatClient(chatId); const [chatState, setChatState] = useState(chatClient.getState()); useEffect(() => { chatClient.onStateChange(setChatState); @@ -47,11 +55,16 @@ export const Chat = ({ chatId }: IMgtChatProps) => { chatClient.offStateChange(setChatState); }; }, [chatClient]); + + const isLoading = ['creating server connections', 'subscribing to notifications', 'loading messages'].includes( + chatState.status + ); + return (
- {chatState.userId && chatState.messages.length > 0 ? ( + {chatState.userId && chatId && chatState.messages.length > 0 ? ( <> { ) : ( <> - {chatState.status} - + {isLoading && ( +
+
+ {chatState.status} +
+ )} + {chatState.status === 'no messages' && ( + + )} + {chatState.status === 'no chat id' && ( + + )} )}
diff --git a/packages/mgt-chat/src/components/ChatMessageBar/ChatMessageBar.tsx b/packages/mgt-chat/src/components/ChatMessageBar/ChatMessageBar.tsx new file mode 100644 index 0000000000..d6ef57b54b --- /dev/null +++ b/packages/mgt-chat/src/components/ChatMessageBar/ChatMessageBar.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +import { MessageBar, MessageBarType } from '@fluentui/react'; + +interface ChatMessageBarProps { + messageBarType: MessageBarType; + message: string; +} + +const ChatMessageBar = ({ messageBarType, message }: ChatMessageBarProps) => { + return {message}; +}; + +export default ChatMessageBar; diff --git a/packages/mgt-chat/src/components/styles/registerIcons.tsx b/packages/mgt-chat/src/components/styles/registerIcons.tsx index b68bc5eb7d..fc1df83331 100644 --- a/packages/mgt-chat/src/components/styles/registerIcons.tsx +++ b/packages/mgt-chat/src/components/styles/registerIcons.tsx @@ -5,6 +5,7 @@ import { buttonIconStyles } from './common.styles'; const registerAppIcons = () => { const icons = Object.assign(DEFAULT_COMPONENT_ICONS, { + // TODO: Register the info and errorbadge icons 'add-friend': ( { * @memberof StatefulGraphChatClient */ private readonly onLoginStateChanged = (e: LoginChangedEvent) => { - switch (Providers.globalProvider.state) { + switch (e.detail) { case ProviderState.SignedIn: // update userId and displayName this.updateUserInfo(); // load messages? // configure subscriptions // emit new state; - if (this._chatId) { + if (this.chatId) { void this.updateFollowedChat(); } return; @@ -265,23 +268,68 @@ class StatefulGraphChatClient implements StatefulClient { }; private readonly onActiveAccountChanged = (e: ActiveAccountChanged) => { - this.updateUserInfo(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (e.detail && this.userId !== e.detail?.id) { + this.clearCurrentUserMessages(); + void this.closeCurrentSignalRConnections(); + sessionStorage.removeItem('graph-subscriptions'); + void this.reconnectSignalRConnection(); + this.updateUserInfo(); + void this.updateFollowedChat(); + } }; + private async closeCurrentSignalRConnections() { + await this._notificationClient.closeSignalRConnection(); + } + + private async reconnectSignalRConnection() { + await this._notificationClient.reConnectSignalR(); + } + + private clearCurrentUserMessages() { + this.notifyStateChange((draft: GraphChatClient) => { + draft.messages = []; + draft.participants = []; + draft.chat = undefined; + draft.status = 'initial'; // no message? + }); + } + private updateUserInfo() { this.updateCurrentUserId(); this.updateCurrentUserName(); } + /** + * Changes the userDisplayName to the current value. + */ private updateCurrentUserName() { - this._userDisplayName = Providers.globalProvider.getActiveAccount?.().name || ''; + this._userDisplayName = currentUserName(); } + /** + * Changes the current user ID value to the current value. + */ private updateCurrentUserId() { this.userId = currentUserId(); } + /** + * Current User ID. + */ private _userId = ''; + + /** + * Returns the current User ID. + */ + public get userId() { + return this._userId; + } + + /** + * Sets the current User ID and updates the state value. + */ private set userId(userId: string) { if (this._userId === userId) { return; @@ -292,15 +340,30 @@ class StatefulGraphChatClient implements StatefulClient { }); } + /** + * Current chat ID. + */ private _chatId = ''; + /** + * Get the current chat ID. + */ + public get chatId() { + return this._chatId; + } + + /** + * Set the current chat ID and tries to get the chat data. + */ public set chatId(value: string) { // take no action if the chatId is the same - if (this._chatId === value) { + if (value && this._chatId === value) { return; } this._chatId = value; - void this.updateFollowedChat(); + if (this._chatId) { + void this.updateFollowedChat(); + } } /** @@ -310,37 +373,55 @@ class StatefulGraphChatClient implements StatefulClient { * @memberof StatefulGraphChatClient */ private async updateFollowedChat() { - // reset state to initial - this.notifyStateChange((draft: GraphChatClient) => { - draft.status = 'initial'; - draft.messages = []; - draft.chat = undefined; - draft.participants = []; - }); - // Subscribe to notifications for messages - this.notifyStateChange((draft: GraphChatClient) => { - draft.status = 'creating server connections'; - }); - const promises: Promise[] = []; - promises.push(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 - promises.push( - this._notificationClient.subscribeToChatNotifications(this._userId, this._chatId, this._eventEmitter, () => - this.notifyStateChange((draft: GraphChatClient) => { - draft.status = 'subscribing to notifications'; - }) - ) - ); - await Promise.all(promises); + // avoid subscribing to a resource with an empty chatId + if (this.chatId) { + // reset state to initial + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'initial'; + draft.messages = []; + draft.chat = undefined; + draft.participants = []; + }); + // Subscribe to notifications for messages + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'creating server connections'; + }); + + try { + // Prefer sequential promise resolving to catch loading message errors + // TODO: in parallel promise resolving, find out how to trigger different + // TODO: state for failed subscriptions in GraphChatClient.onSubscribeFailed + await this.loadChatData(); + await this._notificationClient.subscribeToChatNotifications(this.userId, this.chatId, this._eventEmitter, () => + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'subscribing to notifications'; + }) + ); + } catch (e) { + console.error('Failed to load chat data or subscribe to notications: ', e); + if (e instanceof GraphError) { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'no messages'; + }); + } + } + } else { + this.notifyStateChange((draft: GraphChatClient) => { + draft.status = 'no chat id'; + }); + } } private async loadChatData() { this.notifyStateChange((draft: GraphChatClient) => { draft.status = 'loading messages'; }); - this._chat = await loadChat(this.graph, this._chatId); - const messages: MessageCollection = await loadChatThread(this.graph, this._chatId, this._messagesPerCall); + try { + this._chat = await loadChat(this.graph, this.chatId); + } catch (error) { + return Promise.reject(error); + } + const messages: MessageCollection = await loadChatThread(this.graph, this.chatId, this._messagesPerCall); await this.writeMessagesToState(messages); } @@ -405,7 +486,7 @@ class StatefulGraphChatClient implements StatefulClient { private convertChatMessage(message: ChatMessage): MessageConversion { switch (message.messageType) { case 'message': - return this.graphChatMessageToAcsChatMessage(message, this._userId); + return this.graphChatMessageToAcsChatMessage(message, this.userId); case 'unknownFutureValue': return { futureValue: this.buildSystemContentMessage(message) }; default: @@ -528,7 +609,7 @@ detail: ${JSON.stringify(eventDetail)}`); content, senderDisplayName: this._userDisplayName, createdOn: new Date(), - senderId: this._userId, + senderId: this.userId, mine: true, status: 'sending' }; @@ -536,11 +617,11 @@ detail: ${JSON.stringify(eventDetail)}`); }); try { // send message - const chat: ChatMessage = await sendChatMessage(this.graph, this._chatId, content); + const chat: ChatMessage = await sendChatMessage(this.graph, this.chatId, content); // emit new state this.notifyStateChange((draft: GraphChatClient) => { const draftIndex = draft.messages.findIndex(m => m.messageId === pendingId); - const message = this.graphChatMessageToAcsChatMessage(chat, this._userId).currentValue; + const message = this.graphChatMessageToAcsChatMessage(chat, this.userId).currentValue; // we only need use the current value of the message // this message can't have a future value as it's not been sent yet if (message) draft.messages.splice(draftIndex, 1, message); @@ -579,7 +660,7 @@ detail: ${JSON.stringify(eventDetail)}`); try { // uncommitted messages are not persisted to the graph, so don't call graph when deleting them if (!uncommitted) { - await deleteChatMessage(this.graph, this._chatId, messageId); + await deleteChatMessage(this.graph, this.chatId, messageId); } this.notifyStateChange((draft: GraphChatClient) => { const draftMessage = draft.messages.find(m => m.messageId === messageId) as AcsChatMessage; @@ -616,7 +697,7 @@ detail: ${JSON.stringify(eventDetail)}`); } }); try { - await updateChatMessage(this.graph, this._chatId, messageId, content); + await updateChatMessage(this.graph, this.chatId, messageId, content); this.notifyStateChange((draft: GraphChatClient) => { const updated = draft.messages.find(m => m.messageId === messageId) as AcsChatMessage; updated.status = 'delivered'; @@ -668,7 +749,7 @@ detail: ${JSON.stringify(eventDetail)}`); }; private readonly checkForMissedMessages = async () => { - const messages: MessageCollection = await loadChatThread(this.graph, this._chatId, this._messagesPerCall); + 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 // deleted messages are returned as messages with no content, which we can't filter on the graph request @@ -706,7 +787,7 @@ detail: ${JSON.stringify(eventDetail)}`); }; private readonly onChatNotificationsSubscribed = (resource: string): void => { - if (resource.includes(`/${this._chatId}/`) && resource.includes('/messages')) { + if (resource.includes(`/${this.chatId}/`) && resource.includes('/messages')) { void this.checkForMissedMessages(); } else { // better clean this up as we don't want to be listening to events for other chats @@ -765,7 +846,7 @@ detail: ${JSON.stringify(eventDetail)}`); } private readonly addChatMembers = async (userIds: string[], history?: Date): Promise => { - await addChatMembers(this.graph, this._chatId, userIds, history); + await addChatMembers(this.graph, this.chatId, userIds, history); }; /** @@ -779,7 +860,7 @@ detail: ${JSON.stringify(eventDetail)}`); if (!membershpId) return; const isPresent = this._chat?.members?.findIndex(m => m.id === membershpId) ?? -1; if (isPresent === -1) return; - await removeChatMember(this.graph, this._chatId, membershpId); + await removeChatMember(this.graph, this.chatId, membershpId); this.removeParticipantFromState(membershpId); }; @@ -889,7 +970,7 @@ detail: ${JSON.stringify(eventDetail)}`); } private readonly renameChat = async (topic: string | null): Promise => { - await updateChatTopic(this.graph, this._chatId, topic); + await updateChatTopic(this.graph, this.chatId, topic); this.notifyStateChange(() => void (this._chat = { ...this._chat, ...{ topic } })); }; diff --git a/packages/mgt-chat/src/utils/currentUser.ts b/packages/mgt-chat/src/utils/currentUser.ts index 9d70f165bc..59888e084c 100644 --- a/packages/mgt-chat/src/utils/currentUser.ts +++ b/packages/mgt-chat/src/utils/currentUser.ts @@ -1,5 +1,7 @@ import { Providers } from '@microsoft/mgt-element'; -const currentUserId = () => Providers.globalProvider.getActiveAccount?.().id.split('.')[0] || ''; +const getCurrentUser = () => Providers.globalProvider.getActiveAccount?.(); +const currentUserId = () => getCurrentUser()?.id.split('.')[0] || ''; +const currentUserName = () => getCurrentUser()?.name || ''; -export { currentUserId }; +export { getCurrentUser, currentUserId, currentUserName }; diff --git a/packages/mgt-element/src/providers/IProvider.ts b/packages/mgt-element/src/providers/IProvider.ts index 0dd2d6a5bd..3780a20b81 100644 --- a/packages/mgt-element/src/providers/IProvider.ts +++ b/packages/mgt-element/src/providers/IProvider.ts @@ -148,7 +148,7 @@ export abstract class IProvider implements AuthenticationProvider { public setState(state: ProviderState) { if (state !== this._state) { this._state = state; - this._loginChangedDispatcher.fire({}); + this._loginChangedDispatcher.fire({ detail: this._state }); } } @@ -211,7 +211,7 @@ export abstract class IProvider implements AuthenticationProvider { */ // eslint-disable-next-line @typescript-eslint/no-unused-vars public setActiveAccount?(user: IProviderAccount) { - this.fireActiveAccountChanged(); + this.fireActiveAccountChanged({ detail: user }); } /** @@ -239,8 +239,8 @@ export abstract class IProvider implements AuthenticationProvider { * * @memberof IProvider */ - private fireActiveAccountChanged() { - this._activeAccountChangedDispatcher.fire({}); + private fireActiveAccountChanged(account: { detail: IProviderAccount }) { + this._activeAccountChangedDispatcher.fire(account); } /** @@ -272,7 +272,9 @@ export abstract class IProvider implements AuthenticationProvider { * @interface ActiveAccountChanged */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ActiveAccountChanged {} +export interface ActiveAccountChanged { + detail: IProviderAccount; +} /** * loginChangedEvent * @@ -280,7 +282,9 @@ export interface ActiveAccountChanged {} * @interface LoginChangedEvent */ // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface LoginChangedEvent {} +export interface LoginChangedEvent { + detail: ProviderState; +} /** * LoginType diff --git a/samples/react-chat/src/App.tsx b/samples/react-chat/src/App.tsx index e13a721aba..b8ddc479ba 100644 --- a/samples/react-chat/src/App.tsx +++ b/samples/react-chat/src/App.tsx @@ -14,14 +14,14 @@ const ChatList = memo(({ chatSelected }: { chatSelected: (e: GraphChat) => void }); function App() { - const [chatId, setChatId] = useState(); + const [chatId, setChatId] = useState(''); const chatSelected = useCallback((e: GraphChat) => { - setChatId(e.id); + setChatId(e.id ?? ''); }, []); const [showNewChat, setShowNewChat] = useState(false); const onChatCreated = useCallback((chat: GraphChat) => { - setChatId(chat.id); + setChatId(chat.id ?? ''); setShowNewChat(false); }, []); @@ -50,7 +50,7 @@ function App() { )} -
{chatId && }
+
{}
); diff --git a/samples/react-chat/src/components/ChatListTemplate/ChatListTemplate.tsx b/samples/react-chat/src/components/ChatListTemplate/ChatListTemplate.tsx index e6d0b33da4..dc219a1220 100644 --- a/samples/react-chat/src/components/ChatListTemplate/ChatListTemplate.tsx +++ b/samples/react-chat/src/components/ChatListTemplate/ChatListTemplate.tsx @@ -6,6 +6,8 @@ import ChatItem, { ChatInteractionProps } from '../ChatItem/ChatItem'; const ChatListTemplate = (props: MgtTemplateProps & ChatInteractionProps) => { const { value } = props.dataContext; const chats: Chat[] = value; + // Select a default chat to display + // props.onSelected(chats[0]); return (