From 09c71ad35628828bf3e51134e7256a79b99bc378 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 3 Feb 2026 04:06:25 +1000 Subject: [PATCH] Support for Group Member Labels --- _locales/en/messages.json | 20 ++ images/icons/v3/tag/tag.svg | 1 + protos/Backups.proto | 8 +- protos/Groups.proto | 27 +- stylesheets/_mixins.scss | 4 + stylesheets/_modules.scss | 25 +- stylesheets/components/AboutContactModal.scss | 10 + stylesheets/components/ContactModal.scss | 5 + stylesheets/components/ContactName.scss | 50 ++++ .../components/ConversationDetails.scss | 11 + stylesheets/components/fun/FunEmoji.scss | 7 + ts/RemoteConfig.dom.ts | 2 + .../AnnouncementsOnlyGroupBanner.dom.tsx | 84 +++++-- .../CallLinkPendingParticipantModal.dom.tsx | 5 +- ts/components/CompositionArea.dom.stories.tsx | 39 ++- ts/components/CompositionArea.dom.tsx | 10 +- .../PreferencesNotificationProfiles.dom.tsx | 2 +- .../AboutContactModal.dom.stories.tsx | 94 +++++-- .../conversation/AboutContactModal.dom.tsx | 156 +++++++----- .../conversation/ContactModal.dom.stories.tsx | 34 ++- .../conversation/ContactModal.dom.tsx | 25 +- .../conversation/ContactName.dom.stories.tsx | 54 ++++ .../conversation/ContactName.dom.tsx | 109 +++++++- .../ConversationHero.dom.stories.tsx | 2 + .../conversation/ConversationHero.dom.tsx | 5 +- ts/components/conversation/Message.dom.tsx | 6 +- .../TimelineMessage.dom.stories.tsx | 141 ++++++++++- .../ConversationDetails.dom.stories.tsx | 213 +++++++++------- .../ConversationDetails.dom.tsx | 38 ++- .../ConversationDetailsHeader.dom.tsx | 5 +- .../ConversationDetailsIcon.dom.tsx | 1 + ...ationDetailsMembershipList.dom.stories.tsx | 68 +++-- .../ConversationDetailsMembershipList.dom.tsx | 67 +++-- .../GroupMemberLabelEditor.dom.stories.tsx | 52 ++++ .../GroupMemberLabelEditor.dom.tsx | 155 ++++++++++++ ts/components/fun/FunEmoji.dom.tsx | 2 + ts/groups.preload.ts | 233 +++++++++++++++++- ts/model-types.d.ts | 2 + ts/models/conversations.preload.ts | 29 +++ ts/state/ducks/conversations.preload.ts | 52 ++++ ts/state/ducks/globalModals.preload.ts | 12 +- ts/state/selectors/conversations.dom.ts | 15 +- ts/state/selectors/globalModals.std.ts | 5 + ts/state/selectors/message.preload.ts | 12 + ts/state/smart/AboutContactModal.preload.tsx | 71 ++++-- ts/state/smart/CompositionArea.preload.tsx | 10 +- ts/state/smart/ContactModal.preload.tsx | 22 +- .../smart/ConversationDetails.preload.tsx | 20 +- ts/state/smart/ConversationPanel.preload.tsx | 6 + ts/state/smart/GV1Members.preload.tsx | 6 + .../smart/GlobalModalContainer.preload.tsx | 4 +- ts/state/smart/SafetyNumberViewer.preload.tsx | 4 +- .../SmartGroupMemberLabelEditor.preload.tsx | 55 +++++ ts/test-helpers/getDefaultConversation.std.ts | 2 + .../isConversationTooBigToRing_test.dom.ts | 7 +- .../util/getGroupMemberships_test.dom.ts | 14 ++ ts/types/Panels.std.ts | 3 + .../getConversationTitleForPanelType.std.ts | 4 + ts/util/getGroupMemberships.dom.ts | 10 +- ts/util/groupMembershipUtils.preload.ts | 4 + 60 files changed, 1794 insertions(+), 345 deletions(-) create mode 100644 images/icons/v3/tag/tag.svg create mode 100644 ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.stories.tsx create mode 100644 ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx create mode 100644 ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3e4002e044..fbfeaeed03 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1486,6 +1486,10 @@ "messageformat": "{name} is in your system contacts", "description": "Text of a row in the About modal describing that the contact is in system contacts" }, + "icu:AboutContactModal__add-member-label": { + "messageformat": "Add a member label", + "description": "Text for a row on the about contact modal, allowing user to add a label for themselves in the current group" + }, "icu:NotePreviewModal__Title": { "messageformat": "Note", "description": "Title of Note Preview modal" @@ -6184,6 +6188,22 @@ "messageformat": "Group link", "description": "This is the label for the group link management panel" }, + "icu:ConversationDetails--member-label": { + "messageformat": "Member label", + "description": "This is the label describing the member label editor screen" + }, + "icu:ConversationDetails--member-label--placeholder": { + "messageformat": "Enter your member label", + "description": "Shown on the member label editor screen as a placeholder in the text box" + }, + "icu:ConversationDetails--member-label--description": { + "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--saving": { + "messageformat": "Saving changes...", + "description": "Accessibility label for button with spinner as we save changes." + }, "icu:ConversationDetails--disappearing-messages-label": { "messageformat": "Disappearing messages", "description": "This is the label for the disappearing messages setting panel" diff --git a/images/icons/v3/tag/tag.svg b/images/icons/v3/tag/tag.svg new file mode 100644 index 0000000000..384a9daaeb --- /dev/null +++ b/images/icons/v3/tag/tag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/protos/Backups.proto b/protos/Backups.proto index c8ee0ffbd6..324000d525 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -325,11 +325,13 @@ message Group { ADMINISTRATOR = 2; } - bytes userId = 1; + bytes user_id = 1; Role role = 2; reserved /*profileKey*/ 3; // This field is ignored in Backups, in favor of Contact frames for members reserved /*presentation*/ 4; // This field is deprecated in the context of static group state uint32 joinedAtVersion = 5; + string label_emoji = 6; + string label_string = 7; } message MemberPendingProfileKey { @@ -339,14 +341,14 @@ message Group { } message MemberPendingAdminApproval { - bytes userId = 1; + bytes user_id = 1; reserved /*profileKey*/ 2; // This field is ignored in Backups, in favor of Contact frames for members reserved /*presentation*/ 3; // This field is deprecated in the context of static group state uint64 timestamp = 4; } message MemberBanned { - bytes userId = 1; + bytes user_id = 1; uint64 timestamp = 2; } diff --git a/protos/Groups.proto b/protos/Groups.proto index 80aa611607..50b718ed36 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -1,11 +1,11 @@ -syntax = "proto3"; - // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +syntax = "proto3"; package signalservice; -option java_package = "org.whispersystems.signalservice.protos.groups"; +option java_package = "org.signal.storageservice.storage.protos.groups"; +option java_outer_classname = "GroupProtos"; option java_multiple_files = true; message AvatarUploadAttributes { @@ -27,11 +27,14 @@ message Member { ADMINISTRATOR = 2; } - bytes userId = 1; + bytes user_id = 1; Role role = 2; bytes profileKey = 3; bytes presentation = 4; uint32 joinedAtVersion = 5; + // These two fields each decrypt to a UTF8 string + bytes label_emoji = 6; + bytes label_string = 7; } message MemberPendingProfileKey { @@ -41,14 +44,14 @@ message MemberPendingProfileKey { } message MemberPendingAdminApproval { - bytes userId = 1; + bytes user_id = 1; bytes profileKey = 2; bytes presentation = 3; uint64 timestamp = 4; // ms since epoch } message MemberBanned { - bytes userId = 1; + bytes user_id = 1; uint64 timestamp = 2; // ms since epoch } @@ -134,10 +137,17 @@ message GroupChange { } message ModifyMemberRoleAction { - bytes userId = 1; + bytes user_id = 1; Member.Role role = 2; } + message ModifyMemberLabelAction { + bytes user_id = 1; + // These two fields each decrypt to a UTF8 string + bytes label_emoji = 2; + bytes label_string = 3; + } + message ModifyMemberProfileKeyAction { bytes presentation = 1; bytes user_id = 2; @@ -174,7 +184,7 @@ message GroupChange { } message PromoteMemberPendingAdminApprovalAction { - bytes userId = 1; + bytes user_id = 1; Member.Role role = 2; } @@ -231,6 +241,7 @@ message GroupChange { repeated AddMemberAction addMembers = 3; repeated DeleteMemberAction deleteMembers = 4; repeated ModifyMemberRoleAction modifyMemberRoles = 5; + repeated ModifyMemberLabelAction modifyMemberLabels = 26; // change epoch = 6; repeated ModifyMemberProfileKeyAction modifyMemberProfileKeys = 6; repeated AddMemberPendingProfileKeyAction addMembersPendingProfileKey = 7; repeated DeleteMemberPendingProfileKeyAction deleteMembersPendingProfileKey = 8; diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 7fcaf82fc9..b8c78475e7 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -116,6 +116,10 @@ line-height: 16px; letter-spacing: 0px; } +@mixin font-body-small-bold { + @include font-body-small; + font-weight: 600; +} @mixin font-subtitle { font-size: 12px; diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 81ae9fff06..08d3f03211 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -559,7 +559,6 @@ $message-padding-horizontal: 12px; margin-block-start: 0; border-top-left-radius: 0; border-top-right-radius: 0; - margin-top: -7px; } .module-message__attachment-too-big--content-below { border-bottom-left-radius: 0; @@ -1071,28 +1070,16 @@ $message-padding-horizontal: 12px; } .module-message__author { - @include mixins.font-body-2-bold; + @include mixins.font-body-small-bold; - height: 18px; - overflow-x: hidden; - overflow-y: hidden; - white-space: nowrap; - text-overflow: ellipsis; + min-height: 18px; user-select: none; } -.module-message__author--with-quote { - margin-bottom: 4px; -} - .module-message__author_with_sticker { - @include mixins.font-body-2-bold; + @include mixins.font-body-small-bold; - height: 18px; - overflow-x: hidden; - overflow-y: hidden; - white-space: nowrap; - text-overflow: ellipsis; + min-height: 18px; // Stickers are pretty narrow, so we allow this one element of a sticker // message to go wider than normal. @@ -6347,6 +6334,10 @@ button.module-calling-participants-list__contact { } } +.module-message__container--sticker-like { + overflow: initial; +} + .module-message__context { &--icon::before { content: ' '; diff --git a/stylesheets/components/AboutContactModal.scss b/stylesheets/components/AboutContactModal.scss index bf0118fc9e..5d37cc745d 100644 --- a/stylesheets/components/AboutContactModal.scss +++ b/stylesheets/components/AboutContactModal.scss @@ -115,6 +115,16 @@ '../images/icons/v3/person/person-questionmark-compact.svg' ); } + + &--label { + @include about-modal-icon('../images/icons/v3/tag/tag.svg'); + } + } + + &__label-container { + display: flex; + align-items: center; + gap: 6px; } &__button { diff --git a/stylesheets/components/ContactModal.scss b/stylesheets/components/ContactModal.scss index fc2ca32923..c804d8ceaf 100644 --- a/stylesheets/components/ContactModal.scss +++ b/stylesheets/components/ContactModal.scss @@ -67,6 +67,11 @@ } } + &__member-label { + margin-top: 6px; + margin-bottom: 4px; + } + &__info { text-align: center; max-width: 248px; diff --git a/stylesheets/components/ContactName.scss b/stylesheets/components/ContactName.scss index eeb40008b6..51696727ca 100644 --- a/stylesheets/components/ContactName.scss +++ b/stylesheets/components/ContactName.scss @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only @use 'sass:map'; +@use 'sass:color'; @use '../mixins'; @@ -277,12 +278,61 @@ $contact-colors: ( ); .module-contact-name { + &--label-pill { + font-weight: 400; + display: inline-block; + padding-inline: 6px; + padding-block: 1px; + border-radius: 9px; + white-space: nowrap; + } + + &--label-pill--inner { + display: flex; + align-items: center; + white-space: nowrap; + } + + &--label-pill--bubble { + margin-top: -2px; + margin-bottom: 2px; + } + + &--label-pill--emoji { + display: inline-block; + padding-block: 2px; + padding-inline-end: 3px; + line-height: 12px; + } + + &--label-pill--text { + display: inline-block; + } + @each $name, $value in $contact-colors { &--#{map.get($value, 'name')} { color: map.get($value, 'light'); @include mixins.dark-theme() { color: map.get($value, 'dark'); } + + &--label-pill--bubble { + color: color.mix(map.get($value, 'light'), #000000, 70%); + background-color: rgba(map.get($value, 'light'), 0.14); + @include mixins.dark-theme() { + color: color.mix(map.get($value, 'dark'), #ffffff, 68%); + background-color: rgba(map.get($value, 'dark'), 0.32); + } + } + + &--label-pill--list { + color: color.mix(map.get($value, 'light'), #000000, 70%); + background-color: rgba(map.get($value, 'light'), 0.1); + @include mixins.dark-theme() { + color: color.mix(map.get($value, 'dark'), #ffffff, 75%); + background-color: rgba(map.get($value, 'dark'), 0.32); + } + } } } } diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 81fffc8e0f..6b15cd95fe 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -285,6 +285,12 @@ @include details-icon('../images/icons/v3/heart/heart.svg'); } } + + &--tag { + &::after { + @include details-icon('../images/icons/v3/tag/tag.svg'); + } + } } } @@ -520,3 +526,8 @@ light-dark(variables.$color-black, variables.$color-white) ); } + +.Input__input--textarea.GroupMemberLabelEditor__input--textarea { + line-height: 32px; + margin-top: 0px; +} diff --git a/stylesheets/components/fun/FunEmoji.scss b/stylesheets/components/fun/FunEmoji.scss index 1cc4bb43c7..d4e26d19da 100644 --- a/stylesheets/components/fun/FunEmoji.scss +++ b/stylesheets/components/fun/FunEmoji.scss @@ -57,6 +57,13 @@ $emoji-sprite-sheet-grid-item-count: 62; @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(12 / 32)); } +.FunStaticEmoji--Size14 { + width: 14px; + height: 14px; + // Use 32px variant even on smaller sizes to avoid shipping the 16px sheet + @include emoji-sprite($sheet: 32, $margin: 1px, $scale: calc(14 / 32)); +} + .FunStaticEmoji--Size16 { width: 16px; height: 16px; diff --git a/ts/RemoteConfig.dom.ts b/ts/RemoteConfig.dom.ts index 20c1adcf51..fb7442352b 100644 --- a/ts/RemoteConfig.dom.ts +++ b/ts/RemoteConfig.dom.ts @@ -34,6 +34,8 @@ const SemverKeys = [ 'desktop.callQualitySurvey.prod', 'desktop.donationPaypal.beta', 'desktop.donationPaypal.prod', + 'desktop.groupMemberLabels.edit.beta', + 'desktop.groupMemberLabels.edit.prod', 'desktop.pinnedMessages.receive.beta', 'desktop.pinnedMessages.receive.prod', 'desktop.pinnedMessages.send.beta', diff --git a/ts/components/AnnouncementsOnlyGroupBanner.dom.tsx b/ts/components/AnnouncementsOnlyGroupBanner.dom.tsx index fa8c5c1582..68170ea7bf 100644 --- a/ts/components/AnnouncementsOnlyGroupBanner.dom.tsx +++ b/ts/components/AnnouncementsOnlyGroupBanner.dom.tsx @@ -2,28 +2,31 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { useState } from 'react'; -import lodash from 'lodash'; -import type { - ConversationType, - ShowConversationType, -} from '../state/ducks/conversations.preload.js'; +import type { ShowConversationType } from '../state/ducks/conversations.preload.js'; import { I18n } from './I18n.dom.js'; import type { LocalizerType, ThemeType } from '../types/Util.std.js'; import { Modal } from './Modal.dom.js'; -import { ConversationListItem } from './conversationList/ConversationListItem.dom.js'; - -const { noop } = lodash; +import { Avatar, AvatarSize } from './Avatar.dom.js'; +import { GroupMemberLabel } from './conversation/ContactName.dom.js'; +import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.js'; +import { tw } from '../axo/tw.dom.js'; +import type { AdminMembershipType } from '../state/selectors/conversations.dom.js'; +import { UserText } from './UserText.dom.js'; type PropsType = { - groupAdmins: Array; + getPreferredBadge: PreferredBadgeSelectorType; + groupAdmins: Array; + memberColors: Map; i18n: LocalizerType; showConversation: ShowConversationType; theme: ThemeType; }; export function AnnouncementsOnlyGroupBanner({ + getPreferredBadge, groupAdmins, i18n, + memberColors, showConversation, theme, }: PropsType): React.JSX.Element { @@ -33,25 +36,58 @@ export function AnnouncementsOnlyGroupBanner({ <> {isShowingAdmins && ( setIsShowingAdmins(false)} title={i18n('icu:AnnouncementsOnlyGroupBanner--modal')} > - {groupAdmins.map(admin => ( - { - showConversation({ conversationId: admin.id }); - }} - onMouseDown={noop} - theme={theme} - /> - ))} + {groupAdmins.map(admin => { + const { member, labelEmoji, labelString } = admin; + const contactNameColor = memberColors.get(member.id); + + return ( +
+ +
+ ); + })}
)}
diff --git a/ts/components/CallLinkPendingParticipantModal.dom.tsx b/ts/components/CallLinkPendingParticipantModal.dom.tsx index aefbc59eb4..5715fb3142 100644 --- a/ts/components/CallLinkPendingParticipantModal.dom.tsx +++ b/ts/components/CallLinkPendingParticipantModal.dom.tsx @@ -13,6 +13,7 @@ import { ThemeType } from '../types/Util.std.js'; import { Theme } from '../util/theme.std.js'; import { UserText } from './UserText.dom.js'; import { SharedGroupNames } from './SharedGroupNames.dom.js'; +import type { ContactModalStateType } from '../state/ducks/globalModals.preload.js'; export type CallLinkPendingParticipantModalProps = { readonly i18n: LocalizerType; @@ -21,7 +22,7 @@ export type CallLinkPendingParticipantModalProps = { readonly denyUser: (payload: PendingUserActionPayloadType) => void; readonly onClose: () => void; readonly sharedGroupNames: ReadonlyArray; - readonly toggleAboutContactModal: (conversationId: string) => void; + readonly toggleAboutContactModal: (options: ContactModalStateType) => void; }; export function CallLinkPendingParticipantModal({ @@ -75,7 +76,7 @@ export function CallLinkPendingParticipantModal({ onClick={ev => { ev.preventDefault(); ev.stopPropagation(); - toggleAboutContactModal(conversation.id); + toggleAboutContactModal({ contactId: conversation.id }); }} className="CallLinkPendingParticipantModal__NameButton" > diff --git a/ts/components/CompositionArea.dom.stories.tsx b/ts/components/CompositionArea.dom.stories.tsx index c984552bf6..374e8e5185 100644 --- a/ts/components/CompositionArea.dom.stories.tsx +++ b/ts/components/CompositionArea.dom.stories.tsx @@ -12,13 +12,47 @@ import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext.st import { fakeDraftAttachment } from '../test-helpers/fakeAttachment.std.js'; import { landscapeGreenUrl } from '../storybook/Fixtures.std.js'; import { RecordingState } from '../types/AudioRecorder.std.js'; -import { ConversationColors } from '../types/Colors.std.js'; +import { ContactNameColors, ConversationColors } from '../types/Colors.std.js'; import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.js'; import { PaymentEventKind } from '../types/Payment.std.js'; import { EmojiSkinTone } from './fun/data/emojis.std.js'; +import { isNotNil } from '../util/isNotNil.std.js'; const { i18n } = window.SignalContext; +const groupAdmins = [ + { + member: getDefaultConversation(), + labelEmoji: undefined, + labelString: undefined, + }, + { + member: getDefaultConversation(), + labelEmoji: '✅', + labelString: 'Planner', + }, + { + member: getDefaultConversation(), + labelEmoji: '#', + labelString: 'Invalid Emoji', + }, + { + member: getDefaultConversation(), + labelEmoji: undefined, + labelString: 'No Emoji', + }, +]; +const memberColors = new Map( + groupAdmins + .map((admin, i): [string, string] | null => { + if (!admin.member.id) { + return null; + } + return [admin.member.id?.toString(), ContactNameColors[i]]; + }) + .filter(isNotNil) +); + export default { title: 'Components/CompositionArea', decorators: [ @@ -102,7 +136,8 @@ export default { announcementsOnly: false, areWeAdmin: false, areWePendingApproval: false, - groupAdmins: [], + groupAdmins, + memberColors, cancelJoinRequest: action('cancelJoinRequest'), showConversation: action('showConversation'), isSmsOnlyOrUnregistered: false, diff --git a/ts/components/CompositionArea.dom.tsx b/ts/components/CompositionArea.dom.tsx index df9ec13d61..988a8768c3 100644 --- a/ts/components/CompositionArea.dom.tsx +++ b/ts/components/CompositionArea.dom.tsx @@ -112,7 +112,11 @@ export type OwnProps = Readonly<{ errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null; errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; focusCounter: number; - groupAdmins: Array; + groupAdmins: Array<{ + member: ConversationType; + labelEmoji: string | undefined; + labelString: string | undefined; + }>; groupVersion: 1 | 2 | null; i18n: LocalizerType; imageToBlurHash: typeof imageToBlurHash; @@ -126,6 +130,7 @@ export type OwnProps = Readonly<{ lastEditableMessageId: string | null; recordingState: RecordingState; messageCompositionId: string; + memberColors: Map; shouldHidePopovers: boolean | null; isMuted: boolean; isSmsOnlyOrUnregistered: boolean | null; @@ -321,6 +326,7 @@ export const CompositionArea = memo(function CompositionArea({ announcementsOnly, areWeAdmin, groupAdmins, + memberColors, cancelJoinRequest, showConversation, // SMS-only contacts @@ -1005,8 +1011,10 @@ export const CompositionArea = memo(function CompositionArea({ if (announcementsOnly && !areWeAdmin) { return ( diff --git a/ts/components/PreferencesNotificationProfiles.dom.tsx b/ts/components/PreferencesNotificationProfiles.dom.tsx index 3337557cc4..985f6bd314 100644 --- a/ts/components/PreferencesNotificationProfiles.dom.tsx +++ b/ts/components/PreferencesNotificationProfiles.dom.tsx @@ -558,7 +558,7 @@ export function NotificationProfilesHome({ i18n={i18n} isEditing onBack={() => setPage(HomePage.Edit)} - onNext={() => setPage(HomePage.Edit)} // TODO: probably don't show Next button? + onNext={() => setPage(HomePage.Edit)} onSetIsEnabled={(scheduleEnabled: boolean) => { const newProfile = { ...profile, diff --git a/ts/components/conversation/AboutContactModal.dom.stories.tsx b/ts/components/conversation/AboutContactModal.dom.stories.tsx index fb791a4e39..99ba52fc7a 100644 --- a/ts/components/conversation/AboutContactModal.dom.stories.tsx +++ b/ts/components/conversation/AboutContactModal.dom.stories.tsx @@ -61,18 +61,23 @@ export default { isSignalConnection: { control: { type: 'boolean' } }, }, args: { + canAddLabel: false, + contact: conversation, + contactLabelEmoji: undefined, + contactLabelString: undefined, + contactNameColor: undefined, + fromOrAddedByTrustedContact: false, i18n, + isSignalConnection: false, + isEditMemberLabelEnabled: true, onClose: action('onClose'), onOpenNotePreviewModal: action('onOpenNotePreviewModal'), - toggleSignalConnectionsModal: action('toggleSignalConnections'), - toggleSafetyNumberModal: action('toggleSafetyNumberModal'), - toggleProfileNameWarningModal: action('toggleProfileNameWarningModal'), - startAvatarDownload: action('startAvatarDownload'), pendingAvatarDownload: false, - conversation, sharedGroupNames: [], - fromOrAddedByTrustedContact: false, - isSignalConnection: false, + startAvatarDownload: action('startAvatarDownload'), + toggleProfileNameWarningModal: action('toggleProfileNameWarningModal'), + toggleSafetyNumberModal: action('toggleSafetyNumberModal'), + toggleSignalConnectionsModal: action('toggleSignalConnections'), }, } satisfies ComponentMeta; @@ -81,27 +86,80 @@ export function Defaults(args: PropsType): React.JSX.Element { } export function Me(args: PropsType): React.JSX.Element { - return ; + return ; +} + +export function MeWithLabel(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function MeWithInvalidLabelEmoji(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function MeWithAddLabel(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function MeWithAddLabelEditDisabled(args: PropsType): React.JSX.Element { + return ( + + ); } export function Verified(args: PropsType): React.JSX.Element { - return ; + return ; } export function Blocked(args: PropsType): React.JSX.Element { - return ; + return ; } export function Pending(args: PropsType): React.JSX.Element { - return ; + return ; } export function NoMessages(args: PropsType): React.JSX.Element { - return ; + return ; } export function WithAbout(args: PropsType): React.JSX.Element { - return ; + return ; } export function SignalConnection(args: PropsType): React.JSX.Element { @@ -110,11 +168,7 @@ export function SignalConnection(args: PropsType): React.JSX.Element { export function SystemContact(args: PropsType): React.JSX.Element { return ( - + ); } @@ -122,7 +176,7 @@ export function WithSharedGroups(args: PropsType): React.JSX.Element { return ( @@ -133,7 +187,7 @@ export function DirectFromTrustedContact(args: PropsType): React.JSX.Element { return ( ); diff --git a/ts/components/conversation/AboutContactModal.dom.tsx b/ts/components/conversation/AboutContactModal.dom.tsx index a9d06efd16..c299f87238 100644 --- a/ts/components/conversation/AboutContactModal.dom.tsx +++ b/ts/components/conversation/AboutContactModal.dom.tsx @@ -13,6 +13,14 @@ import { About } from './About.dom.js'; import { I18n } from '../I18n.dom.js'; import { canHaveNicknameAndNote } from '../../util/nicknames.dom.js'; import { Tooltip, TooltipPlacement } from '../Tooltip.dom.js'; +import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.js'; +import { + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from '../fun/data/emojis.std.js'; +import { FunStaticEmoji } from '../fun/FunEmoji.dom.js'; +import { missingEmojiPlaceholder } from './ContactName.dom.js'; function muted(parts: Array) { return ( @@ -22,11 +30,16 @@ function muted(parts: Array) { export type PropsType = Readonly<{ i18n: LocalizerType; + canAddLabel: boolean; + contact: ConversationType; + contactLabelEmoji: string | undefined; + contactLabelString: string | undefined; + contactNameColor: string | undefined; + fromOrAddedByTrustedContact?: boolean; + isEditMemberLabelEnabled: boolean; + isSignalConnection: boolean; onClose: () => void; onOpenNotePreviewModal: () => void; - conversation: ConversationType; - fromOrAddedByTrustedContact?: boolean; - isSignalConnection: boolean; pendingAvatarDownload?: boolean; sharedGroupNames: ReadonlyArray; startAvatarDownload?: (id: string) => unknown; @@ -37,8 +50,13 @@ export type PropsType = Readonly<{ export function AboutContactModal({ i18n, - conversation, + canAddLabel, + contact, + contactLabelEmoji, + contactLabelString, + contactNameColor, fromOrAddedByTrustedContact, + isEditMemberLabelEnabled, isSignalConnection, pendingAvatarDownload, sharedGroupNames, @@ -49,7 +67,7 @@ export function AboutContactModal({ onClose, onOpenNotePreviewModal, }: PropsType): React.JSX.Element { - const { avatarUrl, hasAvatar, isMe } = conversation; + const { avatarUrl, hasAvatar, isMe } = contact; // If hasAvatar is true, we show the download button instead of blur const enableClickToLoad = !avatarUrl && !isMe && hasAvatar; @@ -64,11 +82,11 @@ export function AboutContactModal({ } return () => { if (!pendingAvatarDownload && startAvatarDownload) { - startAvatarDownload(conversation.id); + startAvatarDownload(contact.id); } }; }, [ - conversation.id, + contact.id, startAvatarDownload, enableClickToLoad, pendingAvatarDownload, @@ -85,9 +103,9 @@ export function AboutContactModal({ const onVerifiedClick = useCallback( (ev: React.MouseEvent) => { ev.preventDefault(); - toggleSafetyNumberModal(conversation.id); + toggleSafetyNumberModal(contact.id); }, - [toggleSafetyNumberModal, conversation.id] + [toggleSafetyNumberModal, contact.id] ); const onProfileNameWarningClick = useCallback( @@ -99,31 +117,58 @@ export function AboutContactModal({ ); let statusRow: React.JSX.Element | undefined; + const hasLabel = contactNameColor && contactLabelString; + const shouldShowLabel = isMe && hasLabel; + const shouldShowAddLabel = + isMe && !hasLabel && canAddLabel && isEditMemberLabelEnabled; + const emojiLocalizer = useFunEmojiLocalizer(); + + let labelEmojiElement; + if ( + shouldShowLabel && + contactLabelEmoji && + isEmojiVariantValue(contactLabelEmoji) + ) { + const emojiKey = getEmojiVariantKeyByValue(contactLabelEmoji); + const labelEmojiData = getEmojiVariantByKey(emojiKey); + labelEmojiElement = ( + <> + {' '} + + ); + } else if (shouldShowLabel && contactLabelEmoji) { + labelEmojiElement = `${missingEmojiPlaceholder} `; + } if (isMe) { // No status for ourselves - } else if (conversation.isBlocked) { + } else if (contact.isBlocked) { statusRow = (
{i18n('icu:AboutContactModal__blocked', { - name: conversation.title, + name: contact.title, })}
); - } else if (!conversation.acceptedMessageRequest) { + } else if (!contact.acceptedMessageRequest) { statusRow = (
{i18n('icu:AboutContactModal__message-request')}
); - } else if (!conversation.hasMessages && !conversation.profileSharing) { + } else if (!contact.hasMessages && !contact.profileSharing) { statusRow = (
{i18n('icu:AboutContactModal__no-dms', { - name: conversation.title, + name: contact.title, })}
); @@ -140,22 +185,21 @@ export function AboutContactModal({ >
-

{isMe @@ -163,19 +207,18 @@ export function AboutContactModal({ : i18n('icu:AboutContactModal__title')}

-
- {canHaveNicknameAndNote(conversation) && - conversation.titleNoNickname !== conversation.title && - conversation.titleNoNickname ? ( + {canHaveNicknameAndNote(contact) && + contact.titleNoNickname !== contact.title && + contact.titleNoNickname ? ( , + nickname: , titleNoNickname: ( - ), + title: , }} /> } delay={0} > - + ), muted, @@ -201,14 +242,13 @@ export function AboutContactModal({ /> ) : ( - + )}
- {!isMe && !fromOrAddedByTrustedContact ? (
) : null} - - {!isMe && conversation.isVerified ? ( + {!isMe && contact.isVerified ? (
) : null} - - {!isMe && conversation.about ? ( + {!isMe && contact.about ? (
- +
) : null} - {!isMe && isSignalConnection ? (
@@ -266,23 +300,35 @@ export function AboutContactModal({
) : null} - - {!isMe && isInSystemContacts(conversation) ? ( + {!isMe && isInSystemContacts(contact) ? (
{i18n('icu:AboutContactModal__system-contact', { - name: - conversation.systemGivenName || - conversation.firstName || - conversation.title, + name: contact.systemGivenName || contact.firstName || contact.title, })}
) : null} - {conversation.phoneNumber ? ( + {shouldShowLabel && ( +
+ +
+ {labelEmojiElement} + {contactLabelString} +
+
+ )} + {shouldShowAddLabel && ( +
+ + {i18n('icu:AboutContactModal__add-member-label')} +
+ )} + + {contact.phoneNumber ? (
- +
) : null} @@ -294,8 +340,7 @@ export function AboutContactModal({
)} - - {conversation.note && ( + {contact.note && (
)} - {statusRow} ); diff --git a/ts/components/conversation/ContactModal.dom.stories.tsx b/ts/components/conversation/ContactModal.dom.stories.tsx index afa2de0d1c..e1daea5ed5 100644 --- a/ts/components/conversation/ContactModal.dom.stories.tsx +++ b/ts/components/conversation/ContactModal.dom.stories.tsx @@ -39,6 +39,9 @@ export default { badges: [], blockConversation: action('blockConversation'), contact: defaultContact, + contactLabelEmoji: undefined, + contactLabelString: undefined, + contactNameColor: undefined, conversation: defaultGroup, hasActiveCall: false, hasStories: undefined, @@ -70,6 +73,29 @@ AsNonAdmin.args = { areWeAdmin: false, }; +export const WithLabel = Template.bind({}); +WithLabel.args = { + areWeAdmin: false, + contactLabelEmoji: '💪🏼', + contactLabelString: 'Strong', + contactNameColor: '180', +}; + +export const WithLabelNoEmoji = Template.bind({}); +WithLabelNoEmoji.args = { + areWeAdmin: false, + contactLabelString: 'Strong', + contactNameColor: '220', +}; + +export const WithLabelInvalidEmoji = Template.bind({}); +WithLabelInvalidEmoji.args = { + areWeAdmin: false, + contactLabelEmoji: '%', + contactLabelString: 'Strong', + contactNameColor: '220', +}; + export const AsAdmin = Template.bind({}); AsAdmin.args = { areWeAdmin: true, @@ -97,14 +123,6 @@ WithoutPhoneNumber.args = { }, }; -export const ViewingSelf = Template.bind({}); -ViewingSelf.args = { - contact: { - ...defaultContact, - isMe: true, - }, -}; - export const WithBadges = Template.bind({}); WithBadges.args = { badges: getFakeBadges(2), diff --git a/ts/components/conversation/ContactModal.dom.tsx b/ts/components/conversation/ContactModal.dom.tsx index f275442912..ae534efe80 100644 --- a/ts/components/conversation/ContactModal.dom.tsx +++ b/ts/components/conversation/ContactModal.dom.tsx @@ -31,6 +31,8 @@ import { InAnotherCallTooltip, getTooltipContent, } from './InAnotherCallTooltip.dom.js'; +import type { ContactModalStateType } from '../../state/ducks/globalModals.preload.js'; +import { GroupMemberLabel } from './ContactName.dom.js'; const log = createLogger('ContactModal'); @@ -39,6 +41,9 @@ export type PropsDataType = { areWeAdmin: boolean; badges: ReadonlyArray; contact?: ConversationType; + contactLabelEmoji: string | undefined; + contactLabelString: string | undefined; + contactNameColor: string | undefined; conversation?: ConversationType; hasStories?: HasStories; readonly i18n: LocalizerType; @@ -59,7 +64,7 @@ type PropsActionType = { showConversation: ShowConversationType; startAvatarDownload: () => void; toggleAdmin: (conversationId: string, contactId: string) => void; - toggleAboutContactModal: (conversationId: string) => unknown; + toggleAboutContactModal: (options: ContactModalStateType) => unknown; togglePip: () => void; toggleSafetyNumberModal: (conversationId: string) => unknown; toggleAddUserToAnotherGroupModal: (conversationId: string) => void; @@ -87,6 +92,9 @@ export function ContactModal({ badges, blockConversation, contact, + contactLabelEmoji, + contactLabelString, + contactNameColor, conversation, hasActiveCall, hasStories, @@ -344,7 +352,7 @@ export function ContactModal({ className="ContactModal__name" onClick={ev => { ev.preventDefault(); - toggleAboutContactModal(contact.id); + toggleAboutContactModal({ contactId: contact.id }); }} >
@@ -361,6 +369,19 @@ export function ContactModal({
+ {contactLabelString && contactNameColor && ( +
+ +
+ )} {!contact.isMe && renderQuickActions(contact.id)}
diff --git a/ts/components/conversation/ContactName.dom.stories.tsx b/ts/components/conversation/ContactName.dom.stories.tsx index 3a825cff1d..53dd49bcbc 100644 --- a/ts/components/conversation/ContactName.dom.stories.tsx +++ b/ts/components/conversation/ContactName.dom.stories.tsx @@ -36,3 +36,57 @@ export function Colors(): React.JSX.Element { ); } + +export function ColorsWithLabels(): React.JSX.Element { + return ( + <> + {ContactNameColors.map(color => ( +
+ +
+ ))} + + ); +} + +export function ColorsWithNoLabelEmoji(): React.JSX.Element { + return ( + <> + {ContactNameColors.map(color => ( +
+ +
+ ))} + + ); +} + +export function ColorsWithInvalidLabelEmoji(): React.JSX.Element { + return ( + <> + {ContactNameColors.map(color => ( +
+ +
+ ))} + + ); +} diff --git a/ts/components/conversation/ContactName.dom.tsx b/ts/components/conversation/ContactName.dom.tsx index 44cb6776c3..cff427deff 100644 --- a/ts/components/conversation/ContactName.dom.tsx +++ b/ts/components/conversation/ContactName.dom.tsx @@ -4,14 +4,28 @@ import React, { useMemo } from 'react'; import classNames from 'classnames'; +import type { ReactNode } from 'react'; + import { Emojify } from './Emojify.dom.js'; -import type { ContactNameColorType } from '../../types/Colors.std.js'; import { getClassNamesFor } from '../../util/getClassNamesFor.std.js'; -import type { ConversationType } from '../../state/ducks/conversations.preload.js'; import { isSignalConversation as getIsSignalConversation } from '../../util/isSignalConversation.dom.js'; +import { + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from '../fun/data/emojis.std.js'; +import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.js'; +import { FunStaticEmoji } from '../fun/FunEmoji.dom.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 }; firstName?: string; isSignalConversation?: boolean; isMe?: boolean; @@ -47,6 +61,7 @@ export type PropsType = ContactNameData & { }; export function ContactName({ + contactLabel, contactNameColor, firstName, isSignalConversation, @@ -85,6 +100,96 @@ export function ContactName({ } /> )} + {contactLabel && ( + <> + {' '} + + + )} ); } + +export type Context = 'bubble' | 'list'; + +export function GroupMemberLabel({ + emojiSize = 12, + contactLabel, + contactNameColor, + context, + module, +}: { + emojiSize?: FunStaticEmojiSize; + contactLabel?: { labelString: string; labelEmoji: string | undefined }; + contactNameColor?: ContactNameColorType; + context: Context; + module?: string; +}): ReactNode { + const emojiLocalizer = useFunEmojiLocalizer(); + const getClassName = getClassNamesFor('module-contact-name', module); + + if (!contactLabel) { + return null; + } + + const { labelEmoji, labelString } = contactLabel; + + let emojiElement; + if (labelEmoji && isEmojiVariantValue(labelEmoji)) { + const emojiKey = getEmojiVariantKeyByValue(labelEmoji); + const emojiData = getEmojiVariantByKey(emojiKey); + + emojiElement = ( + + + + ); + } else if (labelEmoji) { + emojiElement = ( + + {missingEmojiPlaceholder} + + ); + } + + return ( + + + {emojiElement} + + + + + + ); +} diff --git a/ts/components/conversation/ConversationHero.dom.stories.tsx b/ts/components/conversation/ConversationHero.dom.stories.tsx index 000eb743d7..ab32d11f28 100644 --- a/ts/components/conversation/ConversationHero.dom.stories.tsx +++ b/ts/components/conversation/ConversationHero.dom.stories.tsx @@ -29,6 +29,8 @@ const createMemberships = ({ return Array.from(new Array(count)).map( (_, i): GroupV2Membership => ({ isAdmin: i % 3 === 0, + labelEmoji: i % 6 === 0 ? '🟢' : undefined, + labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined, member: unknownContactIndices.includes(i) ? getDefaultConversation({ isMe: includeMe && i === 0, diff --git a/ts/components/conversation/ConversationHero.dom.tsx b/ts/components/conversation/ConversationHero.dom.tsx index 1e363676cb..888bc11624 100644 --- a/ts/components/conversation/ConversationHero.dom.tsx +++ b/ts/components/conversation/ConversationHero.dom.tsx @@ -18,6 +18,7 @@ import { StoryViewModeType } from '../../types/Stories.std.js'; import { Button, ButtonVariant } from '../Button.dom.js'; import { SafetyTipsModal } from '../SafetyTipsModal.dom.js'; import { I18n } from '../I18n.dom.js'; +import type { ContactModalStateType } from '../../state/ducks/globalModals.preload.js'; export type Props = { about?: string; @@ -41,7 +42,7 @@ export type Props = { startAvatarDownload: () => void; theme: ThemeType; viewUserStories: ViewUserStoriesActionCreatorType; - toggleAboutContactModal: (conversationId: string) => unknown; + toggleAboutContactModal: (options: ContactModalStateType) => unknown; toggleProfileNameWarningModal: (conversationType?: string) => unknown; } & Omit; @@ -306,7 +307,7 @@ export function ConversationHero({ className="module-conversation-hero__title" onClick={ev => { ev.preventDefault(); - toggleAboutContactModal(id); + toggleAboutContactModal({ contactId: id }); }} > diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 8e43a09e08..8333080042 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -247,6 +247,7 @@ export type PropsData = { id: string; renderingContext: RenderingContextType; contactNameColor?: ContactNameColorType; + contactLabel?: { labelString: string; labelEmoji: string | undefined }; conversationColor: ConversationColorType; conversationTitle: string; customColor?: CustomColorType; @@ -1135,7 +1136,8 @@ export class Message extends React.PureComponent { } #renderAuthor(): ReactNode { - const { author, contactNameColor, i18n, isSticker, quote } = this.props; + const { author, contactLabel, contactNameColor, i18n, isSticker, quote } = + this.props; if (!this.#shouldRenderAuthor()) { return null; @@ -1153,6 +1155,7 @@ export class Message extends React.PureComponent { > @@ -3244,6 +3247,7 @@ export class Message extends React.PureComponent { : null, isTargeted ? 'module-message__container--targeted' : null, lighterSelect ? 'module-message__container--targeted-lighter' : null, + isStickerLike ? 'module-message__container--sticker-like' : null, !isStickerLike ? `module-message__container--${direction}` : null, isEmojiOnly ? 'module-message__container--emoji' : null, !isStickerLike && direction === 'outgoing' diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index b2685c9dcf..dff3ad4db2 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -256,6 +256,8 @@ const createProps = (overrideProps: Partial = {}): Props => ({ conversationId: overrideProps.conversationId ?? '', conversationType: overrideProps.conversationType || 'direct', contact: overrideProps.contact, + contactNameColor: overrideProps.contactNameColor, + contactLabel: overrideProps.contactLabel, // disableMenu: overrideProps.disableMenu, deletedForEveryone: overrideProps.deletedForEveryone, disableScroll: overrideProps.disableScroll, @@ -370,7 +372,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ }); const renderMany = (propsArray: ReadonlyArray) => ( - <> +
{propsArray.map((message, index) => ( ) => ( shouldCollapseBelow={Boolean(propsArray[index + 1])} /> ))} - +
); const renderThree = (props: Props) => @@ -402,7 +404,7 @@ const renderBothDirections = (props: Props) => ( ); const renderOneInBothDirections = (props: Props) => ( - <> +
( canEndPoll: true, }} /> - +
); export const PlainMessage = Template.bind({}); @@ -757,6 +759,7 @@ export const AvatarInGroup = Template.bind({}); AvatarInGroup.args = { author: getDefaultConversation({ avatarUrl: pngUrl }), conversationType: 'group', + contactNameColor: '100', status: 'sent', text: 'Hello it is me, the saxophone.', }; @@ -765,10 +768,55 @@ export const BadgeInGroup = Template.bind({}); BadgeInGroup.args = { conversationType: 'group', getPreferredBadge: () => getFakeBadge(), + contactNameColor: '300', status: 'sent', text: 'Hello it is me, the saxophone.', }; +export const LabelInGroup = Template.bind({}); +LabelInGroup.args = { + conversationType: 'group', + status: 'sent', + text: 'Hello it is me, the saxophone.', + contactNameColor: '260', + contactLabel: { + labelEmoji: '🍗', + labelString: 'Chicken Taster', + }, +}; + +export const LabelInGroupWithLongName = Template.bind({}); +LabelInGroupWithLongName.args = { + conversationType: 'group', + status: 'sent', + text: 'Hello it is me, the saxophone.', + author: { + ...getDefaultConversation(), + title: 'Long long long long long long long long long long long name', + }, + contactNameColor: '260', + contactLabel: { + labelEmoji: '🍗', + labelString: 'Chicken Taster', + }, +}; + +export const LabelInGroupWithLongNameAndLongMessage = Template.bind({}); +LabelInGroupWithLongNameAndLongMessage.args = { + conversationType: 'group', + status: 'sent', + text: 'Hello it is me, the saxophone. I am a good friend of yours. Do you remember? A long long long long long long long time ago.', + author: { + ...getDefaultConversation(), + title: 'Long long long long long long long long long long long name', + }, + contactNameColor: '260', + contactLabel: { + labelEmoji: '🍗', + labelString: 'Chicken Taster', + }, +}; + export const Sticker = Template.bind({}); Sticker.args = { attachments: [ @@ -784,6 +832,69 @@ Sticker.args = { status: 'sent', }; +export const StickerInGroup = Template.bind({}); +StickerInGroup.args = { + attachments: [ + fakeAttachment({ + url: '/fixtures/512x515-thumbs-up-lincoln.webp', + fileName: '512x515-thumbs-up-lincoln.webp', + contentType: IMAGE_WEBP, + width: 128, + height: 128, + }), + ], + conversationType: 'group', + contactNameColor: '180', + isSticker: true, + status: 'sent', +}; + +export const StickerWithLabelInGroup = Template.bind({}); +StickerWithLabelInGroup.args = { + attachments: [ + fakeAttachment({ + url: '/fixtures/512x515-thumbs-up-lincoln.webp', + fileName: '512x515-thumbs-up-lincoln.webp', + contentType: IMAGE_WEBP, + width: 128, + height: 128, + }), + ], + conversationType: 'group', + isSticker: true, + status: 'sent', + contactNameColor: '260', + contactLabel: { + labelEmoji: '🍗', + labelString: 'Chicken Taster', + }, +}; + +export const StickerWithLongNameAndLabelInGroup = Template.bind({}); +StickerWithLongNameAndLabelInGroup.args = { + attachments: [ + fakeAttachment({ + url: '/fixtures/512x515-thumbs-up-lincoln.webp', + fileName: '512x515-thumbs-up-lincoln.webp', + contentType: IMAGE_WEBP, + width: 128, + height: 128, + }), + ], + conversationType: 'group', + isSticker: true, + status: 'sent', + author: { + ...getDefaultConversation(), + title: 'Long long long long long long long long long long long name', + }, + contactNameColor: '280', + contactLabel: { + labelEmoji: '🍗', + labelString: 'Chicken Taster', + }, +}; + export const Quote = Template.bind({}); Quote.args = { quote: { @@ -806,6 +917,7 @@ Quote.args = { badges: [], }, conversationType: 'group', + contactNameColor: '100', }; export function Deleted(): React.JSX.Element { @@ -834,6 +946,7 @@ export const DeletedWithExpireTimer = Template.bind({}); DeletedWithExpireTimer.args = { timestamp: Date.now() - 60 * 1000, conversationType: 'group', + contactNameColor: '100', deletedForEveryone: true, canForward: false, expirationLength: 5 * 60 * 1000, @@ -846,6 +959,7 @@ export function DeletedWithError(): React.JSX.Element { timestamp: Date.now() - 60 * 1000, // canDeleteForEveryone: true, conversationType: 'group', + contactNameColor: '100', deletedForEveryone: true, status: 'partial-sent', direction: 'outgoing', @@ -854,6 +968,7 @@ export function DeletedWithError(): React.JSX.Element { timestamp: Date.now() - 60 * 1000, // canDeleteForEveryone: true, conversationType: 'group', + contactNameColor: '100', deletedForEveryone: true, status: 'error', direction: 'outgoing', @@ -985,6 +1100,7 @@ LinkPreviewInGroup.args = { status: 'sent', text: 'Be sure to look at https://www.signal.org', conversationType: 'group', + contactNameColor: '100', }; export const LinkPreviewWithLongWord = Template.bind({}); @@ -1010,6 +1126,7 @@ LinkPreviewWithLongWord.args = { status: 'sent', text: 'Be sure to look at https://www.signal.org', conversationType: 'group', + contactNameColor: '100', }; export const LinkPreviewWithQuote = Template.bind({}); @@ -1048,6 +1165,7 @@ LinkPreviewWithQuote.args = { status: 'sent', text: 'Be sure to look at https://www.signal.org', conversationType: 'group', + contactNameColor: '100', }; export const LinkPreviewWithSmallImage = Template.bind({}); @@ -1851,6 +1969,7 @@ GifInAGroup.args = { }), ], conversationType: 'group', + contactNameColor: '100', status: 'sent', }; @@ -2168,6 +2287,7 @@ function createMockPollWithVoters( export const Poll = Template.bind({}); Poll.args = { conversationType: 'group', + contactNameColor: '100', poll: { question: 'What should we have for lunch?', options: ['Pizza 🍕', 'Sushi 🍱', 'Tacos 🌮', 'Salad 🥗'], @@ -2182,6 +2302,7 @@ Poll.args = { export const PollMultipleChoice = Template.bind({}); PollMultipleChoice.args = { conversationType: 'group', + contactNameColor: '100', poll: { question: 'Which features would you like to see in the next update?', options: ['Dark mode', 'Video calls', 'File sharing', 'Reactions', 'Polls'], @@ -2196,6 +2317,7 @@ PollMultipleChoice.args = { export const PollWithVotes = Template.bind({}); PollWithVotes.args = { conversationType: 'group', + contactNameColor: '100', poll: createMockPollWithVoters( 'Best day for the team meeting?', ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], @@ -2220,6 +2342,7 @@ PollWithVotes.args = { export const PollWithPendingVotes = Template.bind({}); PollWithPendingVotes.args = { conversationType: 'group', + contactNameColor: '100', poll: createMockPollWithVoters( 'Best day for the team meeting?', ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'], @@ -2248,6 +2371,7 @@ PollWithPendingVotes.args = { export const PollTerminated = Template.bind({}); PollTerminated.args = { conversationType: 'group', + contactNameColor: '100', poll: createMockPollWithVoters( 'Quick poll: Coffee or tea?', ['Coffee ☕', 'Tea 🍵'], @@ -2274,6 +2398,7 @@ PollTerminated.args = { export const PollLongText = Template.bind({}); PollLongText.args = { conversationType: 'group', + contactNameColor: '100', poll: createMockPollWithVoters( 'Given the current situation with remote work becoming more prevalent, what would be your preferred working arrangement for the future once everything stabilizes?', [ @@ -2299,6 +2424,7 @@ PollLongText.args = { export const PollMultipleChoiceWithVotes = Template.bind({}); PollMultipleChoiceWithVotes.args = { conversationType: 'group', + contactNameColor: '100', poll: createMockPollWithVoters( 'Which toppings do you want on the pizza?', [ @@ -2414,6 +2540,7 @@ export function PollAnimationPlayground(): React.JSX.Element { const props = createProps({ conversationType: 'group', + contactNameColor: '100', poll, status: 'sent', sendPollVote: handleSendPollVote, @@ -2660,6 +2787,7 @@ TapToViewImageInGroup.args = { isTapToView: true, status: 'sent', conversationType: 'group', + contactNameColor: '100', }; export const TapToViewVideo = Template.bind({}); @@ -2754,7 +2882,7 @@ export function Colors(): React.JSX.Element { <> {ConversationColors.map(color => (
- {renderBothDirections( + {renderOneInBothDirections( createProps({ conversationColor: color, text: `Here is a preview of the chat color: ${color}. The color is visible to only you.`, @@ -2897,18 +3025,21 @@ export const CollapsingTextOnlyGroupMessages = (): React.JSX.Element => { createProps({ author, conversationType: 'group', + contactNameColor: '100', text: 'One', timestamp: Date.now() - 2 * MINUTE, }), createProps({ author, conversationType: 'group', + contactNameColor: '100', text: 'Two', timestamp: Date.now() - MINUTE, }), createProps({ author, conversationType: 'group', + contactNameColor: '100', text: 'Three', }), ]); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx index da45a0cc7a..48a9e0ef48 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.dom.stories.tsx @@ -18,6 +18,8 @@ import { ThemeType } from '../../../types/Util.std.js'; import { DurationInSeconds } from '../../../util/durations/index.std.js'; import { NavTab } from '../../../types/Nav.std.js'; import { getFakeCallHistoryGroup } from '../../../test-helpers/getFakeCallHistoryGroup.std.js'; +import { ContactNameColors } from '../../../types/Colors.std.js'; +import { isNotNil } from '../../../util/isNotNil.std.js'; const { times } = lodash; @@ -41,97 +43,118 @@ const allCandidateContacts = times(10, () => getDefaultConversation()); const createProps = ( hasGroupLink = false, expireTimer?: DurationInSeconds -): Props => ({ - acceptConversation: action('acceptConversation'), - addMembersToGroup: async () => { - action('addMembersToGroup'); - }, - areWeASubscriber: false, - blockConversation: action('blockConversation'), - canEditGroupInfo: false, - canAddNewMembers: false, - conversation: expireTimer - ? { - ...conversation, - expireTimer, - } - : conversation, - hasActiveCall: false, - hasGroupLink, - getPreferredBadge: () => undefined, - getProfilesForConversation: action('getProfilesForConversation'), - groupsInCommon: [], - i18n, - isAdmin: false, - isGroup: true, - isSignalConversation: false, - leaveGroup: action('leaveGroup'), - hasMedia: true, - memberships: times(32, i => ({ +): Props => { + const memberships = times(32, i => ({ isAdmin: i === 1, + labelEmoji: i % 6 === 0 ? '🟢' : undefined, + labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined, member: getDefaultConversation({ isMe: i === 2, }), - })), - maxGroupSize: 1001, - maxRecommendedGroupSize: 151, - pendingApprovalMemberships: times(8, () => ({ - member: getDefaultConversation(), - })), - pendingMemberships: times(5, () => ({ - metadata: {}, - member: getDefaultConversation(), - })), - selectedNavTab: NavTab.Chats, - setDisappearingMessages: action('setDisappearingMessages'), - showContactModal: action('showContactModal'), - pushPanelForConversation: action('pushPanelForConversation'), - showConversation: action('showConversation'), - startAvatarDownload: action('startAvatarDownload'), - updateGroupAttributes: async () => { - action('updateGroupAttributes')(); - }, - deleteAvatarFromDisk: action('deleteAvatarFromDisk'), - replaceAvatar: action('replaceAvatar'), - saveAvatarToDisk: action('saveAvatarToDisk'), - setMuteExpiration: action('setMuteExpiration'), - userAvatarData: [], - toggleSafetyNumberModal: action('toggleSafetyNumberModal'), - toggleAboutContactModal: action('toggleAboutContactModal'), - toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'), - onDeleteNicknameAndNote: action('onDeleteNicknameAndNote'), - onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'), - onOutgoingAudioCallInConversation: action( - 'onOutgoingAudioCallInConversation' - ), - onOutgoingVideoCallInConversation: action( - 'onOutgoingVideoCallInConversation' - ), - searchInConversation: action('searchInConversation'), - theme: ThemeType.light, - renderChooseGroupMembersModal: props => { - return ( - - ); - }, - renderConfirmAdditionsModal: props => { - return ( - - ); - }, -}); + })); + const memberColors = new Map( + memberships + .map((membership, i): [string, string] | null => { + const { serviceId } = membership.member; + + if (!serviceId) { + return null; + } + + return [serviceId.toString(), ContactNameColors[i]]; + }) + .filter(isNotNil) + ); + + return { + acceptConversation: action('acceptConversation'), + addMembersToGroup: async () => { + action('addMembersToGroup'); + }, + areWeASubscriber: false, + blockConversation: action('blockConversation'), + canEditGroupInfo: false, + canAddNewMembers: false, + conversation: expireTimer + ? { + ...conversation, + expireTimer, + } + : conversation, + hasActiveCall: false, + hasGroupLink, + getPreferredBadge: () => undefined, + getProfilesForConversation: action('getProfilesForConversation'), + groupsInCommon: [], + i18n, + isAdmin: false, + isEditMemberLabelEnabled: true, + isGroup: true, + isSignalConversation: false, + leaveGroup: action('leaveGroup'), + hasMedia: true, + memberships, + memberColors, + maxGroupSize: 1001, + maxRecommendedGroupSize: 151, + pendingApprovalMemberships: times(8, () => ({ + member: getDefaultConversation(), + })), + pendingMemberships: times(5, () => ({ + metadata: {}, + member: getDefaultConversation(), + })), + selectedNavTab: NavTab.Chats, + setDisappearingMessages: action('setDisappearingMessages'), + showContactModal: action('showContactModal'), + pushPanelForConversation: action('pushPanelForConversation'), + showConversation: action('showConversation'), + startAvatarDownload: action('startAvatarDownload'), + updateGroupAttributes: async () => { + action('updateGroupAttributes')(); + }, + deleteAvatarFromDisk: action('deleteAvatarFromDisk'), + replaceAvatar: action('replaceAvatar'), + saveAvatarToDisk: action('saveAvatarToDisk'), + setMuteExpiration: action('setMuteExpiration'), + userAvatarData: [], + toggleSafetyNumberModal: action('toggleSafetyNumberModal'), + toggleAboutContactModal: action('toggleAboutContactModal'), + toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'), + onDeleteNicknameAndNote: action('onDeleteNicknameAndNote'), + onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'), + onOutgoingAudioCallInConversation: action( + 'onOutgoingAudioCallInConversation' + ), + onOutgoingVideoCallInConversation: action( + 'onOutgoingVideoCallInConversation' + ), + searchInConversation: action('searchInConversation'), + theme: ThemeType.light, + renderChooseGroupMembersModal: props => { + return ( + + ); + }, + renderConfirmAdditionsModal: props => { + return ( + + ); + }, + }; +}; export function Basic(): React.JSX.Element { const props = createProps(); @@ -170,6 +193,8 @@ export function AsLastAdmin(): React.JSX.Element { isAdmin memberships={times(32, i => ({ isAdmin: i === 2, + labelEmoji: i % 6 === 0 ? '🟢' : undefined, + labelString: i % 3 === 0 ? `Last Admin ${i}` : undefined, member: getDefaultConversation({ isMe: i === 2, }), @@ -188,6 +213,8 @@ export function AsOnlyAdmin(): React.JSX.Element { memberships={[ { isAdmin: true, + labelEmoji: undefined, + labelString: undefined, member: getDefaultConversation({ isMe: true, }), @@ -203,6 +230,18 @@ export function GroupEditable(): React.JSX.Element { return ; } +export function GroupEditableEditLabelDisabled(): React.JSX.Element { + const props = createProps(); + + return ( + + ); +} + export function GroupEditableWithCustomDisappearingTimeout(): React.JSX.Element { const props = createProps(false, DurationInSeconds.fromDays(3)); diff --git a/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx index 1bf68446b8..14fdb9a746 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.dom.tsx @@ -9,6 +9,7 @@ import type { ConversationType, PushPanelForConversationActionType, ShowConversationType, + UpdateGroupAttributesType, } from '../../../state/ducks/conversations.preload.js'; import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.preload.js'; import type { SmartChooseGroupMembersModalPropsType } from '../../../state/smart/ChooseGroupMembersModal.preload.js'; @@ -63,6 +64,7 @@ import { getTooltipContent, } from '../InAnotherCallTooltip.dom.js'; import { BadgeSustainerInstructionsDialog } from '../../BadgeSustainerInstructionsDialog.dom.js'; +import type { ContactModalStateType } from '../../../state/ducks/globalModals.preload.js'; enum ModalState { AddingGroupMembers, @@ -88,12 +90,14 @@ export type StateProps = { hasActiveCall: boolean; i18n: LocalizerType; isAdmin: boolean; + isEditMemberLabelEnabled: boolean; isGroup: boolean; isSignalConversation: boolean; groupsInCommon: ReadonlyArray; maxGroupSize: number; maxRecommendedGroupSize: number; memberships: ReadonlyArray; + memberColors: Map; pendingApprovalMemberships: ReadonlyArray; pendingAvatarDownload?: boolean; pendingMemberships: ReadonlyArray; @@ -136,21 +140,10 @@ type ActionProps = { setMuteExpiration: (id: string, muteExpiresAt: undefined | number) => unknown; showContactModal: (contactId: string, conversationId?: string) => void; showConversation: ShowConversationType; - toggleAboutContactModal: (contactId: string) => void; + toggleAboutContactModal: (options: ContactModalStateType) => void; toggleAddUserToAnotherGroupModal: (contactId?: string) => void; toggleSafetyNumberModal: (conversationId: string) => unknown; - updateGroupAttributes: ( - conversationId: string, - _: Readonly<{ - avatar?: undefined | Uint8Array; - description?: string; - title?: string; - }>, - opts: { - onSuccess?: () => unknown; - onFailure?: () => unknown; - } - ) => unknown; + updateGroupAttributes: UpdateGroupAttributesType; }; export type Props = StateProps & ActionProps; @@ -188,10 +181,12 @@ export function ConversationDetails({ hasActiveCall, i18n, isAdmin, + isEditMemberLabelEnabled, isGroup, isSignalConversation, leaveGroup, memberships, + memberColors, maxGroupSize, maxRecommendedGroupSize, onDeleteNicknameAndNote, @@ -732,6 +727,7 @@ export function ConversationDetails({ getPreferredBadge={getPreferredBadge} i18n={i18n} memberships={memberships} + memberColors={memberColors} showContactModal={showContactModal} startAddingNewMembers={() => { setModalState(ModalState.AddingGroupMembers); @@ -759,6 +755,22 @@ export function ConversationDetails({ right={hasGroupLink ? i18n('icu:on') : i18n('icu:off')} /> ) : null} + {canEditGroupInfo && isEditMemberLabelEnabled ? ( + + } + label={i18n('icu:ConversationDetails--member-label')} + onClick={() => + pushPanelForConversation({ + type: PanelType.GroupMemberLabelEditor, + }) + } + /> + ) : null} void; startEditing: (isGroupTitle: boolean) => void; - toggleAboutContactModal: (contactId: string) => void; + toggleAboutContactModal: (options: ContactModalStateType) => void; theme: ThemeType; }; @@ -237,7 +238,7 @@ export function ConversationDetailsHeader({ onClick={ev => { ev.preventDefault(); ev.stopPropagation(); - toggleAboutContactModal(conversation.id); + toggleAboutContactModal({ contactId: conversation.id }); }} className="ConversationDetailsHeader__about-button" > diff --git a/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx index af049c22ae..57c28119cb 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsIcon.dom.tsx @@ -31,6 +31,7 @@ export enum IconType { 'reset' = 'reset', 'share' = 'share', 'spinner' = 'spinner', + 'tag' = 'tag', 'timer' = 'timer', 'trash' = 'trash', 'verify' = 'verify', diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx index 64e0e36fdd..f652f30044 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.stories.tsx @@ -12,9 +12,37 @@ import type { GroupV2Membership, } from './ConversationDetailsMembershipList.dom.js'; import { ConversationDetailsMembershipList } from './ConversationDetailsMembershipList.dom.js'; +import { ContactNameColors } from '../../../types/Colors.std.js'; const { i18n } = window.SignalContext; +const createMemberships = ( + numberOfMemberships = 10 +): Array => { + return Array.from(new Array(numberOfMemberships)).map( + (_, i): GroupV2Membership => ({ + isAdmin: i % 4 === 0, + labelEmoji: i % 6 === 0 ? '🟢' : undefined, + labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined, + member: getDefaultConversation({ + isMe: i === 2, + }), + }) + ); +}; + +const getMemberColors = ( + memberships: Array +): Map => + new Map( + memberships.map((membership, i) => [ + membership.member.id, + ContactNameColors[i], + ]) + ); + +const defaultMemberships = createMemberships(); + export default { title: 'Components/Conversation/ConversationDetails/ConversationDetailsMembershipList', @@ -24,49 +52,37 @@ export default { conversationId: '123', getPreferredBadge: () => undefined, i18n, - memberships: [], + memberships: defaultMemberships, + memberColors: getMemberColors(defaultMemberships), showContactModal: action('showContactModal'), startAddingNewMembers: action('startAddingNewMembers'), theme: ThemeType.light, }, } satisfies Meta; -const createMemberships = ( - numberOfMemberships = 10 -): Array => { - return Array.from(new Array(numberOfMemberships)).map( - (_, i): GroupV2Membership => ({ - isAdmin: i % 3 === 0, - member: getDefaultConversation({ - isMe: i === 2, - }), - }) - ); -}; - export function Few(args: Props): React.JSX.Element { - const memberships = createMemberships(3); + const memberships = defaultMemberships.slice(3); return ( ); } export function Limit(args: Props): React.JSX.Element { - const memberships = createMemberships(5); + const memberships = defaultMemberships.slice(5); return ( ); } export function Limit1(args: Props): React.JSX.Element { - const memberships = createMemberships(6); + const memberships = defaultMemberships.slice(6); return ( ); } export function Limit2(args: Props): React.JSX.Element { - const memberships = createMemberships(7); + const memberships = defaultMemberships.slice(7); return ( ); @@ -74,8 +90,13 @@ export function Limit2(args: Props): React.JSX.Element { export function Many(args: Props): React.JSX.Element { const memberships = createMemberships(100); + const memberColors = getMemberColors(memberships); return ( - + ); } @@ -84,12 +105,5 @@ export function None(args: Props): React.JSX.Element { } export function CanAddNewMembers(args: Props): React.JSX.Element { - const memberships = createMemberships(10); - return ( - - ); + return ; } diff --git a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx index 15295c42f1..b5180fb0b7 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsMembershipList.dom.tsx @@ -16,10 +16,13 @@ import type { ConversationType } from '../../../state/ducks/conversations.preloa import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.preload.js'; import { PanelRow } from './PanelRow.dom.js'; import { PanelSection } from './PanelSection.dom.js'; +import { GroupMemberLabel } from '../ContactName.dom.js'; export type GroupV2Membership = { isAdmin: boolean; member: ConversationType; + labelEmoji: string | undefined; + labelString: string | undefined; }; export type Props = { @@ -29,6 +32,7 @@ export type Props = { i18n: LocalizerType; maxShownMemberCount?: number; memberships: ReadonlyArray; + memberColors: Map; showContactModal: (contactId: string, conversationId?: string) => void; startAddingNewMembers?: () => void; theme: ThemeType; @@ -79,6 +83,7 @@ export function ConversationDetailsMembershipList({ getPreferredBadge, i18n, maxShownMemberCount = 5, + memberColors, memberships, showContactModal, startAddingNewMembers, @@ -109,26 +114,50 @@ export function ConversationDetailsMembershipList({ onClick={() => startAddingNewMembers?.()} /> )} - {sortedMemberships.slice(0, membersToShow).map(({ isAdmin, member }) => ( - showContactModal(member.id, conversationId)} - icon={ - { + const contactNameColor = memberColors.get(member.id); + + return ( + showContactModal(member.id, conversationId)} + icon={ + + } + label={ +
+
+ +
+ {labelString && contactNameColor && ( +
+ +
+ )} +
+ } + right={isAdmin ? i18n('icu:GroupV2--admin') : ''} /> - } - label={ - - } - right={isAdmin ? i18n('icu:GroupV2--admin') : ''} - /> - ))} + ); + })} {showAllMembers === false && shouldHideRestMembers && ( ; + +const createProps = (conversation?: ConversationType): PropsType => ({ + conversation: conversation || getDefaultConversation({ type: 'group' }), + existingLabelEmoji: '🐘', + existingLabelString: 'Good Memory', + i18n, + popPanelForConversation: action('popPanelForConversation'), + theme: ThemeType.light, + updateGroupMemberLabel: action('changeHasGroupLink'), +}); + +export function NoExistingLabel(): React.JSX.Element { + const props = { + ...createProps(), + existingLabelEmoji: undefined, + existingLabelString: undefined, + }; + + return ; +} + +export function ExistingLabel(): React.JSX.Element { + const props = createProps(); + + return ; +} + +export function StringButNoEmoji(): React.JSX.Element { + const props = { + ...createProps(), + existingLabelEmoji: undefined, + }; + + return ; +} diff --git a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx new file mode 100644 index 0000000000..cb606a4c8a --- /dev/null +++ b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx @@ -0,0 +1,155 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { useState } from 'react'; + +import { Input } from '../../Input.dom.js'; +import { FunEmojiPicker } from '../../fun/FunEmojiPicker.dom.js'; +import { + getEmojiVariantByKey, + getEmojiVariantKeyByValue, + isEmojiVariantValue, +} from '../../fun/data/emojis.std.js'; +import { FunEmojiPickerButton } from '../../fun/FunButton.dom.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'; + +export type PropsDataType = { + conversation: ConversationType; + existingLabelEmoji: string | undefined; + existingLabelString: string | undefined; + i18n: LocalizerType; + theme: ThemeType; +}; + +export type PropsType = PropsDataType & { + popPanelForConversation: () => void; + updateGroupMemberLabel: UpdateGroupMemberLabelType; +}; + +function getEmojiVariantKey(value: string): EmojiVariantKey | undefined { + if (isEmojiVariantValue(value)) { + return getEmojiVariantKeyByValue(value); + } + + return undefined; +} + +export function GroupMemberLabelEditor({ + conversation, + existingLabelEmoji, + existingLabelString, + i18n, + popPanelForConversation, + theme, + updateGroupMemberLabel, +}: PropsType): React.JSX.Element { + const [labelEmoji, setLabelEmoji] = useState(existingLabelEmoji); + const [labelString, setLabelString] = useState(existingLabelString); + + const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); + + const emojiKey = labelEmoji ? getEmojiVariantKey(labelEmoji) : null; + const [isSaving, setIsSaving] = useState(false); + + const isDirty = + labelEmoji !== existingLabelEmoji || labelString !== existingLabelString; + const spinner = isSaving + ? { + 'aria-label': i18n('icu:ConversationDetails--member-label--saving'), + } + : undefined; + + return ( +
+
+ setEmojiPickerOpen(open)} + placement="bottom" + onSelectEmoji={data => { + const newEmoji = getEmojiVariantByKey(data.variantKey)?.value; + + setLabelEmoji(newEmoji); + }} + closeOnSelect + theme={theme} + > + + + } + maxLengthCount={24} + maxByteCount={96} + moduleClassName="GroupMemberLabelEditor" + onChange={value => { + if (!value) { + setLabelEmoji(undefined); + } + setLabelString(value); + }} + ref={undefined} + placeholder={i18n( + 'icu:ConversationDetails--member-label--placeholder' + )} + value={labelString} + whenToShowRemainingCount={20} + /> +
+
+ {i18n('icu:ConversationDetails--member-label--description')} +
+ +
+
+ { + popPanelForConversation(); + }} + > + {i18n('icu:cancel')} + + + { + setIsSaving(true); + updateGroupMemberLabel( + { + conversationId: conversation.id, + labelEmoji, + labelString, + }, + { + onSuccess() { + setIsSaving(false); + popPanelForConversation(); + }, + onFailure() { + // TODO: DESKTOP-9698 + }, + } + ); + }} + > + {i18n('icu:save')} + +
+
+ ); +} diff --git a/ts/components/fun/FunEmoji.dom.tsx b/ts/components/fun/FunEmoji.dom.tsx index 9ebd194e73..4db6ac6b68 100644 --- a/ts/components/fun/FunEmoji.dom.tsx +++ b/ts/components/fun/FunEmoji.dom.tsx @@ -39,6 +39,7 @@ function getEmojiJumboBackground( export type FunStaticEmojiSize = | 12 + | 14 | 16 | 18 | 20 @@ -62,6 +63,7 @@ export enum FunJumboEmojiSize { const funStaticEmojiSizeClasses = { 12: 'FunStaticEmoji--Size12', + 14: 'FunStaticEmoji--Size14', 16: 'FunStaticEmoji--Size16', 18: 'FunStaticEmoji--Size18', 20: 'FunStaticEmoji--Size20', diff --git a/ts/groups.preload.ts b/ts/groups.preload.ts index 1da7caf8bd..fba44eef9f 100644 --- a/ts/groups.preload.ts +++ b/ts/groups.preload.ts @@ -211,7 +211,7 @@ const GROUP_DESC_MAX_ENCRYPTED_BYTES = 8192; const TEMPORAL_AUTH_REJECTED_CODE = 401; const GROUP_ACCESS_DENIED_CODE = 403; const GROUP_NONEXISTENT_CODE = 404; -const SUPPORTED_CHANGE_EPOCH = 5; +const SUPPORTED_CHANGE_EPOCH = 6; // support for ModifyMemberLabelAction export const LINK_VERSION_ERROR = 'LINK_VERSION_ERROR'; const GROUP_INVITE_LINK_PASSWORD_LENGTH = 16; @@ -849,16 +849,59 @@ export function buildAnnouncementsOnlyChange( export function buildAccessControlAttributesChange( group: ConversationAttributesType, - value: AccessRequiredEnum + newValue: AccessRequiredEnum ): Proto.GroupChange.Actions { + const ACCESS_ENUM = Proto.AccessControl.AccessRequired; + const ROLE_ENUM = Proto.Member.Role; + const accessControlAction = new Proto.GroupChange.Actions.ModifyAttributesAccessControlAction(); - accessControlAction.attributesAccess = value; + accessControlAction.attributesAccess = newValue; + + if (!group.secretParams) { + throw new Error( + 'buildAccessControlAttributesChange: group was missing secretParams!' + ); + } const actions = new Proto.GroupChange.Actions(); actions.version = (group.revision || 0) + 1; actions.modifyAttributesAccess = accessControlAction; + // Clear out all non-admin labels + const previousValue = group.accessControl?.attributes; + if ( + previousValue !== ACCESS_ENUM.ADMINISTRATOR && + newValue === ACCESS_ENUM.ADMINISTRATOR + ) { + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + + const modifyLabelActions = (group.membersV2 || []) + .map(member => { + if (member.role === ROLE_ENUM.ADMINISTRATOR) { + return undefined; + } + + if (!member.labelString && !member.labelEmoji) { + return undefined; + } + + const modifyLabel = + new Proto.GroupChange.Actions.ModifyMemberLabelAction(); + modifyLabel.userId = encryptServiceId(clientZkGroupCipher, member.aci); + + return modifyLabel; + }) + .filter(isNotNil); + + if (modifyLabelActions.length) { + log.info( + `buildAccessControlAttributesChange: Found ${modifyLabelActions.length} non-admins with labels. Clearing.` + ); + actions.modifyMemberLabels = modifyLabelActions; + } + } + return actions; } @@ -1207,7 +1250,9 @@ export function buildModifyMemberRoleChange({ const actions = new Proto.GroupChange.Actions(); if (!group.secretParams) { - throw new Error('buildMakeAdminChange: group was missing secretParams!'); + throw new Error( + 'buildModifyMemberRoleChange: group was missing secretParams!' + ); } const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); @@ -1223,6 +1268,49 @@ export function buildModifyMemberRoleChange({ return actions; } +export function buildModifyMemberLabelChange({ + serviceId, + group, + labelEmoji, + labelString, +}: { + serviceId: ServiceIdString; + group: ConversationAttributesType; + labelEmoji: string | undefined; + labelString: string | undefined; +}): Proto.GroupChange.Actions { + const actions = new Proto.GroupChange.Actions(); + + if (!group.secretParams) { + throw new Error( + 'buildModifyMemberLabelChange: group was missing secretParams!' + ); + } + + const clientZkGroupCipher = getClientZkGroupCipher(group.secretParams); + const userIdCipherText = encryptServiceId(clientZkGroupCipher, serviceId); + + const modifyLabel = new Proto.GroupChange.Actions.ModifyMemberLabelAction(); + modifyLabel.userId = userIdCipherText; + if (labelEmoji) { + modifyLabel.labelEmoji = encryptGroupBlob( + clientZkGroupCipher, + Bytes.fromString(labelEmoji) + ); + } + if (labelString) { + modifyLabel.labelString = encryptGroupBlob( + clientZkGroupCipher, + Bytes.fromString(labelString) + ); + } + + actions.version = (group.revision || 0) + 1; + actions.modifyMemberLabels = [modifyLabel]; + + return actions; +} + export function buildPromotePendingAdminApprovalMemberChange({ group, aci, @@ -5128,6 +5216,28 @@ async function applyGroupChange({ } }); + // modifyMemberLabels?: Array; + (actions.modifyMemberLabels || []).forEach(modifyMemberLabel => { + const { userId, labelEmoji, labelString } = modifyMemberLabel; + if (!userId) { + throw new Error( + 'applyGroupChange: modifyMemberLabel had a missing userId' + ); + } + + if (members[userId]) { + members[userId] = { + ...members[userId], + labelEmoji, + labelString, + }; + } else { + throw new Error( + 'applyGroupChange: modifyMemberLabel tried to modify nonexistent member' + ); + } + }); + // modifyMemberProfileKeys?: // Array; (actions.modifyMemberProfileKeys || []).forEach(modifyMemberProfileKey => { @@ -5848,6 +5958,8 @@ async function applyGroupState({ role: member.role || MEMBER_ROLE_ENUM.DEFAULT, joinedAtVersion: member.joinedAtVersion, aci: member.userId, + labelEmoji: member.labelEmoji, + labelString: member.labelString, }; }); } @@ -6051,6 +6163,12 @@ function normalizeTimestamp(timestamp: Long | null | undefined): number { return asNumber; } +type DecryptedModifyMemberLabelAction = { + userId: AciString; + labelEmoji?: string; + labelString?: string; +}; + type DecryptedGroupChangeActions = { version?: number; sourceServiceId?: ServiceIdString; @@ -6065,6 +6183,7 @@ type DecryptedGroupChangeActions = { userId: AciString; role: Proto.Member.Role; }>; + modifyMemberLabels?: ReadonlyArray; modifyMemberProfileKeys?: ReadonlyArray<{ profileKey: Uint8Array; aci: AciString; @@ -6236,6 +6355,17 @@ function decryptGroupChange( }) ); + // modifyMemberLabels?: Array + result.modifyMemberLabels = compact( + (actions.modifyMemberLabels || []).map(modifyMemberLabel => + decryptModifyMemberLabelAction( + clientZkGroupCipher, + modifyMemberLabel, + logId + ) + ) + ); + // modifyMemberProfileKeys?: Array< // GroupChange.Actions.ModifyMemberProfileKeyAction // >; @@ -6785,6 +6915,67 @@ export function decryptGroupDescription( return undefined; } +function decryptModifyMemberLabelAction( + clientZkGroupCipher: ClientZkGroupCipher, + modifyMember: Readonly, + logId: string +): DecryptedModifyMemberLabelAction | undefined { + const { userId, labelEmoji, labelString } = modifyMember; + + // userId + strictAssert( + Bytes.isNotEmpty(userId), + 'decryptModifyMemberLabelAction: Missing userId' + ); + + let decryptedUserId: AciString; + try { + decryptedUserId = decryptAci(clientZkGroupCipher, userId); + } catch (error) { + log.warn( + `decryptModifyMemberLabelAction/${logId}: Unable to decrypt pending member userId. Dropping member.`, + Errors.toLogFormat(error) + ); + return undefined; + } + + // labelEmoji + let decryptedLabelEmoji: string | undefined; + if (Bytes.isNotEmpty(labelEmoji)) { + try { + decryptedLabelEmoji = Bytes.toString( + decryptGroupBlob(clientZkGroupCipher, labelEmoji) + ); + } catch (error) { + log.warn( + `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelEmoji. Dropping it.`, + Errors.toLogFormat(error) + ); + } + } + + // labelString + let decryptedLabelString: string | undefined; + if (Bytes.isNotEmpty(labelString)) { + try { + decryptedLabelString = Bytes.toString( + decryptGroupBlob(clientZkGroupCipher, labelString) + ); + } catch (error) { + log.warn( + `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelString. Dropping it.`, + Errors.toLogFormat(error) + ); + } + } + + return { + userId: decryptedUserId, + labelEmoji: decryptedLabelEmoji, + labelString: decryptedLabelString, + }; +} + type DecryptedGroupState = { title?: Proto.GroupAttributeBlob; disappearingMessagesTimer?: Proto.GroupAttributeBlob; @@ -6973,6 +7164,8 @@ type DecryptedMember = Readonly<{ profileKey: Uint8Array; role: Proto.Member.Role; joinedAtVersion: number; + labelEmoji?: string; + labelString?: string; }>; function decryptMember( @@ -7019,11 +7212,43 @@ function decryptMember( throw new Error(`decryptMember: Member had invalid role ${member.role}`); } + // labelEmoji + let decryptedLabelEmoji: string | undefined; + if (Bytes.isNotEmpty(member.labelEmoji)) { + try { + decryptedLabelEmoji = Bytes.toString( + decryptGroupBlob(clientZkGroupCipher, member.labelEmoji) + ); + } catch (error) { + log.warn( + `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelEmoji. Dropping it.`, + Errors.toLogFormat(error) + ); + } + } + + // labelString + let decryptedLabelString: string | undefined; + if (Bytes.isNotEmpty(member.labelString)) { + try { + decryptedLabelString = Bytes.toString( + decryptGroupBlob(clientZkGroupCipher, member.labelString) + ); + } catch (error) { + log.warn( + `decryptMemberPendingAdminApproval/${logId}: Unable to decrypt labelString. Dropping it.`, + Errors.toLogFormat(error) + ); + } + } + return { userId, profileKey, role, joinedAtVersion: dropNull(member.joinedAtVersion) ?? 0, + labelEmoji: decryptedLabelEmoji, + labelString: decryptedLabelString, }; } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 8381c1b350..5a3062ac9e 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -557,6 +557,8 @@ export type GroupV2MemberType = { aci: AciString; role: MemberRoleEnum; joinedAtVersion: number; + labelString?: string; + labelEmoji?: string; // Note that these are temporary flags, generated by applyGroupChange, but eliminated // by applyGroupState. They are used to make our diff-generation more intelligent but diff --git a/ts/models/conversations.preload.ts b/ts/models/conversations.preload.ts index 1a8b77251c..567a6a55cb 100644 --- a/ts/models/conversations.preload.ts +++ b/ts/models/conversations.preload.ts @@ -246,6 +246,7 @@ import { buildDisappearingMessagesTimerChange, buildGroupLink, buildInviteLinkPasswordChange, + buildModifyMemberLabelChange, buildModifyMemberRoleChange, buildNewGroupLinkChange, buildPromoteMemberChange, @@ -4579,6 +4580,34 @@ export class ConversationModel { } } + async updateGroupMemberLabel({ + labelEmoji, + labelString, + }: { + labelEmoji: string | undefined; + labelString: string | undefined; + }): Promise { + if (!isGroupV2(this.attributes)) { + return; + } + + log.info('updateGroupMemberLabel for conversation', this.idForLogging()); + + const ourServiceId = itemStorage.user.getCheckedAci(); + + await this.modifyGroupV2({ + name: 'updateGroupMemberLabel', + usingCredentialsFrom: [], + createGroupChange: async () => + buildModifyMemberLabelChange({ + serviceId: ourServiceId, + group: this.attributes, + labelEmoji, + labelString, + }), + }); + } + async refreshGroupLink(): Promise { if (!isGroupV2(this.attributes)) { return; diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index b9928ede6f..c0611f2d74 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -396,6 +396,8 @@ export type ConversationType = ReadonlyDeep< memberships?: ReadonlyArray<{ aci: AciString; isAdmin: boolean; + labelEmoji: string | undefined; + labelString: string | undefined; }>; pendingMemberships?: ReadonlyArray<{ serviceId: ServiceIdString; @@ -1341,6 +1343,7 @@ export const actions = { toggleSelectMessage, toggleSelectMode, updateGroupAttributes, + updateGroupMemberLabel, updateLastMessage, updateNicknameAndNote, verifyConversationsStoppingSend, @@ -4636,6 +4639,14 @@ function addMembersToGroup( }; } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ActionCreator) => any> = ReadonlyDeep< + (...params: Parameters) => void +>; + +export type UpdateGroupAttributesType = ReadonlyDeep< + ActionCreator +>; function updateGroupAttributes( conversationId: string, attributes: Readonly<{ @@ -4678,6 +4689,47 @@ function updateGroupAttributes( }; } +export type UpdateGroupMemberLabelType = ReadonlyDeep< + ActionCreator +>; +function updateGroupMemberLabel( + { + conversationId, + labelEmoji, + labelString, + }: { + conversationId: string; + labelEmoji: string | undefined; + labelString: string | undefined; + }, + { + onSuccess, + onFailure, + }: { + onSuccess?: () => unknown; + onFailure?: () => unknown; + } = {} +): ThunkAction { + return async () => { + const conversation = window.ConversationController.get(conversationId); + if (!conversation) { + throw new Error('updateGroupMemberLabel: No conversation found'); + } + + try { + await longRunningTaskWrapper({ + name: 'updateGroupMemberLabel', + idForLogging: conversation.idForLogging(), + task: async () => + conversation.updateGroupMemberLabel({ labelEmoji, labelString }), + }); + onSuccess?.(); + } catch { + onFailure?.(); + } + }; +} + function leaveGroup( conversationId: string ): ThunkAction { diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts index 753ce22901..e13b565f33 100644 --- a/ts/state/ducks/globalModals.preload.ts +++ b/ts/state/ducks/globalModals.preload.ts @@ -116,7 +116,7 @@ export type CallQualitySurveyPropsType = ReadonlyDeep<{ export type GlobalModalsStateType = ReadonlyDeep<{ addUserToAnotherGroupModalContactId?: string; - aboutContactModalContactId?: string; + aboutContactModalState?: ContactModalStateType; backfillFailureModalProps: BackfillFailureModalPropsType | undefined; callLinkAddNameModalRoomId: string | null; callLinkEditModalRoomId: string | null; @@ -395,7 +395,7 @@ export type HideCallQualitySurveyActionType = ReadonlyDeep<{ type ToggleAboutContactModalActionType = ReadonlyDeep<{ type: typeof TOGGLE_ABOUT_MODAL; - payload: string | undefined; + payload: ContactModalStateType | undefined; }>; type ToggleSignalConnectionsModalActionType = ReadonlyDeep<{ @@ -1105,11 +1105,11 @@ function toggleCallLinkPendingParticipantModal( } function toggleAboutContactModal( - contactId?: string + payload?: ContactModalStateType ): ToggleAboutContactModalActionType { return { type: TOGGLE_ABOUT_MODAL, - payload: contactId, + payload, }; } @@ -1505,7 +1505,7 @@ export function reducer( if (action.type === TOGGLE_ABOUT_MODAL) { return { ...state, - aboutContactModalContactId: action.payload, + aboutContactModalState: action.payload, }; } @@ -1639,7 +1639,7 @@ export function reducer( if (action.payload.contactId === ourId) { return { ...state, - aboutContactModalContactId: ourId, + aboutContactModalState: action.payload, }; } diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index c439d94248..14f86d9217 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -1361,10 +1361,15 @@ export function isMissingRequiredProfileSharing( ); } +export type AdminMembershipType = { + member: ConversationType; + labelEmoji: string | undefined; + labelString: string | undefined; +}; export const getGroupAdminsSelector = createSelector( getConversationSelector, (conversationSelector: GetConversationByIdType) => { - return (conversationId: string): Array => { + return (conversationId: string): Array => { const { groupId, groupVersion, @@ -1380,11 +1385,15 @@ export const getGroupAdminsSelector = createSelector( return []; } - const admins: Array = []; + const admins: Array = []; memberships.forEach(membership => { if (membership.isAdmin) { const admin = conversationSelector(membership.aci); - admins.push(admin); + admins.push({ + member: admin, + labelEmoji: membership.labelEmoji, + labelString: membership.labelString, + }); } }); return admins; diff --git a/ts/state/selectors/globalModals.std.ts b/ts/state/selectors/globalModals.std.ts index 820d4d5a28..c380d99dfa 100644 --- a/ts/state/selectors/globalModals.std.ts +++ b/ts/state/selectors/globalModals.std.ts @@ -50,6 +50,11 @@ export const getConfirmLeaveCallModalState = createSelector( ({ confirmLeaveCallModalState }) => confirmLeaveCallModalState ); +export const getAboutContactModalState = createSelector( + getGlobalModalsState, + ({ aboutContactModalState }) => aboutContactModalState +); + export const getContactModalState = createSelector( getGlobalModalsState, ({ contactModalState }) => contactModalState diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 82d01bd8f7..4ac8d13036 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -905,6 +905,17 @@ export const getPropsForMessage = ( ourAci, }); const contactNameColor = getContactNameColor(contactNameColors, authorId); + const sourceServiceId = getSourceServiceId(message, ourAci); + // TODO: DESKTOP-9698 + const sourceMember = conversation.memberships?.find( + membership => membership.aci === sourceServiceId + ); + const contactLabel = sourceMember?.labelString + ? { + labelString: sourceMember.labelString, + labelEmoji: sourceMember.labelEmoji, + } + : undefined; const { conversationColor, customColor } = getConversationColorAttributes( conversation, @@ -940,6 +951,7 @@ export const getPropsForMessage = ( canRetry: hasErrors(message), canRetryDeleteForEveryone: canRetryDeleteForEveryone(message), contact: getPropsForEmbeddedContact(message, regionCode, accountSelector), + contactLabel, contactNameColor, conversationColor, conversationId, diff --git a/ts/state/smart/AboutContactModal.preload.tsx b/ts/state/smart/AboutContactModal.preload.tsx index 80d48c06c6..dda7892456 100644 --- a/ts/state/smart/AboutContactModal.preload.tsx +++ b/ts/state/smart/AboutContactModal.preload.tsx @@ -2,11 +2,13 @@ // SPDX-License-Identifier: AGPL-3.0-only import React, { memo, useCallback } from 'react'; import { useSelector } from 'react-redux'; + import { AboutContactModal } from '../../components/conversation/AboutContactModal.dom.js'; import { isSignalConnection } from '../../util/getSignalConnections.preload.js'; -import { getIntl } from '../selectors/user.std.js'; -import { getGlobalModalsState } from '../selectors/globalModals.std.js'; +import { getIntl, getVersion } from '../selectors/user.std.js'; +import { getAboutContactModalState } from '../selectors/globalModals.std.js'; import { + getCachedConversationMemberColorsSelector, getConversationSelector, getPendingAvatarDownloadSelector, } from '../selectors/conversations.dom.js'; @@ -16,6 +18,9 @@ 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'; function isFromOrAddedByTrustedContact( conversation: ConversationType @@ -36,16 +41,43 @@ function isFromOrAddedByTrustedContact( export const SmartAboutContactModal = memo(function SmartAboutContactModal() { const i18n = useSelector(getIntl); - const globalModals = useSelector(getGlobalModalsState); - const { aboutContactModalContactId: contactId } = globalModals; + const version = useSelector(getVersion); + const items = useSelector(getItems); + const { conversationId, contactId } = + useSelector(getAboutContactModalState) ?? {}; const getConversation = useSelector(getConversationSelector); const isPendingAvatarDownload = useSelector(getPendingAvatarDownloadSelector); + + const isEditMemberLabelEnabled = isFeaturedEnabledSelector({ + betaKey: 'desktop.groupMemberLabels.edit.beta', + currentVersion: version, + remoteConfig: items.remoteConfig, + prodKey: 'desktop.groupMemberLabels.edit.prod', + }); + const sharedGroupNames = useSharedGroupNamesOnMount(contactId ?? ''); const { startAvatarDownload } = useConversationsActions(); - const conversation = getConversation(contactId); - const { id: conversationId } = conversation ?? {}; + const contact = getConversation(contactId); + const conversation = getConversation(conversationId); + + const getMemberColors = useSelector( + getCachedConversationMemberColorsSelector + ); + 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 { toggleAboutContactModal, @@ -56,32 +88,37 @@ export const SmartAboutContactModal = memo(function SmartAboutContactModal() { } = useGlobalModalActions(); const handleOpenNotePreviewModal = useCallback(() => { - strictAssert(conversationId != null, 'conversationId is required'); - toggleNotePreviewModal({ conversationId }); - }, [toggleNotePreviewModal, conversationId]); + strictAssert(contactId != null, 'contactId is required'); + toggleNotePreviewModal({ conversationId: contactId }); + }, [toggleNotePreviewModal, contactId]); - if (conversation == null) { + if (contact == null) { return null; } return ( startAvatarDownload(conversationId) : undefined } + toggleProfileNameWarningModal={toggleProfileNameWarningModal} + toggleSafetyNumberModal={toggleSafetyNumberModal} + toggleSignalConnectionsModal={toggleSignalConnectionsModal} /> ); }); diff --git a/ts/state/smart/CompositionArea.preload.tsx b/ts/state/smart/CompositionArea.preload.tsx index 85411361ef..88cfa68fe3 100644 --- a/ts/state/smart/CompositionArea.preload.tsx +++ b/ts/state/smart/CompositionArea.preload.tsx @@ -23,6 +23,7 @@ import { import { getPreferredBadgeSelector } from '../selectors/badges.preload.js'; import { getComposerStateForConversationIdSelector } from '../selectors/composer.preload.js'; import { + getCachedConversationMemberColorsSelector, getConversationSelector, getGroupAdminsSelector, getHasPanelOpen, @@ -131,6 +132,10 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ const groupAdmins = useMemo(() => { return getGroupAdmins(id); }, [getGroupAdmins, id]); + const getMemberColors = useSelector( + getCachedConversationMemberColorsSelector + ); + const memberColors = getMemberColors(id); const addedBy = useMemo(() => { if (conversation.type === 'group') { @@ -229,7 +234,9 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({ { + const contactMembership = useMemo(() => { + // TODO: DESKTOP-9698 return conversation?.memberships?.find(membership => { return membership.aci === contact.serviceId; }); }, [conversation?.memberships, contact]); - const isMember = ourMembership != null; - const isAdmin = ourMembership?.isAdmin ?? false; + const isMember = contactMembership != null; + const isAdmin = contactMembership?.isAdmin ?? false; + const getMemberColors = useSelector( + getCachedConversationMemberColorsSelector + ); + const memberColors = getMemberColors(conversationId); + const { labelEmoji: contactLabelEmoji, labelString: contactLabelString } = + contactMembership || {}; + const contactNameColor = contactId ? memberColors.get(contactId) : undefined; const { removeMemberFromGroup, @@ -81,6 +92,9 @@ export const SmartContactModal = memo(function SmartContactModal() { badges={badges} blockConversation={blockConversation} contact={contact} + contactLabelEmoji={contactLabelEmoji} + contactLabelString={contactLabelString} + contactNameColor={contactNameColor} conversation={conversation} hasActiveCall={hasActiveCall} hasStories={hasStories} diff --git a/ts/state/smart/ConversationDetails.preload.tsx b/ts/state/smart/ConversationDetails.preload.tsx index 84e435b612..e14b6ade16 100644 --- a/ts/state/smart/ConversationDetails.preload.tsx +++ b/ts/state/smart/ConversationDetails.preload.tsx @@ -21,6 +21,7 @@ import { import { getActiveCallState } from '../selectors/calling.std.js'; import { getAllComposableConversations, + getCachedConversationMemberColorsSelector, getConversationByIdSelector, getConversationByServiceIdSelector, getPendingAvatarDownloadSelector, @@ -28,9 +29,10 @@ import { import { getAreWeASubscriber, getDefaultConversationColor, + getItems, } from '../selectors/items.dom.js'; import { getSelectedNavTab } from '../selectors/nav.preload.js'; -import { getIntl, getTheme } from '../selectors/user.std.js'; +import { getIntl, getTheme, 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'; @@ -43,6 +45,7 @@ import { useGlobalModalActions } from '../ducks/globalModals.preload.js'; 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'; const { sortBy } = lodash; @@ -93,6 +96,8 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ const i18n = useSelector(getIntl); const theme = useSelector(getTheme); const activeCall = useSelector(getActiveCallState); + const version = useSelector(getVersion); + const items = useSelector(getItems); const allComposableConversations = useSelector(getAllComposableConversations); const areWeASubscriber = useSelector(getAreWeASubscriber); const badgesSelector = useSelector(getBadgesSelector); @@ -104,6 +109,9 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ const getPreferredBadge = useSelector(getPreferredBadgeSelector); const isPendingAvatarDownload = useSelector(getPendingAvatarDownloadSelector); const selectedNavTab = useSelector(getSelectedNavTab); + const getCachedConversationMemberColors = useSelector( + getCachedConversationMemberColorsSelector + ); const { acceptConversation, @@ -155,6 +163,13 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ const badges = badgesSelector(conversation.badges); const canAddNewMembers = conversation.canAddNewMembers ?? false; const canEditGroupInfo = conversation.canEditGroupInfo ?? false; + const isEditMemberLabelEnabled = isFeaturedEnabledSelector({ + betaKey: 'desktop.groupMemberLabels.edit.beta', + currentVersion: version, + remoteConfig: items.remoteConfig, + prodKey: 'desktop.groupMemberLabels.edit.prod', + }); + const groupsInCommon = getGroupsInCommonSorted( conversation, allComposableConversations @@ -169,6 +184,7 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ const maxGroupSize = getGroupSizeHardLimit(1001); const maxRecommendedGroupSize = getGroupSizeRecommendedLimit(151); const userAvatarData = conversation.avatars ?? []; + const memberColors = getCachedConversationMemberColors(conversationId); const handleDeleteNicknameAndNote = useCallback(() => { updateNicknameAndNote(conversationId, { nickname: null, note: null }); @@ -217,12 +233,14 @@ export const SmartConversationDetails = memo(function SmartConversationDetails({ hasGroupLink={hasGroupLink} i18n={i18n} isAdmin={isAdmin} + isEditMemberLabelEnabled={isEditMemberLabelEnabled} isGroup={isGroup} isSignalConversation={isSignalConversation(conversation)} leaveGroup={leaveGroup} hasMedia={hasMedia} maxGroupSize={maxGroupSize} maxRecommendedGroupSize={maxRecommendedGroupSize} + memberColors={memberColors} memberships={memberships} onDeleteNicknameAndNote={handleDeleteNicknameAndNote} onOpenEditNicknameAndNoteModal={handleOpenEditNicknameAndNoteModal} diff --git a/ts/state/smart/ConversationPanel.preload.tsx b/ts/state/smart/ConversationPanel.preload.tsx index bbc53e4c5a..3798c07d88 100644 --- a/ts/state/smart/ConversationPanel.preload.tsx +++ b/ts/state/smart/ConversationPanel.preload.tsx @@ -41,6 +41,7 @@ import { useReducedMotion } from '../../hooks/useReducedMotion.dom.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; import { SmartPinnedMessagesPanel } from './PinnedMessagesPanel.preload.js'; import { SmartMiniPlayer } from './MiniPlayer.preload.js'; +import { SmartGroupMemberLabelEditor } from './SmartGroupMemberLabelEditor.preload.js'; const log = createLogger('ConversationPanel'); @@ -383,6 +384,10 @@ function PanelElement({ return ; } + if (panel.type === PanelType.GroupMemberLabelEditor) { + return ; + } + if (panel.type === PanelType.GroupPermissions) { return ; } @@ -422,6 +427,7 @@ function getPanelKey(panel: PanelRenderType): string { case PanelType.GroupLinkManagement: case PanelType.GroupPermissions: case PanelType.GroupV1Members: + case PanelType.GroupMemberLabelEditor: case PanelType.NotificationSettings: case PanelType.PinnedMessages: case PanelType.StickerManager: diff --git a/ts/state/smart/GV1Members.preload.tsx b/ts/state/smart/GV1Members.preload.tsx index d0f5745d33..a4c7fbc332 100644 --- a/ts/state/smart/GV1Members.preload.tsx +++ b/ts/state/smart/GV1Members.preload.tsx @@ -8,6 +8,7 @@ import { ConversationDetailsMembershipList } from '../../components/conversation import { assertDev } from '../../util/assert.std.js'; import { getGroupMemberships } from '../../util/getGroupMemberships.dom.js'; import { + getCachedConversationMemberColorsSelector, getConversationByIdSelector, getConversationByServiceIdSelector, } from '../selectors/conversations.dom.js'; @@ -31,6 +32,10 @@ export const SmartGV1Members = memo(function SmartGV1Members({ const conversationByServiceIdSelector = useSelector( getConversationByServiceIdSelector ); + const getMemberColors = useSelector( + getCachedConversationMemberColorsSelector + ); + const memberColors = getMemberColors(conversationId); const conversation = conversationSelector(conversationId); assertDev( @@ -50,6 +55,7 @@ export const SmartGV1Members = memo(function SmartGV1Members({ i18n={i18n} getPreferredBadge={getPreferredBadge} maxShownMemberCount={32} + memberColors={memberColors} memberships={memberships} showContactModal={showContactModal} theme={theme} diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx index 32408a964e..e0c6c17d65 100644 --- a/ts/state/smart/GlobalModalContainer.preload.tsx +++ b/ts/state/smart/GlobalModalContainer.preload.tsx @@ -149,7 +149,7 @@ export const SmartGlobalModalContainer = memo( const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0; const { - aboutContactModalContactId, + aboutContactModalState, addUserToAnotherGroupModalContactId, backfillFailureModalProps, callLinkAddNameModalRoomId, @@ -304,7 +304,7 @@ export const SmartGlobalModalContainer = memo( finishKeyTransparencyOnboarding={finishKeyTransparencyOnboarding} hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal} i18n={i18n} - isAboutContactModalVisible={aboutContactModalContactId != null} + isAboutContactModalVisible={aboutContactModalState != null} isKeyTransparencyErrorVisible={isKeyTransparencyErrorVisible} isKeyTransparencyOnboardingVisible={isKeyTransparencyOnboardingVisible} isProfileNameWarningModalVisible={isProfileNameWarningModalVisible} diff --git a/ts/state/smart/SafetyNumberViewer.preload.tsx b/ts/state/smart/SafetyNumberViewer.preload.tsx index 181146c272..89fa633450 100644 --- a/ts/state/smart/SafetyNumberViewer.preload.tsx +++ b/ts/state/smart/SafetyNumberViewer.preload.tsx @@ -8,7 +8,7 @@ import { SafetyNumberViewer } from '../../components/SafetyNumberViewer.dom.js'; import type { SafetyNumberProps } from '../../components/SafetyNumberChangeDialog.dom.js'; import { getContactSafetyNumberSelector } from '../selectors/safetyNumber.std.js'; import { getConversationSelector } from '../selectors/conversations.dom.js'; -import { getIntl } from '../selectors/user.std.js'; +import { getIntl, getVersion } from '../selectors/user.std.js'; import { getItems } from '../selectors/items.dom.js'; import { useSafetyNumberActions } from '../ducks/safetyNumber.preload.js'; import { keyTransparency } from '../../services/keyTransparency.preload.js'; @@ -31,7 +31,7 @@ export const SmartSafetyNumberViewer = memo(function SmartSafetyNumberViewer({ const contact = conversationSelector(contactID); const items = useSelector(getItems); - const version = window.SignalContext.getVersion(); + const version = useSelector(getVersion); const isKeyTransparencyEnabled = isFeaturedEnabledSelector({ betaKey: 'desktop.keyTransparency.beta', prodKey: 'desktop.keyTransparency.prod', diff --git a/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx b/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx new file mode 100644 index 0000000000..20f550956a --- /dev/null +++ b/ts/state/smart/SmartGroupMemberLabelEditor.preload.tsx @@ -0,0 +1,55 @@ +// 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 { getIntl, getTheme, getUser } from '../selectors/user.std.js'; +import { useConversationsActions } from '../ducks/conversations.preload.js'; +import { createLogger } from '../../logging/log.std.js'; + +const log = createLogger('SmartGroupMemberLabelEditor'); + +export type SmartGroupMemberLabelEditorProps = Readonly<{ + conversationId: string; +}>; + +export const SmartGroupMemberLabelEditor = memo( + function SmartGroupMemberLabelEditor({ + conversationId, + }: SmartGroupMemberLabelEditorProps) { + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); + const user = useSelector(getUser); + + const conversationSelector = useSelector(getConversationSelector); + const conversation = conversationSelector(conversationId); + const { updateGroupMemberLabel, popPanelForConversation } = + useConversationsActions(); + + const { ourAci } = user; + // TODO: DESKTOP-9698 + const ourMembership = conversation.memberships?.find( + membership => membership?.aci === ourAci + ); + if (!ourMembership) { + log.warn('User was not found in group, leaving this pane!'); + popPanelForConversation(); + return null; + } + const { labelEmoji: existingLabelEmoji, labelString: existingLabelString } = + ourMembership; + + return ( + + ); + } +); diff --git a/ts/test-helpers/getDefaultConversation.std.ts b/ts/test-helpers/getDefaultConversation.std.ts index 9ed21fb7f8..ba3df04659 100644 --- a/ts/test-helpers/getDefaultConversation.std.ts +++ b/ts/test-helpers/getDefaultConversation.std.ts @@ -71,6 +71,8 @@ export function getDefaultGroup( const memberships = Array.from(Array(casual.integer(1, 20)), () => ({ aci: generateAci(), isAdmin: Boolean(casual.coin_flip), + labelEmoji: undefined, + labelString: undefined, })); return { diff --git a/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts b/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts index 840dc0c46e..05d30bd1f9 100644 --- a/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts +++ b/ts/test-node/conversations/isConversationTooBigToRing_test.dom.ts @@ -14,7 +14,12 @@ const CONFIG_KEY = 'global.calling.maxGroupCallRingSize'; describe('isConversationTooBigToRing', () => { const fakeMemberships = (count: number) => - times(count, () => ({ aci: generateAci(), isAdmin: false })); + times(count, () => ({ + aci: generateAci(), + isAdmin: false, + labelEmoji: undefined, + labelString: undefined, + })); it('returns false if there are no memberships (i.e., for a direct conversation)', async () => { await updateRemoteConfig([]); diff --git a/ts/test-node/util/getGroupMemberships_test.dom.ts b/ts/test-node/util/getGroupMemberships_test.dom.ts index 9b1a83b9e7..bf14682918 100644 --- a/ts/test-node/util/getGroupMemberships_test.dom.ts +++ b/ts/test-node/util/getGroupMemberships_test.dom.ts @@ -56,6 +56,8 @@ describe('getGroupMemberships', () => { { aci: generateAci(), isAdmin: true, + labelEmoji: undefined, + labelString: undefined, }, ], }; @@ -74,6 +76,8 @@ describe('getGroupMemberships', () => { { aci: normalizeAci(unregisteredConversation.serviceId, 'test'), isAdmin: true, + labelEmoji: undefined, + labelString: 'Task Wrangler', }, ], }; @@ -87,6 +91,8 @@ describe('getGroupMemberships', () => { assert.deepEqual(result[0], { isAdmin: true, member: unregisteredConversation, + labelEmoji: undefined, + labelString: 'Task Wrangler', }); }); @@ -96,10 +102,14 @@ describe('getGroupMemberships', () => { { aci: normalizeAci(normalConversation2.serviceId, 'test'), isAdmin: false, + labelEmoji: undefined, + labelString: undefined, }, { aci: normalizeAci(normalConversation1.serviceId, 'test'), isAdmin: true, + labelEmoji: '✅', + labelString: 'Task Wrangler', }, ], }; @@ -113,10 +123,14 @@ describe('getGroupMemberships', () => { assert.deepEqual(result[0], { isAdmin: false, member: normalConversation2, + labelEmoji: undefined, + labelString: undefined, }); assert.deepEqual(result[1], { isAdmin: true, member: normalConversation1, + labelEmoji: '✅', + labelString: 'Task Wrangler', }); }); }); diff --git a/ts/types/Panels.std.ts b/ts/types/Panels.std.ts index 56a845c5ac..5130db882e 100644 --- a/ts/types/Panels.std.ts +++ b/ts/types/Panels.std.ts @@ -14,6 +14,7 @@ export enum PanelType { GroupLinkManagement = 'GroupLinkManagement', GroupPermissions = 'GroupPermissions', GroupV1Members = 'GroupV1Members', + GroupMemberLabelEditor = 'GroupMemberLabelEditor', MessageDetails = 'MessageDetails', NotificationSettings = 'NotificationSettings', PinnedMessages = 'PinnedMessages', @@ -34,6 +35,7 @@ export type PanelRequestType = ReadonlyDeep< | { type: PanelType.GroupLinkManagement } | { type: PanelType.GroupPermissions } | { type: PanelType.GroupV1Members } + | { type: PanelType.GroupMemberLabelEditor } | { type: PanelType.MessageDetails; args: { messageId: string } } | { type: PanelType.NotificationSettings } | { type: PanelType.PinnedMessages } @@ -54,6 +56,7 @@ export type PanelRenderType = ReadonlyDeep< | { type: PanelType.GroupLinkManagement } | { type: PanelType.GroupPermissions } | { type: PanelType.GroupV1Members } + | { type: PanelType.GroupMemberLabelEditor } | { type: PanelType.MessageDetails; args: { message: ReadonlyMessageAttributesType }; diff --git a/ts/util/getConversationTitleForPanelType.std.ts b/ts/util/getConversationTitleForPanelType.std.ts index 828df3c047..51bf8c0894 100644 --- a/ts/util/getConversationTitleForPanelType.std.ts +++ b/ts/util/getConversationTitleForPanelType.std.ts @@ -35,6 +35,10 @@ export function getConversationTitleForPanelType( return i18n('icu:ConversationDetails--requests-and-invites'); } + if (panelType === PanelType.GroupMemberLabelEditor) { + return i18n('icu:ConversationDetails--member-label'); + } + if (panelType === PanelType.GroupLinkManagement) { return i18n('icu:ConversationDetails--group-link'); } diff --git a/ts/util/getGroupMemberships.dom.ts b/ts/util/getGroupMemberships.dom.ts index 6329d2da35..c84122b5b7 100644 --- a/ts/util/getGroupMemberships.dom.ts +++ b/ts/util/getGroupMemberships.dom.ts @@ -39,7 +39,15 @@ export const getGroupMemberships = ( if (!member) { return result; } - return [...result, { isAdmin: membership.isAdmin, member }]; + return [ + ...result, + { + isAdmin: membership.isAdmin, + labelEmoji: membership.labelEmoji, + labelString: membership.labelString, + member, + }, + ]; }, [] ), diff --git a/ts/util/groupMembershipUtils.preload.ts b/ts/util/groupMembershipUtils.preload.ts index a2098c0e70..067455be19 100644 --- a/ts/util/groupMembershipUtils.preload.ts +++ b/ts/util/groupMembershipUtils.preload.ts @@ -177,6 +177,8 @@ export function getMemberships( ): ReadonlyArray<{ aci: AciString; isAdmin: boolean; + labelEmoji: string | undefined; + labelString: string | undefined; }> { if (!isGroupV2(conversationAttrs)) { return EMPTY_ARRAY; @@ -186,6 +188,8 @@ export function getMemberships( return members.map(member => ({ isAdmin: member.role === Proto.Member.Role.ADMINISTRATOR, aci: member.aci, + labelEmoji: member.labelEmoji, + labelString: member.labelString, })); }