Add alert dialog to confirm replacing oldest pinned message

This commit is contained in:
Jamie
2025-12-16 07:43:04 -08:00
committed by GitHub
parent 377d272841
commit 5ec3f763cd
9 changed files with 149 additions and 59 deletions

View File

@@ -1658,6 +1658,22 @@
"messageformat": "Pin", "messageformat": "Pin",
"description": "Message > Context Menu > Pin Message > Dialog > Pin Button" "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": { "icu:PinnedMessagesBar__AccessibilityLabel": {
"messageformat": "{pinsCount, plural, one {Pinned message} other {Pinned messages}}", "messageformat": "{pinsCount, plural, one {Pinned message} other {Pinned messages}}",
"description": "Conversation > With pinned message(s) > Pinned messages bar > Accessibility label" "description": "Conversation > With pinned message(s) > Pinned messages bar > Accessibility label"

View File

@@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { AlertDialog } from 'radix-ui'; import { AlertDialog } from 'radix-ui';
import type { FC, ReactNode } from 'react'; import type { FC, MouseEvent, ReactNode } from 'react';
import React, { memo } from 'react'; import React, { memo } from 'react';
import { AxoButton } from './AxoButton.dom.js'; import { AxoButton } from './AxoButton.dom.js';
import { tw } from './tw.dom.js'; import { tw } from './tw.dom.js';
@@ -221,7 +221,7 @@ export namespace AxoAlertDialog {
variant: ActionVariant; variant: ActionVariant;
symbol?: AxoSymbol.InlineGlyphName; symbol?: AxoSymbol.InlineGlyphName;
arrow?: boolean; arrow?: boolean;
onClick: () => void; onClick: (event: MouseEvent<HTMLButtonElement>) => void;
children: ReactNode; children: ReactNode;
}>; }>;

View File

@@ -98,6 +98,7 @@ const defaultMessageProps: TimelineMessagesProps = {
'default--doubleCheckMissingQuoteReference' 'default--doubleCheckMissingQuoteReference'
), ),
getPreferredBadge: () => undefined, getPreferredBadge: () => undefined,
hasMaxPinnedMessages: false,
i18n, i18n,
platform: 'darwin', platform: 'darwin',
id: 'messageId', id: 'messageId',

View File

@@ -66,6 +66,7 @@ function mockMessageTimelineItem(
direction: 'incoming', direction: 'incoming',
status: 'sent', status: 'sent',
text: 'Hello there from the new world!', text: 'Hello there from the new world!',
hasMaxPinnedMessages: false,
isBlocked: false, isBlocked: false,
isMessageRequestAccepted: true, isMessageRequestAccepted: true,
isPinned: false, isPinned: false,

View File

@@ -265,6 +265,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
expirationTimestamp: overrideProps.expirationTimestamp ?? 0, expirationTimestamp: overrideProps.expirationTimestamp ?? 0,
getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined), getPreferredBadge: overrideProps.getPreferredBadge || (() => undefined),
giftBadge: overrideProps.giftBadge, giftBadge: overrideProps.giftBadge,
hasMaxPinnedMessages: false,
i18n, i18n,
platform: 'darwin', platform: 'darwin',
id: overrideProps.id ?? 'random-message-id', id: overrideProps.id ?? 'random-message-id',

View File

@@ -55,6 +55,7 @@ export type PropsData = {
canReact: boolean; canReact: boolean;
canReply: boolean; canReply: boolean;
canPinMessages: boolean; canPinMessages: boolean;
hasMaxPinnedMessages: boolean;
selectedReaction?: string; selectedReaction?: string;
isTargeted?: boolean; isTargeted?: boolean;
} & Omit<MessagePropsData, 'renderingContext' | 'menu'>; } & Omit<MessagePropsData, 'renderingContext' | 'menu'>;
@@ -118,6 +119,7 @@ export function TimelineMessage(props: Props): JSX.Element {
containerWidthBreakpoint, containerWidthBreakpoint,
conversationId, conversationId,
direction, direction,
hasMaxPinnedMessages,
i18n, i18n,
id, id,
interactivity, interactivity,
@@ -493,6 +495,7 @@ export function TimelineMessage(props: Props): JSX.Element {
open={pinMessageDialogOpen} open={pinMessageDialogOpen}
onOpenChange={setPinMessageDialogOpen} onOpenChange={setPinMessageDialogOpen}
onPinnedMessageAdd={handlePinnedMessageAdd} onPinnedMessageAdd={handlePinnedMessageAdd}
hasMaxPinnedMessages={hasMaxPinnedMessages}
/> />
</> </>
); );

View File

@@ -20,6 +20,7 @@ export function Default(): JSX.Element {
onOpenChange={setOpen} onOpenChange={setOpen}
messageId="42" messageId="42"
onPinnedMessageAdd={action('onPinnedMessageAdd')} onPinnedMessageAdd={action('onPinnedMessageAdd')}
hasMaxPinnedMessages={false}
/> />
); );
} }

View File

@@ -1,11 +1,13 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { MouseEvent } from 'react';
import React, { memo, useCallback, useState } from 'react'; import React, { memo, useCallback, useState } from 'react';
import { AxoDialog } from '../../../axo/AxoDialog.dom.js'; import { AxoDialog } from '../../../axo/AxoDialog.dom.js';
import type { LocalizerType } from '../../../types/I18N.std.js'; import type { LocalizerType } from '../../../types/I18N.std.js';
import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js'; import { AxoRadioGroup } from '../../../axo/AxoRadioGroup.dom.js';
import { DurationInSeconds } from '../../../util/durations/duration-in-seconds.std.js'; import { DurationInSeconds } from '../../../util/durations/duration-in-seconds.std.js';
import { strictAssert } from '../../../util/assert.std.js'; import { strictAssert } from '../../../util/assert.std.js';
import { AxoAlertDialog } from '../../../axo/AxoAlertDialog.dom.js';
enum DurationOption { enum DurationOption {
TIME_24_HOURS = 'TIME_24_HOURS', TIME_24_HOURS = 'TIME_24_HOURS',
@@ -30,6 +32,7 @@ export type PinMessageDialogProps = Readonly<{
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
messageId: string; messageId: string;
hasMaxPinnedMessages: boolean;
onPinnedMessageAdd: ( onPinnedMessageAdd: (
messageId: string, messageId: string,
duration: DurationInSeconds | null duration: DurationInSeconds | null
@@ -41,6 +44,23 @@ export const PinMessageDialog = memo(function PinMessageDialog(
) { ) {
const { i18n, messageId, onPinnedMessageAdd, onOpenChange } = props; const { i18n, messageId, onPinnedMessageAdd, onOpenChange } = props;
const [duration, setDuration] = useState(DurationOption.TIME_7_DAYS); 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) => { const handleValueChange = useCallback((value: string) => {
strictAssert(isValidDurationOption(value), `Invalid option: ${value}`); strictAssert(isValidDurationOption(value), `Invalid option: ${value}`);
@@ -48,72 +68,107 @@ export const PinMessageDialog = memo(function PinMessageDialog(
}, []); }, []);
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
onOpenChange(false); handleOpenChange(false);
}, [onOpenChange]); }, [handleOpenChange]);
const handlePinnedMessageAdd = useCallback(() => { const handlePinnedMessageAdd = useCallback(() => {
const durationValue = DURATION_OPTIONS[duration]; const durationValue = DURATION_OPTIONS[duration];
onPinnedMessageAdd(messageId, durationValue); onPinnedMessageAdd(messageId, durationValue);
}, [duration, onPinnedMessageAdd, messageId]); }, [duration, onPinnedMessageAdd, messageId]);
const showConfirmReplaceOldestPin =
props.hasMaxPinnedMessages && !confirmedReplaceOldestPin;
return ( return (
<AxoDialog.Root open={props.open} onOpenChange={onOpenChange}> <>
<AxoDialog.Content <AxoAlertDialog.Root
size="sm" open={props.open && showConfirmReplaceOldestPin}
escape="cancel-is-noop" onOpenChange={handleOpenChange}
disableMissingAriaDescriptionWarning
> >
<AxoDialog.Header> <AxoAlertDialog.Content escape="cancel-is-noop">
<AxoDialog.Title> <AxoAlertDialog.Body>
{i18n('icu:PinMessageDialog__Title')} <AxoAlertDialog.Title>
</AxoDialog.Title> {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Title')}
<AxoDialog.Close aria-label={i18n('icu:PinMessageDialog__Close')} /> </AxoAlertDialog.Title>
</AxoDialog.Header> <AxoAlertDialog.Description>
<AxoDialog.Body> {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Description')}
<AxoRadioGroup.Root </AxoAlertDialog.Description>
value={duration} </AxoAlertDialog.Body>
onValueChange={handleValueChange} <AxoAlertDialog.Footer>
> <AxoAlertDialog.Cancel>
<AxoRadioGroup.Item value={DurationOption.TIME_24_HOURS}> {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Cancel')}
<AxoRadioGroup.Indicator /> </AxoAlertDialog.Cancel>
<AxoRadioGroup.Label> <AxoAlertDialog.Action
{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>
</AxoRadioGroup.Root>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={handleCancel}>
{i18n('icu:PinMessageDialog__Cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="primary" variant="primary"
onClick={handlePinnedMessageAdd} onClick={handleConfirmReplaceOldestPin}
> >
{i18n('icu:PinMessageDialog__Pin')} {i18n('icu:PinMessageDialog--HasMaxPinnedMessages__Continue')}
</AxoDialog.Action> </AxoAlertDialog.Action>
</AxoDialog.Actions> </AxoAlertDialog.Footer>
</AxoDialog.Footer> </AxoAlertDialog.Content>
</AxoDialog.Content> </AxoAlertDialog.Root>
</AxoDialog.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}>
<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>
</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>
</>
); );
}); });

View File

@@ -170,6 +170,7 @@ import type { PinnedMessageNotificationData } from '../../components/conversatio
import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js'; import { canEditGroupInfo } from '../../util/canEditGroupInfo.preload.js';
import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js'; import { getPinnedMessagesMessageIds } from './pinnedMessages.dom.js';
import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js'; import { isPinnedMessagesSendEnabled } from '../../util/isPinnedMessagesEnabled.dom.js';
import { getPinnedMessagesLimit } from '../../util/pinnedMessages.dom.js';
const { groupBy, isEmpty, isNumber, isObject, map } = lodash; const { groupBy, isEmpty, isNumber, isObject, map } = lodash;
@@ -966,6 +967,9 @@ export const getPropsForMessage = (
expirationStartTimestamp, expirationStartTimestamp,
}), }),
giftBadge: message.giftBadge, giftBadge: message.giftBadge,
hasMaxPinnedMessages: getHasMaxPinnedMessages(
options.pinnedMessagesMessageIds ?? []
),
poll: getPollForMessage(message, { poll: getPollForMessage(message, {
conversationSelector: options.conversationSelector, conversationSelector: options.conversationSelector,
ourConversationId, ourConversationId,
@@ -2410,6 +2414,14 @@ export function canPinMessages(conversation: ConversationType): boolean {
return conversation.type === 'direct' || canEditGroupInfo(conversation); return conversation.type === 'direct' || canEditGroupInfo(conversation);
} }
function getHasMaxPinnedMessages(
pinnedMessagesMessageIds: ReadonlyArray<string>
) {
const pinnedMessagesLimit = getPinnedMessagesLimit();
const pinnedMessagesCount = pinnedMessagesMessageIds.length;
return pinnedMessagesCount >= pinnedMessagesLimit;
}
export function getLastChallengeError( export function getLastChallengeError(
message: Pick<MessageWithUIFieldsType, 'errors'> message: Pick<MessageWithUIFieldsType, 'errors'>
): ShallowChallengeError | undefined { ): ShallowChallengeError | undefined {