Skip to content
Draft
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: 1 addition & 0 deletions apps/webapp/src/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"E2EI.certificateDetails": "Certificate details (PEM format)",
"E2EI.certificateExpired": "End-to-end identity certificate expired",
"E2EI.certificateExpiresSoon": "End-to-end identity certificate expires soon",
"E2EI.certificateNotActivated": "End-to-end identity certificate not activated",
"E2EI.certificateNotDownloaded": "End-to-end identity certificate not downloaded",
"E2EI.certificateRevoked": "End-to-end identity certificate revoked",
"E2EI.certificateTitle": "End-to-end identity certificate",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/

import {render} from '@testing-library/react';
import {Maybe} from 'true-myth';

import {withTheme} from 'src/script/auth/util/test/testUtil';
import {MLSStatuses} from 'src/script/e2eIdentity';
Expand All @@ -26,7 +27,7 @@ import {
createRootProviderWrapperForTest,
} from 'src/script/page/testSupport/rootContextTestSupport';

import {VerificationBadges} from './verificationBadges';
import {VerificationBadges, getUserVerificationBadgeLabel} from './verificationBadges';
import {translateForTest} from 'Util/test/translateForTest';

const rootContextValue = createRootContextValueForTest({translate: translateForTest});
Expand Down Expand Up @@ -92,3 +93,37 @@ describe('VerificationBadges', () => {
expect(E2EIdentityStatus.getAttribute('data-uie-value')).toEqual(MLSStatuses.EXPIRES_SOON);
});
});

