diff --git a/samples/react-contoso/package.json b/samples/react-contoso/package.json index 92fc380525..743c41afa9 100644 --- a/samples/react-contoso/package.json +++ b/samples/react-contoso/package.json @@ -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": "*", diff --git a/samples/react-contoso/src/pages/ChatPage.tsx b/samples/react-contoso/src/pages/ChatPage.tsx index a246f26091..6c880fdb03 100644 --- a/samples/react-contoso/src/pages/ChatPage.tsx +++ b/samples/react-contoso/src/pages/ChatPage.tsx @@ -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(); 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); }; @@ -91,7 +106,7 @@ const ChatPage: React.FunctionComponent = () => { New Chat { setIsNewChatOpen(false); }} @@ -100,7 +115,7 @@ const ChatPage: React.FunctionComponent = () => { - +
{selectedChat && }
@@ -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 ( - - + + ); diff --git a/samples/react-contoso/src/pages/Chats/ChatItem.tsx b/samples/react-contoso/src/pages/Chats/ChatItem.tsx index e7029683d4..2e0a6fa666 100644 --- a/samples/react-contoso/src/pages/Chats/ChatItem.tsx +++ b/samples/react-contoso/src/pages/Chats/ChatItem.tsx @@ -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: { @@ -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', @@ -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(); - - useEffect(() => { - const getMyId = async () => { - const me = await Providers.me(); - setMyId(me.id); - }; - if (!myId) { - void getMyId(); - } - }, [myId]); + const [messagePreview, setMessagePreview] = useState(''); 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 && ( -
+ {userId && ( +
{chat.chatType === 'oneOnOne' && ( onSelected(chat)} className={styles.person} > - + {messagePreview && ( + + )} )} {chat.chatType === 'group' && ( -
onSelected(chat)}> +
, initials: null }} - className={styles.person} + secondaryText={messagePreview} + avatar={{ icon: , initials: null }} + className={mergeClasses(styles.person, styles.group)} />
)} {chat.chatType === 'meeting' && ( -
onSelected(chat)}> +
, initials: null }} + className={mergeClasses(styles.person, styles.group)} + avatar={{ icon: , initials: null }} name={getGroupTitle(chat)} - secondaryText={getMessagePreview(chat)} + secondaryText={messagePreview} />
)} @@ -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 ( - <> - {getMessagePreview(props.chat)} - - ); + return <>{props.messagePreview && {props.messagePreview}}; }; -export default ChatItem; +export const ChatItem = React.memo(ChatItemComponent); diff --git a/samples/react-contoso/src/pages/Chats/ChatListTemplate.tsx b/samples/react-contoso/src/pages/Chats/ChatListTemplate.tsx index 5cdb58be57..bd8d4c5242 100644 --- a/samples/react-contoso/src/pages/Chats/ChatListTemplate.tsx +++ b/samples/react-contoso/src/pages/Chats/ChatListTemplate.tsx @@ -1,26 +1,68 @@ import React, { useState } from 'react'; -import { MgtTemplateProps } from '@microsoft/mgt-react'; +import { MgtTemplateProps, Providers } from '@microsoft/mgt-react'; import { Chat } from '@microsoft/microsoft-graph-types'; -import ChatItem, { ChatInteractionProps } from './ChatItem'; +import { ChatItem } from './ChatItem'; import { Chat as GraphChat } from '@microsoft/microsoft-graph-types'; +import { List, ListItem } from '@fluentui/react-northstar'; +import { makeStyles, shorthands } from '@fluentui/react-components'; +import { iconFilledClassName, iconRegularClassName } from '@fluentui/react-icons'; + +const useStyles = makeStyles({ + listItem: { + listStyleType: 'none', + width: '100%', + ':focus-visible': { + [`& .${iconFilledClassName}`]: { + display: 'inline', + color: 'var(--colorNeutralForeground2BrandHover)' + }, + [`& .${iconRegularClassName}`]: { + display: 'none' + } + } + }, + selected: { + backgroundColor: 'var(--colorNeutralBackground1Selected)' + }, + list: { + fontWeight: 800, + gridGap: '8px', + ...shorthands.marginBlock('0'), + ...shorthands.padding('0') + } +}); + +export interface ChatInteractionProps { + onChatSelected: (selected: Chat) => void; +} const ChatListTemplate = (props: MgtTemplateProps & ChatInteractionProps) => { + const styles = useStyles(); const { value } = props.dataContext; - const chats: Chat[] = value; - const [selectedChat, setSelectedChat] = useState(props.selectedChat || chats[0]); - - const onChatSelected = React.useCallback( - (e: GraphChat) => { - setSelectedChat(e); - props.onSelected(selectedChat); - }, - [setSelectedChat, selectedChat, props] - ); + const [chats] = useState((value as Chat[]).filter(c => c.members?.length! > 1)); + const [userId, setUserId] = useState(); + const [selectedChat, setSelectedChat] = useState(chats.length > 0 ? chats[0] : undefined); + + const onChatSelected = (e: GraphChat) => { + setSelectedChat(e); + } // Set the selected chat to the first chat in the list // Fires only the first time the component is rendered React.useEffect(() => { - onChatSelected(selectedChat); + if (selectedChat) { + props.onChatSelected(selectedChat); + } + }, [props, selectedChat]); + + React.useEffect(() => { + const getMyId = async () => { + const me = await Providers.me(); + setUserId(me.id); + }; + if (!userId) { + void getMyId(); + } }); const isChatActive = (chat: Chat) => { @@ -31,18 +73,26 @@ const ChatListTemplate = (props: MgtTemplateProps & ChatInteractionProps) => { return false; }; - console.log('chats', chats); return ( -
+ {chats.map((c, index) => ( - + index={index} + className={styles.listItem} + content={ +
+ +
+ } + onClick={() => onChatSelected(c)}> + ))} -
+ ); }; diff --git a/yarn.lock b/yarn.lock index 123c2c8947..98bd5ab0d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -32913,6 +32913,7 @@ __metadata: "@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": "*"