mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Add alert dialog to confirm replacing oldest pinned message
Co-authored-by: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +68,50 @@ 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}>
|
<>
|
||||||
|
<AxoAlertDialog.Root
|
||||||
|
open={props.open && showConfirmReplaceOldestPin}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<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
|
<AxoDialog.Content
|
||||||
size="sm"
|
size="sm"
|
||||||
escape="cancel-is-noop"
|
escape="cancel-is-noop"
|
||||||
@@ -115,5 +169,6 @@ export const PinMessageDialog = memo(function PinMessageDialog(
|
|||||||
</AxoDialog.Footer>
|
</AxoDialog.Footer>
|
||||||
</AxoDialog.Content>
|
</AxoDialog.Content>
|
||||||
</AxoDialog.Root>
|
</AxoDialog.Root>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user