diff --git a/src/pages/inbox/BaseInbox.tsx b/src/pages/inbox/BaseInbox.tsx index c0b82a4d29..93ff955a35 100644 --- a/src/pages/inbox/BaseInbox.tsx +++ b/src/pages/inbox/BaseInbox.tsx @@ -233,6 +233,10 @@ const InboxPage: FC = (props) => { useEffect(() => { fetchData(); + + return () => { + dispatch(inboxActions.resetInbox({ onlyIfUnread: true })); + }; }, [userId]); useEffect(() => { diff --git a/src/shared/converters/ChatChannelToLayoutItemConverter.ts b/src/shared/converters/ChatChannelToLayoutItemConverter.ts new file mode 100644 index 0000000000..5299541b79 --- /dev/null +++ b/src/shared/converters/ChatChannelToLayoutItemConverter.ts @@ -0,0 +1,23 @@ +import { InboxItemType } from "@/shared/constants"; +import { ChatChannelLayoutItem } from "@/shared/interfaces"; +import { ChatChannel } from "@/shared/models"; +import { Converter } from "./Converter"; + +class ChatChannelToLayoutItemConverter extends Converter< + ChatChannel, + ChatChannelLayoutItem +> { + public toTargetEntity = ( + chatChannel: ChatChannel, + ): ChatChannelLayoutItem => ({ + type: InboxItemType.ChatChannel, + itemId: chatChannel.id, + chatChannel, + }); + + public toBaseEntity = ( + chatChannelLayoutItem: ChatChannelLayoutItem, + ): ChatChannel => chatChannelLayoutItem.chatChannel; +} + +export default new ChatChannelToLayoutItemConverter(); diff --git a/src/shared/converters/FeedItemFollowToLayoutItemWithFollowDataConverter.ts b/src/shared/converters/FeedItemFollowToLayoutItemWithFollowDataConverter.ts new file mode 100644 index 0000000000..2e8cbeb739 --- /dev/null +++ b/src/shared/converters/FeedItemFollowToLayoutItemWithFollowDataConverter.ts @@ -0,0 +1,25 @@ +import { InboxItemType } from "@/shared/constants"; +import { FeedItemFollowLayoutItemWithFollowData } from "@/shared/interfaces"; +import { FeedItemFollowWithMetadata } from "@/shared/models"; +import { Converter } from "./Converter"; + +class FeedItemFollowToLayoutItemWithFollowDataConverter extends Converter< + FeedItemFollowWithMetadata, + FeedItemFollowLayoutItemWithFollowData +> { + public toTargetEntity = ( + feedItemFollowWithMetadata: FeedItemFollowWithMetadata, + ): FeedItemFollowLayoutItemWithFollowData => ({ + type: InboxItemType.FeedItemFollow, + itemId: feedItemFollowWithMetadata.feedItemId, + feedItem: feedItemFollowWithMetadata.feedItem, + feedItemFollowWithMetadata: feedItemFollowWithMetadata, + }); + + public toBaseEntity = ( + feedItemFollowLayoutItemWithFollowData: FeedItemFollowLayoutItemWithFollowData, + ): FeedItemFollowWithMetadata => + feedItemFollowLayoutItemWithFollowData.feedItemFollowWithMetadata; +} + +export default new FeedItemFollowToLayoutItemWithFollowDataConverter(); diff --git a/src/shared/converters/index.ts b/src/shared/converters/index.ts index 0853aa9193..033587d03e 100644 --- a/src/shared/converters/index.ts +++ b/src/shared/converters/index.ts @@ -1,2 +1,4 @@ export { default as ChatChannelToDiscussionConverter } from "./ChatChannelToDiscussionConverter"; +export { default as ChatChannelToLayoutItemConverter } from "./ChatChannelToLayoutItemConverter"; export { default as ChatMessageToUserDiscussionMessageConverter } from "./ChatMessageToUserDiscussionMessageConverter"; +export { default as FeedItemFollowToLayoutItemWithFollowDataConverter } from "./FeedItemFollowToLayoutItemWithFollowDataConverter"; diff --git a/src/shared/hooks/useCases/useInboxItems.ts b/src/shared/hooks/useCases/useInboxItems.ts index e52ed2c95a..91aee3b7a3 100644 --- a/src/shared/hooks/useCases/useInboxItems.ts +++ b/src/shared/hooks/useCases/useInboxItems.ts @@ -18,6 +18,7 @@ import { FeedItemFollowWithMetadata, } from "@/shared/models"; import { inboxActions, InboxItems, selectInboxItems } from "@/store/states"; +import { useUnreadInboxItems } from "./useUnreadInboxItems"; interface Return extends Pick { @@ -128,6 +129,7 @@ export const useInboxItems = ( const userId = user?.uid; const unread = options?.unread; const lastBatch = newItemsBatches[0]; + useUnreadInboxItems(options?.unread); const fetch = () => { dispatch( diff --git a/src/shared/hooks/useCases/useUnreadInboxItems.ts b/src/shared/hooks/useCases/useUnreadInboxItems.ts new file mode 100644 index 0000000000..6b3ae8f6c0 --- /dev/null +++ b/src/shared/hooks/useCases/useUnreadInboxItems.ts @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { usePreviousDistinct, useUpdateEffect } from "react-use"; +import { selectUserStreamsWithNotificationsAmount } from "@/pages/Auth/store/selectors"; +import { inboxActions } from "@/store/states"; + +export const useUnreadInboxItems = (unread?: boolean): void => { + const dispatch = useDispatch(); + const notificationsAmount = useSelector( + selectUserStreamsWithNotificationsAmount(), + ); + const previousNotificationsAmount = usePreviousDistinct(notificationsAmount); + + useUpdateEffect(() => { + if ( + !unread || + !notificationsAmount || + (typeof previousNotificationsAmount === "number" && + notificationsAmount < previousNotificationsAmount) + ) { + return; + } + + dispatch(inboxActions.refreshUnreadInboxItems.request()); + }, [notificationsAmount]); + + useEffect(() => { + return () => { + dispatch( + inboxActions.refreshUnreadInboxItems.cancel( + "Cancel unread inbox items refresh on unmount", + ), + ); + }; + }, []); +}; diff --git a/src/store/states/inbox/actions.ts b/src/store/states/inbox/actions.ts index 360ec377ed..aad2217412 100644 --- a/src/store/states/inbox/actions.ts +++ b/src/store/states/inbox/actions.ts @@ -4,7 +4,9 @@ import { ChatChannel, CommonFeed } from "@/shared/models"; import { InboxActionType } from "./constants"; import { InboxItems } from "./types"; -export const resetInbox = createStandardAction(InboxActionType.RESET_INBOX)(); +export const resetInbox = createStandardAction(InboxActionType.RESET_INBOX)<{ + onlyIfUnread?: boolean; +} | void>(); export const getInboxItems = createAsyncAction( InboxActionType.GET_INBOX_ITEMS, @@ -21,6 +23,13 @@ export const getInboxItems = createAsyncAction( string >(); +export const refreshUnreadInboxItems = createAsyncAction( + InboxActionType.REFRESH_UNREAD_INBOX_ITEMS, + InboxActionType.REFRESH_UNREAD_INBOX_ITEMS_SUCCESS, + InboxActionType.REFRESH_UNREAD_INBOX_ITEMS_FAILURE, + InboxActionType.REFRESH_UNREAD_INBOX_ITEMS_CANCEL, +)(); + export const addNewInboxItems = createStandardAction( InboxActionType.ADD_NEW_INBOX_ITEMS, )< diff --git a/src/store/states/inbox/constants.ts b/src/store/states/inbox/constants.ts index 9a7b410e4c..bc42bc1710 100644 --- a/src/store/states/inbox/constants.ts +++ b/src/store/states/inbox/constants.ts @@ -6,6 +6,11 @@ export enum InboxActionType { GET_INBOX_ITEMS_FAILURE = "@INBOX/GET_INBOX_ITEMS_FAILURE", GET_INBOX_ITEMS_CANCEL = "@INBOX/GET_INBOX_ITEMS_CANCEL", + REFRESH_UNREAD_INBOX_ITEMS = "@INBOX/REFRESH_UNREAD_INBOX_ITEMS", + REFRESH_UNREAD_INBOX_ITEMS_SUCCESS = "@INBOX/REFRESH_UNREAD_INBOX_ITEMS_SUCCESS", + REFRESH_UNREAD_INBOX_ITEMS_FAILURE = "@INBOX/REFRESH_UNREAD_INBOX_ITEMS_FAILURE", + REFRESH_UNREAD_INBOX_ITEMS_CANCEL = "@INBOX/REFRESH_UNREAD_INBOX_ITEMS_CANCEL", + ADD_NEW_INBOX_ITEMS = "@INBOX/ADD_NEW_INBOX_ITEMS", UPDATE_INBOX_ITEM = "@INBOX/UPDATE_INBOX_ITEM", diff --git a/src/store/states/inbox/reducer.ts b/src/store/states/inbox/reducer.ts index 08a1e165d1..c6d488bebb 100644 --- a/src/store/states/inbox/reducer.ts +++ b/src/store/states/inbox/reducer.ts @@ -413,7 +413,13 @@ const updateChatChannelItem = ( }; export const reducer = createReducer(INITIAL_INBOX_STATE) - .handleAction(actions.resetInbox, () => ({ ...INITIAL_INBOX_STATE })) + .handleAction(actions.resetInbox, (state, { payload }) => { + if (payload?.onlyIfUnread && !state.items.unread) { + return state; + } + + return { ...INITIAL_INBOX_STATE }; + }) .handleAction(actions.getInboxItems.request, (state) => produce(state, (nextState) => { nextState.items = { diff --git a/src/store/states/inbox/saga/getInboxItems.ts b/src/store/states/inbox/saga/getInboxItems.ts index 5d7c94b8b1..d69503832f 100644 --- a/src/store/states/inbox/saga/getInboxItems.ts +++ b/src/store/states/inbox/saga/getInboxItems.ts @@ -1,12 +1,10 @@ import { call, put, select } from "redux-saga/effects"; import { UserService } from "@/services"; -import { InboxItemType } from "@/shared/constants"; import { - Awaited, - ChatChannelLayoutItem, - FeedItemFollowLayoutItemWithFollowData, - FeedLayoutItemWithFollowData, -} from "@/shared/interfaces"; + ChatChannelToLayoutItemConverter, + FeedItemFollowToLayoutItemWithFollowDataConverter, +} from "@/shared/converters"; +import { Awaited, FeedLayoutItemWithFollowData } from "@/shared/interfaces"; import { isError } from "@/shared/utils"; import * as actions from "../actions"; import { selectInboxItems } from "../selectors"; @@ -44,21 +42,11 @@ export function* getInboxItems( }, )) as Awaited>; const chatChannelItems = data.chatChannels - .map((chatChannel) => ({ - type: InboxItemType.ChatChannel, - itemId: chatChannel.id, - chatChannel, - })) + .map((item) => ChatChannelToLayoutItemConverter.toTargetEntity(item)) .filter((item) => item.chatChannel.messageCount > 0); - const feedItemFollowItems = - data.feedItemFollows.map( - (feedItemFollowWithMetadata) => ({ - type: InboxItemType.FeedItemFollow, - itemId: feedItemFollowWithMetadata.feedItemId, - feedItem: feedItemFollowWithMetadata.feedItem, - feedItemFollowWithMetadata: feedItemFollowWithMetadata, - }), - ); + const feedItemFollowItems = data.feedItemFollows.map((item) => + FeedItemFollowToLayoutItemWithFollowDataConverter.toTargetEntity(item), + ); const convertedData = sortItems([ ...chatChannelItems, ...feedItemFollowItems, diff --git a/src/store/states/inbox/saga/index.ts b/src/store/states/inbox/saga/index.ts index f040cb9624..b71cbe048b 100644 --- a/src/store/states/inbox/saga/index.ts +++ b/src/store/states/inbox/saga/index.ts @@ -1,6 +1,7 @@ import { takeLatestWithCancel } from "@/shared/utils/saga"; import * as actions from "../actions"; import { getInboxItems } from "./getInboxItems"; +import { refreshUnreadInboxItems } from "./refreshUnreadInboxItems"; export function* mainSaga() { yield takeLatestWithCancel( @@ -8,4 +9,9 @@ export function* mainSaga() { actions.getInboxItems.cancel, getInboxItems, ); + yield takeLatestWithCancel( + actions.refreshUnreadInboxItems.request, + actions.refreshUnreadInboxItems.cancel, + refreshUnreadInboxItems, + ); } diff --git a/src/store/states/inbox/saga/refreshUnreadInboxItems.ts b/src/store/states/inbox/saga/refreshUnreadInboxItems.ts new file mode 100644 index 0000000000..f2f61f2681 --- /dev/null +++ b/src/store/states/inbox/saga/refreshUnreadInboxItems.ts @@ -0,0 +1,73 @@ +import { call, put, select } from "redux-saga/effects"; +import { Logger, UserService } from "@/services"; +import { + ChatChannelToLayoutItemConverter, + FeedItemFollowToLayoutItemWithFollowDataConverter, +} from "@/shared/converters"; +import { Awaited, FeedLayoutItemWithFollowData } from "@/shared/interfaces"; +import { Timestamp } from "@/shared/models"; +import * as actions from "../actions"; +import { selectInboxItems } from "../selectors"; +import { InboxItems } from "../types"; + +export function* refreshUnreadInboxItems() { + try { + const currentItems = (yield select(selectInboxItems)) as InboxItems; + const newInboxItems: FeedLayoutItemWithFollowData[] = []; + let startAfter: Timestamp | null = null; + let keepItemsFetching = true; + + while (keepItemsFetching) { + const { data, lastDocTimestamp, hasMore } = (yield call( + UserService.getInboxItems, + { + startAfter, + limit: 5, + unread: true, + }, + )) as Awaited>; + const chatChannelItems = data.chatChannels + .filter( + (chatChannel) => + chatChannel.messageCount > 0 && + (!currentItems.data || + currentItems.data.every( + (item) => item.itemId !== chatChannel.id, + )), + ) + .map((item) => ChatChannelToLayoutItemConverter.toTargetEntity(item)); + const feedItemFollowItems = data.feedItemFollows + .filter( + (feedItemFollow) => + !currentItems.data || + currentItems.data.every( + (item) => item.itemId !== feedItemFollow.id, + ), + ) + .map((item) => + FeedItemFollowToLayoutItemWithFollowDataConverter.toTargetEntity( + item, + ), + ); + newInboxItems.push(...chatChannelItems, ...feedItemFollowItems); + keepItemsFetching = hasMore; + startAfter = lastDocTimestamp; + } + + if (newInboxItems.length > 0) { + yield put( + actions.addNewInboxItems( + newInboxItems.map((item) => ({ + item, + statuses: { + isAdded: false, + isRemoved: false, + }, + })), + ), + ); + } + } catch (err) { + Logger.error(err); + } +}