From 79a273d9a03347c31020bd587a0de6dbc72e3241 Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:03:23 -0800 Subject: [PATCH] Add warning when pinning disappearing message --- _locales/en/messages.json | 12 + ts/components/GlobalModalContainer.dom.tsx | 11 + .../conversation/Quote.dom.stories.tsx | 3 +- .../conversation/Timeline.dom.stories.tsx | 3 +- .../conversation/TimelineItem.dom.stories.tsx | 2 +- .../TimelineMessage.dom.stories.tsx | 3 +- .../conversation/TimelineMessage.dom.tsx | 62 ++- .../PinMessageDialog.dom.stories.tsx | 7 +- .../pinned-messages/PinMessageDialog.dom.tsx | 357 ++++++++++++------ ts/state/ducks/globalModals.preload.ts | 45 +++ ts/state/selectors/conversations.dom.ts | 10 + ts/state/selectors/globalModals.std.ts | 8 + ts/state/selectors/items.dom.ts | 7 + ts/state/selectors/message.preload.ts | 12 - .../smart/GlobalModalContainer.preload.tsx | 8 + ts/state/smart/PinMessageDialog.preload.tsx | 64 ++++ ts/state/smart/TimelineItem.preload.tsx | 4 +- ts/types/Storage.d.ts | 1 + 18 files changed, 444 insertions(+), 175 deletions(-) create mode 100644 ts/state/smart/PinMessageDialog.preload.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8c2d30ef84..38ccec38dc 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1650,6 +1650,18 @@ "messageformat": "Continue", "description": "Message > Context Menu > Pin Message (when at max pinned messages) > Dialog > Continue Button" }, + "icu:PinMessageDisappearingMessagesWarningDialog__Title": { + "messageformat": "Pinning disappearing messages", + "description": "Message > Context Menu > Pin Message (when pinning disappearing message) > Dialog > Title" + }, + "icu:PinMessageDisappearingMessagesWarningDialog__Description": { + "messageformat": "Disappearing messages will be unpinned when their timer expires and the message is removed from the chat.", + "description": "Message > Context Menu > Pin Message (when pinning disappearing message) > Dialog > Description" + }, + "icu:PinMessageDisappearingMessagesWarningDialog__Okay": { + "messageformat": "Okay", + "description": "Message > Context Menu > Pin Message (when pinning disappearing message) > Dialog > Okay Button" + }, "icu:PinnedMessagesBar__AccessibilityLabel": { "messageformat": "{pinsCount, plural, one {Pinned message} other {Pinned messages}}", "description": "Conversation > With pinned message(s) > Pinned messages bar > Accessibility label" diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx index d910fde21a..0fead9d751 100644 --- a/ts/components/GlobalModalContainer.dom.tsx +++ b/ts/components/GlobalModalContainer.dom.tsx @@ -35,6 +35,7 @@ import type { SmartDraftGifMessageSendModalProps } from '../state/smart/DraftGif import { CriticalIdlePrimaryDeviceModal } from './CriticalIdlePrimaryDeviceModal.dom.js'; import { LowDiskSpaceBackupImportModal } from './LowDiskSpaceBackupImportModal.dom.js'; import { isUsernameValid } from '../util/Username.dom.js'; +import type { PinMessageDialogData } from '../state/smart/PinMessageDialog.preload.js'; // NOTE: All types should be required for this component so that the smart // component gives you type errors when adding/removing props. @@ -111,6 +112,9 @@ export type PropsType = { // MessageRequestActionsConfirmation messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null; renderMessageRequestActionsConfirmation: () => React.JSX.Element; + // PinMessageDialog + pinMessageDialogData: PinMessageDialogData | null; + renderPinMessageDialog: () => React.JSX.Element; // NotePreviewModal notePreviewModalProps: { conversationId: string } | null; renderNotePreviewModal: () => React.JSX.Element; @@ -224,6 +228,9 @@ export function GlobalModalContainer({ // NotePreviewModal notePreviewModalProps, renderNotePreviewModal, + // PinMessageDialog + pinMessageDialogData, + renderPinMessageDialog, // SafetyNumberModal safetyNumberModalContactId, renderSafetyNumber, @@ -370,6 +377,10 @@ export function GlobalModalContainer({ return renderNotePreviewModal(); } + if (pinMessageDialogData) { + return renderPinMessageDialog(); + } + if (isProfileNameWarningModalVisible) { return renderProfileNameWarningModal(); } diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index 23c3ba653a..f44d1e2518 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -98,7 +98,6 @@ const defaultMessageProps: TimelineMessagesProps = { 'default--doubleCheckMissingQuoteReference' ), getPreferredBadge: () => undefined, - hasMaxPinnedMessages: false, i18n, platform: 'darwin', id: 'messageId', @@ -140,7 +139,7 @@ const defaultMessageProps: TimelineMessagesProps = { shouldCollapseBelow: false, shouldHideMetadata: false, showSpoiler: action('showSpoiler'), - onPinnedMessageAdd: action('onPinnedMessageAdd'), + showPinMessageDialog: action('showPinMessageDialog'), onPinnedMessageRemove: action('onPinnedMessageRemove'), pushPanelForConversation: action('default--pushPanelForConversation'), showContactModal: action('default--showContactModal'), diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 5ecf7f81c8..ad113b3b21 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -58,7 +58,6 @@ function mockMessageTimelineItem( direction: 'incoming', status: 'sent', text: 'Hello there from the new world!', - hasMaxPinnedMessages: false, isBlocked: false, isMessageRequestAccepted: true, isPinned: false, @@ -307,7 +306,7 @@ const actions = () => ({ doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'), openGiftBadge: action('openGiftBadge'), - onPinnedMessageAdd: action('onPinnedMessageAdd'), + showPinMessageDialog: action('showPinMessageDialog'), onPinnedMessageRemove: action('onPinnedMessageRemove'), scrollToPinnedMessage: action('scrollToPinnedMessage'), scrollToPollMessage: action('scrollToPollMessage'), diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index a1bd8551fa..ed0520e2b4 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -70,7 +70,7 @@ const getDefaultProps = () => ({ openGiftBadge: action('openGiftBadge'), saveAttachment: action('saveAttachment'), saveAttachments: action('saveAttachments'), - onPinnedMessageAdd: action('onPinnedMessageAdd'), + showPinMessageDialog: action('showPinMessageDialog'), onPinnedMessageRemove: action('onPinnedMessageRemove'), onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'), onOutgoingAudioCallInConversation: action( diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index 417b7c0190..fafb468df7 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -265,7 +265,6 @@ const createProps = (overrideProps: Partial = {}): Props => ({ expirationTimestamp: overrideProps.expirationTimestamp ?? 0, getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined), giftBadge: overrideProps.giftBadge, - hasMaxPinnedMessages: false, i18n, platform: 'darwin', id: overrideProps.id ?? 'random-message-id', @@ -300,7 +299,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ messageExpanded: action('messageExpanded'), showConversation: action('showConversation'), openGiftBadge: action('openGiftBadge'), - onPinnedMessageAdd: action('onPinnedMessageAdd'), + showPinMessageDialog: action('showPinMessageDialog'), onPinnedMessageRemove: action('onPinnedMessageRemove'), previews: overrideProps.previews || [], quote: overrideProps.quote || undefined, diff --git a/ts/components/conversation/TimelineMessage.dom.tsx b/ts/components/conversation/TimelineMessage.dom.tsx index 7fbb9be613..4c3a459d59 100644 --- a/ts/components/conversation/TimelineMessage.dom.tsx +++ b/ts/components/conversation/TimelineMessage.dom.tsx @@ -36,8 +36,6 @@ import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions import { isNotNil } from '../../util/isNotNil.std.js'; import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js'; import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js'; -import { PinMessageDialog } from './pinned-messages/PinMessageDialog.dom.js'; -import type { DurationInSeconds } from '../../util/durations/duration-in-seconds.std.js'; import { useDocumentKeyDown } from '../../hooks/useDocumentKeyDown.dom.js'; const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu; @@ -55,16 +53,11 @@ export type PropsData = { canReact: boolean; canReply: boolean; canPinMessage: boolean; - hasMaxPinnedMessages: boolean; selectedReaction?: string; isTargeted?: boolean; } & Omit; export type PropsActions = { - onPinnedMessageAdd: ( - messageId: string, - duration: DurationInSeconds | null - ) => void; onPinnedMessageRemove: (messageId: string) => void; pushPanelForConversation: PushPanelForConversationActionType; toggleDeleteMessagesModal: (props: DeleteMessagesPropsType) => void; @@ -89,6 +82,10 @@ export type PropsActions = { shift: boolean, selected: boolean ) => void; + showPinMessageDialog: ( + messageId: string, + isPinningDisappearingMessage: boolean + ) => void; } & Omit; export type Props = PropsData & @@ -119,7 +116,6 @@ export function TimelineMessage(props: Props): React.JSX.Element { containerWidthBreakpoint, conversationId, direction, - hasMaxPinnedMessages, i18n, id, interactivity, @@ -128,7 +124,7 @@ export function TimelineMessage(props: Props): React.JSX.Element { kickOffAttachmentDownload, copyMessageText, endPoll, - onPinnedMessageAdd, + expirationLength, onPinnedMessageRemove, pushPanelForConversation, reactToMessage, @@ -138,6 +134,7 @@ export function TimelineMessage(props: Props): React.JSX.Element { saveAttachment, saveAttachments, showAttachmentDownloadStillInProgressToast, + showPinMessageDialog, selectedReaction, setQuoteByMessageId, setMessageToEdit, @@ -151,7 +148,6 @@ export function TimelineMessage(props: Props): React.JSX.Element { const [reactionPickerRoot, setReactionPickerRoot] = useState< HTMLDivElement | undefined >(undefined); - const [pinMessageDialogOpen, setPinMessageDialogOpen] = useState(false); const isWindowWidthNotNarrow = containerWidthBreakpoint !== WidthBreakpoint.Narrow; @@ -286,17 +282,11 @@ export function TimelineMessage(props: Props): React.JSX.Element { } }, [canReact, toggleReactionPicker]); - const handleOpenPinMessageDialog = useCallback(() => { - setPinMessageDialogOpen(true); - }, []); + const isDisappearingMessage = expirationLength != null; - const handlePinnedMessageAdd = useCallback( - (messageId: string, duration: DurationInSeconds | null) => { - onPinnedMessageAdd(messageId, duration); - setPinMessageDialogOpen(false); - }, - [onPinnedMessageAdd] - ); + const handleOpenPinMessageDialog = useCallback(() => { + showPinMessageDialog(id, isDisappearingMessage); + }, [showPinMessageDialog, id, isDisappearingMessage]); const handleUnpinMessage = useCallback(() => { onPinnedMessageRemove(id); @@ -478,27 +468,17 @@ export function TimelineMessage(props: Props): React.JSX.Element { const handleWrapperKeyDown = useAxoContextMenuOutsideKeyboardTrigger(); return ( - <> - { - toggleSelectMessage(conversationId, id, shift, selected); - }} - onReplyToMessage={handleReplyToMessage} - onWrapperKeyDown={handleWrapperKeyDown} - /> - - + { + toggleSelectMessage(conversationId, id, shift, selected); + }} + onReplyToMessage={handleReplyToMessage} + onWrapperKeyDown={handleWrapperKeyDown} + /> ); } diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx index 3330938d8a..6e86d86b38 100644 --- a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx @@ -20,7 +20,12 @@ export function Default(): React.JSX.Element { onOpenChange={setOpen} messageId="42" onPinnedMessageAdd={action('onPinnedMessageAdd')} - hasMaxPinnedMessages={false} + hasMaxPinnedMessages + isPinningDisappearingMessage + seenPinMessageDisappearingMessagesWarningCount={0} + onSeenPinMessageDisappearingMessagesWarning={action( + 'onSeenPinMessageDisappearingMessagesWarning' + )} /> ); } diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx index 6608951dcf..2f5619c379 100644 --- a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { MouseEvent } from 'react'; -import React, { memo, useCallback, useState } from 'react'; +import React, { memo, useCallback, useMemo, useState } from 'react'; import { AxoDialog } from '../../../axo/AxoDialog.dom.js'; import type { LocalizerType } from '../../../types/I18N.std.js'; import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js'; @@ -26,6 +26,13 @@ const DURATION_OPTIONS: Record = { [DurationOption.DEBUG_10_SECONDS]: DurationInSeconds.fromSeconds(10), }; +enum Step { + CLOSED, + CONFIRM_REPLACE_OLDEST_PIN, + SELECT_PIN_DURATION, + DISAPPEARING_MESSAGES_WARNING, +} + function isValidDurationOption(value: string): value is DurationOption { return Object.hasOwn(DURATION_OPTIONS, value); } @@ -36,6 +43,9 @@ export type PinMessageDialogProps = Readonly<{ onOpenChange: (open: boolean) => void; messageId: string; hasMaxPinnedMessages: boolean; + isPinningDisappearingMessage: boolean; + seenPinMessageDisappearingMessagesWarningCount: number; + onSeenPinMessageDisappearingMessagesWarning: () => void; onPinnedMessageAdd: ( messageId: string, duration: DurationInSeconds | null @@ -45,8 +55,31 @@ export type PinMessageDialogProps = Readonly<{ export const PinMessageDialog = memo(function PinMessageDialog( props: PinMessageDialogProps ) { - const { i18n, messageId, onPinnedMessageAdd, onOpenChange } = props; - const [duration, setDuration] = useState(DurationOption.TIME_7_DAYS); + const { + i18n, + onOpenChange, + messageId, + hasMaxPinnedMessages, + isPinningDisappearingMessage, + onPinnedMessageAdd, + seenPinMessageDisappearingMessagesWarningCount, + onSeenPinMessageDisappearingMessagesWarning, + } = props; + + const needsConfirmReplaceOldestPin = useMemo(() => { + return hasMaxPinnedMessages; + }, [hasMaxPinnedMessages]); + const needsConfirmDisappearingMessages = useMemo(() => { + return ( + isPinningDisappearingMessage && + seenPinMessageDisappearingMessagesWarningCount <= 3 + ); + }, [ + isPinningDisappearingMessage, + seenPinMessageDisappearingMessagesWarningCount, + ]); + + const [duration, setDuration] = useState(null); const [confirmedReplaceOldestPin, setConfirmedReplaceOldestPin] = useState(false); @@ -54,132 +87,232 @@ export const PinMessageDialog = memo(function PinMessageDialog( (open: boolean) => { onOpenChange(open); // reset state + setDuration(null); setConfirmedReplaceOldestPin(false); - setDuration(DurationOption.TIME_7_DAYS); }, [onOpenChange] ); - const handleConfirmReplaceOldestPin = useCallback((event: MouseEvent) => { - event.preventDefault(); + const submit = useCallback(() => { + strictAssert( + duration != null, + 'Duration should not be null when submitting' + ); + const durationValue = DURATION_OPTIONS[duration]; + onPinnedMessageAdd(messageId, durationValue); + handleOpenChange(false); + }, [onPinnedMessageAdd, messageId, duration, handleOpenChange]); + + const handleConfirmReplaceOldestPin = useCallback(() => { setConfirmedReplaceOldestPin(true); }, []); - const handleValueChange = useCallback((value: string) => { + const handleSelectDuration = useCallback( + (selected: DurationOption) => { + setDuration(selected); + if (!needsConfirmDisappearingMessages) { + submit(); + } + }, + [needsConfirmDisappearingMessages, submit] + ); + + const handleConfirmDisappearingMessages = useCallback(() => { + onSeenPinMessageDisappearingMessagesWarning(); + submit(); + }, [onSeenPinMessageDisappearingMessagesWarning, submit]); + + let step: Step; + if (!props.open) { + step = Step.CLOSED; + } else if (needsConfirmReplaceOldestPin && !confirmedReplaceOldestPin) { + step = Step.CONFIRM_REPLACE_OLDEST_PIN; + } else if (duration == null) { + step = Step.SELECT_PIN_DURATION; + } else if (needsConfirmDisappearingMessages) { + step = Step.DISAPPEARING_MESSAGES_WARNING; + } else { + step = Step.CLOSED; + } + + return ( + <> + + + + + ); +}); + +function PinMessageConfirmReplacePinDialog(props: { + i18n: LocalizerType; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirmReplaceOldestPin: () => void; +}) { + const { i18n, onConfirmReplaceOldestPin } = props; + const handleConfirmReplaceOldestPin = useCallback( + (event: MouseEvent) => { + event.preventDefault(); + onConfirmReplaceOldestPin(); + }, + [onConfirmReplaceOldestPin] + ); + return ( + + + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Title')} + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Description')} + + + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Cancel')} + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Continue')} + + + + + ); +} + +function PinMessageSelectDurationDialog(props: { + i18n: LocalizerType; + open: boolean; + onOpenChange: (open: boolean) => void; + onSelectDuration: (duration: DurationOption) => void; +}) { + const { i18n, onOpenChange, onSelectDuration } = props; + const [duration, setDuration] = useState(DurationOption.TIME_7_DAYS); + + const handleDurationChange = useCallback((value: string) => { strictAssert(isValidDurationOption(value), `Invalid option: ${value}`); setDuration(value); }, []); const handleCancel = useCallback(() => { - handleOpenChange(false); - }, [handleOpenChange]); + onOpenChange(false); + }, [onOpenChange]); - const handlePinnedMessageAdd = useCallback(() => { - const durationValue = DURATION_OPTIONS[duration]; - onPinnedMessageAdd(messageId, durationValue); - }, [duration, onPinnedMessageAdd, messageId]); - - const showConfirmReplaceOldestPin = - props.hasMaxPinnedMessages && !confirmedReplaceOldestPin; + const handleConfirm = useCallback(() => { + onSelectDuration(duration); + }, [duration, onSelectDuration]); return ( - <> - + - - - - {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Title')} - - - {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Description')} - - - - - {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Cancel')} - - - {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Continue')} - - - - - - - - - - {i18n('icu:PinMessageDialog__Title')} - - - - - - + + + {i18n('icu:PinMessageDialog__Title')} + + + + + + + + + {i18n('icu:PinMessageDialog__Option--TIME_24_HOURS')} + + + + + + {i18n('icu:PinMessageDialog__Option--TIME_7_DAYS')} + + + + + + {i18n('icu:PinMessageDialog__Option--TIME_30_DAYS')} + + + + + + {i18n('icu:PinMessageDialog__Option--FOREVER')} + + + {isInternalFeaturesEnabled() && ( + - - {i18n('icu:PinMessageDialog__Option--TIME_24_HOURS')} - + 10 seconds (Internal) - - - - {i18n('icu:PinMessageDialog__Option--TIME_7_DAYS')} - - - - - - {i18n('icu:PinMessageDialog__Option--TIME_30_DAYS')} - - - - - - {i18n('icu:PinMessageDialog__Option--FOREVER')} - - - {isInternalFeaturesEnabled() && ( - - - - 10 seconds (Internal) - - - )} - - - - - - {i18n('icu:PinMessageDialog__Cancel')} - - - {i18n('icu:PinMessageDialog__Pin')} - - - - - - + )} + + + + + + {i18n('icu:PinMessageDialog__Cancel')} + + + {i18n('icu:PinMessageDialog__Pin')} + + + + + ); -}); +} + +function PinMessageDisappearingMessagesWarningDialog(props: { + i18n: LocalizerType; + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; +}) { + const { i18n } = props; + return ( + + + + + {i18n('icu:PinMessageDisappearingMessagesWarningDialog__Title')} + + + {i18n( + 'icu:PinMessageDisappearingMessagesWarningDialog__Description' + )} + + + + + {i18n('icu:PinMessageDisappearingMessagesWarningDialog__Okay')} + + + + + ); +} diff --git a/ts/state/ducks/globalModals.preload.ts b/ts/state/ducks/globalModals.preload.ts index 14f1f66ea0..3b3cc0f95d 100644 --- a/ts/state/ducks/globalModals.preload.ts +++ b/ts/state/ducks/globalModals.preload.ts @@ -48,6 +48,7 @@ import type { MessageForwardDraft } from '../../types/ForwardDraft.std.js'; import { hydrateRanges } from '../../util/BodyRange.node.js'; import { getConversationSelector, + getHasMaxPinnedMessages, type GetConversationByIdType, } from '../selectors/conversations.dom.js'; import { missingCaseError } from '../../util/missingCaseError.std.js'; @@ -62,6 +63,8 @@ import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../comp import type { DataPropsType as BackfillFailureModalPropsType } from '../../components/BackfillFailureModal.dom.js'; import type { SmartDraftGifMessageSendModalProps } from '../smart/DraftGifMessageSendModal.preload.js'; import { onCriticalIdlePrimaryDeviceModalDismissed } from '../../util/handleServerAlerts.preload.js'; +import type { PinMessageDialogData } from '../smart/PinMessageDialog.preload.js'; +import type { StateThunk } from '../types.std.js'; const log = createLogger('globalModals'); @@ -146,6 +149,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{ } | null; messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null; notePreviewModalProps: NotePreviewModalPropsType | null; + pinMessageDialogData: PinMessageDialogData | null; usernameOnboardingState: UsernameOnboardingState; mediaPermissionsModalProps?: { mediaType: 'camera' | 'microphone'; @@ -233,6 +237,7 @@ const SHOW_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL = 'globalModals/SHOW_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL'; const HIDE_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL = 'globalModals/HIDE_LOW_DISK_SPACE_BACKUP_IMPORT_MODAL'; +const TOGGLE_PIN_MESSAGE_DIALOG = 'globalModals/TOGGLE_PIN_MESSAGE_DIALOG'; export type ContactModalStateType = ReadonlyDeep<{ contactId: string; @@ -500,6 +505,11 @@ type CloseEditHistoryModalActionType = ReadonlyDeep<{ type: typeof CLOSE_EDIT_HISTORY_MODAL; }>; +type TogglePinMessageDialogActionType = ReadonlyDeep<{ + type: typeof TOGGLE_PIN_MESSAGE_DIALOG; + payload: PinMessageDialogData | null; +}>; + export type GlobalModalsActionType = ReadonlyDeep< | CloseEditHistoryModalActionType | CloseDebugLogErrorModalActionType @@ -555,6 +565,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | ToggleSafetyNumberModalActionType | ToggleSignalConnectionsModalActionType | ToggleUsernameOnboardingActionType + | TogglePinMessageDialogActionType >; // Action Creators @@ -612,6 +623,8 @@ export const actions = { toggleSafetyNumberModal, toggleSignalConnectionsModal, toggleUsernameOnboarding, + showPinMessageDialog, + hidePinMessageDialog, }; export const useGlobalModalActions = (): BoundActionCreatorsMapObject< @@ -1342,6 +1355,30 @@ function copyOverMessageAttributesIntoForwardMessages( }); } +function showPinMessageDialog( + messageId: string, + isPinningDisappearingMessage: boolean +): StateThunk { + return async (dispatch, getState) => { + const hasMaxPinnedMessages = getHasMaxPinnedMessages(getState()); + dispatch({ + type: TOGGLE_PIN_MESSAGE_DIALOG, + payload: { + messageId, + hasMaxPinnedMessages, + isPinningDisappearingMessage, + }, + }); + }; +} + +function hidePinMessageDialog(): TogglePinMessageDialogActionType { + return { + type: TOGGLE_PIN_MESSAGE_DIALOG, + payload: null, + }; +} + // Reducer export function getEmptyState(): GlobalModalsStateType { @@ -1367,6 +1404,7 @@ export function getEmptyState(): GlobalModalsStateType { messageRequestActionsConfirmationProps: null, tapToViewNotAvailableModalProps: undefined, notePreviewModalProps: null, + pinMessageDialogData: null, }; } @@ -1812,5 +1850,12 @@ export function reducer( }; } + if (action.type === TOGGLE_PIN_MESSAGE_DIALOG) { + return { + ...state, + pinMessageDialogData: action.payload, + }; + } + return state; } diff --git a/ts/state/selectors/conversations.dom.ts b/ts/state/selectors/conversations.dom.ts index 922c5300e3..e7c1bb9655 100644 --- a/ts/state/selectors/conversations.dom.ts +++ b/ts/state/selectors/conversations.dom.ts @@ -90,6 +90,7 @@ import type { AllChatFoldersMutedStats } from '../../util/countMutedStats.std.js import { countAllChatFoldersMutedStats } from '../../util/countMutedStats.std.js'; import { getActiveProfile } from './notificationProfiles.dom.js'; import type { PinnedMessage } from '../../types/PinnedMessage.std.js'; +import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js'; const { isNumber, pick } = lodash; @@ -338,6 +339,15 @@ export const getPinnedMessagesMessageIds: StateSelector> = }); }); +export const getHasMaxPinnedMessages: StateSelector = createSelector( + getPinnedMessages, + pinnedMessages => { + const pinnedMessagesLimit = getPinnedMessagesLimit(); + const pinnedMessagesCount = pinnedMessages.length; + return pinnedMessagesCount >= pinnedMessagesLimit; + } +); + const collator = new Intl.Collator(); // Note: we will probably want to put i18n and regionCode back when we are formatting diff --git a/ts/state/selectors/globalModals.std.ts b/ts/state/selectors/globalModals.std.ts index f757c487fd..820d4d5a28 100644 --- a/ts/state/selectors/globalModals.std.ts +++ b/ts/state/selectors/globalModals.std.ts @@ -6,6 +6,8 @@ import { createSelector } from 'reselect'; import type { StateType } from '../reducer.preload.js'; import type { GlobalModalsStateType } from '../ducks/globalModals.preload.js'; import { UsernameOnboardingState } from '../../types/globalModals.std.js'; +import type { StateSelector } from '../types.std.js'; +import type { PinMessageDialogData } from '../smart/PinMessageDialog.preload.js'; export const getGlobalModalsState = (state: StateType): GlobalModalsStateType => state.globalModals; @@ -92,3 +94,9 @@ export const getNotePreviewModalProps = createSelector( getGlobalModalsState, ({ notePreviewModalProps }) => notePreviewModalProps ); + +export const getPinMessageDialogData: StateSelector = + createSelector( + getGlobalModalsState, + ({ pinMessageDialogData }) => pinMessageDialogData + ); diff --git a/ts/state/selectors/items.dom.ts b/ts/state/selectors/items.dom.ts index 45109d4e66..82c1e37246 100644 --- a/ts/state/selectors/items.dom.ts +++ b/ts/state/selectors/items.dom.ts @@ -23,6 +23,7 @@ import { isValidEmojiSkinTone, } from '../../components/fun/data/emojis.std.js'; import { BackupLevel } from '../../services/backups/types.std.js'; +import type { StateSelector } from '../types.std.js'; const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; @@ -307,3 +308,9 @@ export const getServerAlerts = createSelector( getItems, (state: ItemsStateType) => state.serverAlerts ?? {} ); + +export const getSeenPinMessageDisappearingMessagesWarningCount: StateSelector = + createSelector( + getItems, + state => state.seenPinMessageDisappearingMessagesWarningCount ?? 0 + ); diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 128f71d4dc..1abf3870bc 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -170,7 +170,6 @@ import type { MessageRequestResponseNotificationData } from '../../components/co import type { PinnedMessageNotificationData } from '../../components/conversation/pinned-messages/PinnedMessageNotification.dom.js'; import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js'; import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js'; -import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js'; const { groupBy, isEmpty, isNumber, isObject, map } = lodash; @@ -967,9 +966,6 @@ export const getPropsForMessage = ( expirationStartTimestamp, }), giftBadge: message.giftBadge, - hasMaxPinnedMessages: getHasMaxPinnedMessages( - options.pinnedMessagesMessageIds ?? [] - ), poll: getPollForMessage(message, { conversationSelector: options.conversationSelector, ourConversationId, @@ -2427,14 +2423,6 @@ export function canPinMessage( return true; } -function getHasMaxPinnedMessages( - pinnedMessagesMessageIds: ReadonlyArray -) { - const pinnedMessagesLimit = getPinnedMessagesLimit(); - const pinnedMessagesCount = pinnedMessagesMessageIds.length; - return pinnedMessagesCount >= pinnedMessagesLimit; -} - export function getLastChallengeError( message: Pick ): ShallowChallengeError | undefined { diff --git a/ts/state/smart/GlobalModalContainer.preload.tsx b/ts/state/smart/GlobalModalContainer.preload.tsx index d525c5be71..a8180ad7ee 100644 --- a/ts/state/smart/GlobalModalContainer.preload.tsx +++ b/ts/state/smart/GlobalModalContainer.preload.tsx @@ -39,6 +39,7 @@ import { shouldShowPlaintextWorkflow, shouldShowLocalBackupWorkflow, } from '../selectors/backups.std.js'; +import { SmartPinMessageDialog } from './PinMessageDialog.preload.js'; function renderCallLinkAddNameModal(): React.JSX.Element { return ; @@ -100,6 +101,10 @@ function renderNotePreviewModal(): React.JSX.Element { return ; } +function renderPinMessageDialog(): React.JSX.Element { + return ; +} + function renderPlaintextExportWorkflow(): React.JSX.Element { return ; } @@ -160,6 +165,7 @@ export const SmartGlobalModalContainer = memo( mediaPermissionsModalProps, messageRequestActionsConfirmationProps, notePreviewModalProps, + pinMessageDialogData, isProfileNameWarningModalVisible, profileNameWarningModalConversationType, isShortcutGuideModalVisible, @@ -278,6 +284,7 @@ export const SmartGlobalModalContainer = memo( closeMediaPermissionsModal={closeMediaPermissionsModal} openSystemMediaPermissions={window.IPC.openSystemMediaPermissions} notePreviewModalProps={notePreviewModalProps} + pinMessageDialogData={pinMessageDialogData} hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hideBackfillFailureModal={hideBackfillFailureModal} hideUserNotFoundModal={hideUserNotFoundModal} @@ -310,6 +317,7 @@ export const SmartGlobalModalContainer = memo( renderMessageRequestActionsConfirmation } renderNotePreviewModal={renderNotePreviewModal} + renderPinMessageDialog={renderPinMessageDialog} renderPlaintextExportWorkflow={renderPlaintextExportWorkflow} renderLocalBackupExportWorkflow={renderLocalBackupExportWorkflow} renderProfileNameWarningModal={renderProfileNameWarningModal} diff --git a/ts/state/smart/PinMessageDialog.preload.tsx b/ts/state/smart/PinMessageDialog.preload.tsx new file mode 100644 index 0000000000..0e7c0508d0 --- /dev/null +++ b/ts/state/smart/PinMessageDialog.preload.tsx @@ -0,0 +1,64 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { memo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { getIntl } from '../selectors/user.std.js'; +import { useConversationsActions } from '../ducks/conversations.preload.js'; +import { PinMessageDialog } from '../../components/conversation/pinned-messages/PinMessageDialog.dom.js'; +import { useItemsActions } from '../ducks/items.preload.js'; +import { getSeenPinMessageDisappearingMessagesWarningCount } from '../selectors/items.dom.js'; +import { getPinMessageDialogData } from '../selectors/globalModals.std.js'; +import { useGlobalModalActions } from '../ducks/globalModals.preload.js'; + +export type PinMessageDialogData = Readonly<{ + messageId: string; + hasMaxPinnedMessages: boolean; + isPinningDisappearingMessage: boolean; +}>; + +export const SmartPinMessageDialog = memo(function SmartPinMessageDialog() { + const i18n = useSelector(getIntl); + const pinMessageDialogData = useSelector(getPinMessageDialogData); + const { hidePinMessageDialog } = useGlobalModalActions(); + const { onPinnedMessageAdd } = useConversationsActions(); + + const seenPinMessageDisappearingMessagesWarningCount = useSelector( + getSeenPinMessageDisappearingMessagesWarningCount + ); + const { putItem } = useItemsActions(); + + const handleClose = useCallback(() => { + hidePinMessageDialog(); + }, [hidePinMessageDialog]); + + const handleSeenPinMessageDisappearingMessagesWarning = useCallback(() => { + putItem( + 'seenPinMessageDisappearingMessagesWarningCount', + seenPinMessageDisappearingMessagesWarningCount + 1 + ); + }, [putItem, seenPinMessageDisappearingMessagesWarningCount]); + + if (pinMessageDialogData == null) { + return null; + } + + return ( + + ); +}); diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index ded3878b03..aec19438ab 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -131,7 +131,6 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( kickOffAttachmentDownload, markAttachmentAsCorrupted, messageExpanded, - onPinnedMessageAdd, onPinnedMessageRemove, openGiftBadge, pushPanelForConversation, @@ -164,6 +163,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( const { showContactModal, showEditHistoryModal, + showPinMessageDialog, showTapToViewNotAvailableModal, toggleMessageRequestActionsConfirmation, toggleDeleteMessagesModal, @@ -239,7 +239,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( } onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} - onPinnedMessageAdd={onPinnedMessageAdd} + showPinMessageDialog={showPinMessageDialog} onPinnedMessageRemove={onPinnedMessageRemove} scrollToPinnedMessage={scrollToPinnedMessage} retryDeleteForEveryone={retryDeleteForEveryone} diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index e0ba414662..c2c8328f4e 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -117,6 +117,7 @@ export type StorageAccessType = { sessionResets: SessionResetsType; showStickerPickerHint: boolean; showStickersIntroduction: boolean; + seenPinMessageDisappearingMessagesWarningCount: number; signedKeyId: number; signedKeyIdPNI: number; signedKeyUpdateTime: number;