diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 83ff26e1f..216b55353 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1366,6 +1366,10 @@ "messageformat": "You", "description": "Title of About modal when viewing your own information" }, + "icu:AboutContactModal__TitleAndTitleWithoutNickname": { + "messageformat": "{nickname} ({titleNoNickname})", + "description": "Title of conversation when there is a nickname, example: 'Jim (James Smith)'" + }, "icu:AboutContactModal__verified": { "messageformat": "Verified", "description": "Text of a button on About modal leading to a safety number modal" @@ -1390,6 +1394,10 @@ "messageformat": "{name} is in your system contacts", "description": "Text of a row in the About modal describing that the contact is in system contacts" }, + "icu:NotePreviewModal__Title": { + "messageformat": "Note", + "description": "Title of Note Preview modal" + }, "icu:ContactModal__showSafetyNumber": { "messageformat": "View safety number", "description": "(Deleted 2024/03/07) Contact modal, label for button to show safety number modal" @@ -4898,6 +4906,26 @@ "messageformat": "When enabled, messages sent and received in this 1:1 chat will disappear after they've been seen.", "description": "This is the info about the disappearing messages setting, for direct conversations" }, + "icu:ConversationDetails--nickname-label": { + "messageformat": "Nickname", + "description": "This is the label for the nickname setting panel" + }, + "icu:ConversationDetails--nickname-actions": { + "messageformat": "Actions", + "description": "This is the label for the nickname actions panel" + }, + "icu:ConversationDetails--nickname-actions--delete": { + "messageformat": "Delete", + "description": "This is the label for the delete nickname action" + }, + "icu:ConversationDetails__ConfirmDeleteNicknameAndNote__Title": { + "messageformat": "Delete nickname?", + "description": "Title for the modal to confirm deleting a nickname" + }, + "icu:ConversationDetails__ConfirmDeleteNicknameAndNote__Description": { + "messageformat": "This will permanently delete this nickname and note.", + "description": "Description for the modal to confirm deleting a nickname" + }, "icu:ConversationDetails--notifications": { "messageformat": "Notifications", "description": "This is the label for notifications in the conversation details screen" @@ -5022,6 +5050,38 @@ "messageformat": "See all", "description": "This is a button on the conversation details (for a direct contact) to show all groups-in-common" }, + "icu:EditNicknameAndNoteModal__Title": { + "messageformat": "Nickname", + "description": "Title for the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__Description": { + "messageformat": "Nicknames & notes are stored using Signal’s end-to-end encrypted storage service. They are only visible to you.", + "description": "Description for the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__FirstName__Label": { + "messageformat": "First name", + "description": "Label for the first name input in the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__FirstName__Placeholder": { + "messageformat": "First name", + "description": "Input placeholder for the first name input in the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__LastName__Label": { + "messageformat": "Last name", + "description": "Label for the last name input in the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__LastName__Placeholder": { + "messageformat": "Last name", + "description": "Input placeholder for the last name input in the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__Note__Label": { + "messageformat": "Note", + "description": "Label for the note input in the edit nickname and note modal" + }, + "icu:EditNicknameAndNoteModal__Note__Placeholder": { + "messageformat": "Note", + "description": "Input placeholder for the note input in the edit nickname and note modal" + }, "icu:ConversationNotificationsSettings__mentions__label": { "messageformat": "Mentions", "description": "In the conversation notifications settings, this is the label for the mentions option" diff --git a/protos/SignalStorage.proto b/protos/SignalStorage.proto index 06d6e0fc9..b7d5a0884 100644 --- a/protos/SignalStorage.proto +++ b/protos/SignalStorage.proto @@ -77,6 +77,11 @@ message ContactRecord { UNVERIFIED = 2; } + message Name { + optional string given = 1; + optional string family = 2; + } + optional string aci = 1; optional string serviceE164 = 2; optional string pni = 15; @@ -98,6 +103,8 @@ message ContactRecord { optional string systemNickname = 19; optional bool hidden = 20; optional bool pniSignatureVerified = 21; + optional Name nickname = 22; + optional string note = 23; } message GroupV1Record { diff --git a/stylesheets/components/AboutContactModal.scss b/stylesheets/components/AboutContactModal.scss index b1eb4bebd..fdd354bf9 100644 --- a/stylesheets/components/AboutContactModal.scss +++ b/stylesheets/components/AboutContactModal.scss @@ -97,12 +97,17 @@ &--about { @include about-modal-icon('../images/icons/v3/edit/edit.svg'); } + + &--note { + @include about-modal-icon('../images/icons/v3/note/note.svg'); + } } - &__signal-connection { + &__button { display: flex; flex-direction: row; align-items: center; + min-width: 0; @include button-reset(); cursor: pointer; @@ -112,6 +117,7 @@ display: inline-block; height: 20px; width: 20px; + flex-shrink: 0; @include color-svg( '../images/icons/v3/chevron/chevron-right-bold.svg', @@ -125,3 +131,13 @@ cursor: pointer; } } + +.AboutContactModal__TitleWithoutNickname { + color: $color-gray-45; +} + +.AboutContactModal__OneLineEllipsis { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 905c1fee2..2cf4e1d2c 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -493,3 +493,42 @@ .ConversationDetails__CallHistoryGroup__ItemTimestamp { flex-shrink: 0; } + +.ConversationDetails--nickname-actions { + @include button-reset; + width: 32px; + height: 32px; + &::before { + display: inline-block; + content: ''; + width: 20px; + height: 20px; + @include light-theme { + @include color-svg( + '../images/icons/v3/chevron/chevron-down.svg', + $color-black + ); + } + @include dark-theme { + @include color-svg( + '../images/icons/v3/chevron/chevron-down.svg', + $color-white + ); + } + } +} + +.ConversationDetails--nickname-actions-label { + @include sr-only; +} + +.ConversationDetails--nickname-actions--delete { + width: 16px; + height: 16px; + @include light-theme { + @include color-svg('../images/icons/v3/trash/trash.svg', $color-black); + } + @include dark-theme { + @include color-svg('../images/icons/v3/trash/trash.svg', $color-white); + } +} diff --git a/stylesheets/components/EditNicknameAndNoteModal.scss b/stylesheets/components/EditNicknameAndNoteModal.scss new file mode 100644 index 000000000..03443663f --- /dev/null +++ b/stylesheets/components/EditNicknameAndNoteModal.scss @@ -0,0 +1,27 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.EditNicknameAndNoteModal__width-container { + max-width: 360px; +} + +.EditNicknameAndNoteModal__Description { + margin-block: 12px 24px; + @include font-body-2; + @include light-theme { + color: $color-gray-75; + } + @include dark-theme { + color: $color-gray-25; + } +} + +.EditNicknameAndNoteModal__Avatar { + margin-block: 0 24px; + display: flex; + justify-content: center; +} + +.EditNicknameAndNoteModal__Label { + @include sr-only; +} diff --git a/stylesheets/components/Input.scss b/stylesheets/components/Input.scss index 0add4327c..eacb90719 100644 --- a/stylesheets/components/Input.scss +++ b/stylesheets/components/Input.scss @@ -76,7 +76,7 @@ height: 280px; } - &--expandable { + &--textarea { margin-top: 4px; } @@ -129,5 +129,9 @@ margin: 12px; } + + &--warn { + color: $color-accent-red; + } } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index dc2741788..9023dc289 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -87,6 +87,7 @@ @import './components/DisappearingTimerSelect.scss'; @import './components/EditConversationAttributesModal.scss'; @import './components/EditHistoryMessagesModal.scss'; +@import './components/EditNicknameAndNoteModal.scss'; @import './components/EditUsernameModalBody.scss'; @import './components/ForwardMessageModal.scss'; @import './components/GradientDial.scss'; diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index a675c5669..c430ecb7e 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -21,6 +21,7 @@ export type ConfigKeyType = | 'desktop.internalUser' | 'desktop.mediaQuality.levels' | 'desktop.messageCleanup' + | 'desktop.nicknames' | 'desktop.retryRespondMaxAge' | 'desktop.senderKey.retry' | 'desktop.senderKeyMaxAge' diff --git a/ts/components/AutoSizeTextArea.tsx b/ts/components/AutoSizeTextArea.tsx new file mode 100644 index 000000000..2de3a0835 --- /dev/null +++ b/ts/components/AutoSizeTextArea.tsx @@ -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 +): JSX.Element { + const ownRef = useRef(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 ; +}); diff --git a/ts/components/EditNicknameAndNoteModal.stories.tsx b/ts/components/EditNicknameAndNoteModal.stories.tsx new file mode 100644 index 000000000..399ced79f --- /dev/null +++ b/ts/components/EditNicknameAndNoteModal.stories.tsx @@ -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; + +export function Normal(args: EditNicknameAndNoteModalProps): JSX.Element { + return ; +} diff --git a/ts/components/EditNicknameAndNoteModal.tsx b/ts/components/EditNicknameAndNoteModal.tsx new file mode 100644 index 000000000..0ee989062 --- /dev/null +++ b/ts/components/EditNicknameAndNoteModal.tsx @@ -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 ( + + + + + } + > +

+ {i18n('icu:EditNicknameAndNoteModal__Description')} +

+
+ +
+
+ + { + setGivenName(value); + }} + /> + + { + setFamilyName(value); + }} + /> + + + { + setNote(value); + }} + /> + + +
+ ); +} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 8bb797640..7132602f4 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -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(); } diff --git a/ts/components/Input.tsx b/ts/components/Input.tsx index 4150f61ce..1b2d38dba 100644 --- a/ts/components/Input.tsx +++ b/ts/components/Input.tsx @@ -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 && ( -
+
= whenToWarnRemainingCount, + })} + > {maxLengthCount - lengthCount}
); @@ -242,7 +256,7 @@ export const Input = forwardRef< )} > {icon ?
{icon}
: null} - {expandable ? ( + {isTextarea || forceTextarea ? (