From 56b9da48401218a0ab5a8d5fbd7c9933be3c7651 Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Sat, 2 Nov 2024 01:52:15 +0300 Subject: [PATCH 01/12] CW-mention-streams Added mentions streams Added Stream mention component --- package.json | 1 + src/pages/App/App.tsx | 31 ++++++++----- .../ChatComponent/ChatComponent.tsx | 9 ++++ .../components/ChatContent/ChatContent.tsx | 4 ++ .../components/ChatInput/ChatInput.tsx | 5 +- .../hooks/useDiscussionChatAdapter.ts | 3 ++ .../common/components/FeedItem/FeedItem.tsx | 1 + src/pages/commonFeed/CommonFeed.tsx | 2 +- .../components/DesktopChat/DesktopChat.tsx | 1 + .../components/MobileChat/MobileChat.tsx | 1 + src/services/CommonFeed.ts | 15 ++++++ src/services/Discussion.ts | 11 +++++ .../Chat/ChatMessage/ChatMessage.tsx | 5 ++ .../Chat/ChatMessage/DMChatMessage.tsx | 6 +++ .../StreamMention/StreamMention.tsx | 46 +++++++++++++++++++ .../components/StreamMention/index.ts | 1 + .../Chat/ChatMessage/components/index.ts | 1 + .../components/Chat/ChatMessage/types.ts | 1 + .../utils/getTextFromTextEditorString.tsx | 20 +++++++- src/shared/hooks/index.tsx | 1 + .../useCases/useDiscussionMessagesById.ts | 7 +++ .../usePreloadDiscussionMessagesById.ts | 3 ++ .../hooks/useFetchDiscussionsByCommonId.tsx | 13 ++++++ .../ui-kit/TextEditor/BaseTextEditor.tsx | 18 +++++++- .../TextEditor/components/Element/Element.tsx | 22 +++++++++ .../MentionDropdown/MentionDropdown.tsx | 40 +++++++++++++--- .../TextEditor/constants/elementType.ts | 2 + .../ui-kit/TextEditor/hofs/withMentions.ts | 4 +- src/shared/ui-kit/TextEditor/types.ts | 8 ++++ .../utils/checkIsTextEditorValueEmpty.ts | 2 +- src/shared/ui-kit/TextEditor/utils/index.ts | 1 + .../TextEditor/utils/insertStreamMention.ts | 18 ++++++++ .../removeTextEditorEmptyEndLinesValues.ts | 1 + yarn.lock | 18 ++++++++ 34 files changed, 297 insertions(+), 25 deletions(-) create mode 100644 src/shared/components/Chat/ChatMessage/components/StreamMention/StreamMention.tsx create mode 100644 src/shared/components/Chat/ChatMessage/components/StreamMention/index.ts create mode 100644 src/shared/hooks/useFetchDiscussionsByCommonId.tsx create mode 100644 src/shared/ui-kit/TextEditor/utils/insertStreamMention.ts diff --git a/package.json b/package.json index 1553bfedca..b662b1e6db 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", 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/common/components/ChatComponent/ChatComponent.tsx b/src/pages/common/components/ChatComponent/ChatComponent.tsx index 5bd1d392da..bed47c5079 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"; @@ -108,6 +109,7 @@ interface ChatComponentInterface { directParent?: DirectParent | null; renderChatInput?: () => ReactNode; onUserClick?: (userId: string) => void; + onStreamMentionClick?: (feedItemId: string) => void; onFeedItemClick?: (feedItemId: string) => void; onInternalLinkClick?: (data: InternalLinkData) => void; } @@ -156,6 +158,7 @@ export default function ChatComponent({ directParent, renderChatInput: renderChatInputOuter, onUserClick, + onStreamMentionClick, onFeedItemClick, onInternalLinkClick, }: ChatComponentInterface) { @@ -202,6 +205,7 @@ export default function ChatComponent({ }, onFeedItemClick, onUserClick, + onStreamMentionClick, commonId, onInternalLinkClick, }); @@ -215,6 +219,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], @@ -827,6 +834,7 @@ export default function ChatComponent({ onMessageDelete={handleMessageDelete} directParent={directParent} onUserClick={onUserClick} + onStreamMentionClick={onStreamMentionClick} onFeedItemClick={onFeedItemClick} onInternalLinkClick={onInternalLinkClick} isEmpty={ @@ -864,6 +872,7 @@ export default function ChatComponent({ onClearFinished={onClearFinished} shouldReinitializeEditor={shouldReinitializeEditor} users={users} + discussions={discussionsData ?? []} onEnterKeyDown={onEnterKeyDown} emojiCount={emojiCount} setMessage={setMessage} 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..f80f128a58 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, @@ -30,6 +30,7 @@ interface ChatInputProps { emojiCount: EmojiCount; onEnterKeyDown: (event: React.KeyboardEvent) => void; users: User[]; + discussions: Discussion[]; shouldReinitializeEditor: boolean; onClearFinished: () => void; canSendMessage?: boolean; @@ -58,6 +59,7 @@ export const ChatInput = React.memo(forwardRef 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/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 de5643757b..2d87529367 100644 --- a/src/pages/commonFeed/CommonFeed.tsx +++ b/src/pages/commonFeed/CommonFeed.tsx @@ -129,7 +129,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], ); 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/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/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..5b72d65788 100644 --- a/src/shared/components/Chat/ChatMessage/components/index.ts +++ b/src/shared/components/Chat/ChatMessage/components/index.ts @@ -3,6 +3,7 @@ export * from "./CheckboxItem"; export * from "./MessageLinkPreview"; export * from "./Time"; export * from "./UserMention"; +export * from "./StreamMention"; 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/getTextFromTextEditorString.tsx b/src/shared/components/Chat/ChatMessage/utils/getTextFromTextEditorString.tsx index a90ca84354..7f804576a7 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 } 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,16 @@ const getTextFromDescendant = async ({ onUserClick={onUserClick} /> ); + case ElementType.StreamMention: + return ( + + ); case ElementType.Emoji: return ( 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/ui-kit/TextEditor/BaseTextEditor.tsx b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx index 87e1841587..beb4abad3b 100644 --- a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx +++ b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx @@ -25,7 +25,7 @@ import { withHistory } from "slate-history"; import { ReactEditor, Slate, withReact } from "slate-react"; import { DOMRange } from "slate-react/dist/utils/dom"; import { KeyboardKeys } from "@/shared/constants/keyboardKeys"; -import { User } from "@/shared/models"; +import { Discussion, User } from "@/shared/models"; import { getUserName, isMobile, isRtlText } from "@/shared/utils"; import { Editor, @@ -40,6 +40,7 @@ import { parseStringToTextEditorValue, insertEmoji, insertMention, + insertStreamMention, checkIsCheckboxCreationText, toggleCheckboxItem, checkIsEmptyCheckboxCreationText, @@ -73,6 +74,7 @@ export interface TextEditorProps { disabled?: boolean; onKeyDown?: (event: KeyboardEvent) => void; users?: User[]; + discussions?: Discussion[]; shouldReinitializeEditor: boolean; onClearFinished: () => void; scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void; @@ -111,6 +113,7 @@ const BaseTextEditor = forwardRef((props onClearFinished, scrollSelectionIntoView, elementStyles, + discussions, } = props; const editor = useMemo( () => @@ -251,6 +254,12 @@ const BaseTextEditor = forwardRef((props .startsWith(search.text.substring(1).toLowerCase()); }); + const discussionChars = (discussions ?? []).filter((discussion) => { + return discussion.title + ?.toLowerCase() + .startsWith(search.text.substring(1).toLowerCase()); + }); + useEffect(() => { if (search && search.text) { setTarget({ @@ -409,7 +418,14 @@ const BaseTextEditor = forwardRef((props setTarget(null); setShouldFocusTarget(false); }} + onClickDiscussion={(discussion: Discussion) => { + Transforms.select(editor, target); + insertStreamMention(editor, discussion); + setTarget(null); + setShouldFocusTarget(false); + }} users={chars} + discussions={discussionChars} onClose={() => { setTarget(null); setShouldFocusTarget(false); diff --git a/src/shared/ui-kit/TextEditor/components/Element/Element.tsx b/src/shared/ui-kit/TextEditor/components/Element/Element.tsx index 17f1e7960b..bddabac37d 100644 --- a/src/shared/ui-kit/TextEditor/components/Element/Element.tsx +++ b/src/shared/ui-kit/TextEditor/components/Element/Element.tsx @@ -22,6 +22,20 @@ const Mention = ({ attributes, element, className, children }) => { ); }; +const StreamMention = ({ attributes, element, className, children }) => { + return ( + + @{element.title} + {children} + + ); +}; + const Element: FC = ( props, ) => { @@ -83,6 +97,14 @@ const Element: FC = ( /> ); } + case ElementType.StreamMention: { + return ( + + ) + } case ElementType.Emoji: { return ( void; + onClickDiscussion: (discussion: Discussion) => void; + discussions?: Discussion[]; onClose: () => void; users?: User[]; shouldFocusTarget?: boolean; @@ -20,7 +22,9 @@ export interface MentionDropdownProps { const MentionDropdown: FC = (props) => { const { onClick, + onClickDiscussion, users = [], + discussions = [], onClose, shouldFocusTarget, } = props; @@ -30,11 +34,12 @@ const MentionDropdown: FC = (props) => { const [index, setIndex] = useState(0); const userIds = useMemo(() => users.map(({ uid }) => uid), [users]); + const discussionIds = useMemo(() => discussions.map(({ id }) => id), [discussions]); useEffect(() => { if (shouldFocusTarget) { const filteredListRefs = uniq(listRefs.current).filter((item) => { - if (userIds.includes(item?.id)) { + if (userIds.includes(item?.id) || discussionIds.includes(item?.id)) { return true; } @@ -44,12 +49,14 @@ const MentionDropdown: FC = (props) => { listRefs.current = filteredListRefs; filteredListRefs && filteredListRefs?.[index]?.focus(); } - }, [index, shouldFocusTarget, userIds]); + }, [index, shouldFocusTarget, userIds, discussionIds]); const increment = () => { setIndex((value) => { const updatedValue = value + 1; - return updatedValue > users.length - 1 ? value : updatedValue; + const usersLastIndex = users.length - 1; + const discussionsLastIndex = discussions.length - 1; + return updatedValue > discussionsLastIndex + usersLastIndex ? value : updatedValue; }); }; const decrement = () => @@ -77,7 +84,11 @@ const MentionDropdown: FC = (props) => { break; } case KeyboardKeys.Enter: { - onClick(users[index]); + if(index > users.length - 1) { + onClickDiscussion(discussions[index - users.length]); + } else { + onClick(users[index]); + } } } }; @@ -92,7 +103,7 @@ const MentionDropdown: FC = (props) => { data-cy="mentions-portal" onKeyDown={onKeyDown} > - {users.length === 0 && ( + {(users.length === 0 && discussions.length === 0) && (
  • @@ -114,6 +125,23 @@ const MentionDropdown: FC = (props) => {

    {getUserName(user)}

    ))} + {discussions.map((discussion, index) => ( +
  • onClickDiscussion(discussion)} + className={styles.content} + > + +

    {discussion.title}

    +
  • + ))} ); }; diff --git a/src/shared/ui-kit/TextEditor/constants/elementType.ts b/src/shared/ui-kit/TextEditor/constants/elementType.ts index e9550961f5..45b5bb01c6 100644 --- a/src/shared/ui-kit/TextEditor/constants/elementType.ts +++ b/src/shared/ui-kit/TextEditor/constants/elementType.ts @@ -3,6 +3,7 @@ export enum ElementType { Heading = "heading", Link = "link", Mention = "mention", + StreamMention = "StreamMention", NumberedList = "numbered-list", BulletedList = "bulleted-list", ListItem = "list-item", @@ -19,5 +20,6 @@ export const PARENT_TYPES = [ export const INLINE_TYPES = [ ElementType.Link, ElementType.Mention, + ElementType.StreamMention, ElementType.Emoji, ]; diff --git a/src/shared/ui-kit/TextEditor/hofs/withMentions.ts b/src/shared/ui-kit/TextEditor/hofs/withMentions.ts index 91bc505b39..1e8522b921 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) ? 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) || markableVoid(element) ); }; diff --git a/src/shared/ui-kit/TextEditor/types.ts b/src/shared/ui-kit/TextEditor/types.ts index 0386b74bd3..c8587e93d4 100644 --- a/src/shared/ui-kit/TextEditor/types.ts +++ b/src/shared/ui-kit/TextEditor/types.ts @@ -59,6 +59,13 @@ export interface MentionElement extends BaseElement { userId: string; } +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 +97,6 @@ export type CustomElement = | BulletedListElement | ListItemElement | MentionElement + | StreamMentionElement | EmojiElement | CheckboxItemElement; diff --git a/src/shared/ui-kit/TextEditor/utils/checkIsTextEditorValueEmpty.ts b/src/shared/ui-kit/TextEditor/utils/checkIsTextEditorValueEmpty.ts index 3d0f5e9537..c92ce30e33 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)) { return false; } diff --git a/src/shared/ui-kit/TextEditor/utils/index.ts b/src/shared/ui-kit/TextEditor/utils/index.ts index 152bee9588..c8eb1b7130 100644 --- a/src/shared/ui-kit/TextEditor/utils/index.ts +++ b/src/shared/ui-kit/TextEditor/utils/index.ts @@ -29,4 +29,5 @@ export * from "./removeTextEditorEmptyEndLinesValues"; export * from "./countTextEditorEmojiElements"; export * from "./insertEmoji"; export * from "./insertMention"; +export * from "./insertStreamMention"; export * from "./isRtlWithNoMentions"; 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/removeTextEditorEmptyEndLinesValues.ts b/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts index 1010d568d0..965df2661b 100644 --- a/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts +++ b/src/shared/ui-kit/TextEditor/utils/removeTextEditorEmptyEndLinesValues.ts @@ -24,6 +24,7 @@ export const removeTextEditorEmptyEndLinesValues = ( if ( firstChild?.text !== "" || Element.isElementType(secondChild, ElementType.Mention) || + Element.isElementType(secondChild, ElementType.StreamMention) || Element.isElementType(secondChild, ElementType.Emoji) ) { endOfTextIndex = index; diff --git a/yarn.lock b/yarn.lock index 14ceec5b07..09680b41c5 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.2.2" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz#c3b6390f3a30eba13200d2302dcdf1e7b57b2ef9" + integrity sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 5d58c1f8874b08e6d26dfa35d4e51bc04294a62c Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Tue, 26 Nov 2024 15:17:53 +0300 Subject: [PATCH 02/12] CW-mention-streams Fixed UI. Fixed control of dropdown --- src/shared/icons/chat.icon.tsx | 26 + src/shared/icons/dots.icon.tsx | 35 + src/shared/icons/index.ts | 2 + .../ui-kit/TextEditor/BaseTextEditor.tsx | 669 +++++++++--------- .../MentionDropdown.module.scss | 24 +- .../MentionDropdown/MentionDropdown.tsx | 182 ++++- 6 files changed, 579 insertions(+), 359 deletions(-) create mode 100644 src/shared/icons/chat.icon.tsx create mode 100644 src/shared/icons/dots.icon.tsx 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/ui-kit/TextEditor/BaseTextEditor.tsx b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx index beb4abad3b..bfa45ee87a 100644 --- a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx +++ b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx @@ -24,7 +24,9 @@ import { 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 { useFeatureFlag } from "@/shared/hooks"; import { Discussion, User } from "@/shared/models"; import { getUserName, isMobile, isRtlText } from "@/shared/utils"; import { @@ -46,8 +48,6 @@ import { checkIsEmptyCheckboxCreationText, } 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; @@ -60,8 +60,8 @@ export interface TextEditorProps { emojiContainerClassName?: string; emojiPickerContainerClassName?: string; inputContainerRef?: - | MutableRefObject - | RefCallback; + | MutableRefObject + | RefCallback; editorRef?: MutableRefObject | RefCallback; id?: string; name?: string; @@ -91,350 +91,361 @@ 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, - discussions, - } = 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, + } = 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 [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: () => { - 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()); - }); - - const discussionChars = (discussions ?? []).filter((discussion) => { - return discussion.title - ?.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(); + editorEl.scrollTo(0, 0); + onClearFinished(); + }, 0); + }; + + useEffect(() => { + if (!shouldReinitializeEditor) { + 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(); - if ( - beforeText === " " && - (selectionOffset === 3 || selectionOffset === 4) && - selectionOffset === lineLastPoint?.offset && - checkIsCheckboxCreationText(checkboxText) - ) { - toggleCheckboxItem( - editor, - !checkIsEmptyCheckboxCreationText(checkboxText), - true, - ); + 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 + const editorEl = ReactEditor.toDOMNode(editor, editor); + editorEl.focus(); + + // Ensure the editor itself is focused programmatically + ReactEditor.focus(editor); + } + }, + clear: () => { + clearInput(); + }, + })); + + useEffect(() => { + if (!editorRef) { return; } - handleSearch(beforeText ?? "", beforeRange); - } - }; + 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 handleMentionSelectionChange = useCallback(() => { - if (!editor.selection || editor.selection.anchor.path.length <= 2) { - return; - } + const handleSearch = (text: string, value?: BaseRange) => { + if (!value || !value?.anchor || !text || text === "") { + setSearch(INITIAL_SEARCH_VALUE); + setTarget(null); + setShouldFocusTarget(false); + return; + } - const { anchor } = editor.selection; - const point: BaseRange["anchor"] = { - ...anchor, - path: [anchor.path[0], anchor.path[1] + 1], + 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); + } }; - 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 + 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(); + } + } + }; + + 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); + } + }; + + const handleMentionSelectionChange = useCallback(() => { + 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); - }} - onClickDiscussion={(discussion: Discussion) => { - Transforms.select(editor, target); - insertStreamMention(editor, discussion); - setTarget(null); - setShouldFocusTarget(false); - }} - users={chars} - discussions={discussionChars} - onClose={() => { - setTarget(null); - setShouldFocusTarget(false); + }, []); + + const handleOnChange = useCallback( + (updatedContent) => { + // Prevent update for cursor clicks + if (isEqual(updatedContent, value)) { + handleMentionSelectionChange(); + 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", + }); + } + }; + + return ( +
    + + + { + insertEmoji(editor, emoji.native); }} /> - )} - -
    - ); -}); + + {target && ( + { + Transforms.select(editor, target); + insertMention(editor, user); + setTarget(null); + setShouldFocusTarget(false); + }} + onClickDiscussion={(discussion: Discussion) => { + Transforms.select(editor, target); + insertStreamMention(editor, discussion); + setTarget(null); + setShouldFocusTarget(false); + }} + users={chars} + discussions={discussionChars} + initUsers={usersWithAI} + initDiscussions={discussions} + onClose={() => { + setTarget(null); + setShouldFocusTarget(false); + }} + /> + )} +
    +
    + ); + }, +); export default BaseTextEditor; diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss index 80ece806b6..7b147c01da 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss @@ -4,7 +4,7 @@ position: absolute; bottom: 5.25rem; left: 2.875rem; - width: 20.125rem; + width: 25.125rem; max-height: 22.75rem; background: var(--tertiary-fill); color: var(--primary-text); @@ -22,7 +22,6 @@ align-items: center; width: 100%; box-sizing: border-box; - border-bottom: 0.0625rem solid var(--gentle-stroke); &:hover { cursor: pointer; @@ -35,13 +34,34 @@ } } +.sectionTitle { + font-weight: 500; + font-size: $mobile-title; + padding: 0.5rem 1rem; + margin: 0.25rem 0; +} + +.separator { + margin: 1rem 0rem 0.5rem; + height: 2px; +} + .userAvatar { margin-right: 0.5625rem; + width: 1.75rem; + height: 1.75rem; +} + +.streamIcon { + margin-right: 0.5625rem; + width: 1.5rem; + height: 1.5rem; } .userName { font-weight: 500; font-size: $mobile-title; + margin: 0.25rem 0; } .loader { diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx index 44730f35f2..bf5d0ccd3a 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx @@ -1,8 +1,10 @@ import React, { FC, useEffect, useMemo, useRef, useState } from "react"; import { uniq } from "lodash"; -import { UserAvatar } from "@/shared/components"; +import { Separator, UserAvatar } from "@/shared/components"; import { KeyboardKeys } from "@/shared/constants/keyboardKeys"; import { useOutsideClick } from "@/shared/hooks"; +import { ChatIcon } from "@/shared/icons"; +import { DotsIcon } from "@/shared/icons"; import { Discussion, User } from "@/shared/models"; import { Loader } from "@/shared/ui-kit"; import { getUserName } from "@/shared/utils"; @@ -13,12 +15,19 @@ export const MENTION_TAG = "@"; export interface MentionDropdownProps { onClick: (user: User) => void; onClickDiscussion: (discussion: Discussion) => void; - discussions?: Discussion[]; onClose: () => void; users?: User[]; + discussions?: Discussion[]; + initUsers?: User[]; + initDiscussions?: Discussion[]; shouldFocusTarget?: boolean; } +type CombinedItem = + | User + | Discussion + | { type: "toggleUsers" | "toggleDiscussions" | "separator" }; + const MentionDropdown: FC = (props) => { const { onClick, @@ -27,43 +36,71 @@ const MentionDropdown: FC = (props) => { discussions = [], onClose, shouldFocusTarget, + initUsers = [], + initDiscussions = [], } = props; const mentionRef = useRef(null); const listRefs = useRef([]); const { isOutside, setOutsideValue } = useOutsideClick(mentionRef); const [index, setIndex] = useState(0); + const [isShowMoreUsers, setIsShowMoreUsers] = useState(false); + const [isShowMoreDiscussions, setIsShowMoreDiscussions] = useState(false); + const usersList = useMemo(() => { + if (isShowMoreUsers) { + return users; + } + + return users.slice(0, 5); + }, [isShowMoreUsers, users]); - const userIds = useMemo(() => users.map(({ uid }) => uid), [users]); - const discussionIds = useMemo(() => discussions.map(({ id }) => id), [discussions]); + 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) || discussionIds.includes(item?.id)) { - return true; - } + // 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)); - return false; + // 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, discussionIds]); + }, [index, usersList, discussionsList, shouldFocusTarget]); const increment = () => { setIndex((value) => { const updatedValue = value + 1; - const usersLastIndex = users.length - 1; - const discussionsLastIndex = discussions.length - 1; - return updatedValue > discussionsLastIndex + usersLastIndex ? 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) { @@ -84,16 +121,59 @@ const MentionDropdown: FC = (props) => { break; } case KeyboardKeys.Enter: { - if(index > users.length - 1) { - onClickDiscussion(discussions[index - users.length]); - } else { + 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; + }); } + break; } } }; 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; return (
      = (props) => { data-cy="mentions-portal" onKeyDown={onKeyDown} > - {(users.length === 0 && discussions.length === 0) && ( + {isLoading && (
    • )} - {users.map((user, index) => ( + {isEmptyContent && ( +
    • +

      No results

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

      People

      } + {usersList.map((user, index) => (
    • onClick(user)} className={styles.content} > @@ -125,23 +212,62 @@ const MentionDropdown: FC = (props) => {

      {getUserName(user)}

    • ))} - {discussions.map((discussion, index) => ( + {users.length > 5 && ( +
    • setIsShowMoreUsers((prev) => !prev)} + className={styles.content} + > + + {isShowMoreUsers ? ( +

      Hide results

      + ) : ( +

      {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 && ( +
    • setIsShowMoreDiscussions((prev) => !prev)} + className={styles.content} + > + + {isShowMoreDiscussions ? ( +

      Hide results

      + ) : ( +

      + {discussions.length - 5} more results +

      + )} +
    • + )}
    ); }; From 915c3869b5ade73d3f851079d4f4ea7fa7761eaa Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Fri, 6 Dec 2024 15:18:39 +0300 Subject: [PATCH 03/12] CW-mention-streams Added support multiline and spaces Fixed UI for mention --- .../ChatComponent/ChatComponent.tsx | 9 +- .../components/ChatInput/ChatInput.tsx | 189 ++++++++++-------- src/shared/constants/keyboardKeys.ts | 1 + .../ui-kit/TextEditor/BaseTextEditor.tsx | 138 ++++++++++--- .../MentionDropdown.module.scss | 5 +- .../MentionDropdown/MentionDropdown.tsx | 132 +++++++++--- 6 files changed, 332 insertions(+), 142 deletions(-) diff --git a/src/pages/common/components/ChatComponent/ChatComponent.tsx b/src/pages/common/components/ChatComponent/ChatComponent.tsx index bed47c5079..28c3a1a3c8 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.tsx +++ b/src/pages/common/components/ChatComponent/ChatComponent.tsx @@ -55,6 +55,7 @@ import { removeTextEditorEmptyEndLinesValues, countTextEditorEmojiElements, } from "@/shared/ui-kit"; +import { BaseTextEditorHandles } from "@/shared/ui-kit/TextEditor/BaseTextEditor"; import { checkUncheckedItemsInTextEditorValue } from "@/shared/ui-kit/TextEditor/utils"; import { InternalLinkData, notEmpty } from "@/shared/utils"; import { getUserName, hasPermission, isMobile } from "@/shared/utils"; @@ -85,7 +86,6 @@ import { uploadFilesAndImages, } from "./utils"; import styles from "./ChatComponent.module.scss"; -import { BaseTextEditorHandles } from "@/shared/ui-kit/TextEditor/BaseTextEditor"; const BASE_CHAT_INPUT_HEIGHT = 48; @@ -220,7 +220,7 @@ export default function ChatComponent({ participants: chatChannel?.participants, }); - const {data: discussionsData} = useFetchDiscussionsByCommonId(commonId); + const { data: discussionsData } = useFetchDiscussionsByCommonId(commonId); const users = useMemo( () => (chatChannel ? chatUsers : discussionUsers), @@ -731,7 +731,7 @@ export default function ChatComponent({ useLayoutEffect(() => { textInputRef?.current?.clear?.(); textInputRef?.current?.focus?.(); - },[discussionId]); + }, [discussionId]); useEffect(() => { if (isFetchedDiscussionMessages) { @@ -888,6 +888,9 @@ export default function ChatComponent({ canSendMessage={Boolean(canSendMessage)} inputContainerRef={inputContainerRef} editorRef={editorRef} + user={user} + commonId={commonId} + circleVisibility={discussion?.circleVisibility} /> diff --git a/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx b/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx index f80f128a58..e0311c13eb 100644 --- a/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx +++ b/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx @@ -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 styles from "./ChatInput.module.scss"; -import { BaseTextEditorHandles } from "@/shared/ui-kit/TextEditor/BaseTextEditor"; interface ChatInputProps { shouldHideChatInput: boolean; @@ -36,98 +36,111 @@ interface ChatInputProps { canSendMessage?: boolean; sendChatMessage: () => void; inputContainerRef?: - | MutableRefObject - | RefCallback; + | MutableRefObject + | RefCallback; editorRef?: MutableRefObject | RefCallback; renderChatInputOuter?: () => ReactElement; isAuthorized?: boolean; + circleVisibility?: string[]; + user?: User | null; + commonId?: string; } -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, - } = 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, + } = 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/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/ui-kit/TextEditor/BaseTextEditor.tsx b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx index bfa45ee87a..59f5593ea9 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,6 +21,8 @@ import { Editor as EditorSlate, BaseRange, BaseSelection, + BasePoint, + Text, } from "slate"; import { withHistory } from "slate-history"; import { ReactEditor, Slate, withReact } from "slate-react"; @@ -79,6 +82,9 @@ export interface TextEditorProps { onClearFinished: () => void; scrollSelectionIntoView?: (editor: ReactEditor, domRange: DOMRange) => void; elementStyles?: EditorElementStyles; + circleVisibility?: string[]; + user?: User | null; + commonId?: string; } const INITIAL_SEARCH_VALUE = { @@ -115,6 +121,9 @@ const BaseTextEditor = forwardRef( scrollSelectionIntoView, elementStyles, discussions, + circleVisibility, + user, + commonId, } = props; const editor = useMemo( () => @@ -139,6 +148,7 @@ const BaseTextEditor = forwardRef( const [target, setTarget] = useState(); const [shouldFocusTarget, setShouldFocusTarget] = useState(false); + const isNewMentionCreated = useRef(false); const [isRtlLanguage, setIsRtlLanguage] = useState(false); @@ -217,33 +227,89 @@ const BaseTextEditor = forwardRef( const [search, setSearch] = useState(INITIAL_SEARCH_VALUE); - const handleSearch = (text: string, value?: BaseRange) => { - if (!value || !value?.anchor || !text || text === "") { + const getTextByRange = (editor, range) => { + if ( + !range || + !EditorSlate.hasPath(editor, range.anchor.path) || + !EditorSlate.hasPath(editor, range.focus.path) + ) { + return ""; + } + + 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 ""; + + // Extract the substring within the offsets + const text = + (node as Text)?.text.slice(anchor.offset, focus.offset) + + (filteredNodes.length > 1 ? " " : ""); + + // Remove newlines from the text + return text.replace(/\n/g, ""); + }); + + // Combine the text parts and return + return nodes.join("").slice(0, -1); + }; + + 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 newText = target?.anchor + ? getTextByRange(editor, { + ...target, + focus: afterValue ? afterValue : value.anchor, + }) + : ""; + 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, + } 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); } @@ -267,7 +333,7 @@ const BaseTextEditor = forwardRef( ...search.range, focus: { ...search.range.focus, - offset: search.range.focus.offset + search.text.length - 1, + offset: search.range.focus.offset + 1, }, }); } @@ -283,8 +349,21 @@ const BaseTextEditor = forwardRef( 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 { - // event.stopPropagation(); onKeyDown && onKeyDown(event); // Call any custom onKeyDown handler if (event.key === KeyboardKeys.Enter && !isMobile()) { onToggleIsMessageSent(); @@ -335,11 +414,11 @@ const BaseTextEditor = forwardRef( return; } - handleSearch(beforeText ?? "", beforeRange); + handleSearch(beforeText ?? "", beforeRange, lineLastPoint); } }; - const handleMentionSelectionChange = useCallback(() => { + const handleMentionSelectionChange = () => { if (!editor.selection || editor.selection.anchor.path.length <= 2) { return; } @@ -353,21 +432,18 @@ const BaseTextEditor = forwardRef( anchor: point, focus: point, }); - }, []); + }; const handleOnChange = useCallback( (updatedContent) => { - // Prevent update for cursor clicks if (isEqual(updatedContent, value)) { handleMentionSelectionChange(); return; } onChange && onChange(updatedContent); - const { selection } = editor; - - handleOnChangeSelection(selection); + handleOnChangeSelection(editor.selection); }, - [onChange, value, handleMentionSelectionChange], + [onChange, value, handleSearch, handleMentionSelectionChange], ); const customScrollSelectionIntoView = () => { @@ -425,18 +501,30 @@ const BaseTextEditor = forwardRef( 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={(discussionId) => { + console.log("--discussionId", discussionId); + Transforms.select(editor, target); + Transforms.insertText(editor, "Hello, Slate!"); }} + 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); }} diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss index 7b147c01da..8660414c22 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss @@ -4,8 +4,9 @@ position: absolute; bottom: 5.25rem; left: 2.875rem; - width: 25.125rem; - max-height: 22.75rem; + width: 34.375rem; + min-height: 25rem; + max-height: 25rem; background: var(--tertiary-fill); color: var(--primary-text); border-radius: 0.25rem; diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx index bf5d0ccd3a..628770d6b5 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx @@ -1,13 +1,20 @@ import React, { FC, useEffect, useMemo, useRef, useState } from "react"; -import { uniq } from "lodash"; +import { useDispatch } from "react-redux"; +import { v4 as uuidv4 } from "uuid"; import { Separator, UserAvatar } from "@/shared/components"; +import { DiscussionMessageOwnerType } from "@/shared/constants"; import { KeyboardKeys } from "@/shared/constants/keyboardKeys"; import { useOutsideClick } from "@/shared/hooks"; -import { ChatIcon } from "@/shared/icons"; +import { ChatIcon, PlusIcon } from "@/shared/icons"; import { DotsIcon } from "@/shared/icons"; -import { Discussion, User } from "@/shared/models"; +import { CommonFeedType, Discussion, User } from "@/shared/models"; import { Loader } from "@/shared/ui-kit"; -import { getUserName } from "@/shared/utils"; +import { + generateFirstMessage, + generateOptimisticFeedItem, + getUserName, +} from "@/shared/utils"; +import { commonActions } from "@/store/states"; import styles from "./MentionDropdown.module.scss"; export const MENTION_TAG = "@"; @@ -15,33 +22,50 @@ export const MENTION_TAG = "@"; export interface MentionDropdownProps { onClick: (user: User) => void; onClickDiscussion: (discussion: Discussion) => void; + onCreateDiscussion: (discussionId: string) => void; onClose: () => void; users?: User[]; discussions?: Discussion[]; initUsers?: User[]; initDiscussions?: Discussion[]; shouldFocusTarget?: boolean; + searchText: string; + circleVisibility?: string[]; + user?: User | null; + commonId?: string; } -type CombinedItem = - | User - | Discussion - | { type: "toggleUsers" | "toggleDiscussions" | "separator" }; - 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; + console.log( + "--user", + user, + "--commonId", + commonId, + "--circleVisibility", + circleVisibility, + ); + const [index, setIndex] = useState(0); const [isShowMoreUsers, setIsShowMoreUsers] = useState(false); const [isShowMoreDiscussions, setIsShowMoreDiscussions] = useState(false); @@ -82,6 +106,7 @@ const MentionDropdown: FC = (props) => { (item) => parseInt(item.getAttribute("tabIndex") || "0", 10) === index, ); + console.log("---elementToFocus", elementToFocus, index); if (elementToFocus) { elementToFocus.focus(); } @@ -91,6 +116,7 @@ const MentionDropdown: FC = (props) => { const increment = () => { setIndex((value) => { const updatedValue = value + 1; + console.log("--listRefs", listRefs, updatedValue); return updatedValue < listRefs.current.length ? updatedValue : value; }); }; @@ -162,12 +188,60 @@ const MentionDropdown: FC = (props) => { } return !prev; }); + } else if (type === "newDiscussion" && searchText) { + createDiscussion(); } break; } } }; + const createDiscussion = () => { + if (!canCreateDiscussion) { + return; + } + + const discussionId = uuidv4(); + const userName = getUserName(user); + const userId = user.uid; + const firstMessage = generateFirstMessage({ userName, userId }); + dispatch( + commonActions.setOptimisticFeedItem( + generateOptimisticFeedItem({ + userId, + commonId, + type: CommonFeedType.OptimisticDiscussion, + circleVisibility: circleVisibility ?? [], + discussionId, + title: searchText, + content: firstMessage, + lastMessageContent: { + ownerId: userId, + userName, + ownerType: DiscussionMessageOwnerType.System, + content: firstMessage, + }, + }), + ), + ); + + dispatch( + commonActions.createDiscussion.request({ + payload: { + id: discussionId, + title: searchText, + message: firstMessage, + ownerId: userId, + commonId, + images: [], + circleVisibility: circleVisibility ?? [], + }, + }), + ); + + onCreateDiscussion(discussionId); + }; + const getRef = (element) => listRefs.current.push(element); const isEmptyContent = (initUsers.length > 0 || initDiscussions.length > 0) && @@ -212,7 +286,7 @@ const MentionDropdown: FC = (props) => {

    {getUserName(user)}

    ))} - {users.length > 5 && ( + {users.length > 5 && !isShowMoreUsers && (
  • = (props) => { className={styles.content} > - {isShowMoreUsers ? ( -

    Hide results

    - ) : ( -

    {users.length - 5} more results

    - )} +

    {users.length - 5} more results

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

    Link to Stream

    +

    Link to stream

    )} {discussionsList.map((discussion, index) => (
  • = (props) => {

    {discussion.title}

  • ))} - {discussions.length > 5 && ( + {discussions.length > 5 && !isShowMoreDiscussions && (
  • = (props) => { className={styles.content} > - {isShowMoreDiscussions ? ( -

    Hide results

    - ) : ( -

    - {discussions.length - 5} more results -

    - )} +

    + {discussions.length - 5} more results +

  • )} + {/* {((users.length > 0 || discussions.length > 0) && canCreateDiscussion && searchText) && ( + + )} + {(searchText && canCreateDiscussion) && ( +
  • 0 ? usersList.length + 1 : 0) + (discussionsList.length > 0 ? discussionsList.length + 1 : 0)} + key="newDiscussion" + data-type="newDiscussion" + onClick={createDiscussion} + className={styles.content} + > + +

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

    +
  • + )} */} ); }; From 6ef91db0d1477a1f1539f82fa61f71eb5d0fc9f4 Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Fri, 6 Dec 2024 15:40:57 +0300 Subject: [PATCH 04/12] Update MentionDropdown.tsx --- .../MentionDropdown/MentionDropdown.tsx | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx index 628770d6b5..a0f483bffb 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx @@ -205,39 +205,39 @@ const MentionDropdown: FC = (props) => { const userName = getUserName(user); const userId = user.uid; const firstMessage = generateFirstMessage({ userName, userId }); - dispatch( - commonActions.setOptimisticFeedItem( - generateOptimisticFeedItem({ - userId, - commonId, - type: CommonFeedType.OptimisticDiscussion, - circleVisibility: circleVisibility ?? [], - discussionId, - title: searchText, - content: firstMessage, - lastMessageContent: { - ownerId: userId, - userName, - ownerType: DiscussionMessageOwnerType.System, - content: firstMessage, - }, - }), - ), - ); + // dispatch( + // commonActions.setOptimisticFeedItem( + // generateOptimisticFeedItem({ + // userId, + // commonId, + // type: CommonFeedType.OptimisticDiscussion, + // circleVisibility: circleVisibility ?? [], + // discussionId, + // title: searchText, + // content: firstMessage, + // lastMessageContent: { + // ownerId: userId, + // userName, + // ownerType: DiscussionMessageOwnerType.System, + // content: firstMessage, + // }, + // }), + // ), + // ); - dispatch( - commonActions.createDiscussion.request({ - payload: { - id: discussionId, - title: searchText, - message: firstMessage, - ownerId: userId, - commonId, - images: [], - circleVisibility: circleVisibility ?? [], - }, - }), - ); + // dispatch( + // commonActions.createDiscussion.request({ + // payload: { + // id: discussionId, + // title: searchText, + // message: firstMessage, + // ownerId: userId, + // commonId, + // images: [], + // circleVisibility: circleVisibility ?? [], + // }, + // }), + // ); onCreateDiscussion(discussionId); }; From 3e8e3f8e6a379ea856a33bc3a7a0b17f20f9cd34 Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Mon, 9 Dec 2024 15:09:30 +0300 Subject: [PATCH 05/12] CW-mention-streams Added new discussion creation --- .../components/FeedLayout/FeedLayout.tsx | 14 +++ src/pages/inbox/BaseInbox.tsx | 11 ++- .../utils/generateInternalLink.tsx | 16 ++- src/shared/constants/queryParamKey.ts | 1 + src/shared/models/CommonFeed.tsx | 1 + .../ui-kit/TextEditor/BaseTextEditor.tsx | 23 +++-- .../MentionDropdown/MentionDropdown.tsx | 99 ++++++++++--------- .../utils/generateOptimisticFeedItem.ts | 3 + src/shared/utils/generateStaticShareLink.ts | 13 ++- 9 files changed, 117 insertions(+), 64 deletions(-) diff --git a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx index 0ce7b452eb..62daad7df1 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); + const feedItemId = feedItem?.itemId; + if(feedItemId) { + handleFeedItemClick(feedItemId, { + commonId: feedPageParams.id, + }); + } + + return; + } + if (itemId) { handleFeedItemClick(itemId, { commonId: feedPageParams.id, 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/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/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/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 59f5593ea9..eea340f3eb 100644 --- a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx +++ b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx @@ -31,7 +31,7 @@ import { AI_PRO_USER, AI_USER, FeatureFlags } from "@/shared/constants"; import { KeyboardKeys } from "@/shared/constants/keyboardKeys"; import { useFeatureFlag } from "@/shared/hooks"; import { Discussion, User } from "@/shared/models"; -import { getUserName, isMobile, isRtlText } from "@/shared/utils"; +import { generateDiscussionShareLink, getUserName, isMobile, isRtlText } from "@/shared/utils"; import { Editor, MentionDropdown, @@ -248,15 +248,18 @@ const BaseTextEditor = forwardRef( // Extract the substring within the offsets const text = - (node as Text)?.text.slice(anchor.offset, focus.offset) + + (node as Text)?.text.slice(anchor.offset, focus.offset + 1) + (filteredNodes.length > 1 ? " " : ""); // Remove newlines from the text return text.replace(/\n/g, ""); }); - // Combine the text parts and return - return nodes.join("").slice(0, -1); + if(nodes.length > 1) { + return nodes.join("").slice(0, -1); + } + + return nodes.join(""); }; const handleSearch = ( @@ -275,7 +278,7 @@ const BaseTextEditor = forwardRef( const newText = target?.anchor ? getTextByRange(editor, { ...target, - focus: afterValue ? afterValue : value.anchor, + focus: afterValue ? { ...afterValue, offset: afterValue.offset } : { ...value.anchor, offset: value.anchor.offset + 1 }, }) : ""; @@ -492,7 +495,6 @@ const BaseTextEditor = forwardRef( insertEmoji(editor, emoji.native); }} /> - {target && ( ( setShouldFocusTarget(false); isNewMentionCreated.current = true; }} - onCreateDiscussion={(discussionId) => { - console.log("--discussionId", discussionId); + onCreateDiscussion={(createdDiscussionCommonId: string, discussionId: string) => { Transforms.select(editor, target); - Transforms.insertText(editor, "Hello, Slate!"); + const link = generateDiscussionShareLink(createdDiscussionCommonId, discussionId); + Transforms.insertText(editor, link); + setTarget(null); + setShouldFocusTarget(false); + isNewMentionCreated.current = true; }} user={user} commonId={commonId} diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx index a0f483bffb..24e864ed41 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx @@ -14,15 +14,16 @@ import { generateOptimisticFeedItem, getUserName, } from "@/shared/utils"; -import { commonActions } from "@/store/states"; +import { commonActions, optimisticActions } from "@/store/states"; import styles from "./MentionDropdown.module.scss"; +import { CommonService } from "@/services"; export const MENTION_TAG = "@"; export interface MentionDropdownProps { onClick: (user: User) => void; onClickDiscussion: (discussion: Discussion) => void; - onCreateDiscussion: (discussionId: string) => void; + onCreateDiscussion: (createdDiscussionCommonId: string, discussionId: string) => void; onClose: () => void; users?: User[]; discussions?: Discussion[]; @@ -57,14 +58,6 @@ const MentionDropdown: FC = (props) => { const dispatch = useDispatch(); const canCreateDiscussion = !!user && !!commonId; - console.log( - "--user", - user, - "--commonId", - commonId, - "--circleVisibility", - circleVisibility, - ); const [index, setIndex] = useState(0); const [isShowMoreUsers, setIsShowMoreUsers] = useState(false); @@ -106,7 +99,6 @@ const MentionDropdown: FC = (props) => { (item) => parseInt(item.getAttribute("tabIndex") || "0", 10) === index, ); - console.log("---elementToFocus", elementToFocus, index); if (elementToFocus) { elementToFocus.focus(); } @@ -116,7 +108,6 @@ const MentionDropdown: FC = (props) => { const increment = () => { setIndex((value) => { const updatedValue = value + 1; - console.log("--listRefs", listRefs, updatedValue); return updatedValue < listRefs.current.length ? updatedValue : value; }); }; @@ -196,50 +187,62 @@ const MentionDropdown: FC = (props) => { } }; - const createDiscussion = () => { + 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 }); - // dispatch( - // commonActions.setOptimisticFeedItem( - // generateOptimisticFeedItem({ - // userId, - // commonId, - // type: CommonFeedType.OptimisticDiscussion, - // circleVisibility: circleVisibility ?? [], - // discussionId, - // title: searchText, - // content: firstMessage, - // lastMessageContent: { - // ownerId: userId, - // userName, - // ownerType: DiscussionMessageOwnerType.System, - // content: firstMessage, - // }, - // }), - // ), - // ); + const optimisticFeedItem = generateOptimisticFeedItem({ + userId, + commonId, + type: CommonFeedType.OptimisticDiscussion, + circleVisibility: circleVisibility ?? [], + discussionId, + title: searchText, + 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: searchText, - // message: firstMessage, - // ownerId: userId, - // commonId, - // images: [], - // circleVisibility: circleVisibility ?? [], - // }, - // }), - // ); + console.log('---discussionId',discussionId, '--optimisticFeedItem',optimisticFeedItem.id); - onCreateDiscussion(discussionId); + dispatch( + commonActions.createDiscussion.request({ + payload: { + id: discussionId, + title: searchText, + message: firstMessage, + ownerId: userId, + commonId, + images: [], + circleVisibility: circleVisibility ?? [], + }, + commonId + }), + ); + + onCreateDiscussion(commonId, discussionId); }; const getRef = (element) => listRefs.current.push(element); @@ -334,7 +337,7 @@ const MentionDropdown: FC = (props) => {

    )} - {/* {((users.length > 0 || discussions.length > 0) && canCreateDiscussion && searchText) && ( + {((users.length > 0 || discussions.length > 0) && canCreateDiscussion && searchText) && ( )} {(searchText && canCreateDiscussion) && ( @@ -351,7 +354,7 @@ const MentionDropdown: FC = (props) => { New "{searchText.slice(1)}" discussion

    - )} */} + )} ); }; 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 From 5106e0bc66305cc869f57143e13a6021c083ea0a Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Mon, 9 Dec 2024 15:31:31 +0300 Subject: [PATCH 06/12] CW-mention-streams Fixed keyboard for create new --- .../MentionDropdown.module.scss | 19 ++++++++ .../MentionDropdown/MentionDropdown.tsx | 47 ++++++++++++++++--- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss index 8660414c22..89b0d24ece 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.module.scss @@ -35,6 +35,25 @@ } } +.emptyContent { + padding: 0.375rem 1.75rem; + display: flex; + justify-content: flex-start; + align-items: center; + width: 100%; + box-sizing: border-box; + + &:hover { + cursor: pointer; + background-color: var(--hover-fill); + } + + &:focus { + background-color: var(--hover-fill); + outline: none; + } +} + .sectionTitle { font-weight: 500; font-size: $mobile-title; diff --git a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx index 24e864ed41..893390dbc0 100644 --- a/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx +++ b/src/shared/ui-kit/TextEditor/components/MentionDropdown/MentionDropdown.tsx @@ -78,6 +78,20 @@ const MentionDropdown: FC = (props) => { return discussions.slice(0, 5); }, [isShowMoreDiscussions, discussions]); + useEffect(() => { + const allRefs = document.querySelectorAll( + `.${styles.content}`, + ); + + 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 @@ -86,7 +100,6 @@ const MentionDropdown: FC = (props) => { `.${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); @@ -225,8 +238,6 @@ const MentionDropdown: FC = (props) => { }), ); - console.log('---discussionId',discussionId, '--optimisticFeedItem',optimisticFeedItem.id); - dispatch( commonActions.createDiscussion.request({ payload: { @@ -252,6 +263,30 @@ const MentionDropdown: FC = (props) => { 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) => { onKeyDown={onKeyDown} > {isLoading && ( -
    • +
    • )} {isEmptyContent && ( -
    • +
    • No results

    • )} @@ -343,7 +378,7 @@ const MentionDropdown: FC = (props) => { {(searchText && canCreateDiscussion) && (
    • 0 ? usersList.length + 1 : 0) + (discussionsList.length > 0 ? discussionsList.length + 1 : 0)} + tabIndex={calculateNewDiscussionTabIndex()} key="newDiscussion" data-type="newDiscussion" onClick={createDiscussion} From 77f5551051e23ef1594aae172c6cdf55e5764628 Mon Sep 17 00:00:00 2001 From: Pavel Meyer Date: Wed, 11 Dec 2024 11:06:46 +0300 Subject: [PATCH 07/12] CW-mention-streams Added creation of discussion --- .../ChatComponent/ChatComponent.tsx | 1 + .../components/ChatInput/ChatInput.tsx | 5 ++- .../DiscussionLink/DiscussionLink.tsx | 36 +++++++++++++++++++ .../components/DiscussionLink/index.ts | 1 + .../Chat/ChatMessage/components/index.ts | 1 + .../utils/getTextFromTextEditorString.tsx | 11 +++++- .../ui-kit/TextEditor/BaseTextEditor.tsx | 9 +++-- .../TextEditor/components/Element/Element.tsx | 21 ++++++++++- .../MentionDropdown/MentionDropdown.tsx | 9 ++--- .../TextEditor/constants/elementType.ts | 2 ++ .../ui-kit/TextEditor/hofs/withMentions.ts | 4 +-- src/shared/ui-kit/TextEditor/types.ts | 11 +++++- .../utils/checkIsTextEditorValueEmpty.ts | 2 +- .../utils/countTextEditorEmojiElements.ts | 2 +- src/shared/ui-kit/TextEditor/utils/index.ts | 1 + .../TextEditor/utils/insertDiscussionLink.ts | 18 ++++++++++ .../TextEditor/utils/isRtlWithNoMentions.ts | 2 +- .../removeTextEditorEmptyEndLinesValues.ts | 1 + .../utils/serializeTextEditorValue.ts | 4 +++ src/store/states/common/reducer.ts | 4 +-- 20 files changed, 127 insertions(+), 18 deletions(-) create mode 100644 src/shared/components/Chat/ChatMessage/components/DiscussionLink/DiscussionLink.tsx create mode 100644 src/shared/components/Chat/ChatMessage/components/DiscussionLink/index.ts create mode 100644 src/shared/ui-kit/TextEditor/utils/insertDiscussionLink.ts diff --git a/src/pages/common/components/ChatComponent/ChatComponent.tsx b/src/pages/common/components/ChatComponent/ChatComponent.tsx index 11ae027f83..d48bd19438 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.tsx +++ b/src/pages/common/components/ChatComponent/ChatComponent.tsx @@ -941,6 +941,7 @@ export default function ChatComponent({ user={user} commonId={commonId} circleVisibility={discussion?.circleVisibility} + onInternalLinkClick={onInternalLinkClick} /> diff --git a/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx b/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx index e0311c13eb..69dca3c56f 100644 --- a/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx +++ b/src/pages/common/components/ChatComponent/components/ChatInput/ChatInput.tsx @@ -18,7 +18,7 @@ import { } 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"; interface ChatInputProps { @@ -44,6 +44,7 @@ interface ChatInputProps { circleVisibility?: string[]; user?: User | null; commonId?: string; + onInternalLinkClick?: (data: InternalLinkData) => void; } export const ChatInput = React.memo( @@ -70,6 +71,7 @@ export const ChatInput = React.memo( circleVisibility, user, commonId, + onInternalLinkClick, } = props; if (shouldHideChatInput) { @@ -131,6 +133,7 @@ export const ChatInput = React.memo( circleVisibility={circleVisibility} user={user} commonId={commonId} + onInternalLinkClick={onInternalLinkClick} />