diff --git a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx index 04573f7956..db9ca28a02 100644 --- a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx +++ b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx @@ -44,6 +44,7 @@ import { GetLastMessageOptions, GetNonAllowedItemsOptions, } from "../FeedItem"; +import { LinkSpaceModal } from "./components"; import { useMenuItems } from "./hooks"; interface DiscussionFeedCardProps { @@ -65,6 +66,7 @@ interface DiscussionFeedCardProps { getNonAllowedItems?: GetNonAllowedItemsOptions; onActiveItemDataChange?: (data: FeedLayoutItemChangeData) => void; directParent?: DirectParent | null; + rootCommonId?: string; feedItemFollow: FeedItemFollowState; onUserSelect?: (userId: string, commonId?: string) => void; } @@ -93,6 +95,7 @@ const DiscussionFeedCard = forwardRef( getNonAllowedItems, onActiveItemDataChange, directParent, + rootCommonId, feedItemFollow, onUserSelect, } = props; @@ -111,6 +114,11 @@ const DiscussionFeedCard = forwardRef( onOpen: onDeleteModalOpen, onClose: onDeleteModalClose, } = useModal(false); + const { + isShowing: isLinkSpaceModalOpen, + onOpen: onLinkSpaceModalOpen, + onClose: onLinkSpaceModalClose, + } = useModal(false); const [isDeletingInProgress, setDeletingInProgress] = useState(false); const { fetchUser: fetchDiscussionCreator, @@ -147,6 +155,7 @@ const DiscussionFeedCard = forwardRef( report: onReportModalOpen, share: () => onShareModalOpen(), remove: onDeleteModalOpen, + linkSpace: onLinkSpaceModalOpen, }, ); const user = useSelector(selectUser()); @@ -346,6 +355,8 @@ const DiscussionFeedCard = forwardRef( isFeedItemUserMetadataFetched && feedItemUserMetadata?.hasUnseenMention } + originalCommonIdForLinking={discussion?.commonId} + linkedCommonIds={discussion?.linkedCommonIds} > {renderContent()} @@ -378,6 +389,18 @@ const DiscussionFeedCard = forwardRef( /> )} + {commonId && ( + + )} ); }, diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/LinkSpaceModal.module.scss b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/LinkSpaceModal.module.scss new file mode 100644 index 0000000000..b99c0e1f41 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/LinkSpaceModal.module.scss @@ -0,0 +1,70 @@ +@import "../../../../../../constants"; +@import "../../../../../../styles/sizes"; + +.modal { + max-width: 31.875rem; + width: 100%; + max-height: 33.75rem; + min-height: 24rem; + border-radius: 0; + box-shadow: 0 0.25rem 0.9375rem var(--drop-shadow); + + :global(.modal__header-wrapper--with-modal-padding) { + .modalHeader { + justify-content: flex-start; + } + + .modalTitle { + margin: 0; + font-family: PoppinsSans, sans-serif; + font-weight: 600; + font-size: 1.25rem; + color: var(--primary-text); + text-align: left; + word-break: break-word; + } + } + + .modalContent { + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + box-sizing: border-box; + } + + .modalCloseWrapper { + top: 1.7rem; + margin: 0; + + @include tablet { + top: 1.1rem; + } + } + + @include tablet { + max-width: unset; + max-height: unset; + } +} + +.submitButtonWrapper { + margin-top: auto; + padding-top: 1.5rem; + display: flex; + justify-content: flex-end; +} + +.submitButton { + --btn-w: 100%; + + max-width: 9.75rem; + + @include tablet { + max-width: 100%; + } +} + +.loader { + margin: 0 auto; +} diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/LinkSpaceModal.tsx b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/LinkSpaceModal.tsx new file mode 100644 index 0000000000..a2937b9d3c --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/LinkSpaceModal.tsx @@ -0,0 +1,110 @@ +import React, { FC, ReactElement, useEffect, useState } from "react"; +import { useSelector } from "react-redux"; +import { selectUser } from "@/pages/Auth/store/selectors"; +import { Modal } from "@/shared/components"; +import { useNotification } from "@/shared/hooks"; +import { useStreamLinking } from "@/shared/hooks/useCases"; +import { Button, ButtonVariant, Loader } from "@/shared/ui-kit"; +import { emptyFunction } from "@/shared/utils"; +import { Projects } from "./components"; +import styles from "./LinkSpaceModal.module.scss"; + +interface DirectMessageModalProps { + isOpen: boolean; + onClose: () => void; + feedItemId: string; + title: string; + rootCommonId: string; + commonId: string; + originalCommonId: string; + linkedCommonIds?: string[]; +} + +const LinkSpaceModal: FC = (props) => { + const { + isOpen, + onClose, + feedItemId, + title, + rootCommonId, + commonId, + originalCommonId, + linkedCommonIds = [], + } = props; + const { notify } = useNotification(); + const { isStreamLinking, isStreamLinked, linkStream } = useStreamLinking(); + const [activeItemId, setActiveItemId] = useState(""); + const user = useSelector(selectUser()); + const userId = user?.uid; + + const handleSubmit = () => { + if (!userId) { + return; + } + + linkStream({ + userId, + feedObjectId: feedItemId, + sourceCommonId: commonId, + targetCommonId: activeItemId, + }); + }; + + const renderContent = (): ReactElement => { + if (isStreamLinking) { + return ; + } + + return ( + <> + +
+ +
+ + ); + }; + + useEffect(() => { + if (isStreamLinked) { + notify("Stream is successfully linked"); + onClose(); + } + }, [isStreamLinking, isStreamLinked]); + + return ( + + {renderContent()} + + ); +}; + +export default LinkSpaceModal; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/Projects.module.scss b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/Projects.module.scss new file mode 100644 index 0000000000..4ab141036b --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/Projects.module.scss @@ -0,0 +1,71 @@ +@import "../../../../../../../../constants"; +@import "../../../../../../../../styles/sizes"; + +.projectsTree { + overflow-y: auto; + box-sizing: border-box; +} + +.projectsTreeItemTriggerClassName { + --item-pl-per-level: 1.25rem; + --item-arrow-pl: 0.5rem; + + height: 3rem; + border-radius: 0; + + &:hover { + --bg-color: var(--secondary-text); + --item-text-color: #{$c-shades-white}; + } + + @media (hover: none) { + &:hover { + --bg-color: var(--primary-background); + --item-text-color: var(--primary-text); + } + } +} +.projectsTreeItemTriggerActiveClassName { + --bg-color: var(--primary-fill); + --item-text-color: #{$c-shades-white}; + + &:hover { + --bg-color: var(--primary-fill); + } + + @media (hover: none) { + &:hover { + --bg-color: var(--primary-fill); + --item-text-color: #{$c-shades-white}; + } + } +} + +.projectsTreeItemTriggerNameClassName { + font-family: PoppinsSans, sans-serif; + font-weight: 500; +} + +.projectsTreeItemTriggerImageClassName { + width: 1.5rem; + height: 1.5rem; + margin-right: 0.875rem; +} +.projectsTreeItemTriggerImageNonRoundedClassName { + border-radius: 0.375rem; +} + +.loader { + margin: 1rem auto 0; + display: block; +} + +.createCommonButton { + width: 100%; + padding-left: 2.125rem; + padding-right: 0.875rem; +} + +.commonsMenuClassName { + max-height: 15rem; +} diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/Projects.tsx b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/Projects.tsx new file mode 100644 index 0000000000..005bf49411 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/Projects.tsx @@ -0,0 +1,85 @@ +import React, { FC, ReactNode, useEffect, useMemo, useState } from "react"; +import { LOADER_APPEARANCE_DELAY } from "@/shared/constants"; +import { TreeItemTriggerStyles } from "@/shared/layouts"; +import { ProjectsTree } from "@/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/ProjectsTree"; +import { Loader } from "@/shared/ui-kit"; +import { useProjectsData } from "./hooks"; +import styles from "./Projects.module.scss"; + +interface ProjectsProps { + rootCommonId: string; + commonId: string; + activeItemId: string; + onActiveItemId: (activeItemId: string) => void; + originalCommonId: string; + linkedCommonIds: string[]; + renderNoItemsInfo?: () => ReactNode; +} + +const Projects: FC = (props) => { + const { + activeItemId, + onActiveItemId, + renderNoItemsInfo, + originalCommonId, + linkedCommonIds, + } = props; + const [currentCommonId, setCurrentCommonId] = useState(props.rootCommonId); + const { + parentItem, + areCommonsLoading, + areProjectsLoading, + commons, + items, + activeItem, + parentItemIds, + } = useProjectsData({ + currentCommonId, + activeItemId, + originalCommonId, + linkedCommonIds, + }); + const treeItemTriggerStyles = useMemo( + () => ({ + container: styles.projectsTreeItemTriggerClassName, + containerActive: styles.projectsTreeItemTriggerActiveClassName, + name: styles.projectsTreeItemTriggerNameClassName, + image: styles.projectsTreeItemTriggerImageClassName, + imageNonRounded: styles.projectsTreeItemTriggerImageNonRoundedClassName, + }), + [], + ); + + useEffect(() => { + onActiveItemId(""); + }, [currentCommonId]); + + if (!parentItem) { + return areCommonsLoading ? ( + + ) : ( + <>{renderNoItemsInfo?.() || null} + ); + } + + return ( + + ); +}; + +export default Projects; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/NameRightContent.module.scss b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/NameRightContent.module.scss new file mode 100644 index 0000000000..e54f537a46 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/NameRightContent.module.scss @@ -0,0 +1,11 @@ +.originalText { + margin-left: 0.5rem; + font-weight: normal; + font-size: 0.875rem; + font-style: italic; +} + +.linkIcon { + flex-shrink: 0; + margin-left: 0.5rem; +} diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/NameRightContent.tsx b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/NameRightContent.tsx new file mode 100644 index 0000000000..16d5923adb --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/NameRightContent.tsx @@ -0,0 +1,25 @@ +import React, { FC } from "react"; +import { Link4Icon } from "@/shared/icons"; +import { ProjectsStateItem } from "@/store/states"; +import styles from "./NameRightContent.module.scss"; + +interface NameRightContentProps { + projectsStateItem: ProjectsStateItem; + originalCommonId: string; + linkedCommonIds: string[]; +} + +const NameRightContent: FC = (props) => { + const { projectsStateItem, originalCommonId, linkedCommonIds } = props; + + if (projectsStateItem.commonId === originalCommonId) { + return original; + } + if (linkedCommonIds.includes(projectsStateItem.commonId)) { + return ; + } + + return null; +}; + +export default NameRightContent; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/index.ts new file mode 100644 index 0000000000..3c341bfce7 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/NameRightContent/index.ts @@ -0,0 +1 @@ +export { default as NameRightContent } from "./NameRightContent"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/index.ts new file mode 100644 index 0000000000..f5705eab2d --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/components/index.ts @@ -0,0 +1 @@ +export * from "./NameRightContent"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/index.ts new file mode 100644 index 0000000000..ced5d7bb9c --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useProjectsData"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/useProjectsData.tsx b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/useProjectsData.tsx new file mode 100644 index 0000000000..683be78b56 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/hooks/useProjectsData.tsx @@ -0,0 +1,145 @@ +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { useSelector } from "react-redux"; +import { selectUser } from "@/pages/Auth/store/selectors"; +import { useLoadingState } from "@/shared/hooks"; +import { + generateProjectsTreeItems, + getItemById, + getItemFromProjectsStateItem, + getParentItemIds, + Item, +} from "@/shared/layouts/SidenavLayout/components/SidenavContent/components"; +import { + ProjectsStateItem, + selectCommonLayoutCommonsState, +} from "@/store/states"; +import { getProjects as getProjectsUtil } from "@/store/states/commonLayout/saga/utils"; +import { NameRightContent } from "./components"; + +interface ProjectsInfo { + currentCommonId: string; + activeItemId: string; + originalCommonId: string; + linkedCommonIds: string[]; +} + +interface Return { + parentItem: Item | null; + areCommonsLoading: boolean; + areProjectsLoading: boolean; + commons: ProjectsStateItem[]; + items: Item[]; + activeItem: Item | null; + parentItemIds: string[]; +} + +const generateItemCommonPagePath = () => ""; + +export const useProjectsData = (projectsInfo: ProjectsInfo): Return => { + const { currentCommonId, activeItemId, originalCommonId, linkedCommonIds } = + projectsInfo; + const currentCommonIdRef = useRef(currentCommonId); + currentCommonIdRef.current = currentCommonId; + const { commons, areCommonsLoading } = useSelector( + selectCommonLayoutCommonsState, + ); + const user = useSelector(selectUser()); + const userId = user?.uid; + const [{ data: projects, loading: areProjectsLoading }, setProjectsState] = + useLoadingState([]); + const currentCommon = commons.find( + ({ commonId }) => commonId === currentCommonId, + ); + + const getAdditionalItemData = useCallback( + (projectsStateItem: ProjectsStateItem): Partial => ({ + disabled: + !projectsStateItem.hasPermissionToLinkToHere || + projectsStateItem.commonId === originalCommonId || + linkedCommonIds.includes(projectsStateItem.commonId), + nameRightContent: ( + + ), + }), + [originalCommonId, linkedCommonIds], + ); + + const parentItem = useMemo( + () => + currentCommon + ? getItemFromProjectsStateItem( + currentCommon, + generateItemCommonPagePath, + undefined, + getAdditionalItemData, + ) + : null, + [currentCommon, getAdditionalItemData], + ); + const items = useMemo(() => { + const [item] = generateProjectsTreeItems( + currentCommon ? projects.concat(currentCommon) : projects, + generateItemCommonPagePath, + getAdditionalItemData, + ); + + return item?.items || []; + }, [currentCommon, projects, getAdditionalItemData]); + const activeItem = getItemById( + activeItemId, + parentItem ? [parentItem, ...items] : items, + ); + const parentItemIds = getParentItemIds( + activeItemId, + currentCommon ? projects.concat(currentCommon) : projects, + ); + + useEffect(() => { + let isRelevantLoading = true; + + (async () => { + try { + setProjectsState({ + data: [], + loading: true, + fetched: false, + }); + const projectsData = await getProjectsUtil(currentCommonId, userId); + + if (isRelevantLoading) { + setProjectsState({ + data: projectsData, + loading: false, + fetched: true, + }); + } + } catch (err) { + if (isRelevantLoading) { + setProjectsState({ + data: [], + loading: false, + fetched: true, + }); + } + } + })(); + + return () => { + isRelevantLoading = false; + }; + }, [currentCommonId]); + + return { + parentItem, + areCommonsLoading, + areProjectsLoading, + commons, + items, + activeItem, + parentItemIds, + }; +}; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/index.ts new file mode 100644 index 0000000000..b8190505b7 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/Projects/index.ts @@ -0,0 +1 @@ +export { default as Projects } from "./Projects"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/index.ts new file mode 100644 index 0000000000..a72f1e6536 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/components/index.ts @@ -0,0 +1 @@ +export * from "./Projects"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/hooks/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/hooks/index.ts new file mode 100644 index 0000000000..dd732fc3f9 --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useDMUsers"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/hooks/useDMUsers.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/hooks/useDMUsers.ts new file mode 100644 index 0000000000..4a41060d9c --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/hooks/useDMUsers.ts @@ -0,0 +1,55 @@ +import { useCallback, useState } from "react"; +import { ChatService } from "@/services"; +import { useIsMounted, useLoadingState } from "@/shared/hooks"; +import { DMUser } from "@/shared/interfaces"; + +interface Return { + loading: boolean; + dmUsers: DMUser[]; + fetchDMUsers: (force?: boolean) => void; + error?: boolean; +} + +export const useDMUsers = (): Return => { + const isMounted = useIsMounted(); + const [state, setState] = useLoadingState([]); + const [error, setError] = useState(false); + + const fetchDMUsers = useCallback( + async (force = true) => { + if (!force && (state.loading || state.fetched)) { + return; + } + + setState({ + loading: true, + fetched: false, + data: [], + }); + + let dmUsers: DMUser[] = []; + + try { + dmUsers = await ChatService.getDMUsers(); + } catch (error) { + setError(true); + } finally { + if (isMounted()) { + setState({ + loading: false, + fetched: true, + data: dmUsers, + }); + } + } + }, + [state], + ); + + return { + fetchDMUsers, + loading: state.loading || !state.fetched, + dmUsers: state.data, + error: error, + }; +}; diff --git a/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/index.ts b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/index.ts new file mode 100644 index 0000000000..8412e8f4fb --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/LinkSpaceModal/index.ts @@ -0,0 +1 @@ +export { default as LinkSpaceModal } from "./LinkSpaceModal"; diff --git a/src/pages/common/components/DiscussionFeedCard/components/index.ts b/src/pages/common/components/DiscussionFeedCard/components/index.ts new file mode 100644 index 0000000000..f96e662f0b --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/components/index.ts @@ -0,0 +1 @@ +export * from "./LinkSpaceModal"; diff --git a/src/pages/common/components/DiscussionFeedCard/hooks/useMenuItems.tsx b/src/pages/common/components/DiscussionFeedCard/hooks/useMenuItems.tsx index 1ce6e71779..4ff375df50 100644 --- a/src/pages/common/components/DiscussionFeedCard/hooks/useMenuItems.tsx +++ b/src/pages/common/components/DiscussionFeedCard/hooks/useMenuItems.tsx @@ -12,10 +12,12 @@ import { Trash2Icon, UnfollowIcon, UnpinIcon, + Link4Icon as LinkIcon, Message3Icon, } from "@/shared/icons"; import { ContextMenuItem as Item, UploadFile } from "@/shared/interfaces"; import { parseStringToTextEditorValue } from "@/shared/ui-kit"; +import { emptyFunction } from "@/shared/utils"; import { notEmpty } from "@/shared/utils/notEmpty"; import { commonActions } from "@/store/states"; import { FeedItemMenuItem, GetAllowedItemsOptions } from "../../FeedItem"; @@ -25,6 +27,7 @@ interface Actions { report: () => void; share: () => void; remove?: () => void; + linkSpace?: () => void; } export const useMenuItems = ( @@ -39,7 +42,7 @@ export const useMenuItems = ( feedItemFollow, feedItemUserMetadata, } = options; - const { report, share, remove } = actions; + const { report, share, remove, linkSpace } = actions; const allowedMenuItems = getAllowedItems({ ...options, feedItemFollow }); const items: Item[] = [ { @@ -145,6 +148,14 @@ export const useMenuItems = ( feedItemFollow.onFollowToggle(FollowFeedItemAction.Unfollow), icon: , }, + linkSpace + ? { + id: FeedItemMenuItem.LinkTo, + text: "Link to...", + onClick: linkSpace, + icon: , + } + : undefined, remove ? { id: FeedItemMenuItem.Remove, diff --git a/src/pages/common/components/DiscussionFeedCard/utils/checkIsLinkToAllowed.ts b/src/pages/common/components/DiscussionFeedCard/utils/checkIsLinkToAllowed.ts new file mode 100644 index 0000000000..0c44a1540e --- /dev/null +++ b/src/pages/common/components/DiscussionFeedCard/utils/checkIsLinkToAllowed.ts @@ -0,0 +1,32 @@ +import { GovernanceActions } from "@/shared/constants"; +import { getCirclesWithLowestTier, hasPermission } from "@/shared/utils"; +import { GetAllowedItemsOptions } from "../../FeedItem"; + +export const checkIsLinkToAllowed = ( + options: GetAllowedItemsOptions, +): boolean => { + if (!options.commonMember) { + return false; + } + + const circlesWithLowestTier = getCirclesWithLowestTier( + Object.values(options.governanceCircles || {}), + ); + const discussionCircleVisibility = + options.discussion?.circleVisibilityByCommon?.[options.commonId || ""] || + []; + + return ( + (discussionCircleVisibility.length === 0 || + discussionCircleVisibility.some((circleId) => + circlesWithLowestTier.some((circle) => circle.id === circleId), + )) && + hasPermission({ + commonMember: options.commonMember, + governance: { + circles: options.governanceCircles || {}, + }, + key: GovernanceActions.LINK_FROM_HERE, + }) + ); +}; diff --git a/src/pages/common/components/DiscussionFeedCard/utils/getAllowedItems.ts b/src/pages/common/components/DiscussionFeedCard/utils/getAllowedItems.ts index c253616b6c..f4872af0ca 100644 --- a/src/pages/common/components/DiscussionFeedCard/utils/getAllowedItems.ts +++ b/src/pages/common/components/DiscussionFeedCard/utils/getAllowedItems.ts @@ -3,6 +3,7 @@ import { notEmpty } from "@/shared/utils/notEmpty"; import { FeedItemMenuItem, FeedItemPinAction } from "../../FeedItem/constants"; import { GetAllowedItemsOptions } from "../../FeedItem/types"; import { checkIsEditItemAllowed } from "./checkIsEditItemAllowed"; +import { checkIsLinkToAllowed } from "./checkIsLinkToAllowed"; import { checkIsPinUnpinAllowed } from "./checkIsPinUnpinAllowed"; import { checkIsRemoveDiscussionAllowed } from "./checkIsRemoveDiscussionAllowed"; @@ -38,6 +39,7 @@ const MENU_ITEM_TO_CHECK_FUNCTION_MAP: Record< return Boolean(count) || !seen; }, + [FeedItemMenuItem.LinkTo]: checkIsLinkToAllowed, }; export const getAllowedItems = ( @@ -53,6 +55,7 @@ export const getAllowedItems = ( FeedItemMenuItem.MarkRead, FeedItemMenuItem.Report, FeedItemMenuItem.Edit, + FeedItemMenuItem.LinkTo, FeedItemMenuItem.Remove, ]; const nonAllowedItems = diff --git a/src/pages/common/components/FeedCard/FeedCard.tsx b/src/pages/common/components/FeedCard/FeedCard.tsx index 05a97ed22c..c413933ef5 100644 --- a/src/pages/common/components/FeedCard/FeedCard.tsx +++ b/src/pages/common/components/FeedCard/FeedCard.tsx @@ -48,6 +48,8 @@ type FeedCardProps = PropsWithChildren<{ hasImages?: boolean; hasUnseenMention?: boolean; notion?: CommonNotion; + originalCommonIdForLinking?: string; + linkedCommonIds?: string[]; }>; const MOBILE_HEADER_HEIGHT = 52; @@ -89,6 +91,8 @@ export const FeedCard = forwardRef((props, ref) => { hasImages, hasFiles, notion, + originalCommonIdForLinking, + linkedCommonIds, } = props; const scrollTimeoutRef = useRef | null>(null); const isTabletView = useIsTabletView(); @@ -213,6 +217,8 @@ export const FeedCard = forwardRef((props, ref) => { hasImages, hasUnseenMention, notion, + originalCommonIdForLinking, + linkedCommonIds, })} )} diff --git a/src/pages/common/components/FeedCard/components/FeedItemBaseContent/FeedItemBaseContent.tsx b/src/pages/common/components/FeedCard/components/FeedItemBaseContent/FeedItemBaseContent.tsx index 43b663ab6c..1d9d191f9d 100644 --- a/src/pages/common/components/FeedCard/components/FeedItemBaseContent/FeedItemBaseContent.tsx +++ b/src/pages/common/components/FeedCard/components/FeedItemBaseContent/FeedItemBaseContent.tsx @@ -14,11 +14,13 @@ import { } from "@/shared/ui-kit"; import { FeedItemBaseContentProps } from "../../../FeedItem"; import { FeedCardTags } from "../FeedCardTags"; +import { LinkedItemMark } from "../LinkedItemMark"; import styles from "./FeedItemBaseContent.module.scss"; export const FeedItemBaseContent: FC = (props) => { const { className, + commonId, titleWrapperClassName, lastActivity, unreadMessages, @@ -41,11 +43,20 @@ export const FeedItemBaseContent: FC = (props) => { shouldHideBottomContent = false, hasUnseenMention, notion, + originalCommonIdForLinking, + linkedCommonIds, } = props; const contextMenuRef = useRef(null); const [isLongPressing, setIsLongPressing] = useState(false); const [isLongPressed, setIsLongPressed] = useState(false); const isContextMenuEnabled = Boolean(menuItems && menuItems.length > 0); + const isLinked = Boolean( + commonId && + linkedCommonIds && + linkedCommonIds.length > 0 && + (linkedCommonIds.includes(commonId) || + originalCommonIdForLinking === commonId), + ); // Here we get either MouseEven, or TouchEven, but I was struggling with importing them from react // and use here to have correct types. @@ -135,6 +146,13 @@ export const FeedItemBaseContent: FC = (props) => { )} + {isLinked && ( + + )}

= (props) => { + const { + currentCommonId, + originalCommonId = "", + linkedCommonIds = [], + } = props; + const isTabletView = useIsTabletView(); + const [isOpen, setIsOpen] = useState(false); + const { + data: commonPaths, + loading, + fetched, + fetchCommonPaths, + } = useCommonPaths(); + const { getCommonPagePath } = useRoutesContext(); + + const toggleTooltip: MouseEventHandler = (event) => { + event.stopPropagation(); + setIsOpen((v) => !v); + }; + + useEffect(() => { + if (!isOpen || loading || fetched) { + return; + } + + const commonIds = linkedCommonIds + .concat(originalCommonId) + .filter((commonId) => commonId && commonId !== currentCommonId); + + fetchCommonPaths(commonIds); + }, [isOpen]); + + return ( + + + + + + + + Also appears in: + {!fetched && } + {fetched && + commonPaths.map((commons, index) => { + const lastCommon = commons[commons.length - 1]; + const key = lastCommon?.id || String(index); + + return ( + + + + ); + })} + + + ); +}; + +export default LinkedItemMark; diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/LinkedItemTooltipContent.module.scss b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/LinkedItemTooltipContent.module.scss new file mode 100644 index 0000000000..7b275b858f --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/LinkedItemTooltipContent.module.scss @@ -0,0 +1,26 @@ +.linkLeft, +.linkRight { + white-space: nowrap; + overflow: hidden; +} + +.linkLeft { + display: flex; + align-items: center; + text-overflow: ellipsis; +} + +.linkRight { + flex-shrink: 0; + max-width: 25rem; + display: flex; + align-items: center; +} + +.ellipsis { + flex-shrink: 0; +} + +.arrowIcon { + flex-shrink: 0; +} diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/LinkedItemTooltipContent.tsx b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/LinkedItemTooltipContent.tsx new file mode 100644 index 0000000000..27c8860392 --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/LinkedItemTooltipContent.tsx @@ -0,0 +1,64 @@ +import React, { FC, ReactElement, useEffect, useState } from "react"; +import { RightArrowThinIcon } from "@/shared/icons"; +import { Common } from "@/shared/models"; +import styles from "./LinkedItemTooltipContent.module.scss"; + +interface LinkedItemTooltipContentProps { + commons: Common[]; +} + +const renderCutPath = (commons: Common[]): ReactElement => { + const lastCommon = commons[commons.length - 1]; + const parentCommons = commons.slice(0, -1); + + return ( + <> +

+ {parentCommons.map((common, commonIndex) => ( + + {common.name} + {commonIndex !== parentCommons.length - 1 && ( + + )} + + ))} +
+
+
+ + {lastCommon?.name} +
+ + ); +}; + +const LinkedItemTooltipContent: FC = (props) => { + const { commons } = props; + const [containerRef, setContainerRef] = useState(null); + const [shouldCut, setShouldCut] = useState(false); + + useEffect(() => { + if (containerRef && containerRef.scrollWidth > containerRef.clientWidth) { + setShouldCut(true); + } + }, [containerRef]); + + if (shouldCut) { + return renderCutPath(commons); + } + + return ( +
+ {commons.map((common, commonIndex) => ( + + {common.name} + {commonIndex !== commons.length - 1 && ( + + )} + + ))} +
+ ); +}; + +export default LinkedItemTooltipContent; diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/index.ts b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/index.ts new file mode 100644 index 0000000000..d7f01d8657 --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/LinkedItemTooltipContent/index.ts @@ -0,0 +1 @@ +export { default as LinkedItemTooltipContent } from "./LinkedItemTooltipContent"; diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/components/index.ts b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/index.ts new file mode 100644 index 0000000000..7ba868bb69 --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/components/index.ts @@ -0,0 +1 @@ +export * from "./LinkedItemTooltipContent"; diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/hooks/index.ts b/src/pages/common/components/FeedCard/components/LinkedItemMark/hooks/index.ts new file mode 100644 index 0000000000..9a25620f16 --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useCommonPaths"; diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/hooks/useCommonPaths.ts b/src/pages/common/components/FeedCard/components/LinkedItemMark/hooks/useCommonPaths.ts new file mode 100644 index 0000000000..88822da5c2 --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/hooks/useCommonPaths.ts @@ -0,0 +1,49 @@ +import { CommonService } from "@/services"; +import { useIsMounted, useLoadingState } from "@/shared/hooks"; +import { LoadingState } from "@/shared/interfaces"; +import { Common } from "@/shared/models"; + +type Data = Common[][]; + +interface Return extends LoadingState { + fetchCommonPaths: (commonIdsForPaths: string[]) => void; +} + +export const useCommonPaths = (): Return => { + const isMounted = useIsMounted(); + const [state, setState] = useLoadingState([]); + + const fetchCommonPaths = async (commonIdsForPaths: string[]) => { + setState({ + loading: true, + fetched: false, + data: [], + }); + + let commons: Data = []; + + try { + commons = await Promise.all( + commonIdsForPaths.map((commonId) => + CommonService.getCommonAndParents(commonId), + ), + ); + commons = commons.filter((path) => path.length > 0); + } catch (err) { + commons = []; + } finally { + if (isMounted()) { + setState({ + loading: false, + fetched: true, + data: commons, + }); + } + } + }; + + return { + ...state, + fetchCommonPaths, + }; +}; diff --git a/src/pages/common/components/FeedCard/components/LinkedItemMark/index.ts b/src/pages/common/components/FeedCard/components/LinkedItemMark/index.ts new file mode 100644 index 0000000000..068cc7e617 --- /dev/null +++ b/src/pages/common/components/FeedCard/components/LinkedItemMark/index.ts @@ -0,0 +1 @@ +export { default as LinkedItemMark } from "./LinkedItemMark"; diff --git a/src/pages/common/components/FeedItem/FeedItem.tsx b/src/pages/common/components/FeedItem/FeedItem.tsx index 946782d22e..765210d10a 100644 --- a/src/pages/common/components/FeedItem/FeedItem.tsx +++ b/src/pages/common/components/FeedItem/FeedItem.tsx @@ -43,6 +43,7 @@ interface FeedItemProps { commonId?: string, ) => void; directParent?: DirectParent | null; + rootCommonId?: string; } const FeedItem = forwardRef((props, ref) => { @@ -67,6 +68,7 @@ const FeedItem = forwardRef((props, ref) => { shouldCheckItemVisibility = true, onActiveItemDataChange, directParent, + rootCommonId, } = props; const { onFeedItemUpdate, @@ -129,6 +131,7 @@ const FeedItem = forwardRef((props, ref) => { isMobileVersion, onActiveItemDataChange: handleActiveItemDataChange, directParent, + rootCommonId, feedItemFollow, onUserSelect, }; diff --git a/src/pages/common/components/FeedItem/constants/feedItemMenuItem.ts b/src/pages/common/components/FeedItem/constants/feedItemMenuItem.ts index 2e6995333b..aa26d398d8 100644 --- a/src/pages/common/components/FeedItem/constants/feedItemMenuItem.ts +++ b/src/pages/common/components/FeedItem/constants/feedItemMenuItem.ts @@ -9,4 +9,5 @@ export enum FeedItemMenuItem { Unfollow = "unfollow", MarkUnread = "markUnread", MarkRead = "markRead", + LinkTo = "linkTo", } diff --git a/src/pages/common/components/FeedItem/context.ts b/src/pages/common/components/FeedItem/context.ts index 0f4ee81c2e..2b81bc5f34 100644 --- a/src/pages/common/components/FeedItem/context.ts +++ b/src/pages/common/components/FeedItem/context.ts @@ -47,6 +47,8 @@ export interface FeedItemBaseContentProps { dmUserIds?: string[]; hasUnseenMention?: boolean; notion?: CommonNotion; + originalCommonIdForLinking?: string; + linkedCommonIds?: string[]; isGroupMessage?: boolean; createdBy?: string; hoverTitle?: string; diff --git a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx index c26fe9a6e4..97ed0cb5c2 100644 --- a/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx +++ b/src/pages/commonFeed/components/FeedLayout/FeedLayout.tsx @@ -718,6 +718,7 @@ const FeedLayout: ForwardRefRenderFunction = ( } onActiveItemDataChange={handleActiveFeedItemDataChange} directParent={outerCommon?.directParent} + rootCommonId={outerCommon?.rootCommonId} /> ); } diff --git a/src/pages/inbox/utils/getNonAllowedItems.ts b/src/pages/inbox/utils/getNonAllowedItems.ts index 8fb89fd88f..7e3b63af7c 100644 --- a/src/pages/inbox/utils/getNonAllowedItems.ts +++ b/src/pages/inbox/utils/getNonAllowedItems.ts @@ -5,4 +5,5 @@ export const getNonAllowedItems: GetNonAllowedItemsOptions = () => [ FeedItemMenuItem.Unpin, FeedItemMenuItem.Edit, FeedItemMenuItem.Remove, + FeedItemMenuItem.LinkTo, ]; diff --git a/src/services/Common.ts b/src/services/Common.ts index 1ae52d0355..b80ace63b6 100644 --- a/src/services/Common.ts +++ b/src/services/Common.ts @@ -323,6 +323,24 @@ class CommonService { return finalCommons; }; + public getCommonAndParents = async ( + commonId: string, + cached = false, + ): Promise => { + const common = await this.getCommonById(commonId, cached); + + if (!common) { + return []; + } + + const parentCommons = await this.getAllParentCommonsForCommon( + common, + cached, + ); + + return [...parentCommons, common]; + }; + public getParentCommonForCommonId = async ( commonId: string, ): Promise => { diff --git a/src/services/CommonFeed.ts b/src/services/CommonFeed.ts index 7ab52352fb..39f898973f 100644 --- a/src/services/CommonFeed.ts +++ b/src/services/CommonFeed.ts @@ -5,6 +5,7 @@ import { PinOrUnpinEndpointAction, } from "@/shared/constants"; import { + LinkStreamPayload, MarkCommonFeedItemAsSeenPayload, UnsubscribeFunction, } from "@/shared/interfaces"; @@ -229,6 +230,14 @@ class CommonFeedService { return convertObjectDatesToFirestoreTimestamps(data); }; + public linkStream = async ( + payload: LinkStreamPayload, + options: { cancelToken?: CancelToken } = {}, + ): Promise => { + const { cancelToken } = options; + await Api.post(ApiEndpoint.LinkStream, payload, { cancelToken }); + }; + public markCommonFeedItemAsUnseen = ( commonId: string, feedObjectId: string, diff --git a/src/services/Project.ts b/src/services/Project.ts index f992fc6ddc..9101213312 100644 --- a/src/services/Project.ts +++ b/src/services/Project.ts @@ -20,6 +20,7 @@ class ProjectService { common: Common; hasMembership: boolean; hasPermissionToAddProject?: boolean; + hasPermissionToLinkToHere?: boolean; }[] => commons .filter((common) => common.state === CommonState.ACTIVE) @@ -39,6 +40,12 @@ class ProjectService { permissionsItem.governance.circles, permissionsItem.commonMemberCircleIds, ).allowedActions[GovernanceActions.CREATE_PROJECT], + hasPermissionToLinkToHere: + permissionsItem && + generateCirclesDataForCommonMember( + permissionsItem.governance.circles, + permissionsItem.commonMemberCircleIds, + ).allowedActions[GovernanceActions.LINK_TO_HERE], }; }); diff --git a/src/shared/components/Modal/Modal.tsx b/src/shared/components/Modal/Modal.tsx index e55331fc1a..f1f586d5e0 100644 --- a/src/shared/components/Modal/Modal.tsx +++ b/src/shared/components/Modal/Modal.tsx @@ -180,7 +180,7 @@ const Modal: ForwardRefRenderFunction = ( )} {typeof title === "string" ? ( -

{title}

+

{title}

) : ( title )} diff --git a/src/shared/constants/endpoint.ts b/src/shared/constants/endpoint.ts index a5cc923ae7..62d925c6b3 100644 --- a/src/shared/constants/endpoint.ts +++ b/src/shared/constants/endpoint.ts @@ -8,6 +8,7 @@ export const ApiEndpoint = { CreateSubCommon: "/commons/subcommon/create", MarkFeedObjectSeenForUser: "/commons/mark-feed-object-seen-for-user", MarkFeedObjectUnseenForUser: "/commons/mark-feed-object-unseen-for-user", + LinkStream: "/commons/link-stream", AcceptRules: "/commons/accept-rules", GetCommonFeedItems: "/commons/:commonId/feed-items", GetCommonPinnedFeedItems: "/commons/:commonId/pinned-feed-items", diff --git a/src/shared/constants/governance/GovernanceActions.ts b/src/shared/constants/governance/GovernanceActions.ts index b1352f03b6..f0295e312c 100644 --- a/src/shared/constants/governance/GovernanceActions.ts +++ b/src/shared/constants/governance/GovernanceActions.ts @@ -41,4 +41,9 @@ export enum GovernanceActions { LEAVE_CIRCLE = "LEAVE_CIRCLE", PIN_OR_UNPIN_FEED_ITEMS = "PIN_OR_UNPIN_FEED_ITEMS", + + MOVE_TO_HERE = "MOVE_TO_HERE", + LINK_TO_HERE = "LINK_TO_HERE", + MOVE_FROM_HERE = "MOVE_FROM_HERE", + LINK_FROM_HERE = "LINK_FROM_HERE", } diff --git a/src/shared/converters/ChatChannelToDiscussionConverter.ts b/src/shared/converters/ChatChannelToDiscussionConverter.ts index 31e0b7fbc9..4ff91eedb8 100644 --- a/src/shared/converters/ChatChannelToDiscussionConverter.ts +++ b/src/shared/converters/ChatChannelToDiscussionConverter.ts @@ -17,6 +17,8 @@ class ChatChannelToDiscussionConverter extends Converter< followers: [], messageCount: chatChannel.messageCount, discussionMessages: [], + linkedCommonIds: [], + circleVisibilityByCommon: {}, isDeleted: false, createdAt: chatChannel.createdAt, updatedAt: chatChannel.updatedAt, diff --git a/src/shared/hooks/useCases/index.ts b/src/shared/hooks/useCases/index.ts index 7e71b5e958..07af3db227 100644 --- a/src/shared/hooks/useCases/index.ts +++ b/src/shared/hooks/useCases/index.ts @@ -21,6 +21,7 @@ export type { ChangePaymentMethodState } from "./usePaymentMethodChange"; export { useProjectCreation } from "./useProjectCreation"; export { useProposalById } from "./useProposalById"; export { useRootCommonMembershipIntro } from "./useRootCommonMembershipIntro"; +export { useStreamLinking } from "./useStreamLinking"; export { useSubCommons } from "./useSubCommons"; export { useSupportersData } from "./useSupportersData"; export { useLastVisitedCommon } from "./useLastVisitedCommon"; diff --git a/src/shared/hooks/useCases/useStreamLinking.ts b/src/shared/hooks/useCases/useStreamLinking.ts new file mode 100644 index 0000000000..18bcea748b --- /dev/null +++ b/src/shared/hooks/useCases/useStreamLinking.ts @@ -0,0 +1,64 @@ +import { useCallback, useRef, useState } from "react"; +import { + CancelTokenSource, + CommonFeedService, + isRequestCancelled, + getCancelTokenSource, + Logger, +} from "@/services"; +import { LinkStreamPayload } from "@/shared/interfaces"; + +interface State { + isStreamLinking: boolean; + isStreamLinked: boolean; +} + +interface Return extends State { + linkStream: (payload: LinkStreamPayload) => void; +} + +export const useStreamLinking = (): Return => { + const cancelTokenRef = useRef(null); + const [state, setState] = useState({ + isStreamLinking: false, + isStreamLinked: false, + }); + + const linkStream = useCallback(async (payload: LinkStreamPayload) => { + if (cancelTokenRef.current) { + cancelTokenRef.current.cancel(); + } + + try { + setState({ + isStreamLinking: true, + isStreamLinked: false, + }); + cancelTokenRef.current = getCancelTokenSource(); + + await CommonFeedService.linkStream(payload, { + cancelToken: cancelTokenRef.current.token, + }); + + cancelTokenRef.current = null; + setState({ + isStreamLinking: false, + isStreamLinked: true, + }); + } catch (error) { + if (!isRequestCancelled(error)) { + Logger.error(error); + cancelTokenRef.current = null; + setState({ + isStreamLinking: false, + isStreamLinked: false, + }); + } + } + }, []); + + return { + ...state, + linkStream, + }; +}; diff --git a/src/shared/icons/index.ts b/src/shared/icons/index.ts index 73368cf0a2..a2c85d05db 100644 --- a/src/shared/icons/index.ts +++ b/src/shared/icons/index.ts @@ -27,6 +27,7 @@ export { default as InfoIcon } from "./info.icon"; export { default as LeftArrowIcon } from "./leftArrow.icon"; export { default as Link2Icon } from "./link2.icon"; export { default as Link3Icon } from "./link3.icon"; +export { default as Link4Icon } from "./link4.icon"; export { default as ListMarkIcon } from "./listMark.icon"; export { default as LogoutIcon } from "./logout.icon"; export { default as LongLeftArrowIcon } from "./longLeftArrow.icon"; diff --git a/src/shared/icons/link4.icon.tsx b/src/shared/icons/link4.icon.tsx new file mode 100644 index 0000000000..e455630c59 --- /dev/null +++ b/src/shared/icons/link4.icon.tsx @@ -0,0 +1,44 @@ +import React, { FC } from "react"; + +interface Link4IconProps { + className?: string; +} + +const Link4Icon: FC = ({ className }) => { + const color = "currentColor"; + + return ( + + + + + + ); +}; + +export default Link4Icon; diff --git a/src/shared/interfaces/LinkStreamPayload.ts b/src/shared/interfaces/LinkStreamPayload.ts new file mode 100644 index 0000000000..210db0cec2 --- /dev/null +++ b/src/shared/interfaces/LinkStreamPayload.ts @@ -0,0 +1,6 @@ +export interface LinkStreamPayload { + feedObjectId: string; + sourceCommonId: string; + targetCommonId: string; + userId: string; +} diff --git a/src/shared/interfaces/ModalProps.tsx b/src/shared/interfaces/ModalProps.tsx index 5f09737534..1bc02719bb 100644 --- a/src/shared/interfaces/ModalProps.tsx +++ b/src/shared/interfaces/ModalProps.tsx @@ -28,6 +28,7 @@ export interface ModalProps { modalOverlay?: string; headerWrapper?: string; header?: string; + title?: string; closeWrapper?: string; content?: string; }; diff --git a/src/shared/interfaces/index.tsx b/src/shared/interfaces/index.tsx index 2a828c1f8b..58d1f48b88 100644 --- a/src/shared/interfaces/index.tsx +++ b/src/shared/interfaces/index.tsx @@ -6,6 +6,7 @@ export * from "./CreateProjectPayload"; export * from "./DMUser"; export * from "./feedLayout"; export * from "./ChatChannelFeedLayoutItemProps"; +export * from "./LinkStreamPayload"; export * from "./LoadingState"; export * from "./MarkCommonFeedItemAsSeenPayload"; export * from "./MenuItem"; diff --git a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/CommonDropdown.tsx b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/CommonDropdown.tsx index d8e5f3eeee..e1990af2fc 100644 --- a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/CommonDropdown.tsx +++ b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/CommonDropdown.tsx @@ -6,16 +6,18 @@ interface CommonDropdownProps { items: MenuItem[]; activeItemId?: string | null; isActive: boolean; + menuItemsClassName?: string; } const CommonDropdown: FC = (props) => { - const { items, activeItemId, isActive } = props; + const { items, activeItemId, isActive, menuItemsClassName } = props; return ( ); }; diff --git a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/components/DesktopCommonDropdown/DesktopCommonDropdown.tsx b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/components/DesktopCommonDropdown/DesktopCommonDropdown.tsx index af92338041..b74bbf19c2 100644 --- a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/components/DesktopCommonDropdown/DesktopCommonDropdown.tsx +++ b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/CommonDropdown/components/DesktopCommonDropdown/DesktopCommonDropdown.tsx @@ -11,13 +11,14 @@ import { CREATE_COMMON_ITEM_ID } from "../../../ProjectsTree"; import styles from "./DesktopCommonDropdown.module.scss"; interface DesktopCommonDropdownProps { + menuItemsClassName?: string; items: MenuItem[]; activeItemId?: string | null; isActive: boolean; } const DesktopCommonDropdown: FC = (props) => { - const { items, activeItemId, isActive } = props; + const { menuItemsClassName, items, activeItemId, isActive } = props; const [menuRerenderHack, setMenuRerenderHack] = useState(false); const screenSize = useSelector(getScreenSize()); const isMobileView = screenSize === ScreenSize.Mobile; @@ -77,7 +78,7 @@ const DesktopCommonDropdown: FC = (props) => {
void; - onCommonCreationClick: () => void; - onAddProjectClick: (commonId: string) => void; + onCommonCreationClick?: () => void; + onAddProjectClick?: (commonId: string) => void; + onItemClick?: (itemId: string) => void; isLoading?: boolean; + withScrollbar?: boolean; + commonsMenuClassName?: string; + loaderDelay?: number; } const ProjectsTree: FC = (props) => { @@ -40,7 +44,11 @@ const ProjectsTree: FC = (props) => { onCommonClick, onCommonCreationClick, onAddProjectClick, + onItemClick, isLoading = false, + withScrollbar = true, + commonsMenuClassName, + loaderDelay = LOADER_APPEARANCE_DELAY, } = props; const menuItems = useMenuItems({ stateItems: commons, @@ -59,6 +67,7 @@ const ProjectsTree: FC = (props) => { treeItemTriggerStyles, parentItemIds, onAddProjectClick, + onItemClick, }), [ activeItem?.id, @@ -67,9 +76,26 @@ const ProjectsTree: FC = (props) => { treeItemTriggerStyles, parentItemIds, onAddProjectClick, + onItemClick, ], ); + const itemsEl = ( + <> + + {isLoading && } + + ); + return ( = (props) => { items={menuItems} activeItemId={currentCommonId} isActive={isParentItemActive} + menuItemsClassName={commonsMenuClassName} /> ), }} isActive={isParentItemActive} /> - - - {isLoading && ( - - )} - + {withScrollbar ? {itemsEl} : itemsEl} ); }; diff --git a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/ProjectsTree/hooks/useMenuItems.ts b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/ProjectsTree/hooks/useMenuItems.ts index da65bfff9e..94905faa7c 100644 --- a/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/ProjectsTree/hooks/useMenuItems.ts +++ b/src/shared/layouts/CommonSidenavLayout/components/SidenavContent/components/ProjectsTree/hooks/useMenuItems.ts @@ -6,7 +6,7 @@ interface Options { stateItems: ProjectsStateItem[]; activeStateItemId?: string | null; onCommonClick: (commonId: string) => void; - onCommonCreationClick: () => void; + onCommonCreationClick?: () => void; } export const useMenuItems = (options: Options): MenuItem[] => { @@ -33,12 +33,13 @@ export const useMenuItems = (options: Options): MenuItem[] => { } return 0; - }) - .concat({ - id: CREATE_COMMON_ITEM_ID, - text: "Create a common", - onClick: onCommonCreationClick, }); - return items; + return onCommonCreationClick + ? items.concat({ + id: CREATE_COMMON_ITEM_ID, + text: "Create a common", + onClick: onCommonCreationClick, + }) + : items; }; diff --git a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/Projects/utils/generateProjectsStateItems.ts b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/Projects/utils/generateProjectsStateItems.ts index 917802cfa9..007a356a0c 100644 --- a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/Projects/utils/generateProjectsStateItems.ts +++ b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/Projects/utils/generateProjectsStateItems.ts @@ -5,6 +5,9 @@ export const getItemFromProjectsStateItem = ( projectsStateItem: ProjectsStateItem, generatePath: (projectsStateItem: ProjectsStateItem) => string, itemsGroupedByCommonParentId?: Map, + getAdditionalItemData?: ( + projectsStateItem: ProjectsStateItem, + ) => Partial, ): Item => { const items = itemsGroupedByCommonParentId ? (itemsGroupedByCommonParentId.get(projectsStateItem.commonId) || []).map( @@ -13,6 +16,7 @@ export const getItemFromProjectsStateItem = ( subCommon, generatePath, itemsGroupedByCommonParentId, + getAdditionalItemData, ), ) : []; @@ -26,12 +30,16 @@ export const getItemFromProjectsStateItem = ( hasPermissionToAddProject: projectsStateItem.hasPermissionToAddProject, notificationsAmount: projectsStateItem.notificationsAmount, items, + ...(getAdditionalItemData?.(projectsStateItem) || {}), }; }; export const generateProjectsTreeItems = ( data: ProjectsStateItem[], generatePath: (projectsStateItem: ProjectsStateItem) => string, + getAdditionalItemData?: ( + projectsStateItem: ProjectsStateItem, + ) => Partial, ): Item[] => { const itemsGroupedByCommonParentId = data.reduce((map, item) => { const commonId = item.directParent?.commonId || null; @@ -50,6 +58,7 @@ export const generateProjectsTreeItems = ( item, generatePath, itemsGroupedByCommonParentId, + getAdditionalItemData, ), ), [], diff --git a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.module.scss b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.module.scss index 4ff097ff33..96865cd7f0 100644 --- a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.module.scss +++ b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.module.scss @@ -1,7 +1,7 @@ @import "../../../../../../../../../constants"; .item { - --bg-color: --var(--primary-background); + --bg-color: var(--primary-background); --item-pl-per-level: 1.125rem; --item-arrow-pl: var(--item-pl-per-level); --item-image-mr: 0.5rem; @@ -25,7 +25,6 @@ } .itemActive { --bg-color: #{$c-primary-200}99; - --item-text-color: #{$white}; &:hover { --bg-color: var(--secondary-hover-fill); @@ -34,6 +33,9 @@ .itemWithoutMembership { --item-text-color: #{$c-neutrals-300}; } +.itemDisabled { + cursor: not-allowed; +} .arrowIconButton { padding: 0.75rem 1.25rem 0.75rem var(--item-arrow-pl); @@ -71,7 +73,7 @@ font-family: PoppinsSans, sans-serif; font-weight: 600; font-size: $small; - color: var(--primary-text); + color: var(--item-text-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.tsx b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.tsx index e81eb77c18..337311a626 100644 --- a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.tsx +++ b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeItemTrigger/TreeItemTrigger.tsx @@ -19,7 +19,7 @@ interface TreeItemTriggerProps { const TreeItemTrigger: FC = (props) => { const { className, item, level, isActive, isOpen, onToggle } = props; - const { treeItemTriggerStyles } = useTreeContext(); + const { treeItemTriggerStyles, onItemClick } = useTreeContext(); const { hasMembership = true } = item; const handleToggle: MouseEventHandler = (event) => { @@ -30,24 +30,28 @@ const TreeItemTrigger: FC = (props) => { } }; - return ( - + const handleItemClick: MouseEventHandler = () => { + if (!item.disabled) { + onItemClick?.(item.id); + } + }; + + const wrapperClassName = classNames( + styles.item, + { + [classNames(styles.itemActive, treeItemTriggerStyles?.containerActive)]: + isActive, + [styles.itemWithoutMembership]: !hasMembership, + [classNames( + styles.itemDisabled, + treeItemTriggerStyles?.containerDisabled, + )]: item.disabled, + }, + className, + treeItemTriggerStyles?.container, + ); + const contentEl = ( + <> = (props) => { {item.name} + {item.nameRightContent} {item.rightContent} {!!item.notificationsAmount && ( = (props) => { {item.notificationsAmount} )} + + ); + + if (onItemClick || item.disabled) { + return ( +
+ {contentEl} +
+ ); + } + + return ( + + {contentEl} ); }; diff --git a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeRecursive/TreeRecursive.tsx b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeRecursive/TreeRecursive.tsx index 13eb943bd4..1b857a459d 100644 --- a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeRecursive/TreeRecursive.tsx +++ b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/components/TreeRecursive/TreeRecursive.tsx @@ -69,7 +69,7 @@ const TreeRecursive: FC = (props) => { > {(item.items && item.items.length > 0) || item.id === itemIdWithNewProjectCreation || - hasPermissionToAddProject ? ( + (hasPermissionToAddProject && onAddProjectClick) ? ( void; + onItemClick?: (itemId: string) => void; } export const TreeContext = React.createContext({ diff --git a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/types.ts b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/types.ts index 1b6df24eac..7d9ed91870 100644 --- a/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/types.ts +++ b/src/shared/layouts/SidenavLayout/components/SidenavContent/components/ProjectsTree/types.ts @@ -4,10 +4,12 @@ export interface Item { id: string; image: string; name: string; + nameRightContent?: ReactNode; path: string; hasMembership?: boolean; hasPermissionToAddProject?: boolean; notificationsAmount?: number; rightContent?: ReactNode; items?: Item[]; + disabled?: boolean; } diff --git a/src/shared/models/Discussion.tsx b/src/shared/models/Discussion.tsx index 59e27ac9e8..87f497db5b 100644 --- a/src/shared/models/Discussion.tsx +++ b/src/shared/models/Discussion.tsx @@ -26,6 +26,11 @@ export interface Discussion extends BaseEntity, SoftDeleteEntity { predefinedType?: PredefinedTypes; notion?: DiscussionNotion; + /** + * List of common IDs that are have linked this discussion + */ + linkedCommonIds: string[]; + /** * A discussion can be linked to a proposal, if it does - proposalId will exist. */ @@ -36,6 +41,12 @@ export interface Discussion extends BaseEntity, SoftDeleteEntity { * If discussion is attached to a proposal, this field will be not exist. */ circleVisibility?: string[]; + + /** + * If array is empty, everyone in common can view. + * If discussion is attached to a proposal, this field is null. + */ + circleVisibilityByCommon: Record | null; } export interface DiscussionWithOwnerInfo extends Discussion { diff --git a/src/shared/ui-kit/Tooltip/hooks/useTooltip.ts b/src/shared/ui-kit/Tooltip/hooks/useTooltip.ts index 95315e5e2e..8590c8453b 100644 --- a/src/shared/ui-kit/Tooltip/hooks/useTooltip.ts +++ b/src/shared/ui-kit/Tooltip/hooks/useTooltip.ts @@ -4,6 +4,7 @@ import { autoUpdate, offset, flip, + safePolygon, shift, arrow, useHover, @@ -20,9 +21,12 @@ export const useTooltip = (options: TooltipOptions = {}) => { placement = "top", open: controlledOpen, onOpenChange: setControlledOpen, + shouldOpenOnHover = false, } = options; const [uncontrolledOpen, setUncontrolledOpen] = useState(initialOpen); const arrowRef = useRef(null); + const shouldEnableHover = + shouldOpenOnHover ?? typeof controlledOpen === "undefined"; const open = controlledOpen ?? uncontrolledOpen; const setOpen = setControlledOpen ?? setUncontrolledOpen; @@ -46,7 +50,8 @@ export const useTooltip = (options: TooltipOptions = {}) => { const hover = useHover(context, { move: false, - enabled: typeof controlledOpen === "undefined", + enabled: shouldEnableHover, + handleClose: shouldEnableHover ? safePolygon() : null, }); const focus = useFocus(context, { enabled: typeof controlledOpen === "undefined", diff --git a/src/shared/ui-kit/Tooltip/types.ts b/src/shared/ui-kit/Tooltip/types.ts index 60d7302ee2..883d3403d7 100644 --- a/src/shared/ui-kit/Tooltip/types.ts +++ b/src/shared/ui-kit/Tooltip/types.ts @@ -5,4 +5,5 @@ export interface TooltipOptions { placement?: Placement; open?: boolean; onOpenChange?: (open: boolean) => void; + shouldOpenOnHover?: boolean; } diff --git a/src/store/states/commonLayout/saga/getCommons.ts b/src/store/states/commonLayout/saga/getCommons.ts index 78b2956aac..9609685827 100644 --- a/src/store/states/commonLayout/saga/getCommons.ts +++ b/src/store/states/commonLayout/saga/getCommons.ts @@ -98,16 +98,24 @@ export function* getCommons( .sort((prevItem, nextItem) => compareCommonsByLastActivity(prevItem.common, nextItem.common), ) - .map(({ common, hasMembership, hasPermissionToAddProject }) => ({ - commonId: common.id, - image: common.image, - name: common.name, - directParent: common.directParent, - rootCommonId: common.rootCommonId, - hasMembership, - hasPermissionToAddProject, - notificationsAmount: 0, - })); + .map( + ({ + common, + hasMembership, + hasPermissionToAddProject, + hasPermissionToLinkToHere, + }) => ({ + commonId: common.id, + image: common.image, + name: common.name, + directParent: common.directParent, + rootCommonId: common.rootCommonId, + hasMembership, + hasPermissionToAddProject, + hasPermissionToLinkToHere, + notificationsAmount: 0, + }), + ); yield put( actions.getCommons.success({ diff --git a/src/store/states/commonLayout/saga/getProjects.ts b/src/store/states/commonLayout/saga/getProjects.ts index cada89f37e..c046c69c24 100644 --- a/src/store/states/commonLayout/saga/getProjects.ts +++ b/src/store/states/commonLayout/saga/getProjects.ts @@ -1,12 +1,10 @@ import { call, put, select } from "redux-saga/effects"; import { selectUser } from "@/pages/Auth/store/selectors"; -import { CommonService, GovernanceService, ProjectService } from "@/services"; import { Awaited } from "@/shared/interfaces"; import { User } from "@/shared/models"; import { isError } from "@/shared/utils"; -import { ProjectsStateItem } from "../../projects"; import * as actions from "../actions"; -import { getPermissionsDataByAllUserCommonMemberInfo } from "./utils"; +import { getProjects as getProjectsUtil } from "./utils"; export function* getProjects( action: ReturnType, @@ -16,50 +14,11 @@ export function* getProjects( try { const user = (yield select(selectUser())) as User | null; const userId = user?.uid; - - const commonsWithSubCommons = (yield call( - CommonService.getCommonsWithSubCommons, - [commonId], - )) as Awaited>; - const commonsWithoutMainParentCommon = commonsWithSubCommons.filter( - (common) => common.id !== commonId, - ); - const allUserCommonMemberInfo = userId - ? ((yield call( - CommonService.getAllUserCommonMemberInfo, - userId, - )) as Awaited< - ReturnType - >) - : []; - const userCommonIds = allUserCommonMemberInfo.map((item) => item.commonId); - const governanceList = (yield call( - GovernanceService.getGovernanceListByCommonIds, - userCommonIds, - )) as Awaited< - ReturnType - >; - const permissionsData = getPermissionsDataByAllUserCommonMemberInfo( - allUserCommonMemberInfo, - governanceList, - ); - const data = ProjectService.parseDataToProjectsInfo( - commonsWithoutMainParentCommon, - userCommonIds, - permissionsData, - ); - const projectsData: ProjectsStateItem[] = data.map( - ({ common, hasMembership, hasPermissionToAddProject }) => ({ - commonId: common.id, - image: common.image, - name: common.name, - directParent: common.directParent, - rootCommonId: common.rootCommonId, - hasMembership, - hasPermissionToAddProject, - notificationsAmount: 0, - }), - ); + const projectsData = (yield call( + getProjectsUtil, + commonId, + userId, + )) as Awaited>; yield put(actions.getProjects.success(projectsData)); } catch (error) { diff --git a/src/store/states/commonLayout/saga/utils/getProjects.ts b/src/store/states/commonLayout/saga/utils/getProjects.ts new file mode 100644 index 0000000000..54733ff9c5 --- /dev/null +++ b/src/store/states/commonLayout/saga/utils/getProjects.ts @@ -0,0 +1,50 @@ +import { CommonService, GovernanceService, ProjectService } from "@/services"; +import { ProjectsStateItem } from "../../../projects"; +import { getPermissionsDataByAllUserCommonMemberInfo } from "./getPermissionsDataByAllUserCommonMemberInfo"; + +export const getProjects = async ( + commonId: string, + userId?: string, +): Promise => { + const commonsWithSubCommons = await CommonService.getCommonsWithSubCommons([ + commonId, + ]); + const commonsWithoutMainParentCommon = commonsWithSubCommons.filter( + (common) => common.id !== commonId, + ); + const allUserCommonMemberInfo = userId + ? await CommonService.getAllUserCommonMemberInfo(userId) + : []; + const userCommonIds = allUserCommonMemberInfo.map((item) => item.commonId); + const governanceList = await GovernanceService.getGovernanceListByCommonIds( + userCommonIds, + ); + const permissionsData = getPermissionsDataByAllUserCommonMemberInfo( + allUserCommonMemberInfo, + governanceList, + ); + const data = ProjectService.parseDataToProjectsInfo( + commonsWithoutMainParentCommon, + userCommonIds, + permissionsData, + ); + + return data.map( + ({ + common, + hasMembership, + hasPermissionToAddProject, + hasPermissionToLinkToHere, + }) => ({ + commonId: common.id, + image: common.image, + name: common.name, + directParent: common.directParent, + rootCommonId: common.rootCommonId, + hasMembership, + hasPermissionToAddProject, + hasPermissionToLinkToHere, + notificationsAmount: 0, + }), + ); +}; diff --git a/src/store/states/commonLayout/saga/utils/index.ts b/src/store/states/commonLayout/saga/utils/index.ts index a6b2ea834d..d929c1f843 100644 --- a/src/store/states/commonLayout/saga/utils/index.ts +++ b/src/store/states/commonLayout/saga/utils/index.ts @@ -1 +1,2 @@ export * from "./getPermissionsDataByAllUserCommonMemberInfo"; +export * from "./getProjects"; diff --git a/src/store/states/projects/types.ts b/src/store/states/projects/types.ts index 2926b9ac13..0be2e530b5 100644 --- a/src/store/states/projects/types.ts +++ b/src/store/states/projects/types.ts @@ -8,6 +8,7 @@ export interface ProjectsStateItem { rootCommonId?: string; hasMembership?: boolean; hasPermissionToAddProject?: boolean; + hasPermissionToLinkToHere?: boolean; notificationsAmount?: number; }