From 909896d65c5074797b03baed32497e4bfccff345 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Thu, 5 Mar 2026 11:18:39 +1000 Subject: [PATCH] Introduce new permission for group member labels --- _locales/en/messages.json | 48 +++++- protos/Backups.proto | 8 + protos/Groups.proto | 8 +- .../components/ConversationDetails.scss | 1 - .../GroupMemberLabelEditor.dom.stories.tsx | 18 +- .../GroupMemberLabelEditor.dom.tsx | 51 +++++- .../GroupV2Permissions.dom.stories.tsx | 3 + .../GroupV2Permissions.dom.tsx | 80 +++++++++ ts/groupChange.std.ts | 30 ++++ ts/groups.preload.ts | 129 ++++++++++++++- ts/model-types.d.ts | 1 + ts/models/conversations.preload.ts | 54 ++---- ts/services/backups/export.preload.ts | 10 ++ ts/services/backups/import.preload.ts | 16 ++ ts/state/ducks/conversations.preload.ts | 26 +++ ts/state/smart/ConversationPanel.preload.tsx | 154 ++++++++++-------- .../smart/GroupMemberLabelEditor.preload.tsx | 13 +- ts/state/smart/GroupV2Permissions.preload.tsx | 2 + ts/types/GroupMemberLabels.std.ts | 9 +- ts/types/groups.std.ts | 5 + ts/util/getConversation.preload.ts | 1 + 21 files changed, 519 insertions(+), 148 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 5708fdc41f..4cabc9fc57 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5340,30 +5340,54 @@ "messageformat": "An admin changed who can edit group info to \"All members.\"", "description": "Shown in timeline or conversation preview when v2 group changes" }, - "icu:GroupV2--access-members--admins--other": { - "messageformat": "{adminName} changed who can edit group membership to \"Only admins.\"", - "description": "Shown in timeline or conversation preview when v2 group changes" - }, "icu:GroupV2--access-members--admins--you": { "messageformat": "You changed who can edit group membership to \"Only admins.\"", "description": "Shown in timeline or conversation preview when v2 group changes" }, + "icu:GroupV2--access-members--admins--other": { + "messageformat": "{adminName} changed who can edit group membership to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, "icu:GroupV2--access-members--admins--unknown": { "messageformat": "An admin changed who can edit group membership to \"Only admins.\"", "description": "Shown in timeline or conversation preview when v2 group changes" }, - "icu:GroupV2--access-members--all--other": { - "messageformat": "{adminName} changed who can edit group membership to \"All members.\"", - "description": "Shown in timeline or conversation preview when v2 group changes" - }, "icu:GroupV2--access-members--all--you": { "messageformat": "You changed who can edit group membership to \"All members.\"", "description": "Shown in timeline or conversation preview when v2 group changes" }, + "icu:GroupV2--access-members--all--other": { + "messageformat": "{adminName} changed who can edit group membership to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, "icu:GroupV2--access-members--all--unknown": { "messageformat": "An admin changed who can edit group membership to \"All members.\"", "description": "Shown in timeline or conversation preview when v2 group changes" }, + "icu:GroupV2--access-member-label--admins--you": { + "messageformat": "You changed who can add member labels to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "icu:GroupV2--access-member-label--admins--other": { + "messageformat": "{adminName} changed who can add member labels to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "icu:GroupV2--access-member-label--admins--unknown": { + "messageformat": "An admin changed who can add member labels to \"Only admins.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "icu:GroupV2--access-member-label--all--you": { + "messageformat": "You changed who can add member labels to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "icu:GroupV2--access-member-label--all--other": { + "messageformat": "{adminName} changed who can add member labels to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, + "icu:GroupV2--access-member-label--all--unknown": { + "messageformat": "An admin changed who can add member labels to \"All members.\"", + "description": "Shown in timeline or conversation preview when v2 group changes" + }, "icu:GroupV2--access-invite-link--disabled--you": { "messageformat": "You disabled admin approval for the group link.", "description": "Shown in timeline or conversation preview when v2 group changes" @@ -6384,6 +6408,14 @@ "messageformat": "Choose who can send messages to the group.", "description": "This is the additional info for the 'who can send messages' panel" }, + "icu:ConversationDetails--member-label--label": { + "messageformat": "Who can add member labels", + "description": "A label for a select box to choose whether all members or just admins can set Member Labels in groups" + }, + "icu:ConversationDetails--member-label--info": { + "messageformat": "Choose who can add member labels in this group.", + "description": "More information about the setting for member labels." + }, "icu:ConversationDetails--label-clear-warning--title": { "messageformat": "Member labels will be cleared", "description": "(Deleted 2026/03/04) When the user changes the 'edit group info' permission to 'Admins only', this dialog shows. Title of dialog." diff --git a/protos/Backups.proto b/protos/Backups.proto index 07c9286402..1038bdab04 100644 --- a/protos/Backups.proto +++ b/protos/Backups.proto @@ -370,6 +370,7 @@ message Group { AccessRequired attributes = 1; AccessRequired members = 2; AccessRequired addFromInviteLink = 3; + AccessRequired memberLabel = 4; } } @@ -1052,6 +1053,7 @@ message GroupChangeChatUpdate { GroupAvatarUpdate groupAvatarUpdate = 4; GroupDescriptionUpdate groupDescriptionUpdate = 5; GroupMembershipAccessLevelChangeUpdate groupMembershipAccessLevelChangeUpdate = 6; + GroupMemberLabelAccessLevelChangeUpdate groupMemberLabelAccessLevelChangeUpdate = 35; GroupAttributesAccessLevelChangeUpdate groupAttributesAccessLevelChangeUpdate = 7; GroupAnnouncementOnlyChangeUpdate groupAnnouncementOnlyChangeUpdate = 8; GroupAdminStatusUpdate groupAdminStatusUpdate = 9; @@ -1080,6 +1082,7 @@ message GroupChangeChatUpdate { GroupV2MigrationDroppedMembersUpdate groupV2MigrationDroppedMembersUpdate = 32; GroupSequenceOfRequestsAndCancelsUpdate groupSequenceOfRequestsAndCancelsUpdate = 33; GroupExpirationTimerUpdate groupExpirationTimerUpdate = 34; + // next: 36 } } @@ -1126,6 +1129,11 @@ message GroupMembershipAccessLevelChangeUpdate { GroupV2AccessLevel accessLevel = 2; } +message GroupMemberLabelAccessLevelChangeUpdate { + optional bytes updaterAci = 1; + GroupV2AccessLevel accessLevel = 2; +} + message GroupAttributesAccessLevelChangeUpdate { optional bytes updaterAci = 1; GroupV2AccessLevel accessLevel = 2; diff --git a/protos/Groups.proto b/protos/Groups.proto index 50b718ed36..6415509d50 100644 --- a/protos/Groups.proto +++ b/protos/Groups.proto @@ -67,6 +67,7 @@ message AccessControl { AccessRequired attributes = 1; AccessRequired members = 2; AccessRequired addFromInviteLink = 3; + AccessRequired member_label = 4; } message Group { @@ -224,6 +225,10 @@ message GroupChange { AccessControl.AccessRequired addFromInviteLinkAccess = 1; } + message ModifyMemberLabelAccessControlAction { + AccessControl.AccessRequired member_label_access = 1; + } + message ModifyInviteLinkPasswordAction { bytes inviteLinkPassword = 1; } @@ -252,6 +257,7 @@ message GroupChange { ModifyAttributesAccessControlAction modifyAttributesAccess = 13; ModifyMembersAccessControlAction modifyMemberAccess = 14; ModifyAddFromInviteLinkAccessControlAction modifyAddFromInviteLinkAccess = 15; // change epoch = 1 + ModifyMemberLabelAccessControlAction modify_member_label_access = 27; // change epoch = 6 repeated AddMemberPendingAdminApprovalAction addMembersPendingAdminApproval = 16; // change epoch = 1 repeated DeleteMemberPendingAdminApprovalAction deleteMembersPendingAdminApproval = 17; // change epoch = 1 repeated PromoteMemberPendingAdminApprovalAction promoteMembersPendingAdminApproval = 18; // change epoch = 1 @@ -261,7 +267,7 @@ message GroupChange { repeated AddMemberBannedAction add_members_banned = 22; // change epoch = 4 repeated DeleteMemberBannedAction delete_members_banned = 23; // change epoch = 4 repeated PromoteMemberPendingPniAciProfileKeyAction promote_members_pending_pni_aci_profile_key = 24; // change epoch = 5 - // next: 27 + // next: 28 } bytes actions = 1; diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 602a68bacf..277eed7fdf 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -418,7 +418,6 @@ &__right { position: relative; color: variables.$color-gray-45; - min-width: 100px; } &__actions { diff --git a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.stories.tsx b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.stories.tsx index bc0875f3d0..f175632f3c 100644 --- a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.stories.tsx +++ b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.stories.tsx @@ -23,12 +23,14 @@ export default { } satisfies Meta; const createProps = (): PropsType => ({ - group: getDefaultConversation({ type: 'group' }), - me: getDefaultConversation({ type: 'direct' }), + canAddLabel: true, existingLabelEmoji: '🐘', existingLabelString: 'Good Memory', getPreferredBadge: () => undefined, + group: getDefaultConversation({ type: 'group' }), i18n, + isActive: true, + me: getDefaultConversation({ type: 'direct' }), membersWithLabel: [], ourColor: '160', popPanelForConversation: action('popPanelForConversation'), @@ -96,17 +98,7 @@ export function ThrowsErrorOnSave(): React.JSX.Element { export function PermissionsError(): React.JSX.Element { const props: PropsType = createProps(); - return ( - - ); + return ; } export function PermissionsRestrictedButAdmin(): React.JSX.Element { diff --git a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx index 1764078a73..b3315dd061 100644 --- a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx +++ b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx @@ -44,10 +44,12 @@ import type { Location } from '../../../types/Nav.std.js'; import { usePrevious } from '../../../hooks/usePrevious.std.js'; export type PropsDataType = { + canAddLabel: boolean; existingLabelEmoji: string | undefined; existingLabelString: string | undefined; group: ConversationType; i18n: LocalizerType; + isActive: boolean; me: ConversationType; membersWithLabel: Array<{ contactNameColor: string; @@ -90,12 +92,14 @@ function getEmojiVariantKey(value: string): EmojiVariantKey | undefined { } export function GroupMemberLabelEditor({ + canAddLabel, group, me, existingLabelEmoji, existingLabelString, getPreferredBadge, i18n, + isActive, membersWithLabel, ourColor, popPanelForConversation, @@ -104,6 +108,8 @@ export function GroupMemberLabelEditor({ }: PropsType): React.JSX.Element { const [isShowingGeneralError, setIsShowingGeneralError] = React.useState(false); + const [isShowingPermissionsError, setIsShowingPermissionsError] = + React.useState(false); const messageContainer = useRef(null); @@ -131,6 +137,17 @@ export function GroupMemberLabelEditor({ ? { labelEmoji, labelString: labelStringForSave } : undefined; + useEffect(() => { + if (!canAddLabel && isActive) { + setIsShowingPermissionsError(true); + } + }, [ + canAddLabel, + isActive, + isShowingPermissionsError, + setIsShowingPermissionsError, + ]); + const tryClose = React.useRef<() => void | undefined>(); const [confirmDiscardModal, confirmDiscardIf] = useConfirmDiscard({ i18n, @@ -397,7 +414,7 @@ export function GroupMemberLabelEditor({ {confirmDiscardModal} { if (!value) { setIsShowingGeneralError(false); @@ -426,6 +443,38 @@ export function GroupMemberLabelEditor({ + { + if (!value) { + setIsShowingPermissionsError(false); + popPanelForConversation(); + } + }} + > + + + + {i18n('icu:ConversationDetails--member-label--error-title')} + + + {i18n('icu:ConversationDetails--member-label--error-permissions')} + + + + { + popPanelForConversation(); + setIsShowingPermissionsError(false); + }} + > + {i18n('icu:ok')} + + + + ); } diff --git a/ts/components/conversation/conversation-details/GroupV2Permissions.dom.stories.tsx b/ts/components/conversation/conversation-details/GroupV2Permissions.dom.stories.tsx index c13439118b..812694ee68 100644 --- a/ts/components/conversation/conversation-details/GroupV2Permissions.dom.stories.tsx +++ b/ts/components/conversation/conversation-details/GroupV2Permissions.dom.stories.tsx @@ -32,6 +32,9 @@ const createProps = (): PropsType => ({ 'setAccessControlAttributesSetting' ), setAccessControlMembersSetting: action('setAccessControlMembersSetting'), + setAccessControlMemberLabelSetting: action( + 'setAccessControlMemberLabelSetting' + ), setAnnouncementsOnly: action('setAnnouncementsOnly'), }); diff --git a/ts/components/conversation/conversation-details/GroupV2Permissions.dom.tsx b/ts/components/conversation/conversation-details/GroupV2Permissions.dom.tsx index b4507ef150..f2d3abaf5c 100644 --- a/ts/components/conversation/conversation-details/GroupV2Permissions.dom.tsx +++ b/ts/components/conversation/conversation-details/GroupV2Permissions.dom.tsx @@ -8,6 +8,7 @@ import { SignalService as Proto } from '../../../protobuf/index.std.js'; import { PanelRow } from './PanelRow.dom.js'; import { PanelSection } from './PanelSection.dom.js'; import { Select } from '../../Select.dom.js'; +import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js'; export type PropsDataType = { conversation?: ConversationType; @@ -17,6 +18,7 @@ export type PropsDataType = { type PropsActionType = { setAccessControlAttributesSetting: (id: string, value: number) => void; setAccessControlMembersSetting: (id: string, value: number) => void; + setAccessControlMemberLabelSetting: (id: string, value: number) => void; setAnnouncementsOnly: (id: string, value: boolean) => void; }; @@ -27,18 +29,34 @@ export function GroupV2Permissions({ i18n, setAccessControlAttributesSetting, setAccessControlMembersSetting, + setAccessControlMemberLabelSetting, setAnnouncementsOnly, }: PropsType): React.JSX.Element { const AccessControlEnum = Proto.AccessControl.AccessRequired; + const [isWarningAboutClearingLabels, setIsWarningAboutClearingLabels] = + React.useState(false); const addMembersSelectId = useId(); const groupInfoSelectId = useId(); const announcementSelectId = useId(); + const memberLabelSelectId = useId(); if (conversation === undefined) { throw new Error('GroupV2Permissions rendered without a conversation'); } + const nonAdminsHaveLabels = conversation.memberships?.some( + membership => !membership.isAdmin && membership.labelString + ); + const updateAccessControlMemberLabel = (value: string) => { + const newValue = Number(value); + if (newValue === AccessControlEnum.ADMINISTRATOR && nonAdminsHaveLabels) { + setIsWarningAboutClearingLabels(true); + return; + } + + setAccessControlMemberLabelSetting(conversation.id, Number(value)); + }; const updateAccessControlAttributes = (value: string) => { setAccessControlAttributesSetting(conversation.id, Number(value)); }; @@ -114,6 +132,68 @@ export function GroupV2Permissions({ } /> )} + + {i18n('icu:ConversationDetails--member-label--label')} + + } + info={i18n('icu:ConversationDetails--member-label--info')} + right={ +