Skip to content

Commit 0ad87b5

Browse files
zskhanaweiss-dev
authored andcommitted
fix: add guard on join/call buttons [WPB-21715]. (#19837)
* fix: add guard on join/call buttons. * fix: return empty cleanup method
1 parent bc914df commit 0ad87b5

File tree

7 files changed

+177
-20
lines changed

7 files changed

+177
-20
lines changed

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ Prerequisites:
2626
## 1. Fetching dependencies and configurations
2727

2828
1. Run `yarn`
29-
3029
- This will install all dependencies and fetch a [configuration](https://github.com/wireapp/wire-web-config-wire/) for the application.
3130

3231
## 2. Build & run

src/script/components/ConversationListCell/ConversationListCell.tsx

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {ChannelAvatar} from 'Components/Avatar/ChannelAvatar';
2929
import {UserBlockedBadge} from 'Components/Badge';
3030
import {CellDescription} from 'Components/ConversationListCell/components/CellDescription';
3131
import {UserInfo} from 'Components/UserInfo';
32+
import {useConversationCall} from 'Hooks/useConversationCall';
3233
import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard';
3334
import type {Conversation} from 'Repositories/entity/Conversation';
3435
import {MediaType} from 'Repositories/media/MediaType';
@@ -45,7 +46,7 @@ interface ConversationListCellProps {
4546
dataUieName: string;
4647
isSelected?: (conversation: Conversation) => boolean;
4748
onClick: (event: ReactMouseEvent<HTMLDivElement, MouseEvent> | ReactKeyBoardEvent<HTMLDivElement>) => void;
48-
onJoinCall: (conversation: Conversation, mediaType: MediaType) => void;
49+
onJoinCall: (conversation: Conversation, mediaType: MediaType) => Promise<void>;
4950
rightClick: (conversation: Conversation, event: MouseEvent | React.MouseEvent<Element, MouseEvent>) => void;
5051
showJoinButton: boolean;
5152
handleArrowKeyDown: (e: React.KeyboardEvent) => void;
@@ -93,6 +94,7 @@ export const ConversationListCell = ({
9394
]);
9495

9596
const guardCall = useNoInternetCallGuard();
97+
const {isCallConnecting} = useConversationCall(conversation);
9698

9799
const {isChannelsEnabled} = useChannelsFeatureFlag();
98100
const isActive = isSelected(conversation);
@@ -103,17 +105,38 @@ export const ConversationListCell = ({
103105
const [isContextMenuOpen, setContextMenuOpen] = useState(false);
104106
const contextMenuKeyboardShortcut = `keyboard-shortcut-${conversation.id}`;
105107

108+
// Ref for immediate synchronous protection from multiple clicks
109+
const isJoiningCallRef = useRef(false);
110+
111+
// Button is disabled if either local state or call state indicates joining
112+
const isButtonDisabled = isJoiningCallRef.current || isCallConnecting;
113+
106114
const openContextMenu = (event: MouseEvent | React.MouseEvent<Element, MouseEvent>) => {
107115
event.stopPropagation();
108116
event.preventDefault();
109117
rightClick(conversation, event);
110118
};
111119

112-
const onClickJoinCall = (event: React.MouseEvent) => {
120+
const handleJoinCall = async (event: React.MouseEvent) => {
113121
event.preventDefault();
114-
guardCall(() => {
115-
onJoinCall(conversation, MediaType.AUDIO);
116-
});
122+
123+
// Check ref first for immediate synchronous protection
124+
if (isJoiningCallRef.current || isButtonDisabled) {
125+
return;
126+
}
127+
128+
// Immediately disable synchronously
129+
isJoiningCallRef.current = true;
130+
131+
try {
132+
await guardCall(async () => {
133+
await onJoinCall(conversation, MediaType.AUDIO);
134+
isJoiningCallRef.current = false;
135+
});
136+
} catch (error) {
137+
// Re-enable on error
138+
isJoiningCallRef.current = false;
139+
}
117140
};
118141

119142
const handleDivKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -241,10 +264,11 @@ export const ConversationListCell = ({
241264

242265
{showJoinButton && (
243266
<button
244-
onClick={onClickJoinCall}
267+
onClick={handleJoinCall}
245268
type="button"
246269
className="call-ui__button call-ui__button--green call-ui__button--join"
247270
data-uie-name="do-call-controls-call-join"
271+
disabled={isButtonDisabled}
248272
>
249273
{t('callJoin')}
250274
</button>

src/script/components/TitleBar/TitleBar.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {ConversationVerificationBadges} from 'Components/Badge';
3030
import {useCallAlertState} from 'Components/calling/useCallAlertState';
3131
import * as Icon from 'Components/Icon';
3232
import {LegalHoldDot} from 'Components/LegalHoldDot';
33+
import {useConversationCall} from 'Hooks/useConversationCall';
3334
import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard';
3435
import {CallState} from 'Repositories/calling/CallState';
3536
import {ConversationFilter} from 'Repositories/conversation/ConversationFilter';
@@ -103,12 +104,24 @@ export const TitleBar = ({
103104
]);
104105

105106
const guardCall = useNoInternetCallGuard();
107+
const {isCallConnecting, isCallActive} = useConversationCall(conversation);
106108

107109
const {isActivatedAccount} = useKoSubscribableChildren(selfUser, ['isActivatedAccount']);
108110
const {joinedCall, activeCalls} = useKoSubscribableChildren(callState, ['joinedCall', 'activeCalls']);
109111

110112
const currentFocusedElementRef = useRef<HTMLButtonElement | null>(null);
111113

114+
// using ref for immediate double-click protection
115+
const isStartingCallRef = useRef(false);
116+
117+
// Reset local state when a call becomes active or cleared
118+
if (isStartingCallRef && (isCallActive || activeCalls.length === 0)) {
119+
isStartingCallRef.current = false;
120+
}
121+
122+
// Button is disabled if starting, connecting, or already active
123+
const isCallButtonDisabled = isReadOnlyConversation || isStartingCallRef.current || isCallConnecting || isCallActive;
124+
112125
const badgeLabelCopy = useMemo(() => {
113126
if (is1to1 && isRequest) {
114127
return '';
@@ -200,9 +213,21 @@ export const TitleBar = ({
200213
const onClickDetails = () => showDetails(false);
201214

202215
const startCallAndShowAlert = () => {
203-
guardCall(() => {
204-
callActions.startAudio(conversation);
205-
showStartedCallAlert(isGroupOrChannel);
216+
if (isStartingCallRef.current || isCallButtonDisabled) {
217+
return;
218+
}
219+
220+
isStartingCallRef.current = true;
221+
222+
guardCall(async () => {
223+
try {
224+
await callActions.startAudio(conversation);
225+
isStartingCallRef.current = false;
226+
showStartedCallAlert(isGroupOrChannel);
227+
} catch (error) {
228+
// Re-enable on error
229+
isStartingCallRef.current = false;
230+
}
206231
});
207232
};
208233

@@ -306,7 +331,7 @@ export const TitleBar = ({
306331
startCallAndShowAlert();
307332
}}
308333
data-uie-name="do-call"
309-
disabled={isReadOnlyConversation}
334+
disabled={isCallButtonDisabled}
310335
>
311336
<CallIcon />
312337
</button>
@@ -331,7 +356,7 @@ export const TitleBar = ({
331356
css={{marginBottom: 0}}
332357
onClick={onClickStartAudio}
333358
data-uie-name="do-call"
334-
disabled={isReadOnlyConversation}
359+
disabled={isCallButtonDisabled}
335360
>
336361
<CallIcon />
337362
</IconButton>

src/script/components/calling/CallingCell/CallingCell.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*
1818
*/
1919

20-
import React, {useCallback, useEffect} from 'react';
20+
import React, {useCallback, useEffect, useRef} from 'react';
2121

2222
import {container} from 'tsyringe';
2323

@@ -33,6 +33,7 @@ import {GroupVideoGrid} from 'Components/calling/GroupVideoGrid';
3333
import {useCallAlertState} from 'Components/calling/useCallAlertState';
3434
import {ConversationClassifiedBar} from 'Components/ClassifiedBar/ClassifiedBar';
3535
import * as Icon from 'Components/Icon';
36+
import {useConversationCall} from 'Hooks/useConversationCall';
3637
import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard';
3738
import type {Call} from 'Repositories/calling/Call';
3839
import type {CallingRepository} from 'Repositories/calling/CallingRepository';
@@ -114,6 +115,10 @@ export const CallingCell = ({
114115
const {activeCallViewTab, viewMode} = useKoSubscribableChildren(callState, ['activeCallViewTab', 'viewMode']);
115116

116117
const guardCall = useNoInternetCallGuard();
118+
const {isCallConnecting} = useConversationCall(conversation);
119+
120+
// Ref for immediate synchronous protection from multiple clicks
121+
const isAnsweringRef = useRef(false);
117122

118123
const selfParticipant = call.getSelfParticipant();
119124

@@ -138,6 +143,14 @@ export const CallingCell = ({
138143
const isConnecting = state === CALL_STATE.ANSWERED;
139144
const isOngoing = state === CALL_STATE.MEDIA_ESTAB;
140145

146+
// Reset local state when call state changes from incoming
147+
if (isAnsweringRef.current && !isIncoming) {
148+
isAnsweringRef.current = false;
149+
}
150+
151+
// Button is disabled if either local state or call state indicates answering
152+
const isAnswerButtonDisabled = isAnsweringRef.current || isCallConnecting;
153+
141154
const callStatus: Partial<Record<CALL_STATE, CallLabel>> = {
142155
[CALL_STATE.OUTGOING]: {
143156
dataUieName: 'call-label-outgoing',
@@ -222,9 +235,23 @@ export const CallingCell = ({
222235
const {showAlert, clearShowAlert} = useCallAlertState();
223236

224237
const answerCall = () => {
225-
guardCall(() => {
226-
callActions.answer(call);
227-
setCurrentView(ViewType.MOBILE_LEFT_SIDEBAR);
238+
// Check ref first for immediate synchronous protection
239+
if (isAnsweringRef.current || isAnswerButtonDisabled) {
240+
return;
241+
}
242+
243+
// Immediately disable synchronously
244+
isAnsweringRef.current = true;
245+
246+
guardCall(async () => {
247+
try {
248+
await callActions.answer(call);
249+
isAnsweringRef.current = false;
250+
setCurrentView(ViewType.MOBILE_LEFT_SIDEBAR);
251+
} catch (error) {
252+
// Re-enable on error
253+
isAnsweringRef.current = false;
254+
}
228255
});
229256
};
230257

@@ -404,6 +431,7 @@ export const CallingCell = ({
404431
disableScreenButton={!callingRepository.supportsScreenSharing}
405432
teamState={teamState}
406433
supportsVideoCall={conversation.supportsVideoCall(call.isConference)}
434+
isAnswerButtonDisabled={isAnswerButtonDisabled}
407435
/>
408436
</div>
409437
)}

src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ interface CallingControlsProps {
5454
disableScreenButton: boolean;
5555
teamState: TeamState;
5656
supportsVideoCall: boolean;
57+
isAnswerButtonDisabled?: boolean;
5758
}
5859

5960
export const CallingControls = ({
@@ -75,6 +76,7 @@ export const CallingControls = ({
7576
selfParticipant,
7677
teamState = container.resolve(TeamState),
7778
supportsVideoCall,
79+
isAnswerButtonDisabled = false,
7880
}: CallingControlsProps) => {
7981
const {isVideoCallingEnabled} = useKoSubscribableChildren(teamState, ['isVideoCallingEnabled']);
8082
const {sharesScreen: selfSharesScreen, sharesCamera: selfSharesCamera} = useKoSubscribableChildren(selfParticipant, [
@@ -190,6 +192,7 @@ export const CallingControls = ({
190192
onClick={answerCall}
191193
type="button"
192194
data-uie-name="do-call-controls-call-join"
195+
disabled={isAnswerButtonDisabled}
193196
>
194197
{t('callJoin')}
195198
</button>
@@ -201,6 +204,7 @@ export const CallingControls = ({
201204
title={t('callAccept')}
202205
aria-label={t('callAccept')}
203206
data-uie-name="do-call-controls-call-accept"
207+
disabled={isAnswerButtonDisabled}
204208
>
205209
<Icon.PickupIcon className="small-icon" />
206210
</button>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Wire
3+
* Copyright (C) 2025 Wire Swiss GmbH
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see http://www.gnu.org/licenses/.
17+
*
18+
*/
19+
20+
import {useEffect, useMemo, useState} from 'react';
21+
22+
import {container} from 'tsyringe';
23+
24+
import {STATE as CALL_STATE} from '@wireapp/avs';
25+
26+
import {CallState} from 'Repositories/calling/CallState';
27+
import type {Conversation} from 'Repositories/entity/Conversation';
28+
import {useKoSubscribableChildren} from 'Util/ComponentUtil';
29+
import {matchQualifiedIds} from 'Util/QualifiedId';
30+
31+
interface ConversationCallState {
32+
/** connecting/joining */
33+
isCallConnecting: boolean;
34+
/** active/joined */
35+
isCallActive: boolean;
36+
}
37+
38+
/**
39+
* Hook to get the call state for a specific conversation
40+
* @param conversation - The conversation to check for calls
41+
* @returns Call state information
42+
*/
43+
export const useConversationCall = (conversation: Conversation): ConversationCallState => {
44+
const callState = container.resolve(CallState);
45+
const {calls} = useKoSubscribableChildren(callState, ['calls']);
46+
47+
const call = useMemo(
48+
() => calls.find(call => matchQualifiedIds(call.conversation.qualifiedId, conversation.qualifiedId)),
49+
[calls, conversation.qualifiedId],
50+
);
51+
52+
const [currentCallState, setCurrentCallState] = useState<CALL_STATE | null>(() => call?.state() ?? null);
53+
54+
// Subscribe to the call's state changes
55+
useEffect(() => {
56+
if (!call) {
57+
setCurrentCallState(null);
58+
return () => {};
59+
}
60+
61+
setCurrentCallState(call.state());
62+
63+
// Subscribe to state changes
64+
const subscription = call.state.subscribe(newState => {
65+
setCurrentCallState(newState);
66+
});
67+
68+
return () => {
69+
subscription.dispose();
70+
};
71+
}, [call]);
72+
73+
return {
74+
isCallConnecting: currentCallState === CALL_STATE.ANSWERED,
75+
isCallActive: currentCallState === CALL_STATE.MEDIA_ESTAB,
76+
};
77+
};

src/script/view_model/ListViewModel.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,23 +148,23 @@ export class ListViewModel {
148148
amplify.subscribe(WebAppEvents.SHORTCUT.SILENCE, this.changeNotificationSetting); // todo: deprecated - remove when user base of wrappers version >= 3.4 is large enough
149149
};
150150

151-
readonly answerCall = (conversationEntity: Conversation): void => {
151+
readonly answerCall = async (conversationEntity: Conversation): Promise<void> => {
152152
const call = this.callingRepository.findCall(conversationEntity.qualifiedId);
153153

154154
if (!call) {
155155
return;
156156
}
157157

158158
if (call.isConference && !this.callingRepository.supportsConferenceCalling) {
159-
PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, {
159+
return PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, {
160160
text: {
161161
message: `${t('modalConferenceCallNotSupportedMessage')} ${t('modalConferenceCallNotSupportedJoinMessage')}`,
162162
title: t('modalConferenceCallNotSupportedHeadline'),
163163
},
164164
});
165-
} else {
166-
this.callingViewModel.callActions.answer(call);
167165
}
166+
167+
return this.callingViewModel.callActions.answer(call);
168168
};
169169

170170
readonly changeNotificationSetting = () => {

0 commit comments

Comments
 (0)