Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ Prerequisites:
## 1. Fetching dependencies and configurations

1. Run `yarn`

- This will install all dependencies and fetch a [configuration](https://github.com/wireapp/wire-web-config-wire/) for the application.

## 2. Build & run
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import {UserBlockedBadge} from 'Components/Badge';
import {CellDescription} from 'Components/ConversationListCell/components/CellDescription';
import {UserInfo} from 'Components/UserInfo';
import {useConversationCall} from 'Hooks/useConversationCall';
import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard';
import type {Conversation} from 'Repositories/entity/Conversation';
import {MediaType} from 'Repositories/media/MediaType';
Expand All @@ -45,7 +46,7 @@
dataUieName: string;
isSelected?: (conversation: Conversation) => boolean;
onClick: (event: ReactMouseEvent<HTMLDivElement, MouseEvent> | ReactKeyBoardEvent<HTMLDivElement>) => void;
onJoinCall: (conversation: Conversation, mediaType: MediaType) => void;
onJoinCall: (conversation: Conversation, mediaType: MediaType) => Promise<void>;
rightClick: (conversation: Conversation, event: MouseEvent | React.MouseEvent<Element, MouseEvent>) => void;
showJoinButton: boolean;
handleArrowKeyDown: (e: React.KeyboardEvent) => void;
Expand Down Expand Up @@ -93,6 +94,7 @@
]);

const guardCall = useNoInternetCallGuard();
const {isCallConnecting} = useConversationCall(conversation);

const {isChannelsEnabled} = useChannelsFeatureFlag();
const isActive = isSelected(conversation);
Expand All @@ -103,17 +105,38 @@
const [isContextMenuOpen, setContextMenuOpen] = useState(false);
const contextMenuKeyboardShortcut = `keyboard-shortcut-${conversation.id}`;

// Ref for immediate synchronous protection from multiple clicks
const isJoiningCallRef = useRef(false);

// Button is disabled if either local state or call state indicates joining
const isButtonDisabled = isJoiningCallRef.current || isCallConnecting;

const openContextMenu = (event: MouseEvent | React.MouseEvent<Element, MouseEvent>) => {
event.stopPropagation();
event.preventDefault();
rightClick(conversation, event);
};

