Updated conversation hero UI & profile name warning

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-04-16 13:22:26 -05:00
committed by GitHub
parent 0589eee9a3
commit 321f67d309
16 changed files with 505 additions and 775 deletions
+33
View File
@@ -4664,10 +4664,22 @@
"messageformat": "<clickable>Profile names</clickable> are not verified",
"description": "Label for profile names in the name verification warning in conversation hero"
},
"icu:ConversationHero--name-not-verified": {
"messageformat": "Name not verified",
"description": "Label for profile names and group names in the name verification warning in conversation hero"
},
"icu:ConversationHero--signal-official-chat": {
"messageformat": "This is the official and only chat from Signal",
"description": "Text indicating that this is the official Signal conversation"
},
"icu:ConversationHero--signal-official-account": {
"messageformat": "Official account",
"description": "Text indicating that this is the official Signal conversation"
},
"icu:ConversationHero--signal-official-account--description": {
"messageformat": "The only official chat from Signal. Keep up to date with news and release notes.",
"description": "Description text at the top of the official Signal conversation"
},
"icu:ConversationHero--release-notes": {
"messageformat": "Keep up to date with news and release notes.",
"description": "Text explaining the purpose of the Signal official conversation"
@@ -7033,6 +7045,10 @@
"messageformat": "Safety Tips",
"description": "Shown on the message request warning. Clicking this button will open a dialog with safety tips"
},
"icu:MessageRequestWarning__safety-tips-v2": {
"messageformat": "Safety tips",
"description": "Shown on the message request warning. Clicking this button will open a dialog with safety tips"
},
"icu:ContactSpoofing__same-name--link": {
"messageformat": "Review requests carefully. Signal found another contact with the same name. <reviewRequestLink>Review request</reviewRequestLink>",
"description": "Shown in the timeline warning when you have a message request from someone with the same name as someone else"
@@ -10188,6 +10204,23 @@
"messageformat": "Don't share personal information with people you don't know",
"description": "Third list item in profile name warning modal for direct conversations"
},
"icu:ProfileNameWarningModal__warning--signal-cant-verify": {
"messageformat": "Signal cant verify names and photos",
"description": "List item in profile name warning modal for direct conversations"
},
"icu:ProfileNameWarningModal__warning--signal-wont-contact": {
"messageformat": "Signal will never contact you for your registration code, PIN, or recovery key",
"description": "List item in profile name warning modal for direct conversations"
},
"icu:ProfileNameWarningModal__warning--be-cautious": {
"messageformat": "Be cautious of accounts that impersonate others",
"description": "Third list item in profile name warning modal for direct conversations"
},
"icu:ProfileNameWarningModal__warning--dont-share-info": {
"messageformat": "Don't share personal information with people you don't know",
"description": "List item in profile name warning modal for direct conversations"
},
"icu:ProfileNameWarningModal__description--group": {
"messageformat": "Group names are chosen by members of the group.",
"description": "Description of how group names work in the profile name warning modal for group conversations"
@@ -1,263 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
@use '../variables';
.module-conversation-hero {
padding-block: 32px 28px;
padding-inline: 0;
text-align: center;
&__avatar {
margin-bottom: 12px;
}
&__title {
@include mixins.button-reset();
& {
cursor: pointer;
}
}
&__title span {
@include mixins.font-title-1;
font-weight: 400;
}
&__title__chevron {
display: inline-block;
height: 20px;
width: 20px;
// Align with the text
position: relative;
inset-block-start: 2px;
@include mixins.color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
light-dark(variables.$color-gray-90, variables.$color-gray-05)
);
}
&__profile-name {
display: flex;
align-items: center;
justify-content: center;
@include mixins.font-title-1;
margin-bottom: 2px;
margin-top: 0;
color: light-dark(variables.$color-gray-90, variables.$color-gray-05);
.module-contact-name {
display: inline-flex;
align-items: center;
}
}
&__with {
@include mixins.font-body-2;
margin-block: 0;
margin-inline: auto;
margin-bottom: 20px;
max-width: 500px;
color: light-dark(variables.$color-gray-60, variables.$color-gray-25);
}
&__note-to-self {
@include mixins.font-body-2;
padding-block: 0;
padding-inline: 16px;
color: light-dark(variables.$color-gray-60, variables.$color-gray-25);
}
&__members-count__button {
@include mixins.button-reset;
& {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: variables.$color-gray-25;
}
}
&__safety-tips-button {
border-radius: 9999px;
padding-block: 6px;
padding-inline: 14px;
margin-top: 5px;
@include mixins.font-subtitle;
}
&__review-carefully {
@include mixins.font-body-2-bold;
color: #a98b52;
}
&__group-question-icon {
display: inline-block;
height: 16px;
width: 22px;
vertical-align: text-top;
margin-inline-end: 8px;
@include mixins.color-svg(
'../images/icons/v3/group/group-questionmark-compact.svg',
light-dark(variables.$color-black, variables.$color-gray-05)
);
}
&__direct-question-icon {
display: inline-block;
height: 16px;
width: 16px;
vertical-align: text-top;
margin-inline-end: 8px;
@include mixins.color-svg(
'../images/icons/v3/person/person-questionmark-compact.svg',
light-dark(variables.$color-black, variables.$color-gray-05)
);
}
&__name-not-verified__button {
@include mixins.button-reset;
& {
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: variables.$color-gray-25;
}
}
&--release-notes-notice {
@include mixins.font-body-1;
user-select: none;
max-width: 255px;
margin-inline: auto;
margin-block-start: 10px;
padding-block: 16px;
padding-inline: 20px;
border-radius: 18px;
background-color: light-dark(#eeefff, #3b3d50);
display: flex;
flex-direction: column;
gap: 8px;
color: light-dark(variables.$color-gray-75, variables.$color-gray-02);
}
&__release-notes-notice-content {
text-align: center;
}
&__release-notes-notice-check-icon {
display: inline-block;
height: 16px;
width: 16px;
margin-inline-end: 4px;
position: relative;
top: 3px;
@include mixins.color-svg(
'../images/icons/v3/official/official-compact.svg',
light-dark(variables.$color-gray-75, variables.$color-gray-05)
);
}
&__release-notes-notice-bell-icon {
display: inline-block;
height: 16px;
width: 16px;
margin-inline-end: 4px;
position: relative;
top: 3px;
@include mixins.color-svg(
'../images/icons/v3/bell/bell-compact.svg',
light-dark(variables.$color-gray-75, variables.$color-gray-05)
);
}
&__membership {
@include mixins.font-body-2;
user-select: none;
max-width: 255px;
margin-inline: auto;
margin-block-start: 10px;
padding-block: 16px;
padding-inline: 20px;
border-radius: 18px;
border-style: solid;
border-width: 2.5px;
display: flex;
flex-direction: column;
gap: 10px;
border-color: light-dark(
variables.$color-gray-04,
variables.$color-gray-80
);
color: light-dark(variables.$color-gray-90, variables.$color-gray-02);
&__chevron {
display: inline-block;
height: 18px;
width: 18px;
vertical-align: text-top;
margin-inline-end: 8px;
@include mixins.color-svg(
'../images/icons/v3/group/group.svg',
light-dark(variables.$color-black, variables.$color-gray-05)
);
}
&__name {
// Cancel bold
font-weight: normal;
}
&__review-carefully-icon {
display: inline-block;
height: 18px;
width: 18px;
vertical-align: text-top;
margin-inline-end: 8px;
@include mixins.color-svg(
'../images/icons/v3/error/error-triangle-fill-compact-bold.svg',
#a98b52
);
}
&__warning {
line-height: 20px;
}
}
&__members-count-icon {
display: inline-block;
height: 16px;
width: 16px;
vertical-align: text-top;
margin-inline-end: 8px;
@include mixins.color-svg(
'../images/icons/v3/group/group-compact.svg',
light-dark(variables.$color-black, variables.$color-gray-05)
);
}
}
-1
View File
@@ -87,7 +87,6 @@ $is-storybook: false !default;
@use 'components/ConversationDetails.scss';
@use 'components/ConversationDetailsHeader.scss';
@use 'components/ConversationHeader.scss';
@use 'components/ConversationHero.scss';
@use 'components/ConversationMergeNotification.scss';
@use 'components/ConversationPanel.scss';
@use 'components/ConversationView.scss';
-1
View File
@@ -27,7 +27,6 @@ function renderClickableButton(
): React.JSX.Element {
return (
<button
className="module-conversation-hero__members-count__button"
type="button"
onClick={ev => {
ev.preventDefault();
+10 -9
View File
@@ -22,6 +22,8 @@ import type { ConversationType } from '../../state/ducks/conversations.preload.t
import type { ContactNameColorType } from '../../types/Colors.std.ts';
import type { FunStaticEmojiSize } from '../fun/FunEmoji.dom.tsx';
import { UserText } from '../UserText.dom.tsx';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx';
import { tw } from '../../axo/tw.dom.tsx';
export type ContactNameData = {
contactNameColor?: ContactNameColorType;
@@ -58,7 +60,6 @@ export type PropsType = ContactNameData & {
module?: string;
preferFirstName?: boolean;
onClick?: VoidFunction;
largeVerifiedBadge?: boolean;
};
export function ContactName({
@@ -71,7 +72,6 @@ export function ContactName({
preferFirstName,
title,
onClick,
largeVerifiedBadge,
}: PropsType): React.JSX.Element {
const getClassName = getClassNamesFor('module-contact-name', module);
@@ -98,15 +98,16 @@ export function ContactName({
}}
>
<UserText text={text} />
{(isSignalConversation || isMe) && (
<span
className={
largeVerifiedBadge
? 'ContactModal__official-badge__large'
: 'ContactModal__official-badge'
}
/>
<>
&nbsp;
<span className={tw('text-color-fill-primary')}>
<AxoSymbol.InlineGlyph symbol="officialbadge-fill" label={null} />
</span>
</>
)}
{contactLabel && (
<>
{' '}
@@ -48,9 +48,11 @@ export default {
component: ConversationHero,
args: {
conversationType: 'direct',
fromOrAddedByTrustedContact: true,
fromOrAddedByTrustedContact: false,
i18n,
isDirectConvoAndHasNickname: false,
hasNickname: false,
hasProfileName: true,
isInSystemContacts: false,
theme: ThemeType.light,
sharedGroupNames: [],
viewUserStories: action('viewUserStories'),
@@ -65,8 +67,8 @@ export default {
const Template: StoryFn<Props> = args => {
const theme = useContext(StorybookThemeContext);
const baseProps = {
...args,
...getDefaultConversation(),
...args,
};
const memberships = createMemberships({
@@ -109,77 +111,35 @@ DirectOneOtherGroup.args = {
sharedGroupNames: [casual.title],
};
export const DirectNoGroupsName = Template.bind({});
DirectNoGroupsName.args = {
about: '👍 Free to chat',
export const DirectNoGroups = Template.bind({});
DirectNoGroups.args = {};
export const DirectWithNickname = Template.bind({});
DirectWithNickname.args = { hasNickname: true };
export const DirectInSystemContacts = Template.bind({});
DirectInSystemContacts.args = { hasNickname: true, isInSystemContacts: true };
export const DirectNoProfileName = Template.bind({});
DirectNoProfileName.args = { title: '123-555-1234', hasProfileName: false };
export const DirectMessageRequest = Template.bind({});
DirectMessageRequest.args = { acceptedMessageRequest: false };
export const DirectUnreadStories = Template.bind({});
DirectUnreadStories.args = {
hasStories: HasStories.Unread,
};
export const DirectNoGroupsJustProfile = Template.bind({});
DirectNoGroupsJustProfile.args = {
phoneNumber: casual.phone,
export const DirectReadStories = Template.bind({});
DirectReadStories.args = {
hasStories: HasStories.Read,
};
export const SignalConversation = Template.bind({});
SignalConversation.args = {
avatarUrl: 'images/profile-avatar.svg',
title: 'Signal',
isSignalConversation: true,
phoneNumber: casual.phone,
};
export const DirectNoGroupsJustPhoneNumber = Template.bind({});
DirectNoGroupsJustPhoneNumber.args = {
phoneNumber: casual.phone,
profileName: '',
title: casual.phone,
};
export const DirectNoGroupsNoData = Template.bind({});
DirectNoGroupsNoData.args = {
avatarUrl: undefined,
phoneNumber: '',
profileName: '',
title: casual.phone,
};
export const DirectNoGroupsNoDataNotAccepted = Template.bind({});
DirectNoGroupsNoDataNotAccepted.args = {
acceptedMessageRequest: false,
avatarUrl: undefined,
phoneNumber: '',
profileName: '',
title: '',
};
export const DirectNoGroupsNotAcceptedWithAvatar = Template.bind({});
DirectNoGroupsNotAcceptedWithAvatar.args = {
acceptedMessageRequest: false,
profileName: '',
};
export const GroupLongGroupDescription = Template.bind({});
GroupLongGroupDescription.args = {
conversationType: 'group',
groupDescription:
"This is a group for all the rock climbers of NYC. We really like to climb rocks and these NYC people climb any rock. No rock is too small or too big to be climbed. We will ascend upon all rocks, and not just in NYC, in the whole world. We are just getting started, NYC is just the beginning, watch out rocks in the galaxy. Kuiper belt I'm looking at you. We will put on a space suit and climb all your rocks. No rock is near nor far for the rock climbers of NYC.",
membersCount: casual.integer(1, 10),
title: casual.title,
};
export const GroupNoName = Template.bind({});
GroupNoName.args = {
conversationType: 'group',
membersCount: 0,
title: '',
};
export const GroupNotAccepted = Template.bind({});
GroupNotAccepted.args = {
conversationType: 'group',
groupDescription: casual.sentence,
membersCount: casual.integer(20, 100),
title: casual.title,
acceptedMessageRequest: false,
};
export const NoteToSelf = Template.bind({});
@@ -187,37 +147,41 @@ NoteToSelf.args = {
isMe: true,
};
export const UnreadStories = Template.bind({});
UnreadStories.args = {
hasStories: HasStories.Unread,
};
export const ReadStories = Template.bind({});
ReadStories.args = {
hasStories: HasStories.Read,
};
export const DirectNotFromTrustedContact = Template.bind({});
DirectNotFromTrustedContact.args = {
conversationType: 'direct',
title: casual.full_name,
fromOrAddedByTrustedContact: false,
};
export const DirectWithNickname = Template.bind({});
DirectWithNickname.args = {
conversationType: 'direct',
title: casual.full_name,
fromOrAddedByTrustedContact: false,
isDirectConvoAndHasNickname: true,
};
export const GroupNotFromTrustedContact = Template.bind({});
GroupNotFromTrustedContact.args = {
const groupArgs = {
conversationType: 'group',
title: casual.title,
membersCount: casual.integer(5, 20),
fromOrAddedByTrustedContact: false,
membersCount: casual.integer(1, 10),
title: 'Group title',
} as const;
export const Group = Template.bind({});
Group.args = {
...groupArgs,
title: 'This is the title that never ends',
};
export const GroupLongTitle = Template.bind({});
GroupLongTitle.args = {
...groupArgs,
title: 'This is the title that never ends',
};
export const GroupLongGroupDescription = Template.bind({});
GroupLongGroupDescription.args = {
...groupArgs,
groupDescription:
"This is anextremelylargewordinaverylargegroupdescriptionandagroup for all the rock climbers of NYC. We really like to climb rocks and these NYC people climb any rock. No rock is too small or too big to be climbed. We will ascend upon all rocks, and not just in NYC, in the whole world. We are just getting started, NYC is just the beginning, watch out rocks in the galaxy. Kuiper belt I'm looking at you. We will put on a space suit and climb all your rocks. No rock is near nor far for the rock climbers of NYC.",
};
export const GroupMessageRequest = Template.bind({});
GroupMessageRequest.args = {
...groupArgs,
groupDescription: casual.sentence,
acceptedMessageRequest: false,
};
export const GroupFromTrustedContact = Template.bind({});
GroupFromTrustedContact.args = {
...groupArgs,
fromOrAddedByTrustedContact: true,
};
export function GroupMemberNames(args: Props): React.JSX.Element {
@@ -2,11 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { type ReactNode, useState } from 'react';
import classNames from 'classnames';
import type { Props as AvatarProps } from '../Avatar.dom.tsx';
import { Avatar, AvatarSize, AvatarBlur } from '../Avatar.dom.tsx';
import { ContactName } from './ContactName.dom.tsx';
import { About } from './About.dom.tsx';
import { GroupDescription } from './GroupDescription.dom.tsx';
import { SharedGroupNames } from '../SharedGroupNames.dom.tsx';
import { GroupMembersNames } from '../GroupMembersNames.dom.tsx';
@@ -15,10 +13,11 @@ import type { HasStories } from '../../types/Stories.std.ts';
import type { ViewUserStoriesActionCreatorType } from '../../state/ducks/stories.preload.ts';
import type { GroupV2Membership } from './conversation-details/ConversationDetailsMembershipList.dom.tsx';
import { StoryViewModeType } from '../../types/Stories.std.ts';
import { Button, ButtonVariant } from '../Button.dom.tsx';
import { SafetyTipsModal } from '../SafetyTipsModal.dom.tsx';
import { I18n } from '../I18n.dom.tsx';
import type { ContactModalStateType } from '../../types/globalModals.std.ts';
import { tw } from '../../axo/tw.dom.tsx';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx';
import { AxoButton } from '../../axo/AxoButton.dom.tsx';
export type Props = {
about?: string;
@@ -26,10 +25,12 @@ export type Props = {
fromOrAddedByTrustedContact?: boolean;
groupDescription?: string;
hasAvatar?: boolean;
hasNickname: boolean;
hasProfileName: boolean;
hasStories?: HasStories;
id: string;
i18n: LocalizerType;
isDirectConvoAndHasNickname?: boolean;
isInSystemContacts: boolean;
isMe: boolean;
invitesCount?: number;
isSignalConversation?: boolean;
@@ -37,7 +38,6 @@ export type Props = {
memberships: ReadonlyArray<GroupV2Membership>;
openConversationDetails?: () => unknown;
pendingAvatarDownload?: boolean;
phoneNumber?: string;
sharedGroupNames?: ReadonlyArray<string>;
startAvatarDownload: () => void;
theme: ThemeType;
@@ -46,193 +46,13 @@ export type Props = {
toggleProfileNameWarningModal: (conversationType?: string) => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderExtraInformation = ({
acceptedMessageRequest,
conversationType,
fromOrAddedByTrustedContact,
i18n,
isDirectConvoAndHasNickname,
isMe,
invitesCount,
memberships,
onClickProfileNameWarning,
onToggleSafetyTips,
openConversationDetails,
phoneNumber,
sharedGroupNames,
}: Pick<
Props,
| 'avatarPlaceholderGradient'
| 'acceptedMessageRequest'
| 'conversationType'
| 'fromOrAddedByTrustedContact'
| 'i18n'
| 'isDirectConvoAndHasNickname'
| 'isMe'
| 'invitesCount'
| 'membersCount'
| 'memberships'
| 'openConversationDetails'
| 'phoneNumber'
| 'sharedGroupNames'
> & {
onClickProfileNameWarning: () => void;
onToggleSafetyTips: (showSafetyTips: boolean) => void;
}) => {
if (conversationType !== 'direct' && conversationType !== 'group') {
return null;
}
if (isMe) {
return (
<div className="module-conversation-hero__note-to-self">
{i18n('icu:noteToSelfHero')}
</div>
);
}
const safetyTipsButton = !acceptedMessageRequest ? (
<div>
<Button
className="module-conversation-hero__safety-tips-button"
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
onToggleSafetyTips(true);
}}
>
{i18n('icu:MessageRequestWarning__safety-tips')}
</Button>
</div>
) : null;
const shouldShowReviewCarefully =
!acceptedMessageRequest &&
(conversationType === 'group' || (sharedGroupNames?.length ?? 0) <= 1);
const reviewCarefullyLabel = shouldShowReviewCarefully ? (
<div className="module-conversation-hero__review-carefully">
<i className="module-conversation-hero__membership__review-carefully-icon" />
{i18n('icu:ConversationHero--review-carefully')}
</div>
) : null;
const sharedGroupsLabel =
conversationType === 'direct' ? (
<div>
<i className="module-conversation-hero__membership__chevron" />
<SharedGroupNames
i18n={i18n}
nameClassName="module-conversation-hero__membership__name"
sharedGroupNames={sharedGroupNames ?? []}
/>
</div>
) : null;
const nameNotVerifiedLabel =
!fromOrAddedByTrustedContact && !isDirectConvoAndHasNickname ? (
<div className="module-conversation-hero__name-not-verified">
<i
className={classNames({
'module-conversation-hero__group-question-icon':
conversationType === 'group',
'module-conversation-hero__direct-question-icon':
conversationType === 'direct',
})}
/>
<I18n
components={{
clickable: (parts: ReactNode) => (
<button
className="module-conversation-hero__name-not-verified__button"
type="button"
onClick={ev => {
ev.preventDefault();
onClickProfileNameWarning();
}}
>
{parts}
</button>
),
}}
i18n={i18n}
id={
conversationType === 'group'
? 'icu:ConversationHero--group-names'
: 'icu:ConversationHero--profile-names'
}
/>
</div>
) : null;
const membersCountLabel =
conversationType === 'group' ? (
<div className="module-conversation-hero__membership__members-count">
<i className="module-conversation-hero__members-count-icon" />
<GroupMembersNames
i18n={i18n}
nameClassName="module-conversation-hero__membership__name"
memberships={memberships}
invitesCount={invitesCount}
onOtherMembersClick={openConversationDetails}
/>
</div>
) : null;
if (
conversationType === 'direct' &&
(sharedGroupNames?.length ?? 0) === 0 &&
acceptedMessageRequest &&
phoneNumber
) {
return null;
}
// Check if we should show anything at all
const shouldShowAnything =
Boolean(reviewCarefullyLabel) ||
Boolean(nameNotVerifiedLabel) ||
Boolean(sharedGroupsLabel) ||
Boolean(safetyTipsButton) ||
Boolean(membersCountLabel);
if (!shouldShowAnything) {
return null;
}
return (
<div className="module-conversation-hero__membership">
{reviewCarefullyLabel}
{nameNotVerifiedLabel}
{sharedGroupsLabel}
{membersCountLabel}
{safetyTipsButton}
</div>
);
};
function ReleaseNotesExtraInformation({
i18n,
}: {
i18n: LocalizerType;
}): React.JSX.Element {
return (
<div className="module-conversation-hero--release-notes-notice">
<div className="module-conversation-hero__release-notes-notice-content">
<i className="module-conversation-hero__release-notes-notice-check-icon" />
{i18n('icu:ConversationHero--signal-official-chat')}
</div>
<div className="module-conversation-hero__release-notes-notice-content">
<i className="module-conversation-hero__release-notes-notice-bell-icon" />
{i18n('icu:ConversationHero--release-notes')}
</div>
</div>
);
}
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown
? Omit<T, K>
: never;
export function ConversationHero({
avatarPlaceholderGradient,
i18n,
about,
acceptedMessageRequest,
avatarUrl,
badge,
@@ -241,26 +61,26 @@ export function ConversationHero({
fromOrAddedByTrustedContact,
groupDescription,
hasAvatar,
hasNickname,
hasProfileName,
hasStories,
id,
isDirectConvoAndHasNickname,
isInSystemContacts,
isMe,
invitesCount,
openConversationDetails,
isSignalConversation,
membersCount,
memberships,
pendingAvatarDownload,
sharedGroupNames = [],
phoneNumber,
profileName,
sharedGroupNames = [],
startAvatarDownload,
theme,
title,
viewUserStories,
toggleAboutContactModal,
toggleProfileNameWarningModal,
}: Props): React.JSX.Element {
}: Props): React.JSX.Element | null {
const [isShowingSafetyTips, setIsShowingSafetyTips] = useState(false);
let avatarBlur: AvatarBlur = AvatarBlur.NoBlur;
@@ -282,71 +102,129 @@ export function ConversationHero({
};
}
let titleElem: React.JSX.Element | undefined;
const maybeSafetyTips = isShowingSafetyTips ? (
<SafetyTipsModal
i18n={i18n}
onClose={() => {
setIsShowingSafetyTips(false);
}}
/>
) : null;
const avatar = (
<ConversationAvatar
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={badge}
blur={avatarBlur}
conversationType={conversationType}
color={color}
i18n={i18n}
hasAvatar={hasAvatar}
loading={pendingAvatarDownload && !avatarUrl}
noteToSelf={isMe}
onClick={avatarOnClick}
profileName={profileName}
storyRing={isMe ? undefined : hasStories}
theme={theme}
title={title}
/>
);
if (isMe) {
titleElem = (
<ContactName
isMe={isMe}
title={i18n('icu:noteToSelf')}
largeVerifiedBadge={isMe}
/>
);
} else if (isSignalConversation || conversationType !== 'direct') {
titleElem = (
<ContactName
isSignalConversation={isSignalConversation}
title={title}
largeVerifiedBadge={isSignalConversation}
/>
);
} else if (title) {
titleElem = (
<button
type="button"
className="module-conversation-hero__title"
onClick={ev => {
ev.preventDefault();
toggleAboutContactModal({ contactId: id });
}}
>
<ContactName title={title} />
<i className="module-conversation-hero__title__chevron" />
</button>
return (
<Root>
{avatar}
<Title title={i18n('icu:noteToSelf')} isMe />
<div
className={tw(
'mt-2 text-center type-body-medium text-label-secondary'
)}
>
{i18n('icu:noteToSelfHero')}
</div>
</Root>
);
}
return (
<>
<div className="module-conversation-hero">
<Avatar
avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarUrl={avatarUrl}
badge={badge}
blur={avatarBlur}
className="module-conversation-hero__avatar"
color={color}
conversationType={conversationType}
i18n={i18n}
hasAvatar={hasAvatar}
loading={pendingAvatarDownload && !avatarUrl}
noteToSelf={isMe}
onClick={avatarOnClick}
profileName={profileName}
size={AvatarSize.EIGHTY}
// user may have stories, but we don't show that on Note to Self conversation
storyRing={isMe ? undefined : hasStories}
theme={theme}
if (isSignalConversation) {
return (
<Root>
{avatar}
<Title title={title} isSignalConversation />
<div
className={tw(
'my-2 rounded-3xl bg-color-fill-primary/12 px-2.5 py-1 type-body-medium font-medium text-color-fill-primary'
)}
>
<AxoSymbol.InlineGlyph symbol="officialbadge" label={null} />
&nbsp;{i18n('icu:ConversationHero--signal-official-account')}
</div>
<div className={tw('text-center type-body-medium text-label-primary')}>
{i18n('icu:ConversationHero--signal-official-account--description')}
</div>
</Root>
);
}
if (conversationType === 'direct') {
const nameIsVerified = hasNickname || isInSystemContacts;
return (
<Root>
{avatar}
<Title
title={title}
onClick={() => toggleAboutContactModal({ contactId: id })}
/>
<h1 className="module-conversation-hero__profile-name">{titleElem}</h1>
{about && !isMe && (
<div className="module-about__container">
<About text={about} />
</div>
)}
{!isMe && groupDescription ? (
<div className="module-conversation-hero__with">
{hasProfileName && !nameIsVerified ? (
<NameNotVerifiedWarning
conversationType={conversationType}
onClick={() => toggleProfileNameWarningModal(conversationType)}
i18n={i18n}
/>
) : null}
<div
className={tw(
'mt-2.5 text-center type-body-medium text-label-primary'
)}
>
<AxoSymbol.InlineGlyph symbol="group" label={null} />
&nbsp;
<SharedGroupNames
i18n={i18n}
sharedGroupNames={sharedGroupNames ?? []}
/>
</div>
{!acceptedMessageRequest ? (
<SafetyTips
onShowSafetyTips={() => setIsShowingSafetyTips(true)}
i18n={i18n}
/>
) : null}
{maybeSafetyTips}
</Root>
);
}
if (conversationType === 'group') {
const nameIsVerified = Boolean(fromOrAddedByTrustedContact);
return (
<Root>
{avatar}
<Title title={title} />
{!nameIsVerified ? (
<NameNotVerifiedWarning
conversationType={conversationType}
onClick={() => toggleProfileNameWarningModal(conversationType)}
i18n={i18n}
/>
) : null}
{groupDescription ? (
<div className={tw('mt-2 w-full text-center text-label-secondary')}>
<GroupDescription
i18n={i18n}
title={title}
@@ -354,38 +232,140 @@ export function ConversationHero({
/>
</div>
) : null}
{!isSignalConversation &&
renderExtraInformation({
acceptedMessageRequest,
conversationType,
fromOrAddedByTrustedContact,
i18n,
isDirectConvoAndHasNickname,
isMe,
invitesCount,
membersCount,
memberships,
onClickProfileNameWarning() {
toggleProfileNameWarningModal(conversationType);
},
onToggleSafetyTips(showSafetyTips: boolean) {
setIsShowingSafetyTips(showSafetyTips);
},
openConversationDetails,
phoneNumber,
sharedGroupNames: sharedGroupNames ?? [],
})}
{isSignalConversation && <ReleaseNotesExtraInformation i18n={i18n} />}
</div>
{isShowingSafetyTips && (
<SafetyTipsModal
i18n={i18n}
onClose={() => {
setIsShowingSafetyTips(false);
}}
/>
)}
</>
);
<div
className={tw(
'mt-2.5 w-full text-center type-body-medium text-label-primary'
)}
>
<AxoSymbol.InlineGlyph symbol="group" label={null} />
&nbsp;
<GroupMembersNames
i18n={i18n}
memberships={memberships}
invitesCount={invitesCount}
onOtherMembersClick={openConversationDetails}
/>
</div>
{!acceptedMessageRequest ? (
<SafetyTips
onShowSafetyTips={() => setIsShowingSafetyTips(true)}
i18n={i18n}
/>
) : null}
{maybeSafetyTips}
</Root>
);
}
return null;
}
type RootProps = {
children: ReactNode;
};
const Root: React.FC<RootProps> = props => {
return (
<div
data-testid="conversation-hero"
className={tw(
'flex w-3xs flex-col items-center rounded-4xl border-2 border-background-secondary p-5 pt-0'
)}
>
{props.children}
</div>
);
};
const ConversationAvatar: React.FC<
DistributiveOmit<AvatarProps, 'size'>
> = props => {
return (
<Avatar
{...props}
size={AvatarSize.SEVENTY_TWO}
className={tw('-mt-4.5')}
/>
);
};
type TitleProps = {
isMe?: boolean;
isSignalConversation?: boolean;
title: string;
onClick?: () => void;
};
const Title: React.FC<TitleProps> = props => {
const className = tw('mt-3 text-center text-[20px] font-medium');
const { onClick, title, isMe, isSignalConversation } = props;
const contactName = (
<ContactName
title={title}
isMe={isMe}
isSignalConversation={isSignalConversation}
/>
);
if (onClick) {
return (
<button
type="button"
className={className}
onClick={ev => {
ev.preventDefault();
onClick();
}}
>
{contactName}
&nbsp;
<span className={tw('text-[18px] text-label-secondary')}>
<AxoSymbol.InlineGlyph symbol="chevron-[end]" label={null} />
</span>
</button>
);
}
return <div className={className}>{contactName}</div>;
};
const NameNotVerifiedWarning: React.FC<{
conversationType: 'direct' | 'group';
onClick: () => void;
i18n: LocalizerType;
}> = ({ conversationType, onClick, i18n }) => {
return (
<button
className={tw(
'mt-2 rounded-3xl bg-color-fill-destructive/12 px-2.5 py-1',
// oxlint-disable-next-line better-tailwindcss/no-restricted-classes
'type-body-medium font-medium text-[#C84118]'
)}
type="button"
onClick={ev => {
ev.preventDefault();
onClick();
}}
>
{conversationType === 'direct' ? (
<AxoSymbol.InlineGlyph symbol="person-question" label={null} />
) : (
// TODO: DESKTOP-10050
<AxoSymbol.InlineGlyph symbol="person-question" label={null} />
)}
&nbsp; {i18n('icu:ConversationHero--name-not-verified')}
</button>
);
};
const SafetyTips: React.FC<{
onShowSafetyTips: () => void;
i18n: LocalizerType;
}> = ({ i18n, onShowSafetyTips }) => {
return (
<div className={tw('mt-3')}>
<AxoButton.Root variant="secondary" size="md" onClick={onShowSafetyTips}>
{i18n('icu:MessageRequestWarning__safety-tips-v2')}
</AxoButton.Root>
</div>
);
};
@@ -2,8 +2,10 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { Modal } from '../Modal.dom.tsx';
import type { LocalizerType } from '../../types/Util.std.ts';
import { AxoDialog } from '../../axo/AxoDialog.dom.tsx';
import { AxoSymbol } from '../../axo/AxoSymbol.dom.tsx';
import { tw } from '../../axo/tw.dom.tsx';
export type PropsType = Readonly<{
conversationType: 'group' | 'direct';
@@ -11,60 +13,80 @@ export type PropsType = Readonly<{
onClose: () => void;
}>;
const DESCRIPTION_KEYS = {
direct: 'icu:ProfileNameWarningModal__description--direct',
group: 'icu:ProfileNameWarningModal__description--group',
} as const;
const LIST_ITEM_KEYS = {
item1: {
direct: 'icu:ProfileNameWarningModal__list--item1--direct',
group: 'icu:ProfileNameWarningModal__list--item1--group',
},
item2: {
direct: 'icu:ProfileNameWarningModal__list--item2--direct',
group: 'icu:ProfileNameWarningModal__list--item2--group',
},
item3: {
direct: 'icu:ProfileNameWarningModal__list--item3--direct',
group: 'icu:ProfileNameWarningModal__list--item3--group',
},
} as const;
export function ProfileNameWarningModal({
conversationType,
i18n,
onClose,
}: PropsType): React.JSX.Element {
return (
<Modal
modalName="ProfileNameWarningModal"
moduleClassName="ProfileNameWarningModal"
hasXButton
i18n={i18n}
onClose={onClose}
>
<i className="ProfileNameWarningModal__header-icon" />
<div className="ProfileNameWarningModal__description">
{i18n(DESCRIPTION_KEYS[conversationType])}
</div>
<ul className="ProfileNameWarningModal__list">
<li className="ProfileNameWarningModal__list-item">
<span className="ProfileNameWarningModal__list-item-text">
{i18n(LIST_ITEM_KEYS.item1[conversationType])}
</span>
</li>
<li className="ProfileNameWarningModal__list-item">
<span className="ProfileNameWarningModal__list-item-text">
{i18n(LIST_ITEM_KEYS.item2[conversationType])}
</span>
</li>
<li className="ProfileNameWarningModal__list-item">
<span className="ProfileNameWarningModal__list-item-text">
{i18n(LIST_ITEM_KEYS.item3[conversationType])}
</span>
</li>
</ul>
</Modal>
<AxoDialog.Root open onOpenChange={onClose}>
<AxoDialog.Content
size="sm"
escape="cancel-is-noop"
disableMissingAriaDescriptionWarning
>
<AxoDialog.Header>
<AxoDialog.Close aria-label={i18n('icu:close')} />
</AxoDialog.Header>
<AxoDialog.Body padding="normal">
<div className={tw('flex justify-center')}>
<div
className={tw(
// oxlint-disable-next-line better-tailwindcss/no-restricted-classes
'rounded-3xl bg-color-fill-destructive/12 px-4 py-1.5 type-title-large font-regular text-[#C84118]'
)}
>
{conversationType === 'direct' ? (
<AxoSymbol.InlineGlyph symbol="person-question" label={null} />
) : (
<AxoSymbol.InlineGlyph symbol="person-question" label={null} />
)}
</div>
</div>
<div className={tw('mt-5 mb-12 type-body-medium text-label-primary')}>
{conversationType === 'direct' ? (
<>
{i18n('icu:ProfileNameWarningModal__description--direct')}
<ul className={tw('list-disc ps-4 [&>li]:mt-3')}>
<li>
{i18n(
'icu:ProfileNameWarningModal__warning--signal-cant-verify'
)}
</li>
<li>
{i18n(
'icu:ProfileNameWarningModal__warning--signal-wont-contact'
)}
</li>
<li>
{i18n('icu:ProfileNameWarningModal__warning--be-cautious')}
</li>
<li>
{i18n(
'icu:ProfileNameWarningModal__warning--dont-share-info'
)}
</li>
</ul>
</>
) : (
<>
{i18n('icu:ProfileNameWarningModal__description--group')}
<ul className={tw('list-disc ps-4 [&>li]:mt-3')}>
<li>
{i18n('icu:ProfileNameWarningModal__list--item1--group')}
</li>
<li>
{i18n('icu:ProfileNameWarningModal__list--item2--group')}
</li>
<li>
{i18n('icu:ProfileNameWarningModal__list--item3--group')}
</li>
</ul>
</>
)}
</div>
</AxoDialog.Body>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
@@ -411,9 +411,12 @@ const renderHeroRow = () => {
avatarUrl={getAvatarPath()}
badge={undefined}
conversationType="direct"
hasNickname={false}
hasProfileName
id={getDefaultConversation().id}
i18n={i18n}
isMe={false}
isInSystemContacts={false}
phoneNumber={getPhoneNumber()}
profileName={getProfileName()}
sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']}
+36 -39
View File
@@ -23,6 +23,8 @@ import { useStoriesActions } from '../ducks/stories.preload.ts';
import { getAddedByForGroup } from '../../util/getAddedByForGroup.preload.ts';
import { getGroupMemberships } from '../../util/getGroupMemberships.dom.ts';
import { useNavActions } from '../ducks/nav.std.ts';
import { tw } from '../../axo/tw.dom.tsx';
import { isInSystemContacts } from '../../util/isInSystemContacts.std.ts';
type SmartHeroRowProps = Readonly<{
id: string;
@@ -83,7 +85,6 @@ export const SmartHeroRow = memo(function SmartHeroRow({
const { viewUserStories } = useStoriesActions();
const {
avatarPlaceholderGradient,
about,
acceptedMessageRequest,
avatarUrl,
color,
@@ -93,50 +94,46 @@ export const SmartHeroRow = memo(function SmartHeroRow({
membersCount,
nicknameGivenName,
nicknameFamilyName,
phoneNumber,
profileName,
title,
type,
} = conversation;
const isDirectConvoAndHasNickname =
type === 'direct' && Boolean(nicknameGivenName || nicknameFamilyName);
const invitesCount =
pendingMemberships.length + pendingApprovalMemberships.length;
return (
<ConversationHero
avatarPlaceholderGradient={avatarPlaceholderGradient}
about={about}
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={badge}
color={color}
conversationType={type}
fromOrAddedByTrustedContact={fromOrAddedByTrustedContact}
groupDescription={groupDescription}
hasAvatar={hasAvatar}
hasStories={hasStories}
i18n={i18n}
id={id}
isDirectConvoAndHasNickname={isDirectConvoAndHasNickname}
isMe={isMe}
invitesCount={invitesCount}
isSignalConversation={isSignalConversationValue}
membersCount={membersCount}
memberships={memberships}
openConversationDetails={openConversationDetails}
pendingAvatarDownload={isPendingAvatarDownload(id)}
phoneNumber={phoneNumber}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
startAvatarDownload={() => startAvatarDownload(id)}
theme={theme}
title={title}
toggleAboutContactModal={toggleAboutContactModal}
toggleProfileNameWarningModal={toggleProfileNameWarningModal}
viewUserStories={viewUserStories}
/>
<div className={tw('mt-10 flex justify-center')}>
<ConversationHero
avatarPlaceholderGradient={avatarPlaceholderGradient}
acceptedMessageRequest={acceptedMessageRequest}
avatarUrl={avatarUrl}
badge={badge}
color={color}
conversationType={type}
fromOrAddedByTrustedContact={fromOrAddedByTrustedContact}
groupDescription={groupDescription}
hasAvatar={hasAvatar}
hasNickname={Boolean(nicknameGivenName || nicknameFamilyName)}
hasProfileName={Boolean(profileName)}
hasStories={hasStories}
i18n={i18n}
id={id}
isMe={isMe}
invitesCount={invitesCount}
isInSystemContacts={isInSystemContacts(conversation)}
isSignalConversation={isSignalConversationValue}
membersCount={membersCount}
memberships={memberships}
openConversationDetails={openConversationDetails}
pendingAvatarDownload={isPendingAvatarDownload(id)}
profileName={profileName}
sharedGroupNames={sharedGroupNames}
startAvatarDownload={() => startAvatarDownload(id)}
theme={theme}
title={title}
toggleAboutContactModal={toggleAboutContactModal}
toggleProfileNameWarningModal={toggleProfileNameWarningModal}
viewUserStories={viewUserStories}
/>
</div>
);
});
+8 -8
View File
@@ -201,7 +201,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for message');
await window
@@ -272,7 +272,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for message');
await window
@@ -352,7 +352,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for message');
await window.locator('.module-message__text >> "hello"').waitFor();
@@ -494,7 +494,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for latest message');
await window.locator('.module-message__text >> "v5"').waitFor();
@@ -540,7 +540,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for latest message');
await window.locator('.module-message__text >> "v2"').waitFor();
@@ -583,7 +583,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await page.locator('.module-conversation-hero').waitFor();
await page.getByTestId('conversation-hero').waitFor();
const { dataMessage: profileKeyMsg } = await friend.waitForMessage();
assert(profileKeyMsg.profileKey != null, 'Profile key message');
@@ -894,7 +894,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for latest message');
await window.locator('.module-message__text >> "v2"').waitFor();
@@ -966,7 +966,7 @@ describe('editing', function (this: Mocha.Suite) {
.locator('.module-conversation-list__item--contact-or-conversation')
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('checking for latest message');
await window.locator('.module-message__text >> "v5"').waitFor();
@@ -273,7 +273,7 @@ describe('messaging/expireTimerVersion', function (this: Mocha.Suite) {
await expectSystemMessages(window, scenario.systemMessages);
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Send message to merged contact');
{
@@ -304,7 +304,7 @@ describe('messaging/expireTimerVersion', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
const conversationStack = window.locator('.Inbox__conversation-stack');
+9 -8
View File
@@ -132,7 +132,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
.locator(`[data-testid="${pniContact.device.aci}"]`)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Send message to ACI');
{
@@ -148,7 +148,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
.first()
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Verify starting state');
{
@@ -175,7 +175,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
.locator(`[data-testid="${pniContact.device.aci}"]`)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
}
debug(
@@ -272,7 +272,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Send message to merged contact');
{
@@ -377,7 +377,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Unregistering ACI');
server.unregister(pniContact);
@@ -460,7 +460,8 @@ describe('pnp/merge', function (this: Mocha.Suite) {
debug('Wait for ACI conversation to go away');
await window
.locator(`.module-conversation-hero >> "${pniContact.profileName}"`)
.getByTestId('conversation-hero')
.getByText(pniContact.profileName)
.waitFor({
state: 'hidden',
});
@@ -522,7 +523,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Verify that the message is in the ACI conversation');
{
@@ -638,7 +639,7 @@ describe('pnp/merge', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Send message to merged contact');
{
@@ -127,18 +127,11 @@ describe('pnp/phone discovery', function (this: Mocha.Suite) {
});
}
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
debug('Open ACI conversation');
await leftPane.locator(`[data-testid="${pniContact.device.aci}"]`).click();
debug('Wait for PNI conversation to go away');
await window
.locator(`.module-conversation-hero >> ${pniContact.profileName}`)
.waitFor({
state: 'hidden',
});
debug('Verify final state');
{
// Should have PNI message
+4 -4
View File
@@ -93,7 +93,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
}
debug('Verify starting state');
@@ -198,7 +198,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
}
debug('Verify starting state');
@@ -307,7 +307,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
}
debug('Verify starting state');
@@ -446,7 +446,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
)
.click();
await window.locator('.module-conversation-hero').waitFor();
await window.getByTestId('conversation-hero').waitFor();
}
debug('Verify starting state');
+2 -1
View File
@@ -381,7 +381,8 @@ describe('pnp/username', function (this: Mocha.Suite) {
debug('waiting for conversation to open');
await window
.locator(`.module-conversation-hero >> "${CARL_USERNAME}"`)
.getByTestId('conversation-hero')
.getByText(CARL_USERNAME)
.waitFor();
debug('sending a message');