Skip to content

Commit

Permalink
chore: updating the chat list component for performance and design (#…
Browse files Browse the repository at this point in the history
…2838)

* Update chat list behavior and look and feel

* Review comments

* Updating deps.

* refactor of resourceUrl calculation to common function

* refactor to ensure a proper name for the memoized component

---------

Co-authored-by: Gavin Barron <gavinbarron@microsoft.com>
  • Loading branch information
sebastienlevert and gavinbarron authored Nov 14, 2023
1 parent 1c16883 commit 3c71e91
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 105 deletions.
1 change: 1 addition & 0 deletions samples/react-contoso/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"@fluentui/react": "^8.0.0",
"@fluentui/react-components": "^9.19.1",
"@fluentui/react-hooks": "^8.2.0",
"@fluentui/react-northstar": "^0.66.4",
"@fluentui/react-theme-provider": "^0.18.5",
"@microsoft/mgt-element": "*",
"@microsoft/mgt-msal2-provider": "*",
Expand Down
43 changes: 21 additions & 22 deletions samples/react-contoso/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,29 @@ const useStyles = makeStyles({
}
});

const getPreviousDate = (months: number) => {
const date = new Date();
date.setMonth(date.getMonth() - months);
return date.toISOString();
};

const nextResourceUrl = () =>
`me/chats?$expand=members,lastMessagePreview&$orderBy=lastMessagePreview/createdDateTime desc&$filter=viewpoint/lastMessageReadDateTime ge ${getPreviousDate(
9
)}`;

const ChatPage: React.FunctionComponent = () => {
const styles = useStyles();

const [resourceUrl, setResourceUrl] = React.useState(nextResourceUrl);

const [selectedChat, setSelectedChat] = React.useState<GraphChat>();
const [isNewChatOpen, setIsNewChatOpen] = React.useState(false);

const chatSelected = (e: GraphChat) => {
const onChatCreated = (e: GraphChat) => {
if (e.id !== selectedChat?.id && isNewChatOpen) {
setIsNewChatOpen(false);
setResourceUrl(nextResourceUrl);
}
setSelectedChat(e);
};
Expand All @@ -91,7 +106,7 @@ const ChatPage: React.FunctionComponent = () => {
<DialogBody className={styles.dialog}>
<DialogTitle>New Chat</DialogTitle>
<NewChat
onChatCreated={chatSelected}
onChatCreated={onChatCreated}
onCancelClicked={() => {
setIsNewChatOpen(false);
}}
Expand All @@ -100,7 +115,7 @@ const ChatPage: React.FunctionComponent = () => {
</DialogSurface>
</Dialog>
</div>
<ChatList chatSelected={selectedChat} onChatSelected={setSelectedChat}></ChatList>
<ChatList onChatSelected={setSelectedChat} resourceUrl={resourceUrl}></ChatList>
</div>
<div className={styles.side}>{selectedChat && <Chat chatId={selectedChat.id!}></Chat>}</div>
</div>
Expand All @@ -110,29 +125,13 @@ const ChatPage: React.FunctionComponent = () => {

interface ChatListProps {
onChatSelected: (e: GraphChat) => void;
chatSelected: GraphChat | undefined;
resourceUrl: string;
}

const ChatList = React.memo((props: ChatListProps) => {
const getPreviousDate = (months: number) => {
const date = new Date();
date.setMonth(date.getMonth() - months);
return date.toISOString();
};

return (
<Get
resource={`me/chats?$expand=members,lastMessagePreview&$orderBy=lastMessagePreview/createdDateTime desc&$filter=viewpoint/lastMessageReadDateTime ge ${getPreviousDate(
9
)}`}
scopes={['chat.read']}
cacheEnabled={true}
>
<ChatListTemplate
template="default"
onSelected={props.onChatSelected}
selectedChat={props.chatSelected}
></ChatListTemplate>
<Get resource={props.resourceUrl} scopes={['chat.read']}>
<ChatListTemplate template="default" onChatSelected={props.onChatSelected}></ChatListTemplate>
<Loading template="loading" message={'Loading your chats...'}></Loading>
</Get>
);
Expand Down
170 changes: 108 additions & 62 deletions samples/react-contoso/src/pages/Chats/ChatItem.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { Persona, makeStyles, mergeClasses, shorthands } from '@fluentui/react-components';
import { Providers } from '@microsoft/mgt-element';
import { MgtTemplateProps, Person, ViewType } from '@microsoft/mgt-react';
import { Chat, AadUserConversationMember } from '@microsoft/microsoft-graph-types';
import React, { useCallback, useEffect, useState } from 'react';
import { PeopleCommunityRegular, CalendarMonthRegular } from '@fluentui/react-icons';
import React, { useCallback, useState } from 'react';
import { PeopleTeam16Regular, Calendar16Regular } from '@fluentui/react-icons';

const useStyles = makeStyles({
chat: {
Expand All @@ -21,18 +20,29 @@ const useStyles = makeStyles({
backgroundColor: 'var(--colorNeutralBackground1Selected)'
},
person: {
userSelect: 'none',
'--person-line1-font-weight': 'var(--fontWeightRegular)',
'--person-avatar-size-small': '40px',
'--person-avatar-size': '40px',
'--person-line2-font-size': 'var(--fontSizeBase200)',
'--person-line2-text-color': 'var(--colorNeutralForeground4)',
'& .fui-Persona__primaryText': {
fontSize: 'var(--fontSizeBase300);'
fontSize: 'var(--fontSizeBase300)',
fontWeight: 'var(--fontWeightRegular)'
},
'& .fui-Persona__secondaryText': {
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
width: '200px',
display: 'inline-block',
fontSize: 'var(--fontSizeBase200);',
color: 'var(--colorNeutralForeground4)',
...shorthands.overflow('hidden')
}
},
group: {
paddingTop: '5px'
},
messagePreview: {
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
Expand All @@ -42,103 +52,143 @@ const useStyles = makeStyles({
}
});

export interface ChatInteractionProps {
onSelected: (selected: Chat) => void;
selectedChat?: Chat;
}

interface ChatItemProps {
chat: Chat;
isSelected?: boolean;
userId?: string;
}

interface MessagePreviewProps {
messagePreview?: string;
}

const getMessagePreview = (chat: Chat) => {
return chat?.lastMessagePreview?.body?.contentType === 'text' ? chat?.lastMessagePreview?.body?.content : '...';
const getMessagePreview = (chat: Chat, userId?: string): string | undefined => {
let preview = '';
if (chat?.lastMessagePreview?.from?.user && chat?.lastMessagePreview?.from?.user.id !== userId) {
preview += chat?.lastMessagePreview?.from?.user.displayName?.split(' ')[0]!;
preview += ': ';
} else {
preview += 'You: ';
}

if (chat?.lastMessagePreview?.body?.contentType === 'text') {
preview += chat?.lastMessagePreview?.body?.content;
} else if (chat?.lastMessagePreview?.body?.contentType === 'html') {
const html = chat?.lastMessagePreview?.body?.content!;
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');

const images = doc.querySelectorAll('img');
const card = doc.querySelector('attachment');
const systemCard = doc.querySelector('span[itemId]');
const systemEventMessage = doc.querySelector('systemEventMessage');

if (systemEventMessage || systemCard) return undefined;

preview += doc.body.textContent || doc.body.innerText || '';

if (images) {
if (Array.from(images.values()).find(i => i.src.includes('.gif'))) {
preview += ' 📷 GIF';
} else {
preview += ' Sent an image';
}
}

if (card) {
preview = 'Sent a card';
}
}

// Remove all new lines, tabs, and carriage returns
// Replace all multiple spaces with a single space
return preview
?.trim()
.replace(/[\n\t\r]/g, '')
.replace(/\s+/g, ' ');
};

const ChatItem = ({ chat, isSelected, onSelected }: ChatItemProps & ChatInteractionProps) => {
const ChatItemComponent = ({ chat, userId }: ChatItemProps) => {
const styles = useStyles();
const [myId, setMyId] = useState<string>();

useEffect(() => {
const getMyId = async () => {
const me = await Providers.me();
setMyId(me.id);
};
if (!myId) {
void getMyId();
}
}, [myId]);
const [messagePreview, setMessagePreview] = useState<string | undefined>('');

const getOtherParticipantId = useCallback(
(chat: Chat) => {
const member = chat.members?.find(m => (m as AadUserConversationMember).userId !== myId);
const member = chat.members?.find(m => (m as AadUserConversationMember).userId !== userId);

if (member) {
console.log('member', member);
return (member as AadUserConversationMember).userId as string;
} else if (chat.members?.length === 1 && (chat.members[0] as AadUserConversationMember).userId === myId) {
return myId;
} else if (chat.members?.length === 1 && (chat.members[0] as AadUserConversationMember).userId === userId) {
return userId;
}

return undefined;
},
[myId]
[userId]
);

const getGroupTitle = useCallback((chat: Chat) => {
let groupTitle: string | undefined = '';
if (chat.topic) {
groupTitle = chat.topic;
} else {
groupTitle = chat.members
?.map(member => {
return member.displayName?.split(' ')[0];
})
.join(', ');
}
const getGroupTitle = useCallback(
(chat: Chat) => {
const lf = new Intl.ListFormat('en');
let groupMembers: string[] = [];

if (chat.topic) {
return chat.topic;
} else {
chat.members
?.filter(member => member['userId'] !== userId)
?.forEach(member => {
groupMembers.push(member.displayName?.split(' ')[0]!);
});

return lf.format(groupMembers);
}
},
[userId]
);

return groupTitle;
}, []);
React.useEffect(() => {
setMessagePreview(getMessagePreview(chat, userId));
}, [chat, userId]);

return (
<>
{myId && (
<div className={mergeClasses(styles.chat, `${isSelected && styles.active}`)}>
{userId && (
<div className={styles.chat}>
{chat.chatType === 'oneOnOne' && (
<Person
userId={getOtherParticipantId(chat)}
view={ViewType.twolines}
view={messagePreview ? ViewType.twolines : ViewType.oneline}
avatarSize="auto"
showPresence={true}
onClick={() => onSelected(chat)}
className={styles.person}
>
<MessagePreview template="line2" chat={chat} />
{messagePreview && (
<MessagePreview template="line2" chat={chat} userId={userId} messagePreview={messagePreview} />
)}
</Person>
)}
{chat.chatType === 'group' && (
<div onClick={() => onSelected(chat)}>
<div>
<Persona
textAlignment="center"
size="extra-large"
name={getGroupTitle(chat)}
secondaryText={getMessagePreview(chat)}
avatar={{ icon: <PeopleCommunityRegular />, initials: null }}
className={styles.person}
secondaryText={messagePreview}
avatar={{ icon: <PeopleTeam16Regular />, initials: null }}
className={mergeClasses(styles.person, styles.group)}
/>
<span></span>
</div>
)}
{chat.chatType === 'meeting' && (
<div onClick={() => onSelected(chat)}>
<div>
<Persona
textAlignment="center"
size="extra-large"
className={styles.person}
avatar={{ icon: <CalendarMonthRegular />, initials: null }}
className={mergeClasses(styles.person, styles.group)}
avatar={{ icon: <Calendar16Regular />, initials: null }}
name={getGroupTitle(chat)}
secondaryText={getMessagePreview(chat)}
secondaryText={messagePreview}
/>
</div>
)}
Expand All @@ -148,14 +198,10 @@ const ChatItem = ({ chat, isSelected, onSelected }: ChatItemProps & ChatInteract
);
};

const MessagePreview = (props: MgtTemplateProps & ChatItemProps) => {
const MessagePreview = (props: MgtTemplateProps & ChatItemProps & MessagePreviewProps) => {
const styles = useStyles();

return (
<>
<span className={styles.messagePreview}>{getMessagePreview(props.chat)}</span>
</>
);
return <>{props.messagePreview && <span className={styles.messagePreview}>{props.messagePreview}</span>}</>;
};

export default ChatItem;
export const ChatItem = React.memo(ChatItemComponent);
Loading

0 comments on commit 3c71e91

Please sign in to comment.