Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: bot names and avatars #3071

Merged
merged 8 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>
);
};
61 changes: 61 additions & 0 deletions packages/mgt-chat/src/components/ChatAvatar/ChatAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { Person } from '@microsoft/mgt-react';
import React, { FC, memo, useContext, useEffect, useState } from 'react';
import { botPrefix } from '../../statefulClient/buildBotId';
import { BotInfoContext } from '../Context/BotInfoContext';

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 BotAvatar: FC<ChatAvatarProps> = ({ chatId, avatarId }) => {
const botInfoClient = useContext(BotInfoContext);
const [botInfo, setBotInfo] = useState(() => botInfoClient?.getState());
useEffect(() => {
if (botInfoClient) {
botInfoClient.onStateChange(setBotInfo);
return () => botInfoClient.offStateChange(setBotInfo);
}
}, [botInfoClient]);

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

return (
<div>
{/* {botInfo?.botInfo.has(avatarId) ? botInfo.botInfo.get(avatarId)?.teamsAppDefinition?.displayName || '' : null} */}
<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"
showPresence={false}
/>
</div>
);
};

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);
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;
}
}
50 changes: 50 additions & 0 deletions packages/mgt-chat/src/statefulClient/BotInfoClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { TeamsAppInstallation } from '@microsoft/microsoft-graph-types-beta';
import { BaseStatefulClient } from './BaseStatefulClient';
import { loadBotInChat, loadBotIcon } from './graph.chat';

export interface BotInfo {
botInfo: Map<string, TeamsAppInstallation>;
botIcons: Map<string, string>;
loadBotInfo: (chatId: string, appId: string) => Promise<void>;
}

const requestKey = (chatId: string, appId: string) => `${chatId}::${appId}`;

export class BotInfoClient extends BaseStatefulClient<BotInfo> {
private readonly infoRequestMap = new Map<string, Promise<unknown>>();

public loadBotInfo = async (chatId: string, appId: string) => {
const beta = this.betaGraph;
if (!beta) return;
const key = requestKey(chatId, appId);
if (!this.infoRequestMap.has(key)) {
const requestPromise = loadBotInChat(beta, chatId, appId);
gavinbarron marked this conversation as resolved.
Show resolved Hide resolved
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);
}
});
});
// load the appIcon for each bot
for (const app of botInfo.value) {
const appIcon = await loadBotIcon(beta, app);
gavinbarron marked this conversation as resolved.
Show resolved Hide resolved
this.notifyStateChange((draft: BotInfo) => {
if (app.teamsAppDefinition?.bot?.id) {
draft.botIcons.set(app.teamsAppDefinition?.bot?.id, appIcon);
}
});
}
}
}
};
protected state: BotInfo = {
botInfo: new Map<string, TeamsAppInstallation>(),
botIcons: new Map<string, string>(),
loadBotInfo: this.loadBotInfo
};
}
Loading
Loading