Group Member Labels: A few tweaks

Co-authored-by: Jamie Kyle <jamie@signal.org>
This commit is contained in:
Scott Nonnenberg
2026-02-14 07:58:06 +10:00
committed by GitHub
parent 4a78b284e8
commit 2557e1d521
25 changed files with 321 additions and 85 deletions
@@ -56,6 +56,8 @@ function getToast(toastType: ToastType): AnyToast {
};
case ToastType.CallQualitySurveySuccess:
return { toastType: ToastType.CallQualitySurveySuccess };
case ToastType.CannotAddMemberLabel:
return { toastType: ToastType.CannotAddMemberLabel };
case ToastType.CannotEditMessage:
return { toastType: ToastType.CannotEditMessage };
case ToastType.CannotForwardEmptyMessage:
+8
View File
@@ -165,6 +165,14 @@ export function renderToast({
);
}
if (toastType === ToastType.CannotAddMemberLabel) {
return (
<Toast onClose={hideToast}>
{i18n('icu:ToastManager__CannotAddMemberLabel')}
</Toast>
);
}
if (toastType === ToastType.CannotEditMessage) {
return (
<Toast onClose={hideToast}>
@@ -12,6 +12,9 @@ import { HasStories } from '../../types/Stories.std.js';
import { ThemeType } from '../../types/Util.std.js';
import { getDefaultConversation } from '../../test-helpers/getDefaultConversation.std.js';
import { getFakeBadges } from '../../test-helpers/getFakeBadge.std.js';
import { SignalService as Proto } from '../../protobuf/index.std.js';
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const { i18n } = window.SignalContext;
@@ -123,6 +126,23 @@ AsAdmin.args = {
areWeAdmin: true,
};
export const AsAdminViewingAdmin = Template.bind({});
AsAdminViewingAdmin.args = {
areWeAdmin: true,
isAdmin: true,
};
export const AsAdminViewingAdminWithLabel = Template.bind({});
AsAdminViewingAdminWithLabel.args = {
areWeAdmin: true,
isAdmin: true,
conversation: {
...defaultGroup,
accessControlAttributes: ACCESS_ENUM.ADMINISTRATOR,
},
contactLabelString: 'Contact Label',
};
export const AsAdminWithNoGroupLink = Template.bind({});
AsAdminWithNoGroupLink.args = {
areWeAdmin: true,
@@ -36,6 +36,9 @@ import type {
ToggleGroupMemberLabelInfoModalType,
} from '../../state/ducks/globalModals.preload.js';
import { GroupMemberLabel } from './ContactName.dom.js';
import { SignalService as Proto } from '../../protobuf/index.std.js';
const ACCESS_ENUM = Proto.AccessControl.AccessRequired;
const log = createLogger('ContactModal');
@@ -227,6 +230,35 @@ export function ContactModal({
break;
}
if (
isAdmin &&
contactLabelString &&
conversation.accessControlAttributes === ACCESS_ENUM.ADMINISTRATOR
) {
modalNode = (
<ConfirmationDialog
dialogName="ContactModal.toggleAdmin"
actions={[
{
action: () => toggleAdmin(conversation.id, contact.id),
text: isAdmin
? i18n('icu:ContactModal--rm-admin')
: i18n('icu:ContactModal--make-admin'),
style: 'affirmative',
},
]}
i18n={i18n}
onClose={() => setSubModalState(SubModalState.None)}
title={i18n('icu:ContactModal--rm-admin-info', {
contact: contact.title,
})}
>
{i18n('icu:ContactModal--rm-admin--clear-label')}
</ConfirmationDialog>
);
break;
}
modalNode = (
<ConfirmationDialog
dialogName="ContactModal.toggleAdmin"
@@ -236,6 +268,7 @@ export function ContactModal({
text: isAdmin
? i18n('icu:ContactModal--rm-admin')
: i18n('icu:ContactModal--make-admin'),
style: 'affirmative',
},
]}
i18n={i18n}
+1 -2
View File
@@ -491,8 +491,7 @@ export function Quote(props: Props): React.JSX.Element | null {
dir="auto"
className={classNames(
getClassName('__primary__author'),
isIncoming ? getClassName('__primary__author--incoming') : null,
authorLabel ? getClassName('__primary__author--with-label') : null
isIncoming ? getClassName('__primary__author--incoming') : null
)}
>
{author}
@@ -118,6 +118,7 @@ const createProps = (
replaceAvatar: action('replaceAvatar'),
saveAvatarToDisk: action('saveAvatarToDisk'),
setMuteExpiration: action('setMuteExpiration'),
showToast: action('showToast'),
userAvatarData: [],
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleAboutContactModal: action('toggleAboutContactModal'),
@@ -163,6 +164,18 @@ export function Basic(): React.JSX.Element {
return <ConversationDetails {...props} />;
}
export function MemberLabelsEditDisabled(): React.JSX.Element {
const props = createProps();
return <ConversationDetails {...props} isEditMemberLabelEnabled={false} />;
}
export function MemberLabelsCannotBeAdded(): React.JSX.Element {
const props = createProps();
return <ConversationDetails {...props} canAddLabel={false} />;
}
export function SystemContact(): React.JSX.Element {
const props = createProps();
const contact = getDefaultConversation();
@@ -3,6 +3,7 @@
import type { ReactNode } from 'react';
import React, { useEffect, useState, useCallback } from 'react';
import classNames from 'classnames';
import { Button, ButtonIconType, ButtonVariant } from '../../Button.dom.js';
import type {
@@ -65,6 +66,8 @@ import {
} from '../InAnotherCallTooltip.dom.js';
import { BadgeSustainerInstructionsDialog } from '../../BadgeSustainerInstructionsDialog.dom.js';
import type { ContactModalStateType } from '../../../state/ducks/globalModals.preload.js';
import type { ShowToastAction } from '../../../state/ducks/toast.preload.js';
import { ToastType } from '../../../types/Toast.dom.js';
enum ModalState {
AddingGroupMembers,
@@ -102,6 +105,7 @@ export type StateProps = {
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingAvatarDownload?: boolean;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
showToast: ShowToastAction;
selectedNavTab: NavTab;
startAvatarDownload: () => void;
theme: ThemeType;
@@ -208,6 +212,7 @@ export function ConversationDetails({
setMuteExpiration,
showContactModal,
showConversation,
showToast,
startAvatarDownload,
theme,
toggleAboutContactModal,
@@ -723,13 +728,20 @@ export function ConversationDetails({
)}
{isGroup && (
<ConversationDetailsMembershipList
canAddLabel={canAddLabel}
canAddNewMembers={canAddNewMembers}
conversationId={conversation.id}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isEditMemberLabelEnabled={isEditMemberLabelEnabled}
memberships={memberships}
memberColors={memberColors}
showContactModal={showContactModal}
showLabelEditor={() => {
pushPanelForConversation({
type: PanelType.GroupMemberLabelEditor,
});
}}
startAddingNewMembers={() => {
setModalState(ModalState.AddingGroupMembers);
}}
@@ -756,20 +768,36 @@ export function ConversationDetails({
right={hasGroupLink ? i18n('icu:on') : i18n('icu:off')}
/>
) : null}
{canAddLabel && isEditMemberLabelEnabled ? (
{isEditMemberLabelEnabled ? (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetails--member-label')}
icon={IconType.tag}
disabled={!canAddLabel}
/>
}
label={i18n('icu:ConversationDetails--member-label')}
onClick={() =>
label={
<div
className={classNames(
!canAddLabel
? 'ConversationDetails__MemberLabel--disabled'
: null
)}
>
{i18n('icu:ConversationDetails--member-label')}
</div>
}
onClick={() => {
if (!canAddLabel) {
showToast({ toastType: ToastType.CannotAddMemberLabel });
return;
}
pushPanelForConversation({
type: PanelType.GroupMemberLabelEditor,
})
}
});
}}
/>
) : null}
<PanelRow
@@ -17,6 +17,7 @@ import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges
import { PanelRow } from './PanelRow.dom.js';
import { PanelSection } from './PanelSection.dom.js';
import { GroupMemberLabel } from '../ContactName.dom.js';
import { AriaClickable } from '../../../axo/AriaClickable.dom.js';
export type GroupV2Membership = {
isAdmin: boolean;
@@ -26,14 +27,17 @@ export type GroupV2Membership = {
};
export type Props = {
canAddLabel: boolean;
canAddNewMembers: boolean;
conversationId: string;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
isEditMemberLabelEnabled: boolean;
maxShownMemberCount?: number;
memberships: ReadonlyArray<GroupV2Membership>;
memberColors: Map<string, string>;
showContactModal: (contactId: string, conversationId?: string) => void;
showLabelEditor: () => void;
startAddingNewMembers?: () => void;
theme: ThemeType;
};
@@ -79,13 +83,16 @@ function sortMemberships(
export function ConversationDetailsMembershipList({
canAddNewMembers,
canAddLabel,
conversationId,
getPreferredBadge,
i18n,
isEditMemberLabelEnabled,
maxShownMemberCount = 5,
memberColors,
memberships,
showContactModal,
showLabelEditor,
startAddingNewMembers,
theme,
}: Props): React.JSX.Element {
@@ -141,7 +148,7 @@ export function ConversationDetailsMembershipList({
/>
</div>
{labelString && contactNameColor && (
<div className="ConversationDetails-membership-list--member-label">
<div className="ConversationDetails-membership-list__member-label">
<GroupMemberLabel
contactNameColor={contactNameColor}
contactLabel={{
@@ -152,6 +159,29 @@ export function ConversationDetailsMembershipList({
/>
</div>
)}
{canAddLabel &&
isEditMemberLabelEnabled &&
member.isMe &&
(!labelString || !contactNameColor) && (
<AriaClickable.SubWidget>
<button
className="ConversationDetails-membership-list__member-label-button"
type="button"
onClick={event => {
showLabelEditor();
event.preventDefault();
event.stopPropagation();
}}
>
<div>
{i18n(
'icu:ConversationDetailsMembershipList--add-member-label'
)}
</div>
<div className="ConversationDetails-membership-list__member-label-button__chevron-icon" />
</button>
</AriaClickable.SubWidget>
)}
</div>
}
right={isAdmin ? i18n('icu:GroupV2--admin') : ''}
@@ -149,7 +149,6 @@ export function GroupLinkManagement({
/>
}
label={i18n('icu:GroupLinkManagement--share')}
ref={!isAdmin ? focusRef : undefined}
onClick={() => {
if (conversation.groupLink) {
drop(copyGroupLink(conversation.groupLink));
@@ -101,7 +101,7 @@ export function GroupMemberLabelEditor({
const isDirty =
labelEmoji !== existingLabelEmoji || labelString !== existingLabelString;
const canSave = Boolean(isDirty && labelString);
const canSave = isDirty;
const spinner = isSaving
? {
'aria-label': i18n('icu:ConversationDetails--member-label--saving'),
@@ -1,9 +1,10 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useId } from 'react';
import classNames from 'classnames';
import { bemGenerator } from './util.std.js';
import { AriaClickable } from '../../../axo/AriaClickable.dom.js';
export type Props = {
alwaysShowActions?: boolean;
@@ -19,55 +20,61 @@ export type Props = {
const bem = bemGenerator('ConversationDetails-panel-row');
export const PanelRow = React.forwardRef<HTMLButtonElement, Props>(
function PanelRowInner(
{
alwaysShowActions,
className,
disabled,
icon,
label,
info,
right,
actions,
onClick,
}: Props,
ref: React.Ref<HTMLButtonElement>
) {
const content = (
<>
{icon !== undefined ? <div className={bem('icon')}>{icon}</div> : null}
<div className={bem('label')}>
<div>{label}</div>
{info !== undefined ? (
<div className={bem('info')}>{info}</div>
) : null}
</div>
{right !== undefined ? (
<div className={bem('right')}>{right}</div>
) : null}
{actions !== undefined ? (
<div className={alwaysShowActions ? '' : bem('actions')}>
{actions}
</div>
) : null}
</>
);
export function PanelRow({
alwaysShowActions,
className,
disabled,
icon,
label,
info,
right,
actions,
onClick,
}: Props): React.ReactNode {
const labelId = useId();
const subWidget =
actions !== undefined ? (
<div className={alwaysShowActions ? '' : bem('actions')}>{actions}</div>
) : null;
if (onClick) {
return (
<button
disabled={disabled}
type="button"
className={classNames(bem('root', 'button'), className)}
onClick={onClick}
ref={ref}
>
const content = (
<>
{icon !== undefined ? <div className={bem('icon')}>{icon}</div> : null}
<div className={bem('label')}>
<div id={labelId}>{label}</div>
{info !== undefined ? <div className={bem('info')}>{info}</div> : null}
</div>
{right !== undefined ? <div className={bem('right')}>{right}</div> : null}
</>
);
if (onClick) {
return (
<AriaClickable.Root
className={classNames(bem('root', 'button'), className)}
>
{!disabled && (
<AriaClickable.HiddenTrigger
onClick={onClick}
aria-labelledby={labelId}
/>
)}
<div className={bem('inner')}>
{content}
</button>
);
}
return <div className={classNames(bem('root'), className)}>{content}</div>;
{subWidget && (
<AriaClickable.SubWidget>{subWidget}</AriaClickable.SubWidget>
)}
</div>
</AriaClickable.Root>
);
}
);
return (
<div className={classNames(bem('root'), className)}>
<div className={bem('inner')}>
{content}
{subWidget}
</div>
</div>
);
}