diff --git a/_locales/en/messages.json b/_locales/en/messages.json index a7baaf22aa..f2d8340ed4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6180,6 +6180,22 @@ "messageformat": "Voice", "description": "Text for button to start a new voice call in the Contact Details modal" }, + "icu:GroupMemberLabelInfoModal--title": { + "messageformat": "Member labels", + "description": "Title of explainer dialog you see when clicking member label in Contact Modal" + }, + "icu:GroupMemberLabelInfoModal--description": { + "messageformat": "Use a member label to describe yourself or your role in this group. Member labels are only visible within this group.", + "description": "Detail text in explainer dialog you see when clicking member label in Contact Modal" + }, + "icu:GroupMemberLabelInfoModal--add-label": { + "messageformat": "Set a label", + "description": "Button to go to label screen in member label explainer dialog, if user has no label" + }, + "icu:GroupMemberLabelInfoModal--edit-label": { + "messageformat": "Edit your label", + "description": "Button to go to label screen in member label explainer dialog, if user already has a label" + }, "icu:showChatColorEditor": { "messageformat": "Chat color", "description": "This is a button in the conversation context menu to show the chat color editor" @@ -6212,9 +6228,17 @@ "messageformat": "Set a member label to describe yourself or your role in this group. Member labels are only visible within this group.", "description": "Summary explaining what a member label is and how it will be shown to other users. Shown below the text box to edit it." }, + "icu:ConversationDetails--member-label--preview": { + "messageformat": "Preview", + "description": "Header for preview area, showing what user's member label will look like on their sent messages" + }, + "icu:ConversationDetails--member-label--hello": { + "messageformat": "Hello!", + "description": "Text of message bubble showing preview of user's member label" + }, "icu:ConversationDetails--member-label--saving": { "messageformat": "Saving changes...", - "description": "Accessibility label for button with spinner as we save changes." + "description": "Accessibility label for button with spinner as we save the user's member label." }, "icu:ConversationDetails--disappearing-messages-label": { "messageformat": "Disappearing messages", diff --git a/images/tag_dark.svg b/images/tag_dark.svg new file mode 100644 index 0000000000..f6488c0b12 --- /dev/null +++ b/images/tag_dark.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/tag_light.svg b/images/tag_light.svg new file mode 100644 index 0000000000..28e8848a88 --- /dev/null +++ b/images/tag_light.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/stylesheets/components/ContactModal.scss b/stylesheets/components/ContactModal.scss index c804d8ceaf..38acaa6afc 100644 --- a/stylesheets/components/ContactModal.scss +++ b/stylesheets/components/ContactModal.scss @@ -68,8 +68,17 @@ } &__member-label { - margin-top: 6px; - margin-bottom: 4px; + @include mixins.button-reset; + @include mixins.button-focus-outline; + + & { + border: 1px solid transparent; + // Note: matches the border radius in ContactName.scss, for --label-pill + border-radius: 9px; + + margin-top: 6px; + margin-bottom: 4px; + } } &__info { diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx index 716f283e6c..108de4927a 100644 --- a/ts/components/GlobalModalContainer.dom.tsx +++ b/ts/components/GlobalModalContainer.dom.tsx @@ -9,6 +9,7 @@ import type { EditHistoryMessagesType, EditNicknameAndNoteModalPropsType, ForwardMessagesPropsType, + GroupMemberLabelInfoPropsType, MessageRequestActionsConfirmationPropsType, SafetyNumberChangedBlockingDataType, UserNotFoundModalStateType, @@ -101,6 +102,9 @@ export type PropsType = { // ForwardMessageModal forwardMessagesProps: ForwardMessagesPropsType | undefined; renderForwardMessagesModal: () => React.JSX.Element; + // GroupMemberLabelInfoModal + groupMemberLabelInfoModalState: GroupMemberLabelInfoPropsType | undefined; + renderGroupMemberLabelInfoModal: () => React.JSX.Element; // MediaPermissionsModal mediaPermissionsModalProps: | { @@ -226,6 +230,9 @@ export function GlobalModalContainer({ // ForwardMessageModal forwardMessagesProps, renderForwardMessagesModal, + // GroupMemberLabelInfoModal + groupMemberLabelInfoModalState, + renderGroupMemberLabelInfoModal, // MediaPermissionsModal mediaPermissionsModalProps, closeMediaPermissionsModal, @@ -441,6 +448,11 @@ export function GlobalModalContainer({ return renderAboutContactModal(); } + // This needs to be before the contact modal, which opens it + if (groupMemberLabelInfoModalState) { + return renderGroupMemberLabelInfoModal(); + } + if (contactModalState) { return renderContactModal(); } diff --git a/ts/components/GroupMemberLabelInfoModal.dom.stories.tsx b/ts/components/GroupMemberLabelInfoModal.dom.stories.tsx new file mode 100644 index 0000000000..4c377d16bd --- /dev/null +++ b/ts/components/GroupMemberLabelInfoModal.dom.stories.tsx @@ -0,0 +1,52 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import type { Meta } from '@storybook/react'; +import type { PropsType } from './GroupMemberLabelInfoModal.dom.js'; +import { GroupMemberLabelInfoModal } from './GroupMemberLabelInfoModal.dom.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/GroupMemberLabelInfoModal', +} satisfies Meta; + +const createProps = (): PropsType => ({ + canAddLabel: true, + hasLabel: false, + i18n, + isEditMemberLabelEnabled: true, + onClose: action('onClose'), + showEditMemberLabelScreen: action('showEditMemberLabelScreen'), +}); + +export function NoExistingLabel(): React.JSX.Element { + return ; +} + +export function ExistingLabel(): React.JSX.Element { + const props = { ...createProps(), hasLabel: true }; + + return ; +} + +export function CannotAddLabel(): React.JSX.Element { + const props = { + ...createProps(), + canAddLabel: false, + }; + + return ; +} + +export function CanAddLabelButFeatureDisabled(): React.JSX.Element { + const props = { + ...createProps(), + canAddLabel: false, + isEditMemberLabelEnabled: true, + }; + + return ; +} diff --git a/ts/components/GroupMemberLabelInfoModal.dom.tsx b/ts/components/GroupMemberLabelInfoModal.dom.tsx new file mode 100644 index 0000000000..309cf85fe0 --- /dev/null +++ b/ts/components/GroupMemberLabelInfoModal.dom.tsx @@ -0,0 +1,88 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { AxoDialog } from '../axo/AxoDialog.dom.js'; + +import type { LocalizerType } from '../types/Util.std.js'; +import { tw } from '../axo/tw.dom.js'; + +export type PropsType = { + canAddLabel: boolean; + hasLabel: boolean; + i18n: LocalizerType; + isEditMemberLabelEnabled: boolean; + onClose: () => unknown; + showEditMemberLabelScreen: () => unknown; +}; + +export function GroupMemberLabelInfoModal(props: PropsType): JSX.Element { + const { + canAddLabel, + hasLabel, + i18n, + isEditMemberLabelEnabled, + onClose, + showEditMemberLabelScreen, + } = props; + return ( + + + +
+ + +
+ +
+ {i18n('icu:GroupMemberLabelInfoModal--title')} +
+
+ +
+ {i18n('icu:GroupMemberLabelInfoModal--description')} +
+
+
+ + {isEditMemberLabelEnabled && canAddLabel && ( + { + showEditMemberLabelScreen(); + onClose(); + }} + > + {hasLabel + ? i18n('icu:GroupMemberLabelInfoModal--edit-label') + : i18n('icu:GroupMemberLabelInfoModal--add-label')} + + )} + { + onClose(); + }} + > + {i18n('icu:Confirmation--confirm')} + + +
+
+ ); +} diff --git a/ts/components/conversation/AboutContactModal.dom.tsx b/ts/components/conversation/AboutContactModal.dom.tsx index c299f87238..64f6a55c11 100644 --- a/ts/components/conversation/AboutContactModal.dom.tsx +++ b/ts/components/conversation/AboutContactModal.dom.tsx @@ -2,8 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { type ReactNode, useCallback, useMemo } from 'react'; -import type { ConversationType } from '../../state/ducks/conversations.preload.js'; -import type { LocalizerType } from '../../types/Util.std.js'; + import { isInSystemContacts } from '../../util/isInSystemContacts.std.js'; import { Avatar, AvatarBlur, AvatarSize } from '../Avatar.dom.js'; import { Modal } from '../Modal.dom.js'; @@ -20,7 +19,10 @@ import { isEmojiVariantValue, } from '../fun/data/emojis.std.js'; import { FunStaticEmoji } from '../fun/FunEmoji.dom.js'; -import { missingEmojiPlaceholder } from './ContactName.dom.js'; +import { missingEmojiPlaceholder } from '../../types/GroupMemberLabels.std.js'; + +import type { ConversationType } from '../../state/ducks/conversations.preload.js'; +import type { LocalizerType } from '../../types/Util.std.js'; function muted(parts: Array) { return ( diff --git a/ts/components/conversation/ContactModal.dom.stories.tsx b/ts/components/conversation/ContactModal.dom.stories.tsx index e1daea5ed5..e9069c2e87 100644 --- a/ts/components/conversation/ContactModal.dom.stories.tsx +++ b/ts/components/conversation/ContactModal.dom.stories.tsx @@ -60,6 +60,7 @@ export default { theme: ThemeType.light, toggleAboutContactModal: action('AboutContactModal'), toggleAdmin: action('toggleAdmin'), + toggleGroupMemberLabelInfoModal: action('toggleGroupMemberLabelInfoModal'), toggleSafetyNumberModal: action('toggleSafetyNumberModal'), viewUserStories: action('viewUserStories'), }, diff --git a/ts/components/conversation/ContactModal.dom.tsx b/ts/components/conversation/ContactModal.dom.tsx index ae534efe80..17d4302214 100644 --- a/ts/components/conversation/ContactModal.dom.tsx +++ b/ts/components/conversation/ContactModal.dom.tsx @@ -31,7 +31,10 @@ import { InAnotherCallTooltip, getTooltipContent, } from './InAnotherCallTooltip.dom.js'; -import type { ContactModalStateType } from '../../state/ducks/globalModals.preload.js'; +import type { + ContactModalStateType, + ToggleGroupMemberLabelInfoModalType, +} from '../../state/ducks/globalModals.preload.js'; import { GroupMemberLabel } from './ContactName.dom.js'; const log = createLogger('ContactModal'); @@ -63,11 +66,12 @@ type PropsActionType = { removeMemberFromGroup: (conversationId: string, contactId: string) => void; showConversation: ShowConversationType; startAvatarDownload: () => void; - toggleAdmin: (conversationId: string, contactId: string) => void; toggleAboutContactModal: (options: ContactModalStateType) => unknown; + toggleAdmin: (conversationId: string, contactId: string) => void; + toggleAddUserToAnotherGroupModal: (conversationId: string) => void; + toggleGroupMemberLabelInfoModal: ToggleGroupMemberLabelInfoModalType; togglePip: () => void; toggleSafetyNumberModal: (conversationId: string) => unknown; - toggleAddUserToAnotherGroupModal: (conversationId: string) => void; viewUserStories: ViewUserStoriesActionCreatorType; }; @@ -113,6 +117,7 @@ export function ContactModal({ toggleAboutContactModal, toggleAddUserToAnotherGroupModal, toggleAdmin, + toggleGroupMemberLabelInfoModal, togglePip, toggleSafetyNumberModal, viewUserStories, @@ -370,7 +375,17 @@ export function ContactModal({ {contactLabelString && contactNameColor && ( -
+
+ )} {!contact.isMe && renderQuickActions(contact.id)}
diff --git a/ts/components/conversation/ContactName.dom.tsx b/ts/components/conversation/ContactName.dom.tsx index cff427deff..9e66f7b50c 100644 --- a/ts/components/conversation/ContactName.dom.tsx +++ b/ts/components/conversation/ContactName.dom.tsx @@ -16,13 +16,12 @@ import { } from '../fun/data/emojis.std.js'; import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.js'; import { FunStaticEmoji } from '../fun/FunEmoji.dom.js'; +import { missingEmojiPlaceholder } from '../../types/GroupMemberLabels.std.js'; import type { ConversationType } from '../../state/ducks/conversations.preload.js'; import type { ContactNameColorType } from '../../types/Colors.std.js'; import type { FunStaticEmojiSize } from '../fun/FunEmoji.dom.js'; -export const missingEmojiPlaceholder = '⍰'; - export type ContactNameData = { contactNameColor?: ContactNameColorType; contactLabel?: { labelString: string; labelEmoji: string | undefined }; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx index 48a9e0ef48..af473f3081 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx @@ -74,6 +74,7 @@ const createProps = ( areWeASubscriber: false, blockConversation: action('blockConversation'), canEditGroupInfo: false, + canAddLabel: true, canAddNewMembers: false, conversation: expireTimer ? { diff --git a/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx index 14fdb9a746..8ca1c73a81 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx @@ -82,6 +82,7 @@ export type StateProps = { badges?: ReadonlyArray; callHistoryGroup?: CallHistoryGroup | null; canEditGroupInfo: boolean; + canAddLabel: boolean; canAddNewMembers: boolean; conversation?: ConversationType; hasGroupLink: boolean; @@ -124,7 +125,6 @@ type ActionProps = { } ) => unknown; blockConversation: (id: string) => void; - deleteAvatarFromDisk: DeleteAvatarFromDiskActionType; getProfilesForConversation: (id: string) => unknown; leaveGroup: (conversationId: string) => void; @@ -170,6 +170,7 @@ export function ConversationDetails({ blockConversation, callHistoryGroup, canEditGroupInfo, + canAddLabel, canAddNewMembers, conversation, deleteAvatarFromDisk, @@ -755,7 +756,7 @@ export function ConversationDetails({ right={hasGroupLink ? i18n('icu:on') : i18n('icu:off')} /> ) : null} - {canEditGroupInfo && isEditMemberLabelEnabled ? ( + {canAddLabel && isEditMemberLabelEnabled ? ( ; -const createProps = (conversation?: ConversationType): PropsType => ({ - conversation: conversation || getDefaultConversation({ type: 'group' }), +const createProps = (): PropsType => ({ + group: getDefaultConversation({ type: 'group' }), + me: getDefaultConversation({ type: 'direct' }), existingLabelEmoji: '🐘', existingLabelString: 'Good Memory', + getPreferredBadge: () => undefined, i18n, + ourColor: '160', popPanelForConversation: action('popPanelForConversation'), theme: ThemeType.light, updateGroupMemberLabel: action('changeHasGroupLink'), @@ -50,3 +53,12 @@ export function StringButNoEmoji(): React.JSX.Element { return ; } + +export function WithBadge(): React.JSX.Element { + const props = { + ...createProps(), + getPreferredBadge: () => getFakeBadge(), + }; + + return ; +} diff --git a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx index cb606a4c8a..8e52e951a0 100644 --- a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx +++ b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx @@ -1,6 +1,8 @@ // Copyright 2026 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState } from 'react'; + +import React, { useRef, useState } from 'react'; +import { noop } from 'lodash'; import { Input } from '../../Input.dom.js'; import { FunEmojiPicker } from '../../fun/FunEmojiPicker.dom.js'; @@ -11,24 +13,40 @@ import { } from '../../fun/data/emojis.std.js'; import { FunEmojiPickerButton } from '../../fun/FunButton.dom.js'; +import { tw } from '../../../axo/tw.dom.js'; +import { AxoButton } from '../../../axo/AxoButton.dom.js'; +import { + STRING_BYTE_LIMIT, + STRING_GRAPHEME_LIMIT, +} from '../../../types/GroupMemberLabels.std.js'; +import { + Message, + MessageInteractivity, + TextDirection, +} from '../Message.dom.js'; +import { ConversationColors } from '../../../types/Colors.std.js'; +import { WidthBreakpoint } from '../../_util.std.js'; + import type { EmojiVariantKey } from '../../fun/data/emojis.std.js'; import type { ConversationType, UpdateGroupMemberLabelType, } from '../../../state/ducks/conversations.preload.js'; import type { LocalizerType, ThemeType } from '../../../types/Util.std.js'; -import { tw } from '../../../axo/tw.dom.js'; -import { AxoButton } from '../../../axo/AxoButton.dom.js'; +import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.preload.js'; export type PropsDataType = { - conversation: ConversationType; existingLabelEmoji: string | undefined; existingLabelString: string | undefined; + group: ConversationType; i18n: LocalizerType; + me: ConversationType; + ourColor: string | undefined; theme: ThemeType; }; export type PropsType = PropsDataType & { + getPreferredBadge: PreferredBadgeSelectorType; popPanelForConversation: () => void; updateGroupMemberLabel: UpdateGroupMemberLabelType; }; @@ -42,14 +60,19 @@ function getEmojiVariantKey(value: string): EmojiVariantKey | undefined { } export function GroupMemberLabelEditor({ - conversation, + group, + me, existingLabelEmoji, existingLabelString, + getPreferredBadge, i18n, + ourColor, popPanelForConversation, theme, updateGroupMemberLabel, }: PropsType): React.JSX.Element { + const messageContainer = useRef(null); + const [labelEmoji, setLabelEmoji] = useState(existingLabelEmoji); const [labelString, setLabelString] = useState(existingLabelString); @@ -60,12 +83,17 @@ export function GroupMemberLabelEditor({ const isDirty = labelEmoji !== existingLabelEmoji || labelString !== existingLabelString; + const canSave = Boolean(isDirty && labelString); const spinner = isSaving ? { 'aria-label': i18n('icu:ConversationDetails--member-label--saving'), } : undefined; + const contactLabelForMessage = labelString?.trim() + ? { labelEmoji, labelString: labelString.trim() } + : undefined; + return (
@@ -89,14 +117,16 @@ export function GroupMemberLabelEditor({ } - maxLengthCount={24} - maxByteCount={96} + maxLengthCount={STRING_GRAPHEME_LIMIT} + maxByteCount={STRING_BYTE_LIMIT} moduleClassName="GroupMemberLabelEditor" onChange={value => { if (!value) { setLabelEmoji(undefined); } - setLabelString(value); + + // Remove trailing/leading whitespace, replace all whitespace with basic space + setLabelString(value.replace(/\s/g, ' ')); }} ref={undefined} placeholder={i18n( @@ -106,12 +136,86 @@ export function GroupMemberLabelEditor({ whenToShowRemainingCount={20} />
-
+
{i18n('icu:ConversationDetails--member-label--description')}
+
+ {i18n('icu:ConversationDetails--member-label--preview')} +
+
+
} + doubleCheckMissingQuoteReference={noop} + messageExpanded={noop} + checkForAccount={noop} + startConversation={noop} + showConversation={noop} + openGiftBadge={noop} + pushPanelForConversation={noop} + retryMessageSend={noop} + sendPollVote={noop} + endPoll={noop} + showContactModal={noop} + showSpoiler={noop} + cancelAttachmentDownload={noop} + kickOffAttachmentDownload={noop} + markAttachmentAsCorrupted={noop} + saveAttachment={noop} + saveAttachments={noop} + showLightbox={noop} + showLightboxForViewOnceMedia={noop} + scrollToQuotedMessage={noop} + showAttachmentDownloadStillInProgressToast={noop} + showExpiredIncomingTapToViewToast={noop} + showExpiredOutgoingTapToViewToast={noop} + showMediaNoLongerAvailableToast={noop} + showTapToViewNotAvailableModal={noop} + viewStory={noop} + onToggleSelect={noop} + onReplyToMessage={noop} + /> +
-
-
+
{ setIsSaving(true); updateGroupMemberLabel( { - conversationId: conversation.id, + conversationId: group.id, labelEmoji, - labelString, + labelString: labelString?.trim(), }, { onSuccess() { @@ -141,7 +245,7 @@ export function GroupMemberLabelEditor({ popPanelForConversation(); }, onFailure() { - // TODO: DESKTOP-9698 + // TODO: DESKTOP-9710 }, } ); diff --git a/ts/groups.preload.ts b/ts/groups.preload.ts index fba44eef9f..6db53cd304 100644 --- a/ts/groups.preload.ts +++ b/ts/groups.preload.ts @@ -127,6 +127,12 @@ import { isTrustedContact, } from './util/isConversationAccepted.preload.js'; import { itemStorage } from './textsecure/Storage.preload.js'; +import { + EMOJI_OUTGOING_BYTE_LIMIT, + SERVER_EMOJI_BYTE_LIMIT, + SERVER_STRING_BYTE_LIMIT, +} from './types/GroupMemberLabels.std.js'; +import { getConversationIdForLogging } from './util/idForLogging.preload.js'; const { compact, difference, flatten, fromPairs, isNumber, omit, values } = lodash; @@ -1280,11 +1286,10 @@ export function buildModifyMemberLabelChange({ labelString: string | undefined; }): Proto.GroupChange.Actions { const actions = new Proto.GroupChange.Actions(); + const logId = `buildModifyMemberLabelChange(${getConversationIdForLogging(group)})`; if (!group.secretParams) { - throw new Error( - 'buildModifyMemberLabelChange: group was missing secretParams!' - ); + throw new Error(`${logId}: group was missing secretParams!`); } const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); @@ -1293,16 +1298,38 @@ export function buildModifyMemberLabelChange({ const modifyLabel = new Proto.GroupChange.Actions.ModifyMemberLabelAction(); modifyLabel.userId = userIdCipherText; if (labelEmoji) { + const labelEmojiBytes = Bytes.fromString(labelEmoji); + + if (labelEmojiBytes.byteLength > EMOJI_OUTGOING_BYTE_LIMIT) { + throw new Error( + `${logId}: plaintext label emoji length (${labelEmojiBytes.byteLength}) is larger than limit (${EMOJI_OUTGOING_BYTE_LIMIT})!` + ); + } + modifyLabel.labelEmoji = encryptGroupBlob( clientZkGroupCipher, - Bytes.fromString(labelEmoji) + labelEmojiBytes ); + if (modifyLabel.labelEmoji.byteLength > SERVER_EMOJI_BYTE_LIMIT) { + throw new Error( + `${logId}: encrypted label emoji length (${modifyLabel.labelEmoji.length}) is larger than limit (${SERVER_EMOJI_BYTE_LIMIT})!` + ); + } } if (labelString) { modifyLabel.labelString = encryptGroupBlob( clientZkGroupCipher, Bytes.fromString(labelString) ); + if (modifyLabel.labelString.byteLength > SERVER_STRING_BYTE_LIMIT) { + throw new Error( + `${logId} encrypted label string length (${modifyLabel.labelString.length}) is larger than limit (${SERVER_STRING_BYTE_LIMIT})!` + ); + } + } + + if (modifyLabel.labelEmoji && !modifyLabel.labelString) { + throw new Error(`${logId} labelEmoji was provided, but not labelString!`); } actions.version = (group.revision || 0) + 1; diff --git a/ts/services/backups/export.preload.ts b/ts/services/backups/export.preload.ts index d1efa9587c..bf6ca2c964 100644 --- a/ts/services/backups/export.preload.ts +++ b/ts/services/backups/export.preload.ts @@ -1340,9 +1340,11 @@ export class BackupExportStream extends Readable { version: convo.revision || 0, members: convo.membersV2?.map(member => { return { - userId: this.#aciToBytes(member.aci), - role: member.role, joinedAtVersion: member.joinedAtVersion, + labelEmoji: member.labelEmoji, + labelString: member.labelString, + role: member.role, + userId: this.#aciToBytes(member.aci), }; }), membersPendingProfileKey: convo.pendingMembersV2?.map(member => { diff --git a/ts/services/backups/import.preload.ts b/ts/services/backups/import.preload.ts index 6b5e787bf7..3036158dba 100644 --- a/ts/services/backups/import.preload.ts +++ b/ts/services/backups/import.preload.ts @@ -1241,18 +1241,22 @@ export class BackupImportStream extends Writable { SignalService.AccessControl.AccessRequired.UNKNOWN, } : undefined, - membersV2: members?.map(({ userId, role, joinedAtVersion }) => { - strictAssert(Bytes.isNotEmpty(userId), 'Empty gv2 member userId'); + membersV2: members?.map( + ({ joinedAtVersion, labelEmoji, labelString, role, userId }) => { + strictAssert(Bytes.isNotEmpty(userId), 'Empty gv2 member userId'); - // Note that we deliberately ignore profile key since it has to be - // in the Contact frame + // Note that we deliberately ignore profile key since it has to be + // in the Contact frame - return { - aci: fromAciObject(Aci.fromUuidBytes(userId)), - role: dropNull(role) ?? SignalService.Member.Role.UNKNOWN, - joinedAtVersion: dropNull(joinedAtVersion) ?? 0, - }; - }), + return { + aci: fromAciObject(Aci.fromUuidBytes(userId)), + joinedAtVersion: dropNull(joinedAtVersion) ?? 0, + labelEmoji: dropNull(labelEmoji), + labelString: dropNull(labelString), + role: dropNull(role) ?? SignalService.Member.Role.UNKNOWN, + }; + } + ), pendingMembersV2: membersPendingProfileKey?.map( ({ member, addedByUserId, timestamp }) => { strictAssert(member != null, 'Missing gv2 pending member'); diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index c0611f2d74..323f654ece 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -325,6 +325,13 @@ export type ConversationRemovalStage = ReadonlyDeep< 'justNotification' | 'messageRequest' >; +export type MembershipType = ReadonlyDeep<{ + aci: AciString; + isAdmin: boolean; + labelEmoji: string | undefined; + labelString: string | undefined; +}>; + export type ConversationType = ReadonlyDeep< { id: string; @@ -393,12 +400,7 @@ export type ConversationType = ReadonlyDeep< announcementsOnly?: boolean; announcementsOnlyReady?: boolean; expireTimer?: DurationInSeconds; - memberships?: ReadonlyArray<{ - aci: AciString; - isAdmin: boolean; - labelEmoji: string | undefined; - labelString: string | undefined; - }>; + memberships?: ReadonlyArray; pendingMemberships?: ReadonlyArray<{ serviceId: ServiceIdString; addedByUserId?: AciString; @@ -4640,9 +4642,8 @@ function addMembersToGroup( } // eslint-disable-next-line @typescript-eslint/no-explicit-any -type ActionCreator) => any> = ReadonlyDeep< - (...params: Parameters) => void ->; +export type ActionCreator) => any> = + ReadonlyDeep<(...params: Parameters) => void>; export type UpdateGroupAttributesType = ReadonlyDeep< ActionCreator diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts index e13b565f33..df9b077ecd 100644 --- a/ts/state/ducks/globalModals.preload.ts +++ b/ts/state/ducks/globalModals.preload.ts @@ -11,6 +11,7 @@ import type { ReadonlyMessageAttributesType, } from '../../model-types.d.ts'; import type { + ActionCreator, MessageChangedActionType, MessageDeletedActionType, MessageExpiredActionType, @@ -114,6 +115,10 @@ export type CallQualitySurveyPropsType = ReadonlyDeep<{ callType: CallQualitySurvey.CallType; }>; +export type GroupMemberLabelInfoPropsType = ReadonlyDeep<{ + conversationId: string; +}>; + export type GlobalModalsStateType = ReadonlyDeep<{ addUserToAnotherGroupModalContactId?: string; aboutContactModalState?: ContactModalStateType; @@ -139,6 +144,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{ }; forwardMessagesProps?: ForwardMessagesPropsType; gv2MigrationProps?: MigrateToGV2PropsType; + groupMemberLabelInfoModalState?: GroupMemberLabelInfoPropsType; hasConfirmationModal: boolean; isProfileNameWarningModalVisible: boolean; profileNameWarningModalConversationType?: string; @@ -227,6 +233,8 @@ const CLOSE_DEBUG_LOG_ERROR_MODAL = 'globalModals/CLOSE_DEBUG_LOG_ERROR_MODAL'; const SHOW_DEBUG_LOG_ERROR_MODAL = 'globalModals/SHOW_DEBUG_LOG_ERROR_MODAL'; const TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL = 'globalModals/TOGGLE_EDIT_NICKNAME_AND_NOTE_MODAL'; +const TOGGLE_GROUP_MEMBER_LABEL_INFO_MODAL = + 'globalModals/TOGGLE_GROUP_MEMBER_LABEL_INFO_MODAL'; const TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION = 'globalModals/TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION'; const CLOSE_SHORTCUT_GUIDE_MODAL = 'globalModals/CLOSE_SHORTCUT_GUIDE_MODAL'; @@ -509,6 +517,11 @@ type ToggleEditNicknameAndNoteModalActionType = ReadonlyDeep<{ payload: EditNicknameAndNoteModalPropsType | null; }>; +type ToggleGroupMemberLabelInfoModalActionType = ReadonlyDeep<{ + type: typeof TOGGLE_GROUP_MEMBER_LABEL_INFO_MODAL; + payload: GroupMemberLabelInfoPropsType | undefined; +}>; + type ToggleMessageRequestActionsConfirmationActionType = ReadonlyDeep<{ type: typeof TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION; payload: MessageRequestActionsConfirmationPropsType | null; @@ -591,6 +604,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | ToggleDraftGifMessageSendModalActionType | ToggleEditNicknameAndNoteModalActionType | ToggleForwardMessagesModalActionType + | ToggleGroupMemberLabelInfoModalActionType | ToggleMessageRequestActionsConfirmationActionType | ToggleNotePreviewModalActionType | ToggleProfileNameWarningModalActionType @@ -654,6 +668,7 @@ export const actions = { toggleDraftGifMessageSendModal, toggleEditNicknameAndNoteModal, toggleForwardMessagesModal, + toggleGroupMemberLabelInfoModal, toggleMessageRequestActionsConfirmation, toggleNotePreviewModal, toggleProfileNameWarningModal, @@ -1349,6 +1364,18 @@ function toggleEditNicknameAndNoteModal( }; } +export type ToggleGroupMemberLabelInfoModalType = ReadonlyDeep< + ActionCreator +>; +function toggleGroupMemberLabelInfoModal( + payload: GroupMemberLabelInfoPropsType | undefined +): ToggleGroupMemberLabelInfoModalActionType { + return { + type: TOGGLE_GROUP_MEMBER_LABEL_INFO_MODAL, + payload, + }; +} + function toggleMessageRequestActionsConfirmation( payload: { conversationId: string; @@ -1818,6 +1845,13 @@ export function reducer( }; } + if (action.type === TOGGLE_GROUP_MEMBER_LABEL_INFO_MODAL) { + return { + ...state, + groupMemberLabelInfoModalState: action.payload, + }; + } + if (action.type === TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION) { return { ...state, diff --git a/ts/state/selectors/globalModals.std.ts b/ts/state/selectors/globalModals.std.ts index c380d99dfa..68f53d41b1 100644 --- a/ts/state/selectors/globalModals.std.ts +++ b/ts/state/selectors/globalModals.std.ts @@ -55,6 +55,11 @@ export const getAboutContactModalState = createSelector( ({ aboutContactModalState }) => aboutContactModalState ); +export const getGroupMemberLabelInfoModalState = createSelector( + getGlobalModalsState, + ({ groupMemberLabelInfoModalState }) => groupMemberLabelInfoModalState +); + export const getContactModalState = createSelector( getGlobalModalsState, ({ contactModalState }) => contactModalState diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 14e72b5f9e..c80b67ae71 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -905,7 +905,6 @@ export const getPropsForMessage = ( }); const contactNameColor = getContactNameColor(contactNameColors, authorId); const sourceServiceId = getSourceServiceId(message, ourAci); - // TODO: DESKTOP-9698 const sourceMember = conversation.memberships?.find( membership => membership.aci === sourceServiceId ); diff --git a/ts/state/smart/AboutContactModal.preload.tsx b/ts/state/smart/AboutContactModal.preload.tsx index dda7892456..93dd8f38f0 100644 --- a/ts/state/smart/AboutContactModal.preload.tsx +++ b/ts/state/smart/AboutContactModal.preload.tsx @@ -18,9 +18,12 @@ import { useConversationsActions } from '../ducks/conversations.preload.js'; import { useGlobalModalActions } from '../ducks/globalModals.preload.js'; import { strictAssert } from '../../util/assert.std.js'; import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation.preload.js'; -import { SignalService as Proto } from '../../protobuf/index.std.js'; import { getItems } from '../selectors/items.dom.js'; import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; +import { getCanAddLabel } from '../../types/GroupMemberLabels.std.js'; +import { createLogger } from '../../logging/log.std.js'; + +const log = createLogger('SmartAboutContactModal'); function isFromOrAddedByTrustedContact( conversation: ConversationType @@ -54,6 +57,10 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() { remoteConfig: items.remoteConfig, prodKey: 'desktop.groupMemberLabels.edit.prod', }); + // TODO: DESKTOP-9711 + log.info( + `Not using feature flag of ${isEditMemberLabelEnabled}; hardcoding to false` + ); const sharedGroupNames = useSharedGroupNamesOnMount(contactId ?? ''); @@ -67,17 +74,12 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() { ); const memberColors = getMemberColors(conversationId); const contactNameColor = memberColors?.get(contact.id); - // TODO: DESKTOP-9698 const contactMembership = conversation.memberships?.find( membership => contact.serviceId && membership.aci === contact.serviceId ); const { labelEmoji: contactLabelEmoji, labelString: contactLabelString } = contactMembership || {}; - const canAddLabel = - conversation.type === 'group' && - (contactMembership?.isAdmin || - conversation.accessControlAttributes === - Proto.AccessControl.AccessRequired.MEMBER); + const canAddLabel = getCanAddLabel(conversation, contactMembership); const { toggleAboutContactModal, @@ -105,7 +107,7 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() { contactLabelString={contactLabelString} contactNameColor={contactNameColor} fromOrAddedByTrustedContact={isFromOrAddedByTrustedContact(contact)} - isEditMemberLabelEnabled={isEditMemberLabelEnabled} + isEditMemberLabelEnabled={false} isSignalConnection={isSignalConnection(contact)} onClose={toggleAboutContactModal} onOpenNotePreviewModal={handleOpenNotePreviewModal} diff --git a/ts/state/smart/ContactModal.preload.tsx b/ts/state/smart/ContactModal.preload.tsx index fafd15d71e..44e084631c 100644 --- a/ts/state/smart/ContactModal.preload.tsx +++ b/ts/state/smart/ContactModal.preload.tsx @@ -43,7 +43,6 @@ export const SmartContactModal = memo(function SmartContactModal() { const areWeAdmin = conversation?.areWeAdmin ?? false; const contactMembership = useMemo(() => { - // TODO: DESKTOP-9698 return conversation?.memberships?.find(membership => { return membership.aci === contact.serviceId; }); @@ -68,11 +67,12 @@ export const SmartContactModal = memo(function SmartContactModal() { } = useConversationsActions(); const { viewUserStories } = useStoriesActions(); const { + hideContactModal, toggleAboutContactModal, toggleAddUserToAnotherGroupModal, - toggleSafetyNumberModal, - hideContactModal, toggleEditNicknameAndNoteModal, + toggleGroupMemberLabelInfoModal, + toggleSafetyNumberModal, } = useGlobalModalActions(); const { onOutgoingVideoCallInConversation, @@ -113,6 +113,7 @@ export const SmartContactModal = memo(function SmartContactModal() { toggleAboutContactModal={toggleAboutContactModal} toggleAddUserToAnotherGroupModal={toggleAddUserToAnotherGroupModal} toggleAdmin={toggleAdmin} + toggleGroupMemberLabelInfoModal={toggleGroupMemberLabelInfoModal} togglePip={togglePip} toggleSafetyNumberModal={toggleSafetyNumberModal} viewUserStories={viewUserStories} diff --git a/ts/state/smart/ConversationDetails.preload.tsx b/ts/state/smart/ConversationDetails.preload.tsx index e14b6ade16..53a40b9ed6 100644 --- a/ts/state/smart/ConversationDetails.preload.tsx +++ b/ts/state/smart/ConversationDetails.preload.tsx @@ -32,7 +32,12 @@ import { getItems, } from '../selectors/items.dom.js'; import { getSelectedNavTab } from '../selectors/nav.preload.js'; -import { getIntl, getTheme, getVersion } from '../selectors/user.std.js'; +import { + getIntl, + getTheme, + getUserACI, + getVersion, +} from '../selectors/user.std.js'; import type { SmartChooseGroupMembersModalPropsType } from './ChooseGroupMembersModal.preload.js'; import { SmartChooseGroupMembersModal } from './ChooseGroupMembersModal.preload.js'; import type { SmartConfirmAdditionsModalPropsType } from './ConfirmAdditionsModal.dom.js'; @@ -46,6 +51,7 @@ import { isSignalConversation } from '../../util/isSignalConversation.dom.js'; import { drop } from '../../util/drop.std.js'; import { DataReader } from '../../sql/Client.preload.js'; import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; +import { getCanAddLabel } from '../../types/GroupMemberLabels.std.js'; const { sortBy } = lodash; @@ -95,6 +101,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ }: SmartConversationDetailsProps) { const i18n = useSelector(getIntl); const theme = useSelector(getTheme); + const ourAci = useSelector(getUserACI); const activeCall = useSelector(getActiveCallState); const version = useSelector(getVersion); const items = useSelector(getItems); @@ -186,6 +193,11 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ const userAvatarData = conversation.avatars ?? []; const memberColors = getCachedConversationMemberColors(conversationId); + const ourMembership = conversation.memberships?.find( + membership => membership?.aci === ourAci + ); + const canAddLabel = getCanAddLabel(conversation, ourMembership); + const handleDeleteNicknameAndNote = useCallback(() => { updateNicknameAndNote(conversationId, { nickname: null, note: null }); }, [conversationId, updateNicknameAndNote]); @@ -222,6 +234,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ badges={badges} blockConversation={blockConversation} callHistoryGroup={callHistoryGroup} + canAddLabel={canAddLabel} canAddNewMembers={canAddNewMembers} canEditGroupInfo={canEditGroupInfo} conversation={conversationWithColorAttributes} diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx index e0c6c17d65..9c21ebb7b6 100644 --- a/ts/state/smart/GlobalModalContainer.preload.tsx +++ b/ts/state/smart/GlobalModalContainer.preload.tsx @@ -41,6 +41,7 @@ import { shouldShowLocalBackupWorkflow, } from '../selectors/backups.std.js'; import { SmartPinMessageDialog } from './PinMessageDialog.preload.js'; +import { SmartGroupMemberLabelInfoModal } from './GroupMemberLabelInfoModal.preload.js'; function renderCallLinkAddNameModal(): React.JSX.Element { return ; @@ -94,6 +95,10 @@ function renderForwardMessagesModal(): React.JSX.Element { return ; } +function renderGroupMemberLabelInfoModal(): React.JSX.Element { + return ; +} + function renderKeyTransparencyErrorDialog(): React.JSX.Element { return ; } @@ -166,6 +171,7 @@ export const SmartGlobalModalContainer = memo( editNicknameAndNoteModalProps, errorModalProps, forwardMessagesProps, + groupMemberLabelInfoModalState, lowDiskSpaceBackupImportModal, mediaPermissionsModalProps, messageRequestActionsConfirmationProps, @@ -283,6 +289,7 @@ export const SmartGlobalModalContainer = memo( deleteMessagesProps={deleteMessagesProps} draftGifMessageSendModalProps={draftGifMessageSendModalProps} forwardMessagesProps={forwardMessagesProps} + groupMemberLabelInfoModalState={groupMemberLabelInfoModalState} hideCriticalIdlePrimaryDeviceModal={hideCriticalIdlePrimaryDeviceModal} hideLowDiskSpaceBackupImportModal={hideLowDiskSpaceBackupImportModal} lowDiskSpaceBackupImportModal={lowDiskSpaceBackupImportModal} @@ -328,6 +335,7 @@ export const SmartGlobalModalContainer = memo( renderDeleteMessagesModal={renderDeleteMessagesModal} renderDraftGifMessageSendModal={renderDraftGifMessageSendModal} renderForwardMessagesModal={renderForwardMessagesModal} + renderGroupMemberLabelInfoModal={renderGroupMemberLabelInfoModal} renderKeyTransparencyErrorDialog={renderKeyTransparencyErrorDialog} renderMessageRequestActionsConfirmation={ renderMessageRequestActionsConfirmation diff --git a/ts/state/smart/GroupMemberLabelInfoModal.preload.tsx b/ts/state/smart/GroupMemberLabelInfoModal.preload.tsx new file mode 100644 index 0000000000..945e70dfbb --- /dev/null +++ b/ts/state/smart/GroupMemberLabelInfoModal.preload.tsx @@ -0,0 +1,63 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { memo } from 'react'; +import { useSelector } from 'react-redux'; + +import { GroupMemberLabelInfoModal } from '../../components/GroupMemberLabelInfoModal.dom.js'; +import { getIntl, getUser, getVersion } from '../selectors/user.std.js'; +import { getGroupMemberLabelInfoModalState } from '../selectors/globalModals.std.js'; +import { getConversationSelector } from '../selectors/conversations.dom.js'; +import { useGlobalModalActions } from '../ducks/globalModals.preload.js'; +import { getItems } from '../selectors/items.dom.js'; +import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; +import { getCanAddLabel } from '../../types/GroupMemberLabels.std.js'; +import { createLogger } from '../../logging/log.std.js'; + +const log = createLogger('SmartGroupMemberLabelInfoModal'); + +export const SmartGroupMemberLabelInfoModal = memo( + function SmartGroupMemberLabelInfoModal() { + const i18n = useSelector(getIntl); + const user = useSelector(getUser); + const version = useSelector(getVersion); + const items = useSelector(getItems); + const { conversationId } = + useSelector(getGroupMemberLabelInfoModalState) ?? {}; + const getConversation = useSelector(getConversationSelector); + + const isEditMemberLabelEnabled = isFeaturedEnabledSelector({ + betaKey: 'desktop.groupMemberLabels.edit.beta', + currentVersion: version, + remoteConfig: items.remoteConfig, + prodKey: 'desktop.groupMemberLabels.edit.prod', + }); + // TODO: DESKTOP-9711 + log.info( + `Not using feature flag of ${isEditMemberLabelEnabled}; hardcoding to false` + ); + + const conversation = getConversation(conversationId); + + const contactMembership = conversation.memberships?.find( + membership => user.ourAci && membership.aci === user.ourAci + ); + const hasLabel = Boolean(contactMembership?.labelString); + const canAddLabel = getCanAddLabel(conversation, contactMembership); + + const { toggleGroupMemberLabelInfoModal } = useGlobalModalActions(); + + return ( + toggleGroupMemberLabelInfoModal(undefined)} + showEditMemberLabelScreen={() => { + // TODO: DESKTOP-9711 + throw new Error('Not yet implemented'); + }} + /> + ); + } +); diff --git a/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx b/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx index 20f550956a..eef34eb3b9 100644 --- a/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx +++ b/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx @@ -1,12 +1,18 @@ // Copyright 2026 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only + import React, { memo } from 'react'; import { useSelector } from 'react-redux'; + import { GroupMemberLabelEditor } from '../../components/conversation/conversation-details/GroupMemberLabelEditor.dom.js'; -import { getConversationSelector } from '../selectors/conversations.dom.js'; +import { + getCachedConversationMemberColorsSelector, + getConversationSelector, +} from '../selectors/conversations.dom.js'; import { getIntl, getTheme, getUser } from '../selectors/user.std.js'; import { useConversationsActions } from '../ducks/conversations.preload.js'; import { createLogger } from '../../logging/log.std.js'; +import { getPreferredBadgeSelector } from '../selectors/badges.preload.js'; const log = createLogger('SmartGroupMemberLabelEditor'); @@ -24,11 +30,18 @@ export const SmartGroupMemberLabelEditor = memo( const conversationSelector = useSelector(getConversationSelector); const conversation = conversationSelector(conversationId); + const me = conversationSelector(user.ourAci); + const { updateGroupMemberLabel, popPanelForConversation } = useConversationsActions(); + const getMemberColors = useSelector( + getCachedConversationMemberColorsSelector + ); + const memberColors = getMemberColors(conversationId); + const ourColor = memberColors?.get(me.id); + const getPreferredBadge = useSelector(getPreferredBadgeSelector); const { ourAci } = user; - // TODO: DESKTOP-9698 const ourMembership = conversation.memberships?.find( membership => membership?.aci === ourAci ); @@ -42,10 +55,13 @@ export const SmartGroupMemberLabelEditor = memo( return ( (null);", + "reasonCategory": "usageTrusted", + "updated": "2026-02-03T20:48:35.470Z", + "reasonDetail": "A container passed to " + }, { "rule": "React-useRef", "path": "ts/components/conversation/media-gallery/MediaGallery.dom.tsx", @@ -2386,12 +2401,5 @@ "line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');", "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" - }, - { - "rule": "React-useRef", - "path": "ts/components/conversation/MessageContextMenu.dom.tsx", - "line": " const shouldReturnFocusToTrigger = useRef(true);", - "reasonCategory": "usageTrusted", - "updated": "2025-12-19T16:03:53.849Z" } ] diff --git a/ts/util/truncateString.std.ts b/ts/util/truncateString.std.ts new file mode 100644 index 0000000000..9db81209d9 --- /dev/null +++ b/ts/util/truncateString.std.ts @@ -0,0 +1,25 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { count, truncateAndSize } from './grapheme.std.js'; +import { isBodyTooLong, trimBody } from './longAttachment.std.js'; + +export function truncateString( + target: string, + { + byteLimit, + graphemeLimit, + }: { byteLimit?: number; graphemeLimit?: number } = {} +): string { + let result = target; + + if (byteLimit && isBodyTooLong(result, byteLimit)) { + result = trimBody(result, byteLimit); + } + + if (graphemeLimit && count(result) > graphemeLimit) { + [result] = truncateAndSize(result, graphemeLimit); + } + + return result; +}