- {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,
}));
}