Add warning when pinning disappearing message

This commit is contained in:
Jamie
2026-01-21 11:03:23 -08:00
committed by GitHub
parent 95354c768c
commit 79a273d9a0
18 changed files with 444 additions and 175 deletions

View File

@@ -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"

View File

@@ -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();
}

View File

@@ -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'),

View File

@@ -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'),

View File

@@ -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(

View File

@@ -265,7 +265,6 @@ const createProps = (overrideProps: Partial<Props> = {}): 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> = {}): 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,

View File

@@ -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<MessagePropsData, 'renderingContext' | 'menu'>;
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<MessagePropsActions, 'onToggleSelect' | 'onReplyToMessage'>;
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 (
<>
<Message
{...props}
renderingContext="conversation/TimelineItem"
renderMenu={renderMenu}
renderMessageContextMenu={renderMessageContextMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}}
onReplyToMessage={handleReplyToMessage}
onWrapperKeyDown={handleWrapperKeyDown}
/>
<PinMessageDialog
i18n={i18n}
messageId={id}
open={pinMessageDialogOpen}
onOpenChange={setPinMessageDialogOpen}
onPinnedMessageAdd={handlePinnedMessageAdd}
hasMaxPinnedMessages={hasMaxPinnedMessages}
/>
</>
<Message
{...props}
renderingContext="conversation/TimelineItem"
renderMenu={renderMenu}
renderMessageContextMenu={renderMessageContextMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}}
onReplyToMessage={handleReplyToMessage}
onWrapperKeyDown={handleWrapperKeyDown}
/>
);
}

View File

@@ -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'
)}
/>
);
}

View File

@@ -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, DurationInSeconds | null> = {
[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<DurationOption | null>(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 (
<>
<PinMessageConfirmReplacePinDialog
i18n={i18n}
open={step === Step.CONFIRM_REPLACE_OLDEST_PIN}
onOpenChange={handleOpenChange}
onConfirmReplaceOldestPin={handleConfirmReplaceOldestPin}
/>
<PinMessageSelectDurationDialog
i18n={i18n}
open={step === Step.SELECT_PIN_DURATION}
onOpenChange={handleOpenChange}
onSelectDuration={handleSelectDuration}
/>
<PinMessageDisappearingMessagesWarningDialog
i18n={i18n}
open={step === Step.DISAPPEARING_MESSAGES_WARNING}
onOpenChange={handleOpenChange}
onConfirm={handleConfirmDisappearingMessages}
/>
</>
);
});
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 (
<AxoAlertDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Description')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Cancel')}
</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action
variant="primary"
onClick={handleConfirmReplaceOldestPin}
>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Continue')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
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 (
<>
<AxoAlertDialog.Root
open={props.open && showConfirmReplaceOldestPin}
onOpenChange={handleOpenChange}
<AxoDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<AxoDialog.Content
size="sm"
escape="cancel-is-noop"
disableMissingAriaDescriptionWarning
>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Description')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Cancel>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Cancel')}
</AxoAlertDialog.Cancel>
<AxoAlertDialog.Action
variant="primary"
onClick={handleConfirmReplaceOldestPin}
>
{i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Continue')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
<AxoDialog.Root
open={props.open && !showConfirmReplaceOldestPin}
onOpenChange={handleOpenChange}
>
<AxoDialog.Content
size="sm"
escape="cancel-is-noop"
disableMissingAriaDescriptionWarning
>
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:PinMessageDialog__Title')}
</AxoDialog.Title>
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} />
</AxoDialog.Header>
<AxoDialog.Body>
<AxoRadioGroup.Root
value={duration}
onValueChange={handleValueChange}
>
<AxoRadioGroup.Item value={DurationOption.TIME_24_HOURS}>
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:PinMessageDialog__Title')}
</AxoDialog.Title>
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} />
</AxoDialog.Header>
<AxoDialog.Body>
<AxoRadioGroup.Root
value={duration}
onValueChange={handleDurationChange}
>
<AxoRadioGroup.Item value={DurationOption.TIME_24_HOURS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--TIME_24_HOURS')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value={DurationOption.TIME_7_DAYS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--TIME_7_DAYS')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value={DurationOption.TIME_30_DAYS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--TIME_30_DAYS')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value={DurationOption.FOREVER}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--FOREVER')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
{isInternalFeaturesEnabled() && (
<AxoRadioGroup.Item value={DurationOption.DEBUG_10_SECONDS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--TIME_24_HOURS')}
</AxoRadioGroup.Label>
<AxoRadioGroup.Label>10 seconds (Internal)</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value={DurationOption.TIME_7_DAYS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--TIME_7_DAYS')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value={DurationOption.TIME_30_DAYS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--TIME_30_DAYS')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value={DurationOption.FOREVER}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
{i18n('icu:PinMessageDialog__Option--FOREVER')}
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
{isInternalFeaturesEnabled() && (
<AxoRadioGroup.Item value={DurationOption.DEBUG_10_SECONDS}>
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
10 seconds (Internal)
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
)}
</AxoRadioGroup.Root>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={handleCancel}>
{i18n('icu:PinMessageDialog__Cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="primary"
onClick={handlePinnedMessageAdd}
>
{i18n('icu:PinMessageDialog__Pin')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
</>
)}
</AxoRadioGroup.Root>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={handleCancel}>
{i18n('icu:PinMessageDialog__Cancel')}
</AxoDialog.Action>
<AxoDialog.Action variant="primary" onClick={handleConfirm}>
{i18n('icu:PinMessageDialog__Pin')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
});
}
function PinMessageDisappearingMessagesWarningDialog(props: {
i18n: LocalizerType;
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
const { i18n } = props;
return (
<AxoAlertDialog.Root open={props.open} onOpenChange={props.onOpenChange}>
<AxoAlertDialog.Content escape="cancel-is-destructive">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:PinMessageDisappearingMessagesWarningDialog__Title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n(
'icu:PinMessageDisappearingMessagesWarningDialog__Description'
)}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action variant="primary" onClick={props.onConfirm}>
{i18n('icu:PinMessageDisappearingMessagesWarningDialog__Okay')}
</AxoAlertDialog.Action>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}

View File

@@ -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<TogglePinMessageDialogActionType> {
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;
}

View File

@@ -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<ReadonlyArray<string>> =
});
});
export const getHasMaxPinnedMessages: StateSelector<boolean> = 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

