Init Nicknames & Notes
This commit is contained in:
parent
ebecf2403f
commit
e26916702c
42 changed files with 1050 additions and 23 deletions
56
ts/components/AutoSizeTextArea.tsx
Normal file
56
ts/components/AutoSizeTextArea.tsx
Normal 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 />;
|
||||
});
|
32
ts/components/EditNicknameAndNoteModal.stories.tsx
Normal file
32
ts/components/EditNicknameAndNoteModal.stories.tsx
Normal 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} />;
|
||||
}
|
185
ts/components/EditNicknameAndNoteModal.tsx
Normal file
185
ts/components/EditNicknameAndNoteModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
32
ts/components/NotePreviewModal.stories.tsx
Normal file
32
ts/components/NotePreviewModal.stories.tsx
Normal 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} />;
|
||||
}
|
47
ts/components/NotePreviewModal.tsx
Normal file
47
ts/components/NotePreviewModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -59,6 +59,7 @@ export default {
|
|||
args: {
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
onOpenNotePreviewModal: action('onOpenNotePreviewModal'),
|
||||
toggleSignalConnectionsModal: action('toggleSignalConnections'),
|
||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||
updateSharedGroups: action('updateSharedGroups'),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -314,7 +314,7 @@ const actions = () => ({
|
|||
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
|
||||
|
||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||
|
||||
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
),
|
||||
|
|
|
@ -81,6 +81,7 @@ const getDefaultProps = () => ({
|
|||
showConversation: action('showConversation'),
|
||||
openGiftBadge: action('openGiftBadge'),
|
||||
saveAttachment: action('saveAttachment'),
|
||||
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
),
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -104,6 +104,8 @@ const createProps = (
|
|||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||
toggleAboutContactModal: action('toggleAboutContactModal'),
|
||||
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
|
||||
onDeleteNicknameAndNote: action('onDeleteNicknameAndNote'),
|
||||
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
),
|
||||
|
|
|
@ -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={
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue