Support for Group Member Labels

This commit is contained in:
Scott Nonnenberg
2026-02-03 04:06:25 +10:00
committed by GitHub
parent d173db816b
commit 09c71ad356
60 changed files with 1794 additions and 345 deletions
+2
View File
@@ -34,6 +34,8 @@ const SemverKeys = [
'desktop.callQualitySurvey.prod',
'desktop.donationPaypal.beta',
'desktop.donationPaypal.prod',
'desktop.groupMemberLabels.edit.beta',
'desktop.groupMemberLabels.edit.prod',
'desktop.pinnedMessages.receive.beta',
'desktop.pinnedMessages.receive.prod',
'desktop.pinnedMessages.send.beta',
@@ -2,28 +2,31 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import lodash from 'lodash';
import type {
ConversationType,
ShowConversationType,
} from '../state/ducks/conversations.preload.js';
import type { ShowConversationType } from '../state/ducks/conversations.preload.js';
import { I18n } from './I18n.dom.js';
import type { LocalizerType, ThemeType } from '../types/Util.std.js';
import { Modal } from './Modal.dom.js';
import { ConversationListItem } from './conversationList/ConversationListItem.dom.js';
const { noop } = lodash;
import { Avatar, AvatarSize } from './Avatar.dom.js';
import { GroupMemberLabel } from './conversation/ContactName.dom.js';
import type { PreferredBadgeSelectorType } from '../state/selectors/badges.preload.js';
import { tw } from '../axo/tw.dom.js';
import type { AdminMembershipType } from '../state/selectors/conversations.dom.js';
import { UserText } from './UserText.dom.js';
type PropsType = {
groupAdmins: Array<ConversationType>;
getPreferredBadge: PreferredBadgeSelectorType;
groupAdmins: Array<AdminMembershipType>;
memberColors: Map<string, string>;
i18n: LocalizerType;
showConversation: ShowConversationType;
theme: ThemeType;
};
export function AnnouncementsOnlyGroupBanner({
getPreferredBadge,
groupAdmins,
i18n,
memberColors,
showConversation,
theme,
}: PropsType): React.JSX.Element {
@@ -33,25 +36,58 @@ export function AnnouncementsOnlyGroupBanner({
<>
{isShowingAdmins && (
<Modal
modalName="AnnouncmentsOnlyGroupBanner"
i18n={i18n}
hasXButton
modalName="AnnouncmentsOnlyGroupBanner"
onClose={() => setIsShowingAdmins(false)}
title={i18n('icu:AnnouncementsOnlyGroupBanner--modal')}
>
{groupAdmins.map(admin => (
<ConversationListItem
{...admin}
draftPreview={undefined}
i18n={i18n}
lastMessage={undefined}
lastUpdated={undefined}
onClick={() => {
showConversation({ conversationId: admin.id });
}}
onMouseDown={noop}
theme={theme}
/>
))}
{groupAdmins.map(admin => {
const { member, labelEmoji, labelString } = admin;
const contactNameColor = memberColors.get(member.id);
return (
<div>
<button
type="button"
onClick={() => {
showConversation({ conversationId: member.id });
}}
className={tw('flex flex-row items-center p-2')}
>
<div className={tw('pe-3')}>
<Avatar
conversationType="direct"
badge={getPreferredBadge(member.badges)}
i18n={i18n}
size={AvatarSize.THIRTY_SIX}
theme={theme}
{...member}
/>
</div>
<div className={tw('flex flex-col items-start')}>
<div>
<UserText
text={member.isMe ? i18n('icu:you') : member.title}
/>
</div>
{labelString && contactNameColor && (
<div>
<GroupMemberLabel
contactNameColor={contactNameColor}
contactLabel={{
labelEmoji,
labelString,
}}
context="list"
/>
</div>
)}
</div>
</button>
</div>
);
})}
</Modal>
)}
<div className="AnnouncementsOnlyGroupBanner__banner">
@@ -13,6 +13,7 @@ import { ThemeType } from '../types/Util.std.js';
import { Theme } from '../util/theme.std.js';
import { UserText } from './UserText.dom.js';
import { SharedGroupNames } from './SharedGroupNames.dom.js';
import type { ContactModalStateType } from '../state/ducks/globalModals.preload.js';
export type CallLinkPendingParticipantModalProps = {
readonly i18n: LocalizerType;
@@ -21,7 +22,7 @@ export type CallLinkPendingParticipantModalProps = {
readonly denyUser: (payload: PendingUserActionPayloadType) => void;
readonly onClose: () => void;
readonly sharedGroupNames: ReadonlyArray<string>;
readonly toggleAboutContactModal: (conversationId: string) => void;
readonly toggleAboutContactModal: (options: ContactModalStateType) => void;
};
export function CallLinkPendingParticipantModal({
@@ -75,7 +76,7 @@ export function CallLinkPendingParticipantModal({
onClick={ev => {
ev.preventDefault();
ev.stopPropagation();
toggleAboutContactModal(conversation.id);
toggleAboutContactModal({ contactId: conversation.id });
}}
className="CallLinkPendingParticipantModal__NameButton"
>
+37 -2
View File
@@ -12,13 +12,47 @@ import { StorybookThemeContext } from '../../.storybook/StorybookThemeContext.st
import { fakeDraftAttachment } from '../test-helpers/fakeAttachment.std.js';
import { landscapeGreenUrl } from '../storybook/Fixtures.std.js';
import { RecordingState } from '../types/AudioRecorder.std.js';
import { ConversationColors } from '../types/Colors.std.js';
import { ContactNameColors, ConversationColors } from '../types/Colors.std.js';
import { getDefaultConversation } from '../test-helpers/getDefaultConversation.std.js';
import { PaymentEventKind } from '../types/Payment.std.js';
import { EmojiSkinTone } from './fun/data/emojis.std.js';
import { isNotNil } from '../util/isNotNil.std.js';
const { i18n } = window.SignalContext;
const groupAdmins = [
{
member: getDefaultConversation(),
labelEmoji: undefined,
labelString: undefined,
},
{
member: getDefaultConversation(),
labelEmoji: '✅',
labelString: 'Planner',
},
{
member: getDefaultConversation(),
labelEmoji: '#',
labelString: 'Invalid Emoji',
},
{
member: getDefaultConversation(),
labelEmoji: undefined,
labelString: 'No Emoji',
},
];
const memberColors = new Map(
groupAdmins
.map((admin, i): [string, string] | null => {
if (!admin.member.id) {
return null;
}
return [admin.member.id?.toString(), ContactNameColors[i]];
})
.filter(isNotNil)
);
export default {
title: 'Components/CompositionArea',
decorators: [
@@ -102,7 +136,8 @@ export default {
announcementsOnly: false,
areWeAdmin: false,
areWePendingApproval: false,
groupAdmins: [],
groupAdmins,
memberColors,
cancelJoinRequest: action('cancelJoinRequest'),
showConversation: action('showConversation'),
isSmsOnlyOrUnregistered: false,
+9 -1
View File
@@ -112,7 +112,11 @@ export type OwnProps = Readonly<{
errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
focusCounter: number;
groupAdmins: Array<ConversationType>;
groupAdmins: Array<{
member: ConversationType;
labelEmoji: string | undefined;
labelString: string | undefined;
}>;
groupVersion: 1 | 2 | null;
i18n: LocalizerType;
imageToBlurHash: typeof imageToBlurHash;
@@ -126,6 +130,7 @@ export type OwnProps = Readonly<{
lastEditableMessageId: string | null;
recordingState: RecordingState;
messageCompositionId: string;
memberColors: Map<string, string>;
shouldHidePopovers: boolean | null;
isMuted: boolean;
isSmsOnlyOrUnregistered: boolean | null;
@@ -321,6 +326,7 @@ export const CompositionArea = memo(function CompositionArea({
announcementsOnly,
areWeAdmin,
groupAdmins,
memberColors,
cancelJoinRequest,
showConversation,
// SMS-only contacts
@@ -1005,8 +1011,10 @@ export const CompositionArea = memo(function CompositionArea({
if (announcementsOnly && !areWeAdmin) {
return (
<AnnouncementsOnlyGroupBanner
getPreferredBadge={getPreferredBadge}
groupAdmins={groupAdmins}
i18n={i18n}
memberColors={memberColors}
showConversation={showConversation}
theme={theme}
/>
@@ -558,7 +558,7 @@ export function NotificationProfilesHome({
i18n={i18n}
isEditing
onBack={() => setPage(HomePage.Edit)}
onNext={() => setPage(HomePage.Edit)} // TODO: probably don't show Next button?
onNext={() => setPage(HomePage.Edit)}
onSetIsEnabled={(scheduleEnabled: boolean) => {
const newProfile = {
...profile,
@@ -61,18 +61,23 @@ export default {
isSignalConnection: { control: { type: 'boolean' } },
},
args: {
canAddLabel: false,
contact: conversation,
contactLabelEmoji: undefined,
contactLabelString: undefined,
contactNameColor: undefined,
fromOrAddedByTrustedContact: false,
i18n,
isSignalConnection: false,
isEditMemberLabelEnabled: true,
onClose: action('onClose'),
onOpenNotePreviewModal: action('onOpenNotePreviewModal'),
toggleSignalConnectionsModal: action('toggleSignalConnections'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleProfileNameWarningModal: action('toggleProfileNameWarningModal'),
startAvatarDownload: action('startAvatarDownload'),
pendingAvatarDownload: false,
conversation,
sharedGroupNames: [],
fromOrAddedByTrustedContact: false,
isSignalConnection: false,
startAvatarDownload: action('startAvatarDownload'),
toggleProfileNameWarningModal: action('toggleProfileNameWarningModal'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleSignalConnectionsModal: action('toggleSignalConnections'),
},
} satisfies ComponentMeta<PropsType>;
@@ -81,27 +86,80 @@ export function Defaults(args: PropsType): React.JSX.Element {
}
export function Me(args: PropsType): React.JSX.Element {
return <AboutContactModal {...args} conversation={me} />;
return <AboutContactModal {...args} contact={me} />;
}
export function MeWithLabel(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...{
...args,
contactLabelEmoji: '🐝',
contactLabelString: 'Worker Bee',
contactNameColor: '270',
}}
contact={me}
/>
);
}
export function MeWithInvalidLabelEmoji(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...{
...args,
contactLabelEmoji: '@',
contactLabelString: 'Worker Bee',
contactNameColor: '270',
}}
contact={me}
/>
);
}
export function MeWithAddLabel(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...{
...args,
canAddLabel: true,
}}
contact={me}
/>
);
}
export function MeWithAddLabelEditDisabled(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...{
...args,
canAddLabel: true,
}}
contact={me}
isEditMemberLabelEnabled={false}
/>
);
}
export function Verified(args: PropsType): React.JSX.Element {
return <AboutContactModal {...args} conversation={verifiedConversation} />;
return <AboutContactModal {...args} contact={verifiedConversation} />;
}
export function Blocked(args: PropsType): React.JSX.Element {
return <AboutContactModal {...args} conversation={blockedConversation} />;
return <AboutContactModal {...args} contact={blockedConversation} />;
}
export function Pending(args: PropsType): React.JSX.Element {
return <AboutContactModal {...args} conversation={pendingConversation} />;
return <AboutContactModal {...args} contact={pendingConversation} />;
}
export function NoMessages(args: PropsType): React.JSX.Element {
return <AboutContactModal {...args} conversation={noMessages} />;
return <AboutContactModal {...args} contact={noMessages} />;
}
export function WithAbout(args: PropsType): React.JSX.Element {
return <AboutContactModal {...args} conversation={conversationWithAbout} />;
return <AboutContactModal {...args} contact={conversationWithAbout} />;
}
export function SignalConnection(args: PropsType): React.JSX.Element {
@@ -110,11 +168,7 @@ export function SignalConnection(args: PropsType): React.JSX.Element {
export function SystemContact(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...args}
conversation={systemContact}
isSignalConnection
/>
<AboutContactModal {...args} contact={systemContact} isSignalConnection />
);
}
@@ -122,7 +176,7 @@ export function WithSharedGroups(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...args}
conversation={conversationWithSharedGroups}
contact={conversationWithSharedGroups}
sharedGroupNames={['Axolotl lovers']}
isSignalConnection
/>
@@ -133,7 +187,7 @@ export function DirectFromTrustedContact(args: PropsType): React.JSX.Element {
return (
<AboutContactModal
{...args}
conversation={conversation}
contact={conversation}
fromOrAddedByTrustedContact
/>
);
@@ -13,6 +13,14 @@ import { About } from './About.dom.js';
import { I18n } from '../I18n.dom.js';
import { canHaveNicknameAndNote } from '../../util/nicknames.dom.js';
import { Tooltip, TooltipPlacement } from '../Tooltip.dom.js';
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.js';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis.std.js';
import { FunStaticEmoji } from '../fun/FunEmoji.dom.js';
import { missingEmojiPlaceholder } from './ContactName.dom.js';
function muted(parts: Array<string | React.JSX.Element>) {
return (
@@ -22,11 +30,16 @@ function muted(parts: Array<string | React.JSX.Element>) {
export type PropsType = Readonly<{
i18n: LocalizerType;
canAddLabel: boolean;
contact: ConversationType;
contactLabelEmoji: string | undefined;
contactLabelString: string | undefined;
contactNameColor: string | undefined;
fromOrAddedByTrustedContact?: boolean;
isEditMemberLabelEnabled: boolean;
isSignalConnection: boolean;
onClose: () => void;
onOpenNotePreviewModal: () => void;
conversation: ConversationType;
fromOrAddedByTrustedContact?: boolean;
isSignalConnection: boolean;
pendingAvatarDownload?: boolean;
sharedGroupNames: ReadonlyArray<string>;
startAvatarDownload?: (id: string) => unknown;
@@ -37,8 +50,13 @@ export type PropsType = Readonly<{
export function AboutContactModal({
i18n,
conversation,
canAddLabel,
contact,
contactLabelEmoji,
contactLabelString,
contactNameColor,
fromOrAddedByTrustedContact,
isEditMemberLabelEnabled,
isSignalConnection,
pendingAvatarDownload,
sharedGroupNames,
@@ -49,7 +67,7 @@ export function AboutContactModal({
onClose,
onOpenNotePreviewModal,
}: PropsType): React.JSX.Element {
const { avatarUrl, hasAvatar, isMe } = conversation;
const { avatarUrl, hasAvatar, isMe } = contact;
// If hasAvatar is true, we show the download button instead of blur
const enableClickToLoad = !avatarUrl && !isMe && hasAvatar;
@@ -64,11 +82,11 @@ export function AboutContactModal({
}
return () => {
if (!pendingAvatarDownload && startAvatarDownload) {
startAvatarDownload(conversation.id);
startAvatarDownload(contact.id);
}
};
}, [
conversation.id,
contact.id,
startAvatarDownload,
enableClickToLoad,
pendingAvatarDownload,
@@ -85,9 +103,9 @@ export function AboutContactModal({
const onVerifiedClick = useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
toggleSafetyNumberModal(conversation.id);
toggleSafetyNumberModal(contact.id);
},
[toggleSafetyNumberModal, conversation.id]
[toggleSafetyNumberModal, contact.id]
);
const onProfileNameWarningClick = useCallback(
@@ -99,31 +117,58 @@ export function AboutContactModal({
);
let statusRow: React.JSX.Element | undefined;
const hasLabel = contactNameColor && contactLabelString;
const shouldShowLabel = isMe && hasLabel;
const shouldShowAddLabel =
isMe && !hasLabel && canAddLabel && isEditMemberLabelEnabled;
const emojiLocalizer = useFunEmojiLocalizer();
let labelEmojiElement;
if (
shouldShowLabel &&
contactLabelEmoji &&
isEmojiVariantValue(contactLabelEmoji)
) {
const emojiKey = getEmojiVariantKeyByValue(contactLabelEmoji);
const labelEmojiData = getEmojiVariantByKey(emojiKey);
labelEmojiElement = (
<>
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(labelEmojiData.key)}
size={16}
emoji={labelEmojiData}
/>{' '}
</>
);
} else if (shouldShowLabel && contactLabelEmoji) {
labelEmojiElement = `${missingEmojiPlaceholder} `;
}
if (isMe) {
// No status for ourselves
} else if (conversation.isBlocked) {
} else if (contact.isBlocked) {
statusRow = (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--blocked" />
{i18n('icu:AboutContactModal__blocked', {
name: conversation.title,
name: contact.title,
})}
</div>
);
} else if (!conversation.acceptedMessageRequest) {
} else if (!contact.acceptedMessageRequest) {
statusRow = (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--message-request" />
{i18n('icu:AboutContactModal__message-request')}
</div>
);
} else if (!conversation.hasMessages && !conversation.profileSharing) {
} else if (!contact.hasMessages && !contact.profileSharing) {
statusRow = (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--no-dms" />
{i18n('icu:AboutContactModal__no-dms', {
name: conversation.title,
name: contact.title,
})}
</div>
);
@@ -140,22 +185,21 @@ export function AboutContactModal({
>
<div className="AboutContactModal__row AboutContactModal__row--centered">
<Avatar
avatarPlaceholderGradient={conversation.avatarPlaceholderGradient}
avatarUrl={conversation.avatarUrl}
avatarPlaceholderGradient={contact.avatarPlaceholderGradient}
avatarUrl={contact.avatarUrl}
blur={avatarBlur}
onClick={avatarOnClick}
badge={undefined}
color={conversation.color}
color={contact.color}
conversationType="direct"
hasAvatar={conversation.hasAvatar}
hasAvatar={contact.hasAvatar}
i18n={i18n}
loading={pendingAvatarDownload && !conversation.avatarUrl}
profileName={conversation.profileName}
loading={pendingAvatarDownload && !contact.avatarUrl}
profileName={contact.profileName}
size={AvatarSize.TWO_HUNDRED_SIXTEEN}
title={conversation.title}
title={contact.title}
/>
</div>
<div className="AboutContactModal__row">
<h3 className="AboutContactModal__title">
{isMe
@@ -163,19 +207,18 @@ export function AboutContactModal({
: i18n('icu:AboutContactModal__title')}
</h3>
</div>
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--profile" />
{canHaveNicknameAndNote(conversation) &&
conversation.titleNoNickname !== conversation.title &&
conversation.titleNoNickname ? (
{canHaveNicknameAndNote(contact) &&
contact.titleNoNickname !== contact.title &&
contact.titleNoNickname ? (
<span>
<I18n
i18n={i18n}
id="icu:AboutContactModal__TitleAndTitleWithoutNickname"
components={{
nickname: <UserText text={conversation.title} />,
nickname: <UserText text={contact.title} />,
titleNoNickname: (
<Tooltip
className="AboutContactModal__TitleWithoutNickname__Tooltip"
@@ -185,15 +228,13 @@ export function AboutContactModal({
i18n={i18n}
id="icu:AboutContactModal__TitleWithoutNickname__Tooltip"
components={{
title: (
<UserText text={conversation.titleNoNickname} />
),
title: <UserText text={contact.titleNoNickname} />,
}}
/>
}
delay={0}
>
<UserText text={conversation.titleNoNickname} />
<UserText text={contact.titleNoNickname} />
</Tooltip>
),
muted,
@@ -201,14 +242,13 @@ export function AboutContactModal({
/>
</span>
) : (
<UserText text={conversation.title} />
<UserText text={contact.title} />
)}
</div>
{!isMe && !fromOrAddedByTrustedContact ? (
<div className="AboutContactModal__row">
<i
className={`AboutContactModal__row__icon AboutContactModal__row__icon--${conversation.type === 'group' ? 'group' : 'direct'}-question`}
className={`AboutContactModal__row__icon AboutContactModal__row__icon--${contact.type === 'group' ? 'group' : 'direct'}-question`}
/>
<button
type="button"
@@ -222,7 +262,7 @@ export function AboutContactModal({
}}
i18n={i18n}
id={
conversation.type === 'group'
contact.type === 'group'
? 'icu:ConversationHero--group-names'
: 'icu:ConversationHero--profile-names'
}
@@ -230,8 +270,7 @@ export function AboutContactModal({
</button>
</div>
) : null}
{!isMe && conversation.isVerified ? (
{!isMe && contact.isVerified ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--verified" />
<button
@@ -243,17 +282,12 @@ export function AboutContactModal({
</button>
</div>
) : null}
{!isMe && conversation.about ? (
{!isMe && contact.about ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--about" />
<About
className="AboutContactModal__about"
text={conversation.about}
/>
<About className="AboutContactModal__about" text={contact.about} />
</div>
) : null}
{!isMe && isSignalConnection ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--connections" />
@@ -266,23 +300,35 @@ export function AboutContactModal({
</button>
</div>
) : null}
{!isMe && isInSystemContacts(conversation) ? (
{!isMe && isInSystemContacts(contact) ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--person" />
{i18n('icu:AboutContactModal__system-contact', {
name:
conversation.systemGivenName ||
conversation.firstName ||
conversation.title,
name: contact.systemGivenName || contact.firstName || contact.title,
})}
</div>
) : null}
{conversation.phoneNumber ? (
{shouldShowLabel && (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--label" />
<div className="AboutContactModal__label-container">
{labelEmojiElement}
{contactLabelString}
</div>
</div>
)}
{shouldShowAddLabel && (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--label" />
{i18n('icu:AboutContactModal__add-member-label')}
</div>
)}
{contact.phoneNumber ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--phone" />
<UserText text={conversation.phoneNumber} />
<UserText text={contact.phoneNumber} />
</div>
) : null}
@@ -294,8 +340,7 @@ export function AboutContactModal({
</div>
</div>
)}
{conversation.note && (
{contact.note && (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--note" />
<button
@@ -304,12 +349,11 @@ export function AboutContactModal({
onClick={onOpenNotePreviewModal}
>
<div className="AboutContactModal__OneLineEllipsis">
<UserText text={conversation.note} />
<UserText text={contact.note} />
</div>
</button>
</div>
)}
{statusRow}
</Modal>
);
@@ -39,6 +39,9 @@ export default {
badges: [],
blockConversation: action('blockConversation'),
contact: defaultContact,
contactLabelEmoji: undefined,
contactLabelString: undefined,
contactNameColor: undefined,
conversation: defaultGroup,
hasActiveCall: false,
hasStories: undefined,
@@ -70,6 +73,29 @@ AsNonAdmin.args = {
areWeAdmin: false,
};
export const WithLabel = Template.bind({});
WithLabel.args = {
areWeAdmin: false,
contactLabelEmoji: '💪🏼',
contactLabelString: 'Strong',
contactNameColor: '180',
};
export const WithLabelNoEmoji = Template.bind({});
WithLabelNoEmoji.args = {
areWeAdmin: false,
contactLabelString: 'Strong',
contactNameColor: '220',
};
export const WithLabelInvalidEmoji = Template.bind({});
WithLabelInvalidEmoji.args = {
areWeAdmin: false,
contactLabelEmoji: '%',
contactLabelString: 'Strong',
contactNameColor: '220',
};
export const AsAdmin = Template.bind({});
AsAdmin.args = {
areWeAdmin: true,
@@ -97,14 +123,6 @@ WithoutPhoneNumber.args = {
},
};
export const ViewingSelf = Template.bind({});
ViewingSelf.args = {
contact: {
...defaultContact,
isMe: true,
},
};
export const WithBadges = Template.bind({});
WithBadges.args = {
badges: getFakeBadges(2),
@@ -31,6 +31,8 @@ import {
InAnotherCallTooltip,
getTooltipContent,
} from './InAnotherCallTooltip.dom.js';
import type { ContactModalStateType } from '../../state/ducks/globalModals.preload.js';
import { GroupMemberLabel } from './ContactName.dom.js';
const log = createLogger('ContactModal');
@@ -39,6 +41,9 @@ export type PropsDataType = {
areWeAdmin: boolean;
badges: ReadonlyArray<BadgeType>;
contact?: ConversationType;
contactLabelEmoji: string | undefined;
contactLabelString: string | undefined;
contactNameColor: string | undefined;
conversation?: ConversationType;
hasStories?: HasStories;
readonly i18n: LocalizerType;
@@ -59,7 +64,7 @@ type PropsActionType = {
showConversation: ShowConversationType;
startAvatarDownload: () => void;
toggleAdmin: (conversationId: string, contactId: string) => void;
toggleAboutContactModal: (conversationId: string) => unknown;
toggleAboutContactModal: (options: ContactModalStateType) => unknown;
togglePip: () => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
toggleAddUserToAnotherGroupModal: (conversationId: string) => void;
@@ -87,6 +92,9 @@ export function ContactModal({
badges,
blockConversation,
contact,
contactLabelEmoji,
contactLabelString,
contactNameColor,
conversation,
hasActiveCall,
hasStories,
@@ -344,7 +352,7 @@ export function ContactModal({
className="ContactModal__name"
onClick={ev => {
ev.preventDefault();
toggleAboutContactModal(contact.id);
toggleAboutContactModal({ contactId: contact.id });
}}
>
<div className="ContactModal__name__text">
@@ -361,6 +369,19 @@ export function ContactModal({
</div>
<i className="ContactModal__name__chevron" />
</button>
{contactLabelString && contactNameColor && (
<div className="ContactModal__member-label">
<GroupMemberLabel
emojiSize={14}
contactLabel={{
labelEmoji: contactLabelEmoji,
labelString: contactLabelString,
}}
contactNameColor={contactNameColor}
context="list"
/>
</div>
)}
{!contact.isMe && renderQuickActions(contact.id)}
<div className="ContactModal__divider" />
<div className="ContactModal__button-container">
@@ -36,3 +36,57 @@ export function Colors(): React.JSX.Element {
</>
);
}
export function ColorsWithLabels(): React.JSX.Element {
return (
<>
{ContactNameColors.map(color => (
<div key={color}>
<ContactName
title={`Hello ${color}`}
contactNameColor={color}
contactLabel={{ labelEmoji: '✅', labelString: 'Task Wrangler' }}
/>
</div>
))}
</>
);
}
export function ColorsWithNoLabelEmoji(): React.JSX.Element {
return (
<>
{ContactNameColors.map(color => (
<div key={color}>
<ContactName
title={`Hello ${color}`}
contactNameColor={color}
contactLabel={{
labelEmoji: undefined,
labelString: 'Task Wrangler',
}}
/>
</div>
))}
</>
);
}
export function ColorsWithInvalidLabelEmoji(): React.JSX.Element {
return (
<>
{ContactNameColors.map(color => (
<div key={color}>
<ContactName
title={`Hello ${color}`}
contactNameColor={color}
contactLabel={{
labelEmoji: '&',
labelString: 'Task Wrangler',
}}
/>
</div>
))}
</>
);
}
+107 -2
View File
@@ -4,14 +4,28 @@
import React, { useMemo } from 'react';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { Emojify } from './Emojify.dom.js';
import type { ContactNameColorType } from '../../types/Colors.std.js';
import { getClassNamesFor } from '../../util/getClassNamesFor.std.js';
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
import { isSignalConversation as getIsSignalConversation } from '../../util/isSignalConversation.dom.js';
import {
getEmojiVariantByKey,
getEmojiVariantKeyByValue,
isEmojiVariantValue,
} from '../fun/data/emojis.std.js';
import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.js';
import { FunStaticEmoji } from '../fun/FunEmoji.dom.js';
import type { ConversationType } from '../../state/ducks/conversations.preload.js';
import type { ContactNameColorType } from '../../types/Colors.std.js';
import type { FunStaticEmojiSize } from '../fun/FunEmoji.dom.js';
export const missingEmojiPlaceholder = '⍰';
export type ContactNameData = {
contactNameColor?: ContactNameColorType;
contactLabel?: { labelString: string; labelEmoji: string | undefined };
firstName?: string;
isSignalConversation?: boolean;
isMe?: boolean;
@@ -47,6 +61,7 @@ export type PropsType = ContactNameData & {
};
export function ContactName({
contactLabel,
contactNameColor,
firstName,
isSignalConversation,
@@ -85,6 +100,96 @@ export function ContactName({
}
/>
)}
{contactLabel && (
<>
{' '}
<GroupMemberLabel
contactLabel={contactLabel}
context="bubble"
contactNameColor={contactNameColor}
/>
</>
)}
</WrappingElement>
);
}
export type Context = 'bubble' | 'list';
export function GroupMemberLabel({
emojiSize = 12,
contactLabel,
contactNameColor,
context,
module,
}: {
emojiSize?: FunStaticEmojiSize;
contactLabel?: { labelString: string; labelEmoji: string | undefined };
contactNameColor?: ContactNameColorType;
context: Context;
module?: string;
}): ReactNode {
const emojiLocalizer = useFunEmojiLocalizer();
const getClassName = getClassNamesFor('module-contact-name', module);
if (!contactLabel) {
return null;
}
const { labelEmoji, labelString } = contactLabel;
let emojiElement;
if (labelEmoji && isEmojiVariantValue(labelEmoji)) {
const emojiKey = getEmojiVariantKeyByValue(labelEmoji);
const emojiData = getEmojiVariantByKey(emojiKey);
emojiElement = (
<span
className={classNames(
getClassName('--label-pill--emoji'),
getClassName(`--label-pill--${context}--emoji`)
)}
>
<FunStaticEmoji
role="img"
aria-label={emojiLocalizer.getLocaleShortName(emojiData.key)}
size={emojiSize}
emoji={emojiData}
/>
</span>
);
} else if (labelEmoji) {
emojiElement = (
<span
className={classNames(
getClassName('--label-pill--emoji'),
getClassName(`--label-pill--${context}--emoji`)
)}
>
{missingEmojiPlaceholder}
</span>
);
}
return (
<span
className={classNames(
getClassName('--label-pill'),
getClassName(`--label-pill--${context}`),
getClassName(`--${contactNameColor}--label-pill--${context}`)
)}
>
<span className={getClassName('--label-pill--inner')}>
{emojiElement}
<span
className={classNames(
getClassName('--label-pill--text'),
getClassName(`--label-pill--${context}--text`)
)}
>
<Emojify text={labelString} />
</span>
</span>
</span>
);
}
@@ -29,6 +29,8 @@ const createMemberships = ({
return Array.from(new Array(count)).map(
(_, i): GroupV2Membership => ({
isAdmin: i % 3 === 0,
labelEmoji: i % 6 === 0 ? '🟢' : undefined,
labelString: i % 3 === 0 ? `Task Wrangler ${i}` : undefined,
member: unknownContactIndices.includes(i)
? getDefaultConversation({
isMe: includeMe && i === 0,
@@ -18,6 +18,7 @@ import { StoryViewModeType } from '../../types/Stories.std.js';
import { Button, ButtonVariant } from '../Button.dom.js';
import { SafetyTipsModal } from '../SafetyTipsModal.dom.js';
import { I18n } from '../I18n.dom.js';
import type { ContactModalStateType } from '../../state/ducks/globalModals.preload.js';
export type Props = {
about?: string;
@@ -41,7 +42,7 @@ export type Props = {
startAvatarDownload: () => void;
theme: ThemeType;
viewUserStories: ViewUserStoriesActionCreatorType;
toggleAboutContactModal: (conversationId: string) => unknown;
toggleAboutContactModal: (options: ContactModalStateType) => unknown;
toggleProfileNameWarningModal: (conversationType?: string) => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
@@ -306,7 +307,7 @@ export function ConversationHero({
className="module-conversation-hero__title"
onClick={ev => {
ev.preventDefault();
toggleAboutContactModal(id);
toggleAboutContactModal({ contactId: id });
}}
>
<ContactName title={title} />
+5 -1
View File
@@ -247,6 +247,7 @@ export type PropsData = {
id: string;
renderingContext: RenderingContextType;
contactNameColor?: ContactNameColorType;
contactLabel?: { labelString: string; labelEmoji: string | undefined };
conversationColor: ConversationColorType;
conversationTitle: string;
customColor?: CustomColorType;
@@ -1135,7 +1136,8 @@ export class Message extends React.PureComponent<Props, State> {
}
#renderAuthor(): ReactNode {
const { author, contactNameColor, i18n, isSticker, quote } = this.props;
const { author, contactLabel, contactNameColor, i18n, isSticker, quote } =
this.props;
if (!this.#shouldRenderAuthor()) {
return null;
@@ -1153,6 +1155,7 @@ export class Message extends React.PureComponent<Props, State> {
>
<ContactName
contactNameColor={contactNameColor}
contactLabel={contactLabel}
title={author.isMe ? i18n('icu:you') : author.title}
module={moduleName}
/>
@@ -3244,6 +3247,7 @@ export class Message extends React.PureComponent<Props, State> {
: null,
isTargeted ? 'module-message__container--targeted' : null,
lighterSelect ? 'module-message__container--targeted-lighter' : null,
isStickerLike ? 'module-message__container--sticker-like' : null,
!isStickerLike ? `module-message__container--${direction}` : null,
isEmojiOnly ? 'module-message__container--emoji' : null,
!isStickerLike && direction === 'outgoing'
@@ -256,6 +256,8 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
conversationId: overrideProps.conversationId ?? '',
conversationType: overrideProps.conversationType || 'direct',
contact: overrideProps.contact,
contactNameColor: overrideProps.contactNameColor,
contactLabel: overrideProps.contactLabel,
// disableMenu: overrideProps.disableMenu,
deletedForEveryone: overrideProps.deletedForEveryone,
disableScroll: overrideProps.disableScroll,
@@ -370,7 +372,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
});
const renderMany = (propsArray: ReadonlyArray<Props>) => (
<>
<div className="module-timeline--width-wide">
{propsArray.map((message, index) => (
<TimelineMessage
key={`${message.text}_${index}_${message.direction}`}
@@ -379,7 +381,7 @@ const renderMany = (propsArray: ReadonlyArray<Props>) => (
shouldCollapseBelow={Boolean(propsArray[index + 1])}
/>
))}
</>
</div>
);
const renderThree = (props: Props) =>
@@ -402,7 +404,7 @@ const renderBothDirections = (props: Props) => (
);
const renderOneInBothDirections = (props: Props) => (
<>
<div className="module-timeline--width-wide">
<TimelineMessage {...props} />
<TimelineMessage
{...props}
@@ -412,7 +414,7 @@ const renderOneInBothDirections = (props: Props) => (
canEndPoll: true,
}}
/>
</>
</div>
);
export const PlainMessage = Template.bind({});
@@ -757,6 +759,7 @@ export const AvatarInGroup = Template.bind({});
AvatarInGroup.args = {
author: getDefaultConversation({ avatarUrl: pngUrl }),
conversationType: 'group',
contactNameColor: '100',
status: 'sent',
text: 'Hello it is me, the saxophone.',
};
@@ -765,10 +768,55 @@ export const BadgeInGroup = Template.bind({});
BadgeInGroup.args = {
conversationType: 'group',
getPreferredBadge: () => getFakeBadge(),
contactNameColor: '300',
status: 'sent',
text: 'Hello it is me, the saxophone.',
};
export const LabelInGroup = Template.bind({});
LabelInGroup.args = {
conversationType: 'group',
status: 'sent',
text: 'Hello it is me, the saxophone.',
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelString: 'Chicken Taster',
},
};
export const LabelInGroupWithLongName = Template.bind({});
LabelInGroupWithLongName.args = {
conversationType: 'group',
status: 'sent',
text: 'Hello it is me, the saxophone.',
author: {
...getDefaultConversation(),
title: 'Long long long long long long long long long long long name',
},
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelString: 'Chicken Taster',
},
};
export const LabelInGroupWithLongNameAndLongMessage = Template.bind({});
LabelInGroupWithLongNameAndLongMessage.args = {
conversationType: 'group',
status: 'sent',
text: 'Hello it is me, the saxophone. I am a good friend of yours. Do you remember? A long long long long long long long time ago.',
author: {
...getDefaultConversation(),
title: 'Long long long long long long long long long long long name',
},
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelString: 'Chicken Taster',
},
};
export const Sticker = Template.bind({});
Sticker.args = {
attachments: [
@@ -784,6 +832,69 @@ Sticker.args = {
status: 'sent',
};
export const StickerInGroup = Template.bind({});
StickerInGroup.args = {
attachments: [
fakeAttachment({
url: '/fixtures/512x515-thumbs-up-lincoln.webp',
fileName: '512x515-thumbs-up-lincoln.webp',
contentType: IMAGE_WEBP,
width: 128,
height: 128,
}),
],
conversationType: 'group',
contactNameColor: '180',
isSticker: true,
status: 'sent',
};
export const StickerWithLabelInGroup = Template.bind({});
StickerWithLabelInGroup.args = {
attachments: [
fakeAttachment({
url: '/fixtures/512x515-thumbs-up-lincoln.webp',
fileName: '512x515-thumbs-up-lincoln.webp',
contentType: IMAGE_WEBP,
width: 128,
height: 128,
}),
],
conversationType: 'group',
isSticker: true,
status: 'sent',
contactNameColor: '260',
contactLabel: {
labelEmoji: '🍗',
labelString: 'Chicken Taster',
},
};
export const StickerWithLongNameAndLabelInGroup = Template.bind({});
StickerWithLongNameAndLabelInGroup.args = {
attachments: [
fakeAttachment({
url: '/fixtures/512x515-thumbs-up-lincoln.webp',
fileName: '512x515-thumbs-up-lincoln.webp',
contentType: IMAGE_WEBP,
width: 128,
height: 128,
}),
],
conversationType: 'group',
isSticker: true,
status: 'sent',
author: {
...getDefaultConversation(),
title: 'Long long long long long long long long long long long name',
},
contactNameColor: '280',
contactLabel: {
labelEmoji: '🍗',
labelString: 'Chicken Taster',
},
};
export const Quote = Template.bind({});
Quote.args = {
quote: {
@@ -806,6 +917,7 @@ Quote.args = {
badges: [],
},
conversationType: 'group',
contactNameColor: '100',
};
export function Deleted(): React.JSX.Element {
@@ -834,6 +946,7 @@ export const DeletedWithExpireTimer = Template.bind({});
DeletedWithExpireTimer.args = {
timestamp: Date.now() - 60 * 1000,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
canForward: false,
expirationLength: 5 * 60 * 1000,
@@ -846,6 +959,7 @@ export function DeletedWithError(): React.JSX.Element {
timestamp: Date.now() - 60 * 1000,
// canDeleteForEveryone: true,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
status: 'partial-sent',
direction: 'outgoing',
@@ -854,6 +968,7 @@ export function DeletedWithError(): React.JSX.Element {
timestamp: Date.now() - 60 * 1000,
// canDeleteForEveryone: true,
conversationType: 'group',
contactNameColor: '100',
deletedForEveryone: true,
status: 'error',
direction: 'outgoing',
@@ -985,6 +1100,7 @@ LinkPreviewInGroup.args = {
status: 'sent',
text: 'Be sure to look at https://www.signal.org',
conversationType: 'group',
contactNameColor: '100',
};
export const LinkPreviewWithLongWord = Template.bind({});
@@ -1010,6 +1126,7 @@ LinkPreviewWithLongWord.args = {
status: 'sent',
text: 'Be sure to look at https://www.signal.org',
conversationType: 'group',
contactNameColor: '100',
};
export const LinkPreviewWithQuote = Template.bind({});
@@ -1048,6 +1165,7 @@ LinkPreviewWithQuote.args = {
status: 'sent',
text: 'Be sure to look at https://www.signal.org',
conversationType: 'group',
contactNameColor: '100',
};
export const LinkPreviewWithSmallImage = Template.bind({});
@@ -1851,6 +1969,7 @@ GifInAGroup.args = {
}),
],
conversationType: 'group',
contactNameColor: '100',
status: 'sent',
};
@@ -2168,6 +2287,7 @@ function createMockPollWithVoters(
export const Poll = Template.bind({});
Poll.args = {
conversationType: 'group',
contactNameColor: '100',
poll: {
question: 'What should we have for lunch?',
options: ['Pizza 🍕', 'Sushi 🍱', 'Tacos 🌮', 'Salad 🥗'],
@@ -2182,6 +2302,7 @@ Poll.args = {
export const PollMultipleChoice = Template.bind({});
PollMultipleChoice.args = {
conversationType: 'group',
contactNameColor: '100',
poll: {
question: 'Which features would you like to see in the next update?',
options: ['Dark mode', 'Video calls', 'File sharing', 'Reactions', 'Polls'],
@@ -2196,6 +2317,7 @@ PollMultipleChoice.args = {
export const PollWithVotes = Template.bind({});
PollWithVotes.args = {
conversationType: 'group',
contactNameColor: '100',
poll: createMockPollWithVoters(
'Best day for the team meeting?',
['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
@@ -2220,6 +2342,7 @@ PollWithVotes.args = {
export const PollWithPendingVotes = Template.bind({});
PollWithPendingVotes.args = {
conversationType: 'group',
contactNameColor: '100',
poll: createMockPollWithVoters(
'Best day for the team meeting?',
['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'],
@@ -2248,6 +2371,7 @@ PollWithPendingVotes.args = {
export const PollTerminated = Template.bind({});
PollTerminated.args = {
conversationType: 'group',
contactNameColor: '100',
poll: createMockPollWithVoters(
'Quick poll: Coffee or tea?',
['Coffee ☕', 'Tea 🍵'],
@@ -2274,6 +2398,7 @@ PollTerminated.args = {
export const PollLongText = Template.bind({});
PollLongText.args = {
conversationType: 'group',
contactNameColor: '100',
poll: createMockPollWithVoters(
'Given the current situation with remote work becoming more prevalent, what would be your preferred working arrangement for the future once everything stabilizes?',
[
@@ -2299,6 +2424,7 @@ PollLongText.args = {
export const PollMultipleChoiceWithVotes = Template.bind({});
PollMultipleChoiceWithVotes.args = {
conversationType: 'group',
contactNameColor: '100',
poll: createMockPollWithVoters(
'Which toppings do you want on the pizza?',
[
@@ -2414,6 +2540,7 @@ export function PollAnimationPlayground(): React.JSX.Element {
const props = createProps({
conversationType: 'group',
contactNameColor: '100',
poll,
status: 'sent',
sendPollVote: handleSendPollVote,
@@ -2660,6 +2787,7 @@ TapToViewImageInGroup.args = {
isTapToView: true,
status: 'sent',
conversationType: 'group',
contactNameColor: '100',
};
export const TapToViewVideo = Template.bind({});
@@ -2754,7 +2882,7 @@ export function Colors(): React.JSX.Element {
<>
{ConversationColors.map(color => (
<div key={color}>
{renderBothDirections(
{renderOneInBothDirections(
createProps({
conversationColor: color,
text: `Here is a preview of the chat color: ${color}. The color is visible to only you.`,
@@ -2897,18 +3025,21 @@ export const CollapsingTextOnlyGroupMessages = (): React.JSX.Element => {
createProps({
author,
conversationType: 'group',
contactNameColor: '100',
text: 'One',
timestamp: Date.now() - 2 * MINUTE,
}),
createProps({
author,
conversationType: 'group',
contactNameColor: '100',
text: 'Two',
timestamp: Date.now() - MINUTE,
}),
createProps({
author,
conversationType: 'group',
contactNameColor: '100',
text: 'Three',
}),
]);
@@ -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 (
<ChooseGroupMembersModal
{...props}
candidateContacts={allCandidateContacts}
selectedContacts={[]}
regionCode="US"
theme={ThemeType.light}
i18n={i18n}
lookupConversationWithoutServiceId={makeFakeLookupConversationWithoutServiceId()}
ourE164={undefined}
ourUsername={undefined}
showUserNotFoundModal={action('showUserNotFoundModal')}
username={undefined}
/>
);
},
renderConfirmAdditionsModal: props => {
return (
<ConfirmAdditionsModal {...props} selectedContacts={[]} i18n={i18n} />
);
},
});
}));
const memberColors = new Map<string, string>(
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 (
<ChooseGroupMembersModal
{...props}
candidateContacts={allCandidateContacts}
selectedContacts={[]}
regionCode="US"
theme={ThemeType.light}
i18n={i18n}
lookupConversationWithoutServiceId={makeFakeLookupConversationWithoutServiceId()}
ourE164={undefined}
ourUsername={undefined}
showUserNotFoundModal={action('showUserNotFoundModal')}
username={undefined}
/>
);
},
renderConfirmAdditionsModal: props => {
return (
<ConfirmAdditionsModal {...props} selectedContacts={[]} i18n={i18n} />
);
},
};
};
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 <ConversationDetails {...props} canEditGroupInfo />;
}
export function GroupEditableEditLabelDisabled(): React.JSX.Element {
const props = createProps();
return (
<ConversationDetails
{...props}
canEditGroupInfo
isEditMemberLabelEnabled={false}
/>
);
}
export function GroupEditableWithCustomDisappearingTimeout(): React.JSX.Element {
const props = createProps(false, DurationInSeconds.fromDays(3));
@@ -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<ConversationType>;
maxGroupSize: number;
maxRecommendedGroupSize: number;
memberships: ReadonlyArray<GroupV2Membership>;
memberColors: Map<string, string>;
pendingApprovalMemberships: ReadonlyArray<GroupV2RequestingMembership>;
pendingAvatarDownload?: boolean;
pendingMemberships: ReadonlyArray<GroupV2PendingMembership>;
@@ -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 ? (
<PanelRow
icon={
<ConversationDetailsIcon
ariaLabel={i18n('icu:ConversationDetails--member-label')}
icon={IconType.tag}
/>
}
label={i18n('icu:ConversationDetails--member-label')}
onClick={() =>
pushPanelForConversation({
type: PanelType.GroupMemberLabelEditor,
})
}
/>
) : null}
<PanelRow
icon={
<ConversationDetailsIcon
@@ -16,6 +16,7 @@ import type { BadgeType } from '../../../badges/types.std.js';
import { UserText } from '../../UserText.dom.js';
import { isInSystemContacts } from '../../../util/isInSystemContacts.std.js';
import { InContactsIcon } from '../../InContactsIcon.dom.js';
import type { ContactModalStateType } from '../../../state/ducks/globalModals.preload.js';
export type Props = {
areWeASubscriber: boolean;
@@ -30,7 +31,7 @@ export type Props = {
pendingAvatarDownload: boolean;
startAvatarDownload: () => 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"
>
@@ -31,6 +31,7 @@ export enum IconType {
'reset' = 'reset',
'share' = 'share',
'spinner' = 'spinner',
'tag' = 'tag',
'timer' = 'timer',
'trash' = 'trash',
'verify' = 'verify',
@@ -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<GroupV2Membership> => {
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<GroupV2Membership>
): Map<string, string> =>
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<Props>;
const createMemberships = (
numberOfMemberships = 10
): Array<GroupV2Membership> => {
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 (
<ConversationDetailsMembershipList {...args} memberships={memberships} />
);
}
export function Limit(args: Props): React.JSX.Element {
const memberships = createMemberships(5);
const memberships = defaultMemberships.slice(5);
return (
<ConversationDetailsMembershipList {...args} memberships={memberships} />
);
}
export function Limit1(args: Props): React.JSX.Element {
const memberships = createMemberships(6);
const memberships = defaultMemberships.slice(6);
return (
<ConversationDetailsMembershipList {...args} memberships={memberships} />
);
}
export function Limit2(args: Props): React.JSX.Element {
const memberships = createMemberships(7);
const memberships = defaultMemberships.slice(7);
return (
<ConversationDetailsMembershipList {...args} memberships={memberships} />
);
@@ -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 (
<ConversationDetailsMembershipList {...args} memberships={memberships} />
<ConversationDetailsMembershipList
{...args}
memberships={memberships}
memberColors={memberColors}
/>
);
}
@@ -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 (
<ConversationDetailsMembershipList
{...args}
memberships={memberships}
canAddNewMembers
/>
);
return <ConversationDetailsMembershipList {...args} canAddNewMembers />;
}
@@ -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<GroupV2Membership>;
memberColors: Map<string, string>;
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 }) => (
<PanelRow
key={member.id}
onClick={() => showContactModal(member.id, conversationId)}
icon={
<Avatar
conversationType="direct"
badge={getPreferredBadge(member.badges)}
i18n={i18n}
size={AvatarSize.THIRTY_TWO}
theme={theme}
{...member}
{sortedMemberships
.slice(0, membersToShow)
.map(({ isAdmin, member, labelEmoji, labelString }) => {
const contactNameColor = memberColors.get(member.id);
return (
<PanelRow
key={member.id}
onClick={() => showContactModal(member.id, conversationId)}
icon={
<Avatar
conversationType="direct"
badge={getPreferredBadge(member.badges)}
i18n={i18n}
size={AvatarSize.THIRTY_SIX}
theme={theme}
{...member}
/>
}
label={
<div>
<div>
<Emojify
text={member.isMe ? i18n('icu:you') : member.title}
/>
</div>
{labelString && contactNameColor && (
<div>
<GroupMemberLabel
contactNameColor={contactNameColor}
contactLabel={{
labelEmoji,
labelString,
}}
context="list"
/>
</div>
)}
</div>
}
right={isAdmin ? i18n('icu:GroupV2--admin') : ''}
/>
}
label={
<Emojify text={member.isMe ? i18n('icu:you') : member.title} />
}
right={isAdmin ? i18n('icu:GroupV2--admin') : ''}
/>
))}
);
})}
{showAllMembers === false && shouldHideRestMembers && (
<PanelRow
className="ConversationDetails-membership-list--show-all"
@@ -0,0 +1,52 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './GroupMemberLabelEditor.dom.js';
import { GroupMemberLabelEditor } from './GroupMemberLabelEditor.dom.js';
import type { ConversationType } from '../../../state/ducks/conversations.preload.js';
import { getDefaultConversation } from '../../../test-helpers/getDefaultConversation.std.js';
import { ThemeType } from '../../../types/Util.std.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/Conversation/ConversationDetails/GroupMemberLabelEditor',
} satisfies Meta<PropsType>;
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 <GroupMemberLabelEditor {...props} />;
}
export function ExistingLabel(): React.JSX.Element {
const props = createProps();
return <GroupMemberLabelEditor {...props} />;
}
export function StringButNoEmoji(): React.JSX.Element {
const props = {
...createProps(),
existingLabelEmoji: undefined,
};
return <GroupMemberLabelEditor {...props} />;
}
@@ -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 (
<div className={tw('mx-auto flex h-full max-w-[640px] flex-col')}>
<div>
<Input
autoFocus
hasClearButton
i18n={i18n}
icon={
<FunEmojiPicker
open={emojiPickerOpen}
onOpenChange={(open: boolean) => setEmojiPickerOpen(open)}
placement="bottom"
onSelectEmoji={data => {
const newEmoji = getEmojiVariantByKey(data.variantKey)?.value;
setLabelEmoji(newEmoji);
}}
closeOnSelect
theme={theme}
>
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emojiKey} />
</FunEmojiPicker>
}
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}
/>
</div>
<div className={tw('text-label-secondary')}>
{i18n('icu:ConversationDetails--member-label--description')}
</div>
<div className={tw('flex-grow')} />
<div className={tw('mb-3 flex w-full justify-end gap-2')}>
<AxoButton.Root
variant="secondary"
size="md"
onClick={() => {
popPanelForConversation();
}}
>
{i18n('icu:cancel')}
</AxoButton.Root>
<AxoButton.Root
variant="primary"
size="md"
experimentalSpinner={spinner}
disabled={!isDirty || isSaving}
onClick={() => {
setIsSaving(true);
updateGroupMemberLabel(
{
conversationId: conversation.id,
labelEmoji,
labelString,
},
{
onSuccess() {
setIsSaving(false);
popPanelForConversation();
},
onFailure() {
// TODO: DESKTOP-9698
},
}
);
}}
>
{i18n('icu:save')}
</AxoButton.Root>
</div>
</div>
);
}
+2
View File
@@ -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',
+229 -4
View File
@@ -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<GroupChange.Actions.ModifyMemberLabelAction>;
(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<GroupChange.Actions.ModifyMemberProfileKeyAction>;
(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<DecryptedModifyMemberLabelAction>;
modifyMemberProfileKeys?: ReadonlyArray<{
profileKey: Uint8Array;
aci: AciString;
@@ -6236,6 +6355,17 @@ function decryptGroupChange(
})
);
// modifyMemberLabels?: Array<GroupChange.Actions.ModifyMemberLabelAction>
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<Proto.GroupChange.Actions.IModifyMemberLabelAction>,
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,
};
}
+2
View File
@@ -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
+29
View File
@@ -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<void> {
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<void> {
if (!isGroupV2(this.attributes)) {
return;
+52
View File
@@ -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<T extends (...params: Array<any>) => any> = ReadonlyDeep<
(...params: Parameters<T>) => void
>;
export type UpdateGroupAttributesType = ReadonlyDeep<
ActionCreator<typeof updateGroupAttributes>
>;
function updateGroupAttributes(
conversationId: string,
attributes: Readonly<{
@@ -4678,6 +4689,47 @@ function updateGroupAttributes(
};
}
export type UpdateGroupMemberLabelType = ReadonlyDeep<
ActionCreator<typeof updateGroupMemberLabel>
>;
function updateGroupMemberLabel(
{
conversationId,
labelEmoji,
labelString,
}: {
conversationId: string;
labelEmoji: string | undefined;
labelString: string | undefined;
},
{
onSuccess,
onFailure,
}: {
onSuccess?: () => unknown;
onFailure?: () => unknown;
} = {}
): ThunkAction<void, RootStateType, unknown, never> {
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<void, RootStateType, unknown, never> {
+6 -6
View File
@@ -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,
};
}
+12 -3
View File
@@ -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<ConversationType> => {
return (conversationId: string): Array<AdminMembershipType> => {
const {
groupId,
groupVersion,
@@ -1380,11 +1385,15 @@ export const getGroupAdminsSelector = createSelector(
return [];
}
const admins: Array<ConversationType> = [];
const admins: Array<AdminMembershipType> = [];
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;
+5
View File
@@ -50,6 +50,11 @@ export const getConfirmLeaveCallModalState = createSelector(
({ confirmLeaveCallModalState }) => confirmLeaveCallModalState
);
export const getAboutContactModalState = createSelector(
getGlobalModalsState,
({ aboutContactModalState }) => aboutContactModalState
);
export const getContactModalState = createSelector(
getGlobalModalsState,
({ contactModalState }) => contactModalState
+12
View File
@@ -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,
+54 -17
View File
@@ -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 (
<AboutContactModal
i18n={i18n}
conversation={conversation}
sharedGroupNames={sharedGroupNames}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
toggleSafetyNumberModal={toggleSafetyNumberModal}
isSignalConnection={isSignalConnection(conversation)}
fromOrAddedByTrustedContact={isFromOrAddedByTrustedContact(conversation)}
canAddLabel={canAddLabel}
contact={contact}
contactLabelEmoji={contactLabelEmoji}
contactLabelString={contactLabelString}
contactNameColor={contactNameColor}
fromOrAddedByTrustedContact={isFromOrAddedByTrustedContact(contact)}
isEditMemberLabelEnabled={isEditMemberLabelEnabled}
isSignalConnection={isSignalConnection(contact)}
onClose={toggleAboutContactModal}
onOpenNotePreviewModal={handleOpenNotePreviewModal}
toggleProfileNameWarningModal={toggleProfileNameWarningModal}
pendingAvatarDownload={
conversationId ? isPendingAvatarDownload(conversationId) : false
}
sharedGroupNames={sharedGroupNames}
startAvatarDownload={
conversationId ? () => startAvatarDownload(conversationId) : undefined
}
toggleProfileNameWarningModal={toggleProfileNameWarningModal}
toggleSafetyNumberModal={toggleSafetyNumberModal}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
/>
);
});
+8 -2
View File
@@ -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({
<CompositionArea
// Base
conversationId={id}
draftBodyRanges={hydratedDraftBodyRanges ?? null}
draftEditMessage={draftEditMessage ?? null}
draftText={conversation.draftText ?? null}
focusCounter={focusCounter}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
@@ -322,8 +329,7 @@ export const SmartCompositionArea = memo(function SmartCompositionArea({
areWePending={conversation.areWePending ?? null}
areWePendingApproval={conversation.areWePendingApproval ?? null}
groupAdmins={groupAdmins}
draftText={conversation.draftText ?? null}
draftBodyRanges={hydratedDraftBodyRanges ?? null}
memberColors={memberColors}
renderSmartCompositionRecording={renderSmartCompositionRecording}
renderSmartCompositionRecordingDraft={
renderSmartCompositionRecordingDraft
+18 -4
View File
@@ -7,7 +7,10 @@ import { ContactModal } from '../../components/conversation/ContactModal.dom.js'
import { getAreWeASubscriber } from '../selectors/items.dom.js';
import { getIntl, getTheme } from '../selectors/user.std.js';
import { getBadgesSelector } from '../selectors/badges.preload.js';
import { getConversationSelector } from '../selectors/conversations.dom.js';
import {
getCachedConversationMemberColorsSelector,
getConversationSelector,
} from '../selectors/conversations.dom.js';
import { getHasStoriesSelector } from '../selectors/stories2.dom.js';
import {
getActiveCallState,
@@ -39,14 +42,22 @@ export const SmartContactModal = memo(function SmartContactModal() {
const areWeAdmin = conversation?.areWeAdmin ?? false;
const ourMembership = useMemo(() => {
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}
+19 -1
View File
@@ -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}
@@ -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 <SmartGroupLinkManagement conversationId={conversationId} />;
}
if (panel.type === PanelType.GroupMemberLabelEditor) {
return <SmartGroupMemberLabelEditor conversationId={conversationId} />;
}
if (panel.type === PanelType.GroupPermissions) {
return <SmartGroupV2Permissions conversationId={conversationId} />;
}
@@ -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:
+6
View File
@@ -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}
@@ -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}
@@ -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',
@@ -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 (
<GroupMemberLabelEditor
i18n={i18n}
conversation={conversation}
existingLabelEmoji={existingLabelEmoji}
existingLabelString={existingLabelString}
popPanelForConversation={popPanelForConversation}
theme={theme}
updateGroupMemberLabel={updateGroupMemberLabel}
/>
);
}
);
@@ -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 {
@@ -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([]);
@@ -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',
});
});
});
+3
View File
@@ -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 };
@@ -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');
}
+9 -1
View File
@@ -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,
},
];
},
[]
),
+4
View File
@@ -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,
}));
}