describe('getUserVerificationBadgeLabel', () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests cover getUserVerificationBadgeLabel in isolation, but they do not test the newly constructed accessible name assigned to the button in UserDetails.

Please add component tests that render UserDetails and verify the button’s accessible name. At minimum, cover the availability status, combined MLS and Proteus verification, and the EXPIRES_SOON translation. Otherwise, the integration between the status values and the actual accessible name remains untested.

it('returns MLS verified label for VALID status', () => {
const label = getUserVerificationBadgeLabel(translateForTest, {
mlsStatus: Maybe.just(MLSStatuses.VALID),
isProteusVerified: false,
});
expect(label).toBe(translateForTest('E2EI.userDevicesVerified'));
});

it('returns Proteus device verified label when isProteusVerified is true', () => {
const label = getUserVerificationBadgeLabel(translateForTest, {
mlsStatus: Maybe.nothing(),
isProteusVerified: true,
});
expect(label).toBe(translateForTest('proteusDeviceVerified'));
});

it('returns composed labels when both MLS and Proteus are verified', () => {
const label = getUserVerificationBadgeLabel(translateForTest, {
mlsStatus: Maybe.just(MLSStatuses.VALID),
isProteusVerified: true,
});
expect(label).toBe(`${translateForTest('E2EI.userDevicesVerified')}, ${translateForTest('proteusDeviceVerified')}`);
});

it('returns undefined when neither MLS nor Proteus is verified', () => {
const label = getUserVerificationBadgeLabel(translateForTest, {
mlsStatus: Maybe.nothing(),
isProteusVerified: false,
});
expect(label).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {CSSObject} from '@emotion/react';
import {CONVERSATION_PROTOCOL} from '@wireapp/api-client/lib/team';
import {stringifyQualifiedId} from '@wireapp/core/lib/util/qualifiedIdUtil';
import {container} from 'tsyringe';
import {Maybe} from 'true-myth';

import {
TabIndex,
Expand Down Expand Up @@ -98,7 +99,7 @@ const getMLSStatuses = ({identities, user}: {identities?: WireIdentity[]; user?:
});
};

export const UserVerificationBadges = ({
export const useUserVerificationStatus = ({
user,
groupId,
isSelfUser,
Expand All @@ -115,12 +116,57 @@ export const UserVerificationBadges = ({
user,
});

let status: MLSStatuses | undefined = undefined;
if (mlsStatuses && mlsStatuses.length > 0 && mlsStatuses.every(status => status === MLSStatuses.VALID)) {
status = MLSStatuses.VALID;
const mlsStatus = Maybe.of<MLSStatuses.VALID>(
mlsStatuses && mlsStatuses.length > 0 && mlsStatuses.every(status => status === MLSStatuses.VALID)
? MLSStatuses.VALID
: undefined,
);

return {mlsStatus, isProteusVerified};
};

export const getUserVerificationBadgeLabel = (
translate: RootContextValue['translate'],
{mlsStatus, isProteusVerified}: {mlsStatus: Maybe<MLSStatuses.VALID>; isProteusVerified: boolean},
): string | undefined => {

@screendriver screendriver Jul 23, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid undefined and use Maybe instead?

const labels: string[] = [];

mlsStatus.map(() => labels.push(translate('E2EI.userDevicesVerified')));

if (isProteusVerified) {
labels.push(translate('proteusDeviceVerified'));
}

return <VerificationBadges context="user" isProteusVerified={isProteusVerified} MLSStatus={status} />;
return labels.length > 0 ? labels.join(', ') : undefined;
};

export const UserVerificationBadgesContent = ({
mlsStatus,
isProteusVerified,
}: {
mlsStatus: Maybe<MLSStatuses.VALID>;
isProteusVerified: boolean;
}) => {
return (
<VerificationBadges
context="user"
isProteusVerified={isProteusVerified}
MLSStatus={mlsStatus.unwrapOr(undefined)}
/>
);
};

export const UserVerificationBadges = ({
user,
groupId,
isSelfUser,
}: {
user: User;
groupId?: string;
isSelfUser?: boolean;
}) => {
const {mlsStatus, isProteusVerified} = useUserVerificationStatus({user, groupId, isSelfUser});
return <UserVerificationBadgesContent mlsStatus={mlsStatus} isProteusVerified={isProteusVerified} />;
};

export const DeviceVerificationBadges = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,18 @@

import {memo} from 'react';

import is from '@sindresorhus/is';
import cx from 'classnames';
import {Availability} from '@wireapp/protocol-messaging';

import {TabIndex} from '@wireapp/react-ui-kit';

import {Avatar, AVATAR_SIZE} from 'Components/avatar';
import {UserVerificationBadges} from 'Components/badge';
import {
getUserVerificationBadgeLabel,
useUserVerificationStatus,
UserVerificationBadgesContent,
} from 'Components/badge';
import {LegalHoldDot} from 'Components/LegalHoldDot';
import {User} from 'Repositories/entity/User';
import {useApplicationContext} from 'src/script/page/rootProvider';
Expand All @@ -37,18 +43,38 @@ import {AvailabilityContextMenu} from '../../../ui/availabilityContextMenu';
interface AvailabilityStateButtonWrapperProps {
children: React.ReactElement;
isTeam: boolean;
ariaLabel: string;
showAvailabilityContextMenu: (event: MouseEvent) => void;
}

const isAvailabilityType = (value: unknown): value is Availability.Type =>
Object.values(Availability.Type).some(availabilityType => availabilityType === value);

const getAvailabilityTranslationKey = (availability: Availability.Type) => {
switch (availability) {
case Availability.Type.AVAILABLE:
return 'availability.available';
case Availability.Type.BUSY:
return 'availability.busy';
case Availability.Type.AWAY:
return 'availability.away';
case Availability.Type.NONE:
return 'availability.none';
}
};

const AvailabilityStateButtonWrapper = ({
children,
isTeam = false,
ariaLabel,
showAvailabilityContextMenu,
}: AvailabilityStateButtonWrapperProps) => {
return isTeam ? (
<button
onClick={event => showAvailabilityContextMenu(event.nativeEvent)}
className="button-reset-default user-details-avatar"
aria-label={ariaLabel}
Comment thread
e-maad marked this conversation as resolved.
aria-haspopup="menu"
>
{children}
</button>
Expand All @@ -69,9 +95,11 @@ const UserDetailsComponent = ({user, isTeam = false, groupId, isSideBarOpen = fa
const {
name: userName,
username: userHandle,
availability,
isOnLegalHold,
hasPendingLegalHold,
} = useKoSubscribableChildren(user, ['hasPendingLegalHold', 'isOnLegalHold', 'name', 'username']);
} = useKoSubscribableChildren(user, ['availability', 'hasPendingLegalHold', 'isOnLegalHold', 'name', 'username']);
const verificationStatus = useUserVerificationStatus({user, groupId, isSelfUser: isTeam});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still causes useUserVerificationStatus to run twice. UserDetailsComponent calls it here, but the UserVerificationBadges rendered below calls the same hook again internally. The team-user path therefore still performs the identity lookup and registers the device-status listener twice.

Please calculate the verification status once and pass the resulting mlsStatus and isProteusVerified values to the rendered badges.


const showLegalHold = isOnLegalHold || hasPendingLegalHold;

Expand All @@ -84,28 +112,45 @@ const UserDetailsComponent = ({user, isTeam = false, groupId, isSideBarOpen = fa
});
};

const avatarAriaLabel = [
userName,
userHandle,
isTeam && isAvailabilityType(availability) ? translate(getAvailabilityTranslationKey(availability)) : undefined,
getUserVerificationBadgeLabel(translate, verificationStatus),
]
.filter((label): label is string => is.nonEmptyString(label))
.join(', ');

return (
<div css={styles.wrapper(isSideBarOpen)}>
<AvailabilityStateButtonWrapper isTeam={isTeam} showAvailabilityContextMenu={showAvailabilityContextMenu}>
<AvailabilityStateButtonWrapper
isTeam={isTeam}
ariaLabel={avatarAriaLabel}
showAvailabilityContextMenu={showAvailabilityContextMenu}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other button that invokes showAvailabilityContextMenu still does not expose aria-haspopup="menu". Please add aria-haspopup="menu" to both buttons that open the availability menu.

>
<Avatar
className={cx('see-through', {'user-details-avatar': !isTeam})}
participant={user}
avatarSize={AVATAR_SIZE.MEDIUM}
avatarAlt={translate('selfProfileImageAlt')}
avatarAlt={isTeam ? '' : translate('selfProfileImageAlt')}
/>
</AvailabilityStateButtonWrapper>

<div css={styles.userDetailsWrapper(isSideBarOpen)}>
{isTeam ? (
<>
<div css={styles.userDetails} data-uie-name="status-availability">
<button css={styles.userFullName} onClick={event => showAvailabilityContextMenu(event.nativeEvent)}>
<button
css={styles.userFullName}
onClick={event => showAvailabilityContextMenu(event.nativeEvent)}
aria-haspopup="menu"
>
<span data-uie-name="status-label" css={{...styles.userName, ...styles.textEllipsis}} title={userName}>
{userName}
</span>
</button>

<UserVerificationBadges user={user} isSelfUser groupId={groupId} />
<UserVerificationBadgesContent {...verificationStatus} />
</div>

{showLegalHold && (
Expand Down
Loading