diff --git a/README.md b/README.md index e02b5502325..4926113742b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/package.json b/server/package.json index 548618ad962..1d65dab1d25 100644 --- a/server/package.json +++ b/server/package.json @@ -4,13 +4,13 @@ "main": "dist/index.js", "license": "GPL-3.0", "dependencies": { - "@wireapp/commons": "5.4.9", + "@wireapp/commons": "5.4.10", "dotenv": "16.5.0", "dotenv-extended": "2.9.0", "express": "4.22.0", "express-sitemap-xml": "3.1.0", - "express-useragent": "1.0.15", - "fs-extra": "11.3.1", + "express-useragent": "2.0.2", + "fs-extra": "11.3.2", "geolite2": "1.3.0", "hbs": "4.2.0", "helmet": "8.1.0", @@ -31,7 +31,7 @@ "@types/hbs": "4.0.5", "@types/jest": "^29.5.14", "@types/node": "22.5.5", - "browserslist": "^4.28.0", + "browserslist": "^4.28.1", "jest": "29.7.0", "rimraf": "6.1.2", "typescript": "5.6.3" diff --git a/server/yarn.lock b/server/yarn.lock index 8e021b250bb..bc19b8678ce 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -1123,15 +1123,15 @@ __metadata: languageName: node linkType: hard -"@wireapp/commons@npm:5.4.9": - version: 5.4.9 - resolution: "@wireapp/commons@npm:5.4.9" +"@wireapp/commons@npm:5.4.10": + version: 5.4.10 + resolution: "@wireapp/commons@npm:5.4.10" dependencies: ansi-regex: "npm:5.0.1" fs-extra: "npm:11.3.1" logdown: "npm:3.3.1" platform: "npm:1.3.6" - checksum: 10/10804e146e7dbe3f87ad2473a066eafe17faf0e2f8c2e89e378b844e2ff72777b8cb640aa76adb52f17d8ad46bcb37cfb4cffb8311fdea3102d653ff1e49bfa0 + checksum: 10/35832d8da7eeb07dae12ed3489780f302b861841683505ba07ccb10eed3e139af56ccf6245ed4c6a8ee16bddfbc374b145634ea37ff6257d86ed944bd3dc06c6 languageName: node linkType: hard @@ -1462,6 +1462,15 @@ __metadata: languageName: node linkType: hard +"baseline-browser-mapping@npm:^2.9.0": + version: 2.9.4 + resolution: "baseline-browser-mapping@npm:2.9.4" + bin: + baseline-browser-mapping: dist/cli.js + checksum: 10/71cf80f822e74e0f0109a9ed69d87fdb128d01bf06670a2ef91166a3eb636034e0a013d76cd9915a9d38594f649848c8c1ef6cbe39ed417f38314ff5bd22e393 + languageName: node + linkType: hard + "basic-ftp@npm:^5.0.2": version: 5.0.3 resolution: "basic-ftp@npm:5.0.3" @@ -1522,7 +1531,7 @@ __metadata: languageName: node linkType: hard -"browserslist@npm:^4.21.9, browserslist@npm:^4.28.0": +"browserslist@npm:^4.21.9": version: 4.28.0 resolution: "browserslist@npm:4.28.0" dependencies: @@ -1537,6 +1546,21 @@ __metadata: languageName: node linkType: hard +"browserslist@npm:^4.28.1": + version: 4.28.1 + resolution: "browserslist@npm:4.28.1" + dependencies: + baseline-browser-mapping: "npm:^2.9.0" + caniuse-lite: "npm:^1.0.30001759" + electron-to-chromium: "npm:^1.5.263" + node-releases: "npm:^2.0.27" + update-browserslist-db: "npm:^1.2.0" + bin: + browserslist: cli.js + checksum: 10/64f2a97de4bce8473c0e5ae0af8d76d1ead07a5b05fc6bc87b848678bb9c3a91ae787b27aa98cdd33fc00779607e6c156000bed58fefb9cf8e4c5a183b994cdb + languageName: node + linkType: hard + "bser@npm:2.1.1": version: 2.1.1 resolution: "bser@npm:2.1.1" @@ -1628,6 +1652,13 @@ __metadata: languageName: node linkType: hard +"caniuse-lite@npm:^1.0.30001759": + version: 1.0.30001759 + resolution: "caniuse-lite@npm:1.0.30001759" + checksum: 10/da0ec28dd993dffa99402914903426b9466d2798d41c1dc9341fcb7dd10f58fdd148122e2c65001246c030ba1c939645b7b4597f6321e3246dc792323bb11541 + languageName: node + linkType: hard + "chalk@npm:3.0.0, chalk@npm:~3.0.0": version: 3.0.0 resolution: "chalk@npm:3.0.0" @@ -2083,6 +2114,13 @@ __metadata: languageName: node linkType: hard +"electron-to-chromium@npm:^1.5.263": + version: 1.5.266 + resolution: "electron-to-chromium@npm:1.5.266" + checksum: 10/2c7e05d1df189013e01b9fa19f5794dc249b80f330ab87f78674fa7416df153e2d32738d16914eee1112b5d8878b6181336e502215a34c63c255da078de5209d + languageName: node + linkType: hard + "emittery@npm:^0.13.1": version: 0.13.1 resolution: "emittery@npm:0.13.1" @@ -2341,10 +2379,10 @@ __metadata: languageName: node linkType: hard -"express-useragent@npm:1.0.15": - version: 1.0.15 - resolution: "express-useragent@npm:1.0.15" - checksum: 10/5427b00a19b80d8bc2cee6a5c79c17e3d544b19a105d3a8b9d15e10d782b53f0d3c4522ca2bb04673528cd7adbc3c4e26604b58316a0ec086fb28b7064401b92 +"express-useragent@npm:2.0.2": + version: 2.0.2 + resolution: "express-useragent@npm:2.0.2" + checksum: 10/39ed1a15ff5fb865a1386e45958c167f60b7fc90a0ea7f7971bde7fa0f2fd79667f08abb29cffd352afe15172b5442cef5809ea491438762bd4cd511675f02d8 languageName: node linkType: hard @@ -2512,6 +2550,17 @@ __metadata: languageName: node linkType: hard +"fs-extra@npm:11.3.2": + version: 11.3.2 + resolution: "fs-extra@npm:11.3.2" + dependencies: + graceful-fs: "npm:^4.2.0" + jsonfile: "npm:^6.0.1" + universalify: "npm:^2.0.0" + checksum: 10/d559545c73fda69c75aa786f345c2f738b623b42aea850200b1582e006a35278f63787179e3194ba19413c26a280441758952b0c7e88dd96762d497e365a6c3e + languageName: node + linkType: hard + "fs-extra@npm:^8.1.0": version: 8.1.0 resolution: "fs-extra@npm:8.1.0" @@ -5547,6 +5596,20 @@ __metadata: languageName: node linkType: hard +"update-browserslist-db@npm:^1.2.0": + version: 1.2.2 + resolution: "update-browserslist-db@npm:1.2.2" + dependencies: + escalade: "npm:^3.2.0" + picocolors: "npm:^1.1.1" + peerDependencies: + browserslist: ">= 4.21.0" + bin: + update-browserslist-db: cli.js + checksum: 10/ae2102d3c83fca35e9deb012d82bfde6f734998ced937e34a3bf239a4b67577108fdd144283aafc0e5e3cf38ca1aecd7714906ba6f562896c762d2f2fa391026 + languageName: node + linkType: hard + "util-deprecate@npm:^1.0.1": version: 1.0.2 resolution: "util-deprecate@npm:1.0.2" @@ -5641,14 +5704,14 @@ __metadata: "@types/hbs": "npm:4.0.5" "@types/jest": "npm:^29.5.14" "@types/node": "npm:22.5.5" - "@wireapp/commons": "npm:5.4.9" - browserslist: "npm:^4.28.0" + "@wireapp/commons": "npm:5.4.10" + browserslist: "npm:^4.28.1" dotenv: "npm:16.5.0" dotenv-extended: "npm:2.9.0" express: "npm:4.22.0" express-sitemap-xml: "npm:3.1.0" - express-useragent: "npm:1.0.15" - fs-extra: "npm:11.3.1" + express-useragent: "npm:2.0.2" + fs-extra: "npm:11.3.2" geolite2: "npm:1.3.0" hbs: "npm:4.2.0" helmet: "npm:8.1.0" diff --git a/src/script/components/ConversationListCell/ConversationListCell.tsx b/src/script/components/ConversationListCell/ConversationListCell.tsx index 2ff0cc482a8..8a5e8a86d60 100644 --- a/src/script/components/ConversationListCell/ConversationListCell.tsx +++ b/src/script/components/ConversationListCell/ConversationListCell.tsx @@ -29,6 +29,7 @@ import {ChannelAvatar} from 'Components/Avatar/ChannelAvatar'; 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'; @@ -45,7 +46,7 @@ interface ConversationListCellProps { dataUieName: string; isSelected?: (conversation: Conversation) => boolean; onClick: (event: ReactMouseEvent | ReactKeyBoardEvent) => void; - onJoinCall: (conversation: Conversation, mediaType: MediaType) => void; + onJoinCall: (conversation: Conversation, mediaType: MediaType) => Promise; rightClick: (conversation: Conversation, event: MouseEvent | React.MouseEvent) => void; showJoinButton: boolean; handleArrowKeyDown: (e: React.KeyboardEvent) => void; @@ -93,6 +94,7 @@ export const ConversationListCell = ({ ]); const guardCall = useNoInternetCallGuard(); + const {isCallConnecting} = useConversationCall(conversation); const {isChannelsEnabled} = useChannelsFeatureFlag(); const isActive = isSelected(conversation); @@ -103,17 +105,38 @@ export const ConversationListCell = ({ 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) => { 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; + } }; const handleDivKeyDown = (event: React.KeyboardEvent) => { @@ -241,10 +264,11 @@ export const ConversationListCell = ({ {showJoinButton && ( diff --git a/src/script/components/TitleBar/TitleBar.tsx b/src/script/components/TitleBar/TitleBar.tsx index f847447904b..fe23243b72d 100644 --- a/src/script/components/TitleBar/TitleBar.tsx +++ b/src/script/components/TitleBar/TitleBar.tsx @@ -30,6 +30,7 @@ import {ConversationVerificationBadges} from 'Components/Badge'; 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'; @@ -103,12 +104,24 @@ export const TitleBar = ({ ]); const guardCall = useNoInternetCallGuard(); + const {isCallConnecting, isCallActive} = useConversationCall(conversation); const {isActivatedAccount} = useKoSubscribableChildren(selfUser, ['isActivatedAccount']); const {joinedCall, activeCalls} = useKoSubscribableChildren(callState, ['joinedCall', 'activeCalls']); const currentFocusedElementRef = useRef(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 ''; @@ -200,9 +213,21 @@ export const TitleBar = ({ 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; + } }); }; @@ -306,7 +331,7 @@ export const TitleBar = ({ startCallAndShowAlert(); }} data-uie-name="do-call" - disabled={isReadOnlyConversation} + disabled={isCallButtonDisabled} > @@ -331,7 +356,7 @@ export const TitleBar = ({ css={{marginBottom: 0}} onClick={onClickStartAudio} data-uie-name="do-call" - disabled={isReadOnlyConversation} + disabled={isCallButtonDisabled} > diff --git a/src/script/components/calling/CallingCell/CallingCell.tsx b/src/script/components/calling/CallingCell/CallingCell.tsx index 7d9e1a44808..e8c4653d7e2 100644 --- a/src/script/components/calling/CallingCell/CallingCell.tsx +++ b/src/script/components/calling/CallingCell/CallingCell.tsx @@ -17,7 +17,7 @@ * */ -import React, {useCallback, useEffect} from 'react'; +import React, {useCallback, useEffect, useRef} from 'react'; import {container} from 'tsyringe'; @@ -33,6 +33,7 @@ import {GroupVideoGrid} from 'Components/calling/GroupVideoGrid'; 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'; @@ -114,6 +115,10 @@ export const CallingCell = ({ 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(); @@ -138,6 +143,14 @@ export const CallingCell = ({ 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> = { [CALL_STATE.OUTGOING]: { dataUieName: 'call-label-outgoing', @@ -222,9 +235,23 @@ export const CallingCell = ({ 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; + } }); }; @@ -404,6 +431,7 @@ export const CallingCell = ({ disableScreenButton={!callingRepository.supportsScreenSharing} teamState={teamState} supportsVideoCall={conversation.supportsVideoCall(call.isConference)} + isAnswerButtonDisabled={isAnswerButtonDisabled} /> )} diff --git a/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx b/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx index 649b2ddb261..60a62b69f1b 100644 --- a/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx +++ b/src/script/components/calling/CallingCell/CallingControls/CallingControls.tsx @@ -54,6 +54,7 @@ interface CallingControlsProps { disableScreenButton: boolean; teamState: TeamState; supportsVideoCall: boolean; + isAnswerButtonDisabled?: boolean; } export const CallingControls = ({ @@ -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, [ @@ -190,6 +192,7 @@ export const CallingControls = ({ onClick={answerCall} type="button" data-uie-name="do-call-controls-call-join" + disabled={isAnswerButtonDisabled} > {t('callJoin')} @@ -201,6 +204,7 @@ export const CallingControls = ({ title={t('callAccept')} aria-label={t('callAccept')} data-uie-name="do-call-controls-call-accept" + disabled={isAnswerButtonDisabled} > diff --git a/src/script/hooks/useConversationCall.ts b/src/script/hooks/useConversationCall.ts new file mode 100644 index 00000000000..729acbc213a --- /dev/null +++ b/src/script/hooks/useConversationCall.ts @@ -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); + + // 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, + }; +}; diff --git a/src/script/view_model/ListViewModel.ts b/src/script/view_model/ListViewModel.ts index ad283740ceb..2f8be3d251d 100644 --- a/src/script/view_model/ListViewModel.ts +++ b/src/script/view_model/ListViewModel.ts @@ -148,7 +148,7 @@ 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 => { const call = this.callingRepository.findCall(conversationEntity.qualifiedId); if (!call) { @@ -156,15 +156,15 @@ export class ListViewModel { } 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 = () => { diff --git a/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts b/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts index d64eca9d515..c48be6b4aad 100644 --- a/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts +++ b/test/e2e_tests/pageManager/webapp/cells/cellsFileDetailView.modal.ts @@ -31,7 +31,7 @@ export class CellsFileDetailViewModal { this.page = page; this.closeButton = page.locator("[aria-label='Close']"); this.downloadButton = page.locator("[aria-label='Download']"); - this.image = page.locator("[role='dialog'][aria-modal='true'][id^=':'][id$=':'] img"); + this.image = page.locator("[role='dialog'][aria-modal='true'] img"); } async isImageVisible() { diff --git a/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts b/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts index 8917f673fd6..a2fe989355b 100644 --- a/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts +++ b/test/e2e_tests/specs/CriticalFlow/Cells/uploadingFileInGroupConversation.spec.ts @@ -40,7 +40,7 @@ const imageFilePath = getImageFilePath(); test( 'Uploading an file in a group conversation', - {tag: ['@crit-flow-cells']}, + {tag: ['@crit-flow-cells', '@regression']}, async ({pageManager: userAPageManager, browser, api}) => { const {pages: userAPages, modals: userAModals, components: userAComponents} = userAPageManager.webapp;