From db4845100a8f01ef06207345abf82cdb0482d0a2 Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 10 Dec 2025 14:01:17 -0800 Subject: [PATCH] Setup pin/unpin actions and mark messages pinned in timeline --- _locales/en/messages.json | 8 + stylesheets/_modules.scss | 175 +++---------- stylesheets/tailwind-config.css | 2 +- ts/axo/AxoDialog.dom.tsx | 13 + ts/background.preload.ts | 10 + ts/components/StoryViewsNRepliesModal.dom.tsx | 1 + .../conversation/CallingNotification.dom.tsx | 1 + ts/components/conversation/Message.dom.tsx | 24 +- .../conversation/MessageAudio.dom.tsx | 3 + .../conversation/MessageContextMenu.dom.tsx | 7 + .../MessageDetail.dom.stories.tsx | 1 + .../conversation/MessageMetadata.dom.tsx | 47 ++-- .../conversation/Quote.dom.stories.tsx | 4 + .../conversation/Timeline.dom.stories.tsx | 4 + .../conversation/TimelineItem.dom.stories.tsx | 3 + .../TimelineMessage.dom.stories.tsx | 15 +- .../conversation/TimelineMessage.dom.tsx | 38 ++- .../PinMessageDialog.dom.stories.tsx | 2 +- .../pinned-messages/PinMessageDialog.dom.tsx | 39 +-- ts/jobs/JobLogger.std.ts | 7 +- .../helpers/createSendMessageJob.preload.ts | 19 +- ts/messageModifiers/PinnedMessages.preload.ts | 31 +-- ts/models/conversations.preload.ts | 12 + ts/state/ducks/conversations.preload.ts | 242 +++++++++++++----- ts/state/selectors/conversations.dom.ts | 8 + ts/state/selectors/message.preload.ts | 18 ++ ts/state/selectors/timeline.preload.ts | 3 + ts/state/smart/TimelineItem.preload.tsx | 4 + .../smart/renderAudioAttachment.preload.tsx | 7 +- ts/util/canEditGroupInfo.preload.ts | 29 ++- ts/util/pinnedMessages.dom.ts | 10 + ts/util/pinnedMessages.std.ts | 15 ++ 32 files changed, 504 insertions(+), 298 deletions(-) create mode 100644 ts/util/pinnedMessages.dom.ts create mode 100644 ts/util/pinnedMessages.std.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 35dffd31cb..9edc458685 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1470,6 +1470,10 @@ "messageformat": "Pin message", "description": "Shown on the drop-down menu for an individual message, pins the current message" }, + "icu:MessageContextMenu__UnpinMessage": { + "messageformat": "Unpin message", + "description": "Shown on the drop-down menu for an individual message, unpins the current message" + }, "icu:Poll__end-poll": { "messageformat": "End poll", "description": "Label for button/menu item to end a poll. Shown in the poll votes modal and in the message context menu" @@ -9266,6 +9270,10 @@ "messageformat": "Edited", "description": "label for an edited message" }, + "icu:MessageMetadata__pinned": { + "messageformat": "Pinned", + "description": "label for an pinned message" + }, "icu:DraftGifMessageSendModal__Title": { "messageformat": "Add a message", "description": "Draft GIF Message Send Modal > Title" diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 59c05fb32d..774cbb312f 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -1193,12 +1193,34 @@ $message-padding-horizontal: 12px; } .module-message__metadata { + @include mixins.font-caption; + align-items: center; display: flex; flex-direction: row; justify-content: flex-end; margin-top: 3px; font-style: normal; + user-select: none; + white-space: nowrap; + + color: light-dark(variables.$color-gray-60, variables.$color-gray-25); + + &--outgoing { + color: variables.$color-white-alpha-80; + } + + &--with-image-no-caption { + color: light-dark(variables.$color-white, variables.$color-white-alpha-80); + } + + &--outline-only-bubble { + color: light-dark(variables.$color-gray-60, variables.$color-gray-25); + } + + &--sticker { + color: variables.$color-gray-60; + } &--inline { float: inline-end; @@ -1211,43 +1233,14 @@ $message-padding-horizontal: 12px; .module-message__metadata__edited { @include mixins.button-reset; & { - @include mixins.font-caption; - color: variables.$color-gray-60; cursor: pointer; margin-inline-end: 6px; z-index: variables.$z-index-base; } - - @include mixins.dark-theme { - color: variables.$color-gray-25; - } } .module-message__metadata__sms { - width: 12px; - height: 12px; - display: inline-block; margin-inline-start: 6px; - // High margin to leave space for the increase when we go to two checks - margin-bottom: 2px; - - @include mixins.color-svg( - '../images/icons/v2/lock-unlock-outline-12.svg', - variables.$color-white - ); -} - -.module-message__metadata__sms--incoming { - @include mixins.light-theme { - background-color: variables.$color-gray-60; - } - @include mixins.dark-theme { - background-color: variables.$color-gray-25; - } -} - -.module-message__container--outgoing .module-message__metadata__edited { - color: variables.$color-white-alpha-80; } // With an image and no caption, this section needs to be on top of the image overlay @@ -1265,62 +1258,9 @@ $message-padding-horizontal: 12px; pointer-events: none; } -.module-message__metadata--outline-only-bubble { - @include mixins.light-theme { - color: variables.$color-gray-60; - } - @include mixins.dark-theme { - color: variables.$color-gray-25; - } -} - -.module-message__metadata__date { - @include mixins.font-caption; - user-select: none; - white-space: nowrap; - - @include mixins.light-theme { - color: variables.$color-white-alpha-80; - } - @include mixins.dark-theme { - color: variables.$color-white-alpha-80; - } -} .module-message__metadata__tapable { @include mixins.button-reset; } -.module-message__metadata__date--incoming { - color: variables.$color-white-alpha-80; - - @include mixins.light-theme { - color: variables.$color-gray-60; - } - @include mixins.dark-theme { - color: variables.$color-gray-25; - } -} -.module-message__metadata__date--with-image-no-caption { - @include mixins.light-theme { - color: variables.$color-white; - } - @include mixins.dark-theme { - color: variables.$color-white-alpha-80; - } -} -.module-message__metadata__date--outline-only-bubble { - @include mixins.light-theme { - color: variables.$color-gray-60; - } - @include mixins.dark-theme { - color: variables.$color-gray-25; - } -} - -.module-message__metadata__date--with-sticker { - @include mixins.light-theme { - color: variables.$color-gray-60; - } -} .module-message__metadata__status-icon { width: 12px; @@ -1338,41 +1278,25 @@ $message-padding-horizontal: 12px; } @include mixins.color-svg( '../images/icons/v3/message_status/messagestatus-sending.svg', - variables.$color-white + currentColor ); } .module-message__metadata__status-icon--sent { - @include mixins.light-theme { - @include mixins.color-svg( - '../images/icons/v3/message_status/messagestatus-sent.svg', - variables.$color-white-alpha-80 - ); - } - @include mixins.dark-theme { - @include mixins.color-svg( - '../images/icons/v3/message_status/messagestatus-sent.svg', - variables.$color-white-alpha-80 - ); - } + @include mixins.color-svg( + '../images/icons/v3/message_status/messagestatus-sent.svg', + currentColor + ); } .module-message__metadata__status-icon--delivered { // We reduce the margin size to keep the overall width the same margin-inline-end: 0px; width: 18px; - @include mixins.light-theme { - @include mixins.color-svg( - '../images/icons/v3/message_status/messagestatus-delivered.svg', - variables.$color-white-alpha-80 - ); - } - @include mixins.dark-theme { - @include mixins.color-svg( - '../images/icons/v3/message_status/messagestatus-delivered.svg', - variables.$color-white-alpha-80 - ); - } + @include mixins.color-svg( + '../images/icons/v3/message_status/messagestatus-delivered.svg', + currentColor + ); } .module-message__metadata__status-icon--read, .module-message__metadata__status-icon--viewed { @@ -1380,42 +1304,15 @@ $message-padding-horizontal: 12px; margin-inline-end: 0px; width: 18px; - @include mixins.light-theme { - @include mixins.color-svg( - '../images/icons/v3/message_status/messagestatus-read.svg', - variables.$color-white-alpha-80 - ); - } - @include mixins.dark-theme { - @include mixins.color-svg( - '../images/icons/v3/message_status/messagestatus-read.svg', - variables.$color-white-alpha-80 - ); - } + @include mixins.color-svg( + '../images/icons/v3/message_status/messagestatus-read.svg', + currentColor + ); } // When status indicators are overlaid on top of an image, they use different colors .module-message__metadata__status-icon--with-image-no-caption { - @include mixins.dark-theme { - background-color: variables.$color-gray-02; - } - @include mixins.light-theme { - background-color: variables.$color-white; - } -} -.module-message__metadata__status-icon--with-sticker { - @include mixins.light-theme { - background-color: variables.$color-gray-60; - } -} - -.module-message__metadata__status-icon--outline-only-bubble { - @include mixins.light-theme { - background-color: variables.$color-gray-60; - } - @include mixins.dark-theme { - background-color: variables.$color-gray-25; - } + color: light-dark(variables.$color-white, variables.$color-gray-02); } .module-message__metadata__spinner-container { diff --git a/stylesheets/tailwind-config.css b/stylesheets/tailwind-config.css index d9c85d1598..c1cb70fed3 100644 --- a/stylesheets/tailwind-config.css +++ b/stylesheets/tailwind-config.css @@ -266,7 +266,7 @@ --type-text-body-large: 0.875rem /* 14px */; --type-text-body-medium: 0.8125rem /* 13px */; --type-text-body-small: 0.75rem /* 12px */; - --type-text-caption: 0.6825rem /* 11px */; + --type-text-caption: 0.6875rem /* 11px */; /* font-weight */ --font-weight-*: initial; /* reset defaults */ --font-weight-semibold: 600; diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index 95edec84b8..2e0f956f90 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -73,12 +73,24 @@ export namespace AxoDialog { export type ContentProps = Readonly<{ size: ContentSize; escape: ContentEscape; + disableMissingAriaDescriptionWarning?: boolean; children: ReactNode; }>; export const Content: FC = memo(props => { const sizeConfig = ContentSizes[props.size]; const handleContentEscapeEvent = useContentEscapeBehavior(props.escape); + + const descriptionProps = useMemo((): Dialog.DialogContentProps => { + if (props.disableMissingAriaDescriptionWarning) { + // Generally you should just add a description with `AxoDialog.Description` + // and use `sr-only` to hide it if you don't want it to be visible + // https://www.radix-ui.com/primitives/docs/components/dialog#description + return { 'aria-describedby': undefined }; + } + return {}; + }, [props.disableMissingAriaDescriptionWarning]); + return ( @@ -90,6 +102,7 @@ export namespace AxoDialog { width: sizeConfig.width, minWidth: 320, }} + {...descriptionProps} > {props.children} diff --git a/ts/background.preload.ts b/ts/background.preload.ts index f76d323539..9eca08f1c1 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -2985,6 +2985,11 @@ export async function startApp(): Promise { } if (data.message.pinMessage != null) { + if (!isPinnedMessagesReceiveEnabled()) { + log.warn('Dropping PinMessage because the flag is disabled'); + confirm(); + return; + } strictAssert(data.timestamp != null, 'Missing sent timestamp'); await PinnedMessages.onPinnedMessageAdd({ targetSentTimestamp: data.message.pinMessage.targetSentTimestamp, @@ -3129,6 +3134,11 @@ export async function startApp(): Promise { } if (data.message.unpinMessage != null) { + if (!isPinnedMessagesReceiveEnabled()) { + log.warn('Dropping UnpinMessage because the flag is disabled'); + confirm(); + return; + } await PinnedMessages.onPinnedMessageRemove({ targetSentTimestamp: data.message.unpinMessage.targetSentTimestamp, targetAuthorAci: data.message.unpinMessage.targetAuthorAci, diff --git a/ts/components/StoryViewsNRepliesModal.dom.tsx b/ts/components/StoryViewsNRepliesModal.dom.tsx index 7280b36e72..278682840a 100644 --- a/ts/components/StoryViewsNRepliesModal.dom.tsx +++ b/ts/components/StoryViewsNRepliesModal.dom.tsx @@ -59,6 +59,7 @@ const MESSAGE_DEFAULT_PROPS = { doubleCheckMissingQuoteReference: shouldNeverBeCalled, isBlocked: false, isMessageRequestAccepted: true, + isPinned: false, isSelected: false, isSelectMode: false, isSMS: false, diff --git a/ts/components/conversation/CallingNotification.dom.tsx b/ts/components/conversation/CallingNotification.dom.tsx index ff5260f589..6991119e8a 100644 --- a/ts/components/conversation/CallingNotification.dom.tsx +++ b/ts/components/conversation/CallingNotification.dom.tsx @@ -85,6 +85,7 @@ export const CallingNotification: React.FC = React.memo( onForward={null} onMoreInfo={null} onPinMessage={null} + onUnpinMessage={null} >
JSX.Element; + renderAudioAttachment: (props: RenderAudioAttachmentProps) => JSX.Element; shouldCollapseAbove: boolean; shouldCollapseBelow: boolean; shouldHideMetadata: boolean; @@ -826,6 +829,7 @@ export class Message extends React.PureComponent { expirationTimestamp, giftBadge, i18n, + isPinned, isTapToView, isTapToViewError, isTapToViewExpired, @@ -840,6 +844,7 @@ export class Message extends React.PureComponent { if ( !expirationLength && !expirationTimestamp && + !isPinned && (!status || SENT_STATUSES.has(status)) && shouldHideMetadata ) { @@ -1084,6 +1089,7 @@ export class Message extends React.PureComponent { i18n, id, isEditedMessage, + isPinned, isSMS, isSticker, retryMessageSend, @@ -1107,6 +1113,7 @@ export class Message extends React.PureComponent { i18n={i18n} id={id} isEditedMessage={isEditedMessage} + isPinned={isPinned} isSMS={isSMS} isInline={isInline} isOutlineOnlyBubble={ @@ -1153,12 +1160,12 @@ export class Message extends React.PureComponent { attachmentDroppedDueToSize, attachments, cancelAttachmentDownload, - conversationId, direction, expirationLength, expirationTimestamp, i18n, id, + isPinned, isSticker, isVoiceMessagePlayed, kickOffAttachmentDownload, @@ -1301,7 +1308,6 @@ export class Message extends React.PureComponent { i18n, buttonRef: this.audioButtonRef, renderingContext, - theme, attachment: firstAttachment, collapseMetadata, withContentAbove, @@ -1311,9 +1317,8 @@ export class Message extends React.PureComponent { expirationLength, expirationTimestamp, id, - conversationId, + isPinned, played: isVoiceMessagePlayed, - pushPanelForConversation, status, textPending: textAttachment?.pending, timestamp, @@ -1336,7 +1341,10 @@ export class Message extends React.PureComponent { const isIncoming = direction === 'incoming'; const willShowMetadata = - expirationLength || expirationTimestamp || !shouldHideMetadata; + expirationLength || + expirationTimestamp || + isPinned || + !shouldHideMetadata; // Note: this has to be interactive for the case where text comes along with the // attachment. But we don't want the user to tab here unless that text exists. @@ -1426,6 +1434,7 @@ export class Message extends React.PureComponent { i18n={i18n} id={id} isEditedMessage={false} + isPinned={isPinned} isSMS={false} isInline={false} isOutlineOnlyBubble={false} @@ -2699,6 +2708,7 @@ export class Message extends React.PureComponent { expirationTimestamp, i18n, id, + isPinned, isTapToViewError, isTapToViewExpired, pushPanelForConversation, @@ -2763,6 +2773,7 @@ export class Message extends React.PureComponent { i18n={i18n} id={id} isEditedMessage={false} + isPinned={isPinned} isSMS={false} isInline={false} isOutlineOnlyBubble={false} @@ -2804,6 +2815,7 @@ export class Message extends React.PureComponent { i18n={i18n} id={id} isEditedMessage={false} + isPinned={isPinned} isSMS={false} isInline={false} isOutlineOnlyBubble={false} diff --git a/ts/components/conversation/MessageAudio.dom.tsx b/ts/components/conversation/MessageAudio.dom.tsx index 3e9283d668..882265b1fa 100644 --- a/ts/components/conversation/MessageAudio.dom.tsx +++ b/ts/components/conversation/MessageAudio.dom.tsx @@ -49,6 +49,7 @@ export type OwnProps = Readonly<{ expirationLength?: number; expirationTimestamp?: number; id: string; + isPinned: boolean; played: boolean; status?: MessageStatusType; textPending?: boolean; @@ -156,6 +157,7 @@ export function MessageAudio(props: Props): JSX.Element { expirationLength, expirationTimestamp, id, + isPinned, played, status, textPending, @@ -381,6 +383,7 @@ export function MessageAudio(props: Props): JSX.Element { hasText={withContentBelow} i18n={i18n} id={id} + isPinned={isPinned} isShowingImage={false} isSticker={false} pushPanelForConversation={pushPanelForConversation} diff --git a/ts/components/conversation/MessageContextMenu.dom.tsx b/ts/components/conversation/MessageContextMenu.dom.tsx index fd3404016b..d7dad57744 100644 --- a/ts/components/conversation/MessageContextMenu.dom.tsx +++ b/ts/components/conversation/MessageContextMenu.dom.tsx @@ -23,6 +23,7 @@ type MessageContextMenuProps = Readonly<{ onForward: (() => void) | null; onDeleteMessage: (() => void) | null; onPinMessage: (() => void) | null; + onUnpinMessage: (() => void) | null; onMoreInfo: (() => void) | null; onSelect: (() => void) | null; children: ReactNode; @@ -47,6 +48,7 @@ export function MessageContextMenu({ onForward, onDeleteMessage, onPinMessage, + onUnpinMessage, children, }: MessageContextMenuProps): JSX.Element { return ( @@ -104,6 +106,11 @@ export function MessageContextMenu({ {i18n('icu:MessageContextMenu__PinMessage')} )} + {isPinnedMessagesReceiveEnabled() && onUnpinMessage && ( + + {i18n('icu:MessageContextMenu__UnpinMessage')} + + )} {onMoreInfo && ( {i18n('icu:MessageContextMenu__info')} diff --git a/ts/components/conversation/MessageDetail.dom.stories.tsx b/ts/components/conversation/MessageDetail.dom.stories.tsx index 651b6f0801..a8fae397b7 100644 --- a/ts/components/conversation/MessageDetail.dom.stories.tsx +++ b/ts/components/conversation/MessageDetail.dom.stories.tsx @@ -32,6 +32,7 @@ const defaultMessage: MessageDataPropsType = { renderMenu: undefined, isBlocked: false, isMessageRequestAccepted: true, + isPinned: false, isSelected: false, isSelectMode: false, isSMS: false, diff --git a/ts/components/conversation/MessageMetadata.dom.tsx b/ts/components/conversation/MessageMetadata.dom.tsx index 2dba22bf94..2b1ec8db34 100644 --- a/ts/components/conversation/MessageMetadata.dom.tsx +++ b/ts/components/conversation/MessageMetadata.dom.tsx @@ -18,6 +18,8 @@ import { ConfirmationDialog } from '../ConfirmationDialog.dom.js'; import { refMerger } from '../../util/refMerger.std.js'; import type { Size } from '../../hooks/useSizeObserver.dom.js'; import { SizeObserver } from '../../hooks/useSizeObserver.dom.js'; +import { AxoSymbol } from '../../axo/AxoSymbol.dom.js'; +import { tw } from '../../axo/tw.dom.js'; type PropsType = { deletedForEveryone?: boolean; @@ -28,6 +30,7 @@ type PropsType = { i18n: LocalizerType; id: string; isEditedMessage?: boolean; + isPinned: boolean; isSMS?: boolean; isInline?: boolean; isOutlineOnlyBubble?: boolean; @@ -57,6 +60,7 @@ export const MessageMetadata = forwardRef>( i18n, id, isEditedMessage, + isPinned, isSMS, isOutlineOnlyBubble, isInline, @@ -135,19 +139,7 @@ export const MessageMetadata = forwardRef>( } timestampNode = ( - - {statusInfo} - + {statusInfo} ); } else { timestampNode = ( @@ -195,12 +187,22 @@ export const MessageMetadata = forwardRef>( const className = classNames( 'module-message__metadata', + direction === 'outgoing' && 'module-message__metadata--outgoing', isInline && 'module-message__metadata--inline', withImageNoCaption && 'module-message__metadata--with-image-no-caption', - isOutlineOnlyBubble && 'module-message__metadata--outline-only-bubble' + isOutlineOnlyBubble && 'module-message__metadata--outline-only-bubble', + isSticker && 'module-message__metadata--sticker' ); const children = ( <> + {isPinned && ( + + + + )} {isEditedMessage && showEditHistoryModal && (