View File

@@ -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<PinMessageDialogData | null> =
createSelector(
getGlobalModalsState,
({ pinMessageDialogData }) => pinMessageDialogData
);

View File

@@ -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<number> =
createSelector(
getItems,
state => state.seenPinMessageDisappearingMessagesWarningCount ?? 0
);

View File

@@ -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<string>
) {
const pinnedMessagesLimit = getPinnedMessagesLimit();
const pinnedMessagesCount = pinnedMessagesMessageIds.length;
return pinnedMessagesCount >= pinnedMessagesLimit;
}
export function getLastChallengeError(
message: Pick<MessageWithUIFieldsType, 'errors'>
): ShallowChallengeError | undefined {

View File

@@ -39,6 +39,7 @@ import {
shouldShowPlaintextWorkflow,
shouldShowLocalBackupWorkflow,
} from '../selectors/backups.std.js';
import { SmartPinMessageDialog } from './PinMessageDialog.preload.js';
function renderCallLinkAddNameModal(): React.JSX.Element {
return <SmartCallLinkAddNameModal />;
@@ -100,6 +101,10 @@ function renderNotePreviewModal(): React.JSX.Element {
return <SmartNotePreviewModal />;
}
function renderPinMessageDialog(): React.JSX.Element {
return <SmartPinMessageDialog />;
}
function renderPlaintextExportWorkflow(): React.JSX.Element {
return <SmartPlaintextExportWorkflow />;
}
@@ -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}

View File

@@ -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 (
<PinMessageDialog
i18n={i18n}
open
onOpenChange={handleClose}
messageId={pinMessageDialogData.messageId}
hasMaxPinnedMessages={pinMessageDialogData.hasMaxPinnedMessages}
isPinningDisappearingMessage={
pinMessageDialogData.isPinningDisappearingMessage
}
seenPinMessageDisappearingMessagesWarningCount={
seenPinMessageDisappearingMessagesWarningCount
}
onSeenPinMessageDisappearingMessagesWarning={
handleSeenPinMessageDisappearingMessagesWarning
}
onPinnedMessageAdd={onPinnedMessageAdd}
/>
);
});

View File

@@ -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}

View File

@@ -117,6 +117,7 @@ export type StorageAccessType = {
sessionResets: SessionResetsType;
showStickerPickerHint: boolean;
showStickersIntroduction: boolean;
seenPinMessageDisappearingMessagesWarningCount: number;
signedKeyId: number;
signedKeyIdPNI: number;
signedKeyUpdateTime: number;