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

@ -1366,6 +1366,10 @@
"messageformat": "You",
"description": "Title of About modal when viewing your own information"
},
"icu:AboutContactModal__TitleAndTitleWithoutNickname": {
"messageformat": "{nickname} <muted>({titleNoNickname})</muted>",
"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 Signals 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"

View file

@ -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 {

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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;
}

View file

@ -76,7 +76,7 @@
height: 280px;
}
&--expandable {
&--textarea {
margin-top: 4px;
}
@ -129,5 +129,9 @@
margin: 12px;
}
&--warn {
color: $color-accent-red;
}
}
}

View file

@ -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';

View file

@ -21,6 +21,7 @@ export type ConfigKeyType =
| 'desktop.internalUser'
| 'desktop.mediaQuality.levels'
| 'desktop.messageCleanup'
| 'desktop.nicknames'
| 'desktop.retryRespondMaxAge'
| 'desktop.senderKey.retry'
| 'desktop.senderKeyMaxAge'

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" />
{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={

5
ts/model-types.d.ts vendored
View file

@ -374,6 +374,9 @@ export type ConversationAttributesType = {
systemGivenName?: string;
systemFamilyName?: string;
systemNickname?: string;
nicknameGivenName?: string | null;
nicknameFamilyName?: string | null;
note?: string | null;
needsStorageServiceSync?: boolean;
needsVerification?: boolean;
profileSharing?: boolean;
@ -469,6 +472,8 @@ export type ConversationRenderInfoType = Pick<
| 'systemGivenName'
| 'systemFamilyName'
| 'systemNickname'
| 'nicknameGivenName'
| 'nicknameFamilyName'
| 'type'
| 'username'
>;

View file

@ -5032,6 +5032,7 @@ export class ConversationModel extends window.Backbone
// [X] verified!
// [-] profileName
// [-] profileFamilyName
// [X] nicknameAndNote
// [X] blocked
// [X] whitelisted
// [X] archived

View file

@ -202,6 +202,18 @@ export async function toContactRecord(
if (profileFamilyName) {
contactRecord.familyName = profileFamilyName;
}
const nicknameGivenName = conversation.get('nicknameGivenName');
if (nicknameGivenName) {
const nicknameFamilyName = conversation.get('nicknameFamilyName');
contactRecord.nickname = {
given: nicknameGivenName,
family: nicknameFamilyName,
};
}
const note = conversation.get('note');
if (note) {
contactRecord.note = note;
}
const systemGivenName = conversation.get('systemGivenName');
if (systemGivenName) {
contactRecord.systemGivenName = systemGivenName;
@ -1082,6 +1094,9 @@ export async function mergeContactRecord(
systemGivenName: dropNull(contactRecord.systemGivenName),
systemFamilyName: dropNull(contactRecord.systemFamilyName),
systemNickname: dropNull(contactRecord.systemNickname),
nicknameGivenName: dropNull(contactRecord.nickname?.given),
nicknameFamilyName: dropNull(contactRecord.nickname?.family),
note: dropNull(contactRecord.note),
});
// https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/RecipientDatabase.kt#L921-L936

View file

@ -243,6 +243,9 @@ export type ConversationType = ReadonlyDeep<
pni?: PniString;
e164?: string;
name?: string;
nicknameGivenName?: string;
nicknameFamilyName?: string;
note?: string;
systemGivenName?: string;
systemFamilyName?: string;
systemNickname?: string;
@ -317,6 +320,7 @@ export type ConversationType = ReadonlyDeep<
sortedGroupMembers?: ReadonlyArray<ConversationType>;
title: string;
titleNoDefault?: string;
titleNoNickname?: string;
searchableTitle?: string;
unreadCount?: number;
unreadMentionsCount?: number;
@ -1158,6 +1162,7 @@ export const actions = {
updateConversationModelSharedGroups,
updateGroupAttributes,
updateLastMessage,
updateNicknameAndNote,
updateSharedGroups,
verifyConversationsStoppingSend,
};
@ -4672,6 +4677,39 @@ export function updateLastMessage(
};
}
export type NicknameAndNote = ReadonlyDeep<{
nickname: {
givenName: string;
familyName: string | null;
} | null;
note: string | null;
}>;
function updateNicknameAndNote(
conversationId: string,
nicknameAndNote: NicknameAndNote
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const { nickname, note } = nicknameAndNote;
const conversationModel = window.ConversationController.get(conversationId);
strictAssert(
conversationModel != null,
'updateNicknameAndNote: Conversation not found'
);
conversationModel.set({
nicknameGivenName: nickname?.givenName,
nicknameFamilyName: nickname?.familyName,
note,
});
window.Signal.Data.updateConversation(conversationModel.attributes);
const conversation = conversationModel.format();
dispatch(conversationChanged(conversationId, conversation));
conversationModel.captureChange('nicknameAndNote');
};
}
export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly<

View file

@ -49,6 +49,9 @@ import type { MessageRequestState } from '../../components/conversation/MessageR
export type EditHistoryMessagesType = ReadonlyDeep<
Array<MessageAttributesType>
>;
export type EditNicknameAndNoteModalPropsType = ReadonlyDeep<{
conversationId: string;
}>;
export type DeleteMessagesPropsType = ReadonlyDeep<{
conversationId: string;
messageIds: ReadonlyArray<string>;
@ -63,6 +66,9 @@ export type MessageRequestActionsConfirmationPropsType = ReadonlyDeep<{
conversationId: string;
state: MessageRequestState;
}>;
export type NotePreviewModalPropsType = ReadonlyDeep<{
conversationId: string;
}>;
export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
promiseUuid: SingleServePromise.SingleServePromiseIdString;
source?: SafetyNumberChangeSource;
@ -91,6 +97,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
contactModalState?: ContactModalStateType;
deleteMessagesProps?: DeleteMessagesPropsType;
editHistoryMessages?: EditHistoryMessagesType;
editNicknameAndNoteModalProps: EditNicknameAndNoteModalPropsType | null;
errorModalProps?: {
buttonVariant?: ButtonVariant;
description?: string;
@ -107,6 +114,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
isStoriesSettingsVisible: boolean;
isWhatsNewVisible: boolean;
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
notePreviewModalProps: NotePreviewModalPropsType | null;
usernameOnboardingState: UsernameOnboardingState;
profileEditorHasError: boolean;
profileEditorInitialEditState: ProfileEditorEditState | undefined;
@ -133,6 +141,7 @@ const TOGGLE_DELETE_MESSAGES_MODAL =
'globalModals/TOGGLE_DELETE_MESSAGES_MODAL';
const TOGGLE_FORWARD_MESSAGES_MODAL =
'globalModals/TOGGLE_FORWARD_MESSAGES_MODAL';
const TOGGLE_NOTE_PREVIEW_MODAL = 'globalModals/TOGGLE_NOTE_PREVIEW_MODAL';
const TOGGLE_PROFILE_EDITOR = 'globalModals/TOGGLE_PROFILE_EDITOR';
export const TOGGLE_PROFILE_EDITOR_ERROR =
'globalModals/TOGGLE_PROFILE_EDITOR_ERROR';
@ -150,6 +159,8 @@ const SHOW_STICKER_PACK_PREVIEW = 'globalModals/SHOW_STICKER_PACK_PREVIEW';
const CLOSE_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW';
const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL';
export const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL';
const TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL =
'globalModals/TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL';
const TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION =
'globalModals/TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION';
const SHOW_FORMATTING_WARNING_MODAL =
@ -221,6 +232,11 @@ type ToggleForwardMessagesModalActionType = ReadonlyDeep<{
payload: ForwardMessagesPropsType | undefined;
}>;
type ToggleNotePreviewModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_NOTE_PREVIEW_MODAL;
payload: NotePreviewModalPropsType | null;
}>;
type ToggleProfileEditorActionType = ReadonlyDeep<{
type: typeof TOGGLE_PROFILE_EDITOR;
payload: {
@ -324,6 +340,11 @@ export type ShowErrorModalActionType = ReadonlyDeep<{
};
}>;
type ToggleEditNicknameAndNoteModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL;
payload: EditNicknameAndNoteModalPropsType | null;
}>;
type ToggleMessageRequestActionsConfirmationActionType = ReadonlyDeep<{
type: typeof TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION;
payload: MessageRequestActionsConfirmationPropsType | null;
@ -386,6 +407,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowContactModalActionType
| ShowEditHistoryModalActionType
| ShowErrorModalActionType
| ToggleEditNicknameAndNoteModalActionType
| ToggleMessageRequestActionsConfirmationActionType
| ShowFormattingWarningModalActionType
| ShowSendAnywayDialogActionType
@ -401,6 +423,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ToggleConfirmationModalActionType
| ToggleDeleteMessagesModalActionType
| ToggleForwardMessagesModalActionType
| ToggleNotePreviewModalActionType
| ToggleProfileEditorActionType
| ToggleProfileEditorErrorActionType
| ToggleSafetyNumberModalActionType
@ -428,6 +451,7 @@ export const actions = {
showContactModal,
showEditHistoryModal,
showErrorModal,
toggleEditNicknameAndNoteModal,
toggleMessageRequestActionsConfirmation,
showFormattingWarningModal,
showSendEditWarningModal,
@ -442,6 +466,7 @@ export const actions = {
toggleConfirmationModal,
toggleDeleteMessagesModal,
toggleForwardMessagesModal,
toggleNotePreviewModal,
toggleProfileEditor,
toggleProfileEditorHasError,
toggleSafetyNumberModal,
@ -626,6 +651,15 @@ function toggleForwardMessagesModal(
};
}
function toggleNotePreviewModal(
payload: NotePreviewModalPropsType | null
): ToggleNotePreviewModalActionType {
return {
type: TOGGLE_NOTE_PREVIEW_MODAL,
payload,
};
}
function toggleProfileEditor(
initialEditState?: ProfileEditorEditState
): ToggleProfileEditorActionType {
@ -765,6 +799,15 @@ function showErrorModal({
};
}
function toggleEditNicknameAndNoteModal(
payload: EditNicknameAndNoteModalPropsType | null
): ToggleEditNicknameAndNoteModalActionType {
return {
type: TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL,
payload,
};
}
function toggleMessageRequestActionsConfirmation(
payload: {
conversationId: string;
@ -927,6 +970,7 @@ function copyOverMessageAttributesIntoForwardMessages(
export function getEmptyState(): GlobalModalsStateType {
return {
hasConfirmationModal: false,
editNicknameAndNoteModalProps: null,
isProfileEditorVisible: false,
isShortcutGuideModalVisible: false,
isSignalConnectionsVisible: false,
@ -936,6 +980,7 @@ export function getEmptyState(): GlobalModalsStateType {
profileEditorHasError: false,
profileEditorInitialEditState: undefined,
messageRequestActionsConfirmationProps: null,
notePreviewModalProps: null,
};
}
@ -950,6 +995,13 @@ export function reducer(
};
}
if (action.type === TOGGLE_NOTE_PREVIEW_MODAL) {
return {
...state,
notePreviewModalProps: action.payload,
};
}
if (action.type === TOGGLE_PROFILE_EDITOR) {
return {
...state,
@ -1160,6 +1212,13 @@ export function reducer(
};
}
if (action.type === TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL) {
return {
...state,
editNicknameAndNoteModalProps: action.payload,
};
}
if (action.type === TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION) {
return {
...state,

View file

@ -61,3 +61,13 @@ export const getProfileEditorInitialEditState = createSelector(
getGlobalModalsState,
({ profileEditorInitialEditState }) => profileEditorInitialEditState
);
export const getEditNicknameAndNoteModalProps = createSelector(
getGlobalModalsState,
({ editNicknameAndNoteModalProps }) => editNicknameAndNoteModalProps
);
export const getNotePreviewModalProps = createSelector(
getGlobalModalsState,
({ notePreviewModalProps }) => notePreviewModalProps
);

View file

@ -1,6 +1,6 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { AboutContactModal } from '../../components/conversation/AboutContactModal';
import { isSignalConnection } from '../../util/getSignalConnections';
@ -9,6 +9,7 @@ import { getGlobalModalsState } from '../selectors/globalModals';
import { getConversationSelector } from '../selectors/conversations';
import { useConversationsActions } from '../ducks/conversations';
import { useGlobalModalActions } from '../ducks/globalModals';
import { strictAssert } from '../../util/assert';
export const SmartAboutContactModal = memo(function SmartAboutContactModal() {
const i18n = useSelector(getIntl);
@ -18,18 +19,25 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() {
const { updateSharedGroups, unblurAvatar } = useConversationsActions();
const conversation = getConversation(contactId);
const { id: conversationId } = conversation ?? {};
const {
toggleAboutContactModal,
toggleSignalConnectionsModal,
toggleSafetyNumberModal,
toggleNotePreviewModal,
} = useGlobalModalActions();
if (!contactId) {
const handleOpenNotePreviewModal = useCallback(() => {
strictAssert(conversationId != null, 'conversationId is required');
toggleNotePreviewModal({ conversationId });
}, [toggleNotePreviewModal, conversationId]);
if (conversation == null) {
return null;
}
const conversation = getConversation(contactId);
return (
<AboutContactModal
i18n={i18n}
@ -40,6 +48,7 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() {
toggleSafetyNumberModal={toggleSafetyNumberModal}
isSignalConnection={isSignalConnection(conversation)}
onClose={toggleAboutContactModal}
onOpenNotePreviewModal={handleOpenNotePreviewModal}
/>
);
});

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { sortBy } from 'lodash';
import React, { memo } from 'react';
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails';
import {
@ -114,6 +114,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
setMuteExpiration,
showConversation,
updateGroupAttributes,
updateNicknameAndNote,
} = useConversationsActions();
const {
onOutgoingAudioCallInConversation,
@ -124,6 +125,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
showContactModal,
toggleAboutContactModal,
toggleAddUserToAnotherGroupModal,
toggleEditNicknameAndNoteModal,
toggleSafetyNumberModal,
} = useGlobalModalActions();
const { showLightboxWithMedia } = useLightboxActions();
@ -162,6 +164,14 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
const userAvatarData = conversation.avatars ?? [];
const handleDeleteNicknameAndNote = useCallback(() => {
updateNicknameAndNote(conversationId, { nickname: null, note: null });
}, [conversationId, updateNicknameAndNote]);
const handleOpenEditNicknameAndNoteModal = useCallback(() => {
toggleEditNicknameAndNoteModal({ conversationId });
}, [conversationId, toggleEditNicknameAndNoteModal]);
return (
<ConversationDetails
acceptConversation={acceptConversation}
@ -187,6 +197,8 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
maxGroupSize={maxGroupSize}
maxRecommendedGroupSize={maxRecommendedGroupSize}
memberships={memberships}
onDeleteNicknameAndNote={handleDeleteNicknameAndNote}
onOpenEditNicknameAndNoteModal={handleOpenEditNicknameAndNoteModal}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
pendingApprovalMemberships={pendingApprovalMemberships}

View file

@ -0,0 +1,51 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { getConversationSelector } from '../selectors/conversations';
import { getEditNicknameAndNoteModalProps } from '../selectors/globalModals';
import { strictAssert } from '../../util/assert';
import { EditNicknameAndNoteModal } from '../../components/EditNicknameAndNoteModal';
import { getIntl } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals';
import type { NicknameAndNote } from '../ducks/conversations';
import { useConversationsActions } from '../ducks/conversations';
export const SmartEditNicknameAndNoteModal = memo(
function SmartEditNicknameAndNoteModal(): JSX.Element {
const props = useSelector(getEditNicknameAndNoteModalProps);
strictAssert(props != null, 'EditNicknameAndNoteModal requires props');
const { conversationId } = props;
const i18n = useSelector(getIntl);
const conversationSelector = useSelector(getConversationSelector);
const conversation = conversationSelector(conversationId);
strictAssert(
conversation != null,
'EditNicknameAndNoteModal requires conversation'
);
const { toggleEditNicknameAndNoteModal } = useGlobalModalActions();
const { updateNicknameAndNote } = useConversationsActions();
const handleSave = useCallback(
(nicknameAndNote: NicknameAndNote) => {
updateNicknameAndNote(conversationId, nicknameAndNote);
},
[conversationId, updateNicknameAndNote]
);
const handleClose = useCallback(() => {
toggleEditNicknameAndNoteModal(null);
}, [toggleEditNicknameAndNoteModal]);
return (
<EditNicknameAndNoteModal
i18n={i18n}
conversation={conversation}
onSave={handleSave}
onClose={handleClose}
/>
);
}
);

View file

@ -24,11 +24,17 @@ import { useGlobalModalActions } from '../ducks/globalModals';
import { SmartDeleteMessagesModal } from './DeleteMessagesModal';
import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsConfirmation';
import { getGlobalModalsState } from '../selectors/globalModals';
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
import { SmartNotePreviewModal } from './NotePreviewModal';
function renderEditHistoryMessagesModal(): JSX.Element {
return <SmartEditHistoryMessagesModal />;
}
function renderEditNicknameAndNoteModal(): JSX.Element {
return <SmartEditNicknameAndNoteModal />;
}
function renderProfileEditor(): JSX.Element {
return <SmartProfileEditorModal />;
}
@ -53,6 +59,10 @@ function renderMessageRequestActionsConfirmation(): JSX.Element {
return <SmartMessageRequestActionsConfirmation />;
}
function renderNotePreviewModal(): JSX.Element {
return <SmartNotePreviewModal />;
}
function renderStoriesSettings(): JSX.Element {
return <SmartStoriesSettingsModal />;
}
@ -84,10 +94,12 @@ export const SmartGlobalModalContainer = memo(
contactModalState,
deleteMessagesProps,
editHistoryMessages,
editNicknameAndNoteModalProps,
errorModalProps,
formattingWarningData,
forwardMessagesProps,
messageRequestActionsConfirmationProps,
notePreviewModalProps,
isAuthorizingArtCreator,
isProfileEditorVisible,
isShortcutGuideModalVisible,
@ -166,6 +178,7 @@ export const SmartGlobalModalContainer = memo(
}
contactModalState={contactModalState}
editHistoryMessages={editHistoryMessages}
editNicknameAndNoteModalProps={editNicknameAndNoteModalProps}
errorModalProps={errorModalProps}
deleteMessagesProps={deleteMessagesProps}
formattingWarningData={formattingWarningData}
@ -173,6 +186,7 @@ export const SmartGlobalModalContainer = memo(
messageRequestActionsConfirmationProps={
messageRequestActionsConfirmationProps
}
notePreviewModalProps={notePreviewModalProps}
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
hideUserNotFoundModal={hideUserNotFoundModal}
hideWhatsNewModal={hideWhatsNewModal}
@ -187,12 +201,14 @@ export const SmartGlobalModalContainer = memo(
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
renderContactModal={renderContactModal}
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
renderEditNicknameAndNoteModal={renderEditNicknameAndNoteModal}
renderErrorModal={renderErrorModal}
renderDeleteMessagesModal={renderDeleteMessagesModal}
renderForwardMessagesModal={renderForwardMessagesModal}
renderMessageRequestActionsConfirmation={
renderMessageRequestActionsConfirmation
}
renderNotePreviewModal={renderNotePreviewModal}
renderProfileEditor={renderProfileEditor}
renderUsernameOnboarding={renderUsernameOnboarding}
renderSafetyNumber={renderSafetyNumber}

View file

@ -0,0 +1,41 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { NotePreviewModal } from '../../components/NotePreviewModal';
import { strictAssert } from '../../util/assert';
import { getConversationSelector } from '../selectors/conversations';
import { getNotePreviewModalProps } from '../selectors/globalModals';
import { getIntl } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals';
export const SmartNotePreviewModal = memo(function SmartNotePreviewModal() {
const i18n = useSelector(getIntl);
const props = useSelector(getNotePreviewModalProps);
strictAssert(props != null, 'props is required');
const { conversationId } = props;
const conversationSelector = useSelector(getConversationSelector);
const conversation = conversationSelector(conversationId);
strictAssert(conversation != null, 'conversation is required');
const { toggleNotePreviewModal, toggleEditNicknameAndNoteModal } =
useGlobalModalActions();
const handleClose = useCallback(() => {
toggleNotePreviewModal(null);
}, [toggleNotePreviewModal]);
const handleEdit = useCallback(() => {
toggleEditNicknameAndNoteModal({ conversationId });
}, [toggleEditNicknameAndNoteModal, conversationId]);
return (
<NotePreviewModal
conversation={conversation}
i18n={i18n}
onClose={handleClose}
onEdit={handleEdit}
/>
);
});

View file

@ -143,6 +143,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
showEditHistoryModal,
toggleMessageRequestActionsConfirmation,
toggleDeleteMessagesModal,
toggleEditNicknameAndNoteModal,
toggleForwardMessagesModal,
toggleSafetyNumberModal,
} = useGlobalModalActions();
@ -155,6 +156,10 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
returnToActiveCall,
} = useCallingActions();
const onOpenEditNicknameAndNoteModal = useCallback(() => {
toggleEditNicknameAndNoteModal({ conversationId });
}, [conversationId, toggleEditNicknameAndNoteModal]);
const onOpenMessageRequestActionsConfirmation = useCallback(
(state: MessageRequestState) => {
toggleMessageRequestActionsConfirmation({ conversationId, state });
@ -198,6 +203,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
pushPanelForConversation={pushPanelForConversation}
reactToMessage={reactToMessage}
copyMessageText={copyMessageText}
onOpenEditNicknameAndNoteModal={onOpenEditNicknameAndNoteModal}
onOpenMessageRequestActionsConfirmation={
onOpenMessageRequestActionsConfirmation
}

View file

@ -7,11 +7,16 @@ export function getParticipantName(
participant: Readonly<
Pick<
ConversationType,
'firstName' | 'systemGivenName' | 'systemNickname' | 'title'
| 'firstName'
| 'systemGivenName'
| 'systemNickname'
| 'title'
| 'nicknameGivenName'
>
>
): string {
return (
participant.nicknameGivenName ||
participant.systemNickname ||
participant.systemGivenName ||
participant.firstName ||

View file

@ -206,6 +206,9 @@ export function getConversation(model: ConversationModel): ConversationType {
expireTimer: attributes.expireTimer,
muteExpiresAt: attributes.muteExpiresAt,
dontNotifyForMentionsIfMuted: attributes.dontNotifyForMentionsIfMuted,
nicknameFamilyName: dropNull(attributes.nicknameFamilyName),
nicknameGivenName: dropNull(attributes.nicknameGivenName),
note: dropNull(attributes.note),
name: attributes.name,
systemGivenName: attributes.systemGivenName,
systemFamilyName: attributes.systemFamilyName,
@ -222,6 +225,7 @@ export function getConversation(model: ConversationModel): ConversationType {
timestamp: dropNull(timestamp),
title: getTitle(attributes),
titleNoDefault: getTitleNoDefault(attributes),
titleNoNickname: getTitle(attributes, { ignoreNickname: true }),
typingContactIdTimestamps,
searchableTitle: isMe(attributes)
? window.i18n('icu:noteToSelf')

View file

@ -9,10 +9,16 @@ import { combineNames } from './combineNames';
import { getRegionCodeForNumber } from './libphonenumberUtil';
import { isDirectConversation } from './whatTypeOfConversation';
import { getE164 } from './getE164';
import { areNicknamesEnabled } from './nicknames';
type TitleOptions = {
isShort?: boolean;
ignoreNickname?: boolean;
};
export function getTitle(
attributes: ConversationRenderInfoType,
options?: { isShort?: boolean }
options?: TitleOptions
): string {
const title = getTitleNoDefault(attributes, options);
if (title) {
@ -27,7 +33,7 @@ export function getTitle(
export function getTitleNoDefault(
attributes: ConversationRenderInfoType,
{ isShort = false }: { isShort?: boolean } = {}
{ isShort = false, ignoreNickname = false }: TitleOptions = {}
): string | undefined {
if (!isDirectConversation(attributes)) {
return attributes.name;
@ -35,7 +41,15 @@ export function getTitleNoDefault(
const { username } = attributes;
let nicknameValue: string | undefined;
if (areNicknamesEnabled() && !ignoreNickname) {
nicknameValue =
(isShort ? attributes.nicknameGivenName : undefined) ||
getNicknameName(attributes);
}
return (
nicknameValue ||
(isShort ? attributes.systemGivenName : undefined) ||
getSystemName(attributes) ||
(isShort ? attributes.profileName : undefined) ||
@ -59,6 +73,8 @@ export function canHaveUsername(
| 'systemGivenName'
| 'systemFamilyName'
| 'systemNickname'
| 'nicknameGivenName'
| 'nicknameFamilyName'
| 'type'
>,
ourConversationId: string | undefined
@ -72,6 +88,7 @@ export function canHaveUsername(
}
return (
!getNicknameName(attributes) &&
!getSystemName(attributes) &&
!getProfileName(attributes) &&
!getNumber(attributes)
@ -91,6 +108,25 @@ export function getProfileName(
return undefined;
}
export function getNicknameName(
attributes: Pick<
ConversationAttributesType,
'nicknameGivenName' | 'nicknameFamilyName' | 'type'
>
): string | undefined {
if (!areNicknamesEnabled()) {
return undefined;
}
if (isDirectConversation(attributes)) {
return combineNames(
attributes.nicknameGivenName ?? undefined,
attributes.nicknameFamilyName ?? undefined
);
}
return undefined;
}
export function getSystemName(
attributes: Pick<
ConversationAttributesType,
@ -151,6 +187,7 @@ export function hasNumberTitle(
>
): boolean {
return (
!getNicknameName(attributes) &&
!getSystemName(attributes) &&
!getProfileName(attributes) &&
Boolean(getNumber(attributes))
@ -164,6 +201,7 @@ export function hasUsernameTitle(
>
): boolean {
return (
!getNicknameName(attributes) &&
!getSystemName(attributes) &&
!getProfileName(attributes) &&
!getNumber(attributes) &&

View file

@ -2773,6 +2773,13 @@
"updated": "2024-01-11T16:58:57.146Z",
"reasonDetail": "Needs access to a hidden span element to get its width"
},
{
"rule": "React-useRef",
"path": "ts/components/AutoSizeTextArea.tsx",
"line": " const ownRef = useRef<HTMLTextAreaElement | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-03-26T17:14:14.370Z"
},
{
"rule": "React-useRef",
"path": "ts/components/AvatarTextEditor.tsx",

8
ts/util/nicknames.ts Normal file
View file

@ -0,0 +1,8 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as RemoteConfig from '../RemoteConfig';
export function areNicknamesEnabled(): boolean {
return RemoteConfig.getValue('desktop.nicknames') === 'TRUE';
}