diff --git a/src/pages/common/components/ChatComponent/ChatComponent.module.scss b/src/pages/common/components/ChatComponent/ChatComponent.module.scss index 9a297227a9..116687b69a 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.module.scss +++ b/src/pages/common/components/ChatComponent/ChatComponent.module.scss @@ -80,8 +80,12 @@ display: flex; flex-direction: column; justify-content: center; - padding: 0.125rem 1.5rem 0.125rem 0.25rem; word-break: break-word; + padding: 0.125rem 1.75rem 0.125rem 0.25rem; +} + +.messageInputRtl { + padding: 0.125rem 0.25rem 0.125rem 1.75rem; } .messageInputEmpty { diff --git a/src/pages/common/components/ChatComponent/ChatComponent.tsx b/src/pages/common/components/ChatComponent/ChatComponent.tsx index 61a68cfa88..be49eb8f92 100644 --- a/src/pages/common/components/ChatComponent/ChatComponent.tsx +++ b/src/pages/common/components/ChatComponent/ChatComponent.tsx @@ -612,6 +612,7 @@ export default function ChatComponent({ className={classNames(styles.messageInput, { [styles.messageInputEmpty]: checkIsTextEditorValueEmpty(message), })} + classNameRtl={styles.messageInputRtl} elementStyles={{ emoji: classNames({ [styles.singleEmojiText]: emojiCount.isSingleEmoji, diff --git a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx index f39724f179..708eb087c2 100644 --- a/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx +++ b/src/pages/common/components/DiscussionFeedCard/DiscussionFeedCard.tsx @@ -124,7 +124,10 @@ const DiscussionFeedCard = forwardRef( fetchFeedItemUserMetadata, } = useFeedItemUserMetadata(); const { data: common } = useCommon(isHome ? commonId : ""); - const feedItemFollow = useFeedItemFollow(item.id, commonId); + const feedItemFollow = useFeedItemFollow( + { feedItemId: item.id, commonId }, + { withSubscription: true }, + ); const menuItems = useMenuItems( { commonId, diff --git a/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx b/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx index 48cb39a700..eda98848e1 100644 --- a/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx +++ b/src/pages/common/components/ProposalFeedCard/ProposalFeedCard.tsx @@ -165,7 +165,10 @@ const ProposalFeedCard = forwardRef( onOpen: onShareModalOpen, onClose: onShareModalClose, } = useModal(false); - const feedItemFollow = useFeedItemFollow(item.id, commonId); + const feedItemFollow = useFeedItemFollow( + { feedItemId: item.id, commonId }, + { withSubscription: true }, + ); const menuItems = useMenuItems( { commonId, diff --git a/src/pages/commonFeed/components/FeedLayout/components/FollowFeedItemButton/FollowFeedItemButton.tsx b/src/pages/commonFeed/components/FeedLayout/components/FollowFeedItemButton/FollowFeedItemButton.tsx index 40739f35e4..2203a87db7 100644 --- a/src/pages/commonFeed/components/FeedLayout/components/FollowFeedItemButton/FollowFeedItemButton.tsx +++ b/src/pages/commonFeed/components/FeedLayout/components/FollowFeedItemButton/FollowFeedItemButton.tsx @@ -12,10 +12,10 @@ interface FollowFeedItemButtonProps { const FollowFeedItemButton: FC = (props) => { const { feedItemId, commonId } = props; - const { isDisabled, isFollowing, onFollowToggle } = useFeedItemFollow( + const { isDisabled, isFollowing, onFollowToggle } = useFeedItemFollow({ feedItemId, commonId, - ); + }); return ( = (props) => { props; const { getCommonPageAboutTabPath } = useRoutesContext(); const isMobileVersion = useIsTabletView(); + const commonFollow = useCommonFollow(common.id, commonMember); const isProject = checkIsProject(common); + const showFollowIcon = commonFollow.isFollowInProgress + ? !commonMember?.isFollowing + : commonMember?.isFollowing; return (
@@ -52,7 +56,10 @@ const HeaderContent: FC = (props) => { />
-

{common.name}

+
+

{common.name}

+ {showFollowIcon && } +

{commonMembersAmount} member{getPluralEnding(commonMembersAmount)}

@@ -66,13 +73,12 @@ const HeaderContent: FC = (props) => { governance={governance} isMobileVersion={isMobileVersion} /> - {!isMobileVersion && ( - - )} +
); diff --git a/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/ActionsButton.tsx b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/ActionsButton.tsx new file mode 100644 index 0000000000..5b189a21ad --- /dev/null +++ b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/ActionsButton.tsx @@ -0,0 +1,52 @@ +import React, { FC } from "react"; +import { ShareModal } from "@/shared/components"; +import { useModal } from "@/shared/hooks"; +import { CommonFollowState } from "@/shared/hooks/useCases/useCommonFollow"; +import { CirclesPermissions, Common, CommonMember } from "@/shared/models"; +import { DesktopMenu, MenuButton } from "@/shared/ui-kit"; +import { StaticLinkType, generateStaticShareLink } from "@/shared/utils"; +import { useMenuItems } from "./hooks"; + +interface ActionsButtonProps { + common: Common; + commonMember: (CommonMember & CirclesPermissions) | null; + commonFollow: CommonFollowState; + isMobileVersion: boolean; +} + +const ActionsButton: FC = (props) => { + const { common, commonMember, commonFollow, isMobileVersion } = props; + const { + isShowing: isShareModalOpen, + onOpen: onShareModalOpen, + onClose: onShareModalClose, + } = useModal(false); + const items = useMenuItems( + { + common, + commonMember, + isMobileVersion, + isFollowInProgress: commonFollow.isFollowInProgress, + }, + { + share: onShareModalOpen, + onFollowToggle: commonFollow.onFollowToggle, + }, + ); + const shareLink = generateStaticShareLink(StaticLinkType.Common, common); + + return ( + <> + {items.length > 0 && ( + } items={items} /> + )} + + + ); +}; + +export default ActionsButton; diff --git a/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/hooks/index.ts b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/hooks/index.ts new file mode 100644 index 0000000000..f0a637aef4 --- /dev/null +++ b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/hooks/index.ts @@ -0,0 +1 @@ +export * from "./useMenuItems"; diff --git a/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/hooks/useMenuItems.tsx b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/hooks/useMenuItems.tsx new file mode 100644 index 0000000000..0e73c10606 --- /dev/null +++ b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/hooks/useMenuItems.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { CommonFollowState } from "@/shared/hooks/useCases"; +import { FollowIcon, Share3Icon, UnfollowIcon } from "@/shared/icons"; +import { MenuItem as Item } from "@/shared/interfaces"; +import { CommonFeedMenuItem } from "../../../../../constants"; +import { + Options as GetAllowedItemsOptions, + getAllowedItems, +} from "../../../../../utils"; + +interface Actions { + share: () => void; + onFollowToggle: CommonFollowState["onFollowToggle"]; +} + +export const useMenuItems = ( + options: GetAllowedItemsOptions, + actions: Actions, +): Item[] => { + const { common } = options; + const { share, onFollowToggle } = actions; + + const items: Item[] = [ + { + id: CommonFeedMenuItem.Share, + text: "Share", + onClick: share, + icon: , + }, + { + id: CommonFeedMenuItem.Follow, + text: `Follow ${common.name}`, + onClick: () => onFollowToggle(), + icon: , + }, + { + id: CommonFeedMenuItem.Mute, + text: `Unfollow ${common.name}`, + onClick: () => onFollowToggle(), + icon: , + }, + ]; + + return getAllowedItems(items, options); +}; diff --git a/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/index.ts b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/index.ts new file mode 100644 index 0000000000..6a3f825881 --- /dev/null +++ b/src/pages/commonFeed/components/HeaderContent/components/ActionsButton/index.ts @@ -0,0 +1 @@ +export { default as ActionsButton } from "./ActionsButton"; diff --git a/src/pages/commonFeed/components/HeaderContent/components/index.ts b/src/pages/commonFeed/components/HeaderContent/components/index.ts index 19ddcfae7e..0f8782fc2a 100644 --- a/src/pages/commonFeed/components/HeaderContent/components/index.ts +++ b/src/pages/commonFeed/components/HeaderContent/components/index.ts @@ -1,2 +1,3 @@ export * from "./NewStreamButton"; export * from "./ShareButton"; +export * from "./ActionsButton"; diff --git a/src/pages/commonFeed/constants/commonFeedMenuItem.ts b/src/pages/commonFeed/constants/commonFeedMenuItem.ts new file mode 100644 index 0000000000..0b4602ee33 --- /dev/null +++ b/src/pages/commonFeed/constants/commonFeedMenuItem.ts @@ -0,0 +1,5 @@ +export enum CommonFeedMenuItem { + Share = "share", + Follow = "follow", + Mute = "mute", +} diff --git a/src/pages/commonFeed/constants/index.ts b/src/pages/commonFeed/constants/index.ts index 0974293fc8..75abf837ba 100644 --- a/src/pages/commonFeed/constants/index.ts +++ b/src/pages/commonFeed/constants/index.ts @@ -1 +1,2 @@ -export const MIN_CHAT_WIDTH = 384; +export * from "./commonFeedMenuItem"; +export * from "./minChatWidth"; diff --git a/src/pages/commonFeed/constants/minChatWidth.ts b/src/pages/commonFeed/constants/minChatWidth.ts new file mode 100644 index 0000000000..0974293fc8 --- /dev/null +++ b/src/pages/commonFeed/constants/minChatWidth.ts @@ -0,0 +1 @@ +export const MIN_CHAT_WIDTH = 384; diff --git a/src/pages/commonFeed/utils/getAllowedItems.ts b/src/pages/commonFeed/utils/getAllowedItems.ts new file mode 100644 index 0000000000..b5a536d6a0 --- /dev/null +++ b/src/pages/commonFeed/utils/getAllowedItems.ts @@ -0,0 +1,24 @@ +import { MenuItem as Item } from "@/shared/interfaces"; +import { CirclesPermissions, Common, CommonMember } from "@/shared/models"; +import { CommonFeedMenuItem } from "../constants"; + +export interface Options { + common: Common; + commonMember: (CommonMember & CirclesPermissions) | null; + isFollowInProgress: boolean; + isMobileVersion: boolean; +} + +const MENU_ITEM_TO_CHECK_FUNCTION_MAP: Record< + CommonFeedMenuItem, + (options: Options) => boolean +> = { + [CommonFeedMenuItem.Share]: ({ isMobileVersion }) => !isMobileVersion, + [CommonFeedMenuItem.Follow]: ({ commonMember, isFollowInProgress }) => + !isFollowInProgress && Boolean(commonMember && !commonMember.isFollowing), + [CommonFeedMenuItem.Mute]: ({ commonMember, isFollowInProgress }) => + !isFollowInProgress && Boolean(commonMember?.isFollowing), +}; + +export const getAllowedItems = (items: Item[], options: Options): Item[] => + items.filter(({ id }) => MENU_ITEM_TO_CHECK_FUNCTION_MAP[id](options)); diff --git a/src/pages/commonFeed/utils/index.ts b/src/pages/commonFeed/utils/index.ts index 7e1db5d415..aced63ae4c 100644 --- a/src/pages/commonFeed/utils/index.ts +++ b/src/pages/commonFeed/utils/index.ts @@ -1,2 +1,3 @@ export * from "./generateSplitViewMaxSizeGetter"; export * from "./getLastMessage"; +export * from "./getAllowedItems"; diff --git a/src/services/Common.ts b/src/services/Common.ts index 925591a04b..d1d7880df7 100644 --- a/src/services/Common.ts +++ b/src/services/Common.ts @@ -4,6 +4,7 @@ import { commonMembersSubCollection } from "@/pages/OldCommon/store/api"; import { store } from "@/shared/appConfig"; import { ApiEndpoint, + DocChange, GovernanceActions, ProposalsTypes, } from "@/shared/constants"; @@ -337,8 +338,8 @@ class CommonService { if (docChange) { callback(docChange.doc.data(), { - isAdded: docChange.type === "added", - isRemoved: docChange.type === "removed", + isAdded: docChange.type === DocChange.Added, + isRemoved: docChange.type === DocChange.Removed, }); } }); @@ -381,6 +382,14 @@ class CommonService { args: { circleId, commonId, userId }, }); }; + + public followCommon = async (commonId: string): Promise => { + await Api.post(ApiEndpoint.FollowCommon, { commonId }); + }; + + public muteCommon = async (commonId: string): Promise => { + await Api.post(ApiEndpoint.MuteCommon, { commonId }); + }; } export default new CommonService(); diff --git a/src/services/FeedItemFollows.ts b/src/services/FeedItemFollows.ts index 0c910b345e..c74bf40d10 100644 --- a/src/services/FeedItemFollows.ts +++ b/src/services/FeedItemFollows.ts @@ -1,4 +1,5 @@ import { ApiEndpoint } from "@/shared/constants"; +import { DocChange } from "@/shared/constants/docChange"; import { UnsubscribeFunction } from "@/shared/interfaces"; import { FollowFeedItemPayload } from "@/shared/interfaces/api"; import { @@ -36,6 +37,37 @@ class FeedItemFollowsService { return snapshot.docs[0]?.data() || null; }; + public subscribeToUserFeedItemFollowData = ( + userId: string, + feedItemId: string, + callback: ( + userFeedItemFollowData: FeedItemFollow | null, + statuses: { + isAdded: boolean; + isRemoved: boolean; + isModified: boolean; + }, + ) => void, + ): UnsubscribeFunction => { + const query = this.getFeedItemFollowsSubCollection(userId).where( + "feedItemId", + "==", + feedItemId, + ); + + return query.onSnapshot((snapshot) => { + const docChange = snapshot.docChanges()[0]; + + if (docChange) { + callback(docChange.doc.data(), { + isAdded: docChange.type === DocChange.Added, + isRemoved: docChange.type === DocChange.Removed, + isModified: docChange.type === DocChange.Modified, + }); + } + }); + }; + public getUserFeedItemFollowDataWithMetadata = async ( userId: string, feedItemId: string, diff --git a/src/shared/components/Chat/ChatMessage/ChatMessage.module.scss b/src/shared/components/Chat/ChatMessage/ChatMessage.module.scss index ee1bf0d5cf..9da9e2e916 100644 --- a/src/shared/components/Chat/ChatMessage/ChatMessage.module.scss +++ b/src/shared/components/Chat/ChatMessage/ChatMessage.module.scss @@ -111,6 +111,11 @@ word-break: break-word; } +.messageContentRtl { + direction: rtl; + text-align: right; +} + .messageContentCurrentUser { color: $white; margin-right: 2.5rem; diff --git a/src/shared/components/Chat/ChatMessage/ChatMessage.tsx b/src/shared/components/Chat/ChatMessage/ChatMessage.tsx index 7cf8c2cd0e..343a2e74c9 100644 --- a/src/shared/components/Chat/ChatMessage/ChatMessage.tsx +++ b/src/shared/components/Chat/ChatMessage/ChatMessage.tsx @@ -40,8 +40,7 @@ import { parseStringToTextEditorValue, } from "@/shared/ui-kit"; import { ChatImageGallery } from "@/shared/ui-kit"; -import { StaticLinkType, isRTL } from "@/shared/utils"; -import { getUserName } from "@/shared/utils"; +import { StaticLinkType, isRtlText, getUserName } from "@/shared/utils"; import { convertBytes } from "@/shared/utils/convertBytes"; import { EditMessageInput } from "../EditMessageInput"; import { ChatMessageLinkify, InternalLinkData, Time } from "./components"; @@ -302,12 +301,14 @@ export default function ChatMessage({ className={classNames( styles.messageContent, styles.replyMessageContent, - { [styles.replyMessageContentCurrentUser]: !isNotCurrentUserMessage, [styles.replyMessageContentWithImage]: image, [styles.replyMessageContentWithFile]: file, + [styles.messageContentRtl]: isRtlText( + discussionMessage?.parentMessage?.text, + ), }, )} > @@ -378,7 +379,7 @@ export default function ChatMessage({ ref={messageRef} className={classNames(styles.messageText, { [styles.messageTextCurrentUser]: !isNotCurrentUserMessage, - [styles.messageTextRtl]: isRTL(discussionMessage.text), + [styles.messageTextRtl]: isRtlText(discussionMessage.text), [styles.messageTextWithReply]: !!discussionMessage.parentMessage?.id, [styles.systemMessage]: isSystemMessage, @@ -398,6 +399,7 @@ export default function ChatMessage({
{filePreview && ( diff --git a/src/shared/constants/docChange.ts b/src/shared/constants/docChange.ts new file mode 100644 index 0000000000..fca22092ed --- /dev/null +++ b/src/shared/constants/docChange.ts @@ -0,0 +1,5 @@ +export enum DocChange { + Added = "added", + Removed = "removed", + Modified = "modified", +} diff --git a/src/shared/constants/endpoint.ts b/src/shared/constants/endpoint.ts index 88af6d488c..8b6eeb46e9 100644 --- a/src/shared/constants/endpoint.ts +++ b/src/shared/constants/endpoint.ts @@ -25,6 +25,8 @@ export const ApiEndpoint = { CreateReport: "/moderation/report", HideContent: "/moderation/hide", ShowContent: "/moderation/show", + FollowCommon: "/commons/follow", + MuteCommon: "/commons/mute", LeaveCommon: "/commons/leave", CreateSubscription: "/commons/immediate-contribution", UpdateSubscription: "/subscriptions/update", diff --git a/src/shared/constants/index.tsx b/src/shared/constants/index.tsx index fa5a9b3c9d..9fdb47aa00 100755 --- a/src/shared/constants/index.tsx +++ b/src/shared/constants/index.tsx @@ -29,3 +29,4 @@ export * from "./viewportBreakpoint"; export * from "./currencyTypes"; export * from "./systemDiscussionMessage"; export * from "./theme"; +export * from "./docChange"; diff --git a/src/shared/hooks/useCases/index.ts b/src/shared/hooks/useCases/index.ts index b83f3aeb77..41dc1342fb 100644 --- a/src/shared/hooks/useCases/index.ts +++ b/src/shared/hooks/useCases/index.ts @@ -35,3 +35,4 @@ export { useDiscussionMessagesById } from "./useDiscussionMessagesById"; export { useUserPendingJoin } from "./useUserPendingJoin"; export { useCommonMemberWithUserInfo } from "./useCommonMemberWithUserInfo"; export { useEligibleVoters } from "./useEligibleVoters"; +export * from "./useCommonFollow"; diff --git a/src/shared/hooks/useCases/useCommonFollow.ts b/src/shared/hooks/useCases/useCommonFollow.ts new file mode 100644 index 0000000000..0d73d2219c --- /dev/null +++ b/src/shared/hooks/useCases/useCommonFollow.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from "react"; +import { useDispatch } from "react-redux"; +import { CommonService, Logger } from "@/services"; +import { CirclesPermissions, CommonMember } from "@/shared/models"; +import { + FollowFeedItemMutationState, + commonFeedFollowsActions, +} from "@/store/states"; + +export interface CommonFollowState { + isFollowInProgress: boolean; + onFollowToggle: () => void; +} + +export function useCommonFollow( + commonId: string, + commonMember: (CommonMember & CirclesPermissions) | null, +): CommonFollowState { + const dispatch = useDispatch(); + const [isFollowInProgress, setIsFollowInProgress] = useState(false); + + const setFeedItemsFollow = ( + isFollowing: boolean, + mutationState: FollowFeedItemMutationState, + ) => { + dispatch( + commonFeedFollowsActions.setFeedItemsFollowStateByCommon({ + commonId, + isFollowing, + mutationState, + }), + ); + }; + + const onFollowToggle = useCallback(async () => { + const { isFollowing = null } = commonMember ?? {}; + + if (isFollowing === null) { + return; + } + + const nextIsFollowing = !isFollowing; + const action = nextIsFollowing + ? CommonService.followCommon + : CommonService.muteCommon; + + try { + setIsFollowInProgress(true); + setFeedItemsFollow(nextIsFollowing, { + isFollowingInProgress: true, + isFollowingFinished: false, + }); + + await action(commonId); + } catch (error) { + setFeedItemsFollow(isFollowing, { + isFollowingInProgress: false, + isFollowingFinished: false, + isFollowingFinishedWithError: true, + }); + setIsFollowInProgress(false); + Logger.error(error); + } + }, [commonId, commonMember?.isFollowing]); + + useEffect(() => { + setIsFollowInProgress(false); + }, [commonId, commonMember]); + + return { + isFollowInProgress, + onFollowToggle, + }; +} diff --git a/src/shared/hooks/useCases/useCommonMemberWithUserInfo.ts b/src/shared/hooks/useCases/useCommonMemberWithUserInfo.ts index e9a89f8681..56757a3193 100644 --- a/src/shared/hooks/useCases/useCommonMemberWithUserInfo.ts +++ b/src/shared/hooks/useCases/useCommonMemberWithUserInfo.ts @@ -58,6 +58,7 @@ export const useCommonMemberWithUserInfo = ( joinedAt: new Timestamp(0, 0), circleIds: [], user: user, + isFollowing: false, streamsUnreadCountByProjectStream: {}, unreadCountByProjectStream: {}, }, @@ -74,6 +75,7 @@ export const useCommonMemberWithUserInfo = ( joinedAt: commonMember.joinedAt, circleIds: [circlesString], user: user, + isFollowing: commonMember.isFollowing, streamsUnreadCountByProjectStream: commonMember.streamsUnreadCountByProjectStream, unreadCountByProjectStream: commonMember.unreadCountByProjectStream, diff --git a/src/shared/hooks/useCases/useFeedItemFollow.ts b/src/shared/hooks/useCases/useFeedItemFollow.ts index df98ecac36..0fc1e02687 100644 --- a/src/shared/hooks/useCases/useFeedItemFollow.ts +++ b/src/shared/hooks/useCases/useFeedItemFollow.ts @@ -17,11 +17,22 @@ export interface FeedItemFollowState { onFollowToggle: (action?: FollowFeedItemAction) => void; } +interface Data { + commonId?: string; + feedItemId?: string; +} + +interface Options { + withSubscription?: boolean; +} + export function useFeedItemFollow( - feedItemId?: string, - commonId?: string, + { commonId, feedItemId }: Data, + { withSubscription }: Options = {}, ): FeedItemFollowState { const dispatch = useDispatch(); + const user = useSelector(selectUser()); + const userId = user?.uid; const follows = useSelector(selectCommonFeedFollows); const isFollowing = feedItemId && commonId ? !!follows[commonId]?.[feedItemId] : false; @@ -30,7 +41,7 @@ export function useFeedItemFollow( data: userFeedItemFollowData, fetchUserFeedItemFollowData, setUserFeedItemFollowData, - } = useUserFeedItemFollowData(); + } = useUserFeedItemFollowData({ feedItemId, userId }, { withSubscription }); const followFeedItemMutationState = useSelector( selectFollowFeedItemMutationState, ); @@ -46,8 +57,6 @@ export function useFeedItemFollow( isFollowingFinished: false, }; - const user = useSelector(selectUser()); - const userId = user?.uid; const isDisabled = !isUserFeedItemFollowDataFetched || isFollowingInProgress; const onFollowToggle = (action?: FollowFeedItemAction) => { @@ -78,15 +87,19 @@ export function useFeedItemFollow( useEffect(() => { if (isUserFeedItemFollowDataFetched && feedItemId && commonId) { + const action = userFeedItemFollowData + ? FollowFeedItemAction.Follow + : FollowFeedItemAction.Unfollow; + dispatch( commonFeedFollowsActions.setFeedItemFollow({ - itemId: feedItemId, - commonId: commonId, - isFollowing: Boolean(userFeedItemFollowData), + feedItemId, + commonId, + action, }), ); } - }, [isUserFeedItemFollowDataFetched]); + }, [isUserFeedItemFollowDataFetched, userFeedItemFollowData]); return { isFollowing, diff --git a/src/shared/hooks/useCases/useUserFeedItemFollowData.ts b/src/shared/hooks/useCases/useUserFeedItemFollowData.ts index fdf23cebb6..87477fca22 100644 --- a/src/shared/hooks/useCases/useUserFeedItemFollowData.ts +++ b/src/shared/hooks/useCases/useUserFeedItemFollowData.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { FeedItemFollowsService } from "@/services"; import { useIsMounted, useLoadingState } from "@/shared/hooks"; import { LoadingState } from "@/shared/interfaces"; @@ -6,12 +6,26 @@ import { FeedItemFollow } from "@/shared/models"; type State = LoadingState; +interface Data { + userId?: string; + feedItemId?: string; +} + +interface Options { + withSubscription?: boolean; +} + interface Return extends State { fetchUserFeedItemFollowData: (userId: string, feedItemId: string) => void; setUserFeedItemFollowData: (data: FeedItemFollow | null) => void; } -export const useUserFeedItemFollowData = (): Return => { +export const useUserFeedItemFollowData = ( + data: Data, + options: Options = {}, +): Return => { + const { feedItemId, userId } = data; + const { withSubscription = false } = options; const isMounted = useIsMounted(); const [state, setState] = useLoadingState(null); @@ -56,6 +70,33 @@ export const useUserFeedItemFollowData = (): Return => { [], ); + useEffect(() => { + if (!withSubscription || !feedItemId || !userId) { + return; + } + + const unsubscribe = + FeedItemFollowsService.subscribeToUserFeedItemFollowData( + userId, + feedItemId, + (userFeedItemFollowData, { isAdded, isModified }) => { + let data: State["data"] = null; + + if (isAdded || isModified) { + data = userFeedItemFollowData; + } + + setState({ + loading: false, + fetched: true, + data, + }); + }, + ); + + return unsubscribe; + }, [withSubscription, feedItemId, userId]); + return { ...state, fetchUserFeedItemFollowData, diff --git a/src/shared/icons/index.ts b/src/shared/icons/index.ts index 0af15350b7..8a80e6c0c3 100644 --- a/src/shared/icons/index.ts +++ b/src/shared/icons/index.ts @@ -27,6 +27,7 @@ export { default as Link3Icon } from "./link3.icon"; export { default as ListMarkIcon } from "./listMark.icon"; export { default as LongLeftArrowIcon } from "./longLeftArrow.icon"; export { default as LTRDirectionMarkIcon } from "./ltrDirection.icon"; +export { default as MenuIcon } from "./menu.icon"; export { default as Menu2Icon } from "./menu2.icon"; export { default as MoreIcon } from "./more.icon"; export { OpenIcon } from "./open.icon"; diff --git a/src/shared/models/Common.tsx b/src/shared/models/Common.tsx index 0d2a561e60..c03ad4ca19 100644 --- a/src/shared/models/Common.tsx +++ b/src/shared/models/Common.tsx @@ -148,6 +148,7 @@ export interface CommonMember { rulesAccepted?: boolean; joinedAt: firebase.firestore.Timestamp; circleIds: string[]; + isFollowing: boolean; streamsUnreadCountByProjectStream?: Record; unreadCountByProjectStream?: Record; } diff --git a/src/shared/ui-kit/MenuButton/MenuButton.module.scss b/src/shared/ui-kit/MenuButton/MenuButton.module.scss new file mode 100644 index 0000000000..4150411fe4 --- /dev/null +++ b/src/shared/ui-kit/MenuButton/MenuButton.module.scss @@ -0,0 +1,7 @@ +@import "../../../constants.scss"; + +.icon { + width: 1.5rem; + height: 1.5rem; + color: $c-gray-800; +} diff --git a/src/shared/ui-kit/MenuButton/MenuButton.tsx b/src/shared/ui-kit/MenuButton/MenuButton.tsx new file mode 100644 index 0000000000..b407060f94 --- /dev/null +++ b/src/shared/ui-kit/MenuButton/MenuButton.tsx @@ -0,0 +1,24 @@ +import React, { FC } from "react"; +import { Orientation } from "@/shared/constants"; +import { MenuIcon } from "@/shared/icons"; +import { ButtonVariant } from "../Button"; +import { ButtonIcon } from "../ButtonIcon"; +import styles from "./MenuButton.module.scss"; + +interface MenuButtonProps { + onClick?: () => void; + variant?: ButtonVariant; +} + +const MenuButton: FC = ({ + onClick, + variant = ButtonVariant.PrimaryGray, +}) => { + return ( + + + + ); +}; + +export default MenuButton; diff --git a/src/shared/ui-kit/MenuButton/index.ts b/src/shared/ui-kit/MenuButton/index.ts new file mode 100644 index 0000000000..18aefc1403 --- /dev/null +++ b/src/shared/ui-kit/MenuButton/index.ts @@ -0,0 +1 @@ +export { default as MenuButton } from "./MenuButton"; diff --git a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx index 734db6ff39..4d80475dbe 100644 --- a/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx +++ b/src/shared/ui-kit/TextEditor/BaseTextEditor.tsx @@ -8,6 +8,7 @@ import React, { useMemo, useState, } from "react"; +import classNames from "classnames"; import { isEqual } from "lodash"; import { createEditor, @@ -20,7 +21,7 @@ import { withHistory } from "slate-history"; import { ReactEditor, Slate, withReact } from "slate-react"; import { KeyboardKeys } from "@/shared/constants/keyboardKeys"; import { User } from "@/shared/models"; -import { getUserName, isMobile } from "@/shared/utils"; +import { getUserName, isMobile, isRtlText } from "@/shared/utils"; import { Editor, MentionDropdown, @@ -39,6 +40,7 @@ import styles from "./BaseTextEditor.module.scss"; export interface TextEditorProps { className?: string; + classNameRtl?: string; emojiContainerClassName?: string; emojiPickerContainerClassName?: string; inputContainerRef?: @@ -74,6 +76,7 @@ const INITIAL_SEARCH_VALUE = { const BaseTextEditor: FC = (props) => { const { className, + classNameRtl, emojiContainerClassName, emojiPickerContainerClassName, editorRef, @@ -106,6 +109,8 @@ const BaseTextEditor: FC = (props) => { const [target, setTarget] = useState(); const [shouldFocusTarget, setShouldFocusTarget] = useState(false); + + const [isRtlLanguage, setIsRtlLanguage] = useState(false); useEffect(() => { if (!shouldReinitializeEditor) { return; @@ -235,10 +240,17 @@ const BaseTextEditor: FC = (props) => { handleSearch(beforeText ?? "", beforeRange); } + + setIsRtlLanguage(isRtlText(EditorSlate.string(editor, []))); }} > = (props) => { elementStyles={elementStyles} /> { - Transforms.select(editor, EditorSlate.end(editor, [])); insertEmoji(editor, emoji.native); }} /> diff --git a/src/shared/ui-kit/TextEditor/components/Editor/Editor.module.scss b/src/shared/ui-kit/TextEditor/components/Editor/Editor.module.scss index 1c68d600ba..2dea5f321f 100644 --- a/src/shared/ui-kit/TextEditor/components/Editor/Editor.module.scss +++ b/src/shared/ui-kit/TextEditor/components/Editor/Editor.module.scss @@ -16,6 +16,8 @@ & [dir="rtl"] { font-family: Heebo, sans-serif; + direction: rtl; + text-align: right; } &:focus { diff --git a/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.module.scss b/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.module.scss index bd55663eda..359df6f96a 100644 --- a/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.module.scss +++ b/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.module.scss @@ -10,6 +10,10 @@ $phone-breakpoint: 415px; transform: translateY(50%); } +.containerRtl { + left: 0.5625rem; +} + .pickerContainer { position: absolute; bottom: 3.25rem; @@ -23,3 +27,7 @@ $phone-breakpoint: 415px; right: -4.5rem; // Picker would be at the right corner of send button icon } } + +.pickerContainerRtl { + left: 0.5625rem; +} diff --git a/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.tsx b/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.tsx index df0fee7dd3..a41db4fa06 100644 --- a/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.tsx +++ b/src/shared/ui-kit/TextEditor/components/EmojiPicker/EmojiPicker.tsx @@ -13,6 +13,7 @@ export interface EmojiPickerProps { onEmojiSelect: (emoji: Skin) => void; isMessageSent?: boolean; onToggleIsMessageSent?: () => void; + isRtl: boolean; } const EmojiPicker: FC = (props) => { @@ -22,6 +23,7 @@ const EmojiPicker: FC = (props) => { onEmojiSelect, isMessageSent, onToggleIsMessageSent, + isRtl, } = props; const [isOpen, setIsOpen] = useState(false); const wrapperRef = useRef(null); @@ -48,14 +50,23 @@ const EmojiPicker: FC = (props) => { return (
{isOpen && ( -
+
)} diff --git a/src/shared/ui-kit/index.ts b/src/shared/ui-kit/index.ts index ba7b1cb14e..44feb899d2 100644 --- a/src/shared/ui-kit/index.ts +++ b/src/shared/ui-kit/index.ts @@ -24,3 +24,4 @@ export * from "./Tooltip"; export * from "./TopNavigation"; export * from "./UploadFiles"; export * from "./FilePreview"; +export * from "./MenuButton"; diff --git a/src/shared/utils/shared.tsx b/src/shared/utils/shared.tsx index aa32f97fdc..f57ffd1c1f 100644 --- a/src/shared/utils/shared.tsx +++ b/src/shared/utils/shared.tsx @@ -200,6 +200,25 @@ export const isRTL = (text = ""): boolean => { return Boolean(text && rtlDirCheck.test(text)); }; +export const isRtlText = (text = ""): boolean => { + for (let i = 0; i < text.length; i++) { + const charCode = text.charCodeAt(i); + + // Hebrew Block + if (charCode >= 0x0590 && charCode <= 0x05ff) return true; + + // Arabic Block + if (charCode >= 0x0600 && charCode <= 0x06ff) return true; + + // Arabic Supplement Block + if (charCode >= 0x0750 && charCode <= 0x077f) return true; + + // Arabic Extended-A Block + if (charCode >= 0x08a0 && charCode <= 0x08ff) return true; + } + return false; +}; + /** * Validate credit card provider (Visa or MasterCard) * Currently only Visa is supported. diff --git a/src/store/states/commonFeedFollows/actions.ts b/src/store/states/commonFeedFollows/actions.ts index 49f518e505..9d97b0b1bd 100644 --- a/src/store/states/commonFeedFollows/actions.ts +++ b/src/store/states/commonFeedFollows/actions.ts @@ -1,6 +1,7 @@ import { createAsyncAction, createStandardAction } from "typesafe-actions"; import { FollowFeedItemPayload } from "@/shared/interfaces/api"; import { CommonFeedFollowsActionType } from "./constants"; +import { FollowFeedItemMutationState } from "./types"; export const followFeedItem = createAsyncAction( CommonFeedFollowsActionType.FOLLOW_FEED_ITEM, @@ -16,8 +17,12 @@ export const followFeedItem = createAsyncAction( export const setFeedItemFollow = createStandardAction( CommonFeedFollowsActionType.SET_FEED_ITEM_FOLLOW, +)(); + +export const setFeedItemsFollowStateByCommon = createStandardAction( + CommonFeedFollowsActionType.SET_FEED_ITEMS_FOLLOW_STATE_BY_COMMON, )<{ - itemId: string; commonId: string; isFollowing: boolean; + mutationState: FollowFeedItemMutationState; }>(); diff --git a/src/store/states/commonFeedFollows/constants.ts b/src/store/states/commonFeedFollows/constants.ts index 7ee0a85446..7c16067ac9 100644 --- a/src/store/states/commonFeedFollows/constants.ts +++ b/src/store/states/commonFeedFollows/constants.ts @@ -4,4 +4,5 @@ export enum CommonFeedFollowsActionType { FOLLOW_FEED_ITEM_FAILURE = "@CommonFeedFollows/FOLLOW_FEED_ITEM_FAILURE", FOLLOW_FEED_ITEM_CANCEL = "@CommonFeedFollows/FOLLOW_FEED_ITEM_CANCEL", SET_FEED_ITEM_FOLLOW = "@CommonFeedFollows/SET_FEED_ITEM_FOLLOW", + SET_FEED_ITEMS_FOLLOW_STATE_BY_COMMON = "@CommonFeedFollows/SET_FEED_ITEMS_FOLLOW_STATE_BY_COMMON", } diff --git a/src/store/states/commonFeedFollows/reducer.ts b/src/store/states/commonFeedFollows/reducer.ts index 70d5becdd2..cc7c125ac9 100644 --- a/src/store/states/commonFeedFollows/reducer.ts +++ b/src/store/states/commonFeedFollows/reducer.ts @@ -1,8 +1,10 @@ import produce from "immer"; +import { WritableDraft } from "immer/dist/internal"; import { ActionType, createReducer } from "typesafe-actions"; import { FollowFeedItemAction } from "@/shared/constants"; +import { FollowFeedItemPayload } from "@/shared/interfaces/api"; import * as actions from "./actions"; -import { CommonFeedFollowsState } from "./types"; +import { CommonFeedFollowsState, FollowFeedItemMutationState } from "./types"; import { getFollowFeedItemMutationId } from "./utils"; type Action = ActionType; @@ -12,23 +14,30 @@ const initialState: CommonFeedFollowsState = { follows: {}, }; +const updateFeedItemFollow = ( + state: WritableDraft, + payload: FollowFeedItemPayload, + mutationState: FollowFeedItemMutationState, +) => { + const mutationId = getFollowFeedItemMutationId( + payload.commonId, + payload.feedItemId, + ); + state.follows[payload.commonId] = state.follows[payload.commonId] || {}; + state.follows[payload.commonId][payload.feedItemId] = + payload.action === FollowFeedItemAction.Follow; + state.followFeedItemMutation[mutationId] = mutationState; +}; + export const reducer = createReducer( initialState, ) .handleAction(actions.followFeedItem.request, (state, action) => produce(state, (nextState) => { - const mutationId = getFollowFeedItemMutationId( - action.payload.commonId, - action.payload.feedItemId, - ); - nextState.follows[action.payload.commonId] = - nextState.follows[action.payload.commonId] || {}; - nextState.follows[action.payload.commonId][action.payload.feedItemId] = - action.payload.action === FollowFeedItemAction.Follow; - nextState.followFeedItemMutation[mutationId] = { + updateFeedItemFollow(nextState, action.payload, { isFollowingInProgress: true, isFollowingFinished: false, - }; + }); }), ) .handleAction(actions.followFeedItem.success, (state, action) => @@ -66,8 +75,32 @@ export const reducer = createReducer( ) .handleAction(actions.setFeedItemFollow, (state, { payload }) => produce(state, (nextState) => { - nextState.follows[payload.commonId] = - nextState.follows[payload.commonId] || {}; - nextState.follows[payload.commonId][payload.itemId] = payload.isFollowing; + updateFeedItemFollow(nextState, payload, { + isFollowingInProgress: false, + isFollowingFinished: true, + }); + }), + ) + .handleAction(actions.setFeedItemsFollowStateByCommon, (state, { payload }) => + produce(state, (nextState) => { + const feedItemIds = Object.keys(nextState.follows[payload.commonId]); + + feedItemIds.forEach((itemId) => { + const mutationId = getFollowFeedItemMutationId( + payload.commonId, + itemId, + ); + const feedItemsFollow = nextState.follows[payload.commonId]; + + if (feedItemsFollow[itemId] === payload.isFollowing) { + return; + } + + feedItemsFollow[itemId] = payload.isFollowing; + nextState.followFeedItemMutation[mutationId] = { + isFollowingInProgress: true, + isFollowingFinished: false, + }; + }); }), );