From 3d5ad40ea72bcff5c15216da0792a1c858d5b900 Mon Sep 17 00:00:00 2001 From: Pedro Tiburcio Date: Thu, 16 Jan 2025 08:14:10 -0500 Subject: [PATCH 1/2] feat: add members to existing group feat: leave chatroom as admin feat: moved group chat list display functionality from CreateGroup into shared component GroupChatMembersList feat: add members to existing group Add member PR fixes Mobile updates PR updates feat: highlight existing member Add members carousel Fix rebase PR fixes --- .../ActivityLogsFragment.graphql.ts | 255 ++++++++++++++++++ .../comments/web/CommentItem/index.tsx | 2 +- .../modules/messages/common/constants.ts | 7 + .../common/graphql/fragments/RoomTitle.ts | 1 + .../modules/messages/common/types.ts | 4 +- .../modules/messages/common/utils.ts | 23 +- .../web/ChatRoom/ChatRoomHeader/index.tsx | 12 +- .../ChatRoomListItem/index.tsx | 3 +- .../messages/web/CreateChatRoomList/index.tsx | 34 +-- .../web/CreateGroup/ConnectionsList/index.tsx | 62 ----- .../web/CreateGroup/ConnectionsList/types.ts | 20 -- .../web/CreateGroup/ProfileCard/types.ts | 9 - .../messages/web/CreateGroup/constants.ts | 28 -- .../messages/web/CreateGroup/index.tsx | 151 ++--------- .../messages/web/CreateGroup/styled.tsx | 7 - .../modules/messages/web/CreateGroup/types.ts | 19 +- .../web/EditGroup/AddMemberCard/index.tsx | 74 +++++ .../web/EditGroup/AddMemberCard/styled.tsx | 16 ++ .../web/EditGroup/AddMemberCard/types.ts | 9 + .../EditGroup/AddMembersDialog/constants.ts | 11 + .../web/EditGroup/AddMembersDialog/index.tsx | 245 +++++++++++++++++ .../web/EditGroup/AddMembersDialog/styled.tsx | 6 + .../web/EditGroup/AddMembersDialog/types.ts | 18 ++ .../EditGroup/AddMembersMobile/constants.ts | 11 + .../web/EditGroup/AddMembersMobile/index.tsx | 206 ++++++++++++++ .../web/EditGroup/AddMembersMobile/styled.tsx | 6 + .../web/EditGroup/AddMembersMobile/types.ts | 17 ++ .../web/EditGroup/AddedMemberCard/index.tsx | 53 ++++ .../web/EditGroup/AddedMemberCard/styled.tsx | 13 + .../web/EditGroup/AddedMemberCard/types.ts | 6 + .../messages/web/EditGroup/constants.ts | 28 +- .../modules/messages/web/EditGroup/index.tsx | 86 +++++- .../modules/messages/web/EditGroup/types.ts | 20 +- .../web/GroupDetails/ProfileCard/constants.ts | 10 - .../web/GroupDetails/ProfileCard/index.tsx | 2 +- .../messages/web/GroupDetails/index.tsx | 20 +- .../MessagesGroup/UserMessage/types.ts | 2 +- .../web/MessagesList/MessagesGroup/types.ts | 2 +- .../ProfileCard/index.tsx | 14 +- .../ProfileCard}/styled.tsx | 0 .../GroupChatMembersList/ProfileCard/types.ts | 8 + .../ProfilesList/index.tsx | 94 +++++++ .../ProfilesList}/styled.tsx | 0 .../ProfilesList/types.ts | 25 ++ .../__shared__/GroupChatMembersList/index.tsx | 136 ++++++++++ .../GroupChatMembersList/styled.tsx | 18 ++ .../__shared__/GroupChatMembersList/types.ts | 38 +++ .../__shared__/LeaveGroupDialog/constants.ts | 36 +++ .../web/__shared__/LeaveGroupDialog/index.tsx | 44 ++- .../web/__shared__/LeaveGroupDialog/types.ts | 14 +- .../messages/web/__shared__/constants.ts | 36 +++ .../modules/messages/web/__shared__/types.ts | 21 ++ .../common/graphql/queries/AllProfilesList.ts | 2 +- .../__storybook__/AvatarButton.mdx | 52 ++++ .../AvatarButton/__storybook__/stories.tsx | 42 +++ .../web/buttons/AvatarButton/index.tsx | 37 +++ .../web/buttons/AvatarButton/styled.tsx | 13 + .../web/buttons/AvatarButton/types.ts | 10 + .../components/web/buttons/index.ts | 3 + .../web/dialogs/ConfirmDialog/index.tsx | 9 +- .../web/dialogs/ConfirmDialog/types.ts | 5 + .../web/icons/AddMemberIcon/index.tsx | 42 +++ .../web/icons/FilledCloseIcon/index.tsx | 18 ++ .../web/icons/NewGroupIcon/index.tsx | 48 ++++ .../components/web/icons/index.ts | 3 + 65 files changed, 1886 insertions(+), 380 deletions(-) create mode 100644 packages/components/__generated__/ActivityLogsFragment.graphql.ts delete mode 100644 packages/components/modules/messages/web/CreateGroup/ConnectionsList/index.tsx delete mode 100644 packages/components/modules/messages/web/CreateGroup/ConnectionsList/types.ts delete mode 100644 packages/components/modules/messages/web/CreateGroup/ProfileCard/types.ts delete mode 100644 packages/components/modules/messages/web/CreateGroup/constants.ts create mode 100644 packages/components/modules/messages/web/EditGroup/AddMemberCard/index.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddMemberCard/styled.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddMemberCard/types.ts create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersDialog/constants.ts create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersDialog/index.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersDialog/styled.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersDialog/types.ts create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersMobile/constants.ts create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersMobile/index.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersMobile/styled.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddMembersMobile/types.ts create mode 100644 packages/components/modules/messages/web/EditGroup/AddedMemberCard/index.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddedMemberCard/styled.tsx create mode 100644 packages/components/modules/messages/web/EditGroup/AddedMemberCard/types.ts delete mode 100644 packages/components/modules/messages/web/GroupDetails/ProfileCard/constants.ts rename packages/components/modules/messages/web/{CreateGroup => __shared__/GroupChatMembersList}/ProfileCard/index.tsx (78%) rename packages/components/modules/messages/web/{CreateGroup/ConnectionsList => __shared__/GroupChatMembersList/ProfileCard}/styled.tsx (100%) create mode 100644 packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfileCard/types.ts create mode 100644 packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/index.tsx rename packages/components/modules/messages/web/{CreateGroup/ProfileCard => __shared__/GroupChatMembersList/ProfilesList}/styled.tsx (100%) create mode 100644 packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/types.ts create mode 100644 packages/components/modules/messages/web/__shared__/GroupChatMembersList/index.tsx create mode 100644 packages/components/modules/messages/web/__shared__/GroupChatMembersList/styled.tsx create mode 100644 packages/components/modules/messages/web/__shared__/GroupChatMembersList/types.ts create mode 100644 packages/components/modules/messages/web/__shared__/LeaveGroupDialog/constants.ts create mode 100644 packages/components/modules/messages/web/__shared__/constants.ts create mode 100644 packages/design-system/components/web/buttons/AvatarButton/__storybook__/AvatarButton.mdx create mode 100644 packages/design-system/components/web/buttons/AvatarButton/__storybook__/stories.tsx create mode 100644 packages/design-system/components/web/buttons/AvatarButton/index.tsx create mode 100644 packages/design-system/components/web/buttons/AvatarButton/styled.tsx create mode 100644 packages/design-system/components/web/buttons/AvatarButton/types.ts create mode 100644 packages/design-system/components/web/icons/AddMemberIcon/index.tsx create mode 100644 packages/design-system/components/web/icons/FilledCloseIcon/index.tsx create mode 100644 packages/design-system/components/web/icons/NewGroupIcon/index.tsx diff --git a/packages/components/__generated__/ActivityLogsFragment.graphql.ts b/packages/components/__generated__/ActivityLogsFragment.graphql.ts new file mode 100644 index 00000000..a59e7256 --- /dev/null +++ b/packages/components/__generated__/ActivityLogsFragment.graphql.ts @@ -0,0 +1,255 @@ +/** + * @generated SignedSource<> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ReaderFragment, RefetchableFragment } from 'relay-runtime'; +import { FragmentRefs } from "relay-runtime"; +export type ActivityLogsFragment$data = { + readonly activityLogs: { + readonly edges: ReadonlyArray<{ + readonly node: { + readonly createdAt: any; + readonly id: string; + readonly url: string | null | undefined; + readonly user: { + readonly avatar: { + readonly url: string; + } | null | undefined; + readonly email: string | null | undefined; + readonly fullName: string | null | undefined; + readonly id: string; + } | null | undefined; + readonly verb: string | null | undefined; + } | null | undefined; + } | null | undefined>; + readonly pageInfo: { + readonly endCursor: string | null | undefined; + readonly hasNextPage: boolean; + }; + } | null | undefined; + readonly " $fragmentType": "ActivityLogsFragment"; +}; +export type ActivityLogsFragment$key = { + readonly " $data"?: ActivityLogsFragment$data; + readonly " $fragmentSpreads": FragmentRefs<"ActivityLogsFragment">; +}; + +const node: ReaderFragment = (function(){ +var v0 = [ + "activityLogs" +], +v1 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null +}, +v2 = { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "url", + "storageKey": null +}; +return { + "argumentDefinitions": [ + { + "defaultValue": 10, + "kind": "LocalArgument", + "name": "count" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "cursor" + }, + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "userName" + } + ], + "kind": "Fragment", + "metadata": { + "connection": [ + { + "count": "count", + "cursor": "cursor", + "direction": "forward", + "path": (v0/*: any*/) + } + ], + "refetch": { + "connection": { + "forward": { + "count": "count", + "cursor": "cursor" + }, + "backward": null, + "path": (v0/*: any*/) + }, + "fragmentPathInResult": [], + "operation": require('./ActivityLogsPaginationQuery.graphql') + } + }, + "name": "ActivityLogsFragment", + "selections": [ + { + "alias": "activityLogs", + "args": [ + { + "kind": "Variable", + "name": "userName", + "variableName": "userName" + } + ], + "concreteType": "ActivityLogConnection", + "kind": "LinkedField", + "name": "__ActivityLogs_activityLogs_connection", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ActivityLogEdge", + "kind": "LinkedField", + "name": "edges", + "plural": true, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "ActivityLog", + "kind": "LinkedField", + "name": "node", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "createdAt", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "verb", + "storageKey": null + }, + (v2/*: any*/), + { + "alias": null, + "args": null, + "concreteType": "User", + "kind": "LinkedField", + "name": "user", + "plural": false, + "selections": [ + (v1/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "fullName", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "email", + "storageKey": null + }, + { + "alias": null, + "args": [ + { + "kind": "Literal", + "name": "height", + "value": 48 + }, + { + "kind": "Literal", + "name": "width", + "value": 48 + } + ], + "concreteType": "File", + "kind": "LinkedField", + "name": "avatar", + "plural": false, + "selections": [ + (v2/*: any*/) + ], + "storageKey": "avatar(height:48,width:48)" + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "__typename", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "cursor", + "storageKey": null + } + ], + "storageKey": null + }, + { + "alias": null, + "args": null, + "concreteType": "PageInfo", + "kind": "LinkedField", + "name": "pageInfo", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "endCursor", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "hasNextPage", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } + ], + "type": "Query", + "abstractKey": null +}; +})(); + +(node as any).hash = "89365a3b6109a48bbc293c75f9d7174a"; + +export default node; diff --git a/packages/components/modules/comments/web/CommentItem/index.tsx b/packages/components/modules/comments/web/CommentItem/index.tsx index c5829e81..05078511 100644 --- a/packages/components/modules/comments/web/CommentItem/index.tsx +++ b/packages/components/modules/comments/web/CommentItem/index.tsx @@ -2,10 +2,10 @@ import { FC, useRef, useState, useTransition } from 'react' -import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' import { Typography } from '@mui/material' import { useRefetchableFragment } from 'react-relay' +import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' import { CommentItemRefetchQuery } from '../../../../__generated__/CommentItemRefetchQuery.graphql' import { CommentItem_comment$key } from '../../../../__generated__/CommentItem_comment.graphql' diff --git a/packages/components/modules/messages/common/constants.ts b/packages/components/modules/messages/common/constants.ts index dc9c96cf..af93bb65 100644 --- a/packages/components/modules/messages/common/constants.ts +++ b/packages/components/modules/messages/common/constants.ts @@ -4,3 +4,10 @@ export const MESSAGE_TYPE = { user: 'USER_MESSAGE', system: 'SYSTEM_GENERATED', } as const + +export const CHAT_ROOM_PARTICIPANT_ROLES = { + admin: 'ADMIN', + member: 'MEMBER', +} as const + +export const ADMIN_LABEL = 'Admin' diff --git a/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts b/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts index ff0db626..3afb5001 100644 --- a/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts +++ b/packages/components/modules/messages/common/graphql/fragments/RoomTitle.ts @@ -14,6 +14,7 @@ export const RoomTitleFragment = graphql` url } } + role } } } diff --git a/packages/components/modules/messages/common/types.ts b/packages/components/modules/messages/common/types.ts index fa1c3c3d..d92918a5 100644 --- a/packages/components/modules/messages/common/types.ts +++ b/packages/components/modules/messages/common/types.ts @@ -1,10 +1,12 @@ import { ValueOf } from '@baseapp-frontend/utils' import { MessagesListFragment$data } from '../../../__generated__/MessagesListFragment.graphql' -import { MESSAGE_TYPE } from './constants' +import { CHAT_ROOM_PARTICIPANT_ROLES, MESSAGE_TYPE } from './constants' export type AllMessages = NonNullable export type MessageEdges = AllMessages['edges'] export type MessageNode = NonNullable['node'] export type MessageTypeOptions = ValueOf + +export type ChatRoomParticipantRoles = ValueOf diff --git a/packages/components/modules/messages/common/utils.ts b/packages/components/modules/messages/common/utils.ts index c663880b..d0c80901 100644 --- a/packages/components/modules/messages/common/utils.ts +++ b/packages/components/modules/messages/common/utils.ts @@ -5,13 +5,20 @@ import ConnectionHandler from 'relay-connection-handler-plus' import { RecordProxy, RecordSourceSelectorProxy, Variables } from 'relay-runtime' import { GroupTitleFragment$key } from '../../../__generated__/GroupTitleFragment.graphql' +import { MembersListFragment$data } from '../../../__generated__/MembersListFragment.graphql' import { RoomTitleFragment$key } from '../../../__generated__/RoomTitleFragment.graphql' import { TitleFragment$data } from '../../../__generated__/TitleFragment.graphql' +import { CHAT_ROOM_PARTICIPANT_ROLES } from './constants' import { GroupTitleFragment } from './graphql/fragments/GroupTitle' import { RoomTitleFragment } from './graphql/fragments/RoomTitle' -export const useGroupNameAndAvatar = (headerRef: GroupTitleFragment$key | null | undefined) => { - const header = useFragment(GroupTitleFragment, headerRef) +export const useGroupNameAndAvatar = ( + headerRef: GroupTitleFragment$key | RoomTitleFragment$key | null | undefined, +) => { + const header = useFragment( + GroupTitleFragment, + headerRef as GroupTitleFragment$key, + ) return { title: header?.title, avatar: header?.image?.url, @@ -26,6 +33,7 @@ const useRoomNameAndAvatar = (headerRef: RoomTitleFragment$key | null | undefine title: 'Error: No participants', } } + const otherParticipant = header.participants.edges.find( (edge) => edge?.node?.profile?.id && edge?.node?.profile?.id !== currentProfile?.id, ) @@ -60,3 +68,14 @@ export const getChatRoomConnections: ( } return [] } + +export const useCheckIsAdmin = (participants: MembersListFragment$data['participants']) => { + const { currentProfile } = useCurrentProfile() + const me = participants?.edges?.find((edge) => edge?.node?.profile?.id === currentProfile?.id) + const isAdmin = me?.node?.role === CHAT_ROOM_PARTICIPANT_ROLES.admin + const isSoleAdmin = + isAdmin && + participants?.edges?.filter((edge) => edge?.node?.role === CHAT_ROOM_PARTICIPANT_ROLES.admin) + .length === 1 + return { isAdmin, isSoleAdmin } +} diff --git a/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx b/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx index aa3e1822..279744e1 100644 --- a/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx +++ b/packages/components/modules/messages/web/ChatRoom/ChatRoomHeader/index.tsx @@ -13,13 +13,17 @@ import { useResponsive } from '@baseapp-frontend/design-system/hooks/web' import { Box, Typography } from '@mui/material' import { useFragment } from 'react-relay' +import { MembersListFragment$data } from '../../../../../__generated__/MembersListFragment.graphql' +import { RoomTitleFragment$key } from '../../../../../__generated__/RoomTitleFragment.graphql' import { TitleFragment, getParticipantCountString, useArchiveChatRoomMutation, useChatRoom, + useCheckIsAdmin, useNameAndAvatar, } from '../../../common' +import { RoomTitleFragment } from '../../../common/graphql/fragments/RoomTitle' import LeaveGroupDialog from '../../__shared__/LeaveGroupDialog' import ChatRoomOptions from './ChatRoomOptions' import { BackButtonContainer, ChatHeaderContainer, ChatTitleContainer } from './styled' @@ -34,15 +38,17 @@ const ChatRoomHeader: FC = ({ }) => { const roomHeader = useFragment(TitleFragment, roomTitleRef) const [open, setOpen] = useState(false) + const { currentProfile } = useCurrentProfile() const isUpToMd = useResponsive('up', 'md') const { resetChatRoom } = useChatRoom() const { isGroup } = roomHeader const { title, avatar } = useNameAndAvatar(roomHeader) + const { participants } = useFragment(RoomTitleFragment, roomHeader) + const { isSoleAdmin } = useCheckIsAdmin(participants as MembersListFragment$data['participants']) const members = getParticipantCountString(participantsCount) const popover = usePopover() - const { currentProfile } = useCurrentProfile() const [commit, isMutationInFlight] = useArchiveChatRoomMutation() const toggleArchiveChatroom = () => { @@ -70,8 +76,10 @@ const ChatRoomHeader: FC = ({ setOpen(false)} - profileId={currentProfile?.id} + profileId={currentProfile?.id ?? ''} roomId={roomId} + removingParticipantId={currentProfile?.id ?? ''} + isSoleAdmin={isSoleAdmin} /> {isUpToMd ? ( diff --git a/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx b/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx index 6ef3afd7..85925c68 100644 --- a/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx +++ b/packages/components/modules/messages/web/CreateChatRoomList/ChatRoomListItem/index.tsx @@ -4,6 +4,7 @@ import { FC } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' +import { TypographyWithEllipsis } from '@baseapp-frontend/design-system/components/web/typographies' import { LoadingButton } from '@mui/lab' import { Box, Typography } from '@mui/material' @@ -29,7 +30,7 @@ const ChatRoomListItem: FC = ({ profile: profileRef, onCh sx={{ alignSelf: 'center', justifySelf: 'center' }} /> - {name} + {name} {urlPath?.path && `@${urlPath.path}`} diff --git a/packages/components/modules/messages/web/CreateChatRoomList/index.tsx b/packages/components/modules/messages/web/CreateChatRoomList/index.tsx index 3816785f..2ffb6352 100644 --- a/packages/components/modules/messages/web/CreateChatRoomList/index.tsx +++ b/packages/components/modules/messages/web/CreateChatRoomList/index.tsx @@ -3,12 +3,12 @@ import { ChangeEventHandler, FC, useMemo, useTransition } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' -import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' +import { AvatarButton } from '@baseapp-frontend/design-system/components/web/buttons' import { LoadingState } from '@baseapp-frontend/design-system/components/web/displays' +import { NewGroupIcon } from '@baseapp-frontend/design-system/components/web/icons' import { Searchbar as DefaultSearchbar } from '@baseapp-frontend/design-system/components/web/inputs' -import { Box, Typography } from '@mui/material' -import Image from 'next/image' +import { Box } from '@mui/material' import { useForm } from 'react-hook-form' import { Virtuoso } from 'react-virtuoso' @@ -16,7 +16,7 @@ import { SearchNotFoundState } from '../../../__shared__/web' import { ProfileEdge, ProfileNode, useAllProfilesList } from '../../../profiles/common' import EmptyProfilesListState from '../__shared__/EmptyProfilesListState' import DefaultChatRoomListItem from './ChatRoomListItem' -import { GroupChatContainer, MainContainer, SearchbarContainer } from './styled' +import { MainContainer, SearchbarContainer } from './styled' import { CreateChatRoomListProps } from './types' const CreateChatRoomList: FC = ({ @@ -126,27 +126,11 @@ const CreateChatRoomList: FC = ({ {...SearchbarProps} /> - - - Avatar Group Fallback - - - New Group - - + {renderListContent()} ) diff --git a/packages/components/modules/messages/web/CreateGroup/ConnectionsList/index.tsx b/packages/components/modules/messages/web/CreateGroup/ConnectionsList/index.tsx deleted file mode 100644 index 2228e978..00000000 --- a/packages/components/modules/messages/web/CreateGroup/ConnectionsList/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -'use client' - -import { FC } from 'react' - -import { LoadingState } from '@baseapp-frontend/design-system/components/web/displays' - -import { Box } from '@mui/material' -import { Virtuoso } from 'react-virtuoso' - -import { SearchNotFoundState as DefaultSearchNotFoundState } from '../../../../__shared__/web' -import DefaultEmptyProfilesListState from '../../__shared__/EmptyProfilesListState' -import { ConnectionsListProps } from './types' - -const ConnectionsList: FC = ({ - searchValue, - profiles = [], - isPending, - isLoadingNext, - hasNext, - loadNext, - renderItem, - VirtuosoProps = {}, - EmptyProfilesListState = DefaultEmptyProfilesListState, - SearchNotFoundState = DefaultSearchNotFoundState, -}) => { - const renderLoadingState = () => { - if (!isLoadingNext) return - - return ( - - ) - } - - const emptyProfilesList = profiles.length === 0 - - if (!isPending && searchValue && emptyProfilesList) return - - if (!isPending && emptyProfilesList) return - - return ( - renderItem(item)} - style={{ scrollbarWidth: 'none', maxHeight: '250px' }} - components={{ - Footer: renderLoadingState, - }} - endReached={() => { - if (hasNext) { - loadNext(5) - } - }} - {...VirtuosoProps} - /> - ) -} - -export default ConnectionsList diff --git a/packages/components/modules/messages/web/CreateGroup/ConnectionsList/types.ts b/packages/components/modules/messages/web/CreateGroup/ConnectionsList/types.ts deleted file mode 100644 index e206a39f..00000000 --- a/packages/components/modules/messages/web/CreateGroup/ConnectionsList/types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { FC } from 'react' - -import { LoadMoreFn } from 'react-relay' -import { VirtuosoProps } from 'react-virtuoso' - -import { AllProfilesListPaginationQuery } from '../../../../../__generated__/AllProfilesListPaginationQuery.graphql' -import { ProfileNode } from '../../../../profiles/common' - -export interface ConnectionsListProps { - VirtuosoProps?: Partial> - searchValue?: string | null - profiles?: ProfileNode[] - isPending: boolean - isLoadingNext: boolean - hasNext: boolean - loadNext: LoadMoreFn - renderItem: (profile: ProfileNode, isMember?: boolean) => JSX.Element | null - SearchNotFoundState?: FC - EmptyProfilesListState?: FC -} diff --git a/packages/components/modules/messages/web/CreateGroup/ProfileCard/types.ts b/packages/components/modules/messages/web/CreateGroup/ProfileCard/types.ts deleted file mode 100644 index 41e9e1d0..00000000 --- a/packages/components/modules/messages/web/CreateGroup/ProfileCard/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileItemFragment.graphql' - -export interface ProfileCardProps { - profile: ProfileItemFragment$key - // TODO: type this better - handleAddMember: (profile: any) => void - handleRemoveMember: (profile: any) => void - isMember?: boolean -} diff --git a/packages/components/modules/messages/web/CreateGroup/constants.ts b/packages/components/modules/messages/web/CreateGroup/constants.ts deleted file mode 100644 index 8af8e012..00000000 --- a/packages/components/modules/messages/web/CreateGroup/constants.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { z } from 'zod' - -import { TitleAndImage } from '../__shared__/types' -import { CreateGroupUpload } from './types' - -export const FORM_VALUE: Record & - Record = { - title: 'title', - participants: 'participants', - image: 'image', -} - -export const DEFAULT_FORM_VALUES: CreateGroupUpload = { - title: '', - participants: [], - image: '', -} - -export const DEFAULT_FORM_VALIDATION = z.object({ - [FORM_VALUE.title]: z - .string() - .min(1, { message: 'Please enter a title' }) - .max(20, { message: "Title can't be more than 20 characters" }), - [FORM_VALUE.participants]: z - .array(z.any()) - .min(1, { message: 'Please select at least one member' }), - [FORM_VALUE.image]: z.any(), -}) diff --git a/packages/components/modules/messages/web/CreateGroup/index.tsx b/packages/components/modules/messages/web/CreateGroup/index.tsx index e0bbfcfd..153204c0 100644 --- a/packages/components/modules/messages/web/CreateGroup/index.tsx +++ b/packages/components/modules/messages/web/CreateGroup/index.tsx @@ -1,39 +1,37 @@ 'use client' -import { ChangeEventHandler, FC, useMemo, useTransition } from 'react' +import { FC, useMemo } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' import { CheckMarkIcon, CloseIcon } from '@baseapp-frontend/design-system/components/web/icons' -import { Searchbar as DefaultSearchbar } from '@baseapp-frontend/design-system/components/web/inputs' import { filterDirtyValues, setFormRelayErrors, useNotification } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' -import { Box, Typography, useTheme } from '@mui/material' +import { Box, Typography } from '@mui/material' import { useForm } from 'react-hook-form' import { ConnectionHandler } from 'relay-runtime' import { ProfileNode, useAllProfilesList } from '../../../profiles/common' import { useChatRoom, useCreateChatRoomMutation } from '../../common' import EditGroupTitleAndImage from '../__shared__/EditGroupTitleAndImage' -import DefaultConnectionsList from './ConnectionsList' -import DefaultProfileCard from './ProfileCard' -import { DEFAULT_FORM_VALIDATION, DEFAULT_FORM_VALUES, FORM_VALUE } from './constants' -import { HeaderContainer, ProfilesContainer, SearchbarContainer } from './styled' -import { CreateGroupProps, CreateGroupUpload } from './types' +import DefaultGroupChatMembersList from '../__shared__/GroupChatMembersList' +import { + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION as DEFAULT_FORM_VALIDATION, + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALUE as DEFAULT_FORM_VALUES, + CREATE_OR_EDIT_GROUP_FORM_VALUE as FORM_VALUE, +} from '../__shared__/constants' +import { CreateOrEditGroup } from '../__shared__/types' +import { HeaderContainer, ProfilesContainer } from './styled' +import { CreateGroupProps } from './types' const CreateGroup: FC = ({ allProfilesRef, - ProfileCard = DefaultProfileCard, - ProfileCardProps = {}, - Searchbar = DefaultSearchbar, - SearchbarProps = {}, - ConnectionsList = DefaultConnectionsList, - ConnectionsListProps = {}, + GroupChatMembersList = DefaultGroupChatMembersList, + GroupChatMembersListProps = {}, onValidSubmission, onBackButtonClicked, }) => { - const theme = useTheme() const { sendToast } = useNotification() const { data: { allProfiles }, @@ -42,12 +40,6 @@ const CreateGroup: FC = ({ hasNext, refetch, } = useAllProfilesList(allProfilesRef) - const [isPending, startTransition] = useTransition() - const { - control: searchControl, - reset: searchReset, - watch: searchWatch, - } = useForm({ defaultValues: { search: '' } }) const formReturn = useForm({ defaultValues: DEFAULT_FORM_VALUES, @@ -71,10 +63,10 @@ const CreateGroup: FC = ({ const [commit, isMutationInFlight] = useCreateChatRoomMutation() const { setChatRoom } = useChatRoom() - const onSubmit = handleSubmit((data: CreateGroupUpload) => { + const onSubmit = handleSubmit((data: CreateOrEditGroup) => { const dirtyValues = filterDirtyValues({ values: data, dirtyFields }) const { title, participants, image } = data - const participantsIds = (participants || []).map((member) => member.id) + const participantsIds = (participants || []).map((member: ProfileNode) => member?.id) const uploadables: { image?: File | Blob } = {} if (FORM_VALUE.image in dirtyValues && image && typeof image !== 'string') { uploadables.image = image @@ -110,20 +102,6 @@ const CreateGroup: FC = ({ }) }) - const handleSearchChange: ChangeEventHandler = (e) => { - const value = e.target.value || '' - startTransition(() => { - refetch({ q: value }) - }) - } - - const handleSearchClear = () => { - startTransition(() => { - searchReset() - refetch({ q: '' }) - }) - } - const participants = watch(FORM_VALUE.participants) as ProfileNode[] const profiles = useMemo( @@ -133,45 +111,12 @@ const CreateGroup: FC = ({ (edge) => edge?.node && edge?.node.id !== currentProfile?.id && - !participants.some((member) => member?.id === edge?.node?.id), + !participants.some((member: ProfileNode) => member?.id === edge?.node?.id), ) .map((edge) => edge?.node) || [], [allProfiles, participants, currentProfile?.id], ) - const handleAddMember = (profile: ProfileNode) => { - setValue(FORM_VALUE.participants, [...participants, profile], { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }) - } - - const handleRemoveMember = (profile: ProfileNode) => { - setValue( - FORM_VALUE.participants, - participants.filter((member) => member?.id !== profile?.id), - { - shouldValidate: true, - shouldDirty: true, - shouldTouch: true, - }, - ) - } - - const renderItem = (profile: ProfileNode, isMember = false) => { - if (!profile) return null - return ( - - ) - } - const handleRemoveImage = () => { clearErrors(FORM_VALUE.image) setValue(FORM_VALUE.image, null, { @@ -214,57 +159,19 @@ const CreateGroup: FC = ({ watch={watch} /> - - - - - - - - Members - - - {participants.map((member) => renderItem(member, true))} - - - - - Connections - - - - - + ) } diff --git a/packages/components/modules/messages/web/CreateGroup/styled.tsx b/packages/components/modules/messages/web/CreateGroup/styled.tsx index 6c1a1343..b93a1f29 100644 --- a/packages/components/modules/messages/web/CreateGroup/styled.tsx +++ b/packages/components/modules/messages/web/CreateGroup/styled.tsx @@ -1,13 +1,6 @@ import { Box } from '@mui/material' import { styled } from '@mui/material/styles' -export const SearchbarContainer = styled(Box)(({ theme }) => ({ - padding: `${theme.spacing(2)} ${theme.spacing(2.5)} 0`, - [theme.breakpoints.down('sm')]: { - padding: `${theme.spacing(2)} ${theme.spacing(1.5)} 0`, - }, -})) - export const HeaderContainer = styled(Box)(({ theme }) => ({ display: 'grid', gridTemplateColumns: '24px auto 24px', diff --git a/packages/components/modules/messages/web/CreateGroup/types.ts b/packages/components/modules/messages/web/CreateGroup/types.ts index cd4e9771..012facec 100644 --- a/packages/components/modules/messages/web/CreateGroup/types.ts +++ b/packages/components/modules/messages/web/CreateGroup/types.ts @@ -1,25 +1,12 @@ import { FC, PropsWithChildren } from 'react' -import { SearchbarProps } from '@baseapp-frontend/design-system/components/web/inputs' - +import { GroupChatMembersListProps } from '../__shared__/GroupChatMembersList/types' import { ChatRoomsQuery$data } from '../../../../__generated__/ChatRoomsQuery.graphql' -import { ConnectionsListProps } from './ConnectionsList/types' -import { ProfileCardProps } from './ProfileCard/types' export interface CreateGroupProps extends PropsWithChildren { allProfilesRef: ChatRoomsQuery$data - Searchbar?: FC - SearchbarProps?: Partial - ProfileCard?: FC - ProfileCardProps?: Partial - ConnectionsList?: FC - ConnectionsListProps?: Partial + GroupChatMembersList?: FC + GroupChatMembersListProps?: Partial onValidSubmission: () => void onBackButtonClicked: () => void } - -export interface CreateGroupUpload { - title: string - participants?: any[] - image?: string | File | Blob | null -} diff --git a/packages/components/modules/messages/web/EditGroup/AddMemberCard/index.tsx b/packages/components/modules/messages/web/EditGroup/AddMemberCard/index.tsx new file mode 100644 index 00000000..83dc10a4 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMemberCard/index.tsx @@ -0,0 +1,74 @@ +'use client' + +import { FC } from 'react' + +import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' + +import { Box, Checkbox, Typography } from '@mui/material' +import { useFragment } from 'react-relay' + +import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileItemFragment.graphql' +import { ProfileItemFragment } from '../../../../profiles/common' +import { MainContainer } from './styled' +import { AddMemberCardProps } from './types' + +const AddMemberCard: FC = ({ + profile, + handleAddMember, + handleRemoveMember, + isBeingAdded = false, + isExistingMember = false, +}) => { + const { id, image, name, urlPath } = useFragment( + ProfileItemFragment, + profile as ProfileItemFragment$key, + ) + + const handleCheckboxChange = (event: React.ChangeEvent) => { + const { target } = event + if (target.checked) { + handleAddMember(profile) + } else { + handleRemoveMember(profile) + } + } + + const getCaptionText = () => { + if (isExistingMember) { + return 'Already added to the group' + } + if (urlPath?.path) { + return `@${urlPath.path}` + } + return '' + } + + return ( + + + + + {name} + + + {getCaptionText()} + + + {!isExistingMember && } + + ) +} + +export default AddMemberCard diff --git a/packages/components/modules/messages/web/EditGroup/AddMemberCard/styled.tsx b/packages/components/modules/messages/web/EditGroup/AddMemberCard/styled.tsx new file mode 100644 index 00000000..702324ab --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMemberCard/styled.tsx @@ -0,0 +1,16 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const MainContainer = styled(Box)(({ theme }) => ({ + alignItems: 'center', + display: 'grid', + width: '100%', + height: '100%', + gridTemplateColumns: '48px auto min-content', + gap: theme.spacing(1.5), + padding: theme.spacing(1.5), + [theme.breakpoints.down('sm')]: { + maxWidth: '600px', + padding: `${theme.spacing(1.5)} ${theme.spacing(1.5)}`, + }, +})) diff --git a/packages/components/modules/messages/web/EditGroup/AddMemberCard/types.ts b/packages/components/modules/messages/web/EditGroup/AddMemberCard/types.ts new file mode 100644 index 00000000..5b8793af --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMemberCard/types.ts @@ -0,0 +1,9 @@ +import { ProfileNode } from '../../../../profiles/common' + +export interface AddMemberCardProps { + profile: ProfileNode + handleAddMember: (profile: ProfileNode) => void + handleRemoveMember: (profile: ProfileNode) => void + isBeingAdded: boolean + isExistingMember: boolean +} diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersDialog/constants.ts b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/constants.ts new file mode 100644 index 00000000..0edda502 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/constants.ts @@ -0,0 +1,11 @@ +import z from 'zod' + +import { + CREATE_OR_EDIT_GROUP_FORM_VALUE, + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION, +} from '../../__shared__/constants' + +export const DEFAULT_FORM_VALIDATION = z.object({ + ...DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION.shape, + [CREATE_OR_EDIT_GROUP_FORM_VALUE.title]: z.string(), +}) diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersDialog/index.tsx b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/index.tsx new file mode 100644 index 00000000..75af3429 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/index.tsx @@ -0,0 +1,245 @@ +'use client' + +import { FC, MouseEventHandler, useMemo, useRef } from 'react' + +import { ConfirmDialog } from '@baseapp-frontend/design-system/components/web/dialogs' +import { setFormRelayErrors, useNotification } from '@baseapp-frontend/utils' + +import { zodResolver } from '@hookform/resolvers/zod' +import { LoadingButton } from '@mui/lab' +import { useForm } from 'react-hook-form' + +import { useAllProfilesList } from '../../../../profiles/common' +import { useUpdateChatRoomMutation } from '../../../common' +import DefaultGroupChatMembersList from '../../__shared__/GroupChatMembersList' +import { + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALUE as DEFAULT_FORM_VALUES, + CREATE_OR_EDIT_GROUP_FORM_VALUE as FORM_VALUE, +} from '../../__shared__/constants' +import { CreateOrEditGroup, ProfileNode } from '../../__shared__/types' +import AddMemberCard from '../AddMemberCard' +import AddedMemberCard from '../AddedMemberCard' +import { DEFAULT_FORM_VALIDATION } from './constants' +import { SearchbarContainer } from './styled' +import { AddMembersDialogProps } from './types' + +const AddMembersDialog: FC = ({ + allProfilesRef, + onClose, + handleSubmitSuccess, + open, + profileId, + roomId, + isPending, + GroupChatMembersList = DefaultGroupChatMembersList, + GroupChatMembersListProps = {}, + existingMembers, +}) => { + const { sendToast } = useNotification() + const boxRef = useRef(null) + + const { + data: { allProfiles }, + loadNext, + isLoadingNext, + hasNext, + refetch: refetchProfiles, + } = useAllProfilesList(allProfilesRef) + + const formReturn = useForm({ + defaultValues: DEFAULT_FORM_VALUES, + resolver: zodResolver(DEFAULT_FORM_VALIDATION), + mode: 'onBlur', + }) + + const { + setValue, + watch, + getFieldState, + handleSubmit, + formState: { isDirty }, + reset, + } = formReturn + + const [commit, isMutationInFlight] = useUpdateChatRoomMutation() + + const onSubmit = handleSubmit((data: CreateOrEditGroup) => { + if (!roomId) return + + const { participants } = data + const participantsIds = (participants || []).map((member: ProfileNode) => member?.id) + + commit({ + variables: { + input: { + roomId, + profileId, + addParticipants: participantsIds, + }, + connections: [], + }, + onCompleted: (response) => { + const errors = response?.chatRoomUpdate?.errors + if (errors) { + sendToast('Something went wrong', { type: 'error' }) + setFormRelayErrors(formReturn, errors) + } else { + handleSubmitSuccess() + reset() + } + }, + }) + }) + + const participants = watch(FORM_VALUE.participants) as ProfileNode[] + + const profiles = useMemo( + () => + allProfiles?.edges + .filter((edge) => edge?.node && edge?.node.id !== profileId) + .map((edge) => edge?.node) || [], + [allProfiles, profileId], + ) + + const isEditButtonDisabled = !isDirty || getFieldState(FORM_VALUE.participants).invalid + + const handleAddMember = (profile: ProfileNode) => { + setValue(FORM_VALUE.participants, [...participants, profile], { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + + const handleRemoveMember = (profile: ProfileNode) => { + setValue( + FORM_VALUE.participants, + participants.filter((member: ProfileNode) => member?.id !== profile?.id), + { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }, + ) + } + + const handleClose = () => { + onClose() + reset() + } + + const handleMouseDown: MouseEventHandler = (e) => { + const box = boxRef.current + if (!box) return + + box.style.cursor = 'grabbing' + box.style.userSelect = 'none' + + const startX = e.pageX - box.offsetLeft + const { scrollLeft } = box + + const handleMouseMove = (ev: MouseEvent) => { + const x = ev.pageX - box.offsetLeft + const walk = x - startX + boxRef.current?.scrollTo({ + left: scrollLeft - walk, + behavior: 'auto', + }) + } + + const handleMouseUp = () => { + box.style.cursor = 'grab' + box.style.removeProperty('user-select') + + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + } + + const emptyParticipantsList = participants.length === 0 + + return ( + { + if (!profile) return null + return ( + member?.id === profile?.id)} + isExistingMember={existingMembers.some((member) => member?.id === profile?.id)} + /> + ) + }, + ...(GroupChatMembersListProps?.ConnectionsListProps ?? {}), + }} + MembersListProps={{ + removeTitle: emptyParticipantsList, + title: '', + NormalListProps: { + sx: { + display: 'flex', + justifyContent: 'start', + alignItems: 'center', + padding: emptyParticipantsList ? 0 : '12px', + scrollbarWidth: 'none', + }, + ref: boxRef, + onMouseDown: handleMouseDown, + }, + ...(GroupChatMembersListProps?.MembersListProps ?? {}), + }} + ProfileCard={AddedMemberCard} + {...GroupChatMembersListProps} + /> + } + action={ + onSubmit()} + disabled={isMutationInFlight || isEditButtonDisabled} + loading={isMutationInFlight || isPending} + > + Confirm + + } + onClose={handleClose} + open={open} + /> + ) +} + +export default AddMembersDialog diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersDialog/styled.tsx b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/styled.tsx new file mode 100644 index 00000000..ea50f16d --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/styled.tsx @@ -0,0 +1,6 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const SearchbarContainer = styled(Box)(({ theme }) => ({ + padding: theme.spacing(0, 1.5, 1.5), +})) diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersDialog/types.ts b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/types.ts new file mode 100644 index 00000000..d76ec368 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersDialog/types.ts @@ -0,0 +1,18 @@ +import { FC } from 'react' + +import { ChatRoomsQuery$data } from '../../../../../__generated__/ChatRoomsQuery.graphql' +import { GroupChatMembersListProps } from '../../__shared__/GroupChatMembersList/types' +import { ProfileNode } from '../../__shared__/types' + +export interface AddMembersDialogProps { + allProfilesRef: ChatRoomsQuery$data + onClose: VoidFunction + handleSubmitSuccess: VoidFunction + open: boolean + profileId: string + roomId?: string + isPending: boolean + GroupChatMembersList?: FC + GroupChatMembersListProps?: Partial + existingMembers: ProfileNode[] +} diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersMobile/constants.ts b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/constants.ts new file mode 100644 index 00000000..0edda502 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/constants.ts @@ -0,0 +1,11 @@ +import z from 'zod' + +import { + CREATE_OR_EDIT_GROUP_FORM_VALUE, + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION, +} from '../../__shared__/constants' + +export const DEFAULT_FORM_VALIDATION = z.object({ + ...DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION.shape, + [CREATE_OR_EDIT_GROUP_FORM_VALUE.title]: z.string(), +}) diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersMobile/index.tsx b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/index.tsx new file mode 100644 index 00000000..0cbb4afe --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/index.tsx @@ -0,0 +1,206 @@ +'use client' + +import { FC, useMemo } from 'react' + +import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' +import { CheckMarkIcon } from '@baseapp-frontend/design-system/components/web/icons' +import { Iconify } from '@baseapp-frontend/design-system/components/web/images' +import { setFormRelayErrors, useNotification } from '@baseapp-frontend/utils' + +import { zodResolver } from '@hookform/resolvers/zod' +import { Box, Typography } from '@mui/material' +import { useForm } from 'react-hook-form' + +import { useAllProfilesList } from '../../../../profiles/common' +import { useUpdateChatRoomMutation } from '../../../common' +import DefaultGroupChatMembersList from '../../__shared__/GroupChatMembersList' +import { + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALUE as DEFAULT_FORM_VALUES, + CREATE_OR_EDIT_GROUP_FORM_VALUE as FORM_VALUE, +} from '../../__shared__/constants' +import { CreateOrEditGroup, ProfileNode } from '../../__shared__/types' +import AddMemberCard from '../AddMemberCard' +import AddedMemberCard from '../AddedMemberCard' +import { HeaderContainer } from '../styled' +import { DEFAULT_FORM_VALIDATION } from './constants' +import { SearchbarContainer } from './styled' +import { AddMembersMobileProps } from './types' + +const AddMembersMobile: FC = ({ + allProfilesRef, + onClose, + handleSubmitSuccess, + profileId, + roomId, + isPending, + GroupChatMembersList = DefaultGroupChatMembersList, + GroupChatMembersListProps = {}, + existingMembers = [], +}) => { + const { sendToast } = useNotification() + + const { + data: { allProfiles }, + loadNext, + isLoadingNext, + hasNext, + refetch: refetchProfiles, + } = useAllProfilesList(allProfilesRef) + + const formReturn = useForm({ + defaultValues: DEFAULT_FORM_VALUES, + resolver: zodResolver(DEFAULT_FORM_VALIDATION), + mode: 'onBlur', + }) + + const { + setValue, + watch, + getFieldState, + handleSubmit, + formState: { isDirty }, + reset, + } = formReturn + + const [commit, isMutationInFlight] = useUpdateChatRoomMutation() + + const onSubmit = handleSubmit((data: CreateOrEditGroup) => { + if (!roomId) return + + const { participants } = data + const participantsIds = (participants || []).map((member: ProfileNode) => member?.id) + + commit({ + variables: { + input: { + roomId, + profileId, + addParticipants: participantsIds, + }, + connections: [], + }, + onCompleted: (response) => { + const errors = response?.chatRoomUpdate?.errors + if (errors) { + sendToast('Something went wrong', { type: 'error' }) + setFormRelayErrors(formReturn, errors) + } else { + handleSubmitSuccess() + reset() + } + }, + }) + }) + + const participants = watch(FORM_VALUE.participants) as ProfileNode[] + + const profiles = useMemo( + () => + allProfiles?.edges + .filter((edge) => edge?.node && edge?.node.id !== profileId) + .map((edge) => edge?.node) || [], + [allProfiles, profileId], + ) + + const isEditButtonDisabled = !isDirty || getFieldState(FORM_VALUE.participants).invalid + + const handleAddMember = (profile: ProfileNode) => { + setValue(FORM_VALUE.participants, [...participants, profile], { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + + const handleRemoveMember = (profile: ProfileNode) => { + setValue( + FORM_VALUE.participants, + participants.filter((member: ProfileNode) => member?.id !== profile?.id), + { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }, + ) + } + + const emptyParticipantsList = participants.length === 0 + + return ( + + + + + + + Add Member + + { + onSubmit() + }} + > + + + + { + if (!profile) return null + return ( + member?.id === profile?.id)} + isExistingMember={existingMembers.some((member) => member?.id === profile?.id)} + /> + ) + }, + ...(GroupChatMembersListProps?.ConnectionsListProps ?? {}), + }} + MembersListProps={{ + removeTitle: emptyParticipantsList, + title: '', + NormalListProps: { + sx: { + display: 'flex', + justifyContent: 'start', + alignItems: 'center', + padding: emptyParticipantsList ? 0 : '12px', + scrollbarWidth: 'none', + }, + }, + ...(GroupChatMembersListProps?.MembersListProps ?? {}), + }} + ProfileCard={AddedMemberCard} + {...GroupChatMembersListProps} + /> + + ) +} + +export default AddMembersMobile diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersMobile/styled.tsx b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/styled.tsx new file mode 100644 index 00000000..12c840c9 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/styled.tsx @@ -0,0 +1,6 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const SearchbarContainer = styled(Box)(({ theme }) => ({ + padding: theme.spacing(1.5), +})) diff --git a/packages/components/modules/messages/web/EditGroup/AddMembersMobile/types.ts b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/types.ts new file mode 100644 index 00000000..1c9ab503 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddMembersMobile/types.ts @@ -0,0 +1,17 @@ +import { FC } from 'react' + +import { ChatRoomsQuery$data } from '../../../../../__generated__/ChatRoomsQuery.graphql' +import { GroupChatMembersListProps } from '../../__shared__/GroupChatMembersList/types' +import { ProfileNode } from '../../__shared__/types' + +export interface AddMembersMobileProps { + allProfilesRef: ChatRoomsQuery$data + onClose: VoidFunction + handleSubmitSuccess: VoidFunction + profileId: string + roomId?: string + isPending: boolean + GroupChatMembersList?: FC + GroupChatMembersListProps?: Partial + existingMembers: ProfileNode[] +} diff --git a/packages/components/modules/messages/web/EditGroup/AddedMemberCard/index.tsx b/packages/components/modules/messages/web/EditGroup/AddedMemberCard/index.tsx new file mode 100644 index 00000000..105f1910 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddedMemberCard/index.tsx @@ -0,0 +1,53 @@ +'use client' + +import { FC } from 'react' + +import { AvatarWithPlaceholder } from '@baseapp-frontend/design-system/components/web/avatars' +import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' +import { FilledCloseIcon } from '@baseapp-frontend/design-system/components/web/icons' + +import { Box, Typography } from '@mui/material' +import { useFragment } from 'react-relay' + +import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileItemFragment.graphql' +import { ProfileItemFragment } from '../../../../profiles/common' +import { MainContainer } from './styled' +import { AddedMemberCardProps } from './types' + +const AddedMemberCard: FC = ({ profile, handleRemoveMember }) => { + const { id, image, name } = useFragment(ProfileItemFragment, profile as ProfileItemFragment$key) + + return ( + + + handleRemoveMember(profile)} + > + + + + + + {name} + + + ) +} + +export default AddedMemberCard diff --git a/packages/components/modules/messages/web/EditGroup/AddedMemberCard/styled.tsx b/packages/components/modules/messages/web/EditGroup/AddedMemberCard/styled.tsx new file mode 100644 index 00000000..7ad1ebfb --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddedMemberCard/styled.tsx @@ -0,0 +1,13 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const MainContainer = styled(Box)(({ theme }) => ({ + alignItems: 'center', + display: 'grid', + width: '100%', + maxWidth: '64px', + gridTemplateRows: 'auto auto', + gridTemplateColumns: 'min-content', + gap: theme.spacing(0.5), + margin: theme.spacing(1), +})) diff --git a/packages/components/modules/messages/web/EditGroup/AddedMemberCard/types.ts b/packages/components/modules/messages/web/EditGroup/AddedMemberCard/types.ts new file mode 100644 index 00000000..c81bbe31 --- /dev/null +++ b/packages/components/modules/messages/web/EditGroup/AddedMemberCard/types.ts @@ -0,0 +1,6 @@ +import { ProfileNode } from '../../../../profiles/common' + +export interface AddedMemberCardProps { + profile: ProfileNode + handleRemoveMember: (profile: ProfileNode) => void +} diff --git a/packages/components/modules/messages/web/EditGroup/constants.ts b/packages/components/modules/messages/web/EditGroup/constants.ts index 2e34c2bc..80d995a1 100644 --- a/packages/components/modules/messages/web/EditGroup/constants.ts +++ b/packages/components/modules/messages/web/EditGroup/constants.ts @@ -1,20 +1,15 @@ -import { z } from 'zod' +import z from 'zod' -import { TitleAndImage } from '../__shared__/types' -import { EditGroupUpload } from './types' - -export const FORM_VALUE: Record & - Record = { - title: 'title', - addParticipants: 'addParticipants', - removeParticipants: 'removeParticipants', - image: 'image', -} +import { + CREATE_OR_EDIT_GROUP_FORM_VALUE, + DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION, +} from '../__shared__/constants' +import { CreateOrEditGroup } from '../__shared__/types' export const getDefaultFormValues = ( title: string, image: string | undefined, -): EditGroupUpload => ({ +): CreateOrEditGroup => ({ title, addParticipants: [], removeParticipants: [], @@ -22,11 +17,6 @@ export const getDefaultFormValues = ( }) export const DEFAULT_FORM_VALIDATION = z.object({ - [FORM_VALUE.title]: z - .string() - .min(1, { message: 'Please enter a title' }) - .max(20, { message: "Title can't be more than 20 characters" }), - [FORM_VALUE.addParticipants]: z.array(z.any()), - [FORM_VALUE.removeParticipants]: z.array(z.any()), - [FORM_VALUE.image]: z.any(), + ...DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION.shape, + [CREATE_OR_EDIT_GROUP_FORM_VALUE.participants]: z.any(), }) diff --git a/packages/components/modules/messages/web/EditGroup/index.tsx b/packages/components/modules/messages/web/EditGroup/index.tsx index b51959d2..68989e9a 100644 --- a/packages/components/modules/messages/web/EditGroup/index.tsx +++ b/packages/components/modules/messages/web/EditGroup/index.tsx @@ -1,42 +1,75 @@ 'use client' -import { FC } from 'react' +import { FC, useMemo, useState, useTransition } from 'react' import { useCurrentProfile } from '@baseapp-frontend/authentication' import { IconButton } from '@baseapp-frontend/design-system/components/web/buttons' import { CheckMarkIcon, CloseIcon } from '@baseapp-frontend/design-system/components/web/icons' +import { useResponsive } from '@baseapp-frontend/design-system/hooks/web' import { filterDirtyValues, setFormRelayErrors, useNotification } from '@baseapp-frontend/utils' import { zodResolver } from '@hookform/resolvers/zod' import { Box, Typography } from '@mui/material' import { useForm } from 'react-hook-form' -import { usePreloadedQuery } from 'react-relay' +import { usePaginationFragment, usePreloadedQuery } from 'react-relay' +import { ChatRoomParticipantsPaginationQuery } from '../../../../__generated__/ChatRoomParticipantsPaginationQuery.graphql' import { GroupDetailsQuery as GroupDetailsQueryType } from '../../../../__generated__/GroupDetailsQuery.graphql' +import { MembersListFragment$key } from '../../../../__generated__/MembersListFragment.graphql' +import { ProfileNode } from '../../../profiles/common' import { GroupDetailsQuery, + MembersListFragment, useGroupNameAndAvatar, useRoomListSubscription, useUpdateChatRoomMutation, } from '../../common' import EditGroupTitleAndImage from '../__shared__/EditGroupTitleAndImage' -import { DEFAULT_FORM_VALIDATION, FORM_VALUE, getDefaultFormValues } from './constants' +import DefaultGroupChatMembersList from '../__shared__/GroupChatMembersList' +import { CREATE_OR_EDIT_GROUP_FORM_VALUE as FORM_VALUE } from '../__shared__/constants' +import AddMembersDialog from './AddMembersDialog' +import AddMembersMobile from './AddMembersMobile' +import { DEFAULT_FORM_VALIDATION, getDefaultFormValues } from './constants' import { HeaderContainer } from './styled' import { EditGroupProps } from './types' const EditGroup: FC = ({ profileId, + allProfilesRef, queryRef, roomId, + GroupChatMembersList = DefaultGroupChatMembersList, + GroupChatMembersListProps = {}, onCancellation, onRemovalFromGroup, onValidSubmission, }) => { const { sendToast } = useNotification() + const [open, setOpen] = useState(false) + const smDown = useResponsive('down', 'sm') const { chatRoom: group } = usePreloadedQuery(GroupDetailsQuery, queryRef) const { avatar, title } = useGroupNameAndAvatar(group) useRoomListSubscription({ profileId, connections: [], onRemoval: onRemovalFromGroup }) + const { + data: membersList, + loadNext, + isLoadingNext, + hasNext, + refetch, + } = usePaginationFragment( + MembersListFragment, + group, + ) + + const participants = useMemo( + () => + membersList?.participants?.edges?.map( + (edge: any) => edge?.node?.profile && edge.node.profile, + ) as ProfileNode[], + [membersList], + ) + const formReturn = useForm({ defaultValues: getDefaultFormValues(title || '', avatar), resolver: zodResolver(DEFAULT_FORM_VALIDATION), @@ -70,6 +103,7 @@ const EditGroup: FC = ({ } delete dirtyValues.image } + delete dirtyValues.participants commit({ variables: { @@ -103,9 +137,39 @@ const EditGroup: FC = ({ } const isEditButtonDisabled = !isValid || !isDirty + const [isPending, startTransition] = useTransition() + const handleAddMemberSuccess = () => { + setOpen(false) + startTransition(() => { + refetch?.({}) + }) + } + + if (smDown && open) + return ( + setOpen(false)} + handleSubmitSuccess={handleAddMemberSuccess} + profileId={profileId} + roomId={roomId} + isPending={isPending} + existingMembers={participants} + /> + ) return ( + setOpen(false)} + handleSubmitSuccess={handleAddMemberSuccess} + profileId={profileId} + roomId={roomId} + isPending={isPending} + existingMembers={participants} + /> @@ -134,6 +198,22 @@ const EditGroup: FC = ({ trigger={trigger} watch={watch} /> + setOpen(true), + ...(GroupChatMembersListProps?.MembersListProps ?? {}), + }} + {...GroupChatMembersListProps} + /> ) } diff --git a/packages/components/modules/messages/web/EditGroup/types.ts b/packages/components/modules/messages/web/EditGroup/types.ts index 3d84d47f..53de99e9 100644 --- a/packages/components/modules/messages/web/EditGroup/types.ts +++ b/packages/components/modules/messages/web/EditGroup/types.ts @@ -1,21 +1,19 @@ -import { PropsWithChildren } from 'react' +import { FC, PropsWithChildren } from 'react' import { PreloadedQuery } from 'react-relay' -import { GroupDetailsQuery as GroupDetailsQueryType } from '../../../../__generated__/GroupDetailsQuery.graphql' +import { GroupChatMembersListProps } from '../__shared__/GroupChatMembersList/types' +import { ChatRoomsQuery$data } from '../../../../__generated__/ChatRoomsQuery.graphql' +import { GroupDetailsQuery } from '../../../../__generated__/GroupDetailsQuery.graphql' export interface EditGroupProps extends PropsWithChildren { - queryRef: PreloadedQuery - remotePatternsHostName?: string + allProfilesRef: ChatRoomsQuery$data + queryRef: PreloadedQuery roomId: string | undefined onCancellation: () => void onRemovalFromGroup: () => void onValidSubmission: () => void -} - -export interface EditGroupUpload { - title: string - addParticipants?: any[] - removeParticipants?: any[] - image?: string | File | Blob | null + GroupChatMembersList?: FC + GroupChatMembersListProps?: Partial + remotePatternsHostName?: string } diff --git a/packages/components/modules/messages/web/GroupDetails/ProfileCard/constants.ts b/packages/components/modules/messages/web/GroupDetails/ProfileCard/constants.ts deleted file mode 100644 index 16338b9f..00000000 --- a/packages/components/modules/messages/web/GroupDetails/ProfileCard/constants.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { ValueOf } from '@baseapp-frontend/utils' - -export const CHAT_ROOM_PARTICIPANT_ROLES = { - admin: 'ADMIN', - member: 'MEMBER', -} as const - -export type ChatRoomParticipantRoles = ValueOf - -export const ADMIN_LABEL = 'Admin' diff --git a/packages/components/modules/messages/web/GroupDetails/ProfileCard/index.tsx b/packages/components/modules/messages/web/GroupDetails/ProfileCard/index.tsx index 3d496f74..aa63344b 100644 --- a/packages/components/modules/messages/web/GroupDetails/ProfileCard/index.tsx +++ b/packages/components/modules/messages/web/GroupDetails/ProfileCard/index.tsx @@ -13,9 +13,9 @@ import { useFragment } from 'react-relay' import { ProfileItemFragment$key } from '../../../../../__generated__/ProfileItemFragment.graphql' import { ProfileItemFragment } from '../../../../profiles/common' +import { ADMIN_LABEL, CHAT_ROOM_PARTICIPANT_ROLES } from '../../../common' import AdminOptionsMenu from './AdminOptionsMenu' import MemberOptionsMenu from './MemberOptionsMenu' -import { ADMIN_LABEL, CHAT_ROOM_PARTICIPANT_ROLES } from './constants' import { MainContainer } from './styled' import { ProfileCardProps } from './types' diff --git a/packages/components/modules/messages/web/GroupDetails/index.tsx b/packages/components/modules/messages/web/GroupDetails/index.tsx index 9c4e4efc..10a82ce8 100644 --- a/packages/components/modules/messages/web/GroupDetails/index.tsx +++ b/packages/components/modules/messages/web/GroupDetails/index.tsx @@ -18,13 +18,13 @@ import { GroupDetailsQuery, MembersListFragment, getParticipantCountString, + useCheckIsAdmin, useGroupNameAndAvatar, useRoomListSubscription, } from '../../common' import LeaveGroupDialog from '../__shared__/LeaveGroupDialog' import { GroupDetailsHeader } from './GroupDetailsHeader' import DefaultProfileCard from './ProfileCard' -import { CHAT_ROOM_PARTICIPANT_ROLES } from './ProfileCard/constants' import { GroupMembersEdge } from './ProfileCard/types' import { GroupHeaderContainer, GroupTitleContainer } from './styled' import { GroupDetailsProps } from './types' @@ -58,9 +58,7 @@ const GroupDetails: FC = ({ MembersListFragment$key >(MembersListFragment, group) const members = data?.participants - const me = members?.edges.find((edge) => profileId && edge?.node?.profile?.id === profileId) - const isAdmin = me?.node?.role === CHAT_ROOM_PARTICIPANT_ROLES.admin - + const { isAdmin, isSoleAdmin } = useCheckIsAdmin(members) const renderLoadingState = () => { if (!isLoadingNext) return @@ -124,19 +122,9 @@ const GroupDetails: FC = ({ profileId={profileId} roomId={group?.id} open={!!removingParticipantId} - removingParticipantId={removingParticipantId} - removingParticipantName={removingParticipantName.current} + removingParticipantId={removingParticipantId ?? ''} onClose={handleRemoveDialogClose} - title={ - profileId === removingParticipantId - ? undefined - : `Remove ${removingParticipantName.current}?` - } - content={ - profileId === removingParticipantId - ? undefined - : `Are you sure you want to remove ${removingParticipantName.current}? This cannot be undone.` - } + isSoleAdmin={isSoleAdmin} /> = ({ - profile: profileRef, + profile, handleAddMember, handleRemoveMember, isMember = false, }) => { - const { id, image, name, urlPath } = useFragment(ProfileItemFragment, profileRef) + const { id, name, image, urlPath } = useFragment( + ProfileItemFragment, + profile as ProfileItemFragment$key, + ) return ( @@ -39,9 +43,9 @@ const ProfileCard: FC = ({ size="small" onClick={() => { if (isMember) { - handleRemoveMember(profileRef) + handleRemoveMember(profile) } else { - handleAddMember(profileRef) + handleAddMember(profile) } }} sx={{ maxWidth: 'fit-content', justifySelf: 'end' }} diff --git a/packages/components/modules/messages/web/CreateGroup/ConnectionsList/styled.tsx b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfileCard/styled.tsx similarity index 100% rename from packages/components/modules/messages/web/CreateGroup/ConnectionsList/styled.tsx rename to packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfileCard/styled.tsx diff --git a/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfileCard/types.ts b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfileCard/types.ts new file mode 100644 index 00000000..f45131a0 --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfileCard/types.ts @@ -0,0 +1,8 @@ +import { ProfileNode } from '../../types' + +export interface ProfileCardProps { + profile: ProfileNode + handleAddMember: (profile: ProfileNode) => void + handleRemoveMember: (profile: ProfileNode) => void + isMember?: boolean +} diff --git a/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/index.tsx b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/index.tsx new file mode 100644 index 00000000..c9099ba0 --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/index.tsx @@ -0,0 +1,94 @@ +'use client' + +import { FC } from 'react' + +import { AvatarButton } from '@baseapp-frontend/design-system/components/web/buttons' +import { LoadingState } from '@baseapp-frontend/design-system/components/web/displays' + +import { Box, Typography, useTheme } from '@mui/material' +import { Virtuoso } from 'react-virtuoso' + +import { SearchNotFoundState as DefaultSearchNotFoundState } from '../../../../../__shared__/web' +import DefaultEmptyProfilesListState from '../../EmptyProfilesListState' +import { ProfileNode } from '../../types' +import { ProfilesListProps } from './types' + +const ProfilesList: FC = ({ + searchValue, + profiles = [], + isPending, + isLoadingNext, + hasNext, + loadNext, + renderItem, + VirtuosoProps = {}, + NormalListProps = {}, + label = 'Available connections', + title = 'Connections', + EmptyProfilesListState = DefaultEmptyProfilesListState, + SearchNotFoundState = DefaultSearchNotFoundState, + allowAddMember = false, + onAddMemberClick = () => {}, + removeTitle = false, +}) => { + const theme = useTheme() + const renderLoadingState = () => { + if (!isLoadingNext) return + + return ( + + ) + } + const isPaginated = loadNext + const emptyProfilesList = profiles.length === 0 + + if (!isPending && searchValue && emptyProfilesList && isPaginated) return + + if (!isPending && emptyProfilesList && isPaginated) return + + return ( + <> + + {!removeTitle && ( + + {title} + + )} + + {allowAddMember && } + {isPaginated ? ( + renderItem(item)} + style={{ scrollbarWidth: 'none', maxHeight: '250px' }} + components={{ + Footer: renderLoadingState, + }} + endReached={() => { + if (hasNext) { + loadNext?.(5) + } + }} + {...VirtuosoProps} + /> + ) : ( + + {profiles.map((member: ProfileNode) => renderItem(member, true))} + + )} + + ) +} + +export default ProfilesList diff --git a/packages/components/modules/messages/web/CreateGroup/ProfileCard/styled.tsx b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/styled.tsx similarity index 100% rename from packages/components/modules/messages/web/CreateGroup/ProfileCard/styled.tsx rename to packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/styled.tsx diff --git a/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/types.ts b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/types.ts new file mode 100644 index 00000000..ba2c4769 --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/ProfilesList/types.ts @@ -0,0 +1,25 @@ +import { FC } from 'react' + +import { LoadMoreFn } from 'react-relay' +import { VirtuosoProps } from 'react-virtuoso' +import { BoxProps } from '@mui/material' +import { ProfileNode } from '../../types' + +export interface ProfilesListProps { + profiles: ProfileNode[] + isLoadingNext?: boolean + hasNext?: boolean + loadNext?: LoadMoreFn + searchValue?: string | null + VirtuosoProps?: Partial> + isPending?: boolean + label?: string + title?: string + renderItem: (profile: ProfileNode, isMember?: boolean) => JSX.Element | null + SearchNotFoundState?: FC + EmptyProfilesListState?: FC + allowAddMember?: boolean + onAddMemberClick?: () => void + removeTitle?: boolean + NormalListProps?: Partial +} diff --git a/packages/components/modules/messages/web/__shared__/GroupChatMembersList/index.tsx b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/index.tsx new file mode 100644 index 00000000..a23bdb77 --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/index.tsx @@ -0,0 +1,136 @@ +'use client' + +import { ChangeEventHandler, FC, useTransition } from 'react' + +import { Searchbar as DefaultSearchbar } from '@baseapp-frontend/design-system/components/web/inputs' + +import { Box } from '@mui/material' +import { useForm } from 'react-hook-form' + +import { ProfileNode } from '../types' +import DefaultProfileCard from './ProfileCard' +import DefaultProfilesList from './ProfilesList' +import { + ProfilesContainer as DefaultProfilesContainer, + SearchbarContainer as DefaultSearchbarContainer, +} from './styled' +import { GroupChatMembersListProps } from './types' + +const GroupChatMembersList: FC = ({ + FORM_VALUE, + setValue, + refetch, + connections, + currentParticipants, + connectionsLoadNext, + connectionsHasNext, + connectionsIsLoadingNext, + membersLoadNext, + membersHasNext, + membersIsLoadingNext, + ProfilesContainer = DefaultProfilesContainer, + ProfileCard = DefaultProfileCard, + ProfileCardProps = {}, + Searchbar = DefaultSearchbar, + SearchbarProps = {}, + SearchbarContainer = DefaultSearchbarContainer, + ConnectionsList = DefaultProfilesList, + ConnectionsListProps = {}, + MembersList = DefaultProfilesList, + MembersListProps = {}, +}) => { + const [isPending, startTransition] = useTransition() + const { + control: searchControl, + reset: searchReset, + watch: searchWatch, + } = useForm({ defaultValues: { search: '' } }) + + const handleSearchChange: ChangeEventHandler = (e) => { + const value = e.target.value || '' + startTransition(() => { + refetch?.({ q: value }) + }) + } + + const handleSearchClear = () => { + startTransition(() => { + searchReset() + refetch?.({ q: '' }) + }) + } + + const handleAddMember = (profile: ProfileNode) => { + setValue(FORM_VALUE.participants, [...currentParticipants, profile], { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }) + } + + const handleRemoveMember = (profile: ProfileNode) => { + setValue( + FORM_VALUE.participants, + currentParticipants.filter((member: ProfileNode) => member?.id !== profile?.id), + { + shouldValidate: true, + shouldDirty: true, + shouldTouch: true, + }, + ) + } + + const renderItem = (profile: ProfileNode, isMember = false) => { + if (!profile) return null + return ( + + ) + } + + return ( + + + + + + renderItem(profile, true)} + loadNext={membersLoadNext} + hasNext={membersHasNext} + isLoadingNext={membersIsLoadingNext} + label="Selected group members" + title="Members" + {...MembersListProps} + /> + {connections && ( + renderItem(profile)} + loadNext={connectionsLoadNext} + hasNext={connectionsHasNext} + isLoadingNext={connectionsIsLoadingNext} + isPending={isPending} + searchValue={searchWatch('search')} + {...ConnectionsListProps} + /> + )} + + + ) +} + +export default GroupChatMembersList diff --git a/packages/components/modules/messages/web/__shared__/GroupChatMembersList/styled.tsx b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/styled.tsx new file mode 100644 index 00000000..e951d0e2 --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/styled.tsx @@ -0,0 +1,18 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const SearchbarContainer = styled(Box)(({ theme }) => ({ + padding: `${theme.spacing(2)} ${theme.spacing(2.5)} 0`, + [theme.breakpoints.down('sm')]: { + padding: `${theme.spacing(2)} ${theme.spacing(1.5)} 0`, + }, +})) + +export const ProfilesContainer = styled(Box)(({ theme }) => ({ + height: '100%', + width: '100%', + [theme.breakpoints.down('sm')]: { + // TODO: look for a better way to calculate the height, it doesn't consider different types of headers + height: `calc(100vh - 72px - 57px)`, + }, +})) diff --git a/packages/components/modules/messages/web/__shared__/GroupChatMembersList/types.ts b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/types.ts new file mode 100644 index 00000000..9b6504cc --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/GroupChatMembersList/types.ts @@ -0,0 +1,38 @@ +import { FC, PropsWithChildren } from 'react' + +import { SearchbarProps } from '@baseapp-frontend/design-system/components/web/inputs' + +import { WithControllerProps } from '@baseapp-frontend/utils' + +import { BoxProps } from '@mui/material' +import { UseFormSetValue, UseFormWatch } from 'react-hook-form' +import { LoadMoreFn, RefetchFnDynamic } from 'react-relay' + +import { CreateOrEditGroup, ProfileNode } from '../types' +import { ProfileCardProps } from './ProfileCard/types' +import { ProfilesListProps } from './ProfilesList/types' + +export interface GroupChatMembersListProps extends PropsWithChildren { + FORM_VALUE: Record + setValue: UseFormSetValue + watch: UseFormWatch + currentParticipants: ProfileNode[] + connections?: ProfileNode[] + refetch?: RefetchFnDynamic + connectionsLoadNext?: LoadMoreFn + connectionsHasNext?: boolean + connectionsIsLoadingNext?: boolean + membersLoadNext?: LoadMoreFn + membersHasNext?: boolean + membersIsLoadingNext?: boolean + ProfilesContainer?: FC + Searchbar?: FC | ((props: WithControllerProps) => JSX.Element) + SearchbarProps?: Partial + SearchbarContainer?: FC + ProfileCard?: FC + ProfileCardProps?: Partial + ConnectionsList?: FC + ConnectionsListProps?: Partial + MembersList?: FC + MembersListProps?: Partial +} diff --git a/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/constants.ts b/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/constants.ts new file mode 100644 index 00000000..c0124ce6 --- /dev/null +++ b/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/constants.ts @@ -0,0 +1,36 @@ +export const LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS = { + IS_LEAVING: 'IS_LEAVING', + IS_REMOVING: 'IS_REMOVING', +} as const + +export const LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS = { + ADMIN: 'ADMIN', + MEMBER: 'MEMBER', +} as const + +export const LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS = { + TITLE: 'TITLE', + CONTENT: 'CONTENT', +} as const + +export const LEAVE_GROUP_DIALOG_TEXT_COPY = { + [LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS.IS_LEAVING]: { + [LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS.ADMIN]: { + [LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS.TITLE]: 'Leave without choosing an admin?', + [LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS.CONTENT]: + 'You can choose a new admin from the people listed under members. If you leave the group without choosing a new admin, the most senior group member will become admin.', + }, + [LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS.MEMBER]: { + [LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS.TITLE]: 'Leave group chat?', + [LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS.CONTENT]: + 'You will stop receiving messages from this conversation and people will see that you left.', + }, + }, + [LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS.IS_REMOVING]: { + [LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS.ADMIN]: { + [LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS.TITLE]: 'Remove from chat?', + [LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS.CONTENT]: + 'Are you sure you want to remove this person from the conversation? They will no longer be able to send or receive new messages.', + }, + }, +} as const diff --git a/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/index.tsx b/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/index.tsx index b7f0f884..2508cf97 100644 --- a/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/index.tsx +++ b/packages/components/modules/messages/web/__shared__/LeaveGroupDialog/index.tsx @@ -3,27 +3,51 @@ import { FC } from 'react' import { ConfirmDialog } from '@baseapp-frontend/design-system/components/web/dialogs' -import { useNotification } from '@baseapp-frontend/utils' +import { ValueOf, useNotification } from '@baseapp-frontend/utils' import { LoadingButton } from '@mui/lab' import { ConnectionHandler } from 'react-relay' import { useUpdateChatRoomMutation } from '../../../common' +import { + LEAVE_GROUP_DIALOG_TEXT_COPY, + LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS, + LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS, + LEAVE_GROUP_DIALOG_TEXT_COPY_TYPE_KEYS, +} from './constants' import { LeaveGroupDialogProps } from './types' const LeaveGroupDialog: FC = ({ - title = 'Leave group chat?', - content = 'You will stop receiving messages from this conversation and people will see that you left.', + customTitle, + customContent, onClose, open, profileId, removingParticipantId, - removingParticipantName, roomId, + isSoleAdmin = false, }) => { const [commit, isMutationInFlight] = useUpdateChatRoomMutation() const { sendToast } = useNotification() + const getLeaveGroupDialogTextCopy = ( + type: ValueOf, + ) => { + if (profileId === removingParticipantId) { + if (isSoleAdmin) { + return LEAVE_GROUP_DIALOG_TEXT_COPY[LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS.IS_LEAVING][ + LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS.ADMIN + ][type] + } + return LEAVE_GROUP_DIALOG_TEXT_COPY[LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS.IS_LEAVING][ + LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS.MEMBER + ][type] + } + return LEAVE_GROUP_DIALOG_TEXT_COPY[LEAVE_GROUP_DIALOG_TEXT_COPY_ACTION_KEYS.IS_REMOVING][ + LEAVE_GROUP_DIALOG_TEXT_COPY_ROLE_KEYS.ADMIN + ][type] + } + const onRemoveConfirmed = () => { if (!roomId || !profileId) return commit({ @@ -31,7 +55,7 @@ const LeaveGroupDialog: FC = ({ input: { roomId, profileId, - removeParticipants: [removingParticipantId ?? profileId], + removeParticipants: [removingParticipantId], }, connections: [ConnectionHandler.getConnectionID(roomId, 'ChatRoom_participants')], }, @@ -41,7 +65,7 @@ const LeaveGroupDialog: FC = ({ removingParticipantId !== profileId && !response?.chatRoomUpdate?.errors ) { - sendToast(`${removingParticipantName} was successfully removed`) + sendToast('Member was successfully removed') } onClose() }, @@ -53,8 +77,12 @@ const LeaveGroupDialog: FC = ({ return ( & + Record = { + title: 'title', + participants: 'participants', + addParticipants: 'addParticipants', + removeParticipants: 'removeParticipants', + image: 'image', +} + +export const DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALUE: CreateOrEditGroup = { + title: '', + addParticipants: [], + participants: [], + removeParticipants: [], + image: '', +} + +export const DEFAULT_CREATE_OR_EDIT_GROUP_FORM_VALIDATION = z.object({ + [CREATE_OR_EDIT_GROUP_FORM_VALUE.title]: z + .string() + .min(1, { message: 'Please enter a title' }) + .max(20, { message: "Title can't be more than 20 characters" }), + [CREATE_OR_EDIT_GROUP_FORM_VALUE.addParticipants]: z.array(z.any()), + [CREATE_OR_EDIT_GROUP_FORM_VALUE.participants]: z + .array(z.any()) + .min(1, { message: 'Please select at least one member' }), + [CREATE_OR_EDIT_GROUP_FORM_VALUE.removeParticipants]: z.array(z.any()), + [CREATE_OR_EDIT_GROUP_FORM_VALUE.image]: z.any(), +}) diff --git a/packages/components/modules/messages/web/__shared__/types.ts b/packages/components/modules/messages/web/__shared__/types.ts index 4961a6ed..0f2e8764 100644 --- a/packages/components/modules/messages/web/__shared__/types.ts +++ b/packages/components/modules/messages/web/__shared__/types.ts @@ -1,4 +1,25 @@ +import { AllProfilesListFragment$data } from "../../../../__generated__/AllProfilesListFragment.graphql" +import { MessagesListFragment$data } from "../../../../__generated__/MessagesListFragment.graphql" + + export interface TitleAndImage { title: string image?: string | File | Blob | null } + +export interface AddRemoveParticipants { + addParticipants?: any[] + participants?: any[] + removeParticipants?: any[] +} + +export interface CreateOrEditGroup extends TitleAndImage, AddRemoveParticipants {} + +export type AllMessages = NonNullable +export type MessageEdges = AllMessages['edges'] +export type MessageNode = NonNullable['node'] + +export type AllProfiles = NonNullable +export type AllProfilesEdges = AllProfiles['edges'] +export type ProfileEdge = AllProfilesEdges[number] +export type ProfileNode = NonNullable['node'] diff --git a/packages/components/modules/profiles/common/graphql/queries/AllProfilesList.ts b/packages/components/modules/profiles/common/graphql/queries/AllProfilesList.ts index 03659172..4750ec65 100644 --- a/packages/components/modules/profiles/common/graphql/queries/AllProfilesList.ts +++ b/packages/components/modules/profiles/common/graphql/queries/AllProfilesList.ts @@ -9,7 +9,7 @@ export const fragmentQuery = graphql` @argumentDefinitions( cursor: { type: "String" } count: { type: "Int", defaultValue: 5 } - orderBy: { type: "String", defaultValue: "-created" } + orderBy: { type: "String", defaultValue: "name" } q: { type: "String", defaultValue: null } ) { allProfiles(after: $cursor, first: $count, orderBy: $orderBy, q: $q) diff --git a/packages/design-system/components/web/buttons/AvatarButton/__storybook__/AvatarButton.mdx b/packages/design-system/components/web/buttons/AvatarButton/__storybook__/AvatarButton.mdx new file mode 100644 index 00000000..a7a8d0bc --- /dev/null +++ b/packages/design-system/components/web/buttons/AvatarButton/__storybook__/AvatarButton.mdx @@ -0,0 +1,52 @@ +import { Meta } from '@storybook/addon-docs' + + + +# Component Documentation + +## AvatarButton + +- **Purpose**: The `AvatarButton` component provides a clickable button that displays an avatar and an optional caption. +- **Expected Behavior**: The `AvatarButton` renders as a button containing an avatar image and an optional caption. It responds to hover and click states with visual feedback. + +## Use Cases + +- **Current Usage**: + - User profile actions + - Adding members to a group + - Displaying user avatars with actions +- **Potential Usage**: + - Social media profile actions + - Contact list actions + - Team management interfaces + +## Props + +- **onClick** (function): The function to be called when the button is clicked. +- **Icon** (ReactNode, optional): The icon to be displayed in the button. +- **IconProps** (SvgIconProps, optional): Additional properties for configuring the icon (e.g. size, color, etc.). +- **caption** (string, optional): The caption text to be displayed next to the avatar. + +## Notes + +- **Related Components**: + - `Button`: Standard button component for text-based actions + - `Avatar`: Component for displaying user avatars + - `Tooltip`: Can be used to provide additional context for the `AvatarButton` + +## Example Usage + +```javascript +import { AvatarButton } from '@baseapp-frontend/design-system' + +const MyComponent = () => { + return ( + console.log('clicked')} + Icon=AddMemberIcon + IconProps={{sx={{fontSize: 20}}}} + caption="Add Member" + /> + ) +} +export default MyComponent diff --git a/packages/design-system/components/web/buttons/AvatarButton/__storybook__/stories.tsx b/packages/design-system/components/web/buttons/AvatarButton/__storybook__/stories.tsx new file mode 100644 index 00000000..dc0a3091 --- /dev/null +++ b/packages/design-system/components/web/buttons/AvatarButton/__storybook__/stories.tsx @@ -0,0 +1,42 @@ +import { Meta, StoryObj } from '@storybook/react' + +import AvatarButton from '..' +import { AddMemberIcon } from '../../../icons' +import { AvatarButtonProps } from '../types' + +const meta: Meta = { + title: '@baseapp-frontend | designSystem/Buttons/AvatarButton', + component: AvatarButton, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + onClick: () => console.log('clicked'), + }, +} + +export const WithCaption: Story = { + args: { + onClick: () => console.log('clicked'), + caption: 'Add Member', + }, +} + +export const WithIcon: Story = { + args: { + onClick: () => console.log('clicked'), + Icon: AddMemberIcon, + }, +} + +export const WithIconAndCaption: Story = { + args: { + onClick: () => console.log('clicked'), + Icon: AddMemberIcon, + caption: 'Add Member', + }, +} diff --git a/packages/design-system/components/web/buttons/AvatarButton/index.tsx b/packages/design-system/components/web/buttons/AvatarButton/index.tsx new file mode 100644 index 00000000..1e403aff --- /dev/null +++ b/packages/design-system/components/web/buttons/AvatarButton/index.tsx @@ -0,0 +1,37 @@ +'use client' + +import { FC } from 'react' + +import { Typography } from '@mui/material' + +import { AvatarWithPlaceholder } from '../../avatars' +import { AddMemberIcon as DefaultAddMemberIcon } from '../../icons' +import { AvatarButtonContainer } from './styled' +import { AvatarButtonProps } from './types' + +const AvatarButton: FC = ({ + onClick, + Icon = DefaultAddMemberIcon, + IconProps = {}, + caption, +}) => ( + + + + + {caption && ( + + {caption} + + )} + +) +export default AvatarButton diff --git a/packages/design-system/components/web/buttons/AvatarButton/styled.tsx b/packages/design-system/components/web/buttons/AvatarButton/styled.tsx new file mode 100644 index 00000000..5965ba85 --- /dev/null +++ b/packages/design-system/components/web/buttons/AvatarButton/styled.tsx @@ -0,0 +1,13 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +export const AvatarButtonContainer = styled(Box)(({ theme }) => ({ + display: 'grid', + gridTemplateColumns: '48px auto', + gap: theme.spacing(1.5), + padding: `${theme.spacing(1.5)} ${theme.spacing(2.5)}`, + cursor: 'pointer', + [theme.breakpoints.down('sm')]: { + padding: `${theme.spacing(1.5)} ${theme.spacing(1.5)}`, + }, +})) diff --git a/packages/design-system/components/web/buttons/AvatarButton/types.ts b/packages/design-system/components/web/buttons/AvatarButton/types.ts new file mode 100644 index 00000000..8b097e47 --- /dev/null +++ b/packages/design-system/components/web/buttons/AvatarButton/types.ts @@ -0,0 +1,10 @@ +import { FC } from 'react' + +import { SvgIconProps } from '@mui/material' + +export interface AvatarButtonProps { + onClick: () => void + Icon?: FC + IconProps?: Partial + caption?: string +} diff --git a/packages/design-system/components/web/buttons/index.ts b/packages/design-system/components/web/buttons/index.ts index f5ee32d8..8990abd7 100644 --- a/packages/design-system/components/web/buttons/index.ts +++ b/packages/design-system/components/web/buttons/index.ts @@ -1,5 +1,8 @@ 'use client' +export { default as AvatarButton } from './AvatarButton' +export type * from './AvatarButton/types' + export { default as IconButton } from './IconButton' export type * from './IconButton/types' diff --git a/packages/design-system/components/web/dialogs/ConfirmDialog/index.tsx b/packages/design-system/components/web/dialogs/ConfirmDialog/index.tsx index 26b857b6..453fc303 100644 --- a/packages/design-system/components/web/dialogs/ConfirmDialog/index.tsx +++ b/packages/design-system/components/web/dialogs/ConfirmDialog/index.tsx @@ -16,16 +16,22 @@ const ConfirmDialog: FC = ({ action, cancelText = 'Cancel', onClose, + DialogTitleProps = {}, + DialogContentProps = {}, + DialogActionsProps = {}, ...props }) => ( - {title} + + {title} + {content && ( {content} @@ -36,6 +42,7 @@ const ConfirmDialog: FC = ({ width: 'fit-content', }, }} + {...DialogActionsProps} >