GroupMemberLabelEditor: Show all other members with group labels

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
automated-signal
2026-02-11 18:58:47 -06:00
committed by GitHub
parent 838ab0f607
commit 2123354a49
5 changed files with 335 additions and 114 deletions

View File

@@ -4,6 +4,8 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import { sample } from 'lodash';
import type { PropsType } from './GroupMemberLabelEditor.dom.js';
import { GroupMemberLabelEditor } from './GroupMemberLabelEditor.dom.js';
import { getDefaultConversation } from '../../../test-helpers/getDefaultConversation.std.js';
@@ -12,6 +14,7 @@ import { getFakeBadge } from '../../../test-helpers/getFakeBadge.std.js';
import { SECOND } from '../../../util/durations/constants.std.js';
import { sleep } from '../../../util/sleep.std.js';
import { SignalService as Proto } from '../../../protobuf/index.std.js';
import { ContactNameColors } from '../../../types/Colors.std.js';
const { i18n } = window.SignalContext;
@@ -26,6 +29,7 @@ const createProps = (): PropsType => ({
existingLabelString: 'Good Memory',
getPreferredBadge: () => undefined,
i18n,
membersWithLabel: [],
ourColor: '160',
popPanelForConversation: action('popPanelForConversation'),
theme: ThemeType.light,
@@ -120,3 +124,81 @@ export function PermissionsRestrictedButAdmin(): React.JSX.Element {
/>
);
}
export function NoMembersWithLabel(): React.JSX.Element {
const props: PropsType = createProps();
return <GroupMemberLabelEditor {...props} membersWithLabel={[]} />;
}
export function AFewMembersWithLabel(): React.JSX.Element {
const props: PropsType = createProps();
return (
<GroupMemberLabelEditor
{...props}
membersWithLabel={ContactNameColors.slice(0, 3).map(
(contactNameColor, i) => ({
member: getDefaultConversation(),
isAdmin: i <= 2,
labelEmoji: sample([
'⚫',
'❤️',
'🫥',
'🤍',
'2⃣',
'3⃣',
'🥂',
'🎊',
'',
'😵‍💫',
'🚲',
'🐶',
'🐱',
'🏠',
]),
labelString:
i % 2 === 0
? `Label number long long long long long long long long long ${i}`
: `Label member ${i}`,
contactNameColor,
})
)}
/>
);
}
export function LotsOfMembersWithLabel(): React.JSX.Element {
const props: PropsType = createProps();
return (
<GroupMemberLabelEditor
{...props}
membersWithLabel={ContactNameColors.map((contactNameColor, i) => ({
member: getDefaultConversation(),
isAdmin: i <= 6,
labelEmoji: sample([
'⚫',
'❤️',
'🫥',
'🤍',
'2⃣',
'3⃣',
'🥂',
'🎊',
'',
'😵‍💫',
'🚲',
'🐶',
'🐱',
'🏠',
]),
labelString:
i % 2 === 0
? `Label number long long long long long long long long long ${i}`
: `Label member ${i}`,
contactNameColor,
}))}
/>
);
}

View File

@@ -36,6 +36,9 @@ import type {
} from '../../../state/ducks/conversations.preload.js';
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
import type { PreferredBadgeSelectorType } from '../../../state/selectors/badges.preload.js';
import { Avatar, AvatarSize } from '../../Avatar.dom.js';
import { UserText } from '../../UserText.dom.js';
import { GroupMemberLabel } from '../ContactName.dom.js';
export type PropsDataType = {
existingLabelEmoji: string | undefined;
@@ -43,6 +46,13 @@ export type PropsDataType = {
group: ConversationType;
i18n: LocalizerType;
me: ConversationType;
membersWithLabel: Array<{
contactNameColor: string;
isAdmin: boolean;
labelEmoji: string | undefined;
labelString: string;
member: ConversationType;
}>;
ourColor: string | undefined;
theme: ThemeType;
};
@@ -68,6 +78,7 @@ export function GroupMemberLabelEditor({
existingLabelString,
getPreferredBadge,
i18n,
membersWithLabel,
ourColor,
popPanelForConversation,
theme,
@@ -113,127 +124,207 @@ export function GroupMemberLabelEditor({
}, [group, isShowingPermissionsError, setIsShowingPermissionsError]);
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;
<div className={tw('flex size-full flex-col')}>
<div className={tw('grow flex-col overflow-y-scroll')}>
<div className={tw('mx-auto max-w-[680px] px-5')}>
<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={STRING_GRAPHEME_LIMIT}
maxByteCount={STRING_BYTE_LIMIT}
moduleClassName="GroupMemberLabelEditor"
onChange={value => {
if (!value) {
setLabelEmoji(undefined);
setLabelEmoji(newEmoji);
}}
closeOnSelect
theme={theme}
>
<FunEmojiPickerButton i18n={i18n} selectedEmoji={emojiKey} />
</FunEmojiPicker>
}
maxLengthCount={STRING_GRAPHEME_LIMIT}
maxByteCount={STRING_BYTE_LIMIT}
moduleClassName="GroupMemberLabelEditor"
onChange={value => {
if (!value) {
setLabelEmoji(undefined);
}
// Replace all whitespace with basic space
setLabelString(value.replace(/\s/g, ' '));
}}
ref={undefined}
placeholder={i18n(
'icu:ConversationDetails--member-label--placeholder'
)}
value={labelString}
whenToShowRemainingCount={20}
/>
</div>
<div className={tw('type-body-small text-label-secondary')}>
{i18n('icu:ConversationDetails--member-label--description')}
</div>
<div className={tw('mt-[30px] type-body-medium font-semibold')}>
{i18n('icu:ConversationDetails--member-label--preview')}
// Replace all whitespace with basic space
setLabelString(value.replace(/\s/g, ' '));
}}
ref={undefined}
placeholder={i18n(
'icu:ConversationDetails--member-label--placeholder'
)}
value={labelString}
whenToShowRemainingCount={20}
/>
<div className={tw('type-body-small text-label-secondary')}>
{i18n('icu:ConversationDetails--member-label--description')}
</div>
<div className={tw('mt-[30px] type-body-medium font-semibold')}>
{i18n('icu:ConversationDetails--member-label--preview')}
</div>
<div
className={tw(
'mt-5 rounded-[27px] bg-fill-primary-pressed px-2 pt-[47px] pb-6'
)}
ref={messageContainer}
>
<Message
text={i18n('icu:ConversationDetails--member-label--hello')}
author={{ ...me }}
contactLabel={contactLabelForMessage}
contactNameColor={ourColor}
renderingContext="ConversationDetails/GroupMemberLabelEditor"
theme={theme}
id="fake-id"
conversationColor={
group.conversationColor ?? ConversationColors[0]
}
conversationTitle={group.title}
conversationId={group.id}
textDirection={TextDirection.LeftToRight}
isSelected={false}
isSelectMode={false}
isSMS={false}
isVoiceMessagePlayed={false}
direction="incoming"
timestamp={Date.now()}
conversationType="group"
previews={[]}
isPinned={false}
canDeleteForEveryone={false}
isBlocked={false}
isMessageRequestAccepted={false}
containerElementRef={messageContainer}
containerWidthBreakpoint={WidthBreakpoint.Wide}
i18n={i18n}
interactivity={MessageInteractivity.Static}
interactionMode="mouse"
platform="unused"
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
clearTargetedMessage={noop}
getPreferredBadge={getPreferredBadge}
renderAudioAttachment={() => <div />}
doubleCheckMissingQuoteReference={noop}
messageExpanded={noop}
checkForAccount={noop}
startConversation={noop}
showConversation={noop}
openGiftBadge={noop}
pushPanelForConversation={noop}
retryMessageSend={noop}
sendPollVote={noop}
endPoll={noop}
showContactModal={noop}
showSpoiler={noop}
cancelAttachmentDownload={noop}
kickOffAttachmentDownload={noop}
markAttachmentAsCorrupted={noop}
saveAttachment={noop}
saveAttachments={noop}
showLightbox={noop}
showLightboxForViewOnceMedia={noop}
scrollToQuotedMessage={noop}
showAttachmentDownloadStillInProgressToast={noop}
showExpiredIncomingTapToViewToast={noop}
showExpiredOutgoingTapToViewToast={noop}
showMediaNoLongerAvailableToast={noop}
showTapToViewNotAvailableModal={noop}
viewStory={noop}
onToggleSelect={noop}
onReplyToMessage={noop}
/>
</div>
<div
className={tw('mt-[30px] mb-2.5 type-body-medium font-semibold')}
>
{i18n('icu:ConversationDetails--member-label--list-header')}
</div>
<div>
{membersWithLabel.length === 0 && (
<div className={tw('mt-2 type-body-medium text-label-secondary')}>
{i18n('icu:ConversationDetails--member-label--no-members')}
</div>
)}
{membersWithLabel.map(membership => {
const {
contactNameColor,
isAdmin,
labelEmoji: memberLabelEmoji,
labelString: memberLabelString,
member,
} = membership;
return (
<div
className={tw(
'flex w-full flex-row items-center overflow-hidden py-2'
)}
key={member.serviceId}
>
<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 grow flex-col items-start overflow-hidden'
)}
>
<div>
<UserText
text={member.isMe ? i18n('icu:you') : member.title}
/>
</div>
{memberLabelString && contactNameColor && (
<div
className={tw(
'max-w-full min-w-0 overflow-hidden type-body-small'
)}
>
<GroupMemberLabel
contactNameColor={contactNameColor}
contactLabel={{
labelEmoji: memberLabelEmoji,
labelString: memberLabelString,
}}
context="list"
/>
</div>
)}
</div>
{isAdmin && (
<div className={tw('ms-2 text-label-secondary')}>
{i18n('icu:GroupV2--admin')}
</div>
)}
</div>
);
})}
</div>
</div>
</div>
<div
className={tw(
'mt-5 rounded-[27px] bg-fill-primary-pressed px-2 pt-[47px] pb-6'
'mx-auto flex w-full max-w-[680px] shrink-0 grow-0 justify-end gap-2 px-5 py-3 pe-6.5'
)}
ref={messageContainer}
>
<Message
text={i18n('icu:ConversationDetails--member-label--hello')}
author={{ ...me }}
contactLabel={contactLabelForMessage}
contactNameColor={ourColor}
renderingContext="ConversationDetails/GroupMemberLabelEditor"
theme={theme}
id="fake-id"
conversationColor={group.conversationColor ?? ConversationColors[0]}
conversationTitle={group.title}
conversationId={group.id}
textDirection={TextDirection.LeftToRight}
isSelected={false}
isSelectMode={false}
isSMS={false}
isVoiceMessagePlayed={false}
direction="incoming"
timestamp={Date.now()}
conversationType="group"
previews={[]}
isPinned={false}
canDeleteForEveryone={false}
isBlocked={false}
isMessageRequestAccepted={false}
containerElementRef={messageContainer}
containerWidthBreakpoint={WidthBreakpoint.Wide}
i18n={i18n}
interactivity={MessageInteractivity.Static}
interactionMode="mouse"
platform="unused"
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
clearTargetedMessage={noop}
getPreferredBadge={getPreferredBadge}
renderAudioAttachment={() => <div />}
doubleCheckMissingQuoteReference={noop}
messageExpanded={noop}
checkForAccount={noop}
startConversation={noop}
showConversation={noop}
openGiftBadge={noop}
pushPanelForConversation={noop}
retryMessageSend={noop}
sendPollVote={noop}
endPoll={noop}
showContactModal={noop}
showSpoiler={noop}
cancelAttachmentDownload={noop}
kickOffAttachmentDownload={noop}
markAttachmentAsCorrupted={noop}
saveAttachment={noop}
saveAttachments={noop}
showLightbox={noop}
showLightboxForViewOnceMedia={noop}
scrollToQuotedMessage={noop}
showAttachmentDownloadStillInProgressToast={noop}
showExpiredIncomingTapToViewToast={noop}
showExpiredOutgoingTapToViewToast={noop}
showMediaNoLongerAvailableToast={noop}
showTapToViewNotAvailableModal={noop}
viewStory={noop}
onToggleSelect={noop}
onReplyToMessage={noop}
/>
</div>
<div className={tw('mt-14 mb-3 flex w-full justify-end gap-2')}>
<AxoButton.Root
variant="secondary"
size="md"

View File

@@ -41,7 +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';
import { SmartGroupMemberLabelEditor } from './GroupMemberLabelEditor.preload.js';
const log = createLogger('ConversationPanel');
@@ -339,6 +339,7 @@ const PanelContainer = forwardRef<
'ConversationPanel__body',
panel.type !== PanelType.PinnedMessages &&
panel.type !== PanelType.AllMedia &&
panel.type !== PanelType.GroupMemberLabelEditor &&
'ConversationPanel__body--padding'
)}
ref={focusRef}

View File

@@ -13,6 +13,7 @@ import { getIntl, getTheme, getUser } from '../selectors/user.std.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { createLogger } from '../../logging/log.std.js';
import { getPreferredBadgeSelector } from '../selectors/badges.preload.js';
import { isNotNil } from '../../util/isNotNil.std.js';
const log = createLogger('SmartGroupMemberLabelEditor');
@@ -53,6 +54,43 @@ export const SmartGroupMemberLabelEditor = memo(
const { labelEmoji: existingLabelEmoji, labelString: existingLabelString } =
ourMembership;
const membersWithLabel = (conversation.memberships || [])
.map(membership => {
const { aci, isAdmin, labelEmoji, labelString } = membership;
if (aci === me.serviceId) {
return;
}
if (!labelString) {
return;
}
const member = conversationSelector(aci);
if (!member) {
log.warn(
'Group member was not found, excluding from members with labels'
);
return;
}
const contactNameColor = memberColors.get(member.id);
if (!contactNameColor) {
log.warn(
'Color not found for group member, excluding from members with labels'
);
return;
}
return {
contactNameColor,
isAdmin,
labelEmoji,
labelString,
member,
};
})
.filter(isNotNil);
return (
<GroupMemberLabelEditor
existingLabelEmoji={existingLabelEmoji}
@@ -61,6 +99,7 @@ export const SmartGroupMemberLabelEditor = memo(
group={conversation}
i18n={i18n}
me={me}
membersWithLabel={membersWithLabel}
ourColor={ourColor}
popPanelForConversation={popPanelForConversation}
theme={theme}