const onClickJoinCall = (event: React.MouseEvent) => {
const handleJoinCall = async (event: React.MouseEvent) => {
event.preventDefault();
guardCall(() => {
onJoinCall(conversation, MediaType.AUDIO);
});

// Check ref first for immediate synchronous protection
if (isJoiningCallRef.current || isButtonDisabled) {
return;
}

// Immediately disable synchronously
isJoiningCallRef.current = true;

try {
await guardCall(async () => {
await onJoinCall(conversation, MediaType.AUDIO);
isJoiningCallRef.current = false;
});
} catch (error) {
// Re-enable on error
isJoiningCallRef.current = false;
}

Check warning on line 139 in src/script/components/ConversationListCell/ConversationListCell.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZrk7unUWjao2rxeAe-N&open=AZrk7unUWjao2rxeAe-N&pullRequest=19837
};

const handleDivKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -241,10 +264,11 @@

{showJoinButton && (
<button
onClick={onClickJoinCall}
onClick={handleJoinCall}
type="button"
className="call-ui__button call-ui__button--green call-ui__button--join"
data-uie-name="do-call-controls-call-join"
disabled={isButtonDisabled}
>
{t('callJoin')}
</button>
Expand Down
35 changes: 30 additions & 5 deletions src/script/components/TitleBar/TitleBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import {useCallAlertState} from 'Components/calling/useCallAlertState';
import * as Icon from 'Components/Icon';
import {LegalHoldDot} from 'Components/LegalHoldDot';
import {useConversationCall} from 'Hooks/useConversationCall';
import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard';
import {CallState} from 'Repositories/calling/CallState';
import {ConversationFilter} from 'Repositories/conversation/ConversationFilter';
Expand Down Expand Up @@ -103,12 +104,24 @@
]);

const guardCall = useNoInternetCallGuard();
const {isCallConnecting, isCallActive} = useConversationCall(conversation);

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

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

// using ref for immediate double-click protection
const isStartingCallRef = useRef(false);

// Reset local state when a call becomes active or cleared
if (isStartingCallRef && (isCallActive || activeCalls.length === 0)) {
isStartingCallRef.current = false;
}

// Button is disabled if starting, connecting, or already active
const isCallButtonDisabled = isReadOnlyConversation || isStartingCallRef.current || isCallConnecting || isCallActive;

const badgeLabelCopy = useMemo(() => {
if (is1to1 && isRequest) {
return '';
Expand Down Expand Up @@ -200,9 +213,21 @@
const onClickDetails = () => showDetails(false);

const startCallAndShowAlert = () => {
guardCall(() => {
callActions.startAudio(conversation);
showStartedCallAlert(isGroupOrChannel);
if (isStartingCallRef.current || isCallButtonDisabled) {
return;
}

isStartingCallRef.current = true;

guardCall(async () => {
try {
await callActions.startAudio(conversation);
isStartingCallRef.current = false;
showStartedCallAlert(isGroupOrChannel);
} catch (error) {
// Re-enable on error
isStartingCallRef.current = false;
}

Check warning on line 230 in src/script/components/TitleBar/TitleBar.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZrk7um7Wjao2rxeAe-M&open=AZrk7um7Wjao2rxeAe-M&pullRequest=19837
});
};

Expand Down Expand Up @@ -306,7 +331,7 @@
startCallAndShowAlert();
}}
data-uie-name="do-call"
disabled={isReadOnlyConversation}
disabled={isCallButtonDisabled}
>
<CallIcon />
</button>
Expand All @@ -331,7 +356,7 @@
css={{marginBottom: 0}}
onClick={onClickStartAudio}
data-uie-name="do-call"
disabled={isReadOnlyConversation}
disabled={isCallButtonDisabled}
>
<CallIcon />
</IconButton>
Expand Down
36 changes: 32 additions & 4 deletions src/script/components/calling/CallingCell/CallingCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
*
*/

import React, {useCallback, useEffect} from 'react';
import React, {useCallback, useEffect, useRef} from 'react';

import {container} from 'tsyringe';

Expand All @@ -33,6 +33,7 @@
import {useCallAlertState} from 'Components/calling/useCallAlertState';
import {ConversationClassifiedBar} from 'Components/ClassifiedBar/ClassifiedBar';
import * as Icon from 'Components/Icon';
import {useConversationCall} from 'Hooks/useConversationCall';
import {useNoInternetCallGuard} from 'Hooks/useNoInternetCallGuard/useNoInternetCallGuard';
import type {Call} from 'Repositories/calling/Call';
import type {CallingRepository} from 'Repositories/calling/CallingRepository';
Expand Down Expand Up @@ -114,6 +115,10 @@
const {activeCallViewTab, viewMode} = useKoSubscribableChildren(callState, ['activeCallViewTab', 'viewMode']);

const guardCall = useNoInternetCallGuard();
const {isCallConnecting} = useConversationCall(conversation);

// Ref for immediate synchronous protection from multiple clicks
const isAnsweringRef = useRef(false);

const selfParticipant = call.getSelfParticipant();

Expand All @@ -138,6 +143,14 @@
const isConnecting = state === CALL_STATE.ANSWERED;
const isOngoing = state === CALL_STATE.MEDIA_ESTAB;

// Reset local state when call state changes from incoming
if (isAnsweringRef.current && !isIncoming) {
isAnsweringRef.current = false;
}

// Button is disabled if either local state or call state indicates answering
const isAnswerButtonDisabled = isAnsweringRef.current || isCallConnecting;

const callStatus: Partial<Record<CALL_STATE, CallLabel>> = {
[CALL_STATE.OUTGOING]: {
dataUieName: 'call-label-outgoing',
Expand Down Expand Up @@ -222,9 +235,23 @@
const {showAlert, clearShowAlert} = useCallAlertState();

const answerCall = () => {
guardCall(() => {
callActions.answer(call);
setCurrentView(ViewType.MOBILE_LEFT_SIDEBAR);
// Check ref first for immediate synchronous protection
if (isAnsweringRef.current || isAnswerButtonDisabled) {
return;
}

// Immediately disable synchronously
isAnsweringRef.current = true;

guardCall(async () => {
try {
await callActions.answer(call);
isAnsweringRef.current = false;
setCurrentView(ViewType.MOBILE_LEFT_SIDEBAR);
} catch (error) {
// Re-enable on error
isAnsweringRef.current = false;
}

Check warning on line 254 in src/script/components/calling/CallingCell/CallingCell.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Handle this exception or don't catch it at all.

See more on https://sonarcloud.io/project/issues?id=wireapp_wire-webapp&issues=AZrk7uh9Wjao2rxeAe-L&open=AZrk7uh9Wjao2rxeAe-L&pullRequest=19837
});
};

Expand Down Expand Up @@ -404,6 +431,7 @@
disableScreenButton={!callingRepository.supportsScreenSharing}
teamState={teamState}
supportsVideoCall={conversation.supportsVideoCall(call.isConference)}
isAnswerButtonDisabled={isAnswerButtonDisabled}
/>
</div>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ interface CallingControlsProps {
disableScreenButton: boolean;
teamState: TeamState;
supportsVideoCall: boolean;
isAnswerButtonDisabled?: boolean;
}

export const CallingControls = ({
Expand All @@ -75,6 +76,7 @@ export const CallingControls = ({
selfParticipant,
teamState = container.resolve(TeamState),
supportsVideoCall,
isAnswerButtonDisabled = false,
}: CallingControlsProps) => {
const {isVideoCallingEnabled} = useKoSubscribableChildren(teamState, ['isVideoCallingEnabled']);
const {sharesScreen: selfSharesScreen, sharesCamera: selfSharesCamera} = useKoSubscribableChildren(selfParticipant, [
Expand Down Expand Up @@ -190,6 +192,7 @@ export const CallingControls = ({
onClick={answerCall}
type="button"
data-uie-name="do-call-controls-call-join"
disabled={isAnswerButtonDisabled}
>
{t('callJoin')}
</button>
Expand All @@ -201,6 +204,7 @@ export const CallingControls = ({
title={t('callAccept')}
aria-label={t('callAccept')}
data-uie-name="do-call-controls-call-accept"
disabled={isAnswerButtonDisabled}
>
<Icon.PickupIcon className="small-icon" />
</button>
Expand Down
77 changes: 77 additions & 0 deletions src/script/hooks/useConversationCall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Wire
* Copyright (C) 2025 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 {useEffect, useMemo, useState} from 'react';

import {container} from 'tsyringe';

import {STATE as CALL_STATE} from '@wireapp/avs';

import {CallState} from 'Repositories/calling/CallState';
import type {Conversation} from 'Repositories/entity/Conversation';
import {useKoSubscribableChildren} from 'Util/ComponentUtil';
import {matchQualifiedIds} from 'Util/QualifiedId';

interface ConversationCallState {
/** connecting/joining */
isCallConnecting: boolean;
/** active/joined */
isCallActive: boolean;
}

/**
* Hook to get the call state for a specific conversation
* @param conversation - The conversation to check for calls
* @returns Call state information
*/
export const useConversationCall = (conversation: Conversation): ConversationCallState => {
const callState = container.resolve(CallState);
const {calls} = useKoSubscribableChildren(callState, ['calls']);

const call = useMemo(
() => calls.find(call => matchQualifiedIds(call.conversation.qualifiedId, conversation.qualifiedId)),
[calls, conversation.qualifiedId],
);

const [currentCallState, setCurrentCallState] = useState<CALL_STATE | null>(() => call?.state() ?? null);

// Subscribe to the call's state changes
useEffect(() => {
if (!call) {
setCurrentCallState(null);
return () => {};
}

setCurrentCallState(call.state());

// Subscribe to state changes
const subscription = call.state.subscribe(newState => {
setCurrentCallState(newState);
});

return () => {
subscription.dispose();
};
}, [call]);

return {
isCallConnecting: currentCallState === CALL_STATE.ANSWERED,
isCallActive: currentCallState === CALL_STATE.MEDIA_ESTAB,
};
};
8 changes: 4 additions & 4 deletions src/script/view_model/ListViewModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,23 +148,23 @@ export class ListViewModel {
amplify.subscribe(WebAppEvents.SHORTCUT.SILENCE, this.changeNotificationSetting); // todo: deprecated - remove when user base of wrappers version >= 3.4 is large enough
};

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

if (!call) {
return;
}

if (call.isConference && !this.callingRepository.supportsConferenceCalling) {
PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, {
return PrimaryModal.show(PrimaryModal.type.ACKNOWLEDGE, {
text: {
message: `${t('modalConferenceCallNotSupportedMessage')} ${t('modalConferenceCallNotSupportedJoinMessage')}`,
title: t('modalConferenceCallNotSupportedHeadline'),
},
});
} else {
this.callingViewModel.callActions.answer(call);
}

return this.callingViewModel.callActions.answer(call);
};

readonly changeNotificationSetting = () => {
Expand Down
Loading