diff --git a/package.json b/package.json index 84a2b15b3a..309a040132 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "react-virtualized": "^9.22.3", "react-zoom-pan-pinch": "^3.2.0", "redux": "^4.0.4", + "redux-persist": "^6.0.0", "redux-saga": "^1.1.3", "reselect": "^4.0.0", "slate": "^0.94.1", diff --git a/src/pages/App/AppWrapper.tsx b/src/pages/App/AppWrapper.tsx index 3d8e85deb3..1a769f9468 100644 --- a/src/pages/App/AppWrapper.tsx +++ b/src/pages/App/AppWrapper.tsx @@ -1,11 +1,14 @@ import React, { FC } from "react"; import { Provider } from "react-redux"; -import { store } from "@/shared/appConfig"; +import { PersistGate } from "redux-persist/integration/react"; +import { store, persistor } from "@/shared/appConfig"; import { NotificationProvider } from "./providers"; const AppWrapper: FC = ({ children }) => ( - {children} + + {children} + ); diff --git a/src/pages/Auth/store/saga.tsx b/src/pages/Auth/store/saga.tsx index 6aa36334b2..81641a1cb1 100755 --- a/src/pages/Auth/store/saga.tsx +++ b/src/pages/Auth/store/saga.tsx @@ -15,7 +15,9 @@ import { getProvider } from "@/shared/utils/authProvider"; import { getFundingRequestNotification } from "@/shared/utils/notifications"; import { cacheActions, + commonActions, commonLayoutActions, + inboxActions, multipleSpacesLayoutActions, } from "@/store/states"; import { @@ -301,6 +303,16 @@ const updateUserData = async (user: User): Promise => { }); }; +const resetGlobalData = (fullReset: boolean) => { + if (fullReset) { + store.dispatch(multipleSpacesLayoutActions.resetMultipleSpacesLayout()); + store.dispatch(commonLayoutActions.clearData()); + } + store.dispatch(inboxActions.resetInbox()); + store.dispatch(cacheActions.resetFeedStates()); + store.dispatch(commonActions.resetCommon()); +}; + function* socialLoginSaga({ payload, }: ReturnType) { @@ -465,8 +477,6 @@ function* logOut() { window.ReactNativeWebView.postMessage(WebviewActions.logout); } - yield put(multipleSpacesLayoutActions.resetMultipleSpacesLayout()); - yield put(commonLayoutActions.clearData()); history.push(ROUTE_PATHS.HOME); yield true; } @@ -567,6 +577,12 @@ function* authSagas() { firebase.auth().onAuthStateChanged(async (res) => { try { + const { user: userInStore } = store.getState().auth; + + if (userInStore?.uid !== res?.uid) { + resetGlobalData(!res); + } + store.dispatch( actions.setAuthProvider( getAuthProviderFromProviderData(res?.providerData), diff --git a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx index acdc57a258..5b270c797a 100644 --- a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx +++ b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx @@ -12,9 +12,9 @@ import { DeletePrompt, GlobalOverlay, ReportModal } from "@/shared/components"; import { EntityTypes } from "@/shared/constants"; import { useModal, useNotification } from "@/shared/hooks"; import { + FeedItemFollowState, useCommon, useDiscussionById, - useFeedItemFollow, useFeedItemUserMetadata, useUserById, } from "@/shared/hooks/useCases"; @@ -63,6 +63,7 @@ interface DiscussionFeedCardProps { getNonAllowedItems?: GetNonAllowedItemsOptions; onActiveItemDataChange?: (data: FeedLayoutItemChangeData) => void; directParent?: DirectParent | null; + feedItemFollow: FeedItemFollowState; onUserSelect?: (userId: string, commonId?: string) => void; } @@ -89,6 +90,7 @@ const DiscussionFeedCard = forwardRef( getNonAllowedItems, onActiveItemDataChange, directParent, + feedItemFollow, onUserSelect, } = props; const { @@ -124,10 +126,6 @@ const DiscussionFeedCard = forwardRef( fetchFeedItemUserMetadata, } = useFeedItemUserMetadata(); const { data: common } = useCommon(isHome ? commonId : ""); - const feedItemFollow = useFeedItemFollow( - { feedItemId: item.id, commonId }, - { withSubscription: true }, - ); const menuItems = useMenuItems( { commonId, diff --git a/src/pages/common/components/FeedItem/FeedItem.tsx b/src/pages/common/components/FeedItem/FeedItem.tsx index 96b79f19fb..6936c90e34 100644 --- a/src/pages/common/components/FeedItem/FeedItem.tsx +++ b/src/pages/common/components/FeedItem/FeedItem.tsx @@ -1,4 +1,5 @@ -import React, { forwardRef, memo } from "react"; +import React, { forwardRef, memo, useEffect } from "react"; +import { useFeedItemFollow } from "@/shared/hooks/useCases"; import { FeedLayoutItemChangeData } from "@/shared/interfaces"; import { Circles, @@ -64,10 +65,31 @@ const FeedItem = forwardRef((props, ref) => { onActiveItemDataChange, directParent, } = props; - const { onFeedItemUpdate, getLastMessage, getNonAllowedItems, onUserSelect } = - useFeedItemContext(); + const { + onFeedItemUpdate, + onFeedItemUnfollowed, + getLastMessage, + getNonAllowedItems, + onUserSelect, + } = useFeedItemContext(); + const feedItemFollow = useFeedItemFollow( + { feedItemId: item.id, commonId }, + { withSubscription: true }, + ); useFeedItemSubscription(item.id, commonId, onFeedItemUpdate); + useEffect(() => { + if ( + feedItemFollow.isUserFeedItemFollowDataFetched && + !feedItemFollow.userFeedItemFollowData + ) { + onFeedItemUnfollowed?.(item.id); + } + }, [ + feedItemFollow.isUserFeedItemFollowDataFetched, + feedItemFollow.userFeedItemFollowData, + ]); + if ( shouldCheckItemVisibility && !checkIsItemVisibleForUser( @@ -103,6 +125,7 @@ const FeedItem = forwardRef((props, ref) => { isMobileVersion, onActiveItemDataChange: handleActiveItemDataChange, directParent, + feedItemFollow, onUserSelect, }; diff --git a/src/pages/common/components/FeedItem/context.ts b/src/pages/common/components/FeedItem/context.ts index 3f408ee137..0a23aa1ad7 100644 --- a/src/pages/common/components/FeedItem/context.ts +++ b/src/pages/common/components/FeedItem/context.ts @@ -66,6 +66,7 @@ export interface FeedItemContextValue { setExpandedFeedItemId?: (feedItemId: string | null) => void; renderFeedItemBaseContent?: (props: FeedItemBaseContentProps) => ReactNode; onFeedItemUpdate?: (item: CommonFeed, isRemoved: boolean) => void; + onFeedItemUnfollowed?: (itemId: string) => void; feedCardSettings?: FeedCardSettings; getLastMessage: (options: GetLastMessageOptions) => TextEditorValue; getNonAllowedItems?: GetNonAllowedItemsOptions; diff --git a/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx b/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx index e585b92953..362fd01ba0 100644 --- a/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx +++ b/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx @@ -13,8 +13,8 @@ import { DeletePrompt, GlobalOverlay } from "@/shared/components"; import { useRoutesContext } from "@/shared/contexts"; import { useForceUpdate, useModal, useNotification } from "@/shared/hooks"; import { + FeedItemFollowState, useDiscussionById, - useFeedItemFollow, useFeedItemUserMetadata, useProposalById, useUserById, @@ -80,6 +80,7 @@ interface ProposalFeedCardProps { getLastMessage: (options: GetLastMessageOptions) => TextEditorValue; getNonAllowedItems?: GetNonAllowedItemsOptions; isMobileVersion?: boolean; + feedItemFollow: FeedItemFollowState; onActiveItemDataChange?: (data: FeedLayoutItemChangeData) => void; onUserSelect?: (userId: string, commonId?: string) => void; } @@ -101,6 +102,7 @@ const ProposalFeedCard = forwardRef( getLastMessage, getNonAllowedItems, isMobileVersion, + feedItemFollow, onActiveItemDataChange, onUserSelect, } = props; @@ -175,10 +177,6 @@ const ProposalFeedCard = forwardRef( onOpen: onShareModalOpen, onClose: onShareModalClose, } = useModal(false); - const feedItemFollow = useFeedItemFollow( - { feedItemId: item.id, commonId }, - { withSubscription: true }, - ); const menuItems = useMenuItems( { commonId, diff --git a/src/pages/commonFeed/CommonFeed.tsx b/src/pages/commonFeed/CommonFeed.tsx index 8182a5d33c..1bea857b83 100644 --- a/src/pages/commonFeed/CommonFeed.tsx +++ b/src/pages/commonFeed/CommonFeed.tsx @@ -45,6 +45,7 @@ import { getCommonPageAboutTabPath, } from "@/shared/utils"; import { + cacheActions, commonActions, commonLayoutActions, selectCommonAction, @@ -162,7 +163,11 @@ const CommonFeedComponent: FC = (props) => { hasMore: hasMoreCommonFeedItems, fetch: fetchCommonFeedItems, batchNumber, - } = useCommonFeedItems(commonId, commonFeedItemIdsForNotListening); + } = useCommonFeedItems( + commonId, + commonFeedItemIdsForNotListening, + sharedFeedItemId, + ); const { isModalOpen: isCommonJoinModalOpen, @@ -330,7 +335,13 @@ const CommonFeedComponent: FC = (props) => { useEffect(() => { fetchData(); + const interval = setInterval(() => { + dispatch(cacheActions.copyFeedStateByCommonId(commonId)); + }, 5000); + return () => { + clearInterval(interval); + dispatch(cacheActions.copyFeedStateByCommonId(commonId)); dispatch(commonActions.resetCommon()); }; }, [commonId]); diff --git a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx index 5ffca2f17b..7adc25a741 100644 --- a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx +++ b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx @@ -119,6 +119,7 @@ interface FeedLayoutProps { renderFeedItemBaseContent: (props: FeedItemBaseContentProps) => ReactNode; renderChatChannelItem?: (props: ChatChannelFeedLayoutItemProps) => ReactNode; onFeedItemUpdate?: (item: CommonFeed, isRemoved: boolean) => void; + onFeedItemUnfollowed?: (itemId: string) => void; getLastMessage: (options: GetLastMessageOptions) => TextEditorValue; sharedFeedItemId?: string | null; emptyText?: string; @@ -159,6 +160,7 @@ const FeedLayout: ForwardRefRenderFunction = ( renderFeedItemBaseContent, renderChatChannelItem, onFeedItemUpdate, + onFeedItemUnfollowed, getLastMessage, sharedFeedItemId, emptyText, @@ -326,6 +328,7 @@ const FeedLayout: ForwardRefRenderFunction = ( setExpandedFeedItemId, renderFeedItemBaseContent, onFeedItemUpdate, + onFeedItemUnfollowed, getLastMessage, getNonAllowedItems, onUserSelect: handleUserWithCommonClick, @@ -333,6 +336,7 @@ const FeedLayout: ForwardRefRenderFunction = ( [ renderFeedItemBaseContent, onFeedItemUpdate, + onFeedItemUnfollowed, getLastMessage, getNonAllowedItems, handleUserWithCommonClick, diff --git a/src/pages/inbox/BaseInbox.tsx b/src/pages/inbox/BaseInbox.tsx index fb3da68852..c0b82a4d29 100644 --- a/src/pages/inbox/BaseInbox.tsx +++ b/src/pages/inbox/BaseInbox.tsx @@ -150,6 +150,18 @@ const InboxPage: FC = (props) => { [dispatch], ); + const handleFeedItemUnfollowed = useCallback( + (itemId: string) => { + dispatch( + inboxActions.updateFeedItem({ + item: { id: itemId }, + isRemoved: true, + }), + ); + }, + [dispatch], + ); + const handleActiveItemChange = useCallback( (activeItemId?: string) => { dispatch(inboxActions.removeEmptyChatChannelItems(activeItemId)); @@ -221,10 +233,6 @@ const InboxPage: FC = (props) => { useEffect(() => { fetchData(); - - return () => { - dispatch(inboxActions.resetInbox()); - }; }, [userId]); useEffect(() => { @@ -271,6 +279,7 @@ const InboxPage: FC = (props) => { renderFeedItemBaseContent={renderFeedItemBaseContent} renderChatChannelItem={renderChatChannelItem} onFeedItemUpdate={handleFeedItemUpdate} + onFeedItemUnfollowed={handleFeedItemUnfollowed} getLastMessage={getLastMessage} sharedFeedItemId={sharedFeedItemId} emptyText={ diff --git a/src/services/Chat.ts b/src/services/Chat.ts index d49b6aa7cd..b46e3a0284 100644 --- a/src/services/Chat.ts +++ b/src/services/Chat.ts @@ -234,6 +234,28 @@ class ChatService { }); }; + public getChatChannels = async (options: { + participantId: string; + startAt?: Timestamp; + endAt?: Timestamp; + }): Promise => { + const { participantId, startAt, endAt } = options; + let query = this.getChatChannelCollection() + .where("participants", "array-contains", participantId) + .orderBy("updatedAt", "desc"); + + if (startAt) { + query = query.startAt(startAt); + } + if (endAt) { + query = query.endAt(endAt); + } + + const snapshot = await query.get(); + + return snapshot.docs.map((doc) => doc.data()); + }; + public subscribeToNewUpdatedChatChannels = ( participantId: string, endBefore: Timestamp, diff --git a/src/services/FeedItemFollows.ts b/src/services/FeedItemFollows.ts index c74bf40d10..98a3d99626 100644 --- a/src/services/FeedItemFollows.ts +++ b/src/services/FeedItemFollows.ts @@ -116,6 +116,29 @@ class FeedItemFollowsService { await Api.post(ApiEndpoint.FollowFeedItem, data, { cancelToken }); }; + public getFollowFeedItems = async (options: { + userId: string; + startAt?: Timestamp; + endAt?: Timestamp; + }): Promise => { + const { userId, startAt, endAt } = options; + let query = this.getFeedItemFollowsSubCollection(userId).orderBy( + "lastActivity", + "desc", + ); + + if (startAt) { + query = query.startAt(startAt); + } + if (endAt) { + query = query.endAt(endAt); + } + + const snapshot = await query.get(); + + return snapshot.docs.map((doc) => doc.data()); + }; + public subscribeToNewUpdatedFollowFeedItem = ( userId: string, endBefore: Timestamp, diff --git a/src/shared/appConfig.ts b/src/shared/appConfig.ts index 2650847bfd..07f6406505 100644 --- a/src/shared/appConfig.ts +++ b/src/shared/appConfig.ts @@ -1,6 +1,6 @@ import configureStore from "@/store"; import history from "./history"; -const { store } = configureStore(history); +const { store, persistor } = configureStore(history); -export { history, store }; +export { history, store, persistor }; diff --git a/src/shared/hooks/useCases/useCommonFeedItems.ts b/src/shared/hooks/useCases/useCommonFeedItems.ts index 3a4a6c2f66..f4f5a87edd 100644 --- a/src/shared/hooks/useCases/useCommonFeedItems.ts +++ b/src/shared/hooks/useCases/useCommonFeedItems.ts @@ -11,6 +11,7 @@ interface Return export const useCommonFeedItems = ( commonId: string, idsForNotListening?: string[], + sharedFeedItemId?: string | null, ): Return => { const dispatch = useDispatch(); const feedItems = useSelector(selectFeedItems); @@ -20,6 +21,7 @@ export const useCommonFeedItems = ( dispatch( commonActions.getFeedItems.request({ commonId, + sharedFeedItemId, feedItemId, limit: 15, }), @@ -67,7 +69,6 @@ export const useCommonFeedItems = ( dispatch( commonActions.getFeedItems.cancel("Cancel feed items fetch on unmount"), ); - dispatch(commonActions.resetFeedItems()); }; }, []); diff --git a/src/shared/hooks/useCases/useFeedItemFollow.ts b/src/shared/hooks/useCases/useFeedItemFollow.ts index 0fc1e02687..3524366d07 100644 --- a/src/shared/hooks/useCases/useFeedItemFollow.ts +++ b/src/shared/hooks/useCases/useFeedItemFollow.ts @@ -2,6 +2,7 @@ import { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { selectUser } from "@/pages/Auth/store/selectors"; import { FollowFeedItemAction } from "@/shared/constants"; +import { FeedItemFollow } from "@/shared/models"; import { selectCommonFeedFollows, selectFollowFeedItemMutationState, @@ -15,6 +16,8 @@ export interface FeedItemFollowState { isFollowing: boolean; isDisabled: boolean; onFollowToggle: (action?: FollowFeedItemAction) => void; + isUserFeedItemFollowDataFetched: boolean; + userFeedItemFollowData: FeedItemFollow | null; } interface Data { @@ -105,5 +108,7 @@ export function useFeedItemFollow( isFollowing, isDisabled, onFollowToggle, + isUserFeedItemFollowDataFetched, + userFeedItemFollowData, }; } diff --git a/src/shared/hooks/useCases/useInboxItems.ts b/src/shared/hooks/useCases/useInboxItems.ts index e9329172d4..82805954d4 100644 --- a/src/shared/hooks/useCases/useInboxItems.ts +++ b/src/shared/hooks/useCases/useInboxItems.ts @@ -9,8 +9,13 @@ import { Logger, } from "@/services"; import { InboxItemType } from "@/shared/constants"; +import { useIsMounted } from "@/shared/hooks"; import { FeedLayoutItemWithFollowData } from "@/shared/interfaces"; -import { FeedItemFollow, FeedItemFollowWithMetadata } from "@/shared/models"; +import { + ChatChannel, + FeedItemFollow, + FeedItemFollowWithMetadata, +} from "@/shared/models"; import { inboxActions, InboxItems, selectInboxItems } from "@/store/states"; interface Return @@ -115,6 +120,7 @@ export const useInboxItems = ( options?: { unread?: boolean }, ): Return => { const dispatch = useDispatch(); + const isMounted = useIsMounted(); const [newItemsBatches, setNewItemsBatches] = useState([]); const inboxItems = useSelector(selectInboxItems); const user = useSelector(selectUser()); @@ -135,6 +141,114 @@ export const useInboxItems = ( fetch(); }; + const addNewChatChannels = ( + data: { + chatChannel: ChatChannel; + statuses: { + isAdded: boolean; + isRemoved: boolean; + }; + }[], + ) => { + const finalData = + feedItemIdsForNotListening && feedItemIdsForNotListening.length > 0 + ? data.filter( + (item) => !feedItemIdsForNotListening.includes(item.chatChannel.id), + ) + : data; + + if (finalData.length === 0) { + return; + } + + dispatch( + inboxActions.addNewInboxItems( + finalData.map((item) => ({ + item: { + itemId: item.chatChannel.id, + type: InboxItemType.ChatChannel, + chatChannel: item.chatChannel, + }, + statuses: item.statuses, + })), + ), + ); + }; + + const addNewFollowFeedItems = ( + data: { + item: FeedItemFollow; + statuses: { + isAdded: boolean; + isRemoved: boolean; + }; + }[], + ) => { + if (data.length === 0) { + return; + } + + const finalData = + feedItemIdsForNotListening && feedItemIdsForNotListening.length > 0 + ? data.filter( + (item) => + !feedItemIdsForNotListening.includes(item.item.feedItemId), + ) + : data; + setNewItemsBatches((currentItems) => [...currentItems, finalData]); + }; + + useEffect(() => { + (async () => { + try { + const { firstDocTimestamp: startAt, lastDocTimestamp: endAt } = + inboxItems; + + if (!userId || !startAt || !endAt) { + return; + } + + const [chatChannels, feedItemFollows] = await Promise.all([ + ChatService.getChatChannels({ + participantId: userId, + startAt, + endAt, + }), + FeedItemFollowsService.getFollowFeedItems({ + userId, + startAt, + endAt, + }), + ]); + + if (!isMounted()) { + return; + } + + addNewChatChannels( + chatChannels.map((chatChannel) => ({ + chatChannel, + statuses: { + isAdded: false, + isRemoved: false, + }, + })), + ); + addNewFollowFeedItems( + feedItemFollows.map((item) => ({ + item, + statuses: { + isAdded: false, + isRemoved: false, + }, + })), + ); + } catch (err) { + Logger.error(err); + } + })(); + }, []); + useEffect(() => { if (!inboxItems.firstDocTimestamp || !userId) { return; @@ -144,30 +258,7 @@ export const useInboxItems = ( userId, inboxItems.firstDocTimestamp, (data) => { - const finalData = - feedItemIdsForNotListening && feedItemIdsForNotListening.length > 0 - ? data.filter( - (item) => - !feedItemIdsForNotListening.includes(item.chatChannel.id), - ) - : data; - - if (finalData.length === 0) { - return; - } - - dispatch( - inboxActions.addNewInboxItems( - finalData.map((item) => ({ - item: { - itemId: item.chatChannel.id, - type: InboxItemType.ChatChannel, - chatChannel: item.chatChannel, - }, - statuses: item.statuses, - })), - ), - ); + addNewChatChannels(data); }, ); @@ -184,18 +275,7 @@ export const useInboxItems = ( userId, inboxItems.firstDocTimestamp, (data) => { - if (data.length === 0) { - return; - } - - const finalData = - feedItemIdsForNotListening && feedItemIdsForNotListening.length > 0 - ? data.filter( - (item) => - !feedItemIdsForNotListening.includes(item.item.feedItemId), - ) - : data; - setNewItemsBatches((currentItems) => [...currentItems, finalData]); + addNewFollowFeedItems(data); }, ); diff --git a/src/shared/interfaces/feedLayout.ts b/src/shared/interfaces/feedLayout.ts index 48407f0d3e..284d03781a 100644 --- a/src/shared/interfaces/feedLayout.ts +++ b/src/shared/interfaces/feedLayout.ts @@ -5,6 +5,7 @@ import { CommonFeed, FeedItemFollowWithMetadata, } from "@/shared/models"; +import { convertObjectDatesToFirestoreTimestamps } from "@/shared/utils"; export interface FeedLayoutRef { setExpandedFeedItemId: (feedItemId: string | null) => void; @@ -67,3 +68,45 @@ export type FeedLayoutItemChangeDataWithType = FeedLayoutItemChangeData & commonId: string; } ); + +export const deserializeFeedItemFollowLayoutItem = < + T extends FeedItemFollowLayoutItem | FeedItemFollowLayoutItemWithFollowData, +>( + item: T, +): T => ({ + ...item, + feedItem: convertObjectDatesToFirestoreTimestamps(item.feedItem), + feedItemFollowWithMetadata: item.feedItemFollowWithMetadata && { + ...convertObjectDatesToFirestoreTimestamps( + item.feedItemFollowWithMetadata, + ["lastSeen", "lastActivity"], + ), + feedItem: convertObjectDatesToFirestoreTimestamps( + item.feedItemFollowWithMetadata.feedItem, + ), + }, +}); + +export const deserializeChatChannelLayoutItem = ( + item: ChatChannelLayoutItem, +): ChatChannelLayoutItem => ({ + ...item, + chatChannel: convertObjectDatesToFirestoreTimestamps( + item.chatChannel, + ["lastMessage.createdAt"], + ), +}); + +export const deserializeFeedLayoutItem = ( + item: FeedLayoutItem, +): FeedLayoutItem => + checkIsChatChannelLayoutItem(item) + ? deserializeChatChannelLayoutItem(item) + : deserializeFeedItemFollowLayoutItem(item); + +export const deserializeFeedLayoutItemWithFollowData = ( + item: FeedLayoutItemWithFollowData, +): FeedLayoutItemWithFollowData => + checkIsChatChannelLayoutItem(item) + ? deserializeChatChannelLayoutItem(item) + : deserializeFeedItemFollowLayoutItem(item); diff --git a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/hooks/useProjectsData.ts b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/hooks/useProjectsData.ts index 3e7b0fb903..24fdce0ec0 100644 --- a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/hooks/useProjectsData.ts +++ b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/hooks/useProjectsData.ts @@ -104,22 +104,16 @@ export const useProjectsData = (): Return => { }, [isAuthenticated]); useEffect(() => { - if (areCommonsLoading) { - return; - } if (!areCommonsFetched) { dispatch(commonLayoutActions.getCommons.request(activeItemId)); } - }, [areCommonsLoading, areCommonsFetched]); + }, [areCommonsFetched]); useEffect(() => { - if (areProjectsLoading || !currentCommonId) { - return; - } - if (!areProjectsFetched) { + if (currentCommonId && !areProjectsFetched) { dispatch(commonLayoutActions.getProjects.request(currentCommonId)); } - }, [areProjectsLoading, areProjectsFetched, currentCommonId]); + }, [areProjectsFetched, currentCommonId]); useEffect(() => { if ( diff --git a/src/shared/utils/checkIsSynchronizedDate.ts b/src/shared/utils/checkIsSynchronizedDate.ts new file mode 100644 index 0000000000..b06ed1f63d --- /dev/null +++ b/src/shared/utils/checkIsSynchronizedDate.ts @@ -0,0 +1,4 @@ +import { SynchronizedDate } from "@/shared/interfaces"; + +export const checkIsSynchronizedDate = (date: any): date is SynchronizedDate => + Boolean(date && date._seconds); diff --git a/src/shared/utils/convertDatesToFirestoreTimestamps.ts b/src/shared/utils/convertDatesToFirestoreTimestamps.ts index 4398fed138..ed5022b2f1 100644 --- a/src/shared/utils/convertDatesToFirestoreTimestamps.ts +++ b/src/shared/utils/convertDatesToFirestoreTimestamps.ts @@ -1,11 +1,14 @@ -import firebase from "firebase"; import { get, set } from "lodash"; import { SynchronizedDate } from "@/shared/interfaces"; +import { Timestamp } from "@/shared/models"; +import { checkIsSynchronizedDate } from "@/shared/utils"; export const convertToTimestamp = ( - date: SynchronizedDate, -): firebase.firestore.Timestamp => - new firebase.firestore.Timestamp(date._seconds, date._nanoseconds); + date: SynchronizedDate | Timestamp, +): Timestamp => + checkIsSynchronizedDate(date) + ? new Timestamp(date._seconds, date._nanoseconds) + : new Timestamp(date.seconds, date.nanoseconds); const convertDateInObject = ( data: Record, diff --git a/src/shared/utils/index.tsx b/src/shared/utils/index.tsx index 74e159b1d9..c0151ea0ec 100755 --- a/src/shared/utils/index.tsx +++ b/src/shared/utils/index.tsx @@ -25,6 +25,7 @@ export * from "./routes"; export * from "./checkIsAutomaticJoin"; export * from "./checkIsIFrame"; export * from "./checkIsProject"; +export * from "./checkIsSynchronizedDate"; export * from "./checkIsURL"; export * from "./circles"; export * from "./notifications"; diff --git a/src/store/states/cache/actions.ts b/src/store/states/cache/actions.ts index c46f98db89..24f00c60e3 100644 --- a/src/store/states/cache/actions.ts +++ b/src/store/states/cache/actions.ts @@ -10,6 +10,7 @@ import { User, } from "@/shared/models"; import { CacheActionType } from "./constants"; +import { FeedState } from "./types"; export const getUserStateById = createAsyncAction( CacheActionType.GET_USER_STATE_BY_ID, @@ -131,6 +132,21 @@ export const updateProposalStateById = createStandardAction( state: LoadingState; }>(); +export const copyFeedStateByCommonId = createStandardAction( + CacheActionType.COPY_FEED_STATE_BY_COMMON_ID, +)(); + +export const updateFeedStateByCommonId = createStandardAction( + CacheActionType.UPDATE_FEED_STATE_BY_COMMON_ID, +)<{ + commonId: string; + state: FeedState; +}>(); + +export const resetFeedStates = createStandardAction( + CacheActionType.RESET_FEED_STATES, +)(); + export const getFeedItemUserMetadata = createAsyncAction( CacheActionType.GET_FEED_ITEM_USER_METADATA, CacheActionType.GET_FEED_ITEM_USER_METADATA_SUCCESS, diff --git a/src/store/states/cache/constants.ts b/src/store/states/cache/constants.ts index 042ff0840c..cfa3c62449 100644 --- a/src/store/states/cache/constants.ts +++ b/src/store/states/cache/constants.ts @@ -30,6 +30,10 @@ export enum CacheActionType { UPDATE_PROPOSAL_STATE_BY_ID = "@CACHE/UPDATE_PROPOSAL_STATE_BY_ID", + COPY_FEED_STATE_BY_COMMON_ID = "@CACHE/COPY_FEED_STATE_BY_COMMON_ID", + UPDATE_FEED_STATE_BY_COMMON_ID = "@CACHE/UPDATE_FEED_STATE_BY_COMMON_ID", + RESET_FEED_STATES = "@CACHE/RESET_FEED_STATES", + GET_FEED_ITEM_USER_METADATA = "@CACHE/GET_FEED_ITEM_USER_METADATA", GET_FEED_ITEM_USER_METADATA_SUCCESS = "@CACHE/GET_FEED_ITEM_USER_METADATA_SUCCESS", GET_FEED_ITEM_USER_METADATA_FAILURE = "@CACHE/GET_FEED_ITEM_USER_METADATA_FAILURE", diff --git a/src/store/states/cache/reducer.tsx b/src/store/states/cache/reducer.tsx index 95df165190..0a1a60cefa 100644 --- a/src/store/states/cache/reducer.tsx +++ b/src/store/states/cache/reducer.tsx @@ -13,6 +13,7 @@ const initialState: CacheState = { discussionStates: {}, discussionMessagesStates: {}, proposalStates: {}, + feedByCommonIdStates: {}, feedItemUserMetadataStates: {}, chatChannelUserStatusStates: {}, }; @@ -97,6 +98,18 @@ export const reducer = createReducer(initialState) nextState.proposalStates[proposalId] = { ...state }; }), ) + .handleAction(actions.updateFeedStateByCommonId, (state, { payload }) => + produce(state, (nextState) => { + const { commonId, state } = payload; + + nextState.feedByCommonIdStates[commonId] = { ...state }; + }), + ) + .handleAction(actions.resetFeedStates, (state) => + produce(state, (nextState) => { + nextState.feedByCommonIdStates = {}; + }), + ) .handleAction(actions.updateFeedItemUserMetadata, (state, { payload }) => produce(state, (nextState) => { const { commonId, userId, feedObjectId, state } = payload; diff --git a/src/store/states/cache/saga/copyFeedStateByCommonId.ts b/src/store/states/cache/saga/copyFeedStateByCommonId.ts new file mode 100644 index 0000000000..ab3405ba70 --- /dev/null +++ b/src/store/states/cache/saga/copyFeedStateByCommonId.ts @@ -0,0 +1,35 @@ +import { put, select } from "redux-saga/effects"; +import { getFeedLayoutItemDateForSorting } from "@/store/states/inbox/utils"; +import { selectCommonState, CommonState } from "../../common"; +import * as actions from "../actions"; + +export function* copyFeedStateByCommonId({ + payload: commonId, +}: ReturnType) { + const commonState = (yield select(selectCommonState)) as CommonState; + const data = + commonState.feedItems.data && commonState.feedItems.data.slice(0, 30); + const feedItems = { + ...commonState.feedItems, + data, + loading: false, + hasMore: true, + firstDocTimestamp: data?.[0] + ? getFeedLayoutItemDateForSorting(data[0]) + : null, + lastDocTimestamp: data?.[data.length - 1] + ? getFeedLayoutItemDateForSorting(data[data.length - 1]) + : null, + }; + + yield put( + actions.updateFeedStateByCommonId({ + commonId, + state: { + feedItems, + pinnedFeedItems: commonState.pinnedFeedItems, + sharedFeedItem: commonState.sharedFeedItem, + }, + }), + ); +} diff --git a/src/store/states/cache/saga/index.ts b/src/store/states/cache/saga/index.ts index 802ac87ead..0eeba6aa13 100644 --- a/src/store/states/cache/saga/index.ts +++ b/src/store/states/cache/saga/index.ts @@ -1,6 +1,8 @@ +import { takeLatest } from "redux-saga/effects"; import { getFeedItemUserMetadataKey } from "@/shared/constants/getFeedItemUserMetadataKey"; import { takeLeadingByIdentifier } from "@/shared/utils/saga"; import * as actions from "../actions"; +import { copyFeedStateByCommonId } from "./copyFeedStateByCommonId"; import { getDiscussionStateById } from "./getDiscussionStateById"; import { getFeedItemUserMetadataState } from "./getFeedItemUserMetadataState"; import { getGovernanceStateByCommonId } from "./getGovernanceStateByCommonId"; @@ -33,4 +35,5 @@ export function* mainSaga() { ({ payload: { payload } }) => getFeedItemUserMetadataKey(payload), getFeedItemUserMetadataState, ); + yield takeLatest(actions.copyFeedStateByCommonId, copyFeedStateByCommonId); } diff --git a/src/store/states/cache/selectors.ts b/src/store/states/cache/selectors.ts index 60ec9ed317..3bfb83d902 100644 --- a/src/store/states/cache/selectors.ts +++ b/src/store/states/cache/selectors.ts @@ -21,6 +21,10 @@ export const selectDiscussionMessagesStateByDiscussionId = (discussionId: string) => (state: AppState) => state.cache.discussionMessagesStates[discussionId] || null; +export const selectFeedStateByCommonId = + (commonId: string) => (state: AppState) => + state.cache.feedByCommonIdStates[commonId] || null; + export const selectFeedItemUserMetadata = (info: { commonId: string; userId: string; feedObjectId: string }) => (state: AppState) => diff --git a/src/store/states/cache/types.ts b/src/store/states/cache/types.ts index 17bf5b0e6f..4d99eec351 100644 --- a/src/store/states/cache/types.ts +++ b/src/store/states/cache/types.ts @@ -8,6 +8,12 @@ import { Proposal, User, } from "@/shared/models"; +import { CommonState } from "../common"; + +export type FeedState = Pick< + CommonState, + "feedItems" | "pinnedFeedItems" | "sharedFeedItem" +>; export interface CacheState { userStates: Record>; @@ -18,6 +24,7 @@ export interface CacheState { string, LoadingState >; + feedByCommonIdStates: Record; // key: {commonId}_{userId}_{feedObjectId} feedItemUserMetadataStates: Record< string, diff --git a/src/store/states/common/actions.ts b/src/store/states/common/actions.ts index a83093fe8e..e71f074cb5 100644 --- a/src/store/states/common/actions.ts +++ b/src/store/states/common/actions.ts @@ -20,7 +20,7 @@ import { Proposal, } from "@/shared/models"; import { CommonActionType } from "./constants"; -import { FeedItems, PinnedFeedItems } from "./types"; +import { CommonState, FeedItems, PinnedFeedItems } from "./types"; export const resetCommon = createStandardAction( CommonActionType.RESET_COMMON, @@ -116,6 +116,7 @@ export const getFeedItems = createAsyncAction( )< { commonId: string; + sharedFeedItemId?: string | null; feedItemId?: string; limit?: number; }, @@ -138,6 +139,13 @@ export const getPinnedFeedItems = createAsyncAction( string >(); +export const setFeedState = createStandardAction( + CommonActionType.SET_FEED_STATE, +)<{ + data: Pick; + sharedFeedItemId?: string | null; +}>(); + export const addNewFeedItems = createStandardAction( CommonActionType.ADD_NEW_FEED_ITEMS, )< diff --git a/src/store/states/common/constants.ts b/src/store/states/common/constants.ts index bbb9cdb0dc..a0e1f79e8a 100644 --- a/src/store/states/common/constants.ts +++ b/src/store/states/common/constants.ts @@ -26,6 +26,8 @@ export enum CommonActionType { GET_PINNED_FEED_ITEMS_FAILURE = "@COMMON/GET_PINNED_FEED_ITEMS_FAILURE", GET_PINNED_FEED_ITEMS_CANCEL = "@COMMON/GET_PINNED_FEED_ITEMS_CANCEL", + SET_FEED_STATE = "@COMMON/SET_FEED_STATE", + ADD_NEW_FEED_ITEMS = "@COMMON/ADD_NEW_FEED_ITEMS", ADD_NEW_PINNED_FEED_ITEMS = "@COMMON/ADD_NEW_PINNED_FEED_ITEMS", diff --git a/src/store/states/common/reducer.ts b/src/store/states/common/reducer.ts index 5e4b5fbd07..94bf974e80 100644 --- a/src/store/states/common/reducer.ts +++ b/src/store/states/common/reducer.ts @@ -2,8 +2,12 @@ import produce from "immer"; import { WritableDraft } from "immer/dist/types/types-external"; import { ActionType, createReducer } from "typesafe-actions"; import { InboxItemType } from "@/shared/constants"; -import { FeedItemFollowLayoutItem } from "@/shared/interfaces"; +import { + deserializeFeedItemFollowLayoutItem, + FeedItemFollowLayoutItem, +} from "@/shared/interfaces"; import { CommonFeed } from "@/shared/models"; +import { convertToTimestamp } from "@/shared/utils"; import * as actions from "./actions"; import { CommonState, FeedItems, PinnedFeedItems } from "./types"; @@ -47,8 +51,7 @@ const initialState: CommonState = { const sortFeedItems = (data: FeedItemFollowLayoutItem[]): void => { data.sort( (prevItem, nextItem) => - nextItem.feedItem.updatedAt.toMillis() - - prevItem.feedItem.updatedAt.toMillis(), + nextItem.feedItem.updatedAt.seconds - prevItem.feedItem.updatedAt.seconds, ); }; @@ -469,6 +472,62 @@ export const reducer = createReducer(initialState) }; }), ) + .handleAction(actions.setFeedState, (state, { payload }) => + produce(state, (nextState) => { + const { + data: { feedItems, pinnedFeedItems, sharedFeedItem }, + sharedFeedItemId, + } = payload; + nextState.feedItems = { + ...feedItems, + data: + feedItems.data && + feedItems.data.map(deserializeFeedItemFollowLayoutItem), + firstDocTimestamp: + (feedItems.firstDocTimestamp && + convertToTimestamp(feedItems.firstDocTimestamp)) || + null, + lastDocTimestamp: + (feedItems.lastDocTimestamp && + convertToTimestamp(feedItems.lastDocTimestamp)) || + null, + hasMore: true, + }; + nextState.pinnedFeedItems = { + ...pinnedFeedItems, + data: + pinnedFeedItems.data && + pinnedFeedItems.data.map(deserializeFeedItemFollowLayoutItem), + }; + + if (sharedFeedItem && sharedFeedItem.itemId === sharedFeedItemId) { + return; + } + if ( + sharedFeedItem && + !pinnedFeedItems.data?.some( + (item) => item.itemId === sharedFeedItem.itemId, + ) && + !feedItems.data?.some((item) => item.itemId === sharedFeedItem.itemId) + ) { + const data = [sharedFeedItem, ...(feedItems.data || [])]; + sortFeedItems(data); + nextState.feedItems.data = data; + } + if (sharedFeedItemId) { + nextState.feedItems.data = + nextState.feedItems.data && + nextState.feedItems.data.filter( + (item) => item.itemId !== sharedFeedItemId, + ); + nextState.pinnedFeedItems.data = + nextState.pinnedFeedItems.data && + nextState.pinnedFeedItems.data.filter( + (item) => item.itemId !== sharedFeedItemId, + ); + } + }), + ) .handleAction(actions.addNewFeedItems, (state, { payload }) => produce(state, (nextState) => { addNewFeedItems(nextState, payload); diff --git a/src/store/states/common/saga/getFeedItems.ts b/src/store/states/common/saga/getFeedItems.ts index 3ab56ccb61..5077852c33 100644 --- a/src/store/states/common/saga/getFeedItems.ts +++ b/src/store/states/common/saga/getFeedItems.ts @@ -3,6 +3,7 @@ import { CommonFeedService } from "@/services"; import { InboxItemType } from "@/shared/constants"; import { Awaited, FeedItemFollowLayoutItem } from "@/shared/interfaces"; import { isError } from "@/shared/utils"; +import { selectFeedStateByCommonId } from "@/store/states"; import * as actions from "../actions"; import { selectFeedItems } from "../selectors"; import { FeedItems } from "../types"; @@ -11,11 +12,23 @@ export function* getFeedItems( action: ReturnType, ) { const { - payload: { commonId, feedItemId, limit }, + payload: { commonId, sharedFeedItemId, feedItemId, limit }, } = action; try { const currentFeedItems = (yield select(selectFeedItems)) as FeedItems; + const cachedFeedState = yield select(selectFeedStateByCommonId(commonId)); + + if (!currentFeedItems.data && !feedItemId && cachedFeedState) { + yield put( + actions.setFeedState({ + data: cachedFeedState, + sharedFeedItemId, + }), + ); + return; + } + const isFirstRequest = !currentFeedItems.lastDocTimestamp; const { data, firstDocTimestamp, lastDocTimestamp, hasMore } = (yield call( CommonFeedService.getCommonFeedItemsByUpdatedAt, diff --git a/src/store/states/common/selectors.ts b/src/store/states/common/selectors.ts index ba61e5c62d..5b1005b293 100644 --- a/src/store/states/common/selectors.ts +++ b/src/store/states/common/selectors.ts @@ -1,5 +1,7 @@ import { AppState } from "@/shared/interfaces"; +export const selectCommonState = (state: AppState) => state.common; + export const selectCommonAction = (state: AppState) => state.common.commonAction; diff --git a/src/store/states/inbox/reducer.ts b/src/store/states/inbox/reducer.ts index 55d625cfa3..87dca602b7 100644 --- a/src/store/states/inbox/reducer.ts +++ b/src/store/states/inbox/reducer.ts @@ -34,8 +34,8 @@ const initialState: InboxState = { const sortInboxItems = (data: FeedLayoutItemWithFollowData[]): void => { data.sort( (prevItem, nextItem) => - getFeedLayoutItemDateForSorting(nextItem).toMillis() - - getFeedLayoutItemDateForSorting(prevItem).toMillis(), + getFeedLayoutItemDateForSorting(nextItem).seconds - + getFeedLayoutItemDateForSorting(prevItem).seconds, ); }; diff --git a/src/store/store.tsx b/src/store/store.tsx index 9cb6565f86..3ed1582821 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -1,3 +1,5 @@ +import { routerMiddleware } from "connected-react-router"; +import { History } from "history"; import { createStore, applyMiddleware, @@ -6,14 +8,32 @@ import { Dispatch, Store, } from "redux"; -import { History } from "history"; -import { routerMiddleware } from "connected-react-router"; import { composeWithDevTools } from "redux-devtools-extension"; import freeze from "redux-freeze"; +import { persistStore, persistReducer, PersistConfig } from "redux-persist"; +import autoMergeLevel2 from "redux-persist/lib/stateReconciler/autoMergeLevel2"; +import storage from "redux-persist/lib/storage"; import createSagaMiddleware from "redux-saga"; -import { AppState } from '@/shared/interfaces'; -import appSagas from "./saga"; +import { AppState } from "@/shared/interfaces"; import rootReducer from "./reducer"; +import appSagas from "./saga"; +import { inboxTransform } from "./transforms"; + +const persistConfig: PersistConfig = { + key: "root", + storage, + whitelist: [ + "projects", + "commonLayout", + "commonFeedFollows", + "cache", + "chat", + "inbox", + "multipleSpacesLayout", + ], + stateReconciler: autoMergeLevel2, + transforms: [inboxTransform], +}; const sagaMiddleware = createSagaMiddleware(); let middleware: Array; @@ -28,52 +48,65 @@ if (process.env.NODE_ENV === "development") { composer = compose; } -const errorHandlerMiddleware: Middleware = () => (next: Dispatch) => (action) => { - if (action.type.includes("FAILURE")) { - // next( - // showNotification({ - // message: action.payload.error || action.payload.message, - // appearance: "error", - // }), - // ); +const errorHandlerMiddleware: Middleware = + () => (next: Dispatch) => (action) => { + if (action.type.includes("FAILURE")) { + // next( + // showNotification({ + // message: action.payload.error || action.payload.message, + // appearance: "error", + // }), + // ); - if (action.payload && (action.payload.code === 401 || action.payload.code === 403)) { - localStorage.clear(); + if ( + action.payload && + (action.payload.code === 401 || action.payload.code === 403) + ) { + localStorage.clear(); + } } - } - if (action.type.includes("SUCCESS") && action.payload && action.payload.message) { - // next( - // showNotification({ - // message: action.payload.message, - // appearance: "success", - // }), - // ); - } + if ( + action.type.includes("SUCCESS") && + action.payload && + action.payload.message + ) { + // next( + // showNotification({ + // message: action.payload.message, + // appearance: "success", + // }), + // ); + } - return next(action); -}; + return next(action); + }; +// defaults to localStorage for web export default function configureStore(history: History) { + const persistedReducer = persistReducer(persistConfig, rootReducer(history)); const store: Store = createStore( - rootReducer(history), + persistedReducer, undefined, composer( applyMiddleware( ...middleware, routerMiddleware(history), - errorHandlerMiddleware - ) - ) + errorHandlerMiddleware, + ), + ), ); + const persistor = persistStore(store); sagaMiddleware.run(appSagas); // eslint-disable-next-line if ((module as any).hot) { // eslint-disable-next-line - (module as any).hot.accept(() => store.replaceReducer(rootReducer(history))); + (module as any).hot.accept(() => + store.replaceReducer(persistedReducer as any), + ); } - return { store }; + return { store, persistor }; } diff --git a/src/store/transforms.ts b/src/store/transforms.ts new file mode 100644 index 0000000000..98efc666da --- /dev/null +++ b/src/store/transforms.ts @@ -0,0 +1,45 @@ +import { createTransform } from "redux-persist"; +import { deserializeFeedLayoutItemWithFollowData } from "@/shared/interfaces"; +import { convertObjectDatesToFirestoreTimestamps } from "@/shared/utils"; +import { getFeedLayoutItemDateForSorting } from "@/store/states/inbox/utils"; +import { InboxItems, InboxState } from "./states/inbox"; + +export const inboxTransform = createTransform( + (inboundState: InboxState) => { + const data = + inboundState.items.data && inboundState.items.data.slice(0, 30); + + return { + ...inboundState, + items: { + ...inboundState.items, + data, + loading: false, + hasMore: true, + firstDocTimestamp: data?.[0] + ? getFeedLayoutItemDateForSorting(data[0]) + : null, + lastDocTimestamp: data?.[data.length - 1] + ? getFeedLayoutItemDateForSorting(data[data.length - 1]) + : null, + }, + }; + }, + (outboundState: InboxState) => ({ + ...outboundState, + sharedItem: + outboundState.sharedItem && + deserializeFeedLayoutItemWithFollowData(outboundState.sharedItem), + chatChannelItems: [], + items: { + ...convertObjectDatesToFirestoreTimestamps( + outboundState.items, + ["firstDocTimestamp", "lastDocTimestamp"], + ), + data: + outboundState.items.data && + outboundState.items.data.map(deserializeFeedLayoutItemWithFollowData), + }, + }), + { whitelist: ["inbox"] }, +); diff --git a/yarn.lock b/yarn.lock index e4e8ed095d..a0cc55ab8e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17145,6 +17145,11 @@ redux-freeze@^0.1.7: dependencies: deep-freeze-strict "1.1.1" +redux-persist@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" + integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ== + redux-saga@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/redux-saga/-/redux-saga-1.1.3.tgz#9f3e6aebd3c994bbc0f6901a625f9a42b51d1112"