diff --git a/apps/web/pages/chat/rooms/[roomId].tsx b/apps/web/pages/chat/rooms/[roomId].tsx index 28f029ac3c1ff0dd03c9e802793f9ad414ecbc36..8775c6786b6f6873255b9ccb930f4d7246524432 100644 --- a/apps/web/pages/chat/rooms/[roomId].tsx +++ b/apps/web/pages/chat/rooms/[roomId].tsx @@ -1,4 +1,4 @@ -import { NextPage } from 'next' +import type { NextPage } from 'next' import dynamic from 'next/dynamic' import React from 'react' diff --git a/core/domain/shared/queries.ts b/core/domain/shared/queries.ts index a5e5e01c360d1893afe72890dbbdaee214df7b6a..f411e6736addf8ada62aef69073564cb766a0ee1 100644 --- a/core/domain/shared/queries.ts +++ b/core/domain/shared/queries.ts @@ -44,6 +44,7 @@ export const topicsQuery = gql` export const UserAvatarFragment = gql` fragment UserAvatar on User { id + identity avatar avatarBlurhash avatarLabel @@ -56,6 +57,7 @@ export const UserAvatarFragment = gql` export const FullUserAvatarFragment = gql` fragment FullUserAvatar on User { id + identity avatar avatarBlurhash avatarLabel diff --git a/core/i18n/locales/de.json b/core/i18n/locales/de.json index c11db9b807d28c84202927b24763c4e3cd740ba7..5f64b7684f2aab65241aae5e847b3fbee314eb88 100644 --- a/core/i18n/locales/de.json +++ b/core/i18n/locales/de.json @@ -152,6 +152,7 @@ "chat.emptyRoom.was": "war", "chat.invitation.accept": "Chat starten", "chat.invitation.copy": "{{fullName}} möchte eine Unterhaltung mit dir beginnen", + "chat.invitation.copy.taskChat": "<0>{{fullName}}</0> möchte eine Unterhaltung mit dir zur Aufgabe <0>{{taskName}}</0> beginnen.", "chat.invitation.newconversation": "Neue Unterhaltung", "chat.invitation.reject": "Ablehnen", "chat.inviting": "Eingeladen", @@ -166,11 +167,13 @@ "chat.room.error.noRoom": "Der Chatraum konnte nicht gefunden werden.", "chat.room.error.openRoom": "Der Chatraum konnte nicht betreten werden.", "chat.room.input.placeholder": "Schreibe {{fullName}}", + "chat.room.input.placeholder.taskchat": "Schreibe eine Nachricht", "chat.room.lastMessage.membership.join": "{{user}} ist beigetreten", "chat.room.lastMessage.membership.leave": "{{user}} hat den Raum verlassen", "chat.room.noMessages": "Noch keine Nachrichten", "chat.room.sendButton.label": "Absenden", "chat.room.start.description": "Dies ist der Beginn deines Direkt-Chats mit <0>{{fullName}}</0>. Nur ihr beide seht diese Unterhaltung.", + "chat.room.start.group.description": "Dies ist der Beginn deines Chatverlaufs mit <fullName></fullName> von <spaceName>{{spaceName}}</spaceName>. Nur ihr seid Teil dieses Gespräches, es sei denn, jemand von euch lädt weitere Personen in den Chat ein.\n\nDieser Chat wurde erstellt, um die <taskName>{{taskName}}</taskName> Aufgabe zu besprechen.", "chat.start.button.label": "Schreiben", "chat.start.description": "Tippe den Namen der Person ein, mit der du einen Chat starten möchtest.", "chat.start.input.error": "Der Name der Person muss mindestens {{count}} Buchstaben haben.", @@ -1416,6 +1419,11 @@ "spaces.tabs.mine.membership_requestor": "Ausstehende Anfragen", "spaces.tasks.add.title": "Neue Aufgabe erstellen", "spaces.tasks.contact.title": "Ansprechpartner für diese Aufgabe", + "spaces.tasks.contactOrganization": "Nimm Kontakt auf", + "spaces.tasks.contactOrganization.title": "Starte mit einem \"Hallo\"", + "spaces.tasks.contactOrganization.description": "Du startest jetzt einen Chat mit <0>{{organization}}</0>'s Moderator*innen. Besprich mit ihnen die Details der Aufgabe und wie du mithelfen kannst!", + "spaces.tasks.contactOrganization.subdescription": "Kein Stress – Chatten ist nur der erste Schritt.", + "spaces.tasks.contactOrganization.cta": "Starte den Chat", "spaces.tasks.cta_claim": "Für Aufgabe anbieten…", "spaces.tasks.delete.confirmation.description": "Bist du sicher, dass du deine Aufgabe löschen willst?\nDiese Aktion ist unwiderruflich.", "spaces.tasks.delete.confirmation.title": "Aufgabe löschen", @@ -1424,6 +1432,7 @@ "spaces.tasks.details.space": "Für <0>{{space}}</0>", "spaces.tasks.details.title": "Aufgabendetails", "spaces.tasks.edit.title": "Aufgabe bearbeiten", + "spaces.tasks.goToExistingTaskChat": "Chat öffnen", "spaces.tasks.notFound": "Aufgabe {{taskId}} nicht gefunden", "spaces.tasks.published.by.title": "Aufgabe erstellt von", "spaces.tasks.spots_claimed": "{{taken}} von {{available}} Plätzen vergeben", diff --git a/core/i18n/locales/en.json b/core/i18n/locales/en.json index 8e2dfde968b4664092d31587334458e55a103179..23941b7fcc94f2f6b202994d870415e305269a1e 100644 --- a/core/i18n/locales/en.json +++ b/core/i18n/locales/en.json @@ -152,6 +152,7 @@ "chat.emptyRoom.was": "was", "chat.invitation.accept": "Start chatting", "chat.invitation.copy": "{{fullName}} wants to start a new conversation with you.", + "chat.invitation.copy.taskChat": "<0>{{fullName}}</0> wants to start a new conversation with you about the task <0>{{taskName}}</0>.", "chat.invitation.newconversation": "New conversation", "chat.invitation.reject": "Reject", "chat.inviting": "Inviting", @@ -166,11 +167,13 @@ "chat.room.error.noRoom": "The chat room could not be found.", "chat.room.error.openRoom": "The chat room could not be opened.", "chat.room.input.placeholder": "Message {{fullName}}", + "chat.room.input.placeholder.taskchat": "Write a message Message", "chat.room.lastMessage.membership.join": "{{user}} joined the room", "chat.room.lastMessage.membership.leave": "{{user}} left the room", "chat.room.noMessages": "No messages yet", "chat.room.sendButton.label": "Send message", "chat.room.start.description": "This is the beginning of your direct message history with <0>{{fullName}}</0>. Only the two of you see this conversation.", + "chat.room.start.group.description": "This is the beginning of your direct message history with <fullName></fullName> from <spaceName>{{spaceName}}</spaceName>. Only you are in this conversation, unless either of you invite anyone else to join.\n\nThis chat was created to discuss <taskName>{{taskName}}</taskName> task.", "chat.start.button.label": "Message", "chat.start.description": "Go ahead and type in the name of the person you want to start a chat with.", "chat.start.input.error": "The person's name needs to have at least {{count}} letters.", @@ -1420,6 +1423,11 @@ "spaces.tabs.mine.membership_requestor": "Pending requests", "spaces.tasks.add.title": "Create new task", "spaces.tasks.contact.title": "Contact person for this task", + "spaces.tasks.contactOrganization": "Contact organization", + "spaces.tasks.contactOrganization.title": "Start by simply saying \"Hi\"", + "spaces.tasks.contactOrganization.description": "You will now start a chat with <0>{{organization}}</0>'s admins. Feel free to discuss details of the task and how you can help out!", + "spaces.tasks.contactOrganization.subdescription": "No commitments – just a chance to connect.", + "spaces.tasks.contactOrganization.cta": "Start chatting", "spaces.tasks.cta_claim": "Ask to claim action…", "spaces.tasks.delete.confirmation.description": "Are you sure you want to delete this task?\nThis action is irreversible.", "spaces.tasks.delete.confirmation.title": "Delete your task", @@ -1428,6 +1436,7 @@ "spaces.tasks.details.space": "For <0>{{space}}</0>", "spaces.tasks.details.title": "Task details", "spaces.tasks.edit.title": "Edit task", + "spaces.tasks.goToExistingTaskChat": "Open chat", "spaces.tasks.notFound": "Task {{taskId}} not found", "spaces.tasks.published.by.title": "Task created by", "spaces.tasks.spots_claimed": "{{taken}} of {{available}} spots claimed", diff --git a/core/screens/chat/ChatRoomView.tsx b/core/screens/chat/ChatRoomView.tsx deleted file mode 100644 index 247be363ab73bbe0e1004548b6a1eef8663dd10d..0000000000000000000000000000000000000000 --- a/core/screens/chat/ChatRoomView.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import { useAtomValue } from 'jotai' -import React, { useCallback, useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' -// eslint-disable-next-line no-restricted-imports -import { Keyboard, Platform } from 'react-native' - -import { useChatInitialized } from '@holi/chat/src/client/chatState' -import ChatProfileCard from '@holi/chat/src/components/ChatProfileCard' -import ChatRoomActionDrawer from '@holi/chat/src/components/ChatRoomActionDrawer' -import ChatRoomHeaderTitle from '@holi/chat/src/components/ChatRoomHeaderTitle' -import { ChatTextInput } from '@holi/chat/src/components/ChatTextInput' -import RoomMessageList from '@holi/chat/src/components/RoomMessageList' -import RoomMessageListStart from '@holi/chat/src/components/RoomMessageList/RoomMessageListStart' -import { - chatPartnerAtomCreator, - getHoliUserForChatMember, - isRoomCreator, - setLatestMessagesAsRead, - useRequestRoomMessages, - useRoomById, -} from '@holi/chat/src/store' -import { isSpaceChildChatRoom } from '@holi/chat/src/store/utils' -import { getChatInitials, getDefaultAvatarColor } from '@holi/chat/src/utils' -import { useMatrixIdToHoliUserRecord } from '@holi/chat/src/utils/hooks' -import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' -import PermissionsGuard, { PermissionsType } from '@holi/core/components/PermissionsGuard' -import { logError } from '@holi/core/errors/helpers' -import { getInitials } from '@holi/core/helpers' -import { useShowHeaderOnScreenFocus } from '@holi/core/helpers/useShowHeaderOnScreenFocus' -import HeaderBackButton from '@holi/core/navigation/components/HeaderBackButton' -import createParamHooks from '@holi/core/navigation/hooks/useParam' -import useRouting from '@holi/core/navigation/hooks/useRouting' -import HoliLoader from '@holi/ui/components/molecules/HoliLoader' -import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' -import { TrackingEvent } from '@holi/core/tracking' -import useTracking from '@holi/core/tracking/hooks/useTracking' -import { Screen } from '@holi/core/components/Screen' - -const WAIT_FOR_CHATROOM_DELAY = 5000 - -export type ChatRoomPageParams = { - roomId: string -} - -const { useParam } = createParamHooks<ChatRoomPageParams>() - -// number of text messages needed to fill a screen and cause scrollbars -const NUMBER_OF_INITIAL_MSGS = 40 - -const ChatRoomView = () => { - const { t } = useTranslation() - const { navigateBack, replaceRoute } = useRouting() - const chatInitialized = useChatInitialized() - const { track } = useTracking() - - const [roomId = ''] = useParam('roomId') - const [room, messages] = useRoomById(roomId) - const { user: loggedInUser } = useLoggedInUser() - const chatPartner = useAtomValue(useMemo(() => chatPartnerAtomCreator(roomId, loggedInUser), [loggedInUser, roomId])) - - const requestMessages = useRequestRoomMessages(NUMBER_OF_INITIAL_MSGS) - - const roomMemberIds = room?.members.map((m) => m.id) ?? [] - const { matrixIdToHoliUserRecord, loading: loadingUsers } = useMatrixIdToHoliUserRecord(roomMemberIds) - - const amIRoomCreator = loggedInUser && room && isRoomCreator(loggedInUser, room) - const chatPartnerHoliUser = getHoliUserForChatMember(chatPartner, matrixIdToHoliUserRecord) - - const isSpaceRoom = room && isSpaceChildChatRoom(room) - - const { openToast } = useToast() - - const handleBackPress = useCallback(async () => { - await setLatestMessagesAsRead(roomId) - - /** WORKAROUND: probably bug in `useAnimatedStyle` from `react-native-reanimated` for Android - * For Android we need wait till keyboard is hidden before navigating back - * Otherwise the `useAnimatedStyle` in `HoliKeyboardSafeAreaView` will not get updated - * and the view will be stuck in the keyboard position - */ - if (Platform.OS === 'android' && Keyboard.isVisible()) { - const unsubscribe = Keyboard.addListener('keyboardDidHide', () => { - navigateBack('/chat') - unsubscribe?.remove() - }) - Keyboard.dismiss() - } else { - navigateBack('/chat') - } - }, [navigateBack, roomId]) - - const headerTitle = () => { - if (loadingUsers) return null - switch (room?.type) { - case 'private': - return chatPartnerHoliUser ? ( - <ChatRoomHeaderTitle - href={'/profile/' + chatPartnerHoliUser.id} - name={room.name} - imageSrc={chatPartner?.avatar} - imageBlurhash={chatPartnerHoliUser.avatarBlurhash} - initials={getChatInitials(chatPartner?.name)} - defaultColor={getDefaultAvatarColor(chatPartner?.name)} - /> - ) : ( - <ChatRoomHeaderTitle - name={room.name} - imageSrc={chatPartner?.avatar} - initials={getChatInitials(chatPartner?.name)} - defaultColor={getDefaultAvatarColor(chatPartner?.name)} - /> - ) - - case 'space.child': - return ( - <ChatRoomHeaderTitle - href={'/spaces/' + room.content.holiSpaceId} - name={room.content.spaceName} - initials={getInitials(room.content.spaceName)} - imageSrc={room.avatar} - defaultColor={room.content.avatarDefaultColor} - shape="square" - /> - ) - - default: - return null - } - } - - useEffect(() => { - if (chatInitialized && !room) { - // redirect to Chat screen if no room is found after WAIT_FOR_CHATROOM_DELAY ms - const timer = setTimeout(() => { - if (chatInitialized && !room) { - openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) - replaceRoute('/chat').catch((error) => - logError(error, 'Could not redirect logged in user to /chat', { - location: 'ChatRoomView.useEffect', - }) - ) - } - }, WAIT_FOR_CHATROOM_DELAY) - return () => clearTimeout(timer) - } - }, [chatInitialized, openToast, replaceRoute, t, room]) - - // request initial chunk of room messages - useEffect(() => { - if (!room?.id || room?.hasInvitation) return - requestMessages(roomId, false) - }, [requestMessages, roomId, room?.id, room?.hasInvitation]) - - // reset header when screen is focused - useShowHeaderOnScreenFocus() - - const sendTrackingEvent = () => { - if (!loggedInUser || !room) return - - if (isSpaceRoom) { - track( - TrackingEvent.Chat.MessageSent({ - roomId: room.id, - type: 'space_message', - content: 'text', - spaceId: room.content.holiSpaceId, - spaceName: room.content.spaceName, - }) - ) - } else { - track( - TrackingEvent.Chat.MessageSent({ - roomId: room.id, - type: 'direct_message', - content: 'text', - }) - ) - } - } - - // TODO: provide skeleton instead of HoliLoader - if (!chatInitialized || !room || loadingUsers) return <HoliLoader testID={'chat-room-loader'} /> - - return ( - <PermissionsGuard permissionType={PermissionsType.AUTH} redirectRoute="/chat"> - <Screen - preset="fixed" - keyboardVerticalOffset={Platform.OS == 'ios' ? 168 : undefined} //FIXME: This is needed cause of a bug inside screen component that must be addressed. - StickyBottomComponent={ - <ChatTextInput - isVisible={!room.hasInvitation} - userName={chatPartnerHoliUser?.fullName} - roomId={roomId} - onMessageSent={sendTrackingEvent} - /> - } - contentContainerStyle={{ flex: 1 }} - headerOptions={{ - headerTitle, - headerLeft: ({ tintColor }) => <HeaderBackButton tintColor={tintColor} onPress={handleBackPress} />, - headerRight: () => - !!chatPartner && - !isSpaceRoom && ( - <ChatRoomActionDrawer - roomId={roomId} - chatPartner={chatPartner} - chatPartnerFullName={chatPartnerHoliUser?.fullName ?? chatPartner.name} - /> - ), - navigationCustomOptions: { - footerShown: false, - }, - }} - > - {room.hasInvitation ? ( - <ChatProfileCard - member={chatPartner} - roomId={roomId} - user={chatPartnerHoliUser} - userIsLoading={loadingUsers} - /> - ) : ( - <RoomMessageList - roomId={room.id} - messages={messages || []} - matrixIdToHoliUserRecord={matrixIdToHoliUserRecord} - starter={ - amIRoomCreator && - chatPartnerHoliUser && ( - <RoomMessageListStart member={chatPartner} chatPartnerHoliUser={chatPartnerHoliUser} /> - ) - } - onScroll={({ nativeEvent: { contentOffset, contentSize, layoutMeasurement } }) => { - const needToLoad = contentSize.height - contentOffset.y < layoutMeasurement.height * 2 - - if (needToLoad) { - requestMessages(roomId, false) - } - }} - /> - )} - </Screen> - </PermissionsGuard> - ) -} - -export default ChatRoomView diff --git a/core/screens/chat/ChatRoomView/PrivateRoomView.tsx b/core/screens/chat/ChatRoomView/PrivateRoomView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..16432a2144eff5764ee64e1120a7f235aa3cf98d --- /dev/null +++ b/core/screens/chat/ChatRoomView/PrivateRoomView.tsx @@ -0,0 +1,98 @@ +import React from 'react' + +import ChatProfileCard from '@holi/chat/src/components/ChatProfileCard' +import ChatRoomActionDrawer from '@holi/chat/src/components/ChatRoomActionDrawer' +import ChatRoomHeaderTitle from '@holi/chat/src/components/ChatRoomHeaderTitle' +import { ChatTextInput } from '@holi/chat/src/components/ChatTextInput' +import RoomMessageListStart from '@holi/chat/src/components/RoomMessageList/RoomMessageListStart' +import { isRoomCreator } from '@holi/chat/src/store' +import { getChatInitials, getDefaultAvatarColor } from '@holi/chat/src/utils' +import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' +import HeaderBackButton from '@holi/core/navigation/components/HeaderBackButton' +import useTracking from '@holi/core/tracking/hooks/useTracking' + +import type { PrivateRoomViewProps } from './types' +import ChatRoomScreen from '@holi/core/screens/chat/ChatRoomView/components/ChatRoomScreen' +import { TrackingEvent } from '@holi/core/tracking' + +const PrivateRoomView = ({ + room, + messages = [], + chatPartners, + chatPartnerHoliUsers, + matrixIdToHoliUserRecord, +}: PrivateRoomViewProps) => { + const { track } = useTracking() + + const { user: loggedInUser } = useLoggedInUser() + const amIRoomCreator = loggedInUser && room && isRoomCreator(loggedInUser, room) + const roomId = room.id + + const sendTrackingEvent = () => { + track( + TrackingEvent.Chat.MessageSent({ + roomId: room.id, + type: 'direct_message', + content: 'text', + }) + ) + } + + const headerTitle = () => { + const chatPartner = chatPartners?.[0] + const chatPartnerHoliUser = chatPartnerHoliUsers[0] + + return ( + <ChatRoomHeaderTitle + href={'/profile/' + chatPartnerHoliUser.id} + name={room.name} + imageSrc={chatPartner?.avatar} + imageBlurhash={chatPartnerHoliUser.avatarBlurhash} + initials={getChatInitials(chatPartner?.name)} + defaultColor={getDefaultAvatarColor(chatPartner?.name)} + /> + ) + } + + return ( + <ChatRoomScreen + room={room} + messages={messages} + matrixIdToHoliUserRecord={matrixIdToHoliUserRecord} + StickyBottomComponent={ + <ChatTextInput + isVisible={!room.hasInvitation} + userName={chatPartnerHoliUsers[0]?.fullName} + roomId={roomId} + onMessageSent={sendTrackingEvent} + /> + } + headerOptions={{ + headerTitle, + headerLeft: ({ tintColor }) => <HeaderBackButton tintColor={tintColor} />, + headerRight: () => + !!chatPartners && ( + <ChatRoomActionDrawer + roomId={roomId} + chatPartnerFullName={chatPartnerHoliUsers[0]?.fullName ?? chatPartners[0].name} + usersToBlock={chatPartnerHoliUsers} + /> + ), + navigationCustomOptions: { + footerShown: false, + }, + }} + starter={ + amIRoomCreator && + chatPartnerHoliUsers[0] && ( + <RoomMessageListStart members={chatPartners} chatPartnerHoliUsers={chatPartnerHoliUsers} /> + ) + } + chatProfileCard={ + <ChatProfileCard member={room.creator ?? undefined} roomId={roomId} user={chatPartnerHoliUsers[0]} /> + } + /> + ) +} + +export default PrivateRoomView diff --git a/core/screens/chat/ChatRoomView/SpaceChildRoomView.tsx b/core/screens/chat/ChatRoomView/SpaceChildRoomView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..debe83afbc464f673d4bf130553677fecc9f7c2e --- /dev/null +++ b/core/screens/chat/ChatRoomView/SpaceChildRoomView.tsx @@ -0,0 +1,84 @@ +import React from 'react' + +import ChatRoomHeaderTitle from '@holi/chat/src/components/ChatRoomHeaderTitle' +import { ChatTextInput } from '@holi/chat/src/components/ChatTextInput' +import RoomMessageListStart from '@holi/chat/src/components/RoomMessageList/RoomMessageListStart' +import { isRoomCreator } from '@holi/chat/src/store' +import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' +import { getInitials } from '@holi/core/helpers' +import HeaderBackButton from '@holi/core/navigation/components/HeaderBackButton' +import useTracking from '@holi/core/tracking/hooks/useTracking' + +import type { SpaceChildChatRoomProps } from './types' +import ChatRoomScreen from './components/ChatRoomScreen' +import { TrackingEvent } from '@holi/core/tracking' + +const SpaceChildRoomView = ({ + room, + messages = [], + chatPartners, + chatPartnerHoliUsers, + matrixIdToHoliUserRecord, +}: SpaceChildChatRoomProps) => { + const { track } = useTracking() + + const { user: loggedInUser } = useLoggedInUser() + const amIRoomCreator = loggedInUser && room && isRoomCreator(loggedInUser, room) + const roomId = room.id + + const sendTrackingEvent = () => { + track( + TrackingEvent.Chat.MessageSent({ + roomId: room.id, + type: 'space_message', + content: 'text', + spaceId: room.content.holiSpaceId, + spaceName: room.content.spaceName, + }) + ) + } + + const headerTitle = () => { + return ( + <ChatRoomHeaderTitle + href={'/spaces/' + room.content.holiSpaceId} + name={room.content.spaceName} + initials={getInitials(room.content.spaceName)} + imageSrc={room.avatar} + defaultColor={room.content.avatarDefaultColor} + shape="square" + /> + ) + } + + return ( + <ChatRoomScreen + room={room} + messages={messages} + matrixIdToHoliUserRecord={matrixIdToHoliUserRecord} + StickyBottomComponent={ + <ChatTextInput + isVisible={!room.hasInvitation} + userName={chatPartnerHoliUsers[0]?.fullName} + roomId={roomId} + onMessageSent={sendTrackingEvent} + /> + } + headerOptions={{ + headerTitle, + headerLeft: ({ tintColor }) => <HeaderBackButton tintColor={tintColor} />, + navigationCustomOptions: { + footerShown: false, + }, + }} + starter={ + amIRoomCreator && + chatPartnerHoliUsers[0] && ( + <RoomMessageListStart members={chatPartners} chatPartnerHoliUsers={chatPartnerHoliUsers} /> + ) + } + /> + ) +} + +export default SpaceChildRoomView diff --git a/core/screens/chat/ChatRoomView/SpaceTaskRoomView.tsx b/core/screens/chat/ChatRoomView/SpaceTaskRoomView.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4c6b2accf07b605d98b0965197cd816b411f3959 --- /dev/null +++ b/core/screens/chat/ChatRoomView/SpaceTaskRoomView.tsx @@ -0,0 +1,98 @@ +import React from 'react' + +import ChatRoomHeaderTitle from '@holi/chat/src/components/ChatRoomHeaderTitle' +import { ChatTextInput } from '@holi/chat/src/components/ChatTextInput' +import RoomMessageListStart from '@holi/chat/src/components/RoomMessageList/RoomMessageListStart' +import HeaderBackButton from '@holi/core/navigation/components/HeaderBackButton' +import useTracking from '@holi/core/tracking/hooks/useTracking' + +import type { SpaceTaskChatRoomProps } from './types' +import ChatRoomScreen from './components/ChatRoomScreen' +import { TrackingEvent } from '@holi/core/tracking' +import ChatProfileCard from '@holi/chat/src/components/ChatProfileCard' +import ChatRoomActionDrawer from '@holi/chat/src/components/ChatRoomActionDrawer' +import { useSpaceAdminUsers } from '@holi/core/screens/spaces/hooks/useSpaceAdminUsers' + +const SpaceTaskRoomView = ({ + room, + messages = [], + chatPartners, + chatPartnerHoliUsers, + matrixIdToHoliUserRecord, +}: SpaceTaskChatRoomProps) => { + const { track } = useTracking() + + const roomId = room.id + const { taskId, taskName, holiSpaceId: spaceId, spaceName } = room.content + + const { adminUsers = [] } = useSpaceAdminUsers(spaceId) + const chatPartnersWithoutAdmins = chatPartnerHoliUsers.filter( + (user) => !adminUsers.some((admin) => admin.id === user.id) + ) + + const sendTrackingEvent = () => { + track( + TrackingEvent.Chat.MessageSent({ + roomId: room.id, + type: 'space_task_message', + content: 'text', + spaceId, + spaceName, + taskId, + taskName, + }) + ) + } + + return ( + <ChatRoomScreen + room={room} + messages={messages} + matrixIdToHoliUserRecord={matrixIdToHoliUserRecord} + StickyBottomComponent={ + <ChatTextInput + isVisible={!room.hasInvitation} + userName={chatPartnerHoliUsers[0]?.fullName} + roomId={roomId} + isTaskChat + onMessageSent={sendTrackingEvent} + /> + } + headerOptions={{ + headerTitle: () => <ChatRoomHeaderTitle href={'/spaces/' + spaceId + '/tasks/' + taskId} name={taskName} />, + headerLeft: ({ tintColor }) => <HeaderBackButton tintColor={tintColor} />, + headerRight: () => + !!chatPartners && ( + <ChatRoomActionDrawer + roomId={roomId} + chatPartnerFullName={taskName} + usersToBlock={chatPartnersWithoutAdmins} + /> + ), + navigationCustomOptions: { + footerShown: false, + }, + }} + starter={ + <RoomMessageListStart + members={chatPartners} + chatPartnerHoliUsers={chatPartnerHoliUsers} + taskName={taskName} + taskId={taskId} + parentSpaceId={spaceId} + spaceName={spaceName} + /> + } + chatProfileCard={ + <ChatProfileCard + member={room.creator ?? undefined} + roomId={roomId} + user={chatPartnerHoliUsers[0]} + taskName={taskName} + /> + } + /> + ) +} + +export default SpaceTaskRoomView diff --git a/core/screens/chat/ChatRoomView/components/ChatRoomScreen.tsx b/core/screens/chat/ChatRoomView/components/ChatRoomScreen.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bed3b9caad30009932ee5332d788a95ec043d32f --- /dev/null +++ b/core/screens/chat/ChatRoomView/components/ChatRoomScreen.tsx @@ -0,0 +1,78 @@ +import type React from 'react' +import { useEffect } from 'react' +import { Platform } from 'react-native' + +import RoomMessageList from '@holi/chat/src/components/RoomMessageList' +import { useRequestRoomMessages } from '@holi/chat/src/store' +import PermissionsGuard, { PermissionsType } from '@holi/core/components/PermissionsGuard' +import { useShowHeaderOnScreenFocus } from '@holi/core/helpers/useShowHeaderOnScreenFocus' +import { Screen } from '@holi/core/components/Screen' +import type { ScreenProps } from '@holi/core/components/Screen/types' +import type { ChatRoom, ChatRoomMessage, MatrixId } from '@holi/chat/src/client/types' +import type { RoomMessageListProps } from '@holi/chat/src/components/RoomMessageList/types' +import type { User } from '@holi/core/domain/shared/types' + +export interface ChatRoomScreenProps + extends Pick<ScreenProps, 'StickyBottomComponent' | 'headerOptions'>, + Pick<RoomMessageListProps, 'onScroll' | 'starter'> { + room: ChatRoom + messages?: ChatRoomMessage[] + chatProfileCard?: React.ReactNode + matrixIdToHoliUserRecord: Record<MatrixId, User | undefined> +} + +// number of text messages needed to fill a screen and cause scrollbars +const NUMBER_OF_INITIAL_MSGS = 40 + +const ChatRoomScreen = ({ + room, + messages = [], + StickyBottomComponent, + headerOptions, + starter: messageListStarter, + chatProfileCard, + matrixIdToHoliUserRecord, +}: ChatRoomScreenProps) => { + const requestMessages = useRequestRoomMessages(NUMBER_OF_INITIAL_MSGS) + + // request initial chunk of room messages + useEffect(() => { + if (room.hasInvitation) return + requestMessages(room.id, false) + }, [requestMessages, room.id, room.hasInvitation]) + + // reset header when screen is focused + useShowHeaderOnScreenFocus() + + return ( + <PermissionsGuard permissionType={PermissionsType.AUTH} redirectRoute="/chat"> + <Screen + preset="fixed" + keyboardVerticalOffset={Platform.OS == 'ios' ? 168 : undefined} //FIXME: This is needed cause of a bug inside screen component that must be addressed. + StickyBottomComponent={StickyBottomComponent} + contentContainerStyle={{ flex: 1 }} + headerOptions={headerOptions} + > + {room.hasInvitation ? ( + chatProfileCard + ) : ( + <RoomMessageList + roomId={room.id} + messages={messages} + matrixIdToHoliUserRecord={matrixIdToHoliUserRecord} + starter={messageListStarter} + onScroll={({ nativeEvent: { contentOffset, contentSize, layoutMeasurement } }) => { + const needToLoad = contentSize.height - contentOffset.y < layoutMeasurement.height * 2 + + if (needToLoad) { + requestMessages(room.id, false) + } + }} + /> + )} + </Screen> + </PermissionsGuard> + ) +} + +export default ChatRoomScreen diff --git a/core/screens/chat/ChatRoomView/index.tsx b/core/screens/chat/ChatRoomView/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f08bdd02eb04dc330201982bdd747edd92b536b1 --- /dev/null +++ b/core/screens/chat/ChatRoomView/index.tsx @@ -0,0 +1,88 @@ +import { useAtomValue } from 'jotai' +import React, { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' + +import { useChatInitialized } from '@holi/chat/src/client/chatState' +import { chatPartnerAtomCreator, getHoliUserForChatMember, useRoomById } from '@holi/chat/src/store' +import { useMatrixIdToHoliUserRecord } from '@holi/chat/src/utils/hooks' +import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' +import { logError } from '@holi/core/errors/helpers' +import createParamHooks from '@holi/core/navigation/hooks/useParam' +import useRouting from '@holi/core/navigation/hooks/useRouting' +import HoliLoader from '@holi/ui/components/molecules/HoliLoader' +import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' +import PrivateRoomView from '@holi/core/screens/chat/ChatRoomView/PrivateRoomView' +import SpaceChildRoomView from '@holi/core/screens/chat/ChatRoomView/SpaceChildRoomView' +import { HoliError } from '@holi/core/errors/classes/HoliError' +import SpaceTaskRoomView from '@holi/core/screens/chat/ChatRoomView/SpaceTaskRoomView' +import type { User } from '@holi/core/domain/shared/types' + +const WAIT_FOR_CHATROOM_DELAY = 5000 + +export type ChatRoomPageParams = { + roomId: string +} + +const { useParam } = createParamHooks<ChatRoomPageParams>() + +const ChatRoomView = () => { + const { t } = useTranslation() + const { replaceRoute } = useRouting() + const { openToast } = useToast() + + const chatInitialized = useChatInitialized() + + const [roomId = ''] = useParam('roomId') + const { user: loggedInUser } = useLoggedInUser() + const [room, messages] = useRoomById(roomId) + + const chatPartners = + useAtomValue(useMemo(() => chatPartnerAtomCreator(roomId, loggedInUser), [loggedInUser, roomId])) ?? [] + const roomMemberIds = room?.members.map((m) => m.id) ?? [] + const { matrixIdToHoliUserRecord, loading: loadingUsers } = useMatrixIdToHoliUserRecord(roomMemberIds) + + const chatPartnerHoliUsers = chatPartners + .map((chatPartner) => getHoliUserForChatMember(chatPartner, matrixIdToHoliUserRecord)) + .filter((holiUser) => holiUser !== undefined) as User[] + + useEffect(() => { + if (chatInitialized && !room) { + // redirect to Chat screen if no room is found after WAIT_FOR_CHATROOM_DELAY ms + const timer = setTimeout(() => { + if (chatInitialized && !room) { + openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) + replaceRoute('/chat').catch((error) => + logError(error, 'Could not redirect logged in user to /chat', { + location: 'ChatRoomView.useEffect', + }) + ) + } + }, WAIT_FOR_CHATROOM_DELAY) + return () => clearTimeout(timer) + } + }, [chatInitialized, openToast, replaceRoute, t, room]) + + if (!chatInitialized || !room || loadingUsers) return <HoliLoader testID={'chat-room-loader'} /> + + const chatRoomViewProps = { + messages, + chatPartners, + chatPartnerHoliUsers, + loadingUsers, + matrixIdToHoliUserRecord, + } as const + + switch (room.type) { + case 'private': + return <PrivateRoomView room={room} {...chatRoomViewProps} /> + case 'space.child': + return <SpaceChildRoomView room={room} {...chatRoomViewProps} /> + case 'space.task': + return <SpaceTaskRoomView room={room} {...chatRoomViewProps} /> + case 'space': + default: + throw new HoliError(`Unsupported chat room type: ${room.type}`) + } +} + +export default ChatRoomView diff --git a/core/screens/chat/ChatRoomView/types.ts b/core/screens/chat/ChatRoomView/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..5f767fc1fd51a106273677603669a630e1394519 --- /dev/null +++ b/core/screens/chat/ChatRoomView/types.ts @@ -0,0 +1,25 @@ +import type { + ChatRoomMember, + PrivateChatRoom, + SpaceChildChatRoom, + SpaceTaskChatRoom, +} from '@holi/chat/src/client/types' +import type { User } from '@holi/core/domain/shared/types' +import type { ChatRoomScreenProps } from '@holi/core/screens/chat/ChatRoomView/components/ChatRoomScreen' + +interface BaseRoomViewProps extends Pick<ChatRoomScreenProps, 'messages' | 'matrixIdToHoliUserRecord'> { + chatPartners: ChatRoomMember[] + chatPartnerHoliUsers: User[] +} + +export interface PrivateRoomViewProps extends BaseRoomViewProps { + room: PrivateChatRoom +} + +export interface SpaceTaskChatRoomProps extends BaseRoomViewProps { + room: SpaceTaskChatRoom +} + +export interface SpaceChildChatRoomProps extends BaseRoomViewProps { + room: SpaceChildChatRoom +} diff --git a/core/screens/spaces/__tests__/testData.ts b/core/screens/spaces/__tests__/testData.ts index 62c16f80a3d2318852a551b1ecfed7dbcb62b5d5..feceb4606af6a6a9080ce97f0a9d25255a771191 100644 --- a/core/screens/spaces/__tests__/testData.ts +++ b/core/screens/spaces/__tests__/testData.ts @@ -207,6 +207,7 @@ export const task: Task & WithTypename = { thumbnailBlurhash: '', skills, geolocation, + space: {} as Space, __typename: 'Task', } diff --git a/core/screens/spaces/details/components/AskQuestion.tsx b/core/screens/spaces/details/components/AskQuestion.tsx deleted file mode 100644 index a709adb681283168b7bff2e290f0339792fb477f..0000000000000000000000000000000000000000 --- a/core/screens/spaces/details/components/AskQuestion.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { getOrCreateRoom } from '@holi/chat/src/store' -import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' -import { ButtonTracked } from '@holi/core/components/Trackable' -import { HoliError } from '@holi/core/errors/classes/HoliError' -import { logError } from '@holi/core/errors/helpers' -import { chatServerName } from '@holi/core/helpers/config' -import useRouting from '@holi/core/navigation/hooks/useRouting' -import { useSignupModal } from '@holi/core/providers/SignupModalProvider' -import { useRepresentative } from '@holi/core/screens/spaces/hooks/useRepresentative' -import { TrackingEvent } from '@holi/core/tracking' -import { QuestionAnswerLine } from '@holi/icons/src/generated' -import HoliLocalImage from '@holi/ui/components/atoms/HoliLocalImage' -import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' -import HoliActionDrawer from '@holi/ui/components/organisms/HoliActionDrawer' -import { useActionDrawerContext } from '@holi/ui/components/organisms/HoliActionDrawer/Drawer' -import dimensions from '@holi/ui/styles/globalVars/dimensions' -import { Text } from 'holi-bricks/components/text' -import { useStyles } from 'holi-bricks/hooks' -import { Spacing } from 'holi-bricks/tokens' -import { createStyleSheet } from 'holi-bricks/utils' -import React, { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { Platform, View } from 'react-native' - -const StartChatDrawerActionButton = ({ spaceIdOrName }: AskQuestionProps) => { - const { t } = useTranslation() - const { isLoggedIn } = useLoggedInUser() - const { hideDrawer } = useActionDrawerContext() - const { openSignupModal } = useSignupModal() - const { openToast } = useToast() - const { navigate } = useRouting() - const { representativeIdentity } = useRepresentative(spaceIdOrName) - const [isLoading, setIsLoading] = useState(false) - - const safelyOpenSignupModal = () => { - /* prevent having two modals open as startChat shows another one for guest, which breaks on ios */ - hideDrawer() - - openSignupModal({ - description: t('chat.start.loginRedirect.text'), - trackTrigger: 'START_CHAT', - }) - } - - async function openChatRoom(chatPartnerIdentity: string) { - try { - const chatPartnerMatrixId = '@' + chatPartnerIdentity + ':' + chatServerName - const { room_id } = await getOrCreateRoom([chatPartnerMatrixId]) - - if (!room_id) { - openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) - } else { - navigate('/chat/rooms/' + room_id) - hideDrawer() - } - } catch (e) { - logError(e, 'Failed to start ask-question-chat with chat partner ' + chatPartnerIdentity, { - location: 'StartChatDrawerActionButton.openChatRoom', - }) - openToast(t('chat.room.error.openRoom'), HoliToastType.ERROR) - } - } - - const start = async () => { - setIsLoading(true) - - if (!isLoggedIn) { - safelyOpenSignupModal() - setIsLoading(false) - return - } - - if (!representativeIdentity) { - logError( - new HoliError('Can not create a chat without a space representatives identity'), - 'Failed to start chat', - { - location: 'StartChatDrawerActionButton.start', - } - ) - setIsLoading(false) - return - } - - try { - await openChatRoom(representativeIdentity) - } finally { - setIsLoading(false) - } - } - - return ( - <ButtonTracked - trackingEvent={TrackingEvent.SpaceAskQuestion.askedQuestion} - loading={isLoading} - disabled={isLoading} - onPress={start} - variant="primary" - size="lg" - label={t('spaces.connectCta')} - /> - ) -} - -type AskQuestionProps = { - spaceIdOrName?: string -} - -const AskQuestionButton = (props: { - testID: string | undefined - onPress: () => void -}) => { - const { styles } = useStyles(askQuestionButtonStyles) - const { t } = useTranslation() - const label = t('spaces.askInfo') - return ( - <View - style={[ - styles.ctaWrapper, - Platform.OS === 'web' && { - position: 'fixed', - }, - ]} - testID={props.testID} - > - <ButtonTracked - trackingEvent={TrackingEvent.SpaceAskQuestion.startedConversation} - onPress={props.onPress} - variant="primary" - icon={QuestionAnswerLine} - size="lg" - label={label} - /> - </View> - ) -} - -const askQuestionButtonStyles = createStyleSheet(() => ({ - ctaWrapper: { - padding: Spacing.xxs, - left: 0, - right: 0, - bottom: 0, - maxWidth: Number(dimensions.maxScreenWidth), - position: 'absolute', - marginHorizontal: 'auto', - }, -})) - -export const AskQuestion = ({ spaceIdOrName }: AskQuestionProps) => { - const { styles } = useStyles(stylesheet) - const { t } = useTranslation() - - if (!spaceIdOrName) return null - - return ( - <HoliActionDrawer.Drawer - renderCustomButton={(onPress, testID) => <AskQuestionButton testID={testID} onPress={onPress} />} - label={t('global.options')} - testID="ask-space-question" - > - <View style={styles.ctaDrawerContentWrapper}> - <HoliLocalImage - imageSource={require('@holi/ui/assets/img/spaces/chat.svg')} - isSvg - label="chat" - width={40} - height={36} - resizeMode="contain" - /> - - <View style={styles.ctaDrawerContent}> - <Text size="3xl">{t('spaces.connectTitle')}</Text> - <Text size="md">{t('spaces.connectDescription')}</Text> - </View> - - <StartChatDrawerActionButton spaceIdOrName={spaceIdOrName} /> - </View> - </HoliActionDrawer.Drawer> - ) -} - -const stylesheet = createStyleSheet(() => ({ - ctaDrawerContent: { - flexDirection: 'column', - gap: 8, - }, - ctaDrawerContentWrapper: { - flexDirection: 'column', - gap: 16, - }, -})) diff --git a/core/screens/spaces/details/components/AskQuestion/AskQuestionActionButton.tsx b/core/screens/spaces/details/components/AskQuestion/AskQuestionActionButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e21bc7b716b98f533abd080d52ec59729d181a7 --- /dev/null +++ b/core/screens/spaces/details/components/AskQuestion/AskQuestionActionButton.tsx @@ -0,0 +1,51 @@ +import { ButtonTracked } from '@holi/core/components/Trackable' +import { TrackingEvent } from '@holi/core/tracking' +import { QuestionAnswerLine } from '@holi/icons/src/generated' +import dimensions from '@holi/ui/styles/globalVars/dimensions' +import { useStyles } from 'holi-bricks/hooks' +import { Spacing } from 'holi-bricks/tokens' +import { createStyleSheet } from 'holi-bricks/utils' +import React, {} from 'react' +import { Platform, View } from 'react-native' +import { useTranslation } from 'react-i18next' + +export const AskQuestionActionButton = (props: { + testID: string | undefined + onPress: () => void +}) => { + const { styles } = useStyles(askQuestionButtonStyles) + const { t } = useTranslation() + const label = t('spaces.askInfo') + return ( + <View + style={[ + styles.ctaWrapper, + Platform.OS === 'web' && { + position: 'fixed', + }, + ]} + testID={props.testID} + > + <ButtonTracked + trackingEvent={TrackingEvent.SpaceAskQuestion.startedConversation} + onPress={props.onPress} + variant="primary" + icon={QuestionAnswerLine} + size="lg" + label={label} + /> + </View> + ) +} + +const askQuestionButtonStyles = createStyleSheet(() => ({ + ctaWrapper: { + padding: Spacing.xxs, + left: 0, + right: 0, + bottom: 0, + maxWidth: Number(dimensions.maxScreenWidth), + position: 'absolute', + marginHorizontal: 'auto', + }, +})) diff --git a/core/screens/spaces/details/components/AskQuestion/index.tsx b/core/screens/spaces/details/components/AskQuestion/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9c77e294383004b74a7236db1d02c18cf38fbbc8 --- /dev/null +++ b/core/screens/spaces/details/components/AskQuestion/index.tsx @@ -0,0 +1,74 @@ +import { toMatrixIdentity, getOrCreatePrivateRoom } from '@holi/chat/src/store' +import { AskQuestionActionButton } from '@holi/core/screens/spaces/details/components/AskQuestion/AskQuestionActionButton' +import { useRepresentative } from '@holi/core/screens/spaces/hooks/useRepresentative' +import { StartChatButton } from '@holi/core/screens/spaces/tasks/components/ContactOrganization/StartChatButton' +import { TrackingEvent } from '@holi/core/tracking' +import HoliLocalImage from '@holi/ui/components/atoms/HoliLocalImage' +import HoliActionDrawer from '@holi/ui/components/organisms/HoliActionDrawer' +import { Text } from 'holi-bricks/components/text' +import { useStyles } from 'holi-bricks/hooks' +import { createStyleSheet } from 'holi-bricks/utils' +import React from 'react' +import { useTranslation } from 'react-i18next' +import { View } from 'react-native' + +export type AskQuestionProps = { + spaceIdOrName?: string +} + +export const AskQuestion = ({ spaceIdOrName }: AskQuestionProps) => { + const { styles } = useStyles(stylesheet) + const { t } = useTranslation() + const { representative } = useRepresentative(spaceIdOrName) + + const getOrCreateRoom = async () => { + if (!representative) return + const chatPartnerMatrixId = toMatrixIdentity(representative.identity) + const { room_id } = await getOrCreatePrivateRoom([chatPartnerMatrixId]) + return room_id + } + + if (!spaceIdOrName) return null + + return ( + <HoliActionDrawer.Drawer + renderCustomButton={(onPress, testID) => <AskQuestionActionButton testID={testID} onPress={onPress} />} + label={t('global.options')} + testID="ask-space-question" + > + <View style={styles.ctaDrawerContentWrapper}> + <HoliLocalImage + imageSource={require('@holi/ui/assets/img/spaces/chat.svg')} + isSvg + label="chat" + width={40} + height={36} + resizeMode="contain" + /> + + <View style={styles.ctaDrawerContent}> + <Text size="3xl">{t('spaces.connectTitle')}</Text> + <Text size="md">{t('spaces.connectDescription')}</Text> + </View> + + <StartChatButton + chatPartners={representative ? [representative] : []} + label={t('spaces.connectCta')} + trackingEvent={TrackingEvent.SpaceAskQuestion.askedQuestion} + getRoomId={getOrCreateRoom} + /> + </View> + </HoliActionDrawer.Drawer> + ) +} + +const stylesheet = createStyleSheet(() => ({ + ctaDrawerContent: { + flexDirection: 'column', + gap: 8, + }, + ctaDrawerContentWrapper: { + flexDirection: 'column', + gap: 16, + }, +})) diff --git a/core/screens/spaces/details/components/StartSpaceChat.tsx b/core/screens/spaces/details/components/StartSpaceChat.tsx index 95cf0f8adf79d732593f3a08b7e27021b160b20c..26dbaf3ceee40ecd8c3c165a5f0bd18fd0d2a686 100644 --- a/core/screens/spaces/details/components/StartSpaceChat.tsx +++ b/core/screens/spaces/details/components/StartSpaceChat.tsx @@ -1,4 +1,4 @@ -import { getOrCreateRoom } from '@holi/chat/src/store' +import { getOrCreatePrivateRoom } from '@holi/chat/src/store' import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' import { ButtonTracked } from '@holi/core/components/Trackable' import { HoliError } from '@holi/core/errors/classes/HoliError' @@ -37,7 +37,7 @@ const ButtonWrapper = ({ spaceIdOrName, drawerButtonLabel }: ButtonWrapperProps) const { openSignupModal } = useSignupModal() const { openToast } = useToast() const { navigate } = useRouting() - const { representativeIdentity } = useRepresentative(spaceIdOrName) + const { representative } = useRepresentative(spaceIdOrName) const { t } = useTranslation() const [isLoading, setIsLoading] = useState(false) @@ -45,7 +45,7 @@ const ButtonWrapper = ({ spaceIdOrName, drawerButtonLabel }: ButtonWrapperProps) async function openChatRoom(chatPartnerIdentity: string) { try { const chatPartnerMatrixId = '@' + chatPartnerIdentity + ':' + chatServerName - const { room_id } = await getOrCreateRoom([chatPartnerMatrixId]) + const { room_id } = await getOrCreatePrivateRoom([chatPartnerMatrixId]) if (!room_id) { openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) @@ -79,7 +79,7 @@ const ButtonWrapper = ({ spaceIdOrName, drawerButtonLabel }: ButtonWrapperProps) return } - if (!representativeIdentity) { + if (!representative?.identity) { logError( new HoliError('Can not create a chat without a space representatives identity'), 'Failed to start chat', @@ -92,7 +92,7 @@ const ButtonWrapper = ({ spaceIdOrName, drawerButtonLabel }: ButtonWrapperProps) } try { - await openChatRoom(representativeIdentity) + await openChatRoom(representative.identity) } finally { setIsLoading(false) } @@ -100,7 +100,7 @@ const ButtonWrapper = ({ spaceIdOrName, drawerButtonLabel }: ButtonWrapperProps) return ( <ButtonTracked - trackingEvent={TrackingEvent.TaskContactOrganization.startedConversation} + trackingEvent={TrackingEvent.TaskContactOrganization.startedConversationFromTask} loading={isLoading} disabled={isLoading} onPress={start} @@ -124,7 +124,7 @@ export const StartSpaceChatButton = ({ <HoliActionDrawer.Drawer renderCustomButton={(onPress, testID) => ( <ButtonTracked - trackingEvent={TrackingEvent.TaskContactOrganization.contactOrganization} + trackingEvent={TrackingEvent.TaskContactOrganization.contactedOrganization} onPress={onPress} variant="primary" size="lg" diff --git a/core/screens/spaces/details/queries.ts b/core/screens/spaces/details/queries.ts index 088401c92075bd3081ebb1e1d16f438e13b30446..9aa5fd9cec1bd68c7cd4a0c579168270861fc54c 100644 --- a/core/screens/spaces/details/queries.ts +++ b/core/screens/spaces/details/queries.ts @@ -7,6 +7,7 @@ import { SpaceMemberListFragment, SpaceMembershipRequestListFragment, } from '@holi/core/screens/spaces/queries' +import type { Task } from '@holi/core/screens/spaces/types' export const spaceMembersQuery = gql` ${SpaceMemberListFragment} @@ -83,6 +84,15 @@ export const taskByIdQuery = gql` query taskById($id: UUID!) { taskById(id: $id) { ...FullTask + space { + id + name + title + } } } ` + +export interface TaskByIdResponse { + taskById: Task +} diff --git a/core/screens/spaces/hooks/useRepresentative.ts b/core/screens/spaces/hooks/useRepresentative.ts index 190eecbaebdc1fe72ecef1bda17609cc9cf85b50..da2a0c2dc92c6a483191c89f61cedcf9ad97d0f8 100644 --- a/core/screens/spaces/hooks/useRepresentative.ts +++ b/core/screens/spaces/hooks/useRepresentative.ts @@ -21,14 +21,12 @@ export const useRepresentative = (spaceIdOrName?: string) => { }, }) - const representative = data?.spaceById - const representativeIdentity = - representative && representative?.representatives?.length > 0 ? representative?.representatives[0] : undefined + const representative = data?.spaceById.representatives[0].user return { loading, error, - representativeIdentity: representativeIdentity?.user?.identity, + representative, called, refetch, } diff --git a/core/screens/spaces/hooks/useSpaceAdminUsers.ts b/core/screens/spaces/hooks/useSpaceAdminUsers.ts new file mode 100644 index 0000000000000000000000000000000000000000..63d116ca4b70120e173c2d4401cfd33915ec5552 --- /dev/null +++ b/core/screens/spaces/hooks/useSpaceAdminUsers.ts @@ -0,0 +1,17 @@ +import { useSpace } from '@holi/core/screens/spaces/hooks/useSpace' + +export const useSpaceAdminUsers = (spaceIdOrName?: string) => { + const { loading, error, space, refetch, called } = useSpace(spaceIdOrName) + + const members = space?.members.data + const adminMembers = members?.filter((member) => member.isAdministrator) + const adminUsers = adminMembers?.map((admin) => admin.user) + + return { + loading, + error, + adminUsers, + called, + refetch, + } +} diff --git a/core/screens/spaces/tasks/TaskDetails.tsx b/core/screens/spaces/tasks/TaskDetails.tsx index fd88a2104c14576f32dd5314dfbb307d2796276a..2ffc640f6d3f73233e769917f7473bd2db647add 100644 --- a/core/screens/spaces/tasks/TaskDetails.tsx +++ b/core/screens/spaces/tasks/TaskDetails.tsx @@ -1,5 +1,4 @@ import React, { memo, useCallback } from 'react' -import { useTranslation } from 'react-i18next' import { Screen } from '@holi/core/components/Screen' import { DetailsScreen } from '@holi/core/layouts/DetailsScreen' import { Stack } from 'holi-bricks/components/stack' @@ -8,7 +7,9 @@ import HeaderBackButton from '@holi/core/navigation/components/HeaderBackButton' import { TaskDrawer } from '@holi/core/screens/spaces/tasks/components/TaskDrawer' import { Platform } from 'react-native' import { white } from 'holi-bricks/tokens' -import { StartSpaceChatButton } from '@holi/core/screens/spaces/details/components/StartSpaceChat' +import { ContactOrganization } from '@holi/core/screens/spaces/tasks/components/ContactOrganization' +import { useFeatureFlag } from '@holi/core/featureFlags/hooks/useFeatureFlag' +import { FeatureFlagKey } from '@holi/core/featureFlags/constants' export type TaskDetailsParams = { taskId: string @@ -21,8 +22,10 @@ const TaskDetailsContainer = () => { spaceName, id, refetch, + spaceId, + spaceTitle, } = useTaskDetailsData() - const { t } = useTranslation() + const displayCta = useFeatureFlag(FeatureFlagKey.TASK_CONTACT_CTA) const renderActionDrawer = useCallback( (tintColor?: string) => <TaskDrawer tintColor={tintColor} isOwner={isOwner} spaceName={spaceName} taskId={id} />, @@ -59,16 +62,15 @@ const TaskDetailsContainer = () => { alignSelf="center" width="100%" > - <StartSpaceChatButton - preDrawerButtonProps={{ - label: t('task.cta'), - inline: Platform.OS === 'web', - }} - spaceIdOrName={spaceName} - drawerTitle={t('task.startChat.drawer.title')} - drawerContent={t('task.startChat.drawer.content', { spaceName: spaceName })} - drawerButtonLabel={t('task.startChat.drawer.cta')} - /> + {displayCta && ( + <ContactOrganization + spaceIdOrName={spaceName} + taskName={title} + taskId={id} + spaceTitle={spaceTitle} + spaceId={spaceId} + /> + )} </Stack> } > diff --git a/core/screens/spaces/tasks/components/ContactOrganization/ContactOrganizationActionButton.tsx b/core/screens/spaces/tasks/components/ContactOrganization/ContactOrganizationActionButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c50a7dad154e3170c35cd2fbaa7d60b706f4d1ce --- /dev/null +++ b/core/screens/spaces/tasks/components/ContactOrganization/ContactOrganizationActionButton.tsx @@ -0,0 +1,56 @@ +import { ButtonTracked } from '@holi/core/components/Trackable' +import { TrackingEvent } from '@holi/core/tracking' +import { ChatSmile2 } from '@holi/icons/src/generated' +import dimensions from '@holi/ui/styles/globalVars/dimensions' +import { useStyles } from 'holi-bricks/hooks' +import { Spacing } from 'holi-bricks/tokens' +import { createStyleSheet } from 'holi-bricks/utils' +import React, {} from 'react' +import { Platform, View } from 'react-native' +import { useTranslation } from 'react-i18next' + +interface Props { + onPress: () => void + loading?: boolean + testID?: string +} + +export const ContactOrganizationActionButton = ({ onPress, testID, loading }: Props) => { + const { styles } = useStyles(stylesheet) + const { t } = useTranslation() + + return ( + <View + style={[ + styles.ctaWrapper, + Platform.OS === 'web' && { + position: 'fixed', + }, + ]} + testID={testID} + > + <ButtonTracked + trackingEvent={TrackingEvent.TaskContactOrganization.startedConversationFromTask} + onPress={onPress} + loading={loading} + disabled={loading} + variant="primary" + icon={ChatSmile2} + size="lg" + label={t('spaces.tasks.contactOrganization')} + /> + </View> + ) +} + +const stylesheet = createStyleSheet(() => ({ + ctaWrapper: { + padding: Spacing.xxs, + left: 0, + right: 0, + bottom: 0, + maxWidth: Number(dimensions.maxScreenWidth), + position: 'absolute', + marginHorizontal: 'auto', + }, +})) diff --git a/core/screens/spaces/tasks/components/ContactOrganization/StartChatButton.tsx b/core/screens/spaces/tasks/components/ContactOrganization/StartChatButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8442f57376036303834d02bec024e668b38077c7 --- /dev/null +++ b/core/screens/spaces/tasks/components/ContactOrganization/StartChatButton.tsx @@ -0,0 +1,105 @@ +import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' +import { ButtonTracked } from '@holi/core/components/Trackable' +import { HoliError } from '@holi/core/errors/classes/HoliError' +import { logError } from '@holi/core/errors/helpers' +import useRouting from '@holi/core/navigation/hooks/useRouting' +import { useSignupModal } from '@holi/core/providers/SignupModalProvider' +import type { TrackingEvent } from '@holi/core/tracking' +import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' +import { useActionDrawerContext } from '@holi/ui/components/organisms/HoliActionDrawer/Drawer' +import React, { useState } from 'react' + +import { useTranslation } from 'react-i18next' +import type { User } from '@holi/core/domain/shared/types' +import type { HoliIconType } from '@holi/icons/src/HoliIconType' + +type StartChatButtonProps = { + chatPartners?: User[] + icon?: HoliIconType + label: string + trackingEvent: TrackingEvent + getRoomId: () => Promise<string | undefined> +} + +export const StartChatButton = ({ + chatPartners, + icon, + label, + trackingEvent, + getRoomId: getOrCreateRoom, +}: StartChatButtonProps) => { + const { t } = useTranslation() + const { isLoggedIn } = useLoggedInUser() + const { hideDrawer } = useActionDrawerContext() + const { openSignupModal } = useSignupModal() + const { openToast } = useToast() + const { navigate } = useRouting() + + const [isLoading, setIsLoading] = useState(false) + + const safelyOpenSignupModal = () => { + /* prevent having two modals open as startChat shows another one for guest, which breaks on ios */ + hideDrawer() + + openSignupModal({ + description: t('chat.start.loginRedirect.text'), + trackTrigger: 'START_CHAT', + }) + } + + async function openChatRoom(chatPartners: User[]) { + try { + const room_id = await getOrCreateRoom() + + if (!room_id) { + openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) + } else { + navigate('/chat/rooms/' + room_id) + hideDrawer() + } + } catch (e) { + logError(e, 'Failed to start contact-organization-chat with chat partner ' + chatPartners, { + location: 'StartChatDrawerActionButton.openChatRoom', + }) + openToast(t('chat.room.error.openRoom'), HoliToastType.ERROR) + } + } + + const startChat = async () => { + if (!isLoggedIn) { + safelyOpenSignupModal() + return + } + + if (!chatPartners) { + logError( + new HoliError('Can not create a chat without a space representatives identity'), + 'Failed to start chat', + { + location: 'StartChatDrawerActionButton.start', + } + ) + return + } + + setIsLoading(true) + try { + await openChatRoom(chatPartners) + } finally { + setIsLoading(false) + } + } + + return ( + <ButtonTracked + trackingEvent={trackingEvent} + loading={isLoading} + disabled={isLoading} + onPress={startChat} + variant="primary" + size="lg" + icon={icon} + label={label} + /> + ) +} diff --git a/core/screens/spaces/tasks/components/ContactOrganization/index.tsx b/core/screens/spaces/tasks/components/ContactOrganization/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6ea6a7eb36f3925d6a6ec3b3f69e86e067d2a819 --- /dev/null +++ b/core/screens/spaces/tasks/components/ContactOrganization/index.tsx @@ -0,0 +1,167 @@ +import { getExistingRoomId, getOrCreateSpaceTaskRoom, toMatrixIdentity } from '@holi/chat/src/store' +import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' +import useRouting from '@holi/core/navigation/hooks/useRouting' +import HoliLocalImage from '@holi/ui/components/atoms/HoliLocalImage' +import HoliActionDrawer from '@holi/ui/components/organisms/HoliActionDrawer' +import dimensions from '@holi/ui/styles/globalVars/dimensions' +import { Text } from 'holi-bricks/components/text' +import { Button } from 'holi-bricks/components/button' +import { useStyles } from 'holi-bricks/hooks' +import { Spacing } from 'holi-bricks/tokens' +import { createStyleSheet } from 'holi-bricks/utils' +import React from 'react' +import { Platform, View } from 'react-native' +import { Trans, useTranslation } from 'react-i18next' +import { useSpaceAdminUsers } from '@holi/core/screens/spaces/hooks/useSpaceAdminUsers' +import { useChatInitialized } from '@holi/chat/src/client/chatState' + +import { StartChatButton } from './StartChatButton' +import { ContactOrganizationActionButton } from './ContactOrganizationActionButton' +import { TrackingEvent } from '@holi/core/tracking' +import { ChatSmile2 } from '@holi/icons/src/generated' +import { useErrorHandling } from '@holi/core/errors/hooks' + +interface Props { + spaceIdOrName?: string + taskName: string + taskId?: string + spaceTitle?: string + spaceId?: string + alreadyStarted?: boolean +} + +export const ContactOrganization = ({ spaceIdOrName, spaceId, spaceTitle, taskId, taskName }: Props) => { + const { styles } = useStyles(stylesheet) + const { t } = useTranslation() + const { adminUsers, loading: adminUsersLoading } = useSpaceAdminUsers(spaceIdOrName) + const { user: loggedInUser } = useLoggedInUser() + const isChatInitialized = useChatInitialized() + const { displayError } = useErrorHandling() + + const { navigate } = useRouting() + + if (!adminUsers) return null + + const getOrCreateRoom = async () => { + if (!spaceId || !taskId || !spaceTitle) { + displayError(new Error('Cannot create a task room chat'), 'Cannot create a task room chat', { + location: 'ContactOrganization.getOrCreateRoom', + spaceIdOrName, + }) + return undefined + } + + const chatPartnerMatrixIds = adminUsers.map(({ identity }) => toMatrixIdentity(identity)) + const { room_id } = await getOrCreateSpaceTaskRoom(chatPartnerMatrixIds, { + type: 'space.task', + content: { + taskId: taskId, + taskName: taskName, + spaceName: spaceTitle, + holiSpaceId: spaceId, + }, + }) + return room_id + } + + const findExistingRoomId = () => { + if (!loggedInUser) return undefined + if (!isChatInitialized) return undefined + if (!adminUsers) return undefined + + const adminMatrixUserIds = adminUsers.map(({ identity }) => toMatrixIdentity(identity)) + return getExistingRoomId(adminMatrixUserIds, 'space.task') + } + const existingRoomId = findExistingRoomId() + + const isSpaceAdmin = adminUsers?.some((admin) => admin.id === loggedInUser?.id) + if (!spaceIdOrName || isSpaceAdmin) return null + + if (existingRoomId) { + return ( + <View + style={[ + styles.ctaWrapper, + Platform.OS === 'web' && { + position: 'fixed', + }, + ]} + > + <Button + label={t('spaces.tasks.goToExistingTaskChat')} + onPress={() => navigate('/chat/rooms/' + existingRoomId)} + /> + </View> + ) + } + + return ( + <HoliActionDrawer.Drawer + renderCustomButton={(onPress, testID) => ( + <ContactOrganizationActionButton + loading={!isChatInitialized || adminUsersLoading} + testID={testID} + onPress={onPress} + /> + )} + label={t('global.options')} + testID="contact-organization-drawer" + > + <View style={styles.ctaDrawerContentWrapper}> + <View style={styles.ctaDrawerContent}> + <HoliLocalImage + imageSource={require('@holi/ui/assets/img/spaces/chat.svg')} + isSvg + label="chat" + width={40} + height={40} + resizeMode="contain" + /> + + <Text size="3xl">{t('spaces.tasks.contactOrganization.title')}</Text> + + <Text size="md" color="support"> + <Trans t={t} i18nKey="spaces.tasks.contactOrganization.description" values={{ organization: spaceTitle }}> + <Text size="lg" textAlign="center" /> + </Trans> + </Text> + + <Text size="sm" color="support"> + {t('spaces.tasks.contactOrganization.subdescription')} + </Text> + </View> + + <StartChatButton + chatPartners={adminUsers} + trackingEvent={TrackingEvent.TaskContactOrganization.contactedOrganization} + icon={ChatSmile2} + label={t('spaces.tasks.contactOrganization.cta')} + getRoomId={getOrCreateRoom} + /> + </View> + </HoliActionDrawer.Drawer> + ) +} + +const stylesheet = createStyleSheet({ + ctaDrawerContent: { + flexDirection: 'column', + gap: Spacing['3xs'], + }, + ctaDrawerContentWrapper: { + flexDirection: 'column', + gap: Spacing.xs, + }, + image: { + alignSelf: 'center', + }, + ctaWrapper: { + padding: Spacing.xxs, + left: 0, + right: 0, + bottom: 0, + maxWidth: Number(dimensions.maxScreenWidth), + position: 'absolute', + marginHorizontal: 'auto', + }, +}) diff --git a/core/screens/spaces/tasks/tasks.graphql b/core/screens/spaces/tasks/tasks.graphql index 64a7164da173af241db9e3a33c31313568c8e186..906517e3172fc9e932c62ca32026877cb4ae41f7 100644 --- a/core/screens/spaces/tasks/tasks.graphql +++ b/core/screens/spaces/tasks/tasks.graphql @@ -24,6 +24,7 @@ query taskById($id: UUID!) { id connectionStatusToMyself name + title avatar avatarBlurhash topics { diff --git a/core/screens/spaces/tasks/useTaskDetailsData/useTaskDetailsData.ts b/core/screens/spaces/tasks/useTaskDetailsData/useTaskDetailsData.ts index b85f51f15728804c77a5eab049ec7652a18b7507..494b2c7d2187a7ac3cb894394a275d1eac5925d1 100644 --- a/core/screens/spaces/tasks/useTaskDetailsData/useTaskDetailsData.ts +++ b/core/screens/spaces/tasks/useTaskDetailsData/useTaskDetailsData.ts @@ -9,6 +9,7 @@ import { CalendarEvent, MapPin2, UserFilled } from '@holi/icons/src/generated' import { useTranslation } from 'react-i18next' import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' import { useRef } from 'react' +import { getFormattedDate } from '@holi/core/i18n/helpers/dateHelper' export type TaskDetailsParams = { spaceIdOrName: string @@ -21,6 +22,8 @@ export type UseTaskDetailsData = { spaceName?: string isOwner?: boolean refetch(): Promise<void> + spaceId?: string + spaceTitle?: string } const { useParam, useUUIDParam } = createParamHooks<TaskDetailsParams>() @@ -30,7 +33,7 @@ export const useTaskDetailsData = (): UseTaskDetailsData => { const [spaceIdOrName] = useParam('spaceIdOrName') const { displayError } = useErrorHandling() const { replaceRoute } = useRouting() - const { t } = useTranslation() + const { t, i18n } = useTranslation() const [taskId] = useUUIDParam('taskId') const loginState = useLoggedInUser() const { data, loading, refetch } = useTaskByIdQuery({ @@ -100,6 +103,8 @@ export const useTaskDetailsData = (): UseTaskDetailsData => { const infoList = [locationType, slots, regularity].filter((detail) => detail !== undefined) as DetailsScreenInfoList[] + const formattedDate = getFormattedDate(data?.taskById?.created, i18n.language) + return { spaceName: data?.taskById?.space?.name, refetch: async () => { @@ -123,7 +128,7 @@ export const useTaskDetailsData = (): UseTaskDetailsData => { uri: data?.taskById?.thumbnail, }, author: { - topTitle: `${t('postedOnHoli')} ∙ ${new Date(data?.taskById?.created).toLocaleDateString('en')}`, + topTitle: `${formattedDate} ∙ ${t('postedOnHoli')}`, title: data?.taskById?.space?.name || data?.taskById?.name || '??', subtitle: data?.taskById?.creator?.fullName, mainAvatar: { @@ -138,5 +143,7 @@ export const useTaskDetailsData = (): UseTaskDetailsData => { : undefined, }, }, + spaceId: data?.taskById?.space?.id, + spaceTitle: data?.taskById?.space?.title, } } diff --git a/core/screens/spaces/types.ts b/core/screens/spaces/types.ts index 0f15b679a37df56c7d56fd191463315ef2eff293..44f967cc393d2844a7cba60e724336ce0a46014c 100644 --- a/core/screens/spaces/types.ts +++ b/core/screens/spaces/types.ts @@ -162,7 +162,7 @@ export interface Task { geolocation?: GeolocationPoint thumbnail?: string thumbnailBlurhash?: string - space?: Space + space: Space } export enum VisibilityType { diff --git a/core/screens/userprofile/components/OwnConnectionsTab.tsx b/core/screens/userprofile/components/OwnConnectionsTab.tsx index 3e25dcc210999e657a43c44c51822b62a06281a5..a14c2634a0c3447ebbed46d31245770bd67be1a7 100644 --- a/core/screens/userprofile/components/OwnConnectionsTab.tsx +++ b/core/screens/userprofile/components/OwnConnectionsTab.tsx @@ -31,7 +31,7 @@ import { FeatureFlagKey } from '@holi/core/featureFlags/constants' import { useChatInitialized } from '@holi/chat/src/client/chatState' import { HoliError } from '@holi/core/errors/classes/HoliError' import { chatServerName } from '@holi/core/helpers/config' -import { getOrCreateRoom } from '@holi/chat/src/store' +import { getOrCreatePrivateRoom } from '@holi/chat/src/store' import { useUserConnection } from '@holi/core/helpers' import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' import useTracking from '@holi/core/tracking/hooks/useTracking' @@ -126,7 +126,7 @@ const ConnectionsTab = ({ emptyText, emptyCallToAction }: ConnectionsTabProps) = try { // TODO: the correct Matrix identity should come from Okuna const fullIdentity = '@' + identity + ':' + chatServerName - const { room_id } = await getOrCreateRoom([fullIdentity]) + const { room_id } = await getOrCreatePrivateRoom([fullIdentity]) if (!room_id) { openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) diff --git a/core/screens/userprofile/components/StartChatButton.tsx b/core/screens/userprofile/components/StartChatButton.tsx index 538f673b6e1b72986ef215b5a28b1a3cd93099a0..a9cabeb1b0a2c98ec43258bdb9b29f891db5e1ad 100644 --- a/core/screens/userprofile/components/StartChatButton.tsx +++ b/core/screens/userprofile/components/StartChatButton.tsx @@ -2,29 +2,25 @@ import React from 'react' import { useTranslation } from 'react-i18next' import { useChatInitialized } from '@holi/chat/src/client/chatState' -import { getOrCreateRoom } from '@holi/chat/src/store' +import { getOrCreatePrivateRoom, toMatrixIdentity } from '@holi/chat/src/store' import { useLoggedInUser } from '@holi/core/auth/hooks/useLoggedInUser' import type { User } from '@holi/core/domain/shared/types' import { HoliError } from '@holi/core/errors/classes/HoliError' import { logError } from '@holi/core/errors/helpers' import { FeatureFlagKey } from '@holi/core/featureFlags/constants' import { useFeatureFlagWithMaintenance } from '@holi/core/featureFlags/hooks/useFeatureFlagWithMaintenance' -import { chatServerName } from '@holi/core/helpers/config' import useRouting from '@holi/core/navigation/hooks/useRouting' import { useSignupModal } from '@holi/core/providers/SignupModalProvider' import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' import { Button, type ButtonProps } from 'holi-bricks/components/button' -export function StartChatButton({ - label, - doReplaceRoute, - user, - ...rest -}: { +interface Props extends ButtonProps { user: User | null label: string doReplaceRoute?: boolean -} & ButtonProps) { +} + +export function StartChatButton({ label, doReplaceRoute, user: chatPartnerUser, ...rest }: Props) { const { t } = useTranslation() const chatFeatureFlag = useFeatureFlagWithMaintenance(FeatureFlagKey.CHAT) const isChatInitialized = useChatInitialized() @@ -35,8 +31,6 @@ export function StartChatButton({ const { openToast } = useToast() const { replaceRoute, navigate } = useRouting() - const identity = user?.identity - const handleChat = async () => { if (!isLoggedIn) { return openSignupModal({ @@ -45,8 +39,8 @@ export function StartChatButton({ }) } - if (!identity) { - logError(new HoliError('Can not create a chat without a user identity'), 'Failed to open chat', { + if (!chatPartnerUser) { + logError(new HoliError('Can not create a chat without chat partner user'), 'Failed to open chat', { location: 'StartChatButton.handleChat', }) return @@ -54,8 +48,8 @@ export function StartChatButton({ try { // TODO: the correct Matrix identity should come from Okuna - const fullIdentity = '@' + identity + ':' + chatServerName - const { room_id } = await getOrCreateRoom([fullIdentity]) + const chatMemberMatrixId = toMatrixIdentity(chatPartnerUser.identity) + const { room_id } = await getOrCreatePrivateRoom([chatMemberMatrixId]) if (!room_id) { openToast(t('chat.room.error.noRoom'), HoliToastType.ERROR) @@ -73,7 +67,7 @@ export function StartChatButton({ return ( <Button - disabled={!chatFeatureFlag.isOn || !isChatInitialized || !identity} + disabled={!chatFeatureFlag.isOn || !isChatInitialized || !chatPartnerUser} label={label} onPress={handleChat} testID="user-profile-open-chat" diff --git a/core/tracking/events.ts b/core/tracking/events.ts index 4ae24da2a3352e868145edb88875edba0e86a7b5..592cf08c1dc7f5975f16c260c097a1b31dc7e3dd 100644 --- a/core/tracking/events.ts +++ b/core/tracking/events.ts @@ -104,22 +104,22 @@ export namespace TrackingEvent { export const SpaceAskQuestion = { askedQuestion: { - name: 'askQuestionButton_pressed', + name: 'space_askQuestionButton_pressed', ...versionOne, }, startedConversation: { - name: 'startConversationButton_pressed', + name: 'space_startConversationButton_pressed', ...versionOne, }, } export const TaskContactOrganization = { - contactOrganization: { - name: 'contactOrganization_pressed', + contactedOrganization: { + name: 'task_contactOrganisationButton_pressed', ...versionOne, }, - startedConversation: { - name: 'startConversationButton_pressed', + startedConversationFromTask: { + name: 'task_startConversationButton_pressed', ...versionOne, }, } @@ -427,7 +427,15 @@ export namespace TrackingEvent { spaceName: string } - type ChatMessageMetadata = ChatUserMessageMetadata | ChatSpaceMessageMetadata + interface ChatSpaceTaskMessageMetadata extends ChatMessageMetadataBase { + type: 'space_task_message' + spaceId: string + spaceName: string + taskName: string + taskId: string + } + + type ChatMessageMetadata = ChatUserMessageMetadata | ChatSpaceMessageMetadata | ChatSpaceTaskMessageMetadata export const MessageSent = (metadata: ChatMessageMetadata): TrackingEvent => ({ name: 'chatMessageSent', diff --git a/e2e/web/playwright.config.ts b/e2e/web/playwright.config.ts index 6fb70bdf06dac73da76da8b5f184a1b21b6ca36e..5cfd924e84a8c08bdee7ba35d2acc4ba094e7a69 100644 --- a/e2e/web/playwright.config.ts +++ b/e2e/web/playwright.config.ts @@ -89,6 +89,15 @@ const config: PlaywrightTestConfig = { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: baseURL, headless: process.env.CI ? true : !!process.env.HEADLESS || true, + storageState: { + origins: [ + { + localStorage: [{ name: 'HOLI_TRACKING_CONSENT', value: '{"ANALYTICS":"denied","PERSONALIZATION":"denied"}' }], + origin: baseURL, + }, + ], + cookies: [], + }, }, projects: browsersFromEnv(process.env.E2E_WEB_BROWSERS), diff --git a/e2e/web/tests/forgotPassword.spec.ts b/e2e/web/tests/forgotPassword.spec.ts index f29b780b4383b15c0f1eec99e12c6604fb517121..f4b5e231ea94c50a198dd5f138465e57bcc1f6fc 100644 --- a/e2e/web/tests/forgotPassword.spec.ts +++ b/e2e/web/tests/forgotPassword.spec.ts @@ -1,17 +1,9 @@ -import { - byTestId, - checkAccessibility, - clickElementByTestId, - clickFirstElement, - dismissConsentDialog, - locateElementByTestId, -} from './helpers' +import { byTestId, checkAccessibility, clickElementByTestId, clickFirstElement, locateElementByTestId } from './helpers' import { test } from './testmaster' test.describe('@ForgotPassword', () => { test('user journey', async ({ page }) => { await page.goto('/') - await dismissConsentDialog(page) await clickElementByTestId(page, 'btn-to-onboarding') diff --git a/e2e/web/tests/helpers.ts b/e2e/web/tests/helpers.ts index b0c3a97b4bd9f35c04f9e7f12b41b72ec8145e05..480e3ba48824b167a9a3b2456ee8a22bb8269221 100644 --- a/e2e/web/tests/helpers.ts +++ b/e2e/web/tests/helpers.ts @@ -5,8 +5,8 @@ import { checkA11y, configureAxe, injectAxe } from 'axe-playwright' import { deleteAuthenticatedAccount } from '@holi/e2e-web/tests/helpers/api' import { newUserEmail, newUserPassword, randomPassword } from '@holi/e2e-web/tests/helpers/fixtures' -import { test } from './testmaster' import type { OnboardingUserData } from '@holi/core/screens/onboarding/types' +import { test } from './testmaster' // sometimes requests to the app backends take a little longer (10s < response latency < 40s) export const donationsRequestTimeout = 40_000 diff --git a/e2e/web/tests/helpers/api.ts b/e2e/web/tests/helpers/api.ts index 958ea2d7c549c9d901b1d4023308e928f94e86c6..5b7926d700708271f6fe1e84c718c9cf114e7485 100644 --- a/e2e/web/tests/helpers/api.ts +++ b/e2e/web/tests/helpers/api.ts @@ -3,6 +3,8 @@ import { APIRequestContext, BrowserContext } from '@playwright/test' import { newUserEmail, randomPassword } from './fixtures' +import { readFileSync } from 'fs' + export type User = { id: string identity: string @@ -123,3 +125,89 @@ export const signUp = async ( password, } } + +export type E2ETestSpaceResponse = { + id: string + title: string + description: string + name: string + cover: string + coverBlurhash: string +} + +export const createSpace = async (context: BrowserContext) => { + const file = new File([readFileSync('tests/helpers/testimage.png')], 'testimage.png') + + const formData = new FormData() + formData.append( + 'operations', + JSON.stringify({ + operationName: 'CreateSpace', + variables: { + input: { + title: `E2E ${faker.company.name()}`, + description: 'E2E Space Description', + topics: ['e3f03ff4-b205-4196-9eff-57b80e5b0fff'], //Other + cover: null, + }, + }, + query: + 'mutation CreateSpace($input: CreateSpaceInput!) {\ncreateSpace(input: $input) {\n id\n title\n description\n name\n cover\n coverBlurhash\n name\n}\n}', + }) + ) + formData.append('map', '{"1":["variables.input.cover"]}') + formData.append('1', file, 'testimage.png') + + // todo gregor: use codegen types after generating the create space types + // @ts-ignore + const createSpaceResponse: Response<{ data?: { createSpace: E2ETestSpaceResponse }; errors?: object[] }> = await ( + await context.request.post(`${baseURL}/api/graphql`, { + multipart: formData, + }) + ).json() + + if (createSpaceResponse.errors) { + throw new Error('Failed to create space ' + JSON.stringify(createSpaceResponse.errors, null, 2)) + } + + return createSpaceResponse.data.createSpace +} + +export type E2ETestSpaceTaskResponse = { + id: string + name: string + description: string +} + +export const createSpaceTask = async (context: BrowserContext, spaceId: string) => { + // todo gregor: use codegen types after generating the create space task types + // @ts-ignore + const createSpaceTaskResponse: { data?: { createTask: E2ETestSpaceTaskResponse }; errors?: object[] } = await ( + await context.request.post(`${baseURL}/api/graphql`, { + data: { + operationName: 'CreateTask', + query: + 'mutation CreateTask($input: CreateTaskInput!) { createTask(input: $input) { id name description visibility regularity locationType location contactEmail contactPhone __typename }}', + variables: { + input: { + spaceId: spaceId, + name: faker.lorem.words(3), + description: faker.lorem.lines(3), + visibility: 'PUBLIC', + locationType: 'REMOTE', + regularity: 'ONE_TIME', + contactEmail: faker.internet.email(), + slotsAvailable: 1, + skills: [], + }, + }, + }, + }) + ).json() + + if (createSpaceTaskResponse.errors) { + throw new Error('Failed to create space task ' + JSON.stringify(createSpaceTaskResponse.errors, null, 2)) + } + + return createSpaceTaskResponse.data!.createTask +} diff --git a/e2e/web/tests/helpers/testimage.png b/e2e/web/tests/helpers/testimage.png new file mode 100644 index 0000000000000000000000000000000000000000..5f77ef3cc0ad9bf311a8de4ab78ced8f8f720aad Binary files /dev/null and b/e2e/web/tests/helpers/testimage.png differ diff --git a/e2e/web/tests/taskCollaboration.spec.ts b/e2e/web/tests/taskCollaboration.spec.ts new file mode 100644 index 0000000000000000000000000000000000000000..06b9d24add97a418c178ec283a1ecacd6f814ea5 --- /dev/null +++ b/e2e/web/tests/taskCollaboration.spec.ts @@ -0,0 +1,67 @@ +import { clickButtonByText, clickElementByTestId, locateElementByTestId, retry } from '@holi/e2e-web/tests/helpers' +import { test } from '@holi/e2e-web/tests/testmaster' +import { expect } from '@playwright/test' + +test.describe('@Task collaboration multi user journey', () => { + test('Task Space Admins can be contacted by User', async ({ sessions }) => { + const { page: alice, user: aliceUser, api: aliceApi } = await sessions.signUp() + const aliceFullName = aliceUser.firstName + ' ' + aliceUser.lastName + const { page: bob, user: bobUser } = await sessions.signUp() + const bobFullName = bobUser.firstName + ' ' + bobUser.lastName + const { page: carol } = await sessions.signUp() + + const space = await aliceApi.createSpace() + const task = await aliceApi.createSpaceTask(space.id) + + // wait for the home to stabilize + await expect(alice.getByTestId('home-feed-insights-heading')).toBeVisible() + + await test.step('Another User is promoted to space admin', async () => { + //A invites B to space + await alice.goto('/spaces/' + space.name) + await clickElementByTestId(alice, 'btn-space-invite') + const messageTextField = await locateElementByTestId(alice, 'space-invite-user-input') + await messageTextField.fill(bobFullName) + + await clickElementByTestId(alice, 'btn-to-invite') + + // B accepts the invite notification + await retry(async () => { + await bob.goto('/notifications') + await expect( + await locateElementByTestId(bob, `space-invite-notification-item_${bobUser.id}_${aliceUser.id}`) + ).toBeVisible() + }) + await clickElementByTestId(bob, 'accept-invite-button') + + //make B admin + await alice.goto('/spaces/' + space.name) + await clickElementByTestId(alice, 'btn-to-open-space-collaborators-modal') + await clickButtonByText(alice, bobFullName) + await clickElementByTestId(alice, 'spaces-collaborators-grant-admin-btn') + await clickElementByTestId(alice, 'spaces-collaborators-grant-admin-confirmation-confirmation-primary') + }) + + await test.step('User can engage in contact', async () => { + await carol.goto('/spaces/' + space.id + '/tasks') + await carol.getByTestId('link').first().click() + await carol.getByLabel('Contact organization').click() + }) + + await test.step('Task chat explanation modal is confirmable', async () => { + await expect(carol.getByText('Start by simply saying "Hi"')).toBeVisible() + await carol.getByRole('button', { name: 'Start chatting' }).click() + }) + + await test.step('Multi user chat room with all space admins opens', async () => { + await expect(async () => { + carol.getByRole('banner', { name: task.name }) + carol.getByRole('button', { name: 'Send message' }) + }).toPass() + + //and all admins are mentioned + await expect(carol.getByRole('link', { name: aliceFullName })).toBeVisible() + await expect(carol.getByRole('link', { name: bobFullName })).toBeVisible() + }) + }) +}) diff --git a/e2e/web/tests/testmaster.ts b/e2e/web/tests/testmaster.ts index 898ff504770d977e6e7e02d978b6485e8c715b03..74bcc6d1d173e2ba6c424a33fd4ef6ef808859dd 100644 --- a/e2e/web/tests/testmaster.ts +++ b/e2e/web/tests/testmaster.ts @@ -1,14 +1,25 @@ import { type Browser, type BrowserContext, type Page, test as baseTest } from '@playwright/test' +import { OnboardingUserData } from '@holi/core/screens/onboarding/types' import { gotoWithRetries } from './helpers' -import { denyConsent, withRandomUserSession } from './helpers' -import { type User, deleteAuthenticatedAccount, signUp } from './helpers/api' -import type { OnboardingUserData } from '@holi/core/screens/onboarding/types' +import { withRandomUserSession } from './helpers' +import { + E2ETestSpaceResponse, + E2ETestSpaceTaskResponse, + type User, + createSpace, + createSpaceTask, + deleteAuthenticatedAccount, + signUp, +} from './helpers/api' type Session = { page: Page user: User - //api: { createSpace: () => void } + api: { + createSpace: () => Promise<E2ETestSpaceResponse> + createSpaceTask: (spaceId: string) => Promise<E2ETestSpaceTaskResponse> + } } export class UserHandler { @@ -72,15 +83,17 @@ export class SessionHandler { // If the initial attempt fails, fall back to retry logic await gotoWithRetries(newPage, '/') } - await denyConsent(newPage) + //await denyConsent(newPage) return { user, + // todo gregor: rename to newBrowserPage to communicate its not just a new tab based b/c newContext above page: newPage, // TODO: expand to enable setting up data for test runs, without having to drive the UI (slow) - // api: { - // createSpace: () => {//make it so via GQL calls}, - // }, + api: { + createSpace: () => createSpace(context), + createSpaceTask: (spaceId: string) => createSpaceTask(context, spaceId), + }, } } diff --git a/holi-bricks/components/avatar/AvatarPile.tsx b/holi-bricks/components/avatar/AvatarPile.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2edae4fbb7d89c7e4c46ed1bef62ca10cfe72798 --- /dev/null +++ b/holi-bricks/components/avatar/AvatarPile.tsx @@ -0,0 +1,59 @@ +import type React from 'react' +import { View } from 'react-native' + +import { Avatar, type AvatarProps } from 'holi-bricks/components/avatar' +import { AvatarSizesValues } from 'holi-bricks/components/avatar/Avatar' +import { useStyles } from 'holi-bricks/hooks' +import { createStyleSheet } from 'holi-bricks/utils' +// eslint-disable-next-line no-restricted-imports +import type { User } from '@holi/core/domain/shared/types' + +interface AvatarPileProps extends Omit<AvatarProps, 'initials'> { + users: User[] +} + +const AvatarPile = ({ + users, + size = 'md', + testID = 'holi-user-avatar-pile', + border, + shape, +}: React.PropsWithChildren<AvatarPileProps>) => { + const { styles } = useStyles(stylesheet) + return ( + <View style={styles.container} testID={testID}> + {users?.map((user, idx) => { + const overlapDistance = AvatarSizesValues[size] / -2.4 + return ( + <View style={styles.overlap(idx, overlapDistance)} key={idx}> + <Avatar + border={border} + shape={shape} + size={size} + label={user.fullName} + imgSrc={user.avatar} + initials={user.avatarLabel} + testID="pile-avatar" + imgPlaceholder={user.avatarBlurhash} + /> + </View> + ) + })} + </View> + ) +} + +export default AvatarPile + +const stylesheet = createStyleSheet(() => ({ + container: { + flexDirection: 'row', + alignItems: 'center', + }, + overlap: (idx, overlapDistance) => { + return { + marginLeft: idx ? overlapDistance : 0, + zIndex: idx * -1, + } + }, +})) diff --git a/packages/api/graphql/graphql-codegen.ts b/packages/api/graphql/graphql-codegen.ts index fc228d12e99aea470a1d382274539762782577a0..9febf77551157f6ad06f707afea7aa3eed8003cc 100644 --- a/packages/api/graphql/graphql-codegen.ts +++ b/packages/api/graphql/graphql-codegen.ts @@ -2446,6 +2446,7 @@ export type TaskByIdQuery = { id: string connectionStatusToMyself: Array<SpaceUserConnectionType> name: string + title: string avatar?: string avatarBlurhash?: string topics: Array<{ __typename?: 'Topic'; id: string; title: string }> @@ -2659,6 +2660,7 @@ export const TaskByIdDocument = gql` id connectionStatusToMyself name + title avatar avatarBlurhash topics { diff --git a/packages/chat/src/client/ChatClient.ts b/packages/chat/src/client/ChatClient.ts index a7b72a156dbe6c04fde6eb4a0f3d0d85747fe912..fd47066314ef64c14b22a76cff51069b17408778 100644 --- a/packages/chat/src/client/ChatClient.ts +++ b/packages/chat/src/client/ChatClient.ts @@ -9,14 +9,14 @@ import { type ICreateClientOpts, type ICreateRoomOpts, type IStartClientOpts, - MatrixClient, + type MatrixClient, type MatrixEvent, MatrixScheduler, Preset, PushRuleKind, - Room, + type Room, RoomEvent, - RoomMember, + type RoomMember, type RoomState, RoomStateEvent, Visibility, @@ -25,7 +25,7 @@ import { import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts' import { logger as matrixLogger } from 'matrix-js-sdk/lib/logger' import { SyncState } from 'matrix-js-sdk/lib/sync' -import { MsgType, RoomMessageEventContent } from 'matrix-js-sdk/lib/types' +import type { MsgType, RoomMessageEventContent } from 'matrix-js-sdk/lib/types' import { configurePusher } from '@holi/chat/src/client/pusher' import { @@ -33,14 +33,16 @@ import { ChatRoomEvent, type ChatRoomMember, type ChatRoomMessage, + type ChatRoomType, type CreateRoomOptions, type MatrixId, MembershipStatus, type MxMessagesResponse, type NotificationCount, + type RoomHoliMetadata, type RoomId, type RoomUpdatesContent, - RoomUpdatesType, + type RoomUpdatesType, type RoomWithSharedData, } from '@holi/chat/src/client/types' import { @@ -62,7 +64,7 @@ import { setRoomsListeners, } from '@holi/chat/src/client/utils' import { isEmptyString, isString } from '@holi/chat/src/utils' -import { type ChatCredentials } from '@holi/chat/src/utils/types' +import type { ChatCredentials } from '@holi/chat/src/utils/types' import { HoliChatClientError } from '@holi/core/errors/classes/HoliChatClientError' import { logError } from '@holi/core/errors/helpers' import { environment } from '@holi/core/helpers/environment' @@ -102,6 +104,13 @@ export default class ChatClient { return { sharedDataEvent, parentEvent } } + public getRoomHoliMetadata(room: Room): RoomHoliMetadata | undefined { + const state = room.getLiveTimeline().getState(Direction.Forward) + const roomCreateEvent = state?.getStateEvents(EventType.RoomCreate)?.pop() + + return roomCreateEvent?.getContent() as RoomHoliMetadata | undefined + } + private getSpaceChildEvents(): MatrixEvent[] { return this.mxClient.getRooms().reduce((spaceEvents: MatrixEvent[], room: Room) => { if (!room.isSpaceRoom()) return spaceEvents @@ -116,10 +125,27 @@ export default class ChatClient { private createSharedData(room: Room): RoomWithSharedData { const { sharedDataEvent, parentEvent } = this.getSpaceEvents(room) - if (!sharedDataEvent) return { type: 'private', room } + // Check type from `RoomCreate` event + if (!sharedDataEvent) { + const roomHoliMetadata = this.getRoomHoliMetadata(room) + + switch (roomHoliMetadata?.type) { + case 'space.task': + return { + type: roomHoliMetadata.type, + content: roomHoliMetadata.content, + room, + } + default: + return { type: 'private', room } + } + } + + // Chat type based on our custom `SharedData` event const content = sharedDataEvent.getEffectiveEvent().content + // Space Parent Room Shared Data if (room.isSpaceRoom() && isSharedDataContent(content)) { return { type: 'space', room, content } } @@ -260,20 +286,32 @@ export default class ChatClient { return this.mxClient.getHomeserverUrl() } - /** Rooms */ - public async getOrCreateRoom(userIds: string[], options: CreateRoomOptions): Promise<RoomId> { + private getRoomByUserIds(matrixUserIds: string[]): Room | undefined { const room = this.mxClient.getRooms().find((room) => { + const members = room.getMembers() + return ( - // look for a room that has all the requested userIds and is not a space room !room.isSpaceRoom() && - room.getMembers().length == userIds.length + 1 && - room.getMembers().filter((roomMember) => userIds.includes(roomMember.userId)).length == userIds.length + members.length == matrixUserIds.length + 1 && // +1 is for the logged in user + matrixUserIds.every((id) => members.find((member) => member.userId === id)) ) }) - if (room) return { room_id: room.roomId } + return room + } + + public getExistingRoomId(userIds: string[], roomType: ChatRoomType = 'private'): string | undefined { + const room = this.getRoomByUserIds(userIds) + const roomMetadata = room && this.getRoomHoliMetadata(room) + return roomMetadata?.type === roomType ? room?.roomId : undefined + } - return this.createRoom(userIds, options) + /** + * Rooms + * */ + public async getOrCreateRoom(userIds: string[], options: CreateRoomOptions): Promise<RoomId> { + const room_id = this.getExistingRoomId(userIds, options.metadata.type ?? 'private') + return room_id ? { room_id } : this.createRoom(userIds, options) } /** @@ -290,7 +328,7 @@ export default class ChatClient { **/ public async createRoom( userIds: string[], - { isDirectMessage, isPrivate = true, name }: CreateRoomOptions + { isDirectMessage, isPrivate = true, name, metadata }: CreateRoomOptions ): Promise<RoomId> { if (userIds.length < 1) { throw new HoliChatClientError('createRoom', 'Chat rooms must be created with at least one member') @@ -300,6 +338,7 @@ export default class ChatClient { is_direct: isDirectMessage, preset: isPrivate ? Preset.PrivateChat : Preset.PublicChat, visibility: isPrivate ? Visibility.Private : Visibility.Public, + creation_content: metadata, ...(name ? { name } : {}), } @@ -485,6 +524,23 @@ export default class ChatClient { } } + public subscribeToRoomCreateEvent(callback: (chatRoom: ChatRoom) => void): () => void { + const handleRoomCreated = (event: MatrixEvent, roomState: RoomState) => { + if (event.getType() !== EventType.RoomCreate) return + + const room = this.mxClient.getRoom(roomState.roomId) + if (!room) return + + callback(this.createChatRoom(room)) + } + + this.mxClient.on(RoomStateEvent.Events, handleRoomCreated) + + return () => { + this.mxClient.off(RoomStateEvent.Events, handleRoomCreated) + } + } + public subscribeToRoomsUpdates( callback: (roomId: string, type: RoomUpdatesType, content: RoomUpdatesContent) => void ): () => void { diff --git a/packages/chat/src/client/__tests__/ChatClient.createRoom.test.ts b/packages/chat/src/client/__tests__/ChatClient.createRoom.test.ts index fbe3d29f164d9d956e86fc64390d2e21268b0eb8..661231e83e13f43b486473433a6919e99f1617ab 100644 --- a/packages/chat/src/client/__tests__/ChatClient.createRoom.test.ts +++ b/packages/chat/src/client/__tests__/ChatClient.createRoom.test.ts @@ -1,17 +1,17 @@ import { EventType, - IEvent, - IRoomEvent, - IStateEvent, - MatrixClient, - MatrixEvent, + type IEvent, + type IRoomEvent, + type IStateEvent, + type MatrixClient, + type MatrixEvent, Preset, ReceiptType, Visibility, } from 'matrix-js-sdk' -import ChatClient from '@holi/chat/src/client/ChatClient' -import { ChatRoomEvent, MatrixId, MembershipStatus } from '@holi/chat/src/client/types' +import type ChatClient from '@holi/chat/src/client/ChatClient' +import { ChatRoomEvent, type MatrixId, MembershipStatus } from '@holi/chat/src/client/types' import { DM_ROOM_ID, createChatClient, @@ -42,7 +42,7 @@ describe('ChatClient', () => { const isDM = true - const { room_id } = await client.createRoom(members, { isDirectMessage: isDM }) + const { room_id } = await client.createRoom(members, { isDirectMessage: isDM, metadata: { type: 'private' } }) expect(room_id).toEqual(DM_ROOM_ID) expect(mxClient.createRoom).toHaveBeenCalledTimes(1) @@ -51,6 +51,9 @@ describe('ChatClient', () => { is_direct: isDM, preset: 'private_chat', visibility: 'private', + creation_content: { + type: 'private', + }, }) }) @@ -60,7 +63,7 @@ describe('ChatClient', () => { const isDirectMessage = false const name = '#general' - const { room_id } = await client.createRoom(members, { isDirectMessage, name }) + const { room_id } = await client.createRoom(members, { isDirectMessage, name, metadata: { type: 'private' } }) expect(room_id).toEqual(DM_ROOM_ID) expect(mxClient.createRoom).toHaveBeenCalledTimes(1) expect(mxClient.createRoom).toHaveBeenLastCalledWith({ @@ -69,6 +72,9 @@ describe('ChatClient', () => { preset: Preset.PrivateChat, visibility: Visibility.Private, name, + creation_content: { + type: 'private', + }, }) }) @@ -78,14 +84,16 @@ describe('ChatClient', () => { const members: string[] = [] const isDirectMessage = true - expect(client.createRoom(members, { isDirectMessage })).rejects.toThrowError(HoliChatClientError) + expect(client.createRoom(members, { isDirectMessage, metadata: { type: 'private' } })).rejects.toThrowError( + HoliChatClientError + ) }) }) describe('getOrCreateRoom() method', () => { describe('creates a new room if', () => { const checkIfANewRoomWasCreated = async () => { - await client.getOrCreateRoom(members, { isDirectMessage: true }) + await client.getOrCreateRoom(members, { isDirectMessage: true, metadata: { type: 'private' } }) expect(mxClient.createRoom).toHaveBeenCalled() } @@ -126,10 +134,18 @@ describe('ChatClient', () => { it('finds an existing room and returns it.', async () => { const roomMemberMocks = members.map((userId) => createRoomMemberMock(userId)) const roomId = 'testRoomId' - const roomMock = createRoomMock(roomId, { memberMocks: roomMemberMocks }) + const createEvent = createMatrixEventMock(EventType.RoomCreate, roomId, { type: 'private' }) + const roomMock = createRoomMock(roomId, { + memberMocks: roomMemberMocks, + timelineEvents: [], + stateEvents: [createEvent], + }) mxClient.getRooms.mockReturnValue([roomMock]) - const { room_id } = await client.getOrCreateRoom([members[0]], { isDirectMessage: true }) + const { room_id } = await client.getOrCreateRoom([members[0]], { + isDirectMessage: true, + metadata: { type: 'private' }, + }) expect(room_id).toEqual(roomId) expect(mxClient.createRoom).not.toHaveBeenCalled() }) diff --git a/packages/chat/src/client/types.ts b/packages/chat/src/client/types.ts index e8382c45b76495a7bf9b3ad7750e23ccebf54e8d..a41a63573dc836ca6d892b12df8d1b1670ce96a2 100644 --- a/packages/chat/src/client/types.ts +++ b/packages/chat/src/client/types.ts @@ -1,9 +1,7 @@ -import { EventType, IRoomEvent, IStateEvent, NotificationCountType, Room } from 'matrix-js-sdk' +import { EventType, type IRoomEvent, type IStateEvent, type NotificationCountType, type Room } from 'matrix-js-sdk' export type MatrixId = `@${string}:${string}` -export type ChatRoomType = 'private' | 'space' | 'space.child' - export type SharedDataContent = SpaceSharedDataContent | SpaceChildSharedDataContent export interface SpaceSharedDataContent { @@ -17,24 +15,39 @@ export interface SpaceChildSharedDataContent extends SpaceSharedDataContent { parentSpaceRoomId: string } +export interface SpaceTaskSharedDataContent { + taskName: string + taskId: string + spaceName: string + holiSpaceId: string +} + export interface PrivateRoom { type: 'private' - room: Room } export interface SpaceRoom { type: 'space' content: SpaceSharedDataContent - room: Room } export interface SpaceChildRoom { type: 'space.child' content: SpaceChildSharedDataContent - room: Room } -export type RoomWithSharedData = PrivateRoom | SpaceRoom | SpaceChildRoom +export interface SpaceTaskRoom { + type: 'space.task' + content: SpaceTaskSharedDataContent +} + +type AddRoom<T> = { + [K in keyof T]: T[K] +} & { room: Room } + +export type RoomHoliMetadata = PrivateRoom | SpaceRoom | SpaceChildRoom | SpaceTaskRoom +export type RoomWithSharedData = AddRoom<RoomHoliMetadata> +export type ChatRoomType = RoomWithSharedData['type'] export interface BaseChatRoom { id: string @@ -57,7 +70,11 @@ export interface SpaceChildChatRoom extends BaseChatRoom, Omit<SpaceChildRoom, ' content: SpaceChildSharedDataContent } -export type ChatRoom = PrivateChatRoom | SpaceChatRoom | SpaceChildChatRoom +export interface SpaceTaskChatRoom extends BaseChatRoom, Omit<SpaceTaskRoom, 'room'> { + content: SpaceTaskSharedDataContent +} + +export type ChatRoom = PrivateChatRoom | SpaceChatRoom | SpaceChildChatRoom | SpaceTaskChatRoom export enum RoomUpdatesType { name = EventType.RoomName, @@ -73,6 +90,7 @@ export interface CreateRoomOptions { isDirectMessage: boolean isPrivate?: boolean name?: string + metadata: RoomHoliMetadata } export enum MembershipStatus { diff --git a/packages/chat/src/client/utils.ts b/packages/chat/src/client/utils.ts index 0245d3f6acbbe604b949b102a82ec5510f03c1f2..ad749023ace954612f993110baa39dcde2eb2d21 100644 --- a/packages/chat/src/client/utils.ts +++ b/packages/chat/src/client/utils.ts @@ -1,4 +1,4 @@ -import { TFunction } from 'i18next' +import type { TFunction } from 'i18next' import { ClientEvent, EventType, @@ -9,11 +9,11 @@ import { type IStateEventWithRoomId, type Listener, type ListenerMap, - MatrixClient, - MatrixEvent, + type MatrixClient, + type MatrixEvent, type Room, - RoomEvent, - RoomMember, + type RoomEvent, + type RoomMember, type RoomNameState, RoomNameType, } from 'matrix-js-sdk' @@ -39,6 +39,8 @@ import { type SpaceChildRoom, type SpaceChildSharedDataContent, type SpaceRoom, + SpaceTaskChatRoom, + SpaceTaskRoom, } from '@holi/chat/src/client/types' import { getChatMemberName } from '@holi/chat/src/utils' import { HoliError } from '@holi/core/errors/classes/HoliError' @@ -183,8 +185,10 @@ export const createChatRoom = ( return createSpaceChatRoom(baseChatRoom, roomWithSharedData) case 'space.child': return createSpaceChildChatRoom(baseChatRoom, roomWithSharedData) + case 'space.task': + return createSpaceTaskChatRoom(baseChatRoom, roomWithSharedData) default: - throw new HoliError('Unexpected ChatRoom: private, space and space.child supported') + throw new HoliError('Unexpected ChatRoom: private, space, space.child and space.task supported') } } @@ -223,9 +227,17 @@ export function createSpaceChildChatRoom( } } +export function createSpaceTaskChatRoom(baseChatRoom: BaseChatRoom, spaceTaskRoom: SpaceTaskRoom): SpaceTaskChatRoom { + return { + type: spaceTaskRoom.type, + content: { ...spaceTaskRoom.content }, + ...baseChatRoom, + } +} + /** * @description It creates the base for all 3 (internal) types of chats rooms - * `PrivateChatRoom`, `SpaceChatRoom`, `SpaceChildChatRoom` + * `PrivateChatRoom`, `SpaceChatRoom`, `SpaceChildChatRoom`, `SpaceTaskChatRoom` */ export function createBaseChatRoom( room: Room, diff --git a/packages/chat/src/components/ChatProfileCard.tsx b/packages/chat/src/components/ChatProfileCard.tsx index 1d5c4e2451fb8c2633e18c2ce96082bbd3240ef2..207709ae02787c7f0432770f3baa481c9fdc77cd 100644 --- a/packages/chat/src/components/ChatProfileCard.tsx +++ b/packages/chat/src/components/ChatProfileCard.tsx @@ -1,6 +1,6 @@ import React from 'react' import type { PropsWithChildren } from 'react' -import { useTranslation } from 'react-i18next' +import { Trans, useTranslation } from 'react-i18next' import { StyleSheet, View } from 'react-native' import type { ChatRoomMember } from '@holi/chat/src/client/types' @@ -9,24 +9,23 @@ import { getChatInitials } from '@holi/chat/src/utils' import type { User } from '@holi/core/domain/shared/types' import useRouting from '@holi/core/navigation/hooks/useRouting' import { HoliDivider } from '@holi/ui/components/atoms/HoliDivider' -import { HoliHeading } from '@holi/ui/components/atoms/HoliHeading' -import HoliText from '@holi/ui/components/atoms/HoliText' import { Button } from 'holi-bricks/components/button' import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' import HoliCardBoard from '@holi/ui/components/organisms/HoliCardBoard' import { dimensions } from '@holi/ui/styles/globalVars' import { Avatar } from 'holi-bricks/components/avatar' import { Spacing } from 'holi-bricks/tokens' +import { Text } from 'holi-bricks/components/text' interface Props { roomId: string member: ChatRoomMember | undefined user?: User - userIsLoading: boolean + taskName?: string } // eslint-disable-next-line @typescript-eslint/no-unused-vars -const ChatProfileCard = ({ member, roomId, user, userIsLoading }: PropsWithChildren<Props>) => { +const ChatProfileCard = ({ member, roomId, user, taskName }: PropsWithChildren<Props>) => { const { t } = useTranslation() const { navigate } = useRouting() const { openToast } = useToast() @@ -59,16 +58,35 @@ const ChatProfileCard = ({ member, roomId, user, userIsLoading }: PropsWithChild initials={getChatInitials(member?.name) || user?.avatarLabel || ''} size="lg" /> + <View> - <HoliHeading size="m">{chatPartnerName}</HoliHeading> - {!!user?.pronouns && <HoliText color="gray300">{user?.pronouns}</HoliText>} + <Text size="xxl" headingLevel="1"> + {chatPartnerName} + </Text> + + {!!user?.pronouns && ( + <Text color="default" size="md"> + {user?.pronouns} + </Text> + )} </View> </> ) : null} </HoliCardBoard.Slot> <HoliDivider /> <HoliCardBoard.Slot> - <HoliText color="gray300">{t('chat.invitation.copy', { fullName: chatPartnerName })}</HoliText> + {taskName ? ( + <Text size="md" color="default"> + <Trans t={t} i18nKey={'chat.invitation.copy.taskChat'} values={{ fullName: chatPartnerName, taskName }}> + <Text size="md" color="informative" /> + </Trans> + </Text> + ) : ( + <Text size="md" color="default"> + {t('chat.invitation.copy', { fullName: chatPartnerName })} + </Text> + )} + <View style={styles.buttons}> <Button inline label={t('chat.invitation.reject')} variant="secondary" onPress={handleRejectRoom} /> <Button inline label={t('chat.invitation.accept')} testID={'chat-invitation-accept'} onPress={handleJoin} /> diff --git a/packages/chat/src/components/ChatRoomActionDrawer.tsx b/packages/chat/src/components/ChatRoomActionDrawer.tsx index b57cf4aef2dba41a34e1f854f81be4d96d4af344..75ee8c413ba2da51cbad9e80c180c977960153e7 100644 --- a/packages/chat/src/components/ChatRoomActionDrawer.tsx +++ b/packages/chat/src/components/ChatRoomActionDrawer.tsx @@ -1,21 +1,21 @@ import React from 'react' import { useTranslation } from 'react-i18next' -import type { ChatRoomMember } from '@holi/chat/src/client/types' import { ignoreUser, leaveRoom, useSetRemoveRoomAtom } from '@holi/chat/src/store' import useRouting from '@holi/core/navigation/hooks/useRouting' import { CloseCircle, Delete } from '@holi/icons/src/generated' import { HoliToastType, useToast } from '@holi/ui/components/molecules/HoliToastProvider' import HoliActionDrawer from '@holi/ui/components/organisms/HoliActionDrawer' import { useTheme } from '@holi/ui/styles/theme' +import type { User } from '@holi/core/domain/shared/types' interface Props { - chatPartner: ChatRoomMember chatPartnerFullName: string roomId: string + usersToBlock: User[] } -const ChatRoomActionDrawer = ({ chatPartner, chatPartnerFullName, roomId }: Props) => { +const ChatRoomActionDrawer = ({ chatPartnerFullName, roomId, usersToBlock }: Props) => { const { t } = useTranslation() const { navigate } = useRouting() const { openToast } = useToast() @@ -23,11 +23,11 @@ const ChatRoomActionDrawer = ({ chatPartner, chatPartnerFullName, roomId }: Prop const { theme } = useTheme() const { colors } = theme - const handleBlockConfirmation = async () => { - await ignoreUser(chatPartner.id) + const handleBlockConfirmation = async (user: User) => { + await ignoreUser(user.id) navigate('/chat') removeRoom(roomId) - openToast(t('user.profileView.block.successMessage', { name: chatPartnerFullName }), HoliToastType.SUCCESS) + openToast(t('user.profileView.block.successMessage', { name: user.fullName }), HoliToastType.SUCCESS) } const handleLeaveConfirmation = async () => { @@ -38,28 +38,30 @@ const ChatRoomActionDrawer = ({ chatPartner, chatPartnerFullName, roomId }: Prop return ( <HoliActionDrawer.Drawer label={t('chat.actions.more')} testID="chat-room-action-drawer"> - <HoliActionDrawer.Action - icon={CloseCircle} - title={t('chat.actions.block', { name: chatPartnerFullName })} - iconColor={colors.error20} - variant="danger" - showConfirmation - > - <HoliActionDrawer.Confirmation - confirmText={t('chat.actions.block.confirmation.block')} - dismissText={t('chat.actions.block.confirmation.cancel')} - title={t('chat.actions.block', { name: chatPartnerFullName })} - description={t('chat.actions.block.confirmation.text', { name: chatPartnerFullName })} - testID="chat-room-block-confirmation" - handleConfirmation={handleBlockConfirmation} - hasDismissBtn - requireLogin - /> - </HoliActionDrawer.Action> - + {usersToBlock?.map((user) => ( + <HoliActionDrawer.Action + icon={CloseCircle} + title={t('chat.actions.block', { name: user.fullName })} + iconColor={colors.error20} + variant="danger" + showConfirmation + key={user.fullName} + > + <HoliActionDrawer.Confirmation + confirmText={t('chat.actions.block.confirmation.block')} + dismissText={t('chat.actions.block.confirmation.cancel')} + title={t('chat.actions.block', { name: user.fullName })} + description={t('chat.actions.block.confirmation.text', { name: user.fullName })} + testID="chat-room-block-confirmation" + handleConfirmation={() => handleBlockConfirmation(user)} + hasDismissBtn + requireLogin + /> + </HoliActionDrawer.Action> + ))} <HoliActionDrawer.Action icon={Delete} - title={t('chat.actions.leave', { name: chatPartnerFullName })} + title={t('chat.actions.leave')} iconColor={colors.error20} variant="danger" showConfirmation @@ -67,10 +69,10 @@ const ChatRoomActionDrawer = ({ chatPartner, chatPartnerFullName, roomId }: Prop <HoliActionDrawer.Confirmation confirmText={t('chat.actions.leave.confirmation.leave')} dismissText={t('chat.actions.leave.confirmation.cancel')} - title={t('chat.actions.leave', { name: chatPartnerFullName })} + title={t('chat.actions.leave')} description={t('chat.actions.leave.confirmation.text', { name: chatPartnerFullName })} testID="chat-room-leave-confirmation" - handleConfirmation={handleLeaveConfirmation} + handleConfirmation={() => handleLeaveConfirmation()} hasDismissBtn requireLogin /> diff --git a/packages/chat/src/components/ChatRoomHeaderTitle.tsx b/packages/chat/src/components/ChatRoomHeaderTitle.tsx index 8f9777667f67bdfab3c45f23a777994219fe3b80..804c71a342a77f5f6c794163ce0e13e2e58fa22a 100644 --- a/packages/chat/src/components/ChatRoomHeaderTitle.tsx +++ b/packages/chat/src/components/ChatRoomHeaderTitle.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next' import { StyleSheet, View } from 'react-native' import { HoliLink } from '@holi/core/navigation/components/HoliLink' -import HoliText from '@holi/ui/components/atoms/HoliText' +import { Text } from 'holi-bricks/components/text' import { Avatar } from 'holi-bricks/components/avatar' import type { AvatarShape } from 'holi-bricks/components/avatar/Avatar' @@ -12,18 +12,18 @@ interface Props { name: string imageSrc?: string imageBlurhash?: string - initials: string + initials?: string defaultColor?: string shape?: AvatarShape } -const ChatRoomHeaderTitle = ({ href, name, ...avatarProps }: Props) => { +const ChatRoomHeaderTitle = ({ href, name, initials, ...avatarProps }: Props) => { const { t } = useTranslation() const content = ( <View style={styles.title}> - <Avatar label={name} {...avatarProps} size="xs" /> - <HoliText bold>{name}</HoliText> + {initials && <Avatar label={name} initials={initials} {...avatarProps} size="xs" />} + <Text size="lg">{name}</Text> </View> ) diff --git a/packages/chat/src/components/ChatTextInput.tsx b/packages/chat/src/components/ChatTextInput.tsx index f1e6bc05d402cba5b2316a052f3bedca6f003522..eb9d5a88d99fb63d28395059962e74df888bf5c7 100644 --- a/packages/chat/src/components/ChatTextInput.tsx +++ b/packages/chat/src/components/ChatTextInput.tsx @@ -28,10 +28,11 @@ interface Props { roomId: string isVisible: boolean userName?: string + isTaskChat?: boolean onMessageSent?: (content: string) => void } -export const ChatTextInput = ({ roomId, isVisible, userName = '', onMessageSent }: Props) => { +export const ChatTextInput = ({ roomId, isVisible, userName = '', isTaskChat = false, onMessageSent }: Props) => { const { t } = useTranslation() const { theme } = useTheme() const { displayError } = useErrorHandling() @@ -58,12 +59,16 @@ export const ChatTextInput = ({ roomId, isVisible, userName = '', onMessageSent } } + const placeholderText = isTaskChat + ? t('chat.room.input.placeholder.taskchat') + : t('chat.room.input.placeholder', { fullName: userName }) + return isVisible ? ( <View style={styles.wrapper}> <View style={styles.container}> <HoliTextInput - aria-label={t('chat.room.input.placeholder', { fullName: userName })} - placeholder={t('chat.room.input.placeholder', { fullName: userName })} + aria-label={placeholderText} + placeholder={placeholderText} value={inputValue} onChange={setInputValue} style={styles.textInputContainer} diff --git a/packages/chat/src/components/RoomList/RoomItem.tsx b/packages/chat/src/components/RoomList/RoomItem.tsx index c52cfcb41a4bd9bfb8e30889e5705eecb2afebd7..f6fe44ae0512e553d8fd7fe653aa391a7edb8870 100644 --- a/packages/chat/src/components/RoomList/RoomItem.tsx +++ b/packages/chat/src/components/RoomList/RoomItem.tsx @@ -32,7 +32,7 @@ const RoomItem = ({ room, loggedInUser }: RoomItemProps) => { const { theme } = useTheme() const styles = useMemo(() => getStyles(theme), [theme]) const { id: roomId, name, hasInvitation, notificationCount, members } = room - const chatPartner = useAtomValue(useMemo(() => chatPartnerAtomCreator(roomId, loggedInUser), [loggedInUser, roomId])) + const chatPartners = useAtomValue(useMemo(() => chatPartnerAtomCreator(roomId, loggedInUser), [loggedInUser, roomId])) const hasUnseenMessages = notificationCount > 0 const testID = `room-item-${name}` @@ -67,6 +67,8 @@ const RoomItem = ({ room, loggedInUser }: RoomItemProps) => { return null } + const firstChatPartner = chatPartners?.[0] + return ( <HoliLink href={`/chat/rooms/${roomId}`} label={roomName} onPress={handleOnPress}> <HoliBox padding={[ROOM_ITEM_VERTICAL_PADDING, 0]}> @@ -81,19 +83,19 @@ const RoomItem = ({ room, loggedInUser }: RoomItemProps) => { shape="square" size="md" /> - ) : chatPartner ? ( - isMatrixId(chatPartner.name) ? ( + ) : firstChatPartner ? ( + isMatrixId(firstChatPartner.name) ? ( <Avatar label={roomName} - imgSrc={chatPartner.avatar ?? ''} - initials={getChatInitials(chatPartner.name)} + imgSrc={firstChatPartner.avatar ?? ''} + initials={getChatInitials(firstChatPartner.name)} size="md" /> ) : ( <Avatar label={roomName} - imgSrc={chatPartner.avatar ?? ''} - initials={getChatInitials(chatPartner.name)} + imgSrc={firstChatPartner.avatar ?? ''} + initials={getChatInitials(firstChatPartner.name)} size="md" /> ) diff --git a/packages/chat/src/components/RoomMessageList/RoomMessageListStart.tsx b/packages/chat/src/components/RoomMessageList/RoomMessageListStart.tsx index d86619ef733c492910c63d5c7bc5b87677f117a4..f3aa604cad2512e6211d08913eb13112a468c76f 100644 --- a/packages/chat/src/components/RoomMessageList/RoomMessageListStart.tsx +++ b/packages/chat/src/components/RoomMessageList/RoomMessageListStart.tsx @@ -1,53 +1,89 @@ -import { Text } from 'holi-bricks/components/text' +import { Text, TextLink } from 'holi-bricks/components/text' import React from 'react' import { Trans, useTranslation } from 'react-i18next' import { View } from 'react-native' import type { ChatRoomMember } from '@holi/chat/src/client/types' import type { User } from '@holi/core/domain/shared/types' -import { HoliLink } from '@holi/core/navigation/components/HoliLink' -import { HoliTextLink } from '@holi/core/navigation/components/HoliTextLink' import { HoliGap } from '@holi/ui/components/atoms/HoliGap' -import { HoliHeading } from '@holi/ui/components/atoms/HoliHeading' -import { Avatar } from 'holi-bricks/components/avatar' +import AvatarPile from 'holi-bricks/components/avatar/AvatarPile' +import useRouting from '@holi/core/navigation/hooks/useRouting' interface RoomMessageListStartProps { - chatPartnerHoliUser: User - member: ChatRoomMember | undefined + chatPartnerHoliUsers: User[] + members: ChatRoomMember[] | undefined + taskName?: string + taskId?: string + parentSpaceId?: string + spaceName?: string } -const RoomMessageListStart = ({ chatPartnerHoliUser, member }: RoomMessageListStartProps) => { +const RoomMessageListStart = ({ + chatPartnerHoliUsers, + taskName, + taskId, + parentSpaceId, + spaceName, +}: RoomMessageListStartProps) => { const { t } = useTranslation() + const { navigate } = useRouting() + + const chatDescription = () => { + if (taskId) { + return ( + <Text size="md" color="support"> + <Trans + t={t} + i18nKey={'chat.room.start.group.description'} + values={{ taskName: taskName, spaceName: spaceName }} + components={{ + fullName: ( + <> + {chatPartnerHoliUsers.map((user, index) => ( + <React.Fragment key={user.id}> + {index > 0 && <Text size="md">, </Text>} + <TextLink onPress={() => navigate(`/profile/${user.id}`)} size="md" color="informative"> + {user.fullName} + </TextLink> + </React.Fragment> + ))} + </> + ), + taskName: ( + <TextLink + onPress={() => navigate(`/spaces/${parentSpaceId}/tasks/${taskId}`)} + size="md" + color="informative" + /> + ), + spaceName: ( + <TextLink onPress={() => navigate(`/spaces/${parentSpaceId}`)} size="md" color="informative" /> + ), + }} + /> + </Text> + ) + } + return ( + <Text size="md" color="support"> + <Trans t={t} i18nKey={'chat.room.start.description'} values={{ fullName: chatPartnerHoliUsers[0].fullName }}> + <TextLink onPress={() => navigate(`/profile/${chatPartnerHoliUsers[0].id}`)} size="md" color="informative" /> + </Trans> + </Text> + ) + } return ( <View> - <HoliLink - href={`/profile/${chatPartnerHoliUser.id}`} - label={t('user.visitProfile', { name: chatPartnerHoliUser.fullName })} - > - <Avatar - imgSrc={member?.avatar} - imgPlaceholder={chatPartnerHoliUser.avatarBlurhash} - label={chatPartnerHoliUser.fullName} - initials={chatPartnerHoliUser.avatarLabel} - size="lg" - /> - <HoliGap size="xs" /> - - <HoliHeading level="2">{chatPartnerHoliUser.fullName}</HoliHeading> - </HoliLink> + <AvatarPile users={chatPartnerHoliUsers} size="lg" /> + <HoliGap size="xs" /> + <Text headingLevel="2" size="xxl"> + {chatPartnerHoliUsers.map((user) => user.fullName).join(', ')} + </Text> + <HoliGap size="s" /> - <Text size="md"> - <Trans t={t} i18nKey={'chat.room.start.description'} values={{ fullName: chatPartnerHoliUser.fullName }}> - <HoliTextLink - label={t('user.visitProfile', { name: chatPartnerHoliUser.fullName })} - href={`/profile/${chatPartnerHoliUser.id}`} - size="md" - color="informative" - /> - </Trans> - </Text> + {chatDescription()} </View> ) } diff --git a/packages/chat/src/store/store.ts b/packages/chat/src/store/store.ts index a805f1dd1d5659f6b0a30ceb1d2ebd32ef5b7d66..186ba313ad856f3bf86cd01341f4d6555cdcf157 100644 --- a/packages/chat/src/store/store.ts +++ b/packages/chat/src/store/store.ts @@ -9,6 +9,7 @@ import { type ChatRoom, type ChatRoomMember, type ChatRoomMessage, + type ChatRoomType, type MatrixId, MembershipStatus, type NotificationCount, @@ -16,6 +17,7 @@ import { type RoomUpdatesContent, RoomUpdatesType, type SpaceChatRoom, + type SpaceTaskRoom, } from '@holi/chat/src/client/types' import { createChatMessage } from '@holi/chat/src/client/utils' import { @@ -221,11 +223,13 @@ _roomAtomsRecordAtomSubscriber.onMount = (setAtom) => { * Subscribe to incoming rooms */ const unsubscribeNewRooms = client.subscribeToNewRooms(addRoomAtom) + const unsubscribeToRoomCreateEvent = client.subscribeToRoomCreateEvent(addRoomAtom) const unsubscribeRoomSharedData = client.subscribeToSharedData(addRoomAtom) const unsubscribeRoomNameEvents = client.subscribeToRoomNameEvent(addRoomAtom) return () => { unsubscribeNewRooms() + unsubscribeToRoomCreateEvent() unsubscribeRoomNameEvents() unsubscribeRoomSharedData() } @@ -369,21 +373,29 @@ export const roomAtomsRecorAtomForTesting = atom( **/ /** - * @name createNewRoom (Client Helper) - * @description Initiates the creation of a room in Matrix through the - * ChatClient. The store updates once the room creation event is received from - * the Matrix server. + * @name getOrCreateRoom (Client Helper) + * @description returns the room id of an existing one-on-one room, if it + * exists, or creates a new one. */ -export const createNewRoom = async (userIds: string[]): Promise<RoomId> => { - return getChatClient().createRoom(userIds, { isDirectMessage: true }) +export const getOrCreatePrivateRoom = async (userIds: string[], name?: string): Promise<RoomId> => { + return getChatClient().getOrCreateRoom(userIds, { isDirectMessage: true, name: name, metadata: { type: 'private' } }) } + +export const getExistingRoomId = (userIds: string[], roomType?: ChatRoomType): string | undefined => { + return getChatClient().getExistingRoomId(userIds, roomType) +} + /** - * @name getOrCreateRoom (Client Helper) - * @description returns the room id of an existing one-on-one room, if it + * @name getOrCreateSpaceTaskRoom (Client Helper) + * @description returns the room id of an existing space-task room, if it * exists, or creates a new one. */ -export const getOrCreateRoom = async (userIds: string[]): Promise<RoomId> => { - return getChatClient().getOrCreateRoom(userIds, { isDirectMessage: true }) +export const getOrCreateSpaceTaskRoom = async (userIds: string[], metadata: SpaceTaskRoom): Promise<RoomId> => { + return getChatClient().getOrCreateRoom(userIds, { + isDirectMessage: true, + name: metadata.content.taskName, + metadata, + }) } /** @@ -702,13 +714,13 @@ const _membershipSubscriberAtom = atom( if (!roomAtom) return switch (membership) { + case 'invite': case 'join': case 'leave': { set(roomAtom, (room) => addOrUpdateMemberInChatRoom(room, member)) break } - case 'invite': case 'ban': case 'knock': default: @@ -735,15 +747,19 @@ _ignoredUsersAtomSubscriber.onMount = (setAtom) => { * @description An atom creator that gives you the partner member in a DM * conversion between two people. */ -export const chatPartnerAtomCreator = (roomId: string, holiUser?: HoliUser): Atom<ChatRoomMember | undefined> => { +export const chatPartnerAtomCreator = ( + roomId: string, + holiUserToExclude?: HoliUser +): Atom<ChatRoomMember[] | undefined> => { return atom((get) => { - if (!holiUser) return + if (!holiUserToExclude) return const roomAtom = getAtomFromRecordId(get, _roomAtomsRecordAtom, roomId) if (!roomAtom) return const chatRoom = get(roomAtom) - return getChatPartner(holiUser, chatRoom) + const chatPartners = getChatPartners(holiUserToExclude, chatRoom) + return chatPartners.length ? chatPartners : undefined }) } @@ -798,11 +814,8 @@ export const hasOnlyIgnoredMembers = (members: ChatRoomMember[], ignoredUsers: M return ignoredRoomMembers.length === members.length - 1 } -export const getChatPartner = (holiUser: HoliUser, room: ChatRoom): ChatRoomMember | undefined => { - // TODO this will have to be changed once we support multiple users per room - return room.type === 'private' - ? room.members.find((member) => member.id !== toMatrixIdentity(holiUser.identity)) - : undefined +export const getChatPartners = (holiUserToExclude: HoliUser, room: ChatRoom): ChatRoomMember[] => { + return room.members.filter((member) => member.id !== toMatrixIdentity(holiUserToExclude.identity)) } export const isRoomCreator = (user: HoliUser, room: ChatRoom): boolean | undefined => {