diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 01ac28ba85..b88780278a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1658,6 +1658,22 @@ "messageformat": "Pin", "description": "Message > Context Menu > Pin Message > Dialog > Pin Button" }, + "icu:PinMessageDialog--HasMaxPinnedMessages__Title": { + "messageformat": "Replace oldest pin?", + "description": "Message > Context Menu > Pin Message (when at max pinned messages) > Dialog > Title" + }, + "icu:PinMessageDialog--HasMaxPinnedMessages__Description": { + "messageformat": "Pinning this message will replace the oldest one.", + "description": "Message > Context Menu > Pin Message (when at max pinned messages) > Dialog > Description" + }, + "icu:PinMessageDialog--HasMaxPinnedMessages__Cancel": { + "messageformat": "Cancel", + "description": "Message > Context Menu > Pin Message (when at max pinned messages) > Dialog > Cancel Button" + }, + "icu:PinMessageDialog--HasMaxPinnedMessages__Continue": { + "messageformat": "Continue", + "description": "Message > Context Menu > Pin Message (when at max pinned messages) > Dialog > Continue 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/axo/AxoAlertDialog.dom.tsx b/ts/axo/AxoAlertDialog.dom.tsx index 654cedfc84..514cbcd7eb 100644 --- a/ts/axo/AxoAlertDialog.dom.tsx +++ b/ts/axo/AxoAlertDialog.dom.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import { AlertDialog } from 'radix-ui'; -import type { FC, ReactNode } from 'react'; +import type { FC, MouseEvent, ReactNode } from 'react'; import React, { memo } from 'react'; import { AxoButton } from './AxoButton.dom.js'; import { tw } from './tw.dom.js'; @@ -221,7 +221,7 @@ export namespace AxoAlertDialog { variant: ActionVariant; symbol?: AxoSymbol.InlineGlyphName; arrow?: boolean; - onClick: () => void; + onClick: (event: MouseEvent) => void; children: ReactNode; }>; diff --git a/ts/components/conversation/Quote.dom.stories.tsx b/ts/components/conversation/Quote.dom.stories.tsx index 93553c0c67..7d4f579dbe 100644 --- a/ts/components/conversation/Quote.dom.stories.tsx +++ b/ts/components/conversation/Quote.dom.stories.tsx @@ -98,6 +98,7 @@ const defaultMessageProps: TimelineMessagesProps = { 'default--doubleCheckMissingQuoteReference' ), getPreferredBadge: () => undefined, + hasMaxPinnedMessages: false, i18n, platform: 'darwin', id: 'messageId', diff --git a/ts/components/conversation/Timeline.dom.stories.tsx b/ts/components/conversation/Timeline.dom.stories.tsx index 4c7dd1e682..ceedfd4bcd 100644 --- a/ts/components/conversation/Timeline.dom.stories.tsx +++ b/ts/components/conversation/Timeline.dom.stories.tsx @@ -66,6 +66,7 @@ function mockMessageTimelineItem( direction: 'incoming', status: 'sent', text: 'Hello there from the new world!', + hasMaxPinnedMessages: false, isBlocked: false, isMessageRequestAccepted: true, isPinned: false, diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index 43c133a8a0..1aefe19c21 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -265,6 +265,7 @@ 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', diff --git a/ts/components/conversation/TimelineMessage.dom.tsx b/ts/components/conversation/TimelineMessage.dom.tsx index 5f13e4bdf5..54590ecfe3 100644 --- a/ts/components/conversation/TimelineMessage.dom.tsx +++ b/ts/components/conversation/TimelineMessage.dom.tsx @@ -55,6 +55,7 @@ export type PropsData = { canReact: boolean; canReply: boolean; canPinMessages: boolean; + hasMaxPinnedMessages: boolean; selectedReaction?: string; isTargeted?: boolean; } & Omit; @@ -118,6 +119,7 @@ export function TimelineMessage(props: Props): JSX.Element { containerWidthBreakpoint, conversationId, direction, + hasMaxPinnedMessages, i18n, id, interactivity, @@ -493,6 +495,7 @@ export function TimelineMessage(props: Props): JSX.Element { open={pinMessageDialogOpen} onOpenChange={setPinMessageDialogOpen} onPinnedMessageAdd={handlePinnedMessageAdd} + hasMaxPinnedMessages={hasMaxPinnedMessages} /> ); diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx index ea0142ebee..3b6f59d75c 100644 --- a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx @@ -20,6 +20,7 @@ export function Default(): JSX.Element { onOpenChange={setOpen} messageId="42" onPinnedMessageAdd={action('onPinnedMessageAdd')} + hasMaxPinnedMessages={false} /> ); } diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx index bdb11f61fa..a25588793d 100644 --- a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx @@ -1,11 +1,13 @@ // 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 { AxoDialog } from '../../../axo/AxoDialog.dom.js'; import type { LocalizerType } from '../../../types/I18N.std.js'; import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js'; import { DurationInSeconds } from '../../../util/durations/duration-in-seconds.std.js'; import { strictAssert } from '../../../util/assert.std.js'; +import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js'; enum DurationOption { TIME_24_HOURS = 'TIME_24_HOURS', @@ -30,6 +32,7 @@ export type PinMessageDialogProps = Readonly<{ open: boolean; onOpenChange: (open: boolean) => void; messageId: string; + hasMaxPinnedMessages: boolean; onPinnedMessageAdd: ( messageId: string, duration: DurationInSeconds | null @@ -41,6 +44,23 @@ export const PinMessageDialog = memo(function PinMessageDialog( ) { const { i18n, messageId, onPinnedMessageAdd, onOpenChange } = props; const [duration, setDuration] = useState(DurationOption.TIME_7_DAYS); + const [confirmedReplaceOldestPin, setConfirmedReplaceOldestPin] = + useState(false); + + const handleOpenChange = useCallback( + (open: boolean) => { + onOpenChange(open); + // reset state + setConfirmedReplaceOldestPin(false); + setDuration(DurationOption.TIME_7_DAYS); + }, + [onOpenChange] + ); + + const handleConfirmReplaceOldestPin = useCallback((event: MouseEvent) => { + event.preventDefault(); + setConfirmedReplaceOldestPin(true); + }, []); const handleValueChange = useCallback((value: string) => { strictAssert(isValidDurationOption(value), `Invalid option: ${value}`); @@ -48,72 +68,107 @@ export const PinMessageDialog = memo(function PinMessageDialog( }, []); const handleCancel = useCallback(() => { - onOpenChange(false); - }, [onOpenChange]); + handleOpenChange(false); + }, [handleOpenChange]); const handlePinnedMessageAdd = useCallback(() => { const durationValue = DURATION_OPTIONS[duration]; onPinnedMessageAdd(messageId, durationValue); }, [duration, onPinnedMessageAdd, messageId]); + const showConfirmReplaceOldestPin = + props.hasMaxPinnedMessages && !confirmedReplaceOldestPin; + return ( - - + - - - {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')} - - - - - - - - {i18n('icu:PinMessageDialog__Cancel')} - - + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Title')} + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Description')} + + + + + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Cancel')} + + - {i18n('icu:PinMessageDialog__Pin')} - - - - - + {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Continue')} + + + + + + + + + + {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')} + + + + + + + + {i18n('icu:PinMessageDialog__Cancel')} + + + {i18n('icu:PinMessageDialog__Pin')} + + + + + + ); }); diff --git a/ts/state/selectors/message.preload.ts b/ts/state/selectors/message.preload.ts index 4219ee7c9f..2b3094a55b 100644 --- a/ts/state/selectors/message.preload.ts +++ b/ts/state/selectors/message.preload.ts @@ -170,6 +170,7 @@ import type { PinnedMessageNotificationData } from '../../components/conversatio import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js'; import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js'; import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js'; +import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js'; const { groupBy, isEmpty, isNumber, isObject, map } = lodash; @@ -966,6 +967,9 @@ export const getPropsForMessage = ( expirationStartTimestamp, }), giftBadge: message.giftBadge, + hasMaxPinnedMessages: getHasMaxPinnedMessages( + options.pinnedMessagesMessageIds ?? [] + ), poll: getPollForMessage(message, { conversationSelector: options.conversationSelector, ourConversationId, @@ -2410,6 +2414,14 @@ export function canPinMessages(conversation: ConversationType): boolean { return conversation.type === 'direct' || canEditGroupInfo(conversation); } +function getHasMaxPinnedMessages( + pinnedMessagesMessageIds: ReadonlyArray +) { + const pinnedMessagesLimit = getPinnedMessagesLimit(); + const pinnedMessagesCount = pinnedMessagesMessageIds.length; + return pinnedMessagesCount >= pinnedMessagesLimit; +} + export function getLastChallengeError( message: Pick ): ShallowChallengeError | undefined {