From d04a453ea953555eeccb71d68c08571c9e02478a Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Fri, 17 Jul 2026 09:49:37 +0200 Subject: [PATCH 01/31] feat(webapp): add Meet Now modal and instant meeting flow Wire Meet Now entry points to a modal that creates an ad-hoc meeting with a required title and optional participants, applies RFC instant time defaults, refreshes the meetings list, and joins the call directly without a preparation room. Inject meeting store service tasks at the composition root and reuse existing schedule-meeting creation and join plumbing. --- apps/webapp/src/i18n/en-US.json | 3 + .../Meeting/MeetNowModal/MeetNowForm.tsx | 114 +++++++++++++ .../MeetNowModal/MeetNowModal.styles.ts | 42 +++++ .../Meeting/MeetNowModal/MeetNowModal.tsx | 127 ++++++++++++++ .../Meeting/MeetNowModal/meetNowTypes.ts | 31 ++++ .../MeetNowModal/useMeetNowModal.test.ts | 46 +++++ .../Meeting/MeetNowModal/useMeetNowModal.ts | 99 +++++++++++ .../MeetNowModal/useMeetNowSubmit.test.tsx | 128 ++++++++++++++ .../Meeting/MeetNowModal/useMeetNowSubmit.ts | 159 ++++++++++++++++++ .../Meeting/MeetingList/meetingList.test.tsx | 1 + .../script/components/Meeting/Meetings.tsx | 20 ++- .../scheduleMeetingDefaults.test.ts | 21 +++ .../scheduleMeetingDefaults.ts | 6 + .../scheduleMeetingService.test.ts | 70 +++++++- .../scheduleMeetingService.ts | 74 +++++--- .../useScheduleMeetingSubmit.test.tsx | 1 + .../mapMeetNowFormToCreateMeeting.test.ts | 44 +++++ .../Meeting/mapMeetNowFormToCreateMeeting.ts | 34 ++++ .../meetingStore/createMeetingStore.test.ts | 70 +++++--- .../meetingStore/createMeetingStore.ts | 10 +- .../createMeetingStoreServiceTasks.ts | 32 ++++ .../Meeting/meetingStore/meetingStoreDeps.ts | 21 ++- .../Meeting/useMeetingActions.test.ts | 54 ++++++ .../components/Meeting/useMeetingActions.ts | 4 +- .../src/conversation/conversationSchema.ts | 6 +- .../src/meetings/meetingSchema.test.ts | 18 ++ 26 files changed, 1169 insertions(+), 66 deletions(-) create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.styles.ts create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/meetNowTypes.ts create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.test.ts create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.ts create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx create mode 100644 apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts create mode 100644 apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.test.ts create mode 100644 apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts create mode 100644 apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStoreServiceTasks.ts create mode 100644 apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts diff --git a/apps/webapp/src/i18n/en-US.json b/apps/webapp/src/i18n/en-US.json index 23146db4d06..e5ca41adb46 100644 --- a/apps/webapp/src/i18n/en-US.json +++ b/apps/webapp/src/i18n/en-US.json @@ -1271,6 +1271,9 @@ "meetings.list.loadError": "Could not load meetings. Please try again.", "meetings.list.today": "Today", "meetings.list.tomorrow": "Tomorrow", + "meetings.meetNowModal.closeAriaLabel": "Close", + "meetings.meetNowModal.startMeeting": "Start Meeting", + "meetings.meetNowModal.title": "Meet now", "meetings.meetingStatus.participating": "Attending", "meetings.meetingStatus.startedAt": "Started at {time}", "meetings.navigation.label": "Meetings", diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx new file mode 100644 index 00000000000..9eba2f32982 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx @@ -0,0 +1,114 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import is from '@sindresorhus/is'; + +import {CircleCloseIcon, ErrorMessage, getOverlayPortalContainer, Input} from '@wireapp/react-ui-kit'; + +import {MeetingParticipantsPicker} from 'Components/Meeting/MeetingParticipantsPicker'; +import { + scheduleMeetingParticipantsSectionCss, + scheduleMeetingTitleClearButtonStyles, + scheduleMeetingTitleInputWrapperStyles, +} from 'Components/Meeting/ScheduleMeetingModal/ScheduleMeetingForm.styles'; +import {useScheduleMeetingParticipants} from 'Components/Meeting/ScheduleMeetingModal/useScheduleMeetingParticipants'; +import type {User} from 'Repositories/entity/User'; +import {useApplicationContext} from 'src/script/page/rootProvider'; + +import {meetNowFormLayoutStyles} from './MeetNowModal.styles'; +import type {MeetNowFormState} from './meetNowTypes'; + +export interface MeetNowFormProps { + formState: MeetNowFormState; + titleError?: string; + onTitleChange: (title: string) => void; + onSelectedUsersChange: (users: User[]) => void; + onParticipantsFilterChange: (filter: string) => void; + selfUser: User; +} + +export const MeetNowForm = ({ + formState, + titleError, + onTitleChange, + onSelectedUsersChange, + onParticipantsFilterChange, + selfUser, +}: MeetNowFormProps) => { + const {mainViewModel, translate} = useApplicationContext(); + const {users} = useScheduleMeetingParticipants(); + const portalContainer = getOverlayPortalContainer(); + + const contentViewModel = mainViewModel.content; + const conversationRepository = contentViewModel.repositories.conversation; + const searchRepository = contentViewModel.repositories.search; + const teamRepository = contentViewModel.repositories.team; + + return ( +
+ onTitleChange(event.currentTarget.value)} + markInvalid={is.nonEmptyString(titleError)} + error={ + is.nonEmptyString(titleError) ? ( + {titleError} + ) : undefined + } + wrapperCSS={scheduleMeetingTitleInputWrapperStyles} + endContent={ + formState.title.length > 0 && !is.nonEmptyString(titleError) ? ( + + ) : undefined + } + /> + +
+ +
+
+ ); +}; diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.styles.ts b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.styles.ts new file mode 100644 index 00000000000..9723d9f90e3 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.styles.ts @@ -0,0 +1,42 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {CSSObject} from '@emotion/react'; + +export { + bodyStyles, + closeButtonStyles, + footerStyles, + headerStyles, + headerTitleStyles, + modalWrapperStyles, + submitButtonIconStyles, + submitButtonStyles, + wrapperStyles, +} from 'Components/Meeting/ScheduleMeetingModal/ScheduleMeetingModal.styles'; + +export const meetNowModalWrapperStyles: CSSObject = { + maxWidth: '480px', +}; + +export const meetNowFormLayoutStyles: CSSObject = { + display: 'flex', + flexDirection: 'column', + gap: '24px', +}; diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx new file mode 100644 index 00000000000..5b0d69e5520 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx @@ -0,0 +1,127 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useMemo} from 'react'; + +import {container} from 'tsyringe'; + +import {Button, ButtonVariant, CallIcon, CloseIcon} from '@wireapp/react-ui-kit'; + +import {ModalComponent} from 'Components/Modals/ModalComponent'; +import {UserState} from 'Repositories/user/userState'; +import {useApplicationContext} from 'src/script/page/rootProvider'; +import {handleEscDown} from 'Util/keyboardUtil'; + +import {MeetNowForm} from './MeetNowForm'; +import { + bodyStyles, + closeButtonStyles, + footerStyles, + headerStyles, + headerTitleStyles, + meetNowModalWrapperStyles, + modalWrapperStyles, + submitButtonIconStyles, + submitButtonStyles, + wrapperStyles, +} from './MeetNowModal.styles'; +import {hasMeetNowFormErrors, useMeetNowModal} from './useMeetNowModal'; +import {useMeetNowSubmit} from './useMeetNowSubmit'; + +export const MeetNowModal = () => { + const {fireAndForgetInvoker, translate} = useApplicationContext(); + const {isOpen, formState, errors, close, reset, setTitle, setSelectedUsers, setParticipantsFilter, validate} = + useMeetNowModal(); + const {isSubmitting, submit} = useMeetNowSubmit(); + const selfUser = container.resolve(UserState).self(); + + const titleError = useMemo(() => (errors.title ? translate(errors.title) : undefined), [errors.title, translate]); + + const handleClose = () => { + close(); + reset(); + }; + + const handleSubmit = () => { + const validationErrors = validate(); + if (hasMeetNowFormErrors(validationErrors)) { + return; + } + + fireAndForgetInvoker.fireAndForget(async (): Promise => { + const didStart = await submit(formState); + if (didStart) { + handleClose(); + } + }); + }; + + return ( + handleEscDown(event, handleClose)} + > +
+
+ +

+ {translate('meetings.meetNowModal.title')} +

+
+ +
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/meetNowTypes.ts b/apps/webapp/src/script/components/Meeting/MeetNowModal/meetNowTypes.ts new file mode 100644 index 00000000000..891f307eb46 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/meetNowTypes.ts @@ -0,0 +1,31 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {User} from 'Repositories/entity/User'; +import type {TranslationKey} from 'Util/localizerUtil'; + +export type MeetNowFormState = { + title: string; + selectedUsers: User[]; + participantsFilter: string; +}; + +export type MeetNowFormErrors = { + title?: TranslationKey; +}; diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.test.ts b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.test.ts new file mode 100644 index 00000000000..27c1ad27e9d --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.test.ts @@ -0,0 +1,46 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import { + getDefaultMeetNowFormState, + hasMeetNowFormErrors, + useMeetNowModal, + validateMeetNowForm, +} from './useMeetNowModal'; + +describe('useMeetNowModal', () => { + beforeEach(() => { + useMeetNowModal.getState().close(); + useMeetNowModal.getState().reset(); + }); + + it('opens with a fresh form state', () => { + useMeetNowModal.getState().open(); + + expect(useMeetNowModal.getState().isOpen).toBe(true); + expect(useMeetNowModal.getState().formState).toEqual(getDefaultMeetNowFormState()); + }); + + it('requires a title before submit', () => { + const errors = validateMeetNowForm(getDefaultMeetNowFormState()); + + expect(errors.title).toBe('meetings.scheduleModal.error.titleRequired'); + expect(hasMeetNowFormErrors(errors)).toBe(true); + }); +}); diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.ts b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.ts new file mode 100644 index 00000000000..507c964f6a1 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.ts @@ -0,0 +1,99 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {create} from 'zustand'; + +import type {User} from 'Repositories/entity/User'; + +import type {MeetNowFormErrors, MeetNowFormState} from './meetNowTypes'; + +export type {MeetNowFormErrors, MeetNowFormState} from './meetNowTypes'; + +export const getDefaultMeetNowFormState = (): MeetNowFormState => ({ + title: '', + selectedUsers: [], + participantsFilter: '', +}); + +type MeetNowModalState = { + isOpen: boolean; + formState: MeetNowFormState; + errors: MeetNowFormErrors; + open: () => void; + close: () => void; + reset: () => void; + setTitle: (title: string) => void; + setSelectedUsers: (selectedUsers: User[]) => void; + setParticipantsFilter: (participantsFilter: string) => void; + validate: () => MeetNowFormErrors; + clearErrors: () => void; +}; + +const initialState = { + isOpen: false, + formState: getDefaultMeetNowFormState(), + errors: {} as MeetNowFormErrors, +}; + +export const validateMeetNowForm = ({title}: MeetNowFormState): MeetNowFormErrors => { + const errors: MeetNowFormErrors = {}; + + if (!title.trim()) { + errors.title = 'meetings.scheduleModal.error.titleRequired'; + } + + return errors; +}; + +export const hasMeetNowFormErrors = (errors: MeetNowFormErrors): boolean => Boolean(errors.title); + +export const useMeetNowModal = create((set, get) => ({ + ...initialState, + open: () => + set({ + isOpen: true, + formState: getDefaultMeetNowFormState(), + errors: {}, + }), + close: () => set({isOpen: false}), + reset: () => + set({ + formState: getDefaultMeetNowFormState(), + errors: {}, + }), + setTitle: title => + set(state => ({ + formState: {...state.formState, title}, + errors: {...state.errors, title: undefined}, + })), + setSelectedUsers: selectedUsers => + set(state => ({ + formState: {...state.formState, selectedUsers}, + })), + setParticipantsFilter: participantsFilter => + set(state => ({ + formState: {...state.formState, participantsFilter}, + })), + validate: () => { + const errors = validateMeetNowForm(get().formState); + set({errors}); + return errors; + }, + clearErrors: () => set({errors: {}}), +})); diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx new file mode 100644 index 00000000000..0848ce45b30 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx @@ -0,0 +1,128 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {ReactNode} from 'react'; + +import {act, renderHook} from '@testing-library/react'; +import {task} from 'true-myth'; +import {createStore} from 'zustand/vanilla'; + +import type {MeetingStoreState} from 'Components/Meeting/meetingStore/createMeetingStore'; +import {MeetingStoreProvider} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; +import {meetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import { + createRootContextValueForTest, + createRootProviderWrapperForTest, +} from 'src/script/page/testSupport/rootContextTestSupport'; +import {MainViewModel} from 'src/script/view_model/MainViewModel'; +import {translateForTest} from 'Util/test/translateForTest'; + +import {useMeetNowSubmit} from './useMeetNowSubmit'; + +const qualifiedConversation = {id: 'conversation-id', domain: 'example.com'}; + +const formState = { + title: 'Standup', + selectedUsers: [], + participantsFilter: '', +}; + +const createMainViewModelForTest = (): MainViewModel => + ({ + content: { + repositories: { + conversation: { + safeGetConversationById: jest.fn().mockReturnValue(task.resolve({qualifiedId: qualifiedConversation})), + }, + calling: { + findCall: jest.fn().mockReturnValue(undefined), + }, + }, + }, + calling: { + callActions: { + startAudio: jest.fn().mockResolvedValue(undefined), + }, + }, + }) as unknown as MainViewModel; + +const RootProviderWrapper = createRootProviderWrapperForTest( + createRootContextValueForTest({ + translate: translateForTest, + mainViewModel: createMainViewModelForTest(), + }), +); + +const createMeetingStore = ({ + loadMeetings = jest.fn().mockResolvedValue(undefined), + meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})), +}: Partial> = {}) => + createStore(() => ({ + meetingSeries: [], + isLoading: false, + hasLoadError: false, + loadMeetings, + scheduleMeeting: jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), + meetNowMeeting, + updateMeeting: jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), + loadMeetingForEdit: jest.fn().mockReturnValue(task.reject(meetingSubmitErrors.updateFailed)), + })); + +const createWrapper = + (store: ReturnType) => + ({children}: {children: ReactNode}) => ( + + {children} + + ); + +describe('useMeetNowSubmit', () => { + it('refreshes meetings after a successful submit', async () => { + const loadMeetings = jest.fn().mockResolvedValue(undefined); + const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); + const store = createMeetingStore({loadMeetings, meetNowMeeting}); + + const {result} = renderHook(() => useMeetNowSubmit(), {wrapper: createWrapper(store)}); + + let submitResult = false; + await act(async () => { + submitResult = await result.current.submit(formState); + }); + + expect(submitResult).toBe(true); + expect(meetNowMeeting).toHaveBeenCalledWith(formState); + expect(loadMeetings).toHaveBeenCalledTimes(1); + }); + + it('returns false when meeting creation fails', async () => { + const loadMeetings = jest.fn().mockResolvedValue(undefined); + const meetNowMeeting = jest.fn().mockReturnValue(task.reject(meetingSubmitErrors.createFailed)); + const store = createMeetingStore({loadMeetings, meetNowMeeting}); + + const {result} = renderHook(() => useMeetNowSubmit(), {wrapper: createWrapper(store)}); + + let submitResult = true; + await act(async () => { + submitResult = await result.current.submit(formState); + }); + + expect(submitResult).toBe(false); + expect(loadMeetings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts new file mode 100644 index 00000000000..7293695c0b1 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts @@ -0,0 +1,159 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {useCallback, useMemo, useState} from 'react'; + +import type {QualifiedId} from '@wireapp/api-client/lib/user'; +import {task} from 'true-myth'; +import {container} from 'tsyringe'; + +import {joinMeetingCall, type JoinMeetingCallDeps} from 'Components/Meeting/joinMeetingCall'; +import {useMeetingStore} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; +import {meetingSubmitErrors, type MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import {handleJoinMeetingCallResult} from 'Components/Meeting/useJoinMeetingCall'; +import {PrimaryModal} from 'Components/Modals/PrimaryModal'; +import {showCallNotEstablishedModal, useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard'; +import {ConversationState} from 'Repositories/conversation/ConversationState'; +import {Config} from 'src/script/Config'; +import {useApplicationContext, useMainViewModel} from 'src/script/page/rootProvider'; +import type {Translate} from 'Util/localizerUtil'; + +import type {MeetNowFormState} from './meetNowTypes'; + +import {SCHEDULE_MEETING_ERROR_TRANSLATION_KEYS} from '../ScheduleMeetingModal/scheduleMeetingErrorKeys'; +import {shouldRefreshMeetingsListAfterSubmitError} from '../ScheduleMeetingModal/shouldRefreshMeetingsListAfterSubmitError'; +import {showMeetingPartialAddFailureModal} from '../ScheduleMeetingModal/showMeetingPartialAddFailureModal'; + +const showMeetingSubmitError = (translate: Translate, error: MeetingSubmitErrors): void => { + const {titleKey, messageKey} = SCHEDULE_MEETING_ERROR_TRANSLATION_KEYS[error]; + PrimaryModal.show( + PrimaryModal.type.ACKNOWLEDGE, + { + text: { + title: translate(titleKey), + message: translate(messageKey), + }, + }, + undefined, + translate, + ); +}; + +export const useMeetNowSubmit = () => { + const [isSubmitting, setIsSubmitting] = useState(false); + const {translate} = useApplicationContext(); + const {content, calling: callingViewModel} = useMainViewModel(); + const {conversation: conversationRepository, calling: callingRepository} = content.repositories; + const meetNowMeeting = useMeetingStore(state => state.meetNowMeeting); + const loadMeetings = useMeetingStore(state => state.loadMeetings); + + const callNotEstablishedCopy = useMemo( + () => ({ + description: translate('callNotEstablishedDescription'), + descriptionPoints: [ + translate('callNotEstablishedDescriptionPoint1'), + translate('callNotEstablishedDescriptionPoint2'), + translate('callNotEstablishedDescriptionPoint3'), + ] as [string, string, string], + title: translate('callNotEstablishedTitle'), + translate, + }), + [translate], + ); + + const guardCall = useNoInternetCallGuard(callNotEstablishedCopy); + + const joinDeps = useMemo( + () => ({ + conversationState: container.resolve(ConversationState), + conversationRepository, + callingRepository, + callingViewModel, + }), + [callingRepository, callingViewModel, conversationRepository], + ); + + const showConversationNotFoundModal = useCallback(() => { + PrimaryModal.show( + PrimaryModal.type.ACKNOWLEDGE, + { + text: { + message: translate('conversationNotFoundMessage'), + title: translate('conversationNotFoundTitle', {brandName: Config.getConfig().BRAND_NAME}), + }, + }, + undefined, + translate, + ); + }, [translate]); + + const joinCreatedMeeting = useCallback( + (qualifiedConversationId: QualifiedId) => { + guardCall(async () => { + const result = await joinMeetingCall(joinDeps, qualifiedConversationId); + + if (result.isErr) { + handleJoinMeetingCallResult(result, { + showConversationNotFoundModal, + showJoinFailedModal: () => showCallNotEstablishedModal(callNotEstablishedCopy), + }); + } + }); + }, + [callNotEstablishedCopy, guardCall, joinDeps, showConversationNotFoundModal], + ); + + const submit = useCallback( + async (formState: MeetNowFormState): Promise => { + setIsSubmitting(true); + + const submitResult = await meetNowMeeting(formState); + + if (submitResult.isErr) { + if (shouldRefreshMeetingsListAfterSubmitError(submitResult.error)) { + await task.tryOrElse(() => meetingSubmitErrors.refreshFailed, loadMeetings); + } + + setIsSubmitting(false); + showMeetingSubmitError(translate, submitResult.error); + return false; + } + + if (submitResult.value.failedToAdd.length > 0) { + showMeetingPartialAddFailureModal({ + failedToAdd: submitResult.value.failedToAdd, + users: formState.selectedUsers, + translate, + }); + } + + await task.tryOrElse(() => meetingSubmitErrors.refreshFailed, loadMeetings); + + const {qualifiedConversation} = submitResult.value; + + setIsSubmitting(false); + joinCreatedMeeting(qualifiedConversation); + + return true; + }, + [joinCreatedMeeting, loadMeetings, meetNowMeeting, translate], + ); + + return {isSubmitting, submit}; +}; diff --git a/apps/webapp/src/script/components/Meeting/MeetingList/meetingList.test.tsx b/apps/webapp/src/script/components/Meeting/MeetingList/meetingList.test.tsx index ab1fc073275..75c67cfecf3 100644 --- a/apps/webapp/src/script/components/Meeting/MeetingList/meetingList.test.tsx +++ b/apps/webapp/src/script/components/Meeting/MeetingList/meetingList.test.tsx @@ -99,6 +99,7 @@ const createMeetingStoreForTest = () => hasLoadError: false, loadMeetings: jest.fn(), scheduleMeeting: jest.fn(), + meetNowMeeting: jest.fn(), updateMeeting: jest.fn(), loadMeetingForEdit: jest.fn(), })); diff --git a/apps/webapp/src/script/components/Meeting/Meetings.tsx b/apps/webapp/src/script/components/Meeting/Meetings.tsx index 0c84cbe38d0..d17e2a323dc 100644 --- a/apps/webapp/src/script/components/Meeting/Meetings.tsx +++ b/apps/webapp/src/script/components/Meeting/Meetings.tsx @@ -25,7 +25,9 @@ import {meetingsContentWrapperStyles} from 'Components/Meeting/MeetingCallingVie import {MeetingHeader} from 'Components/Meeting/MeetingHeader/MeetingHeader'; import {MeetingList} from 'Components/Meeting/MeetingList/MeetingList'; import {createMeetingStore} from 'Components/Meeting/meetingStore/createMeetingStore'; +import {createMeetingStoreServiceTasks} from 'Components/Meeting/meetingStore/createMeetingStoreServiceTasks'; import {MeetingStoreProvider, useMeetingStore} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; +import {MeetNowModal} from 'Components/Meeting/MeetNowModal/MeetNowModal'; import {ScheduleMeetingModal} from 'Components/Meeting/ScheduleMeetingModal'; import {useApplicationContext} from 'src/script/page/rootProvider'; @@ -54,6 +56,7 @@ const MeetingsContent = () => { + ); }; @@ -62,15 +65,14 @@ export const Meetings = () => { const {mainViewModel, wallClock} = useApplicationContext(); const {meetings: meetingsRepository, conversation: conversationRepository} = mainViewModel.content.repositories; - const store = useMemo( - () => - createMeetingStore({ - meetingsRepository, - conversationRepository, - wallClock, - }), - [meetingsRepository, conversationRepository, wallClock], - ); + const store = useMemo(() => { + const meetingServiceDeps = {meetingsRepository, conversationRepository, wallClock}; + + return createMeetingStore({ + ...meetingServiceDeps, + serviceTasks: createMeetingStoreServiceTasks(meetingServiceDeps), + }); + }, [meetingsRepository, conversationRepository, wallClock]); return ( diff --git a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.test.ts b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.test.ts index 175af029712..53783af0842 100644 --- a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.test.ts +++ b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.test.ts @@ -23,6 +23,7 @@ import { capEndForStart, getDefaultMeetingEndDateTime, getDefaultScheduleMeetingStartDateTime, + getMeetNowMeetingTimes, getNextHalfHourDateTime, resolveEndChange, resolveStartChange, @@ -117,4 +118,24 @@ describe('scheduleMeetingDefaults', () => { expect(getDefaultScheduleMeetingStartDateTime(wallClock)).toEqual(new Date(2026, 6, 13, 17, 0, 0, 0)); }); + + it('uses the current time as the meet-now start time', () => { + const now = new Date(2026, 6, 13, 16, 47, 0, 0); + const wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: now.getTime()}); + + expect(getMeetNowMeetingTimes(wallClock)).toEqual({ + start: now, + end: new Date(2026, 6, 13, 17, 47, 0, 0), + }); + }); + + it('caps meet-now end time at midnight for late starts', () => { + const now = new Date(2026, 6, 13, 23, 45, 0, 0); + const wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: now.getTime()}); + + expect(getMeetNowMeetingTimes(wallClock)).toEqual({ + start: now, + end: new Date(2026, 6, 14, 0, 0, 0, 0), + }); + }); }); diff --git a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.ts b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.ts index d4d323302fc..e36bcdd5235 100644 --- a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.ts +++ b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults.ts @@ -119,3 +119,9 @@ export const resolveEndChange = (previousStart: Date, previousEnd: Date, nextEnd export const getDefaultScheduleMeetingStartDateTime = (wallClock: WallClock): Date => getNextHalfHourDateTime(wallClock.currentDate); + +export const getMeetNowMeetingTimes = (wallClock: WallClock): {start: Date; end: Date} => { + const start = wallClock.currentDate; + + return {start, end: getDefaultMeetingEndDateTime(start)}; +}; diff --git a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.test.ts b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.test.ts index 59e279ec276..d46ad13fc23 100644 --- a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.test.ts +++ b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.test.ts @@ -22,7 +22,7 @@ import {GROUP_CONVERSATION_TYPE} from '@wireapp/api-client/lib/conversation'; import {CONVERSATION_PROTOCOL} from '@wireapp/api-client/lib/team'; import {maybe, task} from 'true-myth'; -import type {MeetingStoreDeps} from 'Components/Meeting/meetingStore/meetingStoreDeps'; +import type {MeetingServiceDeps} from 'Components/Meeting/meetingStore/meetingStoreDeps'; import {meetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; import type {ConversationRepository} from 'Repositories/conversation/ConversationRepository'; import {Conversation} from 'Repositories/entity/Conversation'; @@ -31,7 +31,7 @@ import type {MeetingsRepository} from 'Repositories/meetings/meetingsRepository' import {translateForTest} from 'Util/test/translateForTest'; import {unwrapErr} from 'Util/test/resultTestSupport'; -import {scheduleMeeting, updateMeeting} from './scheduleMeetingService'; +import {meetNowMeeting, scheduleMeeting, updateMeeting} from './scheduleMeetingService'; import type {ScheduleMeetingFormState} from './scheduleMeetingTypes'; const fixedNow = new Date('2026-06-23T14:30:00.000Z'); @@ -124,7 +124,7 @@ describe('scheduleMeeting', () => { establishMeetingConversation?: jest.Mock; safeAddUsers?: jest.Mock; } = {}): { - deps: MeetingStoreDeps; + deps: MeetingServiceDeps; createMeetingMock: jest.Mock; establishMeetingConversation: jest.Mock; saveMeetingConversationFromBackend: jest.Mock; @@ -229,6 +229,70 @@ describe('scheduleMeeting', () => { }); }); +describe('meetNowMeeting', () => { + const createDeps = ({ + createMeetingMock = jest.fn().mockReturnValue( + task.resolve({ + qualified_conversation: qualifiedConversation, + qualified_id: meetingId, + conversation: meetingConversationResponse, + }), + ), + saveMeetingConversationFromBackend = jest.fn().mockReturnValue(task.resolve(undefined)), + safeGetConversationById = jest.fn().mockReturnValue(task.resolve(createConversation())), + establishMeetingConversation = jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), + safeAddUsers = jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), + }: { + createMeetingMock?: jest.Mock; + saveMeetingConversationFromBackend?: jest.Mock; + safeGetConversationById?: jest.Mock; + establishMeetingConversation?: jest.Mock; + safeAddUsers?: jest.Mock; + } = {}) => { + const meetingsRepository = { + createMeeting: createMeetingMock, + getMeetingsList: jest.fn(), + } as unknown as MeetingsRepository; + + const conversationRepository = { + saveMeetingConversationFromBackend, + safeGetConversationById, + establishMeetingConversation, + safeAddUsers, + } as unknown as ConversationRepository; + + return { + deps: {meetingsRepository, conversationRepository, wallClock}, + createMeetingMock, + establishMeetingConversation, + }; + }; + + it('creates an instant meeting and returns the qualified conversation', async () => { + const {deps, createMeetingMock} = createDeps(); + + const result = await meetNowMeeting( + { + title: 'Standup', + selectedUsers: [], + participantsFilter: '', + }, + deps, + ); + + expect(result.isOk).toBe(true); + expect(result.match({Ok: value => value, Err: () => null})).toEqual({ + failedToAdd: [], + qualifiedConversation, + }); + expect(createMeetingMock).toHaveBeenCalledWith({ + title: 'Standup', + start_time: fixedNow.toISOString(), + end_time: new Date(fixedNow.getTime() + 60 * 60 * 1000).toISOString(), + }); + }); +}); + describe('updateMeeting', () => { const createDeps = ({ updateMeetingMock = jest.fn().mockReturnValue( diff --git a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts index cc375313bb4..179106157f0 100644 --- a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts +++ b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts @@ -18,12 +18,14 @@ */ import is from '@sindresorhus/is'; +import type {CreateMeeting} from '@wireapp/api-client/lib/meetings/createMeeting'; import type {MeetingWithConversation} from '@wireapp/api-client/lib/meetings/meeting'; import type {QualifiedId} from '@wireapp/api-client/lib/user'; import type {AddUsersFailure} from '@wireapp/core/lib/conversation'; import {Maybe, Task, task} from 'true-myth'; import {computeParticipantDiff} from 'Components/Meeting/computeMeetingParticipantDiff'; +import {mapMeetNowFormToCreateMeeting} from 'Components/Meeting/mapMeetNowFormToCreateMeeting'; import {mapScheduleFormToCreateMeeting} from 'Components/Meeting/mapScheduleFormToCreateMeeting'; import {mapScheduleFormToUpdateMeeting} from 'Components/Meeting/mapScheduleFormToUpdateMeeting'; import { @@ -31,14 +33,17 @@ import { syncMeetingConversationParticipants, type MeetingConversationSyncError, } from 'Components/Meeting/meetingConversationSync'; -import type {MeetingStoreDeps} from 'Components/Meeting/meetingStore/meetingStoreDeps'; +import type {MeetingServiceDeps} from 'Components/Meeting/meetingStore/meetingStoreDeps'; import {meetingSubmitErrors, type MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; import type {User} from 'Repositories/entity/User'; import type {ScheduleMeetingFormState, ScheduleMeetingRecurrenceOption} from './scheduleMeetingTypes'; export type MeetingSubmitSuccess = {failedToAdd: AddUsersFailure[]}; +export type MeetNowSubmitSuccess = MeetingSubmitSuccess & {qualifiedConversation: QualifiedId}; + export type UpdateMeetingParams = { meetingId: QualifiedId; formState: ScheduleMeetingFormState; @@ -63,7 +68,7 @@ const mapSyncErrorToSubmitError = (error: MeetingConversationSyncError): Meeting }; const saveMeetingConversationFromResponse = ( - conversationRepository: MeetingStoreDeps['conversationRepository'], + conversationRepository: MeetingServiceDeps['conversationRepository'], conversation: MeetingWithConversation['conversation'] | undefined, onFailure: MeetingSubmitErrors, ): Task => @@ -71,21 +76,13 @@ const saveMeetingConversationFromResponse = ( ? conversationRepository.saveMeetingConversationFromBackend(conversation).mapRejected(() => onFailure) : task.resolve(undefined); -/** - * Schedules a meeting and establishes the MLS conversation with selected participants. - */ -export const scheduleMeeting = ( - formState: ScheduleMeetingFormState, - deps: MeetingStoreDeps, -): Task => { - const mappingResult = mapScheduleFormToCreateMeeting(formState, deps.wallClock); - - if (mappingResult.isErr) { - return task.reject(mappingResult.error); - } - - return deps.meetingsRepository - .createMeeting(mappingResult.value) +const createMeetingAndSyncParticipants = ( + createPayload: CreateMeeting, + selectedUsers: User[], + deps: MeetingServiceDeps, +): Task => + deps.meetingsRepository + .createMeeting(createPayload) .mapRejected(() => meetingSubmitErrors.createFailed) .andThen(createdMeeting => saveMeetingConversationFromResponse( @@ -95,21 +92,56 @@ export const scheduleMeeting = ( ).andThen(() => syncMeetingConversationParticipants(deps.conversationRepository, { qualifiedConversationId: createdMeeting.qualified_conversation, - selectedUsers: formState.selectedUsers, - usersToAdd: formState.selectedUsers, + selectedUsers, + usersToAdd: selectedUsers, userIdsToRemove: [], isCreate: true, - }).mapRejected(mapSyncErrorToSubmitError), + }) + .mapRejected(mapSyncErrorToSubmitError) + .map(syncResult => ({ + ...syncResult, + qualifiedConversation: createdMeeting.qualified_conversation, + })), ), ); + +/** + * Schedules a meeting and establishes the MLS conversation with selected participants. + */ +export const scheduleMeeting = ( + formState: ScheduleMeetingFormState, + deps: MeetingServiceDeps, +): Task => { + const mappingResult = mapScheduleFormToCreateMeeting(formState, deps.wallClock); + + if (mappingResult.isErr) { + return task.reject(mappingResult.error); + } + + return createMeetingAndSyncParticipants(mappingResult.value, formState.selectedUsers, deps).map(({failedToAdd}) => ({ + failedToAdd, + })); }; +/** + * Creates an instant meeting and establishes the MLS conversation with selected participants. + */ +export const meetNowMeeting = ( + formState: MeetNowFormState, + deps: MeetingServiceDeps, +): Task => + createMeetingAndSyncParticipants( + mapMeetNowFormToCreateMeeting(formState, deps.wallClock), + formState.selectedUsers, + deps, + ); + /** * Updates meeting metadata and syncs conversation participants. */ export const updateMeeting = ( {meetingId, formState, qualifiedConversation, originalRecurrence, originalSelectedUsers}: UpdateMeetingParams, - deps: MeetingStoreDeps, + deps: MeetingServiceDeps, ): Task => { const mappingResult = mapScheduleFormToUpdateMeeting(formState, deps.wallClock, originalRecurrence); diff --git a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/useScheduleMeetingSubmit.test.tsx b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/useScheduleMeetingSubmit.test.tsx index 6c8133abfef..bca59b9a1be 100644 --- a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/useScheduleMeetingSubmit.test.tsx +++ b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/useScheduleMeetingSubmit.test.tsx @@ -64,6 +64,7 @@ const createMeetingStore = ({ hasLoadError: false, loadMeetings, scheduleMeeting, + meetNowMeeting: jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), updateMeeting, loadMeetingForEdit: jest.fn().mockReturnValue(task.reject(meetingSubmitErrors.updateFailed)), })); diff --git a/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.test.ts b/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.test.ts new file mode 100644 index 00000000000..f1907b7642e --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.test.ts @@ -0,0 +1,44 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {createDeterministicWallClock} from '@enormora/wall-clock/deterministic-wall-clock'; + +import {mapMeetNowFormToCreateMeeting} from './mapMeetNowFormToCreateMeeting'; + +const fixedNow = new Date('2026-06-23T14:30:00.000Z'); +const wallClock = createDeterministicWallClock({initialCurrentTimestampInMilliseconds: fixedNow.getTime()}); + +describe('mapMeetNowFormToCreateMeeting', () => { + it('maps title and immediate start/end times', () => { + const result = mapMeetNowFormToCreateMeeting( + { + title: ' Standup ', + selectedUsers: [], + participantsFilter: '', + }, + wallClock, + ); + + expect(result).toEqual({ + title: 'Standup', + start_time: fixedNow.toISOString(), + end_time: new Date(fixedNow.getTime() + 60 * 60 * 1000).toISOString(), + }); + }); +}); diff --git a/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts b/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts new file mode 100644 index 00000000000..257db7fbc04 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts @@ -0,0 +1,34 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import type {WallClock} from '@enormora/wall-clock/wall-clock'; +import type {CreateMeeting} from '@wireapp/api-client/lib/meetings/createMeeting'; + +import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; +import {getMeetNowMeetingTimes} from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults'; + +export const mapMeetNowFormToCreateMeeting = (formState: MeetNowFormState, wallClock: WallClock): CreateMeeting => { + const {start, end} = getMeetNowMeetingTimes(wallClock); + + return { + title: formState.title.trim(), + start_time: start.toISOString(), + end_time: end.toISOString(), + }; +}; diff --git a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.test.ts b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.test.ts index cb1613e52b6..83a4460f7ad 100644 --- a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.test.ts +++ b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.test.ts @@ -27,20 +27,7 @@ import type {MeetingsRepository} from 'Repositories/meetings/meetingsRepository' import {translateForTest} from 'Util/test/translateForTest'; import {createMeetingStore} from './createMeetingStore'; -import type {MeetingStoreDeps} from './meetingStoreDeps'; - -jest.mock('Components/Meeting/ScheduleMeetingModal/scheduleMeetingService', () => ({ - scheduleMeeting: jest.fn(), - updateMeeting: jest.fn(), -})); - -import { - scheduleMeeting as scheduleMeetingTask, - updateMeeting as updateMeetingTask, -} from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingService'; - -const mockedScheduleMeetingTask = scheduleMeetingTask as jest.MockedFunction; -const mockedUpdateMeetingTask = updateMeetingTask as jest.MockedFunction; +import type {MeetingStoreDeps, MeetingStoreServiceTasks} from './meetingStoreDeps'; describe('createMeetingStore', () => { const apiMeeting = { @@ -77,21 +64,30 @@ describe('createMeetingStore', () => { initialCurrentTimestampInMilliseconds: Date.parse('2026-06-15T13:00:00.000Z'), }); + const createServiceTasks = (overrides: Partial = {}): MeetingStoreServiceTasks => ({ + scheduleMeeting: jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), + meetNowMeeting: jest + .fn() + .mockReturnValue( + task.resolve({failedToAdd: [], qualifiedConversation: {id: 'conversation-id', domain: 'example.com'}}), + ), + updateMeeting: jest.fn().mockReturnValue(task.resolve({failedToAdd: []})), + ...overrides, + }); + const createDeps = ({ getMeetingsList = jest.fn().mockReturnValue(task.resolve([apiMeeting])), safeGetConversationById = jest.fn(), + serviceTasks = createServiceTasks(), }: { getMeetingsList?: jest.Mock; safeGetConversationById?: jest.Mock; + serviceTasks?: MeetingStoreServiceTasks; } = {}): MeetingStoreDeps => ({ meetingsRepository: {getMeetingsList} as unknown as MeetingsRepository, conversationRepository: {safeGetConversationById} as unknown as ConversationRepository, wallClock, - }); - - beforeEach(() => { - mockedScheduleMeetingTask.mockReset(); - mockedUpdateMeetingTask.mockReset(); + serviceTasks, }); it('loads meetings successfully', async () => { @@ -125,9 +121,11 @@ describe('createMeetingStore', () => { }); it('schedules a meeting without refreshing the meetings list', async () => { - mockedScheduleMeetingTask.mockReturnValue(task.resolve({failedToAdd: []})); + const scheduleMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: []})); const getMeetingsList = jest.fn().mockReturnValue(task.resolve([apiMeeting])); - const store = createMeetingStore(createDeps({getMeetingsList})); + const store = createMeetingStore( + createDeps({getMeetingsList, serviceTasks: createServiceTasks({scheduleMeeting})}), + ); const result = await store.getState().scheduleMeeting({ title: 'Weekly sync', @@ -139,7 +137,7 @@ describe('createMeetingStore', () => { }); expect(result.isOk).toBe(true); - expect(mockedScheduleMeetingTask).toHaveBeenCalled(); + expect(scheduleMeeting).toHaveBeenCalledTimes(1); expect(getMeetingsList).not.toHaveBeenCalled(); }); @@ -157,8 +155,18 @@ describe('createMeetingStore', () => { expect(result.isOk).toBe(true); expect(safeGetConversationById).toHaveBeenCalledWith(listMeetingInstance.meetingSeries.qualified_conversation); - expect(result.value.formState.start.unwrapOr(new Date(0))).toEqual(new Date('2026-06-16T10:00:00.000Z')); - expect(result.value.formState.end.unwrapOr(new Date(0))).toEqual(new Date('2026-06-16T11:00:00.000Z')); + expect( + result.match({ + Ok: value => value.formState.start.unwrapOr(new Date(0)), + Err: () => null, + }), + ).toEqual(new Date('2026-06-16T10:00:00.000Z')); + expect( + result.match({ + Ok: value => value.formState.end.unwrapOr(new Date(0)), + Err: () => null, + }), + ).toEqual(new Date('2026-06-16T11:00:00.000Z')); }); it('prefills edit form with the upcoming instance times for recurring meetings', async () => { @@ -184,7 +192,17 @@ describe('createMeetingStore', () => { const result = await store.getState().loadMeetingForEdit(recurringMeetingInstance); expect(result.isOk).toBe(true); - expect(result.value.formState.start.unwrapOr(new Date(0))).toEqual(new Date('2026-06-22T10:00:00.000Z')); - expect(result.value.formState.end.unwrapOr(new Date(0))).toEqual(new Date('2026-06-22T11:00:00.000Z')); + expect( + result.match({ + Ok: value => value.formState.start.unwrapOr(new Date(0)), + Err: () => null, + }), + ).toEqual(new Date('2026-06-22T10:00:00.000Z')); + expect( + result.match({ + Ok: value => value.formState.end.unwrapOr(new Date(0)), + Err: () => null, + }), + ).toEqual(new Date('2026-06-22T11:00:00.000Z')); }); }); diff --git a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts index d29d1f0a519..1a70f29a33a 100644 --- a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts +++ b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts @@ -23,9 +23,9 @@ import {createStore, type StoreApi} from 'zustand/vanilla'; import {loadMeetingsList} from 'Components/Meeting/loadMeetingsList'; import {mapMeetingInstanceToScheduleFormState} from 'Components/Meeting/mapMeetingInstanceToScheduleFormState'; import {meetingSubmitErrors, type MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; import { - scheduleMeeting as scheduleMeetingTask, - updateMeeting as updateMeetingTask, + type MeetNowSubmitSuccess, type MeetingSubmitSuccess, type UpdateMeetingParams, } from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingService'; @@ -48,6 +48,7 @@ export type MeetingStoreState = { hasLoadError: boolean; loadMeetings: () => Promise; scheduleMeeting: (formState: ScheduleMeetingFormState) => Task; + meetNowMeeting: (formState: MeetNowFormState) => Task; updateMeeting: (params: UpdateMeetingParams) => Task; loadMeetingForEdit: (meetingInstance: MeetingInstance) => Task; }; @@ -68,8 +69,9 @@ export const createMeetingStore = (deps: MeetingStoreDeps, initialState?: Meetin set({meetingSeries: listResult.meetingSeries, hasLoadError: listResult.hasLoadError, isLoading: false}); }, - scheduleMeeting: formState => scheduleMeetingTask(formState, deps), - updateMeeting: params => updateMeetingTask(params, deps), + scheduleMeeting: deps.serviceTasks.scheduleMeeting, + meetNowMeeting: deps.serviceTasks.meetNowMeeting, + updateMeeting: deps.serviceTasks.updateMeeting, loadMeetingForEdit: meetingInstance => { const {meetingSeries} = meetingInstance; diff --git a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStoreServiceTasks.ts b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStoreServiceTasks.ts new file mode 100644 index 00000000000..89c3297de79 --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStoreServiceTasks.ts @@ -0,0 +1,32 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import { + meetNowMeeting, + scheduleMeeting, + updateMeeting, +} from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingService'; + +import type {MeetingServiceDeps, MeetingStoreServiceTasks} from './meetingStoreDeps'; + +export const createMeetingStoreServiceTasks = (deps: MeetingServiceDeps): MeetingStoreServiceTasks => ({ + scheduleMeeting: formState => scheduleMeeting(formState, deps), + meetNowMeeting: formState => meetNowMeeting(formState, deps), + updateMeeting: params => updateMeeting(params, deps), +}); diff --git a/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts b/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts index 9f437604fc8..018b97c66af 100644 --- a/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts +++ b/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts @@ -18,12 +18,31 @@ */ import type {WallClock} from '@enormora/wall-clock/wall-clock'; +import type {Task} from 'true-myth'; +import type {MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; +import type { + MeetNowSubmitSuccess, + MeetingSubmitSuccess, + UpdateMeetingParams, +} from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingService'; +import type {ScheduleMeetingFormState} from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingTypes'; import type {ConversationRepository} from 'Repositories/conversation/ConversationRepository'; import type {MeetingsRepository} from 'Repositories/meetings/meetingsRepository'; -export type MeetingStoreDeps = { +export type MeetingStoreServiceTasks = { + scheduleMeeting: (formState: ScheduleMeetingFormState) => Task; + meetNowMeeting: (formState: MeetNowFormState) => Task; + updateMeeting: (params: UpdateMeetingParams) => Task; +}; + +export type MeetingServiceDeps = { meetingsRepository: MeetingsRepository; conversationRepository: ConversationRepository; wallClock: WallClock; }; + +export type MeetingStoreDeps = MeetingServiceDeps & { + serviceTasks: MeetingStoreServiceTasks; +}; diff --git a/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts b/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts new file mode 100644 index 00000000000..058f252dc1d --- /dev/null +++ b/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts @@ -0,0 +1,54 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {renderHook} from '@testing-library/react'; +import {createDeterministicWallClock} from '@enormora/wall-clock/deterministic-wall-clock'; + +import {useMeetNowModal} from 'Components/Meeting/MeetNowModal/useMeetNowModal'; +import {useMeetingActions} from 'Components/Meeting/useMeetingActions'; +import { + createRootContextValueForTest, + createRootProviderWrapperForTest, +} from 'src/script/page/testSupport/rootContextTestSupport'; +import {translateForTest} from 'Util/test/translateForTest'; + +const wallClock = createDeterministicWallClock({ + initialCurrentTimestampInMilliseconds: Date.parse('2026-06-16T10:00:00.000Z'), +}); + +const RootProviderWrapper = createRootProviderWrapperForTest( + createRootContextValueForTest({translate: translateForTest, wallClock}), +); + +describe('useMeetingActions', () => { + beforeEach(() => { + useMeetNowModal.getState().close(); + useMeetNowModal.getState().reset(); + }); + + it('opens the meet-now modal', () => { + const {result} = renderHook(() => useMeetingActions(), {wrapper: RootProviderWrapper}); + + expect(useMeetNowModal.getState().isOpen).toBe(false); + + result.current.handleMeetNow(); + + expect(useMeetNowModal.getState().isOpen).toBe(true); + }); +}); diff --git a/apps/webapp/src/script/components/Meeting/useMeetingActions.ts b/apps/webapp/src/script/components/Meeting/useMeetingActions.ts index c5bcdc3e84a..790b51d9bdb 100644 --- a/apps/webapp/src/script/components/Meeting/useMeetingActions.ts +++ b/apps/webapp/src/script/components/Meeting/useMeetingActions.ts @@ -17,15 +17,17 @@ * */ +import {useMeetNowModal} from 'Components/Meeting/MeetNowModal/useMeetNowModal'; import {useScheduleMeetingModal} from 'Components/Meeting/ScheduleMeetingModal'; import {useApplicationContext} from 'src/script/page/rootProvider'; export const useMeetingActions = () => { const {wallClock} = useApplicationContext(); const openCreate = useScheduleMeetingModal(state => state.openCreate); + const openMeetNow = useMeetNowModal(state => state.open); const handleMeetNow = () => { - // TODO(Meet Now): create instant meeting, then joinMeetingCall (shared path; not wired yet) + openMeetNow(); }; const handleScheduleMeeting = () => { diff --git a/libraries/api-client/src/conversation/conversationSchema.ts b/libraries/api-client/src/conversation/conversationSchema.ts index 0872251c437..9ceec2d6c72 100644 --- a/libraries/api-client/src/conversation/conversationSchema.ts +++ b/libraries/api-client/src/conversation/conversationSchema.ts @@ -110,7 +110,11 @@ export const conversationSchema = z.object({ access_role_v2: z.array(z.nativeEnum(CONVERSATION_ACCESS_ROLE)).optional(), cells_state: z.nativeEnum(CONVERSATION_CELLS_STATE), group_conv_type: z.nativeEnum(GROUP_CONVERSATION_TYPE).optional(), - add_permission: z.nativeEnum(ADD_PERMISSION).optional(), + add_permission: z + .nativeEnum(ADD_PERMISSION) + .nullable() + .optional() + .transform(addPermission => addPermission ?? undefined), name: z.string().optional(), last_event: z.string().optional(), last_event_time: z.string().optional(), diff --git a/libraries/api-client/src/meetings/meetingSchema.test.ts b/libraries/api-client/src/meetings/meetingSchema.test.ts index 54a3f8d409f..fa259ccd38b 100644 --- a/libraries/api-client/src/meetings/meetingSchema.test.ts +++ b/libraries/api-client/src/meetings/meetingSchema.test.ts @@ -106,6 +106,24 @@ describe('meetingSchema', () => { expect(meetingWithConversationSchema.safeParse(validMeetingWithConversation).success).toBe(true); }); + it('accepts null add_permission on embedded meeting conversations', () => { + const result = meetingWithConversationSchema.safeParse({ + ...validMeetingWithConversation, + conversation: { + ...validMeetingConversation, + add_permission: null, + }, + }); + + expect(result.success).toBe(true); + + if (!result.success) { + throw new Error('Expected meeting with conversation schema parse to succeed'); + } + + expect(result.data.conversation.add_permission).toBeUndefined(); + }); + it('normalizes member fields to match backend conversation types', () => { const result = meetingWithConversationSchema.safeParse({ ...validMeetingWithConversation, From 57fee6795e3b7b21508fb5df2ef15cb74869ebf3 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 20 Jul 2026 11:34:40 +0200 Subject: [PATCH 02/31] fix(webapp): inject ConversationState into useMeetNowSubmit Pass ConversationState from MeetNowModal instead of resolving it inside the hook, so the create-and-join flow can be tested with explicit deps. --- .../components/Meeting/MeetNowModal/MeetNowModal.tsx | 4 +++- .../Meeting/MeetNowModal/useMeetNowSubmit.test.tsx | 9 +++++++-- .../components/Meeting/MeetNowModal/useMeetNowSubmit.ts | 9 ++++----- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx index 5b0d69e5520..a992dea6cc4 100644 --- a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx @@ -24,6 +24,7 @@ import {container} from 'tsyringe'; import {Button, ButtonVariant, CallIcon, CloseIcon} from '@wireapp/react-ui-kit'; import {ModalComponent} from 'Components/Modals/ModalComponent'; +import {ConversationState} from 'Repositories/conversation/ConversationState'; import {UserState} from 'Repositories/user/userState'; import {useApplicationContext} from 'src/script/page/rootProvider'; import {handleEscDown} from 'Util/keyboardUtil'; @@ -48,7 +49,8 @@ export const MeetNowModal = () => { const {fireAndForgetInvoker, translate} = useApplicationContext(); const {isOpen, formState, errors, close, reset, setTitle, setSelectedUsers, setParticipantsFilter, validate} = useMeetNowModal(); - const {isSubmitting, submit} = useMeetNowSubmit(); + const conversationState = container.resolve(ConversationState); + const {isSubmitting, submit} = useMeetNowSubmit(conversationState); const selfUser = container.resolve(UserState).self(); const titleError = useMemo(() => (errors.title ? translate(errors.title) : undefined), [errors.title, translate]); diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx index 0848ce45b30..f58fb412de8 100644 --- a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx @@ -26,6 +26,7 @@ import {createStore} from 'zustand/vanilla'; import type {MeetingStoreState} from 'Components/Meeting/meetingStore/createMeetingStore'; import {MeetingStoreProvider} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; import {meetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import type {ConversationState} from 'Repositories/conversation/ConversationState'; import { createRootContextValueForTest, createRootProviderWrapperForTest, @@ -37,6 +38,10 @@ import {useMeetNowSubmit} from './useMeetNowSubmit'; const qualifiedConversation = {id: 'conversation-id', domain: 'example.com'}; +const conversationState = { + findConversation: jest.fn().mockReturnValue(undefined), +} as unknown as ConversationState; + const formState = { title: 'Standup', selectedUsers: [], @@ -98,7 +103,7 @@ describe('useMeetNowSubmit', () => { const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); const store = createMeetingStore({loadMeetings, meetNowMeeting}); - const {result} = renderHook(() => useMeetNowSubmit(), {wrapper: createWrapper(store)}); + const {result} = renderHook(() => useMeetNowSubmit(conversationState), {wrapper: createWrapper(store)}); let submitResult = false; await act(async () => { @@ -115,7 +120,7 @@ describe('useMeetNowSubmit', () => { const meetNowMeeting = jest.fn().mockReturnValue(task.reject(meetingSubmitErrors.createFailed)); const store = createMeetingStore({loadMeetings, meetNowMeeting}); - const {result} = renderHook(() => useMeetNowSubmit(), {wrapper: createWrapper(store)}); + const {result} = renderHook(() => useMeetNowSubmit(conversationState), {wrapper: createWrapper(store)}); let submitResult = true; await act(async () => { diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts index 7293695c0b1..7fa804b5cff 100644 --- a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts @@ -21,7 +21,6 @@ import {useCallback, useMemo, useState} from 'react'; import type {QualifiedId} from '@wireapp/api-client/lib/user'; import {task} from 'true-myth'; -import {container} from 'tsyringe'; import {joinMeetingCall, type JoinMeetingCallDeps} from 'Components/Meeting/joinMeetingCall'; import {useMeetingStore} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; @@ -29,7 +28,7 @@ import {meetingSubmitErrors, type MeetingSubmitErrors} from 'Components/Meeting/ import {handleJoinMeetingCallResult} from 'Components/Meeting/useJoinMeetingCall'; import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import {showCallNotEstablishedModal, useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard'; -import {ConversationState} from 'Repositories/conversation/ConversationState'; +import type {ConversationState} from 'Repositories/conversation/ConversationState'; import {Config} from 'src/script/Config'; import {useApplicationContext, useMainViewModel} from 'src/script/page/rootProvider'; import type {Translate} from 'Util/localizerUtil'; @@ -55,7 +54,7 @@ const showMeetingSubmitError = (translate: Translate, error: MeetingSubmitErrors ); }; -export const useMeetNowSubmit = () => { +export const useMeetNowSubmit = (conversationState: ConversationState) => { const [isSubmitting, setIsSubmitting] = useState(false); const {translate} = useApplicationContext(); const {content, calling: callingViewModel} = useMainViewModel(); @@ -81,12 +80,12 @@ export const useMeetNowSubmit = () => { const joinDeps = useMemo( () => ({ - conversationState: container.resolve(ConversationState), + conversationState, conversationRepository, callingRepository, callingViewModel, }), - [callingRepository, callingViewModel, conversationRepository], + [callingRepository, callingViewModel, conversationRepository, conversationState], ); const showConversationNotFoundModal = useCallback(() => { From 09af48e11afa38bbc4b6ba570648a5743c015039 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 20 Jul 2026 11:36:04 +0200 Subject: [PATCH 03/31] test(webapp): cover Meet Now submit join outcomes in hook tests Assert the created conversation is passed into join, and document that submit returns true for creation success even when join is blocked or fails. --- .../MeetNowModal/useMeetNowSubmit.test.tsx | 207 +++++++++++++++--- 1 file changed, 182 insertions(+), 25 deletions(-) diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx index f58fb412de8..d3ab09124bf 100644 --- a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx +++ b/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx @@ -19,41 +19,64 @@ import type {ReactNode} from 'react'; -import {act, renderHook} from '@testing-library/react'; +import {act, renderHook, waitFor} from '@testing-library/react'; import {task} from 'true-myth'; import {createStore} from 'zustand/vanilla'; import type {MeetingStoreState} from 'Components/Meeting/meetingStore/createMeetingStore'; import {MeetingStoreProvider} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; import {meetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; +import {PrimaryModal} from 'Components/Modals/PrimaryModal'; import type {ConversationState} from 'Repositories/conversation/ConversationState'; import { createRootContextValueForTest, createRootProviderWrapperForTest, } from 'src/script/page/testSupport/rootContextTestSupport'; import {MainViewModel} from 'src/script/view_model/MainViewModel'; +import {useWarningsState} from 'src/script/view_model/WarningsContainer/WarningsState'; +import {TYPE} from 'src/script/view_model/WarningsContainer/WarningsTypes'; import {translateForTest} from 'Util/test/translateForTest'; import {useMeetNowSubmit} from './useMeetNowSubmit'; const qualifiedConversation = {id: 'conversation-id', domain: 'example.com'}; -const conversationState = { - findConversation: jest.fn().mockReturnValue(undefined), -} as unknown as ConversationState; - const formState = { title: 'Standup', selectedUsers: [], participantsFilter: '', }; -const createMainViewModelForTest = (): MainViewModel => - ({ +type JoinTestMocks = { + conversationState: ConversationState; + findConversation: jest.Mock; + safeGetConversationById: jest.Mock; + startAudio: jest.Mock; + mainViewModel: MainViewModel; +}; + +const createJoinTestMocks = ({ + findConversationResult = undefined, + safeGetConversationByIdResult = task.resolve({qualifiedId: qualifiedConversation}), + startAudioResult = Promise.resolve(undefined), +}: { + findConversationResult?: unknown; + safeGetConversationByIdResult?: ReturnType | ReturnType; + startAudioResult?: Promise; +} = {}): JoinTestMocks => { + const findConversation = jest.fn().mockReturnValue(findConversationResult); + const safeGetConversationById = jest.fn().mockReturnValue(safeGetConversationByIdResult); + const startAudio = jest.fn().mockReturnValue(startAudioResult); + + const conversationState = { + findConversation, + } as unknown as ConversationState; + + const mainViewModel = { content: { repositories: { conversation: { - safeGetConversationById: jest.fn().mockReturnValue(task.resolve({qualifiedId: qualifiedConversation})), + safeGetConversationById, }, calling: { findCall: jest.fn().mockReturnValue(undefined), @@ -62,17 +85,13 @@ const createMainViewModelForTest = (): MainViewModel => }, calling: { callActions: { - startAudio: jest.fn().mockResolvedValue(undefined), + startAudio, }, }, - }) as unknown as MainViewModel; + } as unknown as MainViewModel; -const RootProviderWrapper = createRootProviderWrapperForTest( - createRootContextValueForTest({ - translate: translateForTest, - mainViewModel: createMainViewModelForTest(), - }), -); + return {conversationState, findConversation, safeGetConversationById, startAudio, mainViewModel}; +}; const createMeetingStore = ({ loadMeetings = jest.fn().mockResolvedValue(undefined), @@ -90,20 +109,44 @@ const createMeetingStore = ({ })); const createWrapper = - (store: ReturnType) => - ({children}: {children: ReactNode}) => ( - - {children} - - ); + (store: ReturnType, mainViewModel: MainViewModel) => + ({children}: {children: ReactNode}) => { + const RootProviderWrapper = createRootProviderWrapperForTest( + createRootContextValueForTest({ + translate: translateForTest, + mainViewModel, + }), + ); + + return ( + + {children} + + ); + }; describe('useMeetNowSubmit', () => { - it('refreshes meetings after a successful submit', async () => { + beforeEach(() => { + useWarningsState.setState({name: '', warnings: []}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + act(() => { + useWarningsState.setState({name: '', warnings: []}); + }); + }); + + it('returns true, refreshes meetings, and joins the created conversation after a successful submit', async () => { const loadMeetings = jest.fn().mockResolvedValue(undefined); const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); const store = createMeetingStore({loadMeetings, meetNowMeeting}); + const {conversationState, findConversation, safeGetConversationById, startAudio, mainViewModel} = + createJoinTestMocks(); - const {result} = renderHook(() => useMeetNowSubmit(conversationState), {wrapper: createWrapper(store)}); + const {result} = renderHook(() => useMeetNowSubmit(conversationState), { + wrapper: createWrapper(store, mainViewModel), + }); let submitResult = false; await act(async () => { @@ -113,14 +156,21 @@ describe('useMeetNowSubmit', () => { expect(submitResult).toBe(true); expect(meetNowMeeting).toHaveBeenCalledWith(formState); expect(loadMeetings).toHaveBeenCalledTimes(1); + + await waitFor(() => expect(findConversation).toHaveBeenCalledWith(qualifiedConversation)); + expect(safeGetConversationById).toHaveBeenCalledWith(qualifiedConversation); + expect(startAudio).toHaveBeenCalledTimes(1); }); it('returns false when meeting creation fails', async () => { const loadMeetings = jest.fn().mockResolvedValue(undefined); const meetNowMeeting = jest.fn().mockReturnValue(task.reject(meetingSubmitErrors.createFailed)); const store = createMeetingStore({loadMeetings, meetNowMeeting}); + const {conversationState, startAudio, mainViewModel} = createJoinTestMocks(); - const {result} = renderHook(() => useMeetNowSubmit(conversationState), {wrapper: createWrapper(store)}); + const {result} = renderHook(() => useMeetNowSubmit(conversationState), { + wrapper: createWrapper(store, mainViewModel), + }); let submitResult = true; await act(async () => { @@ -129,5 +179,112 @@ describe('useMeetNowSubmit', () => { expect(submitResult).toBe(false); expect(loadMeetings).not.toHaveBeenCalled(); + expect(startAudio).not.toHaveBeenCalled(); + }); + + it('returns true but does not join when there is no internet connection', async () => { + const showModalSpy = jest.spyOn(PrimaryModal, 'show'); + useWarningsState.setState({name: '', warnings: [TYPE.NO_INTERNET]}); + + const loadMeetings = jest.fn().mockResolvedValue(undefined); + const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); + const store = createMeetingStore({loadMeetings, meetNowMeeting}); + const {conversationState, startAudio, mainViewModel} = createJoinTestMocks(); + + const {result} = renderHook(() => useMeetNowSubmit(conversationState), { + wrapper: createWrapper(store, mainViewModel), + }); + + let submitResult = false; + await act(async () => { + submitResult = await result.current.submit(formState); + }); + + await act(async () => { + await Promise.resolve(); + }); + + expect(submitResult).toBe(true); + expect(loadMeetings).toHaveBeenCalledTimes(1); + expect(startAudio).not.toHaveBeenCalled(); + expect(showModalSpy).toHaveBeenCalledWith( + PrimaryModal.type.ACKNOWLEDGE, + expect.objectContaining({ + text: expect.objectContaining({ + title: 'callNotEstablishedTitle', + }), + }), + undefined, + translateForTest, + ); + }); + + it('returns true but shows a modal when joining the created conversation fails', async () => { + const showModalSpy = jest.spyOn(PrimaryModal, 'show'); + const loadMeetings = jest.fn().mockResolvedValue(undefined); + const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); + const store = createMeetingStore({loadMeetings, meetNowMeeting}); + const {conversationState, startAudio, mainViewModel} = createJoinTestMocks({ + startAudioResult: Promise.reject(new Error('join failed')), + }); + + const {result} = renderHook(() => useMeetNowSubmit(conversationState), { + wrapper: createWrapper(store, mainViewModel), + }); + + let submitResult = false; + await act(async () => { + submitResult = await result.current.submit(formState); + }); + + await waitFor(() => expect(startAudio).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(showModalSpy).toHaveBeenCalled()); + + expect(submitResult).toBe(true); + expect(showModalSpy).toHaveBeenCalledWith( + PrimaryModal.type.ACKNOWLEDGE, + expect.objectContaining({ + text: expect.objectContaining({ + title: 'callNotEstablishedTitle', + }), + }), + undefined, + translateForTest, + ); + }); + + it('returns true but shows a modal when the created conversation cannot be found', async () => { + const showModalSpy = jest.spyOn(PrimaryModal, 'show'); + const loadMeetings = jest.fn().mockResolvedValue(undefined); + const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); + const store = createMeetingStore({loadMeetings, meetNowMeeting}); + const {conversationState, safeGetConversationById, startAudio, mainViewModel} = createJoinTestMocks({ + safeGetConversationByIdResult: task.reject('not found'), + }); + + const {result} = renderHook(() => useMeetNowSubmit(conversationState), { + wrapper: createWrapper(store, mainViewModel), + }); + + let submitResult = false; + await act(async () => { + submitResult = await result.current.submit(formState); + }); + + await waitFor(() => expect(safeGetConversationById).toHaveBeenCalledWith(qualifiedConversation)); + await waitFor(() => expect(showModalSpy).toHaveBeenCalled()); + + expect(submitResult).toBe(true); + expect(startAudio).not.toHaveBeenCalled(); + expect(showModalSpy).toHaveBeenCalledWith( + PrimaryModal.type.ACKNOWLEDGE, + expect.objectContaining({ + text: expect.objectContaining({ + title: 'conversationNotFoundTitle', + }), + }), + undefined, + translateForTest, + ); }); }); From 55bd7db1d3ca157f9831e6a6e820ac4a33313213 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 20 Jul 2026 11:39:00 +0200 Subject: [PATCH 04/31] refactor(webapp): rename Meet Now modal files to camelCase Rename the meetNowModal directory and its PascalCase file names to match the agreed camelCase convention for paths introduced in this PR. --- apps/webapp/src/script/components/Meeting/Meetings.tsx | 2 +- .../Meeting/ScheduleMeetingModal/scheduleMeetingService.ts | 2 +- .../components/Meeting/mapMeetNowFormToCreateMeeting.ts | 2 +- .../MeetNowForm.tsx => meetNowModal/meetNowForm.tsx} | 2 +- .../meetNowModal.styles.ts} | 0 .../MeetNowModal.tsx => meetNowModal/meetNowModal.tsx} | 4 ++-- .../Meeting/{MeetNowModal => meetNowModal}/meetNowTypes.ts | 0 .../{MeetNowModal => meetNowModal}/useMeetNowModal.test.ts | 0 .../Meeting/{MeetNowModal => meetNowModal}/useMeetNowModal.ts | 0 .../{MeetNowModal => meetNowModal}/useMeetNowSubmit.test.tsx | 0 .../{MeetNowModal => meetNowModal}/useMeetNowSubmit.ts | 0 .../components/Meeting/meetingStore/createMeetingStore.ts | 2 +- .../components/Meeting/meetingStore/meetingStoreDeps.ts | 2 +- .../src/script/components/Meeting/useMeetingActions.test.ts | 2 +- .../webapp/src/script/components/Meeting/useMeetingActions.ts | 2 +- 15 files changed, 10 insertions(+), 10 deletions(-) rename apps/webapp/src/script/components/Meeting/{MeetNowModal/MeetNowForm.tsx => meetNowModal/meetNowForm.tsx} (98%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal/MeetNowModal.styles.ts => meetNowModal/meetNowModal.styles.ts} (100%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal/MeetNowModal.tsx => meetNowModal/meetNowModal.tsx} (98%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal => meetNowModal}/meetNowTypes.ts (100%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal => meetNowModal}/useMeetNowModal.test.ts (100%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal => meetNowModal}/useMeetNowModal.ts (100%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal => meetNowModal}/useMeetNowSubmit.test.tsx (100%) rename apps/webapp/src/script/components/Meeting/{MeetNowModal => meetNowModal}/useMeetNowSubmit.ts (100%) diff --git a/apps/webapp/src/script/components/Meeting/Meetings.tsx b/apps/webapp/src/script/components/Meeting/Meetings.tsx index d17e2a323dc..2ebc6710029 100644 --- a/apps/webapp/src/script/components/Meeting/Meetings.tsx +++ b/apps/webapp/src/script/components/Meeting/Meetings.tsx @@ -27,7 +27,7 @@ import {MeetingList} from 'Components/Meeting/MeetingList/MeetingList'; import {createMeetingStore} from 'Components/Meeting/meetingStore/createMeetingStore'; import {createMeetingStoreServiceTasks} from 'Components/Meeting/meetingStore/createMeetingStoreServiceTasks'; import {MeetingStoreProvider, useMeetingStore} from 'Components/Meeting/meetingStore/MeetingStoreProvider'; -import {MeetNowModal} from 'Components/Meeting/MeetNowModal/MeetNowModal'; +import {MeetNowModal} from 'Components/Meeting/meetNowModal/meetNowModal'; import {ScheduleMeetingModal} from 'Components/Meeting/ScheduleMeetingModal'; import {useApplicationContext} from 'src/script/page/rootProvider'; diff --git a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts index 179106157f0..828271f6acd 100644 --- a/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts +++ b/apps/webapp/src/script/components/Meeting/ScheduleMeetingModal/scheduleMeetingService.ts @@ -35,7 +35,7 @@ import { } from 'Components/Meeting/meetingConversationSync'; import type {MeetingServiceDeps} from 'Components/Meeting/meetingStore/meetingStoreDeps'; import {meetingSubmitErrors, type MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; -import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; +import type {MeetNowFormState} from 'Components/Meeting/meetNowModal/meetNowTypes'; import type {User} from 'Repositories/entity/User'; import type {ScheduleMeetingFormState, ScheduleMeetingRecurrenceOption} from './scheduleMeetingTypes'; diff --git a/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts b/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts index 257db7fbc04..06732da2233 100644 --- a/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts +++ b/apps/webapp/src/script/components/Meeting/mapMeetNowFormToCreateMeeting.ts @@ -20,7 +20,7 @@ import type {WallClock} from '@enormora/wall-clock/wall-clock'; import type {CreateMeeting} from '@wireapp/api-client/lib/meetings/createMeeting'; -import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; +import type {MeetNowFormState} from 'Components/Meeting/meetNowModal/meetNowTypes'; import {getMeetNowMeetingTimes} from 'Components/Meeting/ScheduleMeetingModal/scheduleMeetingDefaults'; export const mapMeetNowFormToCreateMeeting = (formState: MeetNowFormState, wallClock: WallClock): CreateMeeting => { diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx similarity index 98% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx rename to apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx index 9eba2f32982..9aac7e826c5 100644 --- a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowForm.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx @@ -31,7 +31,7 @@ import {useScheduleMeetingParticipants} from 'Components/Meeting/ScheduleMeeting import type {User} from 'Repositories/entity/User'; import {useApplicationContext} from 'src/script/page/rootProvider'; -import {meetNowFormLayoutStyles} from './MeetNowModal.styles'; +import {meetNowFormLayoutStyles} from './meetNowModal.styles'; import type {MeetNowFormState} from './meetNowTypes'; export interface MeetNowFormProps { diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.styles.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.styles.ts similarity index 100% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.styles.ts rename to apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.styles.ts diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx similarity index 98% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx rename to apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx index a992dea6cc4..760fdceb10b 100644 --- a/apps/webapp/src/script/components/Meeting/MeetNowModal/MeetNowModal.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx @@ -29,7 +29,7 @@ import {UserState} from 'Repositories/user/userState'; import {useApplicationContext} from 'src/script/page/rootProvider'; import {handleEscDown} from 'Util/keyboardUtil'; -import {MeetNowForm} from './MeetNowForm'; +import {MeetNowForm} from './meetNowForm'; import { bodyStyles, closeButtonStyles, @@ -41,7 +41,7 @@ import { submitButtonIconStyles, submitButtonStyles, wrapperStyles, -} from './MeetNowModal.styles'; +} from './meetNowModal.styles'; import {hasMeetNowFormErrors, useMeetNowModal} from './useMeetNowModal'; import {useMeetNowSubmit} from './useMeetNowSubmit'; diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/meetNowTypes.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowTypes.ts similarity index 100% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/meetNowTypes.ts rename to apps/webapp/src/script/components/Meeting/meetNowModal/meetNowTypes.ts diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.test.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowModal.test.ts similarity index 100% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.test.ts rename to apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowModal.test.ts diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowModal.ts similarity index 100% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowModal.ts rename to apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowModal.ts diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.test.tsx similarity index 100% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.test.tsx rename to apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.test.tsx diff --git a/apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.ts similarity index 100% rename from apps/webapp/src/script/components/Meeting/MeetNowModal/useMeetNowSubmit.ts rename to apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.ts diff --git a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts index 1a70f29a33a..9055f08921d 100644 --- a/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts +++ b/apps/webapp/src/script/components/Meeting/meetingStore/createMeetingStore.ts @@ -23,7 +23,7 @@ import {createStore, type StoreApi} from 'zustand/vanilla'; import {loadMeetingsList} from 'Components/Meeting/loadMeetingsList'; import {mapMeetingInstanceToScheduleFormState} from 'Components/Meeting/mapMeetingInstanceToScheduleFormState'; import {meetingSubmitErrors, type MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; -import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; +import type {MeetNowFormState} from 'Components/Meeting/meetNowModal/meetNowTypes'; import { type MeetNowSubmitSuccess, type MeetingSubmitSuccess, diff --git a/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts b/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts index 018b97c66af..57fb62c106e 100644 --- a/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts +++ b/apps/webapp/src/script/components/Meeting/meetingStore/meetingStoreDeps.ts @@ -21,7 +21,7 @@ import type {WallClock} from '@enormora/wall-clock/wall-clock'; import type {Task} from 'true-myth'; import type {MeetingSubmitErrors} from 'Components/Meeting/MeetingSubmitErrors'; -import type {MeetNowFormState} from 'Components/Meeting/MeetNowModal/meetNowTypes'; +import type {MeetNowFormState} from 'Components/Meeting/meetNowModal/meetNowTypes'; import type { MeetNowSubmitSuccess, MeetingSubmitSuccess, diff --git a/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts b/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts index 058f252dc1d..07bf10072aa 100644 --- a/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts +++ b/apps/webapp/src/script/components/Meeting/useMeetingActions.test.ts @@ -20,7 +20,7 @@ import {renderHook} from '@testing-library/react'; import {createDeterministicWallClock} from '@enormora/wall-clock/deterministic-wall-clock'; -import {useMeetNowModal} from 'Components/Meeting/MeetNowModal/useMeetNowModal'; +import {useMeetNowModal} from 'Components/Meeting/meetNowModal/useMeetNowModal'; import {useMeetingActions} from 'Components/Meeting/useMeetingActions'; import { createRootContextValueForTest, diff --git a/apps/webapp/src/script/components/Meeting/useMeetingActions.ts b/apps/webapp/src/script/components/Meeting/useMeetingActions.ts index 790b51d9bdb..b50dc6b86f3 100644 --- a/apps/webapp/src/script/components/Meeting/useMeetingActions.ts +++ b/apps/webapp/src/script/components/Meeting/useMeetingActions.ts @@ -17,7 +17,7 @@ * */ -import {useMeetNowModal} from 'Components/Meeting/MeetNowModal/useMeetNowModal'; +import {useMeetNowModal} from 'Components/Meeting/meetNowModal/useMeetNowModal'; import {useScheduleMeetingModal} from 'Components/Meeting/ScheduleMeetingModal'; import {useApplicationContext} from 'src/script/page/rootProvider'; From a492d2461de21ec6d2313965f775544e91801a81 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 20 Jul 2026 11:46:15 +0200 Subject: [PATCH 05/31] fix(webapp): return explicit Meet Now submit outcomes Replace the submit boolean with a MeetNowSubmitResult that separates creation failure from join success, blocked join, and join failure. Await joining before submit resolves so the result reflects the full flow. --- .../Meeting/meetNowModal/meetNowModal.tsx | 5 +- .../Meeting/meetNowModal/meetNowTypes.ts | 12 +++++ .../meetNowModal/useMeetNowSubmit.test.tsx | 48 ++++++++----------- .../Meeting/meetNowModal/useMeetNowSubmit.ts | 41 ++++++++++------ 4 files changed, 61 insertions(+), 45 deletions(-) diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx index 760fdceb10b..c8e54af2aba 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx @@ -42,6 +42,7 @@ import { submitButtonStyles, wrapperStyles, } from './meetNowModal.styles'; +import {wasMeetNowMeetingCreated} from './meetNowTypes'; import {hasMeetNowFormErrors, useMeetNowModal} from './useMeetNowModal'; import {useMeetNowSubmit} from './useMeetNowSubmit'; @@ -67,8 +68,8 @@ export const MeetNowModal = () => { } fireAndForgetInvoker.fireAndForget(async (): Promise => { - const didStart = await submit(formState); - if (didStart) { + const submitResult = await submit(formState); + if (wasMeetNowMeetingCreated(submitResult)) { handleClose(); } }); diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowTypes.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowTypes.ts index 891f307eb46..95dafabac92 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowTypes.ts +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowTypes.ts @@ -29,3 +29,15 @@ export type MeetNowFormState = { export type MeetNowFormErrors = { title?: TranslationKey; }; + +export const meetNowSubmitResults = { + creationFailed: 'creationFailed', + joined: 'joined', + joinBlocked: 'joinBlocked', + joinFailed: 'joinFailed', +} as const; + +export type MeetNowSubmitResult = (typeof meetNowSubmitResults)[keyof typeof meetNowSubmitResults]; + +export const wasMeetNowMeetingCreated = (result: MeetNowSubmitResult): boolean => + result !== meetNowSubmitResults.creationFailed; diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.test.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.test.tsx index d3ab09124bf..cc37a5239a2 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.test.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.test.tsx @@ -19,7 +19,7 @@ import type {ReactNode} from 'react'; -import {act, renderHook, waitFor} from '@testing-library/react'; +import {act, renderHook} from '@testing-library/react'; import {task} from 'true-myth'; import {createStore} from 'zustand/vanilla'; @@ -37,6 +37,7 @@ import {useWarningsState} from 'src/script/view_model/WarningsContainer/Warnings import {TYPE} from 'src/script/view_model/WarningsContainer/WarningsTypes'; import {translateForTest} from 'Util/test/translateForTest'; +import {meetNowSubmitResults, type MeetNowSubmitResult} from './meetNowTypes'; import {useMeetNowSubmit} from './useMeetNowSubmit'; const qualifiedConversation = {id: 'conversation-id', domain: 'example.com'}; @@ -137,7 +138,7 @@ describe('useMeetNowSubmit', () => { }); }); - it('returns true, refreshes meetings, and joins the created conversation after a successful submit', async () => { + it('returns joined, refreshes meetings, and joins the created conversation after a successful submit', async () => { const loadMeetings = jest.fn().mockResolvedValue(undefined); const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); const store = createMeetingStore({loadMeetings, meetNowMeeting}); @@ -148,21 +149,20 @@ describe('useMeetNowSubmit', () => { wrapper: createWrapper(store, mainViewModel), }); - let submitResult = false; + let submitResult: MeetNowSubmitResult = meetNowSubmitResults.creationFailed; await act(async () => { submitResult = await result.current.submit(formState); }); - expect(submitResult).toBe(true); + expect(submitResult).toBe(meetNowSubmitResults.joined); expect(meetNowMeeting).toHaveBeenCalledWith(formState); expect(loadMeetings).toHaveBeenCalledTimes(1); - - await waitFor(() => expect(findConversation).toHaveBeenCalledWith(qualifiedConversation)); + expect(findConversation).toHaveBeenCalledWith(qualifiedConversation); expect(safeGetConversationById).toHaveBeenCalledWith(qualifiedConversation); expect(startAudio).toHaveBeenCalledTimes(1); }); - it('returns false when meeting creation fails', async () => { + it('returns creationFailed when meeting creation fails', async () => { const loadMeetings = jest.fn().mockResolvedValue(undefined); const meetNowMeeting = jest.fn().mockReturnValue(task.reject(meetingSubmitErrors.createFailed)); const store = createMeetingStore({loadMeetings, meetNowMeeting}); @@ -172,17 +172,17 @@ describe('useMeetNowSubmit', () => { wrapper: createWrapper(store, mainViewModel), }); - let submitResult = true; + let submitResult: MeetNowSubmitResult = meetNowSubmitResults.joined; await act(async () => { submitResult = await result.current.submit(formState); }); - expect(submitResult).toBe(false); + expect(submitResult).toBe(meetNowSubmitResults.creationFailed); expect(loadMeetings).not.toHaveBeenCalled(); expect(startAudio).not.toHaveBeenCalled(); }); - it('returns true but does not join when there is no internet connection', async () => { + it('returns joinBlocked when there is no internet connection', async () => { const showModalSpy = jest.spyOn(PrimaryModal, 'show'); useWarningsState.setState({name: '', warnings: [TYPE.NO_INTERNET]}); @@ -195,16 +195,12 @@ describe('useMeetNowSubmit', () => { wrapper: createWrapper(store, mainViewModel), }); - let submitResult = false; + let submitResult: MeetNowSubmitResult = meetNowSubmitResults.creationFailed; await act(async () => { submitResult = await result.current.submit(formState); }); - await act(async () => { - await Promise.resolve(); - }); - - expect(submitResult).toBe(true); + expect(submitResult).toBe(meetNowSubmitResults.joinBlocked); expect(loadMeetings).toHaveBeenCalledTimes(1); expect(startAudio).not.toHaveBeenCalled(); expect(showModalSpy).toHaveBeenCalledWith( @@ -219,7 +215,7 @@ describe('useMeetNowSubmit', () => { ); }); - it('returns true but shows a modal when joining the created conversation fails', async () => { + it('returns joinFailed when joining the created conversation fails', async () => { const showModalSpy = jest.spyOn(PrimaryModal, 'show'); const loadMeetings = jest.fn().mockResolvedValue(undefined); const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); @@ -232,15 +228,13 @@ describe('useMeetNowSubmit', () => { wrapper: createWrapper(store, mainViewModel), }); - let submitResult = false; + let submitResult: MeetNowSubmitResult = meetNowSubmitResults.creationFailed; await act(async () => { submitResult = await result.current.submit(formState); }); - await waitFor(() => expect(startAudio).toHaveBeenCalledTimes(1)); - await waitFor(() => expect(showModalSpy).toHaveBeenCalled()); - - expect(submitResult).toBe(true); + expect(submitResult).toBe(meetNowSubmitResults.joinFailed); + expect(startAudio).toHaveBeenCalledTimes(1); expect(showModalSpy).toHaveBeenCalledWith( PrimaryModal.type.ACKNOWLEDGE, expect.objectContaining({ @@ -253,7 +247,7 @@ describe('useMeetNowSubmit', () => { ); }); - it('returns true but shows a modal when the created conversation cannot be found', async () => { + it('returns joinFailed when the created conversation cannot be found', async () => { const showModalSpy = jest.spyOn(PrimaryModal, 'show'); const loadMeetings = jest.fn().mockResolvedValue(undefined); const meetNowMeeting = jest.fn().mockReturnValue(task.resolve({failedToAdd: [], qualifiedConversation})); @@ -266,15 +260,13 @@ describe('useMeetNowSubmit', () => { wrapper: createWrapper(store, mainViewModel), }); - let submitResult = false; + let submitResult: MeetNowSubmitResult = meetNowSubmitResults.creationFailed; await act(async () => { submitResult = await result.current.submit(formState); }); - await waitFor(() => expect(safeGetConversationById).toHaveBeenCalledWith(qualifiedConversation)); - await waitFor(() => expect(showModalSpy).toHaveBeenCalled()); - - expect(submitResult).toBe(true); + expect(submitResult).toBe(meetNowSubmitResults.joinFailed); + expect(safeGetConversationById).toHaveBeenCalledWith(qualifiedConversation); expect(startAudio).not.toHaveBeenCalled(); expect(showModalSpy).toHaveBeenCalledWith( PrimaryModal.type.ACKNOWLEDGE, diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.ts b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.ts index 7fa804b5cff..74ba2c03e11 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.ts +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/useMeetNowSubmit.ts @@ -33,7 +33,7 @@ import {Config} from 'src/script/Config'; import {useApplicationContext, useMainViewModel} from 'src/script/page/rootProvider'; import type {Translate} from 'Util/localizerUtil'; -import type {MeetNowFormState} from './meetNowTypes'; +import {meetNowSubmitResults, type MeetNowFormState, type MeetNowSubmitResult} from './meetNowTypes'; import {SCHEDULE_MEETING_ERROR_TRANSLATION_KEYS} from '../ScheduleMeetingModal/scheduleMeetingErrorKeys'; import {shouldRefreshMeetingsListAfterSubmitError} from '../ScheduleMeetingModal/shouldRefreshMeetingsListAfterSubmitError'; @@ -103,23 +103,33 @@ export const useMeetNowSubmit = (conversationState: ConversationState) => { }, [translate]); const joinCreatedMeeting = useCallback( - (qualifiedConversationId: QualifiedId) => { - guardCall(async () => { - const result = await joinMeetingCall(joinDeps, qualifiedConversationId); - - if (result.isErr) { - handleJoinMeetingCallResult(result, { - showConversationNotFoundModal, - showJoinFailedModal: () => showCallNotEstablishedModal(callNotEstablishedCopy), - }); - } + async (qualifiedConversationId: QualifiedId): Promise => { + let joinAllowed = false; + guardCall(() => { + joinAllowed = true; }); + + if (!joinAllowed) { + return meetNowSubmitResults.joinBlocked; + } + + const result = await joinMeetingCall(joinDeps, qualifiedConversationId); + + if (result.isErr) { + handleJoinMeetingCallResult(result, { + showConversationNotFoundModal, + showJoinFailedModal: () => showCallNotEstablishedModal(callNotEstablishedCopy), + }); + return meetNowSubmitResults.joinFailed; + } + + return meetNowSubmitResults.joined; }, [callNotEstablishedCopy, guardCall, joinDeps, showConversationNotFoundModal], ); const submit = useCallback( - async (formState: MeetNowFormState): Promise => { + async (formState: MeetNowFormState): Promise => { setIsSubmitting(true); const submitResult = await meetNowMeeting(formState); @@ -131,7 +141,7 @@ export const useMeetNowSubmit = (conversationState: ConversationState) => { setIsSubmitting(false); showMeetingSubmitError(translate, submitResult.error); - return false; + return meetNowSubmitResults.creationFailed; } if (submitResult.value.failedToAdd.length > 0) { @@ -146,10 +156,11 @@ export const useMeetNowSubmit = (conversationState: ConversationState) => { const {qualifiedConversation} = submitResult.value; + const joinResult = await joinCreatedMeeting(qualifiedConversation); + setIsSubmitting(false); - joinCreatedMeeting(qualifiedConversation); - return true; + return joinResult; }, [joinCreatedMeeting, loadMeetings, meetNowMeeting, translate], ); From 906e9e2593f69ab212e9560d3a3f5d78572641bc Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 20 Jul 2026 11:47:29 +0200 Subject: [PATCH 06/31] fix(webapp): prevent Meet Now modal close during submit Block close, backdrop, and Escape while submission is in progress, and ignore stale submit completions after the modal session was dismissed. --- .../Meeting/meetNowModal/meetNowModal.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx index c8e54af2aba..5cfc1262c1c 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx @@ -17,7 +17,7 @@ * */ -import {useMemo} from 'react'; +import {useMemo, useRef} from 'react'; import {container} from 'tsyringe'; @@ -53,24 +53,41 @@ export const MeetNowModal = () => { const conversationState = container.resolve(ConversationState); const {isSubmitting, submit} = useMeetNowSubmit(conversationState); const selfUser = container.resolve(UserState).self(); + const submitGenerationRef = useRef(0); const titleError = useMemo(() => (errors.title ? translate(errors.title) : undefined), [errors.title, translate]); - const handleClose = () => { + const dismissModal = () => { close(); reset(); }; + const handleClose = () => { + if (isSubmitting) { + return; + } + + submitGenerationRef.current += 1; + dismissModal(); + }; + const handleSubmit = () => { const validationErrors = validate(); if (hasMeetNowFormErrors(validationErrors)) { return; } + const submitGeneration = submitGenerationRef.current; + fireAndForgetInvoker.fireAndForget(async (): Promise => { const submitResult = await submit(formState); + + if (submitGeneration !== submitGenerationRef.current) { + return; + } + if (wasMeetNowMeetingCreated(submitResult)) { - handleClose(); + dismissModal(); } }); }; @@ -91,6 +108,7 @@ export const MeetNowModal = () => { type="button" css={closeButtonStyles} onClick={handleClose} + disabled={isSubmitting} aria-label={translate('meetings.meetNowModal.closeAriaLabel')} data-uie-name="meet-now-modal-close" > From b6dafb4e7484b23dc0d09884bed3d2fa7a1680b3 Mon Sep 17 00:00:00 2001 From: Amir Ghezelbash Date: Mon, 20 Jul 2026 11:52:33 +0200 Subject: [PATCH 07/31] fix(webapp): use semantic form for Meet Now modal Replace the Meet Now form wrapper with a real form and wire the footer submit button through the HTML form attribute so Enter and click share the same onSubmit path. --- .../Meeting/meetNowModal/meetNowForm.tsx | 16 ++++++++++++++-- .../Meeting/meetNowModal/meetNowModal.tsx | 12 ++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx index 9aac7e826c5..589893591e6 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowForm.tsx @@ -17,6 +17,8 @@ * */ +import type {FormEvent} from 'react'; + import is from '@sindresorhus/is'; import {CircleCloseIcon, ErrorMessage, getOverlayPortalContainer, Input} from '@wireapp/react-ui-kit'; @@ -34,12 +36,15 @@ import {useApplicationContext} from 'src/script/page/rootProvider'; import {meetNowFormLayoutStyles} from './meetNowModal.styles'; import type {MeetNowFormState} from './meetNowTypes'; +export const MEET_NOW_FORM_ID = 'meet-now-form'; + export interface MeetNowFormProps { formState: MeetNowFormState; titleError?: string; onTitleChange: (title: string) => void; onSelectedUsersChange: (users: User[]) => void; onParticipantsFilterChange: (filter: string) => void; + onSubmit: (event: FormEvent) => void; selfUser: User; } @@ -49,6 +54,7 @@ export const MeetNowForm = ({ onTitleChange, onSelectedUsersChange, onParticipantsFilterChange, + onSubmit, selfUser, }: MeetNowFormProps) => { const {mainViewModel, translate} = useApplicationContext(); @@ -61,7 +67,13 @@ export const MeetNowForm = ({ const teamRepository = contentViewModel.repositories.team; return ( -
+
- + ); }; diff --git a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx index 5cfc1262c1c..2c53bd52117 100644 --- a/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx +++ b/apps/webapp/src/script/components/Meeting/meetNowModal/meetNowModal.tsx @@ -17,7 +17,7 @@ * */ -import {useMemo, useRef} from 'react'; +import {type FormEvent, useMemo, useRef} from 'react'; import {container} from 'tsyringe'; @@ -29,7 +29,7 @@ import {UserState} from 'Repositories/user/userState'; import {useApplicationContext} from 'src/script/page/rootProvider'; import {handleEscDown} from 'Util/keyboardUtil'; -import {MeetNowForm} from './meetNowForm'; +import {MEET_NOW_FORM_ID, MeetNowForm} from './meetNowForm'; import { bodyStyles, closeButtonStyles, @@ -71,7 +71,9 @@ export const MeetNowModal = () => { dismissModal(); }; - const handleSubmit = () => { + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const validationErrors = validate(); if (hasMeetNowFormErrors(validationErrors)) { return; @@ -126,15 +128,17 @@ export const MeetNowModal = () => { onTitleChange={setTitle} onSelectedUsersChange={setSelectedUsers} onParticipantsFilterChange={setParticipantsFilter} + onSubmit={handleSubmit} selfUser={selfUser} />