diff --git a/package.json b/package.json index 1553bfedca..ff0fa568ab 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "@floating-ui/react-dom-interactions": "^0.13.1", "@headlessui/react": "^1.7.4", "@storybook/addon-viewport": "^6.5.13", + "@tanstack/react-query": "4.5.0", "@tanstack/react-table": "^8.7.9", "@types/react-pdf": "^5.7.2", "axios": "^0.21.0", @@ -33,7 +34,6 @@ "lodash": "^4.17.20", "millify": "^3.5.2", "moment": "^2.29.1", - "node-sass": "^4.14.1", "openpgp": "4.10.4", "pluralize": "^8.0.0", "query-string": "^7.1.0", @@ -67,6 +67,7 @@ "redux-persist": "^6.0.0", "redux-saga": "^1.1.3", "reselect": "^4.0.0", + "node-sass": "^4.14.1", "slate": "0.94.1", "slate-history": "0.93.0", "slate-react": "0.98.1", diff --git a/src/globals.d.ts b/src/globals.d.ts index df83a474fd..da7a606ee4 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -21,3 +21,8 @@ declare global { }; } } + +declare module '*.module.scss' { + const classes: { [key: string]: string }; + export default classes; +} diff --git a/src/pages/App/App.tsx b/src/pages/App/App.tsx index 5e0c242064..7e0f058f61 100644 --- a/src/pages/App/App.tsx +++ b/src/pages/App/App.tsx @@ -16,6 +16,13 @@ import { NotificationsHandler, } from "./handlers"; import { Router } from "./router"; +import { + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query' + +// Create a client +const queryClient = new QueryClient() const App = () => { const dispatch = useDispatch(); @@ -28,17 +35,19 @@ const App = () => { }, [dispatch, isDesktop]); return ( - - - - - - - - - - - + + + + + + + + + + + + + ); }; diff --git a/src/pages/App/router/components/LayoutRoute/types.ts b/src/pages/App/router/components/LayoutRoute/types.ts index 767b001ddc..438831e8f6 100644 --- a/src/pages/App/router/components/LayoutRoute/types.ts +++ b/src/pages/App/router/components/LayoutRoute/types.ts @@ -9,6 +9,7 @@ export interface RenderFunctionOptions configuration: T; userRoles?: UserRole[]; authenticated: boolean; + component?: React.ComponentType; } export type RenderRouteContentFunction = ( diff --git a/src/pages/OldCommon/components/CommonDetailContainer/AddDiscussionComponent/Select/CirclesSelect.tsx b/src/pages/OldCommon/components/CommonDetailContainer/AddDiscussionComponent/Select/CirclesSelect.tsx index 66cb4eadc2..426d19c2e6 100644 --- a/src/pages/OldCommon/components/CommonDetailContainer/AddDiscussionComponent/Select/CirclesSelect.tsx +++ b/src/pages/OldCommon/components/CommonDetailContainer/AddDiscussionComponent/Select/CirclesSelect.tsx @@ -18,7 +18,7 @@ interface CirclesSelectProps { value: CircleSelectType | CircleSelectType[] | null; selectStyles?: StylesConfig; error?: string; - onBlur: (e: React.FocusEvent) => void; + onBlur: (e: React.FocusEvent) => void; isOptionDisabled?: (option: CircleSelectType) => boolean; } diff --git a/src/pages/OldCommon/containers/ProposalContainer/VotingCard.tsx b/src/pages/OldCommon/containers/ProposalContainer/VotingCard.tsx index c192a3e71c..f363035592 100644 --- a/src/pages/OldCommon/containers/ProposalContainer/VotingCard.tsx +++ b/src/pages/OldCommon/containers/ProposalContainer/VotingCard.tsx @@ -1,6 +1,5 @@ -import React, { FC, useMemo } from "react"; +import React, { FC, useMemo, ReactElement } from "react"; import classNames from "classnames"; -import { ReactElement } from "hoist-non-react-statics/node_modules/@types/react"; import { ProgressBar } from "@/shared/components"; import { VotingCardType } from "@/shared/models"; diff --git a/src/pages/OldCommon/hooks/useCommonMembers.ts b/src/pages/OldCommon/hooks/useCommonMembers.ts index f07c8221f5..9ef5ee0df0 100644 --- a/src/pages/OldCommon/hooks/useCommonMembers.ts +++ b/src/pages/OldCommon/hooks/useCommonMembers.ts @@ -116,7 +116,7 @@ export const useCommonMembers = ({ commonId }: Options): Return => { const useCommonMembersTrace = trace(perf, 'useCommonMembers'); useCommonMembersTrace.start(); - const cachedUserStates = selectUserStates()(store.getState()); + const cachedUserStates = selectUserStates(store.getState()); const hasUsersFromCache = commonMembers.some( ({ userId }) => cachedUserStates[userId]?.data, ); diff --git a/src/pages/common/components/ChatComponent/ChatComponent.tsx b/src/pages/common/components/ChatComponent/ChatComponent.tsx index 8fd09ccdd1..d48bd19438 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.tsx +++ b/src/pages/common/components/ChatComponent/ChatComponent.tsx @@ -30,6 +30,7 @@ import { useZoomDisabling, useImageSizeCheck, useQueryParams, + useFetchDiscussionsByCommonId, } from "@/shared/hooks"; import { ArrowInCircleIcon } from "@/shared/icons"; import { LinkPreviewData } from "@/shared/interfaces"; @@ -113,6 +114,7 @@ interface ChatComponentInterface { directParent?: DirectParent | null; renderChatInput?: () => ReactNode; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; } @@ -161,6 +163,7 @@ export default function ChatComponent({ directParent, renderChatInput: renderChatInputOuter, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }: ChatComponentInterface) { @@ -207,6 +210,7 @@ export default function ChatComponent({ }, onFeedItemClick, onUserClick, + onStreamMentionClick, commonId, onInternalLinkClick, }); @@ -220,6 +224,9 @@ export default function ChatComponent({ chatChannelId: chatChannel?.id || "", participants: chatChannel?.participants, }); + + const { data: discussionsData } = useFetchDiscussionsByCommonId(commonId); + const users = useMemo( () => (chatChannel ? chatUsers : discussionUsers), [chatUsers, discussionUsers, chatChannel], @@ -877,6 +884,7 @@ export default function ChatComponent({ onMessageDelete={handleMessageDelete} directParent={directParent} onUserClick={onUserClick} + onStreamMentionClick={onStreamMentionClick} onFeedItemClick={onFeedItemClick} onInternalLinkClick={onInternalLinkClick} isEmpty={ @@ -914,6 +922,7 @@ export default function ChatComponent({ onClearFinished={onClearFinished} shouldReinitializeEditor={shouldReinitializeEditor} users={users} + discussions={discussionsData ?? []} onEnterKeyDown={onEnterKeyDown} emojiCount={emojiCount} setMessage={setMessage} @@ -929,6 +938,10 @@ export default function ChatComponent({ canSendMessage={Boolean(canSendMessage)} inputContainerRef={inputContainerRef} editorRef={editorRef} + user={user} + commonId={commonId} + circleVisibility={discussion?.circleVisibility} + onInternalLinkClick={onInternalLinkClick} /> diff --git a/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx b/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx index 41c6772672..819964cb81 100644 --- a/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx +++ b/src/pages/common/components/ChatComponent/components/ChatContent/ChatContent.tsx @@ -68,6 +68,7 @@ interface ChatContentInterface { onMessageDelete?: (messageId: string) => void; directParent?: DirectParent | null; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (link: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; isEmpty?: boolean; @@ -106,6 +107,7 @@ const ChatContent: ForwardRefRenderFunction< onMessageDelete, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, isEmpty, @@ -292,6 +294,7 @@ const ChatContent: ForwardRefRenderFunction< onMessageDelete={onMessageDelete} directParent={directParent} onUserClick={onUserClick} + onStreamMentionClick={onStreamMentionClick} onFeedItemClick={onFeedItemClick} onInternalLinkClick={onInternalLinkClick} chatChannelId={chatChannelId} @@ -312,6 +315,7 @@ const ChatContent: ForwardRefRenderFunction< onMessageDelete={onMessageDelete} directParent={directParent} onUserClick={onUserClick} + onStreamMentionClick={onStreamMentionClick} onFeedItemClick={onFeedItemClick} onInternalLinkClick={onInternalLinkClick} isMessageEditAllowed={isMessageEditAllowed} diff --git a/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx b/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx index d51f9098dd..69dca3c56f 100644 --- a/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx +++ b/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx @@ -8,7 +8,7 @@ import React, { import classNames from "classnames"; import { FILES_ACCEPTED_EXTENSIONS } from "@/shared/constants"; import { PlusIcon, SendIcon } from "@/shared/icons"; -import { User } from "@/shared/models"; +import { Discussion, User } from "@/shared/models"; import { BaseTextEditor, ButtonIcon, @@ -16,10 +16,10 @@ import { TextEditorSize, TextEditorValue, } from "@/shared/ui-kit"; +import { BaseTextEditorHandles } from "@/shared/ui-kit/TextEditor/BaseTextEditor"; import { EmojiCount } from "@/shared/ui-kit/TextEditor/utils"; -import { emptyFunction } from "@/shared/utils"; +import { emptyFunction, InternalLinkData } from "@/shared/utils"; import styles from "./ChatInput.module.scss"; -import { BaseTextEditorHandles } from "@/shared/ui-kit/TextEditor/BaseTextEditor"; interface ChatInputProps { shouldHideChatInput: boolean; @@ -30,101 +30,120 @@ interface ChatInputProps { emojiCount: EmojiCount; onEnterKeyDown: (event: React.KeyboardEvent) => void; users: User[]; + discussions: Discussion[]; shouldReinitializeEditor: boolean; onClearFinished: () => void; canSendMessage?: boolean; sendChatMessage: () => void; inputContainerRef?: - | MutableRefObject - | RefCallback; + | MutableRefObject + | RefCallback; editorRef?: MutableRefObject | RefCallback; renderChatInputOuter?: () => ReactElement; isAuthorized?: boolean; + circleVisibility?: string[]; + user?: User | null; + commonId?: string; + onInternalLinkClick?: (data: InternalLinkData) => void; } -export const ChatInput = React.memo(forwardRef((props, ref): ReactElement | null => { - const { - inputContainerRef, - editorRef, - canSendMessage, - sendChatMessage, - shouldHideChatInput, - isChatChannel, - renderChatInputOuter, - isAuthorized, - uploadFiles, - message, - setMessage, - emojiCount, - onEnterKeyDown, - users, - shouldReinitializeEditor, - onClearFinished, - } = props; +export const ChatInput = React.memo( + forwardRef( + (props, ref): ReactElement | null => { + const { + inputContainerRef, + editorRef, + canSendMessage, + sendChatMessage, + shouldHideChatInput, + isChatChannel, + renderChatInputOuter, + isAuthorized, + uploadFiles, + message, + setMessage, + emojiCount, + onEnterKeyDown, + users, + discussions, + shouldReinitializeEditor, + onClearFinished, + circleVisibility, + user, + commonId, + onInternalLinkClick, + } = props; - if (shouldHideChatInput) { - return null; - } - if (!isChatChannel) { - const chatInputEl = renderChatInputOuter?.(); + if (shouldHideChatInput) { + return null; + } + if (!isChatChannel) { + const chatInputEl = renderChatInputOuter?.(); - if (chatInputEl || chatInputEl === null) { - return chatInputEl; - } - } - if (!isAuthorized) { - return null; - } + if (chatInputEl || chatInputEl === null) { + return chatInputEl; + } + } + if (!isAuthorized) { + return null; + } - return ( - <> - { - document.getElementById("file")?.click(); - }} - > - - - - - - - ); -})); + return ( + <> + { + document.getElementById("file")?.click(); + }} + > + + + + + + + ); + }, + ), +); diff --git a/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts b/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts index 50cf3590a5..2bd2df4ba4 100644 --- a/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts +++ b/src/pages/common/components/ChatComponent/hooks/useDiscussionChatAdapter.ts @@ -13,6 +13,7 @@ import { InternalLinkData } from "@/shared/utils"; interface Options { hasPermissionToHide: boolean; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; directParent?: DirectParent | null; @@ -37,6 +38,7 @@ export const useDiscussionChatAdapter = (options: Options): Return => { discussionId, onFeedItemClick, onUserClick, + onStreamMentionClick, commonId, onInternalLinkClick, } = options; @@ -63,6 +65,7 @@ export const useDiscussionChatAdapter = (options: Options): Return => { textStyles, onFeedItemClick, onUserClick, + onStreamMentionClick, onInternalLinkClick, }); const { markFeedItemAsSeen } = useUpdateFeedItemSeenState(); diff --git a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx index dcd82a373c..76b20f3d0f 100644 --- a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx +++ b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx @@ -1,3 +1,12 @@ +// import { scan } from 'react-scan'; // import this BEFORE react + +// if (typeof window !== 'undefined') { +// scan({ +// enabled: true, +// log: true, // logs render info to console (default: false) +// }); +// } + import React, { forwardRef, useCallback, @@ -507,6 +516,6 @@ function DiscussionFeedCard(props, ref) { ); } -export default forwardRef( +export default React.memo(forwardRef( DiscussionFeedCard, -); \ No newline at end of file +)); \ No newline at end of file diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkStreamModal/LinkStreamModal.tsx b/src/pages/common/components/DiscussionFeedCard/components/LinkStreamModal/LinkStreamModal.tsx index b3d56b1a68..f240d552fd 100644 --- a/src/pages/common/components/DiscussionFeedCard/components/LinkStreamModal/LinkStreamModal.tsx +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkStreamModal/LinkStreamModal.tsx @@ -110,4 +110,4 @@ const LinkStreamModal: FC = (props) => { ); }; -export default LinkStreamModal; +export default React.memo(LinkStreamModal); diff --git a/src/pages/common/components/DiscussionFeedCard/components/MoveStreamModal/MoveStreamModal.tsx b/src/pages/common/components/DiscussionFeedCard/components/MoveStreamModal/MoveStreamModal.tsx index 46dc541967..49febeb30e 100644 --- a/src/pages/common/components/DiscussionFeedCard/components/MoveStreamModal/MoveStreamModal.tsx +++ b/src/pages/common/components/DiscussionFeedCard/components/MoveStreamModal/MoveStreamModal.tsx @@ -107,4 +107,4 @@ const MoveStreamModal: FC = (props) => { ); }; -export default MoveStreamModal; +export default React.memo(MoveStreamModal); diff --git a/src/pages/common/components/DiscussionFeedCard/components/UnlinkStreamModal/UnlinkStreamModal.tsx b/src/pages/common/components/DiscussionFeedCard/components/UnlinkStreamModal/UnlinkStreamModal.tsx index 76f0886970..c97522a291 100644 --- a/src/pages/common/components/DiscussionFeedCard/components/UnlinkStreamModal/UnlinkStreamModal.tsx +++ b/src/pages/common/components/DiscussionFeedCard/components/UnlinkStreamModal/UnlinkStreamModal.tsx @@ -113,4 +113,4 @@ const UnlinkStreamModal: FC = (props) => { ); }; -export default UnlinkStreamModal; +export default React.memo(UnlinkStreamModal); diff --git a/src/pages/common/components/FeedItem/FeedItem.tsx b/src/pages/common/components/FeedItem/FeedItem.tsx index 15a8d10276..4ee33282d1 100644 --- a/src/pages/common/components/FeedItem/FeedItem.tsx +++ b/src/pages/common/components/FeedItem/FeedItem.tsx @@ -174,6 +174,7 @@ const FeedItem = forwardRef((props, ref) => { shouldPreLoadMessages, withoutMenu, onUserClick: handleUserClick, + onStreamMentionClick: onFeedItemClick, onFeedItemClick, onInternalLinkClick, }), diff --git a/src/pages/commonFeed/CommonFeed.tsx b/src/pages/commonFeed/CommonFeed.tsx index 282a4b60ad..d6f9c1dc96 100644 --- a/src/pages/commonFeed/CommonFeed.tsx +++ b/src/pages/commonFeed/CommonFeed.tsx @@ -133,7 +133,7 @@ const CommonFeedComponent: FC = (props) => { const anotherCommonId = userCommonIds[0] === commonId ? userCommonIds[1] : userCommonIds[0]; const pinnedItemIds = useMemo( - () => commonData?.common.pinnedFeedItems.map((item) => item.feedObjectId), + () => (commonData?.common.pinnedFeedItems ?? []).map((item) => item.feedObjectId), [commonData?.common.pinnedFeedItems], ); @@ -238,7 +238,7 @@ const CommonFeedComponent: FC = (props) => { } return items; - }, [topFeedItemsWithoutOptimistic, optimisticFeedItems]); + }, [optimisticFeedItems, topFeedItemsWithoutOptimistic]); const firstItem = commonFeedItems?.[0]; const isDataFetched = isCommonDataFetched; @@ -290,14 +290,14 @@ const CommonFeedComponent: FC = (props) => { [dispatch, commonId], ); - const scrollToItemsTop = () => { + const scrollToItemsTop = useCallback(() => { setTimeout(() => { feedLayoutRef?.getItemsContainerEl()?.scrollTo({ top: 0, behavior: "smooth", }); }, 0); - }; + }, []); const onJoinCommon = checkIsProject(commonData?.common) ? canJoinProjectAutomatically @@ -607,8 +607,8 @@ const CommonFeedComponent: FC = (props) => { ); }, [ - JSON.stringify(commonData), - JSON.stringify(commonMember), + commonData, + commonMember, commonAction, scrollToItemsTop, ]); @@ -716,4 +716,4 @@ const CommonFeedComponent: FC = (props) => { ); }; -export default CommonFeedComponent; +export default React.memo(CommonFeedComponent); diff --git a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx index 0ce7b452eb..0a1d585e1a 100644 --- a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx +++ b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx @@ -56,6 +56,7 @@ import { ChatChannelFeedLayoutItemProps, checkIsChatChannelLayoutItem, checkIsFeedItemFollowLayoutItem, + FeedItemFollowLayoutItem, FeedLayoutItem, FeedLayoutItemChangeData, FeedLayoutItemChangeDataWithType, @@ -696,8 +697,21 @@ const FeedLayout: ForwardRefRenderFunction = ( } const itemId = data.params[QueryParamKey.Item]; + const discussionItemId = data.params[QueryParamKey.discussionItem]; const messageId = data.params[QueryParamKey.Message]; + if(discussionItemId) { + const feedItem = allFeedItems.find((item) => (item as FeedItemFollowLayoutItem)?.feedItem?.data.id === discussionItemId || (item as FeedItemFollowLayoutItem)?.feedItem?.id === itemId); + const feedItemId = feedItem?.itemId; + if(feedItemId) { + handleFeedItemClick(feedItemId, { + commonId: feedPageParams.id, + }); + } + + return; + } + if (itemId) { handleFeedItemClick(itemId, { commonId: feedPageParams.id, diff --git a/src/pages/commonFeed/components/FeedLayout/components/DesktopChat/DesktopChat.tsx b/src/pages/commonFeed/components/FeedLayout/components/DesktopChat/DesktopChat.tsx index 99bffc75d0..73a15ed04a 100644 --- a/src/pages/commonFeed/components/FeedLayout/components/DesktopChat/DesktopChat.tsx +++ b/src/pages/commonFeed/components/FeedLayout/components/DesktopChat/DesktopChat.tsx @@ -131,6 +131,7 @@ const DesktopChat: FC = (props) => { directParent={directParent} renderChatInput={renderChatInput} onUserClick={onUserClick} + onStreamMentionClick={onFeedItemClick} onFeedItemClick={onFeedItemClick} onInternalLinkClick={onInternalLinkClick} /> diff --git a/src/pages/commonFeed/components/FeedLayout/components/MobileChat/MobileChat.tsx b/src/pages/commonFeed/components/FeedLayout/components/MobileChat/MobileChat.tsx index aa6cbcaebd..62a1f3244b 100644 --- a/src/pages/commonFeed/components/FeedLayout/components/MobileChat/MobileChat.tsx +++ b/src/pages/commonFeed/components/FeedLayout/components/MobileChat/MobileChat.tsx @@ -167,6 +167,7 @@ const MobileChat: FC = (props) => { directParent={directParent} renderChatInput={renderChatInput} onUserClick={onUserClick} + onStreamMentionClick={onFeedItemClick} onFeedItemClick={onFeedItemClick} onInternalLinkClick={onInternalLinkClick} /> diff --git a/src/pages/inbox/BaseInbox.tsx b/src/pages/inbox/BaseInbox.tsx index 711ba2868d..1a91479f6d 100644 --- a/src/pages/inbox/BaseInbox.tsx +++ b/src/pages/inbox/BaseInbox.tsx @@ -152,10 +152,13 @@ const InboxPage: FC = (props) => { const firstFeedItem = topFeedItems[0]; if(optimisticInboxFeedItems.size > 0 && firstFeedItem) { - feedLayoutRef?.setActiveItem({ - feedItemId: firstFeedItem.itemId, - discussion: (firstFeedItem as FeedItemFollowLayoutItemWithFollowData)?.feedItem.optimisticData, - }); + const shouldFocus = (firstFeedItem as FeedItemFollowLayoutItemWithFollowData)?.feedItem.optimisticData?.shouldFocus; + if(shouldFocus) { + feedLayoutRef?.setActiveItem({ + feedItemId: firstFeedItem.itemId, + discussion: (firstFeedItem as FeedItemFollowLayoutItemWithFollowData)?.feedItem.optimisticData, + }); + } } }, [topFeedItems, optimisticInboxFeedItems, feedLayoutRef]) diff --git a/src/services/CommonFeed.ts b/src/services/CommonFeed.ts index 2e2f4d31d8..32e30c024c 100644 --- a/src/services/CommonFeed.ts +++ b/src/services/CommonFeed.ts @@ -19,6 +19,7 @@ import { CommonFeedObjectUserUnique, CommonFeedType, CommonMember, + FeedItemFollow, SubCollections, Timestamp, } from "@/shared/models"; @@ -318,6 +319,20 @@ class CommonFeedService { } }); }; + + + public getFeedItemByCommonAndDiscussionId = async ({commonId, discussionId}: {commonId: string; discussionId: string}): Promise => { + try { + const feedItems = await this.getCommonFeedSubCollection(commonId) + .where("data.id", "==", discussionId) + .get(); + + const data = feedItems.docs.map(doc => doc.data()); + return data?.[0]; + } catch (error) { + return null; + } + }; } export default new CommonFeedService(); diff --git a/src/services/Discussion.ts b/src/services/Discussion.ts index 778f6b1aec..7f4a8520c4 100644 --- a/src/services/Discussion.ts +++ b/src/services/Discussion.ts @@ -112,6 +112,17 @@ class DiscussionService { public deleteDiscussion = async (discussionId: string): Promise => { await Api.delete(ApiEndpoint.DeleteDiscussion(discussionId)); }; + + public getDiscussionsByCommonId = async (commonId: string) => { + const discussionCollection = await this.getDiscussionCollection() + .where("commonId", "==", commonId) // Query for documents where commonId matches + .get(); + + // Map the Firestore document data + const data = discussionCollection.docs.map(doc => doc.data()); + return data; + }; + } export default new DiscussionService(); diff --git a/src/shared/components/Chat/ChatMessage/ChatMessage.tsx b/src/shared/components/Chat/ChatMessage/ChatMessage.tsx index 204f5a5964..9d1438d928 100644 --- a/src/shared/components/Chat/ChatMessage/ChatMessage.tsx +++ b/src/shared/components/Chat/ChatMessage/ChatMessage.tsx @@ -78,6 +78,7 @@ interface ChatMessageProps { onMessageDelete?: (messageId: string) => void; directParent?: DirectParent | null; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemID: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; isMessageEditAllowed: boolean; @@ -109,6 +110,7 @@ const ChatMessage = ({ onMessageDelete, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, isMessageEditAllowed, @@ -165,6 +167,7 @@ const ChatMessage = ({ directParent, onUserClick, onFeedItemClick, + onStreamMentionClick, onInternalLinkClick, }); @@ -177,6 +180,7 @@ const ChatMessage = ({ isNotCurrentUserMessage, discussionMessage.commonId, onUserClick, + onStreamMentionClick, onInternalLinkClick, ]); @@ -302,6 +306,7 @@ const ChatMessage = ({ commonId: discussionMessage.commonId, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }); diff --git a/src/shared/components/Chat/ChatMessage/DMChatMessage.tsx b/src/shared/components/Chat/ChatMessage/DMChatMessage.tsx index 99f8c2affa..8863bc506a 100644 --- a/src/shared/components/Chat/ChatMessage/DMChatMessage.tsx +++ b/src/shared/components/Chat/ChatMessage/DMChatMessage.tsx @@ -76,6 +76,7 @@ interface ChatMessageProps { onMessageDelete?: (messageId: string) => void; directParent?: DirectParent | null; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; chatChannelId?: string; @@ -112,6 +113,7 @@ export default function DMChatMessage({ onMessageDelete, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, chatChannelId, @@ -181,6 +183,7 @@ export default function DMChatMessage({ getCommonPageAboutTabPath, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }); @@ -201,6 +204,7 @@ export default function DMChatMessage({ getCommonPagePath, getCommonPageAboutTabPath, onUserClick, + onStreamMentionClick, ]); useEffect(() => { @@ -217,6 +221,7 @@ export default function DMChatMessage({ commonId: discussionMessage.commonId, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }); @@ -229,6 +234,7 @@ export default function DMChatMessage({ isNotCurrentUserMessage, discussionMessage.commonId, onUserClick, + onStreamMentionClick, discussionMessageUserId, userId, onInternalLinkClick, diff --git a/src/shared/components/Chat/ChatMessage/components/DiscussionLink/DiscussionLink.tsx b/src/shared/components/Chat/ChatMessage/components/DiscussionLink/DiscussionLink.tsx new file mode 100644 index 0000000000..18eac158cb --- /dev/null +++ b/src/shared/components/Chat/ChatMessage/components/DiscussionLink/DiscussionLink.tsx @@ -0,0 +1,36 @@ +import React, { FC } from "react"; +import classNames from "classnames"; +import styles from "../../ChatMessage.module.scss"; +import { InternalLinkData, parseMessageLink } from "@/shared/utils"; + +interface DiscussionLinkProps { + link: string; + title: string; + mentionTextClassName?: string; + onInternalLinkClick?: (data: InternalLinkData) => void; +} + +export const DiscussionLink: FC = (props) => { + const { title, link, mentionTextClassName, onInternalLinkClick } = + props; + + + const handleInternalLinkClick = () => { + if (onInternalLinkClick && link) { + const data = parseMessageLink(link); + data && onInternalLinkClick(data); + } + }; + + return ( + <> + + @{title} + + + ); +}; + diff --git a/src/shared/components/Chat/ChatMessage/components/DiscussionLink/index.ts b/src/shared/components/Chat/ChatMessage/components/DiscussionLink/index.ts new file mode 100644 index 0000000000..de0f3c2227 --- /dev/null +++ b/src/shared/components/Chat/ChatMessage/components/DiscussionLink/index.ts @@ -0,0 +1 @@ +export * from "./DiscussionLink"; \ No newline at end of file diff --git a/src/shared/components/Chat/ChatMessage/components/StreamMention/StreamMention.tsx b/src/shared/components/Chat/ChatMessage/components/StreamMention/StreamMention.tsx new file mode 100644 index 0000000000..9fd2d74142 --- /dev/null +++ b/src/shared/components/Chat/ChatMessage/components/StreamMention/StreamMention.tsx @@ -0,0 +1,46 @@ +import React, { FC, useMemo } from "react"; +import classNames from "classnames"; +import styles from "../../ChatMessage.module.scss"; +import { useQuery } from "@tanstack/react-query"; +import { CommonFeedService } from "@/services"; + +interface StreamMentionProps { + commonId: string; + discussionId: string; + title: string; + mentionTextClassName?: string; + onStreamMentionClick?: (feedItemId: string) => void; +} + +const StreamMention: FC = (props) => { + const { discussionId, title, commonId, mentionTextClassName, onStreamMentionClick } = + props; + + const { data } = useQuery({ + queryKey: ["stream-mention", discussionId], + queryFn: () => CommonFeedService.getFeedItemByCommonAndDiscussionId({ commonId, discussionId }), + enabled: !!discussionId, + staleTime: Infinity + }) + + const feedItemId = useMemo(() => data?.id, [data?.id]); + + const handleStreamNameClick = () => { + if (onStreamMentionClick && feedItemId) { + onStreamMentionClick(feedItemId); + } + }; + + return ( + <> + + @{title} + + + ); +}; + +export default StreamMention; diff --git a/src/shared/components/Chat/ChatMessage/components/StreamMention/index.ts b/src/shared/components/Chat/ChatMessage/components/StreamMention/index.ts new file mode 100644 index 0000000000..eef391c277 --- /dev/null +++ b/src/shared/components/Chat/ChatMessage/components/StreamMention/index.ts @@ -0,0 +1 @@ +export { default as StreamMention } from "./StreamMention"; diff --git a/src/shared/components/Chat/ChatMessage/components/index.ts b/src/shared/components/Chat/ChatMessage/components/index.ts index 9c5e506643..201e76cbae 100644 --- a/src/shared/components/Chat/ChatMessage/components/index.ts +++ b/src/shared/components/Chat/ChatMessage/components/index.ts @@ -3,6 +3,8 @@ export * from "./CheckboxItem"; export * from "./MessageLinkPreview"; export * from "./Time"; export * from "./UserMention"; +export * from "./StreamMention"; +export * from "./DiscussionLink"; export * from "./InternalLink"; export * from "./ReactWithEmoji"; export * from "./Reactions"; diff --git a/src/shared/components/Chat/ChatMessage/types.ts b/src/shared/components/Chat/ChatMessage/types.ts index 35d5b4d45e..0b812d28a3 100644 --- a/src/shared/components/Chat/ChatMessage/types.ts +++ b/src/shared/components/Chat/ChatMessage/types.ts @@ -18,6 +18,7 @@ export interface TextData { getCommonPageAboutTabPath?: GetCommonPageAboutTabPath; directParent?: DirectParent | null; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; onMessageUpdate?: (message: TextEditorValue) => void; onInternalLinkClick?: (data: InternalLinkData) => void; diff --git a/src/shared/components/Chat/ChatMessage/utils/generateInternalLink.tsx b/src/shared/components/Chat/ChatMessage/utils/generateInternalLink.tsx index 8b53adf920..d6b7c992bc 100644 --- a/src/shared/components/Chat/ChatMessage/utils/generateInternalLink.tsx +++ b/src/shared/components/Chat/ChatMessage/utils/generateInternalLink.tsx @@ -16,6 +16,7 @@ interface GenerateInternalLinkProps { } const ITEM_KEY = "item"; +const DISCUSSION_ITEM_KEY = "discussionItem"; export const getQueryParam = (path: string, key: string): string | null => { const urlParams = new URLSearchParams(path); @@ -44,6 +45,16 @@ const getStreamNameByFeedItemId = async ( } }; +const getDiscussionTitle = async ( + discussionId: string, +): Promise => { + const discussion = await DiscussionService.getDiscussionById(discussionId); + + return discussion?.title; +}; + + + export const generateInternalLink = async ({ text, onInternalLinkClick, @@ -52,11 +63,12 @@ export const generateInternalLink = async ({ if (text.startsWith(BASE_URL) && commonPath) { const [commonId, itemQueryParam] = commonPath.split("?"); const itemId = getQueryParam(itemQueryParam, ITEM_KEY); + const discussionItemId = getQueryParam(itemQueryParam, DISCUSSION_ITEM_KEY); if (commonId) { const common = await getCommon(commonId); if (common?.id && common.name) { - const itemTitle = await getStreamNameByFeedItemId(commonId, itemId); - + const itemTitle = discussionItemId ? await getDiscussionTitle(discussionItemId) : await getStreamNameByFeedItemId(commonId, itemId); + return ( <> {renderLink({ diff --git a/src/shared/components/Chat/ChatMessage/utils/getTextFromTextEditorString.tsx b/src/shared/components/Chat/ChatMessage/utils/getTextFromTextEditorString.tsx index a90ca84354..613c8e60f3 100644 --- a/src/shared/components/Chat/ChatMessage/utils/getTextFromTextEditorString.tsx +++ b/src/shared/components/Chat/ChatMessage/utils/getTextFromTextEditorString.tsx @@ -13,7 +13,7 @@ import textEditorElementsStyles from "@/shared/ui-kit/TextEditor/shared/TextEdit import { EmojiElement } from "@/shared/ui-kit/TextEditor/types"; import { isRtlWithNoMentions } from "@/shared/ui-kit/TextEditor/utils"; import { InternalLinkData } from "@/shared/utils"; -import { CheckboxItem, UserMention } from "../components"; +import { CheckboxItem, StreamMention, UserMention, DiscussionLink } from "../components"; import { Text, TextData } from "../types"; import { generateInternalLink } from "./generateInternalLink"; import { getTextFromSystemMessage } from "./getTextFromSystemMessage"; @@ -35,6 +35,7 @@ interface TextFromDescendant { commonId?: string; directParent?: DirectParent | null; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; showPlainText?: boolean; } @@ -47,6 +48,7 @@ const getTextFromDescendant = async ({ commonId, directParent, onUserClick, + onStreamMentionClick, onInternalLinkClick, showPlainText, }: TextFromDescendant): Promise => { @@ -60,7 +62,7 @@ const getTextFromDescendant = async ({ return await generateInternalLink({ text, onInternalLinkClick }); }), ); - return {mappedText} || ""; + return mappedText ? {mappedText} : ""; } switch (descendant.type) { @@ -79,6 +81,7 @@ const getTextFromDescendant = async ({ commonId, directParent, onUserClick, + onStreamMentionClick, onInternalLinkClick, showPlainText, })} @@ -98,6 +101,25 @@ const getTextFromDescendant = async ({ onUserClick={onUserClick} /> ); + case ElementType.StreamMention: + return ( + + ); + case ElementType.DiscussionLink: + return ( + + ); case ElementType.Emoji: return ( > = (props) => { ); }; -export default ReportModal; +export default React.memo(ReportModal); diff --git a/src/shared/constants/keyboardKeys.ts b/src/shared/constants/keyboardKeys.ts index 54fea94a6c..503f8bf134 100644 --- a/src/shared/constants/keyboardKeys.ts +++ b/src/shared/constants/keyboardKeys.ts @@ -3,6 +3,7 @@ export enum KeyboardKeys { Escape = "Escape", ArrowUp = "ArrowUp", ArrowDown = "ArrowDown", + Backspace = "Backspace", // Add Backspace } /** diff --git a/src/shared/constants/queryParamKey.ts b/src/shared/constants/queryParamKey.ts index c98c135cfb..cf7a85fd09 100644 --- a/src/shared/constants/queryParamKey.ts +++ b/src/shared/constants/queryParamKey.ts @@ -5,6 +5,7 @@ export enum QueryParamKey { Language = "language", Tab = "tab", Item = "item", + discussionItem = "discussionItem", ChatItem = "chatItem", Message = "message", Unread = "unread", diff --git a/src/shared/hooks/index.tsx b/src/shared/hooks/index.tsx index 5371485a16..595cc363df 100644 --- a/src/shared/hooks/index.tsx +++ b/src/shared/hooks/index.tsx @@ -33,5 +33,6 @@ export { default as useImageSizeCheck } from "./useImageSizeCheck"; export { default as useLightThemeOnly } from "./useLightThemeOnly"; export * from "./useToggle"; export * from "./useTraceUpdate"; +export * from "./useFetchDiscussionsByCommonId"; export * from "./useElementPresence"; -export * from "./useIsElementFocused"; \ No newline at end of file +export * from "./useIsElementFocused"; diff --git a/src/shared/hooks/useCases/useCommonFeedItems.ts b/src/shared/hooks/useCases/useCommonFeedItems.ts index 8a478af252..a118dfc22e 100644 --- a/src/shared/hooks/useCases/useCommonFeedItems.ts +++ b/src/shared/hooks/useCases/useCommonFeedItems.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { CommonFeedService } from "@/services"; import { @@ -27,7 +27,7 @@ export const useCommonFeedItems = ( const idsForNotListeningRef = useRef(idsForNotListening || []); const isSubscriptionAllowed = feedItems.data !== null; - const fetch = (feedItemId?: string) => { + const fetch = useCallback(() => (feedItemId?: string) => { dispatch( commonActions.getFeedItems.request({ commonId, @@ -36,7 +36,7 @@ export const useCommonFeedItems = ( limit: 15, }), ); - }; + },[commonId, sharedFeedItemId]); useEffect(() => { idsForNotListeningRef.current = idsForNotListening || []; diff --git a/src/shared/hooks/useCases/useCommonPinnedFeedItems.ts b/src/shared/hooks/useCases/useCommonPinnedFeedItems.ts index eb57214c38..9e6baee721 100644 --- a/src/shared/hooks/useCases/useCommonPinnedFeedItems.ts +++ b/src/shared/hooks/useCases/useCommonPinnedFeedItems.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; import { usePreviousDistinct } from "react-use"; import difference from "lodash/difference"; @@ -69,9 +69,13 @@ export const useCommonPinnedFeedItems = ( }; }, []); + const data = useMemo(() => { + return filteredPinnedFeedItems || pinnedFeedItems.data; + }, [filteredPinnedFeedItems, pinnedFeedItems.data]) + return { // ...pinnedFeedItems, - data: filteredPinnedFeedItems || pinnedFeedItems.data, + data, loading: false, fetch, }; diff --git a/src/shared/hooks/useCases/useDiscussionMessagesById.ts b/src/shared/hooks/useCases/useDiscussionMessagesById.ts index 2dd45e70d8..11601e0072 100644 --- a/src/shared/hooks/useCases/useDiscussionMessagesById.ts +++ b/src/shared/hooks/useCases/useDiscussionMessagesById.ts @@ -40,6 +40,7 @@ interface Options { discussionId: string; directParent?: DirectParent | null; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; users: User[]; textStyles: TextStyles; @@ -78,6 +79,7 @@ export const useDiscussionMessagesById = ({ discussionId, directParent, onUserClick, + onStreamMentionClick, onFeedItemClick, users, onInternalLinkClick, @@ -126,6 +128,7 @@ export const useDiscussionMessagesById = ({ getCommonPageAboutTabPath, directParent, onUserClick, + onStreamMentionClick: onStreamMentionClick ?? onFeedItemClick, onFeedItemClick, onInternalLinkClick, showPlainText: options?.showPlainText, @@ -196,6 +199,7 @@ export const useDiscussionMessagesById = ({ getCommonPageAboutTabPath, directParent, onUserClick, + onStreamMentionClick: onStreamMentionClick ?? onFeedItemClick, onFeedItemClick, onInternalLinkClick, }); @@ -228,6 +232,7 @@ export const useDiscussionMessagesById = ({ getCommonPagePath, getCommonPageAboutTabPath, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, ], @@ -293,6 +298,7 @@ export const useDiscussionMessagesById = ({ getCommonPageAboutTabPath, directParent, onUserClick, + onStreamMentionClick: onStreamMentionClick ?? onFeedItemClick, onFeedItemClick, onInternalLinkClick, }); @@ -339,6 +345,7 @@ export const useDiscussionMessagesById = ({ getCommonPagePath, getCommonPageAboutTabPath, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, dispatch, diff --git a/src/shared/hooks/useCases/usePreloadDiscussionMessagesById.ts b/src/shared/hooks/useCases/usePreloadDiscussionMessagesById.ts index 2fba3af495..1c04567eeb 100644 --- a/src/shared/hooks/useCases/usePreloadDiscussionMessagesById.ts +++ b/src/shared/hooks/useCases/usePreloadDiscussionMessagesById.ts @@ -16,6 +16,7 @@ interface Options { discussionId?: string | null; commonId?: string; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; } @@ -31,6 +32,7 @@ export const usePreloadDiscussionMessagesById = ({ discussionId, commonId, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }: Options): Return => { @@ -84,6 +86,7 @@ export const usePreloadDiscussionMessagesById = ({ getCommonPagePath, getCommonPageAboutTabPath, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }); diff --git a/src/shared/hooks/useFetchDiscussionsByCommonId.tsx b/src/shared/hooks/useFetchDiscussionsByCommonId.tsx new file mode 100644 index 0000000000..cb8f490dcc --- /dev/null +++ b/src/shared/hooks/useFetchDiscussionsByCommonId.tsx @@ -0,0 +1,13 @@ +import { DiscussionService } from "@/services"; +import { useQuery } from "@tanstack/react-query"; + +// React Query hook to fetch discussions +export const useFetchDiscussionsByCommonId = (commonId: string) => { + return useQuery( + ["allDiscussion", commonId], // queryKey based on commonId + () => DiscussionService.getDiscussionsByCommonId(commonId), // Query function that calls Firestore + { + cacheTime: 5 * 60 * 1000, // Cache time set to 5 minutes (300,000 milliseconds) + } + ); + }; \ No newline at end of file diff --git a/src/shared/icons/chat.icon.tsx b/src/shared/icons/chat.icon.tsx new file mode 100644 index 0000000000..69cf4c7de0 --- /dev/null +++ b/src/shared/icons/chat.icon.tsx @@ -0,0 +1,26 @@ +import React, { FC } from "react"; + +interface ChatIconProps { + className?: string; + size?: number; +} + +export const ChatIcon: FC = ({ className, size = 20 }) => { + return ( + + + + ); +}; diff --git a/src/shared/icons/dots.icon.tsx b/src/shared/icons/dots.icon.tsx new file mode 100644 index 0000000000..fed8f78d24 --- /dev/null +++ b/src/shared/icons/dots.icon.tsx @@ -0,0 +1,35 @@ +import React, { ReactElement } from "react"; + +interface DotsIconProps { + className?: string; + size?: number; +} + +export function DotsIcon({ + className, + size = 24, +}: DotsIconProps): ReactElement { + return ( + + + + + + ); +} diff --git a/src/shared/icons/index.ts b/src/shared/icons/index.ts index 20cb914a19..b08d2945ec 100644 --- a/src/shared/icons/index.ts +++ b/src/shared/icons/index.ts @@ -79,3 +79,5 @@ export { default as HideIcon } from "./hide.icon"; export { default as InboxFilterIcon } from "./inboxFilter.icon"; export { default as GroupChatIcon } from "./groupChat.icon"; export { default as NotificationsIcon } from "./notifications.icon"; +export * from "./chat.icon"; +export * from "./dots.icon"; diff --git a/src/shared/models/CommonFeed.tsx b/src/shared/models/CommonFeed.tsx index ccbe1095b0..90acc456d2 100644 --- a/src/shared/models/CommonFeed.tsx +++ b/src/shared/models/CommonFeed.tsx @@ -39,6 +39,7 @@ export interface LastMessageContentWithMessageId { export type DiscussionWithOptimisticData = Discussion & { state?: OptimisticFeedItemState; // Optional state property lastMessageContent: LastMessageContent; // Additional property + shouldFocus: boolean; }; export interface CommonFeed extends BaseEntity, SoftDeleteEntity { diff --git a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx index 87e1841587..1d8f61d703 100644 --- a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx +++ b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx @@ -1,4 +1,5 @@ import React, { + useRef, FocusEventHandler, KeyboardEvent, MutableRefObject, @@ -20,13 +21,17 @@ import { Editor as EditorSlate, BaseRange, BaseSelection, + BasePoint, + Text, } from "slate"; import { withHistory } from "slate-history"; import { ReactEditor, Slate, withReact } from "slate-react"; import { DOMRange } from "slate-react/dist/utils/dom"; +import { AI_PRO_USER, AI_USER, FeatureFlags } from "@/shared/constants"; import { KeyboardKeys } from "@/shared/constants/keyboardKeys"; -import { User } from "@/shared/models"; -import { getUserName, isMobile, isRtlText } from "@/shared/utils"; +import { useFeatureFlag } from "@/shared/hooks"; +import { Discussion, User } from "@/shared/models"; +import { generateDiscussionShareLink, getUserName, InternalLinkData, isMobile, isRtlText } from "@/shared/utils"; import { Editor, MentionDropdown, @@ -40,13 +45,13 @@ import { parseStringToTextEditorValue, insertEmoji, insertMention, + insertStreamMention, checkIsCheckboxCreationText, toggleCheckboxItem, checkIsEmptyCheckboxCreationText, + insertDiscussionLink, } from "./utils"; import styles from "./BaseTextEditor.module.scss"; -import { useFeatureFlag } from "@/shared/hooks"; -import { AI_PRO_USER, AI_USER, FeatureFlags } from "@/shared/constants"; export interface BaseTextEditorHandles { focus: () => void; @@ -59,8 +64,8 @@ export interface TextEditorProps { emojiContainerClassName?: string; emojiPickerContainerClassName?: string; inputContainerRef?: - | MutableRefObject - | RefCallback; + | MutableRefObject + | RefCallback; editorRef?: MutableRefObject | RefCallback; id?: string; name?: string; @@ -73,10 +78,15 @@ export interface TextEditorProps { disabled?: boolean; onKeyDown?: (event: KeyboardEvent) => void; users?: User[]; + discussions?: Discussion[]; shouldReinitializeEditor: boolean; onClearFinished: () => void; scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void; elementStyles?: EditorElementStyles; + circleVisibility?: string[]; + user?: User | null; + commonId?: string; + onInternalLinkClick?: (data: InternalLinkData) => void; } const INITIAL_SEARCH_VALUE = { @@ -89,336 +99,449 @@ const INITIAL_SEARCH_VALUE = { }, }; -const BaseTextEditor = forwardRef((props, ref) => { - const { - className, - classNameRtl, - emojiContainerClassName, - emojiPickerContainerClassName, - editorRef, - inputContainerRef, - id, - name, - value, - onChange, - onBlur, - onKeyDown, - size, - placeholder, - disabled = false, - users, - shouldReinitializeEditor = false, - onClearFinished, - scrollSelectionIntoView, - elementStyles, - } = props; - const editor = useMemo( - () => - withChecklists( - withEmojis( - withMentions( - withInlines(withHistory(withReact(createEditor())), { - shouldInsertURLAsLink: false, - }), +const BaseTextEditor = forwardRef( + (props, ref) => { + const { + className, + classNameRtl, + emojiContainerClassName, + emojiPickerContainerClassName, + editorRef, + inputContainerRef, + id, + name, + value, + onChange, + onBlur, + onKeyDown, + size, + placeholder, + disabled = false, + users, + shouldReinitializeEditor = false, + onClearFinished, + scrollSelectionIntoView, + elementStyles, + discussions, + circleVisibility, + user, + commonId, + onInternalLinkClick, + } = props; + const editor = useMemo( + () => + withChecklists( + withEmojis( + withMentions( + withInlines(withHistory(withReact(createEditor())), { + shouldInsertURLAsLink: false, + }), + ), ), ), - ), - [], - ); - const featureFlags = useFeatureFlag(); - const isAiBotProEnabled = featureFlags?.get(FeatureFlags.AiBotPro); - - const usersWithAI = useMemo( - () => [isAiBotProEnabled ? AI_PRO_USER : AI_USER, ...(users ?? [])], - [users], - ); - - const [target, setTarget] = useState(); - const [shouldFocusTarget, setShouldFocusTarget] = useState(false); - - const [isRtlLanguage, setIsRtlLanguage] = useState(false); - - useDebounce( - () => { - setIsRtlLanguage(isRtlText(EditorSlate.string(editor, []))); - }, - 5000, - [value], - ); - - const clearInput = () => { - setTimeout(() => { - Transforms.delete(editor, { - at: { - anchor: EditorSlate.start(editor, []), - focus: EditorSlate.end(editor, []), - }, - }); + [], + ); + const featureFlags = useFeatureFlag(); + const isAiBotProEnabled = featureFlags?.get(FeatureFlags.AiBotPro); + + const usersWithAI = useMemo( + () => [isAiBotProEnabled ? AI_PRO_USER : AI_USER, ...(users ?? [])], + [users], + ); + + const [target, setTarget] = useState(); + const [shouldFocusTarget, setShouldFocusTarget] = useState(false); + const isNewMentionCreated = useRef(false); + + const [isRtlLanguage, setIsRtlLanguage] = useState(false); + + useDebounce( + () => { + setIsRtlLanguage(isRtlText(EditorSlate.string(editor, []))); + }, + 5000, + [value], + ); + + const clearInput = () => { + setTimeout(() => { + Transforms.delete(editor, { + at: { + anchor: EditorSlate.start(editor, []), + focus: EditorSlate.end(editor, []), + }, + }); - // Removes empty node - Transforms.removeNodes(editor, { - at: [0], - }); + // Removes empty node + Transforms.removeNodes(editor, { + at: [0], + }); - // Insert array of children nodes - Transforms.insertNodes(editor, parseStringToTextEditorValue()); - const editorEl = ReactEditor.toDOMNode(editor, editor); - editorEl.scrollTo(0, 0); - onClearFinished(); - }, 0) - } - - useEffect(() => { - if (!shouldReinitializeEditor) { - return; - } - - clearInput(); - }, [shouldReinitializeEditor, clearInput]); - - useImperativeHandle(ref, () => ({ - focus: () => { - if (editorRef) { - const end = EditorSlate.end(editor, []); - - // Move the selection to the end - Transforms.select(editor, end); - - // Focus the editor DOM node + // Insert array of children nodes + Transforms.insertNodes(editor, parseStringToTextEditorValue()); const editorEl = ReactEditor.toDOMNode(editor, editor); - editorEl.focus(); - - // Ensure the editor itself is focused programmatically - ReactEditor.focus(editor); - } - }, - clear: () => { + editorEl.scrollTo(0, 0); + onClearFinished(); + }, 0); + }; + + useEffect(() => { + if (!shouldReinitializeEditor) { + return; + } + clearInput(); - } - })); - - useEffect(() => { - if (!editorRef) { - return; - } - - const editorEl = ReactEditor.toDOMNode(editor, editor); - - if (typeof editorRef === "function") { - editorRef(editorEl); - } else { - editorRef.current = editorEl; - } - }, [editorRef, editor]); - - const [search, setSearch] = useState(INITIAL_SEARCH_VALUE); - - const handleSearch = (text: string, value?: BaseRange) => { - if (!value || !value?.anchor || !text || text === "") { - setSearch(INITIAL_SEARCH_VALUE); - setTarget(null); - setShouldFocusTarget(false); - return; - } - - if (text === MENTION_TAG) { - setSearch({ - text, - ...value.anchor, - range: value, - }); - } else if (text.match(/^(\s|$)/)) { - setSearch(INITIAL_SEARCH_VALUE); - setTarget(null); - setShouldFocusTarget(false); - } else if ( - search.text.includes(MENTION_TAG) && - isEqual(search.path, value.anchor.path) && - search.offset + 1 === value.anchor.offset - ) { - setSearch({ - ...search, - text: search.text + text, - ...value.anchor, - }); - setShouldFocusTarget(false); - } - }; - - const chars = (usersWithAI ?? []).filter((user) => { - return getUserName(user) - ?.toLowerCase() - .startsWith(search.text.substring(1).toLowerCase()); - }); - - useEffect(() => { - if (search && search.text) { - setTarget({ - ...search.range, - focus: { - ...search.range.focus, - offset: search.range.focus.offset + search.text.length - 1, - }, - }); - } - }, [search]); - - const [isMessageSent, setIsMessageSent] = useState(false); - - const onToggleIsMessageSent = () => { - setIsMessageSent((value) => !value); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === KeyboardKeys.ArrowUp && target) { - event.preventDefault(); - setShouldFocusTarget(true); - } else { - // event.stopPropagation(); - onKeyDown && onKeyDown(event); // Call any custom onKeyDown handler - if (event.key === KeyboardKeys.Enter && !isMobile()) { - onToggleIsMessageSent(); + }, [shouldReinitializeEditor, clearInput]); + + useImperativeHandle(ref, () => ({ + focus: () => { + if (editorRef) { + const end = EditorSlate.end(editor, []); + + // Move the selection to the end + Transforms.select(editor, end); + + // Focus the editor DOM node + const editorEl = ReactEditor.toDOMNode(editor, editor); + editorEl.focus(); + + // Ensure the editor itself is focused programmatically + ReactEditor.focus(editor); + } + }, + clear: () => { + clearInput(); + }, + })); + + useEffect(() => { + if (!editorRef) { + return; } - } - }; - - const handleOnChangeSelection = (selection: BaseSelection) => { - if (selection && Range.isCollapsed(selection)) { - const { - anchor: { path: selectionPath, offset: selectionOffset }, - } = selection; - const [start] = Range.edges(selection); - const before = EditorSlate.before(editor, start); - const lineLastPoint = EditorSlate.after( - editor, - { - anchor: { - offset: 0, - path: selectionPath, - }, - focus: { - offset: 0, - path: selectionPath, - }, - }, - { unit: "line" }, - ); - const beforeRange = before && EditorSlate.range(editor, before, start); - const beforeText = beforeRange && EditorSlate.string(editor, beforeRange); - const checkboxText = EditorSlate.string(editor, { - anchor: { offset: 0, path: selectionPath }, - focus: { offset: 3, path: selectionPath }, - }).trim(); + const editorEl = ReactEditor.toDOMNode(editor, editor); + + if (typeof editorRef === "function") { + editorRef(editorEl); + } else { + editorRef.current = editorEl; + } + }, [editorRef, editor]); + + const [search, setSearch] = useState(INITIAL_SEARCH_VALUE); + + const getTextByRange = (editor, range) => { if ( - beforeText === " " && - (selectionOffset === 3 || selectionOffset === 4) && - selectionOffset === lineLastPoint?.offset && - checkIsCheckboxCreationText(checkboxText) + !range || + !EditorSlate.hasPath(editor, range.anchor.path) || + !EditorSlate.hasPath(editor, range.focus.path) ) { - toggleCheckboxItem( - editor, - !checkIsEmptyCheckboxCreationText(checkboxText), - true, - ); - return; + return ""; } - handleSearch(beforeText ?? "", beforeRange); - } - }; + const filteredNodes = Array.from( + EditorSlate.nodes(editor, { at: range }), + ).filter(([node]) => Text.isText(node)); + const nodes = filteredNodes.map(([node, path]) => { + // Determine the start and end offsets for this node + const { anchor, focus } = + Range.intersection(range, EditorSlate.range(editor, path)) || {}; + + if (!anchor || !focus) return ""; - const handleMentionSelectionChange = useCallback(() => { - if (!editor.selection || editor.selection.anchor.path.length <= 2) { - return; - } + // Extract the substring within the offsets + const text = + (node as Text)?.text.slice(anchor.offset, focus.offset + 1) + + (filteredNodes.length > 1 ? " " : ""); + + // Remove newlines from the text + return text.replace(/\n/g, ""); + }); + + if(nodes.length > 1) { + return nodes.join("").slice(0, -1); + } + + return nodes.join(""); + }; + + const handleSearch = ( + text: string, + value?: BaseRange, + afterValue?: BasePoint, + ) => { + if (!value || !value?.anchor || isNewMentionCreated.current) { + setSearch(INITIAL_SEARCH_VALUE); + isNewMentionCreated.current = false; + setTarget(null); + setShouldFocusTarget(false); + return; + } - const { anchor } = editor.selection; - const point: BaseRange["anchor"] = { - ...anchor, - path: [anchor.path[0], anchor.path[1] + 1], + const newText = target?.anchor + ? getTextByRange(editor, { + ...target, + focus: afterValue ? { ...afterValue, offset: afterValue.offset } : { ...value.anchor, offset: value.anchor.offset + 1 }, + }) + : ""; + + if (text === MENTION_TAG) { + setSearch({ + text, + ...value.anchor, + range: value, + }); + } else if (search.text.includes(MENTION_TAG) && text.match(/^\s+/)) { + setSearch((prevSearch) => { + { + return { + ...prevSearch, + text: newText, + ...value.anchor, + range: { + ...prevSearch.range, + focus: afterValue ? afterValue : value.anchor, + }, + }; + } + }); + } else if (search.text.includes(MENTION_TAG)) { + setSearch((prevSearch) => { + return { + ...prevSearch, + text: newText, + ...value.anchor, + range: { + ...prevSearch.range, + focus: afterValue ? afterValue : value.anchor, + }, + }; + }); + setShouldFocusTarget(false); + } }; - Transforms.select(editor, { - anchor: point, - focus: point, + + const chars = (usersWithAI ?? []).filter((user) => { + return getUserName(user) + ?.toLowerCase() + .startsWith(search.text.substring(1).toLowerCase()); + }); + + const discussionChars = (discussions ?? []).filter((discussion) => { + return discussion.title + ?.toLowerCase() + .startsWith(search.text.substring(1).toLowerCase()); }); - }, []); - const handleOnChange = useCallback( - (updatedContent) => { - // Prevent update for cursor clicks - if (isEqual(updatedContent, value)) { - handleMentionSelectionChange(); + useEffect(() => { + if (search && search.text) { + setTarget({ + ...search.range, + focus: { + ...search.range.focus, + offset: search.range.focus.offset + 1, + }, + }); + } + }, [search]); + + const [isMessageSent, setIsMessageSent] = useState(false); + + const onToggleIsMessageSent = () => { + setIsMessageSent((value) => !value); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === KeyboardKeys.ArrowUp && target) { + event.preventDefault(); + setShouldFocusTarget(true); + } else if ( + event.key === KeyboardKeys.Enter && + !event.shiftKey && + target + ) { + setSearch(INITIAL_SEARCH_VALUE); + setTarget(null); + setShouldFocusTarget(false); + isNewMentionCreated.current = true; + } else if (event.key === KeyboardKeys.Escape && target) { + event.preventDefault(); + setSearch(INITIAL_SEARCH_VALUE); + setTarget(null); + setShouldFocusTarget(false); + } else { + onKeyDown && onKeyDown(event); // Call any custom onKeyDown handler + if (event.key === KeyboardKeys.Enter && !isMobile()) { + onToggleIsMessageSent(); + } + } + }; + + const handleOnChangeSelection = (selection: BaseSelection) => { + if (selection && Range.isCollapsed(selection)) { + const { + anchor: { path: selectionPath, offset: selectionOffset }, + } = selection; + const [start] = Range.edges(selection); + const before = EditorSlate.before(editor, start); + const lineLastPoint = EditorSlate.after( + editor, + { + anchor: { + offset: 0, + path: selectionPath, + }, + focus: { + offset: 0, + path: selectionPath, + }, + }, + { unit: "line" }, + ); + const beforeRange = before && EditorSlate.range(editor, before, start); + const beforeText = + beforeRange && EditorSlate.string(editor, beforeRange); + const checkboxText = EditorSlate.string(editor, { + anchor: { offset: 0, path: selectionPath }, + focus: { offset: 3, path: selectionPath }, + }).trim(); + + if ( + beforeText === " " && + (selectionOffset === 3 || selectionOffset === 4) && + selectionOffset === lineLastPoint?.offset && + checkIsCheckboxCreationText(checkboxText) + ) { + toggleCheckboxItem( + editor, + !checkIsEmptyCheckboxCreationText(checkboxText), + true, + ); + return; + } + + handleSearch(beforeText ?? "", beforeRange, lineLastPoint); + } + }; + + const handleMentionSelectionChange = () => { + if (!editor.selection || editor.selection.anchor.path.length <= 2) { return; } - onChange && onChange(updatedContent); - const { selection } = editor; - - handleOnChangeSelection(selection); - }, - [onChange, value, handleMentionSelectionChange], - ); - - const customScrollSelectionIntoView = ( ) => { - if (inputContainerRef && 'current' in inputContainerRef && inputContainerRef?.current) { - inputContainerRef.current?.scrollIntoView({ - behavior: "smooth", - block: "end", - inline: "nearest", + + const { anchor } = editor.selection; + const point: BaseRange["anchor"] = { + ...anchor, + path: [anchor.path[0], anchor.path[1] + 1], + }; + Transforms.select(editor, { + anchor: point, + focus: point, }); - } - } - - return ( -
- - - { - insertEmoji(editor, emoji.native); - }} - /> - - {target && ( - { - Transforms.select(editor, target); - insertMention(editor, user); - setTarget(null); - setShouldFocusTarget(false); - }} - users={chars} - onClose={() => { - setTarget(null); - setShouldFocusTarget(false); + }; + + const handleOnChange = useCallback( + (updatedContent) => { + if (isEqual(updatedContent, value)) { + handleMentionSelectionChange(); + return; + } + onChange && onChange(updatedContent); + handleOnChangeSelection(editor.selection); + }, + [onChange, value, handleSearch, handleMentionSelectionChange], + ); + + const customScrollSelectionIntoView = () => { + if ( + inputContainerRef && + "current" in inputContainerRef && + inputContainerRef?.current + ) { + inputContainerRef.current?.scrollIntoView({ + behavior: "smooth", + block: "end", + inline: "nearest", + }); + } + }; + + return ( +
+ + + { + insertEmoji(editor, emoji.native); }} /> - )} - -
- ); -}); + {target && ( + { + Transforms.select(editor, target); + insertMention(editor, user); + setTarget(null); + setShouldFocusTarget(false); + isNewMentionCreated.current = true; + }} + onClickDiscussion={(discussion: Discussion) => { + Transforms.select(editor, target); + insertStreamMention(editor, discussion); + setTarget(null); + setShouldFocusTarget(false); + isNewMentionCreated.current = true; + }} + onCreateDiscussion={(createdDiscussionCommonId: string, discussionId: string, title: string) => { + Transforms.select(editor, target); + const link = generateDiscussionShareLink(createdDiscussionCommonId, discussionId); + insertDiscussionLink(editor, title, link, onInternalLinkClick); + setTarget(null); + setShouldFocusTarget(false); + isNewMentionCreated.current = true; + }} + user={user} + commonId={commonId} + circleVisibility={circleVisibility} + users={chars} + discussions={discussionChars} + initUsers={usersWithAI} + initDiscussions={discussions} + searchText={search.text} + onClose={() => { + setSearch(INITIAL_SEARCH_VALUE); + setTarget(null); + setShouldFocusTarget(false); + }} + /> + )} +
+
+ ); + }, +); export default BaseTextEditor; diff --git a/src/shared/ui-kit/TextEditor/components/Element/Element.tsx b/src/shared/ui-kit/TextEditor/components/Element/Element.tsx index 17f1e7960b..0c28cb1afb 100644 --- a/src/shared/ui-kit/TextEditor/components/Element/Element.tsx +++ b/src/shared/ui-kit/TextEditor/components/Element/Element.tsx @@ -22,6 +22,35 @@ const Mention = ({ attributes, element, className, children }) => { ); }; +const StreamMention = ({ attributes, element, className, children }) => { + return ( + + @{element.title} + {children} + + ); +}; + +const DiscussionLink = ({ attributes, element, className, children }) => { + return ( + + @{element.title} + {children} + + ); +}; + const Element: FC = ( props, ) => { @@ -75,6 +104,10 @@ const Element: FC = ( {children} ); + case ElementType.DiscussionLink: + return ( + + ); case ElementType.Mention: { return ( = ( /> ); } + case ElementType.StreamMention: { + return ( + + ); + } case ElementType.Emoji: { return ( void; + onClickDiscussion: (discussion: Discussion) => void; + onCreateDiscussion: (createdDiscussionCommonId: string, discussionId: string, title: string) => void; onClose: () => void; users?: User[]; + discussions?: Discussion[]; + initUsers?: User[]; + initDiscussions?: Discussion[]; shouldFocusTarget?: boolean; + searchText: string; + circleVisibility?: string[]; + user?: User | null; + commonId?: string; } const MentionDropdown: FC = (props) => { const { onClick, + onClickDiscussion, + onCreateDiscussion, users = [], + discussions = [], onClose, shouldFocusTarget, + initUsers = [], + initDiscussions = [], + searchText = "", + circleVisibility, + user, + commonId, } = props; const mentionRef = useRef(null); const listRefs = useRef([]); const { isOutside, setOutsideValue } = useOutsideClick(mentionRef); + const dispatch = useDispatch(); + + const canCreateDiscussion = !!user && !!commonId; + const [index, setIndex] = useState(0); + const [isShowMoreUsers, setIsShowMoreUsers] = useState(false); + const [isShowMoreDiscussions, setIsShowMoreDiscussions] = useState(false); + const usersList = useMemo(() => { + if (isShowMoreUsers) { + return users; + } - const userIds = useMemo(() => users.map(({ uid }) => uid), [users]); + return users.slice(0, 5); + }, [isShowMoreUsers, users]); + + const discussionsList = useMemo(() => { + if (isShowMoreDiscussions) { + return discussions; + } + + return discussions.slice(0, 5); + }, [isShowMoreDiscussions, discussions]); useEffect(() => { - if (shouldFocusTarget) { - const filteredListRefs = uniq(listRefs.current).filter((item) => { - if (userIds.includes(item?.id)) { - return true; - } + const allRefs = document.querySelectorAll( + `.${styles.content}`, + ); - return false; + listRefs.current = []; + allRefs.forEach((ref) => listRefs.current.push(ref)); + listRefs.current.sort((a, b) => { + const tabIndexA = parseInt(a.getAttribute("tabIndex") || "0", 10); + const tabIndexB = parseInt(b.getAttribute("tabIndex") || "0", 10); + return tabIndexA - tabIndexB; + }); + }, [searchText]); + + useEffect(() => { + if (shouldFocusTarget) { + // Clear and rebuild listRefs based on current usersList and discussionsList + listRefs.current = []; + const allRefs = document.querySelectorAll( + `.${styles.content}`, + ); + allRefs.forEach((ref) => listRefs.current.push(ref)); + // Sort the listRefs by tabIndex + listRefs.current.sort((a, b) => { + const tabIndexA = parseInt(a.getAttribute("tabIndex") || "0", 10); + const tabIndexB = parseInt(b.getAttribute("tabIndex") || "0", 10); + return tabIndexA - tabIndexB; }); - listRefs.current = filteredListRefs; - filteredListRefs && filteredListRefs?.[index]?.focus(); + // Find the element with the matching tabIndex and focus it + const elementToFocus = listRefs.current.find( + (item) => parseInt(item.getAttribute("tabIndex") || "0", 10) === index, + ); + + if (elementToFocus) { + elementToFocus.focus(); + } } - }, [index, shouldFocusTarget, userIds]); + }, [index, usersList, discussionsList, shouldFocusTarget]); const increment = () => { setIndex((value) => { const updatedValue = value + 1; - return updatedValue > users.length - 1 ? value : updatedValue; + return updatedValue < listRefs.current.length ? updatedValue : value; }); }; - const decrement = () => + + const decrement = () => { setIndex((value) => { const updatedValue = value - 1; return updatedValue >= 0 ? updatedValue : value; }); + }; useEffect(() => { if (isOutside) { @@ -77,12 +151,142 @@ const MentionDropdown: FC = (props) => { break; } case KeyboardKeys.Enter: { - onClick(users[index]); + const currentElement = listRefs.current.find( + (item) => + parseInt(item.getAttribute("tabIndex") || "0", 10) === index, + ); + + if (!currentElement) return; + + const type = currentElement.dataset.type; + + if (type === "user") { + // Handle user selection + onClick(users[index]); + } else if (type === "discussion") { + // Handle discussion selection + const discussionIndex = + index - usersList.length - (users.length > 5 ? 1 : 0); + onClickDiscussion(discussions[discussionIndex]); + } else if (type === "toggleUsers") { + // Toggle "Show More Users" + setIsShowMoreUsers((prev) => { + if (!prev && users.length > 5) { + // If expanding, move focus to the 6th user + setIndex(5); + } else { + // If collapsing, move focus back to the toggleUsers button + setIndex(5); + } + return !prev; + }); + } else if (type === "toggleDiscussions") { + // Toggle "Show More Discussions" + setIsShowMoreDiscussions((prev) => { + if (!prev && discussions.length > 5) { + // If expanding, move focus to the 6th discussion + setIndex(usersList.length + 5); + } else { + // If collapsing, move focus back to the toggleDiscussions button + setIndex(usersList.length + 5); + } + return !prev; + }); + } else if (type === "newDiscussion" && searchText) { + createDiscussion(); + } + break; } } }; + const createDiscussion = async () => { + if (!canCreateDiscussion) { + return; + } + + const common = await CommonService.getCachedCommonById(commonId); + + if (!common) { + return; + } + + const discussionId = uuidv4(); + const userName = getUserName(user); + const userId = user.uid; + const firstMessage = generateFirstMessage({ userName, userId }); + const title = searchText.slice(1); + const optimisticFeedItem = generateOptimisticFeedItem({ + userId, + commonId, + type: CommonFeedType.OptimisticDiscussion, + circleVisibility: circleVisibility ?? [], + discussionId, + title, + content: firstMessage, + lastMessageContent: { + ownerId: userId, + userName, + ownerType: DiscussionMessageOwnerType.System, + content: firstMessage, + }, + shouldFocus: false + }); + dispatch( + optimisticActions.setOptimisticFeedItem({ + data: optimisticFeedItem, + common + }), + ); + + dispatch( + commonActions.createDiscussion.request({ + payload: { + id: discussionId, + title, + message: firstMessage, + ownerId: userId, + commonId, + images: [], + circleVisibility: circleVisibility ?? [], + }, + commonId + }), + ); + + onCreateDiscussion(commonId, discussionId, title); + }; + const getRef = (element) => listRefs.current.push(element); + const isEmptyContent = + (initUsers.length > 0 || initDiscussions.length > 0) && + users.length === 0 && + discussions.length === 0; + const isLoading = initUsers.length === 0 && initDiscussions.length === 0; + + const calculateNewDiscussionTabIndex = () => { + let tabIndex = 0; + + // Users + if (users.length > 0) { + tabIndex += isShowMoreUsers ? users.length : Math.min(users.length, 5); // Visible users + if (users.length > 5 && !isShowMoreUsers) { + tabIndex += 1; // "Show More Users" button + } + } + + // Discussions + if (discussions.length > 0) { + tabIndex += isShowMoreDiscussions + ? discussions.length + : Math.min(discussions.length, 5); // Visible discussions + if (discussions.length > 5 && !isShowMoreDiscussions) { + tabIndex += 1; // "Show More Discussions" button + } + } + + return tabIndex; // The "New Discussion" button follows all visible items + }; return (
    = (props) => { data-cy="mentions-portal" onKeyDown={onKeyDown} > - {users.length === 0 && ( -
  • + {isLoading && ( +
  • )} - {users.map((user, index) => ( + {isEmptyContent && ( +
  • +

    No results

    +
  • + )} + {users.length > 0 &&

    People

    } + {usersList.map((user, index) => (
  • onClick(user)} className={styles.content} > @@ -114,6 +325,72 @@ const MentionDropdown: FC = (props) => {

    {getUserName(user)}

  • ))} + {users.length > 5 && !isShowMoreUsers && ( +
  • setIsShowMoreUsers((prev) => !prev)} + className={styles.content} + > + +

    {users.length - 5} more results

    +
  • + )} + {users.length > 0 && discussions.length > 0 && ( + + )} + {discussions.length > 0 && ( +

    Link to stream

    + )} + {discussionsList.map((discussion, index) => ( +
  • 5 ? 1 : 0) + index} + data-type="discussion" + key={discussion.id} + onClick={() => onClickDiscussion(discussion)} + className={styles.content} + > + +

    {discussion.title}

    +
  • + ))} + {discussions.length > 5 && !isShowMoreDiscussions && ( +
  • setIsShowMoreDiscussions((prev) => !prev)} + className={styles.content} + > + +

    + {discussions.length - 5} more results +

    +
  • + )} + {((users.length > 0 || discussions.length > 0) && canCreateDiscussion && searchText) && ( + + )} + {(searchText && canCreateDiscussion) && ( +
  • + +

    + New "{searchText.slice(1)}" discussion +

    +
  • + )}
); }; diff --git a/src/shared/ui-kit/TextEditor/constants/elementType.ts b/src/shared/ui-kit/TextEditor/constants/elementType.ts index e9550961f5..d45d03504a 100644 --- a/src/shared/ui-kit/TextEditor/constants/elementType.ts +++ b/src/shared/ui-kit/TextEditor/constants/elementType.ts @@ -3,6 +3,8 @@ export enum ElementType { Heading = "heading", Link = "link", Mention = "mention", + StreamMention = "StreamMention", + DiscussionLink = "DiscussionLink", NumberedList = "numbered-list", BulletedList = "bulleted-list", ListItem = "list-item", @@ -19,5 +21,7 @@ export const PARENT_TYPES = [ export const INLINE_TYPES = [ ElementType.Link, ElementType.Mention, + ElementType.StreamMention, + ElementType.DiscussionLink, ElementType.Emoji, ]; diff --git a/src/shared/ui-kit/TextEditor/hofs/withMentions.ts b/src/shared/ui-kit/TextEditor/hofs/withMentions.ts index 91bc505b39..8fe77f926b 100644 --- a/src/shared/ui-kit/TextEditor/hofs/withMentions.ts +++ b/src/shared/ui-kit/TextEditor/hofs/withMentions.ts @@ -9,14 +9,14 @@ export const withMentions = (editor: Editor): Editor => { checkIsInlineType(element.type) || isInline(element); editor.isVoid = (element) => { - return (element.type as ElementType) === ElementType.Mention + return ((element.type as ElementType) === ElementType.Mention || (element.type as ElementType) === ElementType.StreamMention || (element.type as ElementType) === ElementType.DiscussionLink) ? true : isVoid(element); }; editor.markableVoid = (element) => { return ( - (element.type as ElementType) === ElementType.Mention || + ((element.type as ElementType) === ElementType.Mention || (element.type as ElementType) === ElementType.StreamMention || (element.type as ElementType) === ElementType.DiscussionLink) || markableVoid(element) ); }; diff --git a/src/shared/ui-kit/TextEditor/types.ts b/src/shared/ui-kit/TextEditor/types.ts index 0386b74bd3..ff135c8f1e 100644 --- a/src/shared/ui-kit/TextEditor/types.ts +++ b/src/shared/ui-kit/TextEditor/types.ts @@ -3,6 +3,7 @@ import { HistoryEditor } from "slate-history"; import { ReactEditor } from "slate-react"; import { Skin } from "@emoji-mart/data"; import { ElementType, FormatType } from "./constants"; +import { InternalLinkData } from "@/shared/utils"; export type TextEditorValue = Descendant[]; @@ -59,6 +60,20 @@ export interface MentionElement extends BaseElement { userId: string; } +export interface DiscussionLinkElement extends BaseElement { + type: ElementType.DiscussionLink; + title: string; + link: string; + onInternalLinkClick?: (data: InternalLinkData) => void; +} + +export interface StreamMentionElement extends BaseElement { + type: ElementType.StreamMention; + title: string; + commonId: string; + discussionId +} + export interface EmojiElement extends BaseElement { type: ElementType.Emoji; emoji: Skin; @@ -90,5 +105,7 @@ export type CustomElement = | BulletedListElement | ListItemElement | MentionElement + | StreamMentionElement | EmojiElement - | CheckboxItemElement; + | CheckboxItemElement + | DiscussionLinkElement; diff --git a/src/shared/ui-kit/TextEditor/utils/checkIsTextEditorValueEmpty.ts b/src/shared/ui-kit/TextEditor/utils/checkIsTextEditorValueEmpty.ts index 3d0f5e9537..ffad11e543 100644 --- a/src/shared/ui-kit/TextEditor/utils/checkIsTextEditorValueEmpty.ts +++ b/src/shared/ui-kit/TextEditor/utils/checkIsTextEditorValueEmpty.ts @@ -18,7 +18,7 @@ export const checkIsTextEditorValueEmpty = ( const firstChild = firstElement.children[0]; const secondChild = firstElement.children[1]; - if (Element.isElementType(secondChild, ElementType.Mention)) { + if (Element.isElementType(secondChild, ElementType.Mention) || Element.isElementType(secondChild, ElementType.StreamMention) || Element.isElementType(secondChild, ElementType.DiscussionLink)) { return false; } diff --git a/src/shared/ui-kit/TextEditor/utils/countTextEditorEmojiElements.ts b/src/shared/ui-kit/TextEditor/utils/countTextEditorEmojiElements.ts index 828decab8a..12c86269f8 100644 --- a/src/shared/ui-kit/TextEditor/utils/countTextEditorEmojiElements.ts +++ b/src/shared/ui-kit/TextEditor/utils/countTextEditorEmojiElements.ts @@ -25,7 +25,7 @@ export const countTextEditorEmojiElements = ( emojiCount = emojiCount + 1; } else if (children?.text !== "") { hasText = true; - } else if (Element.isElementType(children, ElementType.Mention)) { + } else if (Element.isElementType(children, ElementType.Mention) || Element.isElementType(children, ElementType.StreamMention) || Element.isElementType(children, ElementType.DiscussionLink)) { hasText = true; } }); diff --git a/src/shared/ui-kit/TextEditor/utils/index.ts b/src/shared/ui-kit/TextEditor/utils/index.ts index 152bee9588..393ec769af 100644 --- a/src/shared/ui-kit/TextEditor/utils/index.ts +++ b/src/shared/ui-kit/TextEditor/utils/index.ts @@ -29,4 +29,6 @@ export * from "./removeTextEditorEmptyEndLinesValues"; export * from "./countTextEditorEmojiElements"; export * from "./insertEmoji"; export * from "./insertMention"; +export * from "./insertStreamMention"; export * from "./isRtlWithNoMentions"; +export * from "./insertDiscussionLink"; diff --git a/src/shared/ui-kit/TextEditor/utils/insertDiscussionLink.ts b/src/shared/ui-kit/TextEditor/utils/insertDiscussionLink.ts new file mode 100644 index 0000000000..79c67286bc --- /dev/null +++ b/src/shared/ui-kit/TextEditor/utils/insertDiscussionLink.ts @@ -0,0 +1,18 @@ +import { Transforms } from "slate"; +import { ReactEditor } from "slate-react"; +import { ElementType } from "../constants"; +import { DiscussionLinkElement } from "../types"; + +export const insertDiscussionLink = (editor, title, link, onInternalLinkClick) => { + const discussionLink: DiscussionLinkElement = { + type: ElementType.DiscussionLink, + title: `${title} `, + link, + onInternalLinkClick, + children: [{ text: "" }], + }; + Transforms.insertNodes(editor, discussionLink); + Transforms.move(editor); + + ReactEditor.focus(editor); +}; diff --git a/src/shared/ui-kit/TextEditor/utils/insertStreamMention.ts b/src/shared/ui-kit/TextEditor/utils/insertStreamMention.ts new file mode 100644 index 0000000000..3fabb6c9f6 --- /dev/null +++ b/src/shared/ui-kit/TextEditor/utils/insertStreamMention.ts @@ -0,0 +1,18 @@ +import { Transforms } from "slate"; +import { ReactEditor } from "slate-react"; +import { ElementType } from "../constants"; +import { StreamMentionElement } from "../types"; + +export const insertStreamMention = (editor, character) => { + const mention: StreamMentionElement = { + type: ElementType.StreamMention, + title: `${character.title} `, + commonId: character.commonId, + discussionId: character.id, + children: [{ text: "" }], + }; + Transforms.insertNodes(editor, mention); + Transforms.move(editor); + + ReactEditor.focus(editor); +}; diff --git a/src/shared/ui-kit/TextEditor/utils/isRtlWithNoMentions.ts b/src/shared/ui-kit/TextEditor/utils/isRtlWithNoMentions.ts index 569383024e..090c4992e2 100644 --- a/src/shared/ui-kit/TextEditor/utils/isRtlWithNoMentions.ts +++ b/src/shared/ui-kit/TextEditor/utils/isRtlWithNoMentions.ts @@ -12,7 +12,7 @@ export const isRtlWithNoMentions = ( const parsedText = typeof text === "string" ? JSON.parse(text) : text; const textWithNoMentions = JSON.stringify( parsedText[0].children?.filter( - (item) => item.type !== ElementType.Mention, + (item) => (item.type !== ElementType.Mention || item.type !== ElementType.StreamMention || item.type !== ElementType.DiscussionLink), ), ); return isRtlText(textWithNoMentions); diff --git a/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts b/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts index 1010d568d0..f849fc0e25 100644 --- a/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts +++ b/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts @@ -24,6 +24,8 @@ export const removeTextEditorEmptyEndLinesValues = ( if ( firstChild?.text !== "" || Element.isElementType(secondChild, ElementType.Mention) || + Element.isElementType(secondChild, ElementType.StreamMention) || + Element.isElementType(secondChild, ElementType.DiscussionLink) || Element.isElementType(secondChild, ElementType.Emoji) ) { endOfTextIndex = index; diff --git a/src/shared/ui-kit/TextEditor/utils/serializeTextEditorValue.ts b/src/shared/ui-kit/TextEditor/utils/serializeTextEditorValue.ts index f41e6f7a85..2ccd0a51dd 100644 --- a/src/shared/ui-kit/TextEditor/utils/serializeTextEditorValue.ts +++ b/src/shared/ui-kit/TextEditor/utils/serializeTextEditorValue.ts @@ -17,6 +17,10 @@ const serializeDescendant = (descendant: Descendant): string => { return `${combinedChildren}\n`; case ElementType.Mention: return `@${descendant.displayName}`; + case ElementType.StreamMention: + return `@${descendant.title}`; + case ElementType.DiscussionLink: + return `@${descendant.title}`; default: return descendant.text || ""; } diff --git a/src/shared/utils/generateOptimisticFeedItem.ts b/src/shared/utils/generateOptimisticFeedItem.ts index 18ce7a2e51..570b9ade51 100644 --- a/src/shared/utils/generateOptimisticFeedItem.ts +++ b/src/shared/utils/generateOptimisticFeedItem.ts @@ -11,6 +11,7 @@ interface GenerateOptimisticFeedItemPayload { content: string; circleVisibility: string[]; lastMessageContent: LastMessageContent + shouldFocus?: boolean; } export const generateOptimisticFeedItem = ({ @@ -22,6 +23,7 @@ export const generateOptimisticFeedItem = ({ content, circleVisibility, lastMessageContent, + shouldFocus = true }: GenerateOptimisticFeedItemPayload): CommonFeed => { const optimisticFeedItemId = uuidv4(); @@ -60,6 +62,7 @@ export const generateOptimisticFeedItem = ({ circleVisibilityByCommon: null, linkedCommonIds: [], state: OptimisticFeedItemState.loading, + shouldFocus: shouldFocus }, circleVisibility, } diff --git a/src/shared/utils/generateStaticShareLink.ts b/src/shared/utils/generateStaticShareLink.ts index c68737288f..f5a153b56d 100644 --- a/src/shared/utils/generateStaticShareLink.ts +++ b/src/shared/utils/generateStaticShareLink.ts @@ -2,7 +2,7 @@ import { Environment, REACT_APP_ENV, ROUTE_PATHS } from "../constants"; import { Common, Discussion, DiscussionMessage, Proposal } from "../models"; import { matchRoute } from "./matchRoute"; -const staticLinkPrefix = () => { +export const staticLinkPrefix = () => { if (window.location.hostname === "localhost") { return "http://localhost:3000"; } @@ -71,3 +71,14 @@ export const generateStaticShareLink = ( return ""; } }; + +export const generateDiscussionShareLink = ( + commonId: string, + discussionId: string, +): string => { + const basePath: string = getStaticLinkBasePath(); + + return `${staticLinkPrefix()}/${basePath}/${ + commonId + }?discussionItem=${discussionId}`; +}; \ No newline at end of file diff --git a/src/shared/utils/tests/setupTests.ts b/src/shared/utils/tests/setupTests.ts index b2fc3c38b7..84c60db4a3 100644 --- a/src/shared/utils/tests/setupTests.ts +++ b/src/shared/utils/tests/setupTests.ts @@ -1,4 +1,15 @@ // The order is important here! +import { TextDecoder, TextEncoder } from "util"; + +if (typeof global.TextDecoder === "undefined") { + (global as any).TextDecoder = TextDecoder; +} + +if (typeof global.TextEncoder === "undefined") { + global.TextEncoder = TextEncoder; +} + + import "./mockMatchMedia"; import "@/projectSetupImports"; import "./mockConfig"; diff --git a/src/store/states/cache/saga/copyFeedStateByCommonId.ts b/src/store/states/cache/saga/copyFeedStateByCommonId.ts index 506777cf93..a35d5a453d 100644 --- a/src/store/states/cache/saga/copyFeedStateByCommonId.ts +++ b/src/store/states/cache/saga/copyFeedStateByCommonId.ts @@ -9,7 +9,7 @@ export function* copyFeedStateByCommonId({ const commonState = (yield select(selectCommonState)) as CommonState; const specificCommonFeedItems = commonState.feedItems[commonId]; const data = - specificCommonFeedItems.data && specificCommonFeedItems.data.slice(0, 30); + specificCommonFeedItems?.data && specificCommonFeedItems.data.slice(0, 30); const feedItems = { ...specificCommonFeedItems, data, diff --git a/src/store/states/cache/selectors.ts b/src/store/states/cache/selectors.ts index 626520dca3..d3e1922c62 100644 --- a/src/store/states/cache/selectors.ts +++ b/src/store/states/cache/selectors.ts @@ -1,3 +1,4 @@ +import { createSelector } from "reselect"; import { getChatChannelUserStatusKey, getCommonMemberStateKey, @@ -5,74 +6,110 @@ import { import { getFeedItemUserMetadataKey } from "@/shared/constants/getFeedItemUserMetadataKey"; import { AppState } from "@/shared/interfaces"; -export const selectUserStateById = (userId: string) => (state: AppState) => - state.cache.userStates[userId] || null; - -export const selectUserStates = () => (state: AppState) => - state.cache.userStates; - -export const selectGovernanceStateByCommonId = - (commonId: string) => (state: AppState) => - state.cache.governanceByCommonIdStates[commonId] || null; - -export const selectDiscussionStateById = - (discussionId: string) => (state: AppState) => - state.cache.discussionStates[discussionId] || null; - -export const selectDiscussionStates = () => (state: AppState) => - state.cache.discussionStates; - -export const selectProposalStates = () => (state: AppState) => - state.cache.proposalStates; - -export const selectProposalStateById = - (proposalId: string) => (state: AppState) => - state.cache.proposalStates[proposalId] || null; - -export const selectDiscussionMessagesStateByDiscussionId = - (discussionId: string) => (state: AppState) => - state.cache.discussionMessagesStates[discussionId] || null; - -export const selectChatChannelMessagesStateByChatChannelId = - (chatChannelId: string) => (state: AppState) => - state.cache.chatChannelMessagesStates[chatChannelId] || null; - -export const selectCommonMembersStateByCommonId = - (commonId?: string) => (state: AppState) => { - if (!commonId) { - return null; - } - - return state.cache.commonMembersState[commonId] || null; - }; - -export const selectFeedStateByCommonId = - (commonId: string) => (state: AppState) => - state.cache.feedByCommonIdStates[commonId] || null; - -export const selectFeedItemUserMetadataStates = (state: AppState) => - state.cache.feedItemUserMetadataStates; - -export const selectFeedItemUserMetadata = - (info: { commonId: string; userId: string; feedObjectId: string }) => - (state: AppState) => - state.cache.feedItemUserMetadataStates[getFeedItemUserMetadataKey(info)] || - null; - -export const selectChatChannelUserStatusStates = (state: AppState) => - state.cache.chatChannelUserStatusStates; - -export const selectChatChannelUserStatus = - (info: { userId: string; chatChannelId: string }) => (state: AppState) => - state.cache.chatChannelUserStatusStates[ - getChatChannelUserStatusKey(info) - ] || null; - -export const selectCommonMemberStateByUserAndCommonIds = - (info: { userId: string; commonId: string }) => (state: AppState) => - state.cache.commonMemberByUserAndCommonIdsStates[ - getCommonMemberStateKey(info) - ] || null; - -export const selectExternalCommonUsers = (state: AppState) => - state.cache.externalCommonUsers; +// Base selector to access cache state +const selectCacheState = (state: AppState) => state.cache; + +// User States +export const selectUserStates = createSelector( + selectCacheState, + (cache) => cache.userStates +); + +export const selectUserStateById = (userId: string) => + createSelector(selectUserStates, (userStates) => userStates[userId] || null); + +// Governance States +export const selectGovernanceStateByCommonId = (commonId: string) => + createSelector( + selectCacheState, + (cache) => cache.governanceByCommonIdStates[commonId] || null + ); + +// Discussion States +export const selectDiscussionStates = createSelector( + selectCacheState, + (cache) => cache.discussionStates +); + +export const selectDiscussionStateById = (discussionId: string) => + createSelector( + selectDiscussionStates, + (discussionStates) => discussionStates[discussionId] || null + ); + +// Proposal States +export const selectProposalStates = createSelector( + selectCacheState, + (cache) => cache.proposalStates +); + +export const selectProposalStateById = (proposalId: string) => + createSelector( + selectProposalStates, + (proposalStates) => proposalStates[proposalId] || null + ); + +// Discussion Messages +export const selectDiscussionMessagesStateByDiscussionId = (discussionId: string) => + createSelector( + selectCacheState, + (cache) => cache.discussionMessagesStates[discussionId] || null + ); + +// Chat Channel Messages +export const selectChatChannelMessagesStateByChatChannelId = (chatChannelId: string) => + createSelector( + selectCacheState, + (cache) => cache.chatChannelMessagesStates[chatChannelId] || null + ); + +// Common Members +export const selectCommonMembersStateByCommonId = (commonId?: string) => + createSelector( + selectCacheState, + (cache) => (commonId ? cache.commonMembersState[commonId] || null : null) + ); + +// Feed States +export const selectFeedStateByCommonId = (commonId: string) => + createSelector( + selectCacheState, + (cache) => cache.feedByCommonIdStates[commonId] || null + ); + +// Feed Item User Metadata States +export const selectFeedItemUserMetadataStates = createSelector( + selectCacheState, + (cache) => cache.feedItemUserMetadataStates +); + +export const selectFeedItemUserMetadata = (info: { commonId: string; userId: string; feedObjectId: string }) => + createSelector( + selectFeedItemUserMetadataStates, + (metadataStates) => metadataStates[getFeedItemUserMetadataKey(info)] || null + ); + +// Chat Channel User Status States +export const selectChatChannelUserStatusStates = createSelector( + selectCacheState, + (cache) => cache.chatChannelUserStatusStates +); + +export const selectChatChannelUserStatus = (info: { userId: string; chatChannelId: string }) => + createSelector( + selectChatChannelUserStatusStates, + (statusStates) => statusStates[getChatChannelUserStatusKey(info)] || null + ); + +// Common Member State by User and Common IDs +export const selectCommonMemberStateByUserAndCommonIds = (info: { userId: string; commonId: string }) => + createSelector( + selectCacheState, + (cache) => cache.commonMemberByUserAndCommonIdsStates[getCommonMemberStateKey(info)] || null + ); + +// External Common Users +export const selectExternalCommonUsers = createSelector( + selectCacheState, + (cache) => cache.externalCommonUsers +); diff --git a/src/store/states/common/reducer.ts b/src/store/states/common/reducer.ts index 249aa11d8b..261e53c008 100644 --- a/src/store/states/common/reducer.ts +++ b/src/store/states/common/reducer.ts @@ -648,8 +648,8 @@ export const reducer = createReducer(initialState) // Sort feedItems by updatedAt in descending order feedItems.sort((a, b) => { - const dateA = a?.feedItem?.updatedAt.toDate().getTime(); - const dateB = b?.feedItem?.updatedAt.toDate().getTime(); + const dateA = a?.feedItem?.updatedAt?.toDate().getTime(); + const dateB = b?.feedItem?.updatedAt?.toDate().getTime(); return dateB - dateA; // Sort in descending order }); diff --git a/src/store/states/common/saga/searchFeedItems.ts b/src/store/states/common/saga/searchFeedItems.ts index dd6f9dbfc2..7c6dba79dc 100644 --- a/src/store/states/common/saga/searchFeedItems.ts +++ b/src/store/states/common/saga/searchFeedItems.ts @@ -22,8 +22,8 @@ export function* searchFeedItems( const pinnedFeedItems = (yield select( selectPinnedFeedItems(commonId), )) as FeedItems; - const discussionStates = yield select(selectDiscussionStates()); - const proposalStates = yield select(selectProposalStates()); + const discussionStates = yield select(selectDiscussionStates); + const proposalStates = yield select(selectProposalStates); const projectStates = yield select(selectCommonStates()); const filterFn = ({ feedItem }) => { diff --git a/src/store/states/common/selectors.ts b/src/store/states/common/selectors.ts index 6b983302d7..51d8faaf33 100644 --- a/src/store/states/common/selectors.ts +++ b/src/store/states/common/selectors.ts @@ -1,76 +1,134 @@ +import { createSelector } from "reselect"; import { AppState } from "@/shared/interfaces"; +// Base selector to get the common state export const selectCommonState = (state: AppState) => state.common; -export const selectCommonAction = (state: AppState) => - state.common.commonAction || null; - -export const selectCommonMember = (commonId: string) => (state: AppState) => - state.common.commonMembers[commonId] || {}; - -export const selectGovernance = (commonId: string) => (state: AppState) => - state.common.governance[commonId] || {}; - -export const selectDiscussionCreationData = - (commonId: string) => (state: AppState) => - state.common.discussionCreations[commonId]?.data || null; - -export const selectIsDiscussionCreationLoading = - (commonId: string) => (state: AppState) => - state.common.discussionCreations[commonId]?.loading || false; - -export const selectProposalCreationData = - (commonId: string) => (state: AppState) => - state.common.proposalCreations[commonId]?.data || null; - -export const selectIsProposalCreationLoading = - (commonId: string) => (state: AppState) => - state.common.proposalCreations[commonId]?.loading || false; - -export const selectFeedItems = (commonId: string) => (state: AppState) => - state.common.feedItems[commonId] || { - data: null, - loading: false, - hasMore: false, - firstDocTimestamp: null, - lastDocTimestamp: null, - batchNumber: 0, - }; - -export const selectPinnedFeedItems = - (commonId: string) => (state: AppState) => { - const pinnedFeedItems = state.common.pinnedFeedItems[commonId]; - return { - data: pinnedFeedItems?.data || [], - loading: pinnedFeedItems?.loading || false, - }; - }; - -export const selectFilteredFeedItems = - (commonId: string) => (state: AppState) => - state.common.searchState[commonId]?.feedItems || null; - -export const selectFilteredPinnedFeedItems = - (commonId: string) => (state: AppState) => - state.common.searchState?.[commonId]?.pinnedFeedItems ?? null; - -export const selectFeedSearchValue = (commonId: string) => (state: AppState) => - state.common.searchState[commonId]?.searchValue || ""; - -export const selectIsSearchingFeedItems = - (commonId: string) => (state: AppState) => - state.common.searchState[commonId]?.isSearching || false; - -export const selectIsNewProjectCreated = - (commonId: string) => (state: AppState) => - state.common.isNewProjectCreated[commonId] || {}; - -export const selectSharedFeedItem = (commonId: string) => (state: AppState) => - state.common.sharedFeedItem[commonId] || null; - -export const selectRecentStreamId = (state: AppState) => - state.common.recentStreamId || ""; - -export const selectRecentAssignedCircle = - (commonId: string, memberId: string) => (state: AppState) => - state.common.recentAssignedCircleByMember[commonId]?.[memberId] || {}; +// Common Action +export const selectCommonAction = createSelector( + selectCommonState, + (common) => common.commonAction || null +); + +// Common Member +export const selectCommonMember = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.commonMembers[commonId] || {} + ); + +// Governance +export const selectGovernance = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.governance[commonId] || {} + ); + +// Discussion Creation +export const selectDiscussionCreationData = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.discussionCreations[commonId]?.data || null + ); + +export const selectIsDiscussionCreationLoading = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.discussionCreations[commonId]?.loading || false + ); + +// Proposal Creation +export const selectProposalCreationData = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.proposalCreations[commonId]?.data || null + ); + +export const selectIsProposalCreationLoading = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.proposalCreations[commonId]?.loading || false + ); + +// Feed Items +export const selectFeedItems = (commonId: string) => + createSelector( + selectCommonState, + (common) => + common.feedItems[commonId] || { + data: null, + loading: false, + hasMore: false, + firstDocTimestamp: null, + lastDocTimestamp: null, + batchNumber: 0, + } + ); + +// Pinned Feed Items +export const selectPinnedFeedItems = (commonId: string) => + createSelector( + selectCommonState, + (common) => { + const pinnedFeedItems = common.pinnedFeedItems[commonId]; + return { + data: pinnedFeedItems?.data || [], + loading: pinnedFeedItems?.loading || false, + }; + } + ); + +// Filtered Feed Items +export const selectFilteredFeedItems = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.searchState[commonId]?.feedItems || null + ); + +// Filtered Pinned Feed Items +export const selectFilteredPinnedFeedItems = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.searchState?.[commonId]?.pinnedFeedItems ?? null + ); + +// Feed Search Value +export const selectFeedSearchValue = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.searchState[commonId]?.searchValue || "" + ); + +// Is Searching Feed Items +export const selectIsSearchingFeedItems = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.searchState[commonId]?.isSearching || false + ); + +// Is New Project Created +export const selectIsNewProjectCreated = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.isNewProjectCreated[commonId] || {} + ); + +// Shared Feed Item +export const selectSharedFeedItem = (commonId: string) => + createSelector( + selectCommonState, + (common) => common.sharedFeedItem[commonId] || null + ); + +// Recent Stream ID +export const selectRecentStreamId = createSelector( + selectCommonState, + (common) => common.recentStreamId || "" +); + +// Recent Assigned Circle +export const selectRecentAssignedCircle = (commonId: string, memberId: string) => + createSelector( + selectCommonState, + (common) => common.recentAssignedCircleByMember[commonId]?.[memberId] || {} + ); diff --git a/src/store/states/commonFeedFollows/selectors.ts b/src/store/states/commonFeedFollows/selectors.ts index d6744b7b5f..6e1cfa24cf 100644 --- a/src/store/states/commonFeedFollows/selectors.ts +++ b/src/store/states/commonFeedFollows/selectors.ts @@ -1,20 +1,33 @@ +import { createSelector } from "reselect"; import { AppState } from "@/shared/interfaces"; -export const selectFollowFeedItemMutationState = (state: AppState) => - state.commonFeedFollows.followFeedItemMutation; +// Base Selector for commonFeedFollows +const selectCommonFeedFollowsState = (state: AppState) => + state.commonFeedFollows; -export const selectFollowFeedItemMutationStateById = - (mutationId?: string) => (state: AppState) => - (mutationId && - state.commonFeedFollows.followFeedItemMutation[mutationId]) || - null; +// Follow Feed Item Mutation State +export const selectFollowFeedItemMutationState = createSelector( + selectCommonFeedFollowsState, + (feedFollows) => feedFollows.followFeedItemMutation +); -export const selectCommonFeedFollows = (state: AppState) => - state.commonFeedFollows.follows; +export const selectFollowFeedItemMutationStateById = (mutationId?: string) => + createSelector( + selectFollowFeedItemMutationState, + (mutationState) => (mutationId ? mutationState[mutationId] || null : null) + ); -export const selectCommonFeedFollowsByIds = - (commonId?: string, feedItemId?: string) => (state: AppState) => - (commonId && - feedItemId && - state.commonFeedFollows.follows[commonId]?.[feedItemId]) || - false; +// Common Feed Follows +export const selectCommonFeedFollows = createSelector( + selectCommonFeedFollowsState, + (feedFollows) => feedFollows.follows +); + +export const selectCommonFeedFollowsByIds = (commonId?: string, feedItemId?: string) => + createSelector( + selectCommonFeedFollows, + (follows) => + commonId && feedItemId + ? follows[commonId]?.[feedItemId] || false + : false + ); diff --git a/src/store/states/inbox/saga/searchInboxItems.ts b/src/store/states/inbox/saga/searchInboxItems.ts index 3f8fa0cdba..f22ff09b9d 100644 --- a/src/store/states/inbox/saga/searchInboxItems.ts +++ b/src/store/states/inbox/saga/searchInboxItems.ts @@ -22,9 +22,9 @@ import { InboxItems } from "../types"; import { doesUserMatchSearchValue } from "./searchFetchedInboxItems"; export function* getFilterBySearchValueFn(searchValue: string) { - const discussionStates = yield select(selectDiscussionStates()); - const proposalStates = yield select(selectProposalStates()); - const userStates = yield select(selectUserStates()); + const discussionStates = yield select(selectDiscussionStates); + const proposalStates = yield select(selectProposalStates); + const userStates = yield select(selectUserStates); const user = yield select(selectUser()); const userId = user?.uid; diff --git a/src/store/states/inbox/selectors.ts b/src/store/states/inbox/selectors.ts index a8b81868ae..003f79dcd1 100644 --- a/src/store/states/inbox/selectors.ts +++ b/src/store/states/inbox/selectors.ts @@ -1,21 +1,53 @@ +import { createSelector } from "reselect"; import { AppState } from "@/shared/interfaces"; -export const selectInboxItems = (state: AppState) => state.inbox.items; +// Base Selector for inbox +const selectInboxState = (state: AppState) => state.inbox; -export const selectSharedInboxItem = (state: AppState) => - state.inbox.sharedItem; +// Inbox Items +export const selectInboxItems = createSelector( + selectInboxState, + (inbox) => inbox.items +); -export const selectChatChannelItems = (state: AppState) => - state.inbox.chatChannelItems; +// Shared Inbox Item +export const selectSharedInboxItem = createSelector( + selectInboxState, + (inbox) => inbox.sharedItem +); -export const selectNextChatChannelItemId = (state: AppState) => - state.inbox.nextChatChannelItemId; +// Chat Channel Items +export const selectChatChannelItems = createSelector( + selectInboxState, + (inbox) => inbox.chatChannelItems +); -export const selectFilteredInboxItems = (state: AppState) => - state.inbox.searchState.items; +// Next Chat Channel Item ID +export const selectNextChatChannelItemId = createSelector( + selectInboxState, + (inbox) => inbox.nextChatChannelItemId +); -export const selectInboxSearchValue = (state: AppState) => - state.inbox.searchState.searchValue; +// Search State +const selectInboxSearchState = createSelector( + selectInboxState, + (inbox) => inbox.searchState +); -export const selectIsSearchingInboxItems = (state: AppState) => - state.inbox.searchState.isSearching; +// Filtered Inbox Items +export const selectFilteredInboxItems = createSelector( + selectInboxSearchState, + (searchState) => searchState.items +); + +// Inbox Search Value +export const selectInboxSearchValue = createSelector( + selectInboxSearchState, + (searchState) => searchState.searchValue +); + +// Is Searching Inbox Items +export const selectIsSearchingInboxItems = createSelector( + selectInboxSearchState, + (searchState) => searchState.isSearching +); diff --git a/src/store/states/multipleSpacesLayout/selectors.ts b/src/store/states/multipleSpacesLayout/selectors.ts index 6c5600bd0a..8d611347dd 100644 --- a/src/store/states/multipleSpacesLayout/selectors.ts +++ b/src/store/states/multipleSpacesLayout/selectors.ts @@ -1,10 +1,24 @@ +import { createSelector } from "reselect"; import { AppState } from "@/shared/interfaces"; -export const selectMultipleSpacesLayoutBreadcrumbs = (state: AppState) => - state.multipleSpacesLayout.breadcrumbs; +// Base Selector for multipleSpacesLayout state +const selectMultipleSpacesLayoutState = (state: AppState) => + state.multipleSpacesLayout; -export const selectMultipleSpacesLayoutBackUrl = (state: AppState) => - state.multipleSpacesLayout.backUrl; +// Breadcrumbs +export const selectMultipleSpacesLayoutBreadcrumbs = createSelector( + selectMultipleSpacesLayoutState, + (layout) => layout.breadcrumbs +); -export const selectMultipleSpacesLayoutMainWidth = (state: AppState) => - state.multipleSpacesLayout.mainWidth; +// Back URL +export const selectMultipleSpacesLayoutBackUrl = createSelector( + selectMultipleSpacesLayoutState, + (layout) => layout.backUrl +); + +// Main Width +export const selectMultipleSpacesLayoutMainWidth = createSelector( + selectMultipleSpacesLayoutState, + (layout) => layout.mainWidth +); diff --git a/src/store/states/optimistic/selectors.ts b/src/store/states/optimistic/selectors.ts index bed310b5ea..266a6c9a32 100644 --- a/src/store/states/optimistic/selectors.ts +++ b/src/store/states/optimistic/selectors.ts @@ -1,17 +1,35 @@ +import { createSelector } from "reselect"; import { AppState } from "@/shared/interfaces"; +// Base Selector for optimistic state +const selectOptimisticState = (state: AppState) => state.optimistic; -export const selectOptimisticFeedItems = (state: AppState) => - state.optimistic.optimisticFeedItems; +// Optimistic Feed Items +export const selectOptimisticFeedItems = createSelector( + selectOptimisticState, + (optimistic) => optimistic.optimisticFeedItems +); -export const selectOptimisticInboxFeedItems = (state: AppState) => - state.optimistic.optimisticInboxFeedItems; +// Optimistic Inbox Feed Items +export const selectOptimisticInboxFeedItems = createSelector( + selectOptimisticState, + (optimistic) => optimistic.optimisticInboxFeedItems +); -export const selectOptimisticDiscussionMessages = (state: AppState) => - state.optimistic.optimisticDiscussionMessages; +// Optimistic Discussion Messages +export const selectOptimisticDiscussionMessages = createSelector( + selectOptimisticState, + (optimistic) => optimistic.optimisticDiscussionMessages +); -export const selectCreatedOptimisticFeedItems = (state: AppState) => - state.optimistic.createdOptimisticFeedItems; +// Created Optimistic Feed Items +export const selectCreatedOptimisticFeedItems = createSelector( + selectOptimisticState, + (optimistic) => optimistic.createdOptimisticFeedItems +); -export const selectInstantDiscussionMessagesOrder = (state: AppState) => - state.optimistic.instantDiscussionMessagesOrder; +// Instant Discussion Messages Order +export const selectInstantDiscussionMessagesOrder = createSelector( + selectOptimisticState, + (optimistic) => optimistic.instantDiscussionMessagesOrder +); diff --git a/src/store/states/projects/selectors.ts b/src/store/states/projects/selectors.ts index 451e528231..ba7101ef41 100644 --- a/src/store/states/projects/selectors.ts +++ b/src/store/states/projects/selectors.ts @@ -1,12 +1,29 @@ +import { createSelector } from "reselect"; import { AppState } from "@/shared/interfaces"; -export const selectProjectsData = (state: AppState) => state.projects.data; +// Base selector for projects state +const selectProjectsState = (state: AppState) => state.projects; -export const selectAreProjectsLoading = (state: AppState) => - state.projects.isDataLoading; +// Projects Data +export const selectProjectsData = createSelector( + selectProjectsState, + (projects) => projects.data +); -export const selectAreProjectsFetched = (state: AppState) => - state.projects.isDataFetched; +// Are Projects Loading +export const selectAreProjectsLoading = createSelector( + selectProjectsState, + (projects) => projects.isDataLoading +); -export const selectIsCommonCreationDisabled = (state: AppState) => - state.projects.isCommonCreationDisabled; +// Are Projects Fetched +export const selectAreProjectsFetched = createSelector( + selectProjectsState, + (projects) => projects.isDataFetched +); + +// Is Common Creation Disabled +export const selectIsCommonCreationDisabled = createSelector( + selectProjectsState, + (projects) => projects.isCommonCreationDisabled +); diff --git a/yarn.lock b/yarn.lock index 14ceec5b07..d6eb35bd32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5097,6 +5097,19 @@ "@svgr/plugin-svgo" "^5.5.0" loader-utils "^2.0.0" +"@tanstack/query-core@4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-4.5.0.tgz#eeb0f290adb34f8682f65ebfce4e74709f3ae130" + integrity sha512-9pHE4TNlnBxdF24bTH3GGAJ4JdIDfJyuE/q+snyV425XEimPDe+OfofM8mVHfrn01Spvk9xAMpbqoEcmQG4kMg== + +"@tanstack/react-query@4.5.0": + version "4.5.0" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-4.5.0.tgz#566fbf4286a075d74cce32859ecfaafd11cb2d89" + integrity sha512-58JRis0+1hdKe37L7ZAJex849mlqhBvpNwlOjz6KzEMXHH/b0AyUHp1YIqn6ULiw7YpZiheYpCkdB/7ArIgfrg== + dependencies: + "@tanstack/query-core" "4.5.0" + use-sync-external-store "^1.2.0" + "@tanstack/react-table@^8.7.9": version "8.7.9" resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.7.9.tgz#9efcd168fb0080a7e0bc213b5eac8b55513babf4" @@ -19926,6 +19939,11 @@ use-long-press@^2.0.2: resolved "https://registry.yarnpkg.com/use-long-press/-/use-long-press-2.0.2.tgz#3c945ee45b671e9c6976fe5364bdb5f563b3ff82" integrity sha512-zQ4sujilCykA7fSZ+m2gDuGw5aW3Gm3M4pulRH4e8c4mGXw8MDQIMthCsHiolxpt/hCe/BbIvd/iDn9XNDzkYg== +use-sync-external-store@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" + integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"