diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 3998bd3c36..f2049d9f1b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -107,6 +107,10 @@ "messageformat": "Unknown contact", "description": "Shown as the name of a contact if we don't have any displayable information about them" }, + "icu:unknownContactShort": { + "messageformat": "Unknown", + "description": "Shown as the shortened name of a contact if we don't have any displayable information about them" + }, "icu:unknownGroup": { "messageformat": "Unknown group", "description": "Shown as the name of a group if we don't have any information about it" @@ -3916,6 +3920,46 @@ "messageformat": "{count, plural, one {# member} other {# members}}", "description": "Specifies the number of members in a group conversation" }, + "icu:ConversationHero--member-list-and-invited": { + "messageformat": "{memberList} (+{invitesCount, plural, one {# invited} other {# invited}})", + "description": "Text for a group with members and additional invited members" + }, + "icu:ConversationHero--group-members-zero": { + "messageformat": "No group members", + "description": "Text for an empty group" + }, + "icu:ConversationHero--group-members-only-you": { + "messageformat": "No other group members yet", + "description": "Text for group when you are the only member" + }, + "icu:ConversationHero--group-members-one": { + "messageformat": "{member}", + "description": "Text for a group with one member (not you)" + }, + "icu:ConversationHero--group-members-one-and-you": { + "messageformat": "{member} and you", + "description": "Text for a 2 member group where you are one of the members" + }, + "icu:ConversationHero--group-members-two": { + "messageformat": "{member1} and {member2}", + "description": "Text for a 2 member group you are not in" + }, + "icu:ConversationHero--group-members-two-and-you": { + "messageformat": "{member1}, {member2}, and you", + "description": "Text for a 3 member group where you are one of the members" + }, + "icu:ConversationHero--group-members-three": { + "messageformat": "{member1}, {member2}, and {member3}", + "description": "Text for a 3 member group you are not in" + }, + "icu:ConversationHero--group-members-other": { + "messageformat": "{member1}, {member2}, {member3}, and {remainingCount, plural, one {# other} other {# others}}", + "description": "Text for a group with more than 3 members that you are not in" + }, + "icu:ConversationHero--group-members-other-and-you": { + "messageformat": "{member1}, {member2}, {member3}, and {remainingCount, plural, one {# other} other {# others}}", + "description": "Text for a group with more than 4 members where you are one of the members" + }, "icu:ConversationHero--review-carefully": { "messageformat": "Review carefully", "description": "Label shown in conversation hero to advise users to review the conversation carefully" diff --git a/ts/components/GroupMembersNames.tsx b/ts/components/GroupMembersNames.tsx new file mode 100644 index 0000000000..579e32cd60 --- /dev/null +++ b/ts/components/GroupMembersNames.tsx @@ -0,0 +1,235 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { take } from 'lodash'; + +import { I18n } from './I18n'; +import type { LocalizerType } from '../types/Util'; +import { UserText } from './UserText'; +import type { GroupV2Membership } from './conversation/conversation-details/ConversationDetailsMembershipList'; + +type PropsType = { + i18n: LocalizerType; + nameClassName?: string; + memberships: ReadonlyArray; + invitesCount?: number; + onOtherMembersClick?: () => void; +}; + +// Define renderClickableButton outside component to avoid nested component definitions +function renderClickableButton( + parts: ReactNode, + onOtherMembersClick?: () => void +): JSX.Element { + return ( + + ); +} + +function MemberList({ + otherMemberNames, + firstThreeMemberNames, + areWeInGroup, + i18n, + onOtherMembersClick, +}: { + otherMemberNames: ReadonlyArray; + firstThreeMemberNames: Array; + areWeInGroup: boolean; + i18n: LocalizerType; + onOtherMembersClick?: () => void; +}): JSX.Element { + if (areWeInGroup) { + if (otherMemberNames.length === 0) { + return ( + + ); + } + + if (otherMemberNames.length === 1) { + return ( + + ); + } + + if (otherMemberNames.length === 2) { + return ( + + ); + } + + // For 3+ members, "you" is looped in with "others", not shown separately + const remainingCount = otherMemberNames.length + Number(areWeInGroup) - 3; + return ( + + renderClickableButton(parts, onOtherMembersClick), + remainingCount, + }} + /> + ); + } + + // When the user is not in the group + + if (otherMemberNames.length === 0) { + return ; + } + + if (otherMemberNames.length === 1) { + return ( + + ); + } + + if (otherMemberNames.length === 2) { + return ( + + ); + } + + if (otherMemberNames.length === 3) { + return ( + + ); + } + + // More than 3 members + const remainingCount = otherMemberNames.length - 3; + return ( + + renderClickableButton(parts, onOtherMembersClick), + remainingCount, + }} + /> + ); +} + +export function GroupMembersNames({ + i18n, + nameClassName, + memberships, + invitesCount, + onOtherMembersClick, +}: PropsType): JSX.Element { + const areWeInGroup = useMemo(() => { + return memberships.some(({ member }) => member.isMe); + }, [memberships]); + + const otherMemberNames = useMemo(() => { + return memberships + .filter(({ member }) => !member.isMe) + .map(({ member }) => member.titleShortNoDefault); + }, [memberships]); + + // Take the first 3 members for display, prioritizing defined names + // "Unknown" is the fallback name if we never got the right profileKey + // for a user, or haven't fetched their profile yet. + const firstThreeMembers = useMemo(() => { + return take( + [...otherMemberNames].sort((a, b) => { + if (a === undefined) { + return 1; + } + if (b === undefined) { + return -1; + } + return 0; + }), + 3 + ).map((name, i) => ( + // We cannot guarantee uniqueness of member names + // eslint-disable-next-line react/no-array-index-key + + + + )); + }, [otherMemberNames, nameClassName, i18n]); + + const memberListElement = ( + + ); + + // If there are invited members, wrap in the "(+1 invited)" format + if (invitesCount && invitesCount > 0) { + return ( + + ); + } + + // Otherwise just return the member list + return memberListElement; +} diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index 6c16bdc19d..28c4ce91be 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -11,9 +11,36 @@ import { HasStories } from '../../types/Stories'; import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { ThemeType } from '../../types/Util'; +import type { GroupV2Membership } from './conversation-details/ConversationDetailsMembershipList'; const { i18n } = window.SignalContext; +type CreateMembershipsArgs = { + count: number; + includeMe: boolean; + unknownContactIndices?: ReadonlyArray; +}; + +const createMemberships = ({ + count, + includeMe, + unknownContactIndices = [], +}: CreateMembershipsArgs): Array => { + return Array.from(new Array(count)).map( + (_, i): GroupV2Membership => ({ + isAdmin: i % 3 === 0, + member: unknownContactIndices.includes(i) + ? getDefaultConversation({ + isMe: includeMe && i === 0, + titleShortNoDefault: undefined, + }) + : getDefaultConversation({ + isMe: includeMe && i === 0, // First member is "me" if includeMe is true + }), + }) + ); +}; + export default { title: 'Components/Conversation/ConversationHero', component: ConversationHero, @@ -36,9 +63,22 @@ export default { // eslint-disable-next-line react/function-component-definition const Template: StoryFn = args => { const theme = useContext(StorybookThemeContext); + const baseProps = { + ...args, + ...getDefaultConversation(), + }; + + const memberships = createMemberships({ + count: baseProps.membersCount ?? 0, + includeMe: baseProps.acceptedMessageRequest ?? false, + }); return (
- +
); }; @@ -110,37 +150,10 @@ DirectNoGroupsNoDataNotAccepted.args = { export const DirectNoGroupsNotAcceptedWithAvatar = Template.bind({}); DirectNoGroupsNotAcceptedWithAvatar.args = { - ...getDefaultConversation(), acceptedMessageRequest: false, profileName: '', }; -export const GroupManyMembers = Template.bind({}); -GroupManyMembers.args = { - conversationType: 'group', - groupDescription: casual.sentence, - membersCount: casual.integer(20, 100), - title: casual.title, -}; - -export const GroupOneMember = Template.bind({}); -GroupOneMember.args = { - avatarUrl: undefined, - conversationType: 'group', - groupDescription: casual.sentence, - membersCount: 1, - title: casual.title, -}; - -export const GroupZeroMembers = Template.bind({}); -GroupZeroMembers.args = { - avatarUrl: undefined, - conversationType: 'group', - groupDescription: casual.sentence, - membersCount: 0, - title: casual.title, -}; - export const GroupLongGroupDescription = Template.bind({}); GroupLongGroupDescription.args = { conversationType: 'group', @@ -203,3 +216,174 @@ GroupNotFromTrustedContact.args = { membersCount: casual.integer(5, 20), fromOrAddedByTrustedContact: false, }; + +export function GroupMemberNames(args: Props): JSX.Element { + const theme = useContext(StorybookThemeContext); + const baseProps = { + ...args, + theme, + conversationType: 'group' as const, + title: 'Group Chat', + isMe: false, + }; + + return ( +
+
+

When user is NOT in the group

+
+
+

0 members

+ +
+
+

1 member

+ +
+
+

2 members

+ +
+
+

2 members + 2 invited

+ +
+
+

3 members

+ +
+
+

5 members

+ +
+
+

5 members + 2 invited

+ +
+
+
+ +
+

When user is in the group

+
+
+

Just me (1 member)

+ +
+
+

Just me (1 member) + 1 invited

+ +
+
+

Me + 1 other (2 members)

+ +
+
+

Me + 2 others (3 members)

+ +
+
+

Me + 3 others (4 members)

+ +
+
+

Me + 4 others (5 members)

+ +
+
+
+ +
+

Edge Cases

+
+
+

Unknown Contact in Small Group

+ +
+
+

Unknown Hidden Under "others"

+ +
+
+
+
+ ); +} diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index ae2a5fdaff..c5beae0a4d 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -9,9 +9,11 @@ import { ContactName } from './ContactName'; import { About } from './About'; import { GroupDescription } from './GroupDescription'; import { SharedGroupNames } from '../SharedGroupNames'; +import { GroupMembersNames } from '../GroupMembersNames'; import type { LocalizerType, ThemeType } from '../../types/Util'; import type { HasStories } from '../../types/Stories'; import type { ViewUserStoriesActionCreatorType } from '../../state/ducks/stories'; +import type { GroupV2Membership } from './conversation-details/ConversationDetailsMembershipList'; import { StoryViewModeType } from '../../types/Stories'; import { Button, ButtonVariant } from '../Button'; import { SafetyTipsModal } from '../SafetyTipsModal'; @@ -28,8 +30,10 @@ export type Props = { i18n: LocalizerType; isDirectConvoAndHasNickname?: boolean; isMe: boolean; + invitesCount?: number; isSignalConversation?: boolean; membersCount?: number; + memberships: ReadonlyArray; openConversationDetails?: () => unknown; pendingAvatarDownload?: boolean; phoneNumber?: string; @@ -49,7 +53,8 @@ const renderExtraInformation = ({ i18n, isDirectConvoAndHasNickname, isMe, - membersCount, + invitesCount, + memberships, onClickProfileNameWarning, onToggleSafetyTips, openConversationDetails, @@ -64,7 +69,9 @@ const renderExtraInformation = ({ | 'i18n' | 'isDirectConvoAndHasNickname' | 'isMe' + | 'invitesCount' | 'membersCount' + | 'memberships' | 'openConversationDetails' | 'phoneNumber' > & @@ -158,21 +165,16 @@ const renderExtraInformation = ({ ) : null; const membersCountLabel = - conversationType === 'group' && membersCount != null ? ( + conversationType === 'group' ? (
- +
) : null; @@ -243,9 +245,11 @@ export function ConversationHero({ id, isDirectConvoAndHasNickname, isMe, + invitesCount, openConversationDetails, isSignalConversation, membersCount, + memberships, pendingAvatarDownload, sharedGroupNames = [], phoneNumber, @@ -358,7 +362,9 @@ export function ConversationHero({ i18n, isDirectConvoAndHasNickname, isMe, + invitesCount, membersCount, + memberships, onClickProfileNameWarning() { toggleProfileNameWarningModal(conversationType); }, diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 9688f2c296..852c8a1d80 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -414,6 +414,7 @@ const renderHeroRow = () => { phoneNumber={getPhoneNumber()} profileName={getProfileName()} sharedGroupNames={['NYC Rock Climbers', 'Dinner Party']} + memberships={[]} theme={theme} title={getTitle()} startAvatarDownload={action('startAvatarDownload')} diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index f89c3e1f0a..8b935cb5b1 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -366,6 +366,7 @@ export type ConversationType = ReadonlyDeep< title: string; titleNoDefault?: string; titleNoNickname?: string; + titleShortNoDefault?: string; searchableTitle?: string; unreadCount?: number; unreadMentionsCount?: number; diff --git a/ts/state/smart/HeroRow.tsx b/ts/state/smart/HeroRow.tsx index ca3cce387a..47f852410a 100644 --- a/ts/state/smart/HeroRow.tsx +++ b/ts/state/smart/HeroRow.tsx @@ -9,6 +9,7 @@ import { getIntl, getTheme } from '../selectors/user'; import { getHasStoriesSelector } from '../selectors/stories2'; import { isSignalConversation } from '../../util/isSignalConversation'; import { + getConversationByServiceIdSelector, getConversationSelector, getPendingAvatarDownloadSelector, } from '../selectors/conversations'; @@ -19,6 +20,7 @@ import { import { useGlobalModalActions } from '../ducks/globalModals'; import { useStoriesActions } from '../ducks/stories'; import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; +import { getGroupMemberships } from '../../util/getGroupMemberships'; type SmartHeroRowProps = Readonly<{ id: string; @@ -49,11 +51,20 @@ export const SmartHeroRow = memo(function SmartHeroRow({ const getPreferredBadge = useSelector(getPreferredBadgeSelector); const hasStoriesSelector = useSelector(getHasStoriesSelector); const conversationSelector = useSelector(getConversationSelector); + const conversationByServiceIdSelector = useSelector( + getConversationByServiceIdSelector + ); const isPendingAvatarDownload = useSelector(getPendingAvatarDownloadSelector); const conversation = conversationSelector(id); if (conversation == null) { throw new Error(`Did not find conversation ${id} in state!`); } + const groupMemberships = getGroupMemberships( + conversation, + conversationByServiceIdSelector + ); + const { memberships, pendingMemberships, pendingApprovalMemberships } = + groupMemberships; const badge = getPreferredBadge(conversation.badges); const hasStories = hasStoriesSelector(id); const isSignalConversationValue = isSignalConversation(conversation); @@ -89,6 +100,9 @@ export const SmartHeroRow = memo(function SmartHeroRow({ const isDirectConvoAndHasNickname = type === 'direct' && Boolean(nicknameGivenName || nicknameFamilyName); + const invitesCount = + pendingMemberships.length + pendingApprovalMemberships.length; + return ( > "4 members"') .waitFor(); - await window.getByText(unknownContact.profileName).waitFor(); + + await window + .locator('.conversation-details-panel') + .getByText(unknownContact.profileName) + .waitFor(); debug('Leave the group through settings'); diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts index 80ffa77c60..841e59a632 100644 --- a/ts/util/getConversation.ts +++ b/ts/util/getConversation.ts @@ -242,6 +242,7 @@ export function getConversation(model: ConversationModel): ConversationType { title: getTitle(attributes), titleNoDefault: getTitleNoDefault(attributes), titleNoNickname: getTitle(attributes, { ignoreNickname: true }), + titleShortNoDefault: getTitle(attributes, { isShort: true }), typingContactIdTimestamps, searchableTitle: isMe(attributes) ? window.i18n('icu:noteToSelf')