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", "messageformat": "You",
"description": "Title of About modal when viewing your own information" "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": { "icu:AboutContactModal__verified": {
"messageformat": "Verified", "messageformat": "Verified",
"description": "Text of a button on About modal leading to a safety number modal" "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", "messageformat": "{name} is in your system contacts",
"description": "Text of a row in the About modal describing that the contact is in 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": { "icu:ContactModal__showSafetyNumber": {
"messageformat": "View safety number", "messageformat": "View safety number",
"description": "(Deleted 2024/03/07) Contact modal, label for button to show safety number modal" "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.", "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" "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": { "icu:ConversationDetails--notifications": {
"messageformat": "Notifications", "messageformat": "Notifications",
"description": "This is the label for notifications in the conversation details screen" "description": "This is the label for notifications in the conversation details screen"
@ -5022,6 +5050,38 @@
"messageformat": "See all", "messageformat": "See all",
"description": "This is a button on the conversation details (for a direct contact) to show all groups-in-common" "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": { "icu:ConversationNotificationsSettings__mentions__label": {
"messageformat": "Mentions", "messageformat": "Mentions",
"description": "In the conversation notifications settings, this is the label for the mentions option" "description": "In the conversation notifications settings, this is the label for the mentions option"

View file

@ -77,6 +77,11 @@ message ContactRecord {
UNVERIFIED = 2; UNVERIFIED = 2;
} }
message Name {
optional string given = 1;
optional string family = 2;
}
optional string aci = 1; optional string aci = 1;
optional string serviceE164 = 2; optional string serviceE164 = 2;
optional string pni = 15; optional string pni = 15;
@ -98,6 +103,8 @@ message ContactRecord {
optional string systemNickname = 19; optional string systemNickname = 19;
optional bool hidden = 20; optional bool hidden = 20;
optional bool pniSignatureVerified = 21; optional bool pniSignatureVerified = 21;
optional Name nickname = 22;
optional string note = 23;
} }
message GroupV1Record { message GroupV1Record {

View file

@ -97,12 +97,17 @@
&--about { &--about {
@include about-modal-icon('../images/icons/v3/edit/edit.svg'); @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; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
min-width: 0;
@include button-reset(); @include button-reset();
cursor: pointer; cursor: pointer;
@ -112,6 +117,7 @@
display: inline-block; display: inline-block;
height: 20px; height: 20px;
width: 20px; width: 20px;
flex-shrink: 0;
@include color-svg( @include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg', '../images/icons/v3/chevron/chevron-right-bold.svg',
@ -125,3 +131,13 @@
cursor: pointer; 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 { .ConversationDetails__CallHistoryGroup__ItemTimestamp {
flex-shrink: 0; 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; height: 280px;
} }
&--expandable { &--textarea {
margin-top: 4px; margin-top: 4px;
} }
@ -129,5 +129,9 @@
margin: 12px; margin: 12px;
} }
&--warn {
color: $color-accent-red;
}
} }
} }

View file

@ -87,6 +87,7 @@
@import './components/DisappearingTimerSelect.scss'; @import './components/DisappearingTimerSelect.scss';
@import './components/EditConversationAttributesModal.scss'; @import './components/EditConversationAttributesModal.scss';
@import './components/EditHistoryMessagesModal.scss'; @import './components/EditHistoryMessagesModal.scss';
@import './components/EditNicknameAndNoteModal.scss';
@import './components/EditUsernameModalBody.scss'; @import './components/EditUsernameModalBody.scss';
@import './components/ForwardMessageModal.scss'; @import './components/ForwardMessageModal.scss';
@import './components/GradientDial.scss'; @import './components/GradientDial.scss';

View file

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

View file

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

View file

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

View file

@ -11,10 +11,19 @@ import { Modal } from '../Modal';
import { UserText } from '../UserText'; import { UserText } from '../UserText';
import { SharedGroupNames } from '../SharedGroupNames'; import { SharedGroupNames } from '../SharedGroupNames';
import { About } from './About'; 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<{ export type PropsType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
onClose: () => void; onClose: () => void;
onOpenNotePreviewModal: () => void;
conversation: ConversationType; conversation: ConversationType;
isSignalConnection: boolean; isSignalConnection: boolean;
toggleSignalConnectionsModal: () => void; toggleSignalConnectionsModal: () => void;
@ -32,6 +41,7 @@ export function AboutContactModal({
updateSharedGroups, updateSharedGroups,
unblurAvatar, unblurAvatar,
onClose, onClose,
onOpenNotePreviewModal,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const { isMe } = conversation; const { isMe } = conversation;
@ -135,7 +145,26 @@ export function AboutContactModal({
<div className="AboutContactModal__row"> <div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--profile" /> <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> </div>
{!isMe && conversation.isVerified ? ( {!isMe && conversation.isVerified ? (
@ -166,7 +195,7 @@ export function AboutContactModal({
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--connections" /> <i className="AboutContactModal__row__icon AboutContactModal__row__icon--connections" />
<button <button
type="button" type="button"
className="AboutContactModal__signal-connection" className="AboutContactModal__button"
onClick={onSignalConnectionClick} onClick={onSignalConnectionClick}
> >
{i18n('icu:AboutContactModal__signal-connection')} {i18n('icu:AboutContactModal__signal-connection')}
@ -205,6 +234,21 @@ export function AboutContactModal({
</div> </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} {statusRow}
</Modal> </Modal>
); );

View file

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

View file

@ -9,16 +9,40 @@ import { SystemMessage } from './SystemMessage';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import type { ProfileNameChangeType } from '../../util/getStringForProfileChange'; import type { ProfileNameChangeType } from '../../util/getStringForProfileChange';
import { getStringForProfileChange } from '../../util/getStringForProfileChange'; import { getStringForProfileChange } from '../../util/getStringForProfileChange';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { areNicknamesEnabled } from '../../util/nicknames';
export type PropsType = { export type PropsType = {
change: ProfileNameChangeType; change: ProfileNameChangeType;
changedContact: ConversationType; changedContact: ConversationType;
i18n: LocalizerType; i18n: LocalizerType;
onOpenEditNicknameAndNoteModal: () => void;
}; };
export function ProfileChangeNotification(props: PropsType): JSX.Element { export function ProfileChangeNotification({
const { change, changedContact, i18n } = props; change,
changedContact,
i18n,
onOpenEditNicknameAndNoteModal,
}: PropsType): JSX.Element {
const message = getStringForProfileChange(change, changedContact, i18n); 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'), toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'), toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action( onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation' 'onOutgoingAudioCallInConversation'
), ),

View file

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

View file

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

View file

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

View file

@ -63,6 +63,8 @@ import {
} from '../../../types/CallDisposition'; } from '../../../types/CallDisposition';
import { formatDate, formatTime } from '../../../util/timestamp'; import { formatDate, formatTime } from '../../../util/timestamp';
import { NavTab } from '../../../state/ducks/nav'; import { NavTab } from '../../../state/ducks/nav';
import { ContextMenu } from '../../ContextMenu';
import { areNicknamesEnabled } from '../../../util/nicknames';
function describeCallHistory( function describeCallHistory(
i18n: LocalizerType, i18n: LocalizerType,
@ -86,11 +88,12 @@ function describeCallHistory(
} }
enum ModalState { enum ModalState {
NothingOpen, AddingGroupMembers,
ConfirmDeleteNicknameAndNote,
EditingGroupDescription, EditingGroupDescription,
EditingGroupTitle, EditingGroupTitle,
AddingGroupMembers,
MuteNotifications, MuteNotifications,
NothingOpen,
UnmuteNotifications, UnmuteNotifications,
} }
@ -140,6 +143,8 @@ type ActionProps = {
getProfilesForConversation: (id: string) => unknown; getProfilesForConversation: (id: string) => unknown;
leaveGroup: (conversationId: string) => void; leaveGroup: (conversationId: string) => void;
loadRecentMediaItems: (id: string, limit: number) => void; loadRecentMediaItems: (id: string, limit: number) => void;
onDeleteNicknameAndNote: () => void;
onOpenEditNicknameAndNoteModal: () => void;
onOutgoingAudioCallInConversation: (conversationId: string) => unknown; onOutgoingAudioCallInConversation: (conversationId: string) => unknown;
onOutgoingVideoCallInConversation: (conversationId: string) => unknown; onOutgoingVideoCallInConversation: (conversationId: string) => unknown;
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
@ -207,6 +212,8 @@ export function ConversationDetails({
memberships, memberships,
maxGroupSize, maxGroupSize,
maxRecommendedGroupSize, maxRecommendedGroupSize,
onDeleteNicknameAndNote,
onOpenEditNicknameAndNoteModal,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
pendingApprovalMemberships, pendingApprovalMemberships,
@ -344,6 +351,30 @@ export function ConversationDetails({
/> />
); );
break; 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: case ModalState.MuteNotifications:
modalNode = ( modalNode = (
<ConversationNotificationsModal <ConversationNotificationsModal
@ -375,6 +406,7 @@ export function ConversationDetails({
</ConfirmationDialog> </ConfirmationDialog>
); );
break; break;
default: default:
throw missingCaseError(modalState); throw missingCaseError(modalState);
} }
@ -534,6 +566,57 @@ export function ConversationDetails({
} }
/> />
) : null} ) : 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 && ( {selectedNavTab === NavTab.Chats && (
<PanelRow <PanelRow
icon={ icon={

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

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

View file

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

View file

@ -202,6 +202,18 @@ export async function toContactRecord(
if (profileFamilyName) { if (profileFamilyName) {
contactRecord.familyName = 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'); const systemGivenName = conversation.get('systemGivenName');
if (systemGivenName) { if (systemGivenName) {
contactRecord.systemGivenName = systemGivenName; contactRecord.systemGivenName = systemGivenName;
@ -1082,6 +1094,9 @@ export async function mergeContactRecord(
systemGivenName: dropNull(contactRecord.systemGivenName), systemGivenName: dropNull(contactRecord.systemGivenName),
systemFamilyName: dropNull(contactRecord.systemFamilyName), systemFamilyName: dropNull(contactRecord.systemFamilyName),
systemNickname: dropNull(contactRecord.systemNickname), 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 // 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; pni?: PniString;
e164?: string; e164?: string;
name?: string; name?: string;
nicknameGivenName?: string;
nicknameFamilyName?: string;
note?: string;
systemGivenName?: string; systemGivenName?: string;
systemFamilyName?: string; systemFamilyName?: string;
systemNickname?: string; systemNickname?: string;
@ -317,6 +320,7 @@ export type ConversationType = ReadonlyDeep<
sortedGroupMembers?: ReadonlyArray<ConversationType>; sortedGroupMembers?: ReadonlyArray<ConversationType>;
title: string; title: string;
titleNoDefault?: string; titleNoDefault?: string;
titleNoNickname?: string;
searchableTitle?: string; searchableTitle?: string;
unreadCount?: number; unreadCount?: number;
unreadMentionsCount?: number; unreadMentionsCount?: number;
@ -1158,6 +1162,7 @@ export const actions = {
updateConversationModelSharedGroups, updateConversationModelSharedGroups,
updateGroupAttributes, updateGroupAttributes,
updateLastMessage, updateLastMessage,
updateNicknameAndNote,
updateSharedGroups, updateSharedGroups,
verifyConversationsStoppingSend, 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( export function reducer(
state: Readonly<ConversationsStateType> = getEmptyState(), state: Readonly<ConversationsStateType> = getEmptyState(),
action: Readonly< action: Readonly<

View file

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

View file

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

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import React, { memo } from 'react'; import React, { memo, useCallback } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails'; import { ConversationDetails } from '../../components/conversation/conversation-details/ConversationDetails';
import { import {
@ -114,6 +114,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
setMuteExpiration, setMuteExpiration,
showConversation, showConversation,
updateGroupAttributes, updateGroupAttributes,
updateNicknameAndNote,
} = useConversationsActions(); } = useConversationsActions();
const { const {
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
@ -124,6 +125,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
showContactModal, showContactModal,
toggleAboutContactModal, toggleAboutContactModal,
toggleAddUserToAnotherGroupModal, toggleAddUserToAnotherGroupModal,
toggleEditNicknameAndNoteModal,
toggleSafetyNumberModal, toggleSafetyNumberModal,
} = useGlobalModalActions(); } = useGlobalModalActions();
const { showLightboxWithMedia } = useLightboxActions(); const { showLightboxWithMedia } = useLightboxActions();
@ -162,6 +164,14 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151); const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151);
const userAvatarData = conversation.avatars ?? []; const userAvatarData = conversation.avatars ?? [];
const handleDeleteNicknameAndNote = useCallback(() => {
updateNicknameAndNote(conversationId, { nickname: null, note: null });
}, [conversationId, updateNicknameAndNote]);
const handleOpenEditNicknameAndNoteModal = useCallback(() => {
toggleEditNicknameAndNoteModal({ conversationId });
}, [conversationId, toggleEditNicknameAndNoteModal]);
return ( return (
<ConversationDetails <ConversationDetails
acceptConversation={acceptConversation} acceptConversation={acceptConversation}
@ -187,6 +197,8 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({
maxGroupSize={maxGroupSize} maxGroupSize={maxGroupSize}
maxRecommendedGroupSize={maxRecommendedGroupSize} maxRecommendedGroupSize={maxRecommendedGroupSize}
memberships={memberships} memberships={memberships}
onDeleteNicknameAndNote={handleDeleteNicknameAndNote}
onOpenEditNicknameAndNoteModal={handleOpenEditNicknameAndNoteModal}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation} onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
pendingApprovalMemberships={pendingApprovalMemberships} 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 { SmartDeleteMessagesModal } from './DeleteMessagesModal';
import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsConfirmation'; import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsConfirmation';
import { getGlobalModalsState } from '../selectors/globalModals'; import { getGlobalModalsState } from '../selectors/globalModals';
import { SmartEditNicknameAndNoteModal } from './EditNicknameAndNoteModal';
import { SmartNotePreviewModal } from './NotePreviewModal';
function renderEditHistoryMessagesModal(): JSX.Element { function renderEditHistoryMessagesModal(): JSX.Element {
return <SmartEditHistoryMessagesModal />; return <SmartEditHistoryMessagesModal />;
} }
function renderEditNicknameAndNoteModal(): JSX.Element {
return <SmartEditNicknameAndNoteModal />;
}
function renderProfileEditor(): JSX.Element { function renderProfileEditor(): JSX.Element {
return <SmartProfileEditorModal />; return <SmartProfileEditorModal />;
} }
@ -53,6 +59,10 @@ function renderMessageRequestActionsConfirmation(): JSX.Element {
return <SmartMessageRequestActionsConfirmation />; return <SmartMessageRequestActionsConfirmation />;
} }
function renderNotePreviewModal(): JSX.Element {
return <SmartNotePreviewModal />;
}
function renderStoriesSettings(): JSX.Element { function renderStoriesSettings(): JSX.Element {
return <SmartStoriesSettingsModal />; return <SmartStoriesSettingsModal />;
} }
@ -84,10 +94,12 @@ export const SmartGlobalModalContainer = memo(
contactModalState, contactModalState,
deleteMessagesProps, deleteMessagesProps,
editHistoryMessages, editHistoryMessages,
editNicknameAndNoteModalProps,
errorModalProps, errorModalProps,
formattingWarningData, formattingWarningData,
forwardMessagesProps, forwardMessagesProps,
messageRequestActionsConfirmationProps, messageRequestActionsConfirmationProps,
notePreviewModalProps,
isAuthorizingArtCreator, isAuthorizingArtCreator,
isProfileEditorVisible, isProfileEditorVisible,
isShortcutGuideModalVisible, isShortcutGuideModalVisible,
@ -166,6 +178,7 @@ export const SmartGlobalModalContainer = memo(
} }
contactModalState={contactModalState} contactModalState={contactModalState}
editHistoryMessages={editHistoryMessages} editHistoryMessages={editHistoryMessages}
editNicknameAndNoteModalProps={editNicknameAndNoteModalProps}
errorModalProps={errorModalProps} errorModalProps={errorModalProps}
deleteMessagesProps={deleteMessagesProps} deleteMessagesProps={deleteMessagesProps}
formattingWarningData={formattingWarningData} formattingWarningData={formattingWarningData}
@ -173,6 +186,7 @@ export const SmartGlobalModalContainer = memo(
messageRequestActionsConfirmationProps={ messageRequestActionsConfirmationProps={
messageRequestActionsConfirmationProps messageRequestActionsConfirmationProps
} }
notePreviewModalProps={notePreviewModalProps}
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
hideUserNotFoundModal={hideUserNotFoundModal} hideUserNotFoundModal={hideUserNotFoundModal}
hideWhatsNewModal={hideWhatsNewModal} hideWhatsNewModal={hideWhatsNewModal}
@ -187,12 +201,14 @@ export const SmartGlobalModalContainer = memo(
renderAddUserToAnotherGroup={renderAddUserToAnotherGroup} renderAddUserToAnotherGroup={renderAddUserToAnotherGroup}
renderContactModal={renderContactModal} renderContactModal={renderContactModal}
renderEditHistoryMessagesModal={renderEditHistoryMessagesModal} renderEditHistoryMessagesModal={renderEditHistoryMessagesModal}
renderEditNicknameAndNoteModal={renderEditNicknameAndNoteModal}
renderErrorModal={renderErrorModal} renderErrorModal={renderErrorModal}
renderDeleteMessagesModal={renderDeleteMessagesModal} renderDeleteMessagesModal={renderDeleteMessagesModal}
renderForwardMessagesModal={renderForwardMessagesModal} renderForwardMessagesModal={renderForwardMessagesModal}
renderMessageRequestActionsConfirmation={ renderMessageRequestActionsConfirmation={
renderMessageRequestActionsConfirmation renderMessageRequestActionsConfirmation
} }
renderNotePreviewModal={renderNotePreviewModal}
renderProfileEditor={renderProfileEditor} renderProfileEditor={renderProfileEditor}
renderUsernameOnboarding={renderUsernameOnboarding} renderUsernameOnboarding={renderUsernameOnboarding}
renderSafetyNumber={renderSafetyNumber} 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, showEditHistoryModal,
toggleMessageRequestActionsConfirmation, toggleMessageRequestActionsConfirmation,
toggleDeleteMessagesModal, toggleDeleteMessagesModal,
toggleEditNicknameAndNoteModal,
toggleForwardMessagesModal, toggleForwardMessagesModal,
toggleSafetyNumberModal, toggleSafetyNumberModal,
} = useGlobalModalActions(); } = useGlobalModalActions();
@ -155,6 +156,10 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
returnToActiveCall, returnToActiveCall,
} = useCallingActions(); } = useCallingActions();
const onOpenEditNicknameAndNoteModal = useCallback(() => {
toggleEditNicknameAndNoteModal({ conversationId });
}, [conversationId, toggleEditNicknameAndNoteModal]);
const onOpenMessageRequestActionsConfirmation = useCallback( const onOpenMessageRequestActionsConfirmation = useCallback(
(state: MessageRequestState) => { (state: MessageRequestState) => {
toggleMessageRequestActionsConfirmation({ conversationId, state }); toggleMessageRequestActionsConfirmation({ conversationId, state });
@ -198,6 +203,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
reactToMessage={reactToMessage} reactToMessage={reactToMessage}
copyMessageText={copyMessageText} copyMessageText={copyMessageText}
onOpenEditNicknameAndNoteModal={onOpenEditNicknameAndNoteModal}
onOpenMessageRequestActionsConfirmation={ onOpenMessageRequestActionsConfirmation={
onOpenMessageRequestActionsConfirmation onOpenMessageRequestActionsConfirmation
} }

View file

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

View file

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

View file

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

View file

@ -2773,6 +2773,13 @@
"updated": "2024-01-11T16:58:57.146Z", "updated": "2024-01-11T16:58:57.146Z",
"reasonDetail": "Needs access to a hidden span element to get its width" "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", "rule": "React-useRef",
"path": "ts/components/AvatarTextEditor.tsx", "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';
}