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 (