Init Nicknames & Notes

This commit is contained in:
Jamie Kyle 2024-03-26 12:48:33 -07:00 committed by GitHub
parent ebecf2403f
commit e26916702c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1050 additions and 23 deletions

View file

@ -0,0 +1,56 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { ForwardedRef } from 'react';
import React, { forwardRef, useEffect, useLayoutEffect, useRef } from 'react';
import { mergeRefs } from '@react-aria/utils';
import { strictAssert } from '../util/assert';
import type { PropsType } from './Input';
import { Input } from './Input';
export const AutoSizeTextArea = forwardRef(function AutoSizeTextArea(
props: PropsType,
ref: ForwardedRef<HTMLTextAreaElement>
): JSX.Element {
const ownRef = useRef<HTMLTextAreaElement | null>(null);
const textareaRef = mergeRefs(ownRef, ref);
function update(textarea: HTMLTextAreaElement) {
const styles = window.getComputedStyle(textarea);
const { scrollHeight } = textarea;
let height = 'calc(';
height += `${scrollHeight}px`;
if (styles.boxSizing === 'border-box') {
height += ` + ${styles.borderTopWidth} + ${styles.borderBottomWidth}`;
} else {
height += ` - ${styles.paddingTop} - ${styles.paddingBottom}`;
}
height += ')';
Object.assign(textarea.style, {
height,
overflow: 'hidden',
resize: 'none',
});
}
useEffect(() => {
strictAssert(ownRef.current, 'inputRef.current should be defined');
const textarea = ownRef.current;
function onInput() {
textarea.style.height = 'auto';
requestAnimationFrame(() => update(textarea));
}
textarea.addEventListener('input', onInput);
return () => {
textarea.removeEventListener('input', onInput);
};
}, []);
useLayoutEffect(() => {
strictAssert(ownRef.current, 'inputRef.current should be defined');
const textarea = ownRef.current;
textarea.style.height = 'auto';
update(textarea);
}, [props.value]);
return <Input ref={textareaRef} {...props} forceTextarea />;
});

View file

@ -0,0 +1,32 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { action } from '@storybook/addon-actions';
import * as React from 'react';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import type { EditNicknameAndNoteModalProps } from './EditNicknameAndNoteModal';
import { EditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/EditNicknameAndNoteModal',
component: EditNicknameAndNoteModal,
argTypes: {},
args: {
conversation: getDefaultConversation({
nicknameGivenName: 'Bestie',
nicknameFamilyName: 'McBesterson',
note: 'Met at UC Berkeley, mutual friends with Katie Hall.\n\nWebsite: https://example.com/',
}),
i18n,
onClose: action('onClose'),
onSave: action('onSave'),
},
} satisfies ComponentMeta<EditNicknameAndNoteModalProps>;
export function Normal(args: EditNicknameAndNoteModalProps): JSX.Element {
return <EditNicknameAndNoteModal {...args} />;
}

View file

@ -0,0 +1,185 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { FormEvent } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import uuid from 'uuid';
import { z } from 'zod';
import { Modal } from './Modal';
import type { LocalizerType } from '../types/I18N';
import { Avatar, AvatarSize } from './Avatar';
import type {
ConversationType,
NicknameAndNote,
} from '../state/ducks/conversations';
import { Input } from './Input';
import { AutoSizeTextArea } from './AutoSizeTextArea';
import { Button, ButtonVariant } from './Button';
const formSchema = z.object({
nickname: z
.object({
givenName: z.string(),
familyName: z.string().nullable(),
})
.nullable(),
note: z.string().nullable(),
});
function toOptionalStringValue(value: string): string | null {
const trimmed = value.trim();
return trimmed === '' ? null : trimmed;
}
export type EditNicknameAndNoteModalProps = Readonly<{
conversation: ConversationType;
i18n: LocalizerType;
onSave: (result: NicknameAndNote) => void;
onClose: () => void;
}>;
export function EditNicknameAndNoteModal({
conversation,
i18n,
onSave,
onClose,
}: EditNicknameAndNoteModalProps): JSX.Element {
const [givenName, setGivenName] = useState(
conversation.nicknameGivenName ?? ''
);
const [familyName, setFamilyName] = useState(
conversation.nicknameFamilyName ?? ''
);
const [note, setNote] = useState(conversation.note ?? '');
const [formId] = useState(() => uuid());
const [givenNameId] = useState(() => uuid());
const [familyNameId] = useState(() => uuid());
const [noteId] = useState(() => uuid());
const formResult = useMemo(() => {
const givenNameValue = toOptionalStringValue(givenName);
const familyNameValue = toOptionalStringValue(familyName);
const noteValue = toOptionalStringValue(note);
const hasEitherName = givenNameValue != null || familyNameValue != null;
return formSchema.safeParse({
nickname: hasEitherName
? { givenName: givenNameValue, familyName: familyNameValue }
: null,
note: noteValue,
});
}, [givenName, familyName, note]);
const handleSubmit = useCallback(
(event: MouseEvent | FormEvent) => {
event.preventDefault();
if (formResult.success) {
onSave(formResult.data);
onClose();
}
},
[formResult, onSave, onClose]
);
return (
<Modal
modalName="EditNicknameAndNoteModal"
moduleClassName="EditNicknameAndNoteModal"
i18n={i18n}
onClose={onClose}
title={i18n('icu:EditNicknameAndNoteModal__Title')}
hasXButton
modalFooter={
<>
<Button variant={ButtonVariant.Secondary} onClick={onClose}>
{i18n('icu:cancel')}
</Button>
<Button
variant={ButtonVariant.Primary}
type="submit"
form={formId}
aria-disabled={!formResult.success}
onClick={handleSubmit}
>
{i18n('icu:save')}
</Button>
</>
}
>
<p className="EditNicknameAndNoteModal__Description">
{i18n('icu:EditNicknameAndNoteModal__Description')}
</p>
<div className="EditNicknameAndNoteModal__Avatar">
<Avatar
{...conversation}
conversationType={conversation.type}
i18n={i18n}
size={AvatarSize.EIGHTY}
badge={undefined}
theme={undefined}
/>
</div>
<form onSubmit={handleSubmit}>
<label
htmlFor={givenNameId}
className="EditNicknameAndNoteModal__Label"
>
{i18n('icu:EditNicknameAndNoteModal__FirstName__Label')}
</label>
<Input
id={givenNameId}
i18n={i18n}
placeholder={i18n(
'icu:EditNicknameAndNoteModal__FirstName__Placeholder'
)}
value={givenName}
hasClearButton
maxLengthCount={26}
maxByteCount={128}
onChange={value => {
setGivenName(value);
}}
/>
<label
htmlFor={familyNameId}
className="EditNicknameAndNoteModal__Label"
>
{i18n('icu:EditNicknameAndNoteModal__LastName__Label')}
</label>
<Input
id={familyNameId}
i18n={i18n}
placeholder={i18n(
'icu:EditNicknameAndNoteModal__LastName__Placeholder'
)}
value={familyName}
hasClearButton
maxLengthCount={26}
maxByteCount={128}
onChange={value => {
setFamilyName(value);
}}
/>
<label htmlFor={noteId} className="EditNicknameAndNoteModal__Label">
{i18n('icu:EditNicknameAndNoteModal__Note__Label')}
</label>
<AutoSizeTextArea
i18n={i18n}
id={noteId}
placeholder={i18n('icu:EditNicknameAndNoteModal__Note__Placeholder')}
value={note}
maxByteCount={240}
maxLengthCount={240}
whenToShowRemainingCount={140}
whenToWarnRemainingCount={235}
onChange={value => {
setNote(value);
}}
/>
<button type="submit" hidden>
{i18n('icu:submit')}
</button>
</form>
</Modal>
);
}

View file

@ -7,6 +7,7 @@ import type {
ContactModalStateType,
DeleteMessagesPropsType,
EditHistoryMessagesType,
EditNicknameAndNoteModalPropsType,
FormattingWarningDataType,
ForwardMessagesPropsType,
MessageRequestActionsConfirmationPropsType,
@ -40,6 +41,9 @@ export type PropsType = {
// EditHistoryMessagesModal
editHistoryMessages: EditHistoryMessagesType | undefined;
renderEditHistoryMessagesModal: () => JSX.Element;
// EditNicknameAndNoteModal
editNicknameAndNoteModalProps: EditNicknameAndNoteModalPropsType | null;
renderEditNicknameAndNoteModal: () => JSX.Element;
// ErrorModal
errorModalProps:
| { buttonVariant?: ButtonVariant; description?: string; title?: string }
@ -63,6 +67,9 @@ export type PropsType = {
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
renderMessageRequestActionsConfirmation: () => JSX.Element;
// NotePreviewModal
notePreviewModalProps: { conversationId: string } | null;
renderNotePreviewModal: () => JSX.Element;
// ProfileEditor
isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element;
@ -122,6 +129,9 @@ export function GlobalModalContainer({
// EditHistoryMessages
editHistoryMessages,
renderEditHistoryMessagesModal,
// EditNicknameAndNoteModal
editNicknameAndNoteModalProps,
renderEditNicknameAndNoteModal,
// ErrorModal
errorModalProps,
renderErrorModal,
@ -137,6 +147,9 @@ export function GlobalModalContainer({
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps,
renderMessageRequestActionsConfirmation,
// NotePreviewModal
notePreviewModalProps,
renderNotePreviewModal,
// ProfileEditor
isProfileEditorVisible,
renderProfileEditor,
@ -205,6 +218,10 @@ export function GlobalModalContainer({
return renderEditHistoryMessagesModal();
}
if (editNicknameAndNoteModalProps) {
return renderEditNicknameAndNoteModal();
}
if (deleteMessagesProps) {
return renderDeleteMessagesModal();
}
@ -234,6 +251,10 @@ export function GlobalModalContainer({
return renderMessageRequestActionsConfirmation();
}
if (notePreviewModalProps) {
return renderNotePreviewModal();
}
if (isProfileEditorVisible) {
return renderProfileEditor();
}

View file

@ -23,9 +23,11 @@ export type PropsType = {
disabled?: boolean;
disableSpellcheck?: boolean;
expandable?: boolean;
forceTextarea?: boolean;
hasClearButton?: boolean;
i18n: LocalizerType;
icon?: ReactNode;
id?: string;
maxByteCount?: number;
maxLengthCount?: number;
moduleClassName?: string;
@ -34,6 +36,7 @@ export type PropsType = {
placeholder: string;
value?: string;
whenToShowRemainingCount?: number;
whenToWarnRemainingCount?: number;
children?: ReactNode;
};
@ -64,9 +67,11 @@ export const Input = forwardRef<
disabled,
disableSpellcheck,
expandable,
forceTextarea,
hasClearButton,
i18n,
icon,
id,
maxByteCount = 0,
maxLengthCount = 0,
moduleClassName,
@ -75,6 +80,7 @@ export const Input = forwardRef<
placeholder,
value = '',
whenToShowRemainingCount = Infinity,
whenToWarnRemainingCount = Infinity,
children,
},
ref
@ -195,14 +201,17 @@ export const Input = forwardRef<
const lengthCount = maxLengthCount ? countLength(value) : -1;
const getClassName = getClassNamesFor('Input', moduleClassName);
const isTextarea = expandable || forceTextarea;
const inputProps = {
className: classNames(
getClassName('__input'),
icon && getClassName('__input--with-icon'),
isLarge && getClassName('__input--large'),
expandable && getClassName('__input--expandable')
isTextarea && getClassName('__input--textarea')
),
disabled: Boolean(disabled),
id,
spellCheck: !disableSpellcheck,
onChange: handleChange,
onKeyDown: handleKeyDown,
@ -228,7 +237,12 @@ export const Input = forwardRef<
) : null;
const lengthCountElement = lengthCount >= whenToShowRemainingCount && (
<div className={getClassName('__remaining-count')}>
<div
className={classNames(getClassName('__remaining-count'), {
[getClassName('__remaining-count--warn')]:
lengthCount >= whenToWarnRemainingCount,
})}
>
{maxLengthCount - lengthCount}
</div>
);
@ -242,7 +256,7 @@ export const Input = forwardRef<
)}
>
{icon ? <div className={getClassName('__icon')}>{icon}</div> : null}
{expandable ? (
{isTextarea || forceTextarea ? (
<textarea dir="auto" rows={1} {...inputProps} />
) : (
<input dir="auto" {...inputProps} />

View file

@ -0,0 +1,32 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { action } from '@storybook/addon-actions';
import * as React from 'react';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../util/setupI18n';
import {
NotePreviewModal,
type NotePreviewModalProps,
} from './NotePreviewModal';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/NotePreviewModal',
component: NotePreviewModal,
argTypes: {},
args: {
conversation: getDefaultConversation({
note: 'Met at UC Berkeley, mutual friends with Katie Hall.\n\nWebsite: https://example.com/',
}),
i18n,
onClose: action('onClose'),
onEdit: action('onEdit'),
},
} satisfies ComponentMeta<NotePreviewModalProps>;
export function Normal(args: NotePreviewModalProps): JSX.Element {
return <NotePreviewModal {...args} />;
}

View file

@ -0,0 +1,47 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { ConversationType } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/I18N';
import { Button, ButtonVariant } from './Button';
import { Modal } from './Modal';
import { Linkify } from './conversation/Linkify';
export type NotePreviewModalProps = Readonly<{
conversation: ConversationType;
i18n: LocalizerType;
onClose: () => void;
onEdit: () => void;
}>;
export function NotePreviewModal({
conversation,
i18n,
onClose,
onEdit,
}: NotePreviewModalProps): JSX.Element {
return (
<Modal
modalName="NotePreviewModal"
i18n={i18n}
title={i18n('icu:NotePreviewModal__Title')}
onClose={onClose}
hasXButton
modalFooter={
<>
<Button onClick={onEdit} variant={ButtonVariant.Secondary}>
{i18n('icu:edit')}
</Button>
<Button onClick={onClose} variant={ButtonVariant.Primary}>
{i18n('icu:done')}
</Button>
</>
}
>
<div dir="auto">
<Linkify text={conversation.note ?? ''} />
</div>
</Modal>
);
}

View file

@ -330,7 +330,6 @@ export function ProfileEditor({
i18n={i18n}
maxLengthCount={26}
maxByteCount={128}
whenToShowRemainingCount={0}
onChange={newFirstName => {
setStagedProfile(profileData => ({
...profileData,
@ -345,7 +344,6 @@ export function ProfileEditor({
i18n={i18n}
maxLengthCount={26}
maxByteCount={128}
whenToShowRemainingCount={0}
onChange={newFamilyName => {
setStagedProfile(profileData => ({
...profileData,

View file

@ -59,6 +59,7 @@ export default {
args: {
i18n,
onClose: action('onClose'),
onOpenNotePreviewModal: action('onOpenNotePreviewModal'),
toggleSignalConnectionsModal: action('toggleSignalConnections'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
updateSharedGroups: action('updateSharedGroups'),

View file

@ -11,10 +11,19 @@ import { Modal } from '../Modal';
import { UserText } from '../UserText';
import { SharedGroupNames } from '../SharedGroupNames';
import { About } from './About';
import { Intl } from '../Intl';
import { areNicknamesEnabled } from '../../util/nicknames';
function muted(parts: Array<string | JSX.Element>) {
return (
<span className="AboutContactModal__TitleWithoutNickname">{parts}</span>
);
}
export type PropsType = Readonly<{
i18n: LocalizerType;
onClose: () => void;
onOpenNotePreviewModal: () => void;
conversation: ConversationType;
isSignalConnection: boolean;
toggleSignalConnectionsModal: () => void;
@ -32,6 +41,7 @@ export function AboutContactModal({
updateSharedGroups,
unblurAvatar,
onClose,
onOpenNotePreviewModal,
}: PropsType): JSX.Element {
const { isMe } = conversation;
@ -135,7 +145,26 @@ export function AboutContactModal({
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--profile" />
<UserText text={conversation.title} />
{areNicknamesEnabled() &&
conversation.nicknameGivenName &&
conversation.titleNoNickname ? (
<span>
<Intl
i18n={i18n}
id="icu:AboutContactModal__TitleAndTitleWithoutNickname"
components={{
nickname: <UserText text={conversation.title} />,
titleNoNickname: (
<UserText text={conversation.titleNoNickname} />
),
muted,
}}
/>
</span>
) : (
<UserText text={conversation.title} />
)}
</div>
{!isMe && conversation.isVerified ? (
@ -166,7 +195,7 @@ export function AboutContactModal({
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--connections" />
<button
type="button"
className="AboutContactModal__signal-connection"
className="AboutContactModal__button"
onClick={onSignalConnectionClick}
>
{i18n('icu:AboutContactModal__signal-connection')}
@ -205,6 +234,21 @@ export function AboutContactModal({
</div>
)}
{areNicknamesEnabled() && conversation.note && (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--note" />
<button
type="button"
className="AboutContactModal__button"
onClick={onOpenNotePreviewModal}
>
<div className="AboutContactModal__OneLineEllipsis">
<UserText text={conversation.note} />
</div>
</button>
</div>
)}
{statusRow}
</Modal>
);

View file

@ -3,6 +3,7 @@
import * as React from 'react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
@ -30,6 +31,7 @@ export function FromContact(): JSX.Element {
oldName: 'Mr. Fire 🔥 Old',
newName: 'Mr. Fire 🔥 New',
}}
onOpenEditNicknameAndNoteModal={action('onOpenEditNicknameAndNoteModal')}
/>
);
}
@ -48,6 +50,7 @@ export function FromNonContact(): JSX.Element {
oldName: 'Mr. Fire 🔥 Old',
newName: 'Mr. Fire 🔥 New',
}}
onOpenEditNicknameAndNoteModal={action('onOpenEditNicknameAndNoteModal')}
/>
);
}
@ -66,6 +69,7 @@ export function FromContactWithLongNamesBeforeAndAfter(): JSX.Element {
oldName: '💅🤷🏽‍♀️🏯'.repeat(50),
newName: '☎️🎉🏝'.repeat(50),
}}
onOpenEditNicknameAndNoteModal={action('onOpenEditNicknameAndNoteModal')}
/>
);
}

View file

@ -9,16 +9,40 @@ import { SystemMessage } from './SystemMessage';
import { Emojify } from './Emojify';
import type { ProfileNameChangeType } from '../../util/getStringForProfileChange';
import { getStringForProfileChange } from '../../util/getStringForProfileChange';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { areNicknamesEnabled } from '../../util/nicknames';
export type PropsType = {
change: ProfileNameChangeType;
changedContact: ConversationType;
i18n: LocalizerType;
onOpenEditNicknameAndNoteModal: () => void;
};
export function ProfileChangeNotification(props: PropsType): JSX.Element {
const { change, changedContact, i18n } = props;
export function ProfileChangeNotification({
change,
changedContact,
i18n,
onOpenEditNicknameAndNoteModal,
}: PropsType): JSX.Element {
const message = getStringForProfileChange(change, changedContact, i18n);
return <SystemMessage icon="profile" contents={<Emojify text={message} />} />;
return (
<SystemMessage
icon="profile"
contents={<Emojify text={message} />}
button={
areNicknamesEnabled() &&
changedContact.nicknameGivenName != null && (
<Button
onClick={onOpenEditNicknameAndNoteModal}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('icu:update')}
</Button>
)
}
/>
);
}

View file

@ -314,7 +314,7 @@ const actions = () => ({
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'
),

View file

@ -81,6 +81,7 @@ const getDefaultProps = () => ({
showConversation: action('showConversation'),
openGiftBadge: action('openGiftBadge'),
saveAttachment: action('saveAttachment'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'
),

View file

@ -181,6 +181,7 @@ type PropsLocalType = {
isTargeted: boolean;
targetMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean;
onOpenEditNicknameAndNoteModal: () => void;
onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void;
platform: string;
renderContact: SmartContactRendererType<JSX.Element>;
@ -219,6 +220,7 @@ export const TimelineItem = memo(function TimelineItem({
isNextItemCallingNotification,
isTargeted,
item,
onOpenEditNicknameAndNoteModal,
onOpenMessageRequestActionsConfirmation,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
@ -383,6 +385,7 @@ export const TimelineItem = memo(function TimelineItem({
{...reducedProps}
{...item.data}
i18n={i18n}
onOpenEditNicknameAndNoteModal={onOpenEditNicknameAndNoteModal}
/>
);
} else if (item.type === 'paymentEvent') {

View file

@ -104,6 +104,8 @@ const createProps = (
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleAboutContactModal: action('toggleAboutContactModal'),
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
onDeleteNicknameAndNote: action('onDeleteNicknameAndNote'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'
),

View file

@ -63,6 +63,8 @@ import {
} from '../../../types/CallDisposition';
import { formatDate, formatTime } from '../../../util/timestamp';
import { NavTab } from '../../../state/ducks/nav';
import { ContextMenu } from '../../ContextMenu';
import { areNicknamesEnabled } from '../../../util/nicknames';
function describeCallHistory(
i18n: LocalizerType,
@ -86,11 +88,12 @@ function describeCallHistory(
}
enum ModalState {
NothingOpen,
AddingGroupMembers,
ConfirmDeleteNicknameAndNote,
EditingGroupDescription,
EditingGroupTitle,
AddingGroupMembers,
MuteNotifications,
NothingOpen,
UnmuteNotifications,
}
@ -140,6 +143,8 @@ type ActionProps = {
getProfilesForConversation: (id: string) => unknown;
leaveGroup: (conversationId: string) => void;
loadRecentMediaItems: (id: string, limit: number) => void;
onDeleteNicknameAndNote: () => void;
onOpenEditNicknameAndNoteModal: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
onOutgoingVideoCallInConversation: (conversationId: string) => unknown;
pushPanelForConversation: PushPanelForConversationActionType;
@ -207,6 +212,8 @@ export function ConversationDetails({
memberships,
maxGroupSize,
maxRecommendedGroupSize,
onDeleteNicknameAndNote,
onOpenEditNicknameAndNoteModal,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
pendingApprovalMemberships,
@ -344,6 +351,30 @@ export function ConversationDetails({
/>
);
break;
case ModalState.ConfirmDeleteNicknameAndNote:
modalNode = (
<ConfirmationDialog
dialogName="ConversationDetails.ConfirmDeleteNicknameAndNote"
actions={[
{
action: onDeleteNicknameAndNote,
style: 'negative',
text: i18n('icu:delete'),
},
]}
hasXButton
i18n={i18n}
title={i18n(
'icu:ConversationDetails__ConfirmDeleteNicknameAndNote__Title'
)}
onClose={onCloseModal}
>
{i18n(
'icu:ConversationDetails__ConfirmDeleteNicknameAndNote__Description'
)}
</ConfirmationDialog>
);
break;
case ModalState.MuteNotifications:
modalNode = (
<ConversationNotificationsModal
@ -375,6 +406,7 @@ export function ConversationDetails({
</ConfirmationDialog>
);
break;
default:
throw missingCaseError(modalState);
}
@ -534,6 +566,57 @@ export function ConversationDetails({
}
/>
) : null}
{areNicknamesEnabled() && !isGroup && (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetails--nickname-label')}
icon={IconType.edit}
/>
}
label={i18n('icu:ConversationDetails--nickname-label')}
onClick={onOpenEditNicknameAndNoteModal}
actions={
(conversation.nicknameGivenName ||
conversation.nicknameFamilyName ||
conversation.note) && (
<ContextMenu
i18n={i18n}
portalToRoot
popperOptions={{
placement: 'bottom',
strategy: 'absolute',
}}
menuOptions={[
{
icon: 'ConversationDetails--nickname-actions--delete',
label: i18n(
'icu:ConversationDetails--nickname-actions--delete'
),
onClick: () => {
setModalState(ModalState.ConfirmDeleteNicknameAndNote);
},
},
]}
>
{({ openMenu }) => {
return (
<button
type="button"
className="ConversationDetails--nickname-actions"
onClick={openMenu}
>
<span className="ConversationDetails--nickname-actions-label">
{i18n('icu:ConversationDetails--nickname-actions')}
</span>
</button>
);
}}
</ContextMenu>
)
}
/>
)}
{selectedNavTab === NavTab.Chats && (
<PanelRow
icon={