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

Implement mark all messages as read #14

Merged
merged 22 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d83ea47
implement mark all messages as read
TechnicallyWilliams Jan 12, 2024
6e85974
merge latest
TechnicallyWilliams Jan 12, 2024
74a8f87
run prettier
TechnicallyWilliams Jan 12, 2024
8cf2f96
make markasread a function that onclick calls
TechnicallyWilliams Jan 12, 2024
d9bb2bc
add comments; remove logging statements
TechnicallyWilliams Jan 13, 2024
8258ecd
remove stateful list of readitems
TechnicallyWilliams Jan 16, 2024
6e9a3a0
merge latest next/mgt-chat
TechnicallyWilliams Jan 16, 2024
333f5ef
merge latest next/mgt-chat
TechnicallyWilliams Jan 16, 2024
34c2725
Merge branch 'next/mgt-chat' into technicallywilliams/markallasread
TechnicallyWilliams Jan 17, 2024
0151d8a
ensure onallmessageread has latest chatthreads
TechnicallyWilliams Jan 17, 2024
b8a154f
add caching to statefulgraphchatlistclient
TechnicallyWilliams Jan 17, 2024
36aeda2
add markasread check to pre-commit of loading more chats
TechnicallyWilliams Jan 17, 2024
0e7b42d
ensure mark all as read returns true read
TechnicallyWilliams Jan 17, 2024
1c06920
Merge branch 'next/mgt-chat' into technicallywilliams/markallasread
TechnicallyWilliams Jan 17, 2024
53cd932
remove async modifier from higher order function
TechnicallyWilliams Jan 17, 2024
6c65555
remove async modifier; remove unsafe undefined
TechnicallyWilliams Jan 17, 2024
8ca2023
remove async modifier; remove unsafe undefined
TechnicallyWilliams Jan 17, 2024
f361886
trailing change: updating placement of read status check
TechnicallyWilliams Jan 18, 2024
62e65d2
feedback: add id filter on load; add nul assertions;
TechnicallyWilliams Jan 18, 2024
3b9fa1c
fix id filter on load
TechnicallyWilliams Jan 18, 2024
ebef10b
update how handler reads
TechnicallyWilliams Jan 18, 2024
a361baa
remove explicit reference to graphchatthread in chatlist
TechnicallyWilliams Jan 18, 2024
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
44 changes: 30 additions & 14 deletions packages/mgt-chat/src/components/ChatList/ChatList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ import { ChatListHeader } from '../ChatListHeader/ChatListHeader';
import { IChatListMenuItemsProps } from '../ChatListHeader/EllipsisMenu';
import { ChatListButtonItem } from '../ChatListHeader/ChatListButtonItem';
import { ChatListMenuItem } from '../ChatListHeader/ChatListMenuItem';
import { LastReadCache } from '../../statefulClient/Caching/LastReadCache';

export interface IChatListItemProps {
onSelected: (e: GraphChat) => void;
onUnselected?: (e: GraphChat) => void;
onLoaded?: () => void;
onAllMessagesRead: (e: string[]) => void;
buttonItems?: ChatListButtonItem[];
chatThreadsPerPage: number;
lastReadTimeInterval?: number;
Expand Down Expand Up @@ -67,7 +67,6 @@ export const ChatList = ({
const [chatListClient, setChatListClient] = useState<StatefulGraphChatListClient | undefined>();
const [chatListState, setChatListState] = useState<GraphChatListClient | undefined>();
const [menuItems, setMenuItems] = useState<ChatListMenuItem[]>(props.menuItems === undefined ? [] : props.menuItems);
const cache = new LastReadCache();

// wait for provider to be ready before setting client and state
useEffect(() => {
Expand All @@ -79,16 +78,6 @@ export const ChatList = ({
setChatListState(client.getState());
}
});

const markAllAsRead = {
displayText: 'Mark all as read',
onClick: () => {
console.log('mark all as read');
}
};

menuItems.unshift(markAllAsRead);
setMenuItems(menuItems);
}, []);

// Store last read time in cache so that when the user comes back to the chat list,
Expand All @@ -98,7 +87,7 @@ export const ChatList = ({
const timer = setInterval(() => {
if (selectedChatId) {
log(`caching the last-read timestamp of now to chat ID '${selectedChatId}'...`);
void cache.cacheLastReadTime(selectedChatId, new Date());
chatListClient?.cacheLastReadTime([selectedChatId]);
}
}, lastReadTimeInterval);

Expand All @@ -116,13 +105,23 @@ export const ChatList = ({
}
}
};

if (chatListClient) {
chatListClient.onStateChange(setChatListState);
chatListClient.onStateChange(state => {
if (state.status === 'chat threads loaded' && props.onLoaded) {
const markAllAsRead = {
displayText: 'Mark all as read',
onClick: () => markAllThreadsAsRead(state.chatThreads)
};
// clone the menuItems array
const updatedMenuItems = [...menuItems];
updatedMenuItems.unshift(markAllAsRead);
setMenuItems(updatedMenuItems);
props.onLoaded();
}
});

chatListClient.onChatListEvent(handleChatListEvent);
return () => {
chatListClient.offStateChange(setChatListState);
Expand All @@ -132,9 +131,26 @@ export const ChatList = ({
}
}, [chatListClient]);

const markAllThreadsAsRead = (chatThreads: GraphChat[]) => {
const readChatThreads = chatThreads.map(c => c.id!);
const markedChatThreads = chatListClient?.markChatThreadsAsRead(readChatThreads);
if (markedChatThreads) {
chatListClient?.cacheLastReadTime(markedChatThreads);
props.onAllMessagesRead(markedChatThreads);
}
};

const markThreadAsRead = (chatThread: string) => {
const markedChatThreads = chatListClient?.markChatThreadsAsRead([chatThread]);
if (markedChatThreads) {
chatListClient?.cacheLastReadTime(markedChatThreads);
}
};

const onClickChatListItem = (chatListItem: GraphChat) => {
// set selected state only once per click event
if (chatListItem.id !== selectedChatId) {
markThreadAsRead(chatListItem.id!);
TechnicallyWilliams marked this conversation as resolved.
Show resolved Hide resolved
props.onSelected(chatListItem);

// trigger an unselect event for the previously selected item
Expand Down Expand Up @@ -170,7 +186,7 @@ export const ChatList = ({
chat={c}
myId={chatListState.userId}
isSelected={c.id === selectedChatId}
isRead={c.id === selectedChatId}
isRead={c.id === selectedChatId || c.isRead}
/>
</Button>
))}
Expand Down
36 changes: 1 addition & 35 deletions packages/mgt-chat/src/components/ChatListItem/ChatListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { rewriteEmojiContent } from '../../utils/rewriteEmojiContent';
import { convert } from 'html-to-text';
import { loadChatWithPreview } from '../../statefulClient/graph.chat';
import { DefaultProfileIcon } from './DefaultProfileIcon';
import { LastReadCache } from '../../statefulClient/Caching/LastReadCache';

interface IMgtChatListItemProps {
chat: Chat;
Expand Down Expand Up @@ -128,22 +127,10 @@ export const ChatListItem = ({ chat, myId, isSelected, isRead }: IMgtChatListIte

// manage the internal state of the chat
const [chatInternal, setChatInternal] = useState(chat);
const [read, setRead] = useState<boolean>(isRead);
const cache = new LastReadCache();

// when isSelected changes to true, setRead to true
useEffect(() => {
if (isSelected) {
setRead(true);
}
}, [isSelected]);

// if chat changes, update the internal state to match
useEffect(() => {
setChatInternal(chat);
if (isLoaded()) {
checkWhetherToMarkAsRead(chat);
}
}, [chat]);

// enrich the chat if necessary
Expand All @@ -158,7 +145,6 @@ export const ChatListItem = ({ chat, myId, isSelected, isRead }: IMgtChatListIte
load(chatInternal.id!).then(
c => {
setChatInternal(c);
checkWhetherToMarkAsRead(c);
},
e => error(e)
);
Expand All @@ -170,26 +156,6 @@ export const ChatListItem = ({ chat, myId, isSelected, isRead }: IMgtChatListIte
return chatInternal.id && (!chatInternal.chatType || !chatInternal.members);
};

// check whether to mark the chat as read or not
const checkWhetherToMarkAsRead = async (c: Chat) => {
await cache
.loadLastReadTime(c.id!)
.then(lastReadData => {
if (lastReadData) {
const lastUpdatedDateTime = new Date(c.lastUpdatedDateTime!);
const lastMessagePreviewCreatedDateTime = new Date(c.lastMessagePreview?.createdDateTime as string);
const lastReadTime = new Date(lastReadData.lastReadTime as string);
const isRead = !(
lastUpdatedDateTime > lastReadTime ||
lastMessagePreviewCreatedDateTime > lastReadTime ||
!lastReadData.lastReadTime
);
setRead(isRead);
}
})
.catch(e => error(e));
};

// shortcut if no valid user
if (!myId) {
return <></>;
Expand Down Expand Up @@ -391,7 +357,7 @@ export const ChatListItem = ({ chat, myId, isSelected, isRead }: IMgtChatListIte
const container = mergeClasses(
styles.chatListItem,
isSelected ? styles.isSelected : styles.isUnSelected,
read ? styles.isNormal : styles.isBold
isRead ? styles.isNormal : styles.isBold
);

// short cut if the id is not defined
Expand Down
115 changes: 95 additions & 20 deletions packages/mgt-chat/src/statefulClient/StatefulGraphChatListClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { ThreadEventEmitter } from './ThreadEventEmitter';
import { ChatThreadCollection, loadChatThreads, loadChatThreadsByPage } from './graph.chat';
import { ChatMessageInfo, Chat as GraphChat } from '@microsoft/microsoft-graph-types';
import { error } from '@microsoft/mgt-element';
import { LastReadCache } from '../statefulClient/Caching/LastReadCache';

interface ODataType {
'@odata.type': MessageEventType;
}
Expand Down Expand Up @@ -55,6 +57,10 @@ type ChatMessageEvents =
| TeamsAppInstalledEventDetail
| TeamsAppRemovedEventDetail;

export type GraphChatThread = GraphChat & {
isRead: boolean;
};

// defines the type of the state object returned from the StatefulGraphChatListClient
export type GraphChatListClient = Pick<MessageThreadProps, 'userId'> & {
status:
Expand All @@ -67,7 +73,7 @@ export type GraphChatListClient = Pick<MessageThreadProps, 'userId'> & {
| 'chat threads loaded'
| 'ready'
| 'error';
chatThreads: GraphChat[];
chatThreads: GraphChatThread[];
moreChatThreadsToLoad: boolean | undefined;
} & Pick<ErrorBarProps, 'activeErrorMessages'>;

Expand Down Expand Up @@ -102,8 +108,20 @@ interface StatefulClient<T> {
offChatListEvent(handler: (event: ChatListEvent) => void): void;

chatThreadsPerPage: number;

/**
* Method for loading more chat threads
*/
loadMoreChatThreads(): void;
/**
* Method for marking chat threads as read
* @param chatThreads - chat threads to mark as read
*/
markChatThreadsAsRead(chatThreads: string[]): string[];
/**
* Method for caching last read time for all included chat threads
* @param chatThreads - chat threads to cache last read time for
*/
cacheLastReadTime(chatThreads: string[]): void;
}

type MessageEventType =
Expand Down Expand Up @@ -139,6 +157,7 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
private readonly _notificationClient: GraphNotificationUserClient;
private readonly _eventEmitter: ThreadEventEmitter;
// private readonly _cache: MessageCache;
private readonly _cache: LastReadCache;
private _stateSubscribers: ((state: GraphChatListClient) => void)[] = [];
private _chatListEventSubscribers: ((state: ChatListEvent) => void)[] = [];
private readonly _graph: IGraph;
Expand All @@ -149,6 +168,7 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
this._eventEmitter = new ThreadEventEmitter();
this.registerEventListeners();
// this._cache = new MessageCache();
this._cache = new LastReadCache();
this._graph = graph('mgt-chat', GraphConfig.version);
this.chatThreadsPerPage = chatThreadsPerPage;
this._notificationClient = new GraphNotificationUserClient(this._eventEmitter, this._graph);
Expand All @@ -169,34 +189,32 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
/**
* Load more chat threads if applicable.
*/
public loadMoreChatThreads(): void {
public loadMoreChatThreads() {
const state = this.getState();
const items: GraphChat[] = [];
const items: GraphChatThread[] = [];
this.loadAndAppendChatThreads('', items, state.chatThreads.length + this.chatThreadsPerPage);
}

private loadAndAppendChatThreads(nextLink: string, items: GraphChat[], maxItems: number): void {
private loadAndAppendChatThreads(nextLink: string, items: GraphChatThread[], maxItems: number) {
if (maxItems < 1) {
error('maxItem is invalid: ' + maxItems);
return;
}

const handler = (latestChatThreads: ChatThreadCollection) => {
items = items.concat(latestChatThreads.value);
const handler = async (latestChatThreads: ChatThreadCollection) => {
const latestItems = (latestChatThreads.value as GraphChatThread[]).filter(chatThread => chatThread.id);
const checkedItems = await this.checkWhetherToMarkAsRead(latestItems);
items = items.concat(checkedItems);

const handlerNextLink = latestChatThreads['@odata.nextLink'];
if (items.length >= maxItems) {
if (items.length > maxItems) {
// return exact page size
this.handleChatThreads(items.slice(0, maxItems), 'more');
return;
}

this.handleChatThreads(items, handlerNextLink);
if (items.length > maxItems) {
// return exact page size
this.handleChatThreads(items.slice(0, maxItems), 'more');
return;
}

if (handlerNextLink && handlerNextLink !== '') {
if (items.length < maxItems && handlerNextLink && handlerNextLink !== '') {
this.loadAndAppendChatThreads(handlerNextLink, items, maxItems);
return;
}
Expand All @@ -214,6 +232,62 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
}
}

public markChatThreadsAsRead = (readChatThreads: string[]): string[] => {
// mark as read after chat thread is found in current state
const markedChatThreads: string[] = [];
this.notifyStateChange((draft: GraphChatListClient) => {
draft.chatThreads = this._state.chatThreads.map((chatThread: GraphChatThread) => {
if (chatThread.id && readChatThreads.includes(chatThread.id) && !chatThread.isRead) {
markedChatThreads.push(chatThread.id);
return {
...chatThread,
isRead: true
};
}
return chatThread;
});
});

return markedChatThreads;
};

public cacheLastReadTime = (readChatThreads: string[]) => {
// cache last read time after chat thread is found in current state
this._state.chatThreads.forEach((chatThread: GraphChatThread) => {
if (chatThread.id && readChatThreads.includes(chatThread.id)) {
void this._cache.cacheLastReadTime(chatThread.id, new Date());
}
});
};

// check whether to mark the chat as read or not
private readonly checkWhetherToMarkAsRead = async (c: GraphChatThread[]): Promise<GraphChatThread[]> => {
const result = await Promise.all(
c.map(async (chatThread: GraphChatThread) => {
const lastReadData = await this._cache.loadLastReadTime(chatThread.id!);
if (lastReadData) {
const lastUpdatedDateTime = chatThread.lastUpdatedDateTime ? new Date(chatThread.lastUpdatedDateTime) : null;
const lastMessagePreviewCreatedDateTime = chatThread.lastMessagePreview?.createdDateTime
? new Date(chatThread.lastMessagePreview?.createdDateTime)
: null;
const lastReadTime = new Date(lastReadData.lastReadTime);
const isRead = !(
(lastUpdatedDateTime && lastUpdatedDateTime > lastReadTime) ||
(lastMessagePreviewCreatedDateTime && lastMessagePreviewCreatedDateTime > lastReadTime) ||
!lastReadData.lastReadTime
);
return {
...chatThread,
isRead
};
} else {
return chatThread;
}
})
);
return result;
};

/**
* Register a callback to receive state updates
*
Expand Down Expand Up @@ -301,7 +375,7 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
const chatThread = draft.chatThreads[chatThreadIndex];

// func to bring the chat thread to the top of the list
const bringToTop = (newThread?: GraphChat) => {
const bringToTop = (newThread?: GraphChatThread) => {
draft.chatThreads.splice(chatThreadIndex, 1);
draft.chatThreads.unshift(newThread ?? chatThread);
};
Expand All @@ -314,7 +388,7 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
} else if (event.type === 'memberAdded' && chatThread) {
// inject a chat thread with only the id to force a reload; this is necessary because the
// notification does not include the displayName of the user who was added
const newThread = { id: chatThread.id } as GraphChat;
const newThread = { id: chatThread.id } as GraphChatThread;
bringToTop(newThread);
} else if (event.type === 'memberRemoved' && event.message?.eventDetail && chatThread) {
// update the user list to remove the user; while we could add a "User removed" message
Expand Down Expand Up @@ -359,10 +433,11 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
bringToTop();
} else if (event.type === 'chatMessageReceived' && event.message?.chatId) {
// create a new chat thread at the top
const newChatThread: GraphChat = {
const newChatThread: GraphChatThread = {
id: event.message.chatId,
lastMessagePreview: event.message as ChatMessageInfo,
lastUpdatedDateTime: event.message.lastModifiedDateTime
lastUpdatedDateTime: event.message.lastModifiedDateTime,
isRead: false
};
draft.chatThreads.unshift(newChatThread);
} else {
Expand Down Expand Up @@ -437,7 +512,7 @@ class StatefulGraphChatListClient implements StatefulClient<GraphChatListClient>
/*
* Event handler to be called when we need to load more chat threads.
*/
private readonly handleChatThreads = (chatThreads: GraphChat[], nextLink: string | undefined) => {
private readonly handleChatThreads = (chatThreads: GraphChatThread[], nextLink: string | undefined) => {
this.notifyStateChange((draft: GraphChatListClient) => {
draft.status = 'chat threads loaded';
draft.chatThreads = chatThreads;
Expand Down
Loading
Loading