Introduce new permission for group member labels

This commit is contained in:
Scott Nonnenberg
2026-03-05 11:18:39 +10:00
committed by GitHub
parent 095e24327b
commit 909896d65c
21 changed files with 519 additions and 148 deletions

View File

@@ -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."

View File

@@ -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;

View File

@@ -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;

View File

@@ -418,7 +418,6 @@
&__right {
position: relative;
color: variables.$color-gray-45;
min-width: 100px;
}
&__actions {

View File

@@ -23,12 +23,14 @@ export default {
} satisfies Meta<PropsType>;
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 (
<GroupMemberLabelEditor
{...props}
group={{
...props.group,
areWeAdmin: false,
accessControlAttributes:
Proto.AccessControl.AccessRequired.ADMINISTRATOR,
}}
/>
);
return <GroupMemberLabelEditor {...props} canAddLabel={false} />;
}
export function PermissionsRestrictedButAdmin(): React.JSX.Element {

View File

@@ -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<HTMLDivElement | null>(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({
</div>
{confirmDiscardModal}
<AxoAlertDialog.Root
open={isShowingGeneralError}
open={isShowingGeneralError && isActive}
onOpenChange={value => {
if (!value) {
setIsShowingGeneralError(false);
@@ -426,6 +443,38 @@ export function GroupMemberLabelEditor({
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
<AxoAlertDialog.Root
open={isShowingPermissionsError && isActive}
onOpenChange={value => {
if (!value) {
setIsShowingPermissionsError(false);
popPanelForConversation();
}
}}
>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:ConversationDetails--member-label--error-title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n('icu:ConversationDetails--member-label--error-permissions')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action
variant="primary"
arrow={false}
onClick={() => {
popPanelForConversation();
setIsShowingPermissionsError(false);
}}
>
{i18n('icu:ok')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
</div>
);
}

View File

@@ -32,6 +32,9 @@ const createProps = (): PropsType => ({
'setAccessControlAttributesSetting'
),
setAccessControlMembersSetting: action('setAccessControlMembersSetting'),
setAccessControlMemberLabelSetting: action(
'setAccessControlMemberLabelSetting'
),
setAnnouncementsOnly: action('setAnnouncementsOnly'),
});

View File

@@ -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({
}
/>
)}
<PanelRow
label={
<label htmlFor={memberLabelSelectId}>
{i18n('icu:ConversationDetails--member-label--label')}
</label>
}
info={i18n('icu:ConversationDetails--member-label--info')}
right={
<Select
id={memberLabelSelectId}
onChange={updateAccessControlMemberLabel}
options={accessControlOptions}
value={String(conversation.accessControlMemberLabel)}
/>
}
/>
<AxoAlertDialog.Root
open={isWarningAboutClearingLabels}
onOpenChange={value => {
if (!value) {
setIsWarningAboutClearingLabels(false);
}
}}
>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:ConversationDetails--label-clear-warning--title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n(
'icu:ConversationDetails--label-clear-warning--description'
)}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action
variant="secondary"
arrow={false}
onClick={() => {
setIsWarningAboutClearingLabels(false);
}}
>
{i18n('icu:cancel')}
</AxoAlertDialog.Action>
<AxoAlertDialog.Action
variant="primary"
arrow={false}
onClick={() => {
setAccessControlMemberLabelSetting(
conversation.id,
AccessControlEnum.ADMINISTRATOR
);
setIsWarningAboutClearingLabels(false);
}}
>
{i18n('icu:ConversationDetails--label-clear-warning--continue')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
</PanelSection>
);
}

View File

@@ -276,6 +276,36 @@ function renderChangeDetail<T extends string | React.JSX.Element>(
);
return '';
}
if (detail.type === 'access-member-label') {
const { newPrivilege } = detail;
if (newPrivilege === AccessControlEnum.ADMINISTRATOR) {
if (fromYou) {
return i18n('icu:GroupV2--access-member-label--admins--you');
}
if (from) {
return i18n('icu:GroupV2--access-member-label--admins--other', {
adminName: renderContact(from),
});
}
return i18n('icu:GroupV2--access-member-label--admins--unknown');
}
if (newPrivilege === AccessControlEnum.MEMBER) {
if (fromYou) {
return i18n('icu:GroupV2--access-member-label--all--you');
}
if (from) {
return i18n('icu:GroupV2--access-member-label--all--other', {
adminName: renderContact(from),
});
}
return i18n('icu:GroupV2--access-member-label--all--unknown');
}
log.warn(
`access-member-label change type, privilege ${newPrivilege} is unknown`
);
return '';
}
if (detail.type === 'member-add') {
const { aci } = detail;
const weAreJoiner = isOurServiceId(aci);

View File

@@ -861,12 +861,6 @@ export function buildAccessControlAttributesChange(
new Proto.GroupChange.Actions.ModifyAttributesAccessControlAction();
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;
@@ -889,6 +883,64 @@ export function buildAccessControlMembersChange(
return actions;
}
export function buildAccessControlMemberLabelChange(
group: ConversationAttributesType,
value: AccessRequiredEnum
): Proto.GroupChange.Actions {
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const ROLE_ENUM = Proto.Member.Role;
if (!group.secretParams) {
throw new Error(
'buildAccessControlMemberLabelChange: group was missing secretParams!'
);
}
const accessControlAction =
new Proto.GroupChange.Actions.ModifyMemberLabelAccessControlAction();
accessControlAction.memberLabelAccess = value;
const actions = new Proto.GroupChange.Actions();
actions.version = (group.revision || 0) + 1;
actions.modifyMemberLabelAccess = accessControlAction;
// Clear out all non-admin labels
const previousValue = group.accessControl?.memberLabel;
if (
previousValue !== ACCESS_ENUM.ADMINISTRATOR &&
value === 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(
`buildAccessControlMemberLabelChange: Found ${modifyLabelActions.length} non-admins with labels. Clearing.`
);
actions.modifyMemberLabels = modifyLabelActions;
}
}
return actions;
}
export function _maybeBuildAddBannedMemberActions({
clientZkGroupCipher,
group,
@@ -1234,6 +1286,25 @@ export function buildModifyMemberRoleChange({
actions.version = (group.revision || 0) + 1;
actions.modifyMemberRoles = [toggleAdmin];
const membership = group.membersV2?.find(member => member.aci === serviceId);
const onlyAdminsCanAddMemberLabel =
group.accessControl?.memberLabel ===
Proto.AccessControl.AccessRequired.ADMINISTRATOR;
const wasPreviouslyAnAdmin =
membership?.role === Proto.Member.Role.ADMINISTRATOR;
const nowNotAnAdmin = role !== Proto.Member.Role.ADMINISTRATOR;
if (
membership?.labelString &&
onlyAdminsCanAddMemberLabel &&
wasPreviouslyAnAdmin &&
nowNotAnAdmin
) {
const modifyLabel = new Proto.GroupChange.Actions.ModifyMemberLabelAction();
modifyLabel.userId = userIdCipherText;
actions.modifyMemberLabels = [modifyLabel];
}
return actions;
}
@@ -1836,6 +1907,7 @@ export async function createGroupV2(
attributes: ACCESS_ENUM.MEMBER,
members: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
memberLabel: ACCESS_ENUM.MEMBER,
},
membersV2,
pendingMembersV2,
@@ -2351,6 +2423,7 @@ export async function initiateMigrationToGroupV2(
attributes: ACCESS_ENUM.MEMBER,
members: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
memberLabel: ACCESS_ENUM.MEMBER,
},
membersV2,
pendingMembersV2,
@@ -4574,6 +4647,17 @@ function extractDiffs({
newPrivilege: current.accessControl.members,
});
}
if (
current.accessControl &&
old.accessControl &&
old.accessControl.memberLabel !== undefined &&
old.accessControl.memberLabel !== current.accessControl.memberLabel
) {
details.push({
type: 'access-member-label',
newPrivilege: current.accessControl.memberLabel,
});
}
const linkPreviouslyEnabled = isAccessControlEnabled(
old.accessControl?.addFromInviteLink
@@ -5446,6 +5530,7 @@ async function applyGroupChange({
members: ACCESS_ENUM.MEMBER,
attributes: ACCESS_ENUM.MEMBER,
addFromInviteLink: ACCESS_ENUM.UNSATISFIABLE,
memberLabel: ACCESS_ENUM.MEMBER,
};
// modifyAttributesAccess?:
@@ -5477,6 +5562,16 @@ async function applyGroupChange({
};
}
// modify_member_label_access?:
// GroupChange.Actions.ModifyMemberLabelAccessControlAction;
if (actions.modifyMemberLabelAccess) {
result.accessControl = {
...result.accessControl,
memberLabel:
actions.modifyMemberLabelAccess.memberLabelAccess || ACCESS_ENUM.MEMBER,
};
}
// addMembersPendingAdminApproval?: Array<
// GroupChange.Actions.AddMemberPendingAdminApprovalAction
// >;
@@ -5881,6 +5976,8 @@ async function applyGroupState({
addFromInviteLink:
(accessControl && accessControl.addFromInviteLink) ||
ACCESS_ENUM.UNSATISFIABLE,
memberLabel:
(accessControl && accessControl.memberLabel) || ACCESS_ENUM.MEMBER,
};
// Optimization: we assume we have left the group unless we are found in members
@@ -6227,6 +6324,7 @@ type DecryptedGroupChangeActions = {
| 'modifyAttributesAccess'
| 'modifyMemberAccess'
| 'modifyAddFromInviteLinkAccess'
| 'modifyMemberLabelAccess'
| 'modifyAvatar'
>;
@@ -6694,6 +6792,21 @@ function decryptGroupChange(
};
}
// modifyMemberLabelAccess?: GroupChange.Actions.ModifyMemberLabelAccessControlAction;
if (actions.modifyMemberLabelAccess) {
const memberLabelAccess = dropNull(
actions.modifyMemberLabelAccess.memberLabelAccess
);
strictAssert(
isValidAccess(memberLabelAccess),
`decryptGroupChange: modifyMemberLabelAccess.memberLabelAccess was not valid: ${actions.modifyMemberLabelAccess.memberLabelAccess}`
);
result.modifyMemberLabelAccess = {
memberLabelAccess,
};
}
// addMemberPendingAdminApprovals?: Array<
// GroupChange.Actions.AddMemberPendingAdminApprovalAction
// >;
@@ -6973,6 +7086,7 @@ type DecryptedGroupState = {
attributes: number;
members: number;
addFromInviteLink: number;
memberLabel: number;
};
version?: number;
members?: ReadonlyArray<DecryptedMember>;
@@ -7042,6 +7156,8 @@ function decryptGroupState(
const addFromInviteLink =
accessControl.addFromInviteLink ??
Proto.AccessControl.AccessRequired.UNKNOWN;
const memberLabel =
accessControl.memberLabel ?? Proto.AccessControl.AccessRequired.UNKNOWN;
strictAssert(
isValidAccess(attributes),
@@ -7060,6 +7176,7 @@ function decryptGroupState(
attributes,
members,
addFromInviteLink,
memberLabel,
};
}

1
ts/model-types.d.ts vendored
View File

@@ -502,6 +502,7 @@ export type ConversationAttributesType = {
attributes: AccessRequiredEnum;
members: AccessRequiredEnum;
addFromInviteLink: AccessRequiredEnum;
memberLabel: AccessRequiredEnum;
};
announcementsOnly?: boolean;
avatar?: ContactAvatarType | null;

View File

@@ -238,6 +238,7 @@ import {
applyNewAvatar,
buildAccessControlAddFromInviteLinkChange,
buildAccessControlAttributesChange,
buildAccessControlMemberLabelChange,
buildAccessControlMembersChange,
buildAddBannedMemberChange,
buildAddMember,
@@ -4634,8 +4635,6 @@ export class ConversationModel {
createGroupChange: async () =>
buildInviteLinkPasswordChange(this.attributes, groupInviteLinkPassword),
});
this.set({ groupInviteLinkPassword });
}
async toggleGroupLink(value: boolean): Promise<void> {
@@ -4678,18 +4677,6 @@ export class ConversationModel {
),
});
}
this.set({
accessControl: {
addFromInviteLink,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
if (shouldCreateNewGroupLink) {
this.set({ groupInviteLinkPassword });
}
}
async updateAccessControlAddFromInviteLink(value: boolean): Promise<void> {
@@ -4712,14 +4699,6 @@ export class ConversationModel {
addFromInviteLink
),
});
this.set({
accessControl: {
addFromInviteLink,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
}
async updateAccessControlAttributes(value: number): Promise<void> {
@@ -4733,16 +4712,6 @@ export class ConversationModel {
createGroupChange: async () =>
buildAccessControlAttributesChange(this.attributes, value),
});
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
this.set({
accessControl: {
addFromInviteLink:
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
attributes: value,
members: this.get('accessControl')?.members || ACCESS_ENUM.MEMBER,
},
});
}
async updateAccessControlMembers(value: number): Promise<void> {
@@ -4756,15 +4725,18 @@ export class ConversationModel {
createGroupChange: async () =>
buildAccessControlMembersChange(this.attributes, value),
});
}
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
this.set({
accessControl: {
addFromInviteLink:
this.get('accessControl')?.addFromInviteLink || ACCESS_ENUM.MEMBER,
attributes: this.get('accessControl')?.attributes || ACCESS_ENUM.MEMBER,
members: value,
},
async updateAccessControlMemberLabel(value: number): Promise<void> {
if (!isGroupV2(this.attributes)) {
return;
}
await this.modifyGroupV2({
name: 'updateAccessControlMembers',
usingCredentialsFrom: [],
createGroupChange: async () =>
buildAccessControlMemberLabelChange(this.attributes, value),
});
}
@@ -4779,8 +4751,6 @@ export class ConversationModel {
createGroupChange: async () =>
buildAnnouncementsOnlyChange(this.attributes, value),
});
this.set({ announcementsOnly: value });
}
async updateExpirationTimer(

View File

@@ -2390,6 +2390,16 @@ export class BackupExportStream extends Readable {
update.groupInviteLinkAdminApprovalUpdate = innerUpdate;
updates.push(update);
} else if (type === 'access-member-label') {
const innerUpdate =
new Backups.GroupMemberLabelAccessLevelChangeUpdate();
if (from) {
innerUpdate.updaterAci = this.#aciToBytesOrUndefined(from);
}
innerUpdate.accessLevel = detail.newPrivilege;
update.groupMemberLabelAccessLevelChangeUpdate = innerUpdate;
updates.push(update);
} else if (type === 'announcements-only') {
const innerUpdate = new Backups.GroupAnnouncementOnlyChangeUpdate();
if (from) {

View File

@@ -1261,6 +1261,9 @@ export class BackupImportStream extends Writable {
addFromInviteLink:
dropNull(accessControl.addFromInviteLink) ??
SignalService.AccessControl.AccessRequired.UNKNOWN,
memberLabel:
dropNull(accessControl.memberLabel) ??
SignalService.AccessControl.AccessRequired.UNKNOWN,
}
: undefined,
membersV2: members?.map(
@@ -3120,6 +3123,19 @@ export class BackupImportStream extends Writable {
SignalService.AccessControl.AccessRequired.UNKNOWN,
});
}
if (update.groupMemberLabelAccessLevelChangeUpdate) {
const { updaterAci, accessLevel } =
update.groupMemberLabelAccessLevelChangeUpdate;
if (updaterAci) {
from = fromAciObject(Aci.fromUuidBytes(updaterAci));
}
details.push({
type: 'access-member-label',
newPrivilege:
dropNull(accessLevel) ??
SignalService.AccessControl.AccessRequired.UNKNOWN,
});
}
if (update.groupAttributesAccessLevelChangeUpdate) {
const { updaterAci, accessLevel } =
update.groupAttributesAccessLevelChangeUpdate;

View File

@@ -390,6 +390,7 @@ export type ConversationType = ReadonlyDeep<
accessControlAddFromInviteLink?: number;
accessControlAttributes?: number;
accessControlMembers?: number;
accessControlMemberLabel?: number;
announcementsOnly?: boolean;
announcementsOnlyReady?: boolean;
expireTimer?: DurationInSeconds;
@@ -1253,6 +1254,7 @@ export const actions = {
setAccessControlAddFromInviteLinkSetting,
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
setAccessControlMemberLabelSetting,
setAnnouncementsOnly,
setCenterMessage,
setComposeGroupAvatar,
@@ -1731,6 +1733,30 @@ function setAccessControlMembersSetting(
};
}
function setAccessControlMemberLabelSetting(
conversationId: string,
value: number
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
'setAccessControlMemberLabelSetting: No conversation found'
);
}
await longRunningTaskWrapper({
name: 'updateAccessControlMemberLabel',
idForLogging: conversation.idForLogging(),
task: async () => conversation.updateAccessControlMemberLabel(value),
});
dispatch({
type: 'NOOP',
payload: null,
});
};
}
function setAccessControlAttributesSetting(
conversationId: string,
value: number

View File

@@ -242,6 +242,7 @@ export const ConversationPanel = memo(function ConversationPanel({
<PanelContainer
key={getPanelKey(prevPanel)}
conversationId={conversationId}
isActive={false}
panel={prevPanel}
ref={animateRef}
/>
@@ -257,6 +258,7 @@ export const ConversationPanel = memo(function ConversationPanel({
lastPanelDoneAnimating !== prevPanel &&
prevPanel && (
<PanelContainer
isActive={false}
conversationId={conversationId}
panel={prevPanel}
key={getPanelKey(prevPanel)}
@@ -283,85 +285,94 @@ export const ConversationPanel = memo(function ConversationPanel({
type PanelPropsType = {
conversationId: string;
isActive: boolean;
panel: PanelArgsType;
};
const PanelContainer = forwardRef<
HTMLDivElement,
PanelPropsType & { isActive?: boolean }
>(function PanelContainerInner(
{ conversationId, isActive, panel },
ref
): React.JSX.Element {
const i18n = useSelector(getIntl);
const { popPanelForConversation } = useNavActions();
const conversationTitle = getConversationTitleForPanelType(i18n, panel.type);
const PanelContainer = forwardRef<HTMLDivElement, PanelPropsType>(
function PanelContainerInner(
{ conversationId, isActive, panel },
ref
): React.JSX.Element {
const i18n = useSelector(getIntl);
const { popPanelForConversation } = useNavActions();
const conversationTitle = getConversationTitleForPanelType(
i18n,
panel.type
);
let info: React.JSX.Element | undefined;
if (panel.type === PanelType.AllMedia) {
info = <SmartAllMediaHeader />;
} else if (conversationTitle != null) {
info = (
<div className="ConversationPanel__header__info">
<div className="ConversationPanel__header__info__title">
{conversationTitle}
let info: React.JSX.Element | undefined;
if (panel.type === PanelType.AllMedia) {
info = <SmartAllMediaHeader />;
} else if (conversationTitle != null) {
info = (
<div className="ConversationPanel__header__info">
<div className="ConversationPanel__header__info__title">
{conversationTitle}
</div>
</div>
);
}
const focusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isActive) {
return;
}
if (panel.type === PanelType.GroupMemberLabelEditor) {
return;
}
const focusNode = focusRef.current;
if (!focusNode) {
return;
}
const elements =
focusNode.querySelectorAll<HTMLElement>(focusableSelector);
if (!elements.length) {
return;
}
elements[0]?.focus();
}, [isActive, panel]);
return (
<div className="ConversationPanel" ref={ref}>
<div className="ConversationPanel__header">
<button
aria-label={i18n('icu:goBack')}
className="ConversationPanel__header__back-button"
onClick={popPanelForConversation}
type="button"
/>
{info}
</div>
<SmartMiniPlayer shouldFlow />
<div
className={classNames(
'ConversationPanel__body',
panel.type !== PanelType.PinnedMessages &&
panel.type !== PanelType.AllMedia &&
panel.type !== PanelType.GroupMemberLabelEditor &&
'ConversationPanel__body--padding'
)}
ref={focusRef}
>
<PanelElement
isActive={isActive}
conversationId={conversationId}
panel={panel}
/>
</div>
</div>
);
}
const focusRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isActive) {
return;
}
if (panel.type === PanelType.GroupMemberLabelEditor) {
return;
}
const focusNode = focusRef.current;
if (!focusNode) {
return;
}
const elements = focusNode.querySelectorAll<HTMLElement>(focusableSelector);
if (!elements.length) {
return;
}
elements[0]?.focus();
}, [isActive, panel]);
return (
<div className="ConversationPanel" ref={ref}>
<div className="ConversationPanel__header">
<button
aria-label={i18n('icu:goBack')}
className="ConversationPanel__header__back-button"
onClick={popPanelForConversation}
type="button"
/>
{info}
</div>
<SmartMiniPlayer shouldFlow />
<div
className={classNames(
'ConversationPanel__body',
panel.type !== PanelType.PinnedMessages &&
panel.type !== PanelType.AllMedia &&
panel.type !== PanelType.GroupMemberLabelEditor &&
'ConversationPanel__body--padding'
)}
ref={focusRef}
>
<PanelElement conversationId={conversationId} panel={panel} />
</div>
</div>
);
});
);
function PanelElement({
conversationId,
isActive,
panel,
}: PanelPropsType): React.JSX.Element | null {
if (panel.type === PanelType.AllMedia) {
@@ -396,7 +407,12 @@ function PanelElement({
}
if (panel.type === PanelType.GroupMemberLabelEditor) {
return <SmartGroupMemberLabelEditor conversationId={conversationId} />;
return (
<SmartGroupMemberLabelEditor
conversationId={conversationId}
isActive={isActive}
/>
);
}
if (panel.type === PanelType.GroupPermissions) {

View File

@@ -15,16 +15,19 @@ import { createLogger } from '../../logging/log.std.js';
import { getPreferredBadgeSelector } from '../selectors/badges.preload.js';
import { isNotNil } from '../../util/isNotNil.std.js';
import { useNavActions } from '../ducks/nav.std.js';
import { getCanAddLabel } from '../../types/GroupMemberLabels.std.js';
const log = createLogger('SmartGroupMemberLabelEditor');
export type SmartGroupMemberLabelEditorProps = Readonly<{
conversationId: string;
isActive: boolean;
}>;
export const SmartGroupMemberLabelEditor = memo(
function SmartGroupMemberLabelEditor({
conversationId,
isActive,
}: SmartGroupMemberLabelEditorProps) {
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
@@ -47,13 +50,9 @@ export const SmartGroupMemberLabelEditor = memo(
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;
ourMembership || {};
const canAddLabel = getCanAddLabel(conversation, ourMembership);
const membersWithLabel = (conversation.memberships || [])
.map(membership => {
@@ -94,11 +93,13 @@ export const SmartGroupMemberLabelEditor = memo(
return (
<GroupMemberLabelEditor
canAddLabel={canAddLabel}
existingLabelEmoji={existingLabelEmoji}
existingLabelString={existingLabelString}
getPreferredBadge={getPreferredBadge}
group={conversation}
i18n={i18n}
isActive={isActive}
me={me}
membersWithLabel={membersWithLabel}
ourColor={ourColor}

View File

@@ -21,6 +21,7 @@ export const SmartGroupV2Permissions = memo(function SmartGroupV2Permissions({
const {
setAccessControlAttributesSetting,
setAccessControlMembersSetting,
setAccessControlMemberLabelSetting,
setAnnouncementsOnly,
} = useConversationsActions();
return (
@@ -29,6 +30,7 @@ export const SmartGroupV2Permissions = memo(function SmartGroupV2Permissions({
conversation={conversation}
setAccessControlAttributesSetting={setAccessControlAttributesSetting}
setAccessControlMembersSetting={setAccessControlMembersSetting}
setAccessControlMemberLabelSetting={setAccessControlMemberLabelSetting}
setAnnouncementsOnly={setAnnouncementsOnly}
/>
);

View File

@@ -5,6 +5,7 @@ import type {
ConversationType,
MembershipType,
} from '../state/ducks/conversations.preload.js';
import { SignalService as Proto } from '../protobuf/index.std.js';
export const missingEmojiPlaceholder = '⍰';
@@ -25,5 +26,11 @@ export function getCanAddLabel(
conversation: ConversationType,
membership: MembershipType | undefined
): boolean {
return Boolean(membership && conversation.type === 'group');
return Boolean(
membership &&
conversation.type === 'group' &&
(membership.isAdmin ||
conversation.accessControlMemberLabel ===
Proto.AccessControl.AccessRequired.MEMBER)
);
}

View File

@@ -21,6 +21,10 @@ type GroupV2AccessInviteLinkChangeType = {
type: 'access-invite-link';
newPrivilege: number;
};
type GroupV2AccessMemberLabelChangeType = {
type: 'access-member-label';
newPrivilege: number;
};
type GroupV2AnnouncementsOnlyChangeType = {
type: 'announcements-only';
announcementsOnly: boolean;
@@ -127,6 +131,7 @@ export type GroupV2ChangeDetailType =
| GroupV2AccessAttributesChangeType
| GroupV2AccessCreateChangeType
| GroupV2AccessInviteLinkChangeType
| GroupV2AccessMemberLabelChangeType
| GroupV2AccessMembersChangeType
| GroupV2AdminApprovalAddOneChangeType
| GroupV2AdminApprovalRemoveOneChangeType

View File

@@ -223,6 +223,7 @@ export function getConversation(model: ConversationModel): ConversationType {
accessControlAddFromInviteLink: attributes.accessControl?.addFromInviteLink,
accessControlAttributes: attributes.accessControl?.attributes,
accessControlMembers: attributes.accessControl?.members,
accessControlMemberLabel: attributes.accessControl?.memberLabel,
announcementsOnly: Boolean(attributes.announcementsOnly),
announcementsOnlyReady: canBeAnnouncementGroup(attributes),
expireTimer: attributes.expireTimer,