Skip to content

Commit

Permalink
feat: show bot names and avatars in conversation (#3071)
Browse files Browse the repository at this point in the history
sets up a provider model for getting Bot based data
refactor to create a base class for stateful clients
creates a new component for rendering chat avatars that can handle bots

fix: stop creating two instances of the notification client

fix: remove contenteditable attribute from emjoi html
  • Loading branch information
gavinbarron authored Mar 5, 2024
1 parent f78d197 commit c42aa19
Show file tree
Hide file tree
Showing 19 changed files with 626 additions and 276 deletions.
159 changes: 84 additions & 75 deletions packages/mgt-chat/src/components/Chat/Chat.tsx
Original file line number Diff line number Diff line change
@@ -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();

Expand Down Expand Up @@ -79,7 +83,8 @@ const messageThreadStyles: MessageThreadStyles = {
zIndex: 'unset',
'& div[data-ui-status]': {
display: 'inline-flex',
justifyContent: 'center'
justifyContent: 'center',
flexDirection: 'column'
}
}
},
Expand All @@ -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 () => {
Expand All @@ -124,74 +133,74 @@ export const Chat = ({ chatId }: IMgtChatProps) => {
const placeholderText = disabled ? 'You cannot send a message' : 'Type a message...';

return (
<FluentThemeProvider fluentTheme={FluentTheme}>
<FluentProvider id="fluentui" theme={webLightTheme} className={styles.fullHeight}>
<div className={styles.chat}>
<ChatHeader chatState={chatState} />
{chatState.userId && chatId && chatState.messages.length > 0 ? (
<>
<div className={styles.chatMessages}>
<MessageThread
userId={chatState.userId}
messages={chatState.messages}
showMessageDate={true}
disableEditing={chatState.disableEditing}
numberOfChatMessagesToReload={chatState.numberOfChatMessagesToReload}
onLoadPreviousChatMessages={chatState.onLoadPreviousChatMessages}
// TODO: Messages date rendering is behind beta flag, find out how to enable it
// onDisplayDateTimeString={(date: Date) => date.toISOString()}
<BotInfoContext.Provider value={botInfoClient}>
<FluentThemeProvider fluentTheme={FluentTheme}>
<FluentProvider id="fluentui" theme={webLightTheme} className={styles.fullHeight}>
<div className={styles.chat}>
<ChatHeader chatState={chatState} />
{chatState.userId && chatId && chatState.messages.length > 0 ? (
<>
<div className={styles.chatMessages}>
<MessageThread
userId={chatState.userId}
messages={chatState.messages}
showMessageDate={true}
disableEditing={chatState.disableEditing}
numberOfChatMessagesToReload={chatState.numberOfChatMessagesToReload}
onLoadPreviousChatMessages={chatState.onLoadPreviousChatMessages}
// TODO: Messages date rendering is behind beta flag, find out how to enable it
// onDisplayDateTimeString={(date: Date) => 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 (
<Person userId={userId} avatarSize="small" personCardInteraction="hover" showPresence={true} />
);
}}
styles={messageThreadStyles}
mentionOptions={{
displayOptions: {
onRenderMention: renderMGTMention(chatState)
}
}}
onRenderMessage={onRenderMessage}
/>
</div>
<div className={styles.chatInput}>
<SendBox onSendMessage={chatState.onSendMessage} strings={{ placeholderText }} />
</div>
</>
) : (
<>
{isLoading && (
<div className={styles.spinner}>
<Spinner /> <br />
{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 ? <ChatAvatar chatId={chatId} avatarId={userId} /> : <></>;
}}
styles={messageThreadStyles}
mentionOptions={{
displayOptions: {
onRenderMention: renderMGTMention(chatState)
}
}}
onRenderMessage={onRenderMessage}
/>
</div>
<div className={styles.chatInput}>
<SendBox onSendMessage={chatState.onSendMessage} strings={{ placeholderText }} />
</div>
</>
) : (
<>
{isLoading && (
<div className={styles.spinner}>
<Spinner /> <br />
{chatState.status}
</div>
)}
{chatState.status === 'no messages' && (
<Error
icon={LoadingMessagesErrorIcon}
message="No messages were found for this chat."
subheading={TypeANewMessage}
></Error>
)}
{chatState.status === 'no chat id' && (
<Error message="No chat id has been provided." subheading={RequireValidChatId}></Error>
)}
{chatState.status === 'error' && (
<Error message="We're sorry—we've run into an issue.." subheading={OpenTeamsLinkError}></Error>
)}
<div className={styles.chatInput}>
<SendBox disabled={disabled} onSendMessage={chatState.onSendMessage} strings={{ placeholderText }} />
</div>
)}
{chatState.status === 'no messages' && (
<Error
icon={LoadingMessagesErrorIcon}
message="No messages were found for this chat."
subheading={TypeANewMessage}
></Error>
)}
{chatState.status === 'no chat id' && (
<Error message="No chat id has been provided." subheading={RequireValidChatId}></Error>
)}
{chatState.status === 'error' && (
<Error message="We're sorry—we've run into an issue.." subheading={OpenTeamsLinkError}></Error>
)}
<div className={styles.chatInput}>
<SendBox disabled={disabled} onSendMessage={chatState.onSendMessage} strings={{ placeholderText }} />
</div>
</>
)}
</div>
</FluentProvider>
</FluentThemeProvider>
</>
)}
</div>
</FluentProvider>
</FluentThemeProvider>
</BotInfoContext.Provider>
);
};
36 changes: 36 additions & 0 deletions packages/mgt-chat/src/components/ChatAvatar/BotAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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<BotAvatarProps> = ({ chatId, avatarId, view = 'image' }) => {
const botInfo = useBotInfo();

useEffect(() => {
if (chatId && avatarId && !botInfo?.botInfo.has(avatarId)) {
void botInfo?.loadBotInfo(chatId, avatarId);
}
}, [chatId, avatarId, botInfo]);

return (
<div>
<Person
personDetails={{
id: avatarId,
displayName: botInfo?.botInfo.has(avatarId)
? botInfo.botInfo.get(avatarId)?.teamsAppDefinition?.displayName
: '',
personImage: botInfo?.botIcons.has(avatarId) ? botInfo.botIcons.get(avatarId) : ''
}}
avatarSize="small"
personCardInteraction="none"
view={view}
showPresence={false}
/>
</div>
);
};
26 changes: 26 additions & 0 deletions packages/mgt-chat/src/components/ChatAvatar/ChatAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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<ChatAvatarProps> = ({ chatId, avatarId }) =>
avatarId.startsWith(botPrefix) ? (
<BotAvatar chatId={chatId} avatarId={avatarId.replace(botPrefix, '')} />
) : (
<Person userId={avatarId} avatarSize="small" personCardInteraction="hover" showPresence={true} />
);

export const ChatAvatar = memo(AvatarSwitcher);
42 changes: 32 additions & 10 deletions packages/mgt-chat/src/components/ChatHeader/OneToOneChatHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 ? (
<Person
className={styles.person}
userId={id}
view="oneline"
avatarSize="small"
personCardInteraction="hover"
showPresence={true}
/>
) : null;
if (!chat?.id) return null;
if (id) {
return (
<Person
className={styles.person}
userId={id}
view="oneline"
avatarSize="small"
personCardInteraction="hover"
showPresence={true}
/>
);
} else if (botInfo?.chatBots.has(chat.id)) {
return (
<>
{Array.from(botInfo.chatBots.get(chat.id)!).map(bot =>
bot.teamsAppDefinition?.bot?.id ? (
<BotAvatar
key={bot.teamsAppDefinition.bot.id}
chatId={chat.id!}
avatarId={bot.teamsAppDefinition.bot.id}
view="oneline"
/>
) : null
)}
</>
);
}
return null;
};
4 changes: 4 additions & 0 deletions packages/mgt-chat/src/components/Context/BotInfoContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { createContext } from 'react';
import { BotInfoClient } from '../../statefulClient/BotInfoClient';

export const BotInfoContext = createContext<BotInfoClient | undefined>(undefined);
75 changes: 75 additions & 0 deletions packages/mgt-chat/src/statefulClient/BaseStatefulClient.ts
Original file line number Diff line number Diff line change
@@ -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<T> implements StatefulClient<T> {
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;
}
}
Loading

0 comments on commit c42aa19

Please sign in to comment.