From 85cc412b40cbd72e83fa98f2658b1bccdd6c6791 Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Fri, 6 Feb 2026 05:43:06 +1000 Subject: [PATCH] Render group member labels in quotes --- stylesheets/_modules.scss | 4 ++ stylesheets/_variables.scss | 1 + stylesheets/components/ContactName.scss | 13 +++++++ stylesheets/components/Quote.scss | 8 ++++ .../conversation/ContactName.dom.tsx | 5 ++- ts/components/conversation/Message.dom.tsx | 5 ++- .../conversation/Quote.dom.stories.tsx | 21 +++++++++- ts/components/conversation/Quote.dom.tsx | 39 ++++++++++++------- .../GroupMemberLabelEditor.dom.tsx | 2 +- ts/state/selectors/message.preload.ts | 11 ++++++ ts/types/GroupMemberLabels.std.ts | 5 +++ 11 files changed, 96 insertions(+), 18 deletions(-) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 6f7b55dd70..9098ca1d59 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1095,6 +1095,10 @@ $message-padding-horizontal: 12px; } } +.module-message__author--with-quote { + margin-bottom: 3px; +} + .module-message__text { @include mixins.font-body-1; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 29b1751654..cf8854002f 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -54,6 +54,7 @@ $color-white-alpha-12: rgba($color-white, 0.12); $color-white-alpha-16: rgba($color-white, 0.16); $color-white-alpha-20: rgba($color-white, 0.2); $color-white-alpha-30: rgba($color-white, 0.3); +$color-white-alpha-36: rgba($color-white, 0.36); $color-white-alpha-40: rgba($color-white, 0.4); $color-white-alpha-50: rgba($color-white, 0.5); $color-white-alpha-55: rgba($color-white, 0.55); diff --git a/stylesheets/components/ContactName.scss b/stylesheets/components/ContactName.scss index 51696727ca..259728a460 100644 --- a/stylesheets/components/ContactName.scss +++ b/stylesheets/components/ContactName.scss @@ -4,6 +4,7 @@ @use 'sass:map'; @use 'sass:color'; +@use '../variables'; @use '../mixins'; button.module-contact-name { @@ -305,6 +306,18 @@ $contact-colors: ( line-height: 12px; } + &--label-pill--quote { + margin-bottom: 2px; + @include mixins.font-body-small; + + color: variables.$color-gray-90; + background-color: variables.$color-white-alpha-36; + @include mixins.dark-theme() { + color: variables.$color-gray-05; + background-color: variables.$color-white-alpha-20; + } + } + &--label-pill--text { display: inline-block; } diff --git a/stylesheets/components/Quote.scss b/stylesheets/components/Quote.scss index 45f4a1d874..3eece68791 100644 --- a/stylesheets/components/Quote.scss +++ b/stylesheets/components/Quote.scss @@ -185,6 +185,10 @@ .module-quote__primary__author { @include mixins.font-body-2-bold; + display: inline-flex; + align-items: center; + gap: 4px; + overflow: hidden; white-space: nowrap; text-overflow: ellipsis; @@ -203,6 +207,10 @@ } } +.module-quote__primary__author--with-label { + margin-top: -3px; +} + .module-quote__primary__text { @include mixins.font-body-1; diff --git a/ts/components/conversation/ContactName.dom.tsx b/ts/components/conversation/ContactName.dom.tsx index 9e66f7b50c..8234f63ea8 100644 --- a/ts/components/conversation/ContactName.dom.tsx +++ b/ts/components/conversation/ContactName.dom.tsx @@ -18,6 +18,7 @@ import { useFunEmojiLocalizer } from '../fun/useFunEmojiLocalizer.dom.js'; import { FunStaticEmoji } from '../fun/FunEmoji.dom.js'; import { missingEmojiPlaceholder } from '../../types/GroupMemberLabels.std.js'; +import type { MemberLabelType } from '../../types/GroupMemberLabels.std.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'; @@ -113,7 +114,7 @@ export function ContactName({ ); } -export type Context = 'bubble' | 'list'; +export type Context = 'bubble' | 'list' | 'quote'; export function GroupMemberLabel({ emojiSize = 12, @@ -123,7 +124,7 @@ export function GroupMemberLabel({ module, }: { emojiSize?: FunStaticEmojiSize; - contactLabel?: { labelString: string; labelEmoji: string | undefined }; + contactLabel?: MemberLabelType; contactNameColor?: ContactNameColorType; context: Context; module?: string; diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 8333080042..6e1d88d635 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -127,6 +127,7 @@ import { import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions.dom.js'; import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js'; import type { RenderAudioAttachmentProps } from '../../state/smart/renderAudioAttachment.preload.js'; +import type { MemberLabelType } from '../../types/GroupMemberLabels.std.js'; const { drop, take, unescape } = lodash; @@ -247,7 +248,7 @@ export type PropsData = { id: string; renderingContext: RenderingContextType; contactNameColor?: ContactNameColorType; - contactLabel?: { labelString: string; labelEmoji: string | undefined }; + contactLabel?: MemberLabelType; conversationColor: ConversationColorType; conversationTitle: string; customColor?: CustomColorType; @@ -305,6 +306,7 @@ export type PropsData = { authorPhoneNumber?: string; authorProfileName?: string; authorTitle: string; + authorLabel?: MemberLabelType; authorName?: string; bodyRanges?: HydratedBodyRangesType; referencedMessageNotFound: boolean; @@ -2120,6 +2122,7 @@ export class Message extends React.PureComponent { payment={quote.payment} isIncoming={isIncoming} authorTitle={quote.authorTitle} + authorLabel={quote.authorLabel} bodyRanges={quote.bodyRanges} conversationColor={conversationColor} conversationTitle={conversationTitle} diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index f44d1e2518..82799f91d0 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -168,6 +168,7 @@ const defaultMessageProps: TimelineMessagesProps = { const renderInMessage = ({ authorTitle, + authorLabel, conversationColor, isFromMe, rawAttachment, @@ -182,6 +183,7 @@ const renderInMessage = ({ quote: { authorId: 'an-author', authorTitle, + authorLabel, conversationColor, conversationTitle: getDefaultConversation().title, isFromMe, @@ -223,6 +225,16 @@ IncomingByAnotherAuthor.args = { isIncoming: true, }; +export const IncomingByAnotherWithLabel = Template.bind({}); +IncomingByAnotherWithLabel.args = { + authorTitle: getDefaultConversation().title, + isIncoming: true, + authorLabel: { + labelEmoji: '1️⃣', + labelString: 'First', + }, +}; + export const IncomingByMe = Template.bind({}); IncomingByMe.args = { isFromMe: true, @@ -233,7 +245,14 @@ export function IncomingOutgoingColors(args: Props): React.JSX.Element { return ( <> {ConversationColors.map(color => - renderInMessage({ ...args, conversationColor: color }) + renderInMessage({ + ...args, + conversationColor: color, + authorLabel: { + labelEmoji: '1️⃣', + labelString: 'First', + }, + }) )} ); diff --git a/ts/components/conversation/Quote.dom.tsx b/ts/components/conversation/Quote.dom.tsx index f23f0ca4d9..e218522141 100644 --- a/ts/components/conversation/Quote.dom.tsx +++ b/ts/components/conversation/Quote.dom.tsx @@ -1,15 +1,25 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReactNode } from 'react'; import React, { useRef, useState, useEffect } from 'react'; import lodash from 'lodash'; import classNames from 'classnames'; +import type { ReactNode } from 'react'; + import * as MIME from '../../types/MIME.std.js'; import * as GoogleChrome from '../../util/GoogleChrome.std.js'; - import { MessageBody } from './MessageBody.dom.js'; +import { ContactName, GroupMemberLabel } from './ContactName.dom.js'; +import { Emojify } from './Emojify.dom.js'; +import { TextAttachment } from '../TextAttachment.dom.js'; +import { getClassNamesFor } from '../../util/getClassNamesFor.std.js'; +import { getCustomColorStyle } from '../../util/getCustomColorStyle.dom.js'; +import { PaymentEventKind } from '../../types/Payment.std.js'; +import { getPaymentEventNotificationText } from '../../messages/payments.std.js'; +import { shouldTryToCopyFromQuotedMessage } from '../../messages/helpers.std.js'; +import { RenderLocation } from './MessageTextRenderer.dom.js'; + import type { AttachmentType, ThumbnailType, @@ -20,17 +30,9 @@ import type { ConversationColorType, CustomColorType, } from '../../types/Colors.std.js'; -import { ContactName } from './ContactName.dom.js'; -import { Emojify } from './Emojify.dom.js'; -import { TextAttachment } from '../TextAttachment.dom.js'; -import { getClassNamesFor } from '../../util/getClassNamesFor.std.js'; -import { getCustomColorStyle } from '../../util/getCustomColorStyle.dom.js'; import type { AnyPaymentEvent } from '../../types/Payment.std.js'; -import { PaymentEventKind } from '../../types/Payment.std.js'; -import { getPaymentEventNotificationText } from '../../messages/payments.std.js'; -import { shouldTryToCopyFromQuotedMessage } from '../../messages/helpers.std.js'; -import { RenderLocation } from './MessageTextRenderer.dom.js'; import type { QuotedAttachmentType } from '../../model-types.d.ts'; +import type { MemberLabelType } from '../../types/GroupMemberLabels.std.js'; const { noop } = lodash; @@ -44,6 +46,7 @@ export type QuotedAttachmentForUIType = Pick< export type Props = { authorTitle: string; + authorLabel?: MemberLabelType; conversationColor: ConversationColorType; conversationTitle: string; customColor?: CustomColorType; @@ -157,6 +160,7 @@ export function Quote(props: Props): React.JSX.Element | null { text, bodyRanges, authorTitle, + authorLabel, conversationTitle, isFromMe, i18n, @@ -471,7 +475,15 @@ export function Quote(props: Props): React.JSX.Element | null { {title} · {i18n('icu:Quote__story')} ) : ( - title + <> + {title} + {authorLabel && !isFromMe ? ( + <> + {' '} + + + ) : undefined} + ); return ( @@ -479,7 +491,8 @@ export function Quote(props: Props): React.JSX.Element | null { dir="auto" className={classNames( getClassName('__primary__author'), - isIncoming ? getClassName('__primary__author--incoming') : null + isIncoming ? getClassName('__primary__author--incoming') : null, + authorLabel ? getClassName('__primary__author--with-label') : null )} > {author} diff --git a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx index 8e52e951a0..77706c138d 100644 --- a/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx +++ b/ts/components/conversation/conversation-details/GroupMemberLabelEditor.dom.tsx @@ -125,7 +125,7 @@ export function GroupMemberLabelEditor({ setLabelEmoji(undefined); } - // Remove trailing/leading whitespace, replace all whitespace with basic space + // Replace all whitespace with basic space setLabelString(value.replace(/\s/g, ' ')); }} ref={undefined} diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index c80b67ae71..21171972a2 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -728,6 +728,16 @@ export const getPropsForQuote = ( const firstAttachment = quote.attachments && quote.attachments[0]; const conversation = getConversation(message, conversationSelector); + const authorMembership = conversation.memberships?.find( + membership => membership.aci === contact.serviceId + ); + const authorLabel = + authorMembership && authorMembership.labelString + ? { + labelEmoji: authorMembership.labelEmoji, + labelString: authorMembership.labelString, + } + : undefined; const { conversationColor, customColor } = getConversationColorAttributes( conversation, @@ -736,6 +746,7 @@ export const getPropsForQuote = ( return { authorId, + authorLabel, authorName, authorPhoneNumber, authorProfileName, diff --git a/ts/types/GroupMemberLabels.std.ts b/ts/types/GroupMemberLabels.std.ts index 638874d5f7..10dbc6fd33 100644 --- a/ts/types/GroupMemberLabels.std.ts +++ b/ts/types/GroupMemberLabels.std.ts @@ -17,6 +17,11 @@ export const EMOJI_OUTGOING_BYTE_LIMIT = 48; export const SERVER_STRING_BYTE_LIMIT = 512; export const SERVER_EMOJI_BYTE_LIMIT = 64; +export type MemberLabelType = { + labelString: string; + labelEmoji: string | undefined; +}; + export function getCanAddLabel( conversation: ConversationType, membership: MembershipType | undefined