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 all 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>
);
};
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);
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
Loading