From 6b16d750367e59cf36bab16fafdbdf105aa22ec0 Mon Sep 17 00:00:00 2001 From: Jamie <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 17 Nov 2025 14:21:59 -0800 Subject: [PATCH] Add pin message item to message context menu --- _locales/en/messages.json | 36 ++++ ts/axo/AxoDialog.dom.tsx | 2 +- ts/axo/AxoRadioGroup.dom.stories.tsx | 34 ++++ ts/axo/AxoRadioGroup.dom.tsx | 155 ++++++++++++++++++ .../conversation/CallingNotification.dom.tsx | 23 +-- .../conversation/MessageContextMenu.dom.tsx | 40 +++-- .../conversation/TimelineMessage.dom.tsx | 70 +++++--- .../PinMessageDialog.dom.stories.tsx | 25 +++ .../pinned-messages/PinMessageDialog.dom.tsx | 112 +++++++++++++ ts/util/isPinnedMessagesEnabled.std.ts | 24 +++ 10 files changed, 467 insertions(+), 54 deletions(-) create mode 100644 ts/axo/AxoRadioGroup.dom.stories.tsx create mode 100644 ts/axo/AxoRadioGroup.dom.tsx create mode 100644 ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx create mode 100644 ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx create mode 100644 ts/util/isPinnedMessagesEnabled.std.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index dd1fe13bf0..352b602380 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1462,6 +1462,10 @@ "messageformat": "Info", "description": "Shown on the drop-down menu for an individual message, takes you to message detail screen" }, + "icu:MessageContextMenu__PinMessage": { + "messageformat": "Pin message", + "description": "Shown on the drop-down menu for an individual message, pins the current message" + }, "icu:Poll__end-poll": { "messageformat": "End poll", "description": "Label for button/menu item to end a poll. Shown in the poll votes modal and in the message context menu" @@ -1610,6 +1614,38 @@ "messageformat": "Go to message", "description": "Conversation > Chat Event > Pinned Message > Button to scroll to the pinned message" }, + "icu:PinMessageDialog__Title": { + "messageformat": "Pin message for...", + "description": "Message > Context Menu > Pin Message > Dialog > Title" + }, + "icu:PinMessageDialog__Close": { + "messageformat": "Close", + "description": "Message > Context Menu > Pin Message > Dialog > Close Button (Accessibility Label)" + }, + "icu:PinMessageDialog__Option--TIME_24_HOURS": { + "messageformat": "24 hours", + "description": "Message > Context Menu > Pin Message > Dialog > Duration Option: 24 hours" + }, + "icu:PinMessageDialog__Option--TIME_7_DAYS": { + "messageformat": "7 days", + "description": "Message > Context Menu > Pin Message > Dialog > Duration Option: 7 days" + }, + "icu:PinMessageDialog__Option--TIME_30_DAYS": { + "messageformat": "30 days", + "description": "Message > Context Menu > Pin Message > Dialog > Duration Option: 30 days" + }, + "icu:PinMessageDialog__Option--FOREVER": { + "messageformat": "Forever", + "description": "Message > Context Menu > Pin Message > Dialog > Duration Option: Forever" + }, + "icu:PinMessageDialog__Cancel": { + "messageformat": "Cancel", + "description": "Message > Context Menu > Pin Message > Dialog > Cancel Button" + }, + "icu:PinMessageDialog__Pin": { + "messageformat": "Pin", + "description": "Message > Context Menu > Pin Message > Dialog > Pin Button" + }, "icu:sessionEnded": { "messageformat": "Secure session reset", "description": "This is a past tense, informational message. In other words, your secure session has been reset." diff --git a/ts/axo/AxoDialog.dom.tsx b/ts/axo/AxoDialog.dom.tsx index 9c6442b604..1e3f20a481 100644 --- a/ts/axo/AxoDialog.dom.tsx +++ b/ts/axo/AxoDialog.dom.tsx @@ -188,7 +188,7 @@ export namespace AxoDialog { export const Close: FC = memo(props => { return ( -
+
+ + + Foo + + + + Bar + + + + + Lorem ipsum dolor, sit amet consectetur adipisicing elit. Veniam + accusantium a aperiam quas perferendis error velit ipsam animi natus + deserunt iste voluptatem asperiores voluptates rem odio necessitatibus + delectus, optio officia? + + + + ); +} diff --git a/ts/axo/AxoRadioGroup.dom.tsx b/ts/axo/AxoRadioGroup.dom.tsx new file mode 100644 index 0000000000..08d207f1e4 --- /dev/null +++ b/ts/axo/AxoRadioGroup.dom.tsx @@ -0,0 +1,155 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { RadioGroup } from 'radix-ui'; +import type { FC, ReactNode } from 'react'; +import React, { memo, useId, useMemo } from 'react'; +import { tw } from './tw.dom.js'; +import { + createStrictContext, + useStrictContext, +} from './_internal/StrictContext.dom.js'; + +export const Namespace = 'AxoRadioGroup'; + +/** + * @example Anatomy + * ```tsx + * + * + * + * ... + * + * + * ``` + */ +export namespace AxoRadioGroup { + /** + * Component: + * ------------------------------- + */ + + export type RootProps = Readonly<{ + value: string | null; + onValueChange: (value: string) => void; + disabled?: boolean; + children: ReactNode; + }>; + + export const Root: FC = memo(props => { + return ( + + {props.children} + + ); + }); + + Root.displayName = `${Namespace}.Root`; + + /** + * Component: + * ------------------------------- + */ + + type ItemContextType = Readonly<{ + id: string; + value: string; + disabled: boolean; + }>; + + const ItemContext = createStrictContext(`${Namespace}.Item`); + + export type ItemProps = Readonly<{ + value: string; + disabled?: boolean; + children: ReactNode; + }>; + + export const Item: FC = memo(props => { + const { value, disabled = false } = props; + const id = useId(); + + const context = useMemo((): ItemContextType => { + return { id, value, disabled }; + }, [id, value, disabled]); + + return ( + + + + ); + }); + + Item.displayName = `${Namespace}.Item`; + + /** + * Component: + * ------------------------------------ + */ + + export type IndicatorProps = Readonly<{ + // ... + }>; + + export const Indicator: FC = memo(() => { + const context = useStrictContext(ItemContext); + return ( + + + + + + ); + }); + + Indicator.displayName = `${Namespace}.Indicator`; + + /** + * Component: + * ------------------------------------ + */ + + export type LabelProps = Readonly<{ + children: ReactNode; + }>; + + export const Label: FC = memo(props => { + return ( + + {props.children} + + ); + }); + + Label.displayName = `${Namespace}.Label`; +} diff --git a/ts/components/conversation/CallingNotification.dom.tsx b/ts/components/conversation/CallingNotification.dom.tsx index 242780e7ba..ff5260f589 100644 --- a/ts/components/conversation/CallingNotification.dom.tsx +++ b/ts/components/conversation/CallingNotification.dom.tsx @@ -73,17 +73,18 @@ export const CallingNotification: React.FC = React.memo( }); }} shouldShowAdditional={false} - onDownload={undefined} - onEdit={undefined} - onReplyToMessage={undefined} - onReact={undefined} - onEndPoll={undefined} - onRetryMessageSend={undefined} - onRetryDeleteForEveryone={undefined} - onCopy={undefined} - onSelect={undefined} - onForward={undefined} - onMoreInfo={undefined} + onDownload={null} + onEdit={null} + onReplyToMessage={null} + onReact={null} + onEndPoll={null} + onRetryMessageSend={null} + onRetryDeleteForEveryone={null} + onCopy={null} + onSelect={null} + onForward={null} + onMoreInfo={null} + onPinMessage={null} >
void; disabled?: boolean; shouldShowAdditional: boolean; - onDownload: (() => void) | undefined; - onEdit: (() => void) | undefined; - onReplyToMessage: (() => void) | undefined; - onReact: (() => void) | undefined; - onEndPoll: (() => void) | undefined; - onRetryMessageSend: (() => void) | undefined; - onRetryDeleteForEveryone: (() => void) | undefined; - onCopy: (() => void) | undefined; - onForward: (() => void) | undefined; - onDeleteMessage: () => void; - onMoreInfo: (() => void) | undefined; - onSelect: (() => void) | undefined; + onDownload: (() => void) | null; + onEdit: (() => void) | null; + onReplyToMessage: (() => void) | null; + onReact: (() => void) | null; + onEndPoll: (() => void) | null; + onRetryMessageSend: (() => void) | null; + onRetryDeleteForEveryone: (() => void) | null; + onCopy: (() => void) | null; + onForward: (() => void) | null; + onDeleteMessage: (() => void) | null; + onPinMessage: (() => void) | null; + onMoreInfo: (() => void) | null; + onSelect: (() => void) | null; children: ReactNode; }>; @@ -50,6 +52,7 @@ export function MessageContextMenu({ onRetryDeleteForEveryone, onForward, onDeleteMessage, + onPinMessage, children, }: MessageContextMenuProps): JSX.Element { return ( @@ -102,14 +105,21 @@ export function MessageContextMenu({ {i18n('icu:copy')} )} + {isPinnedMessagesEnabled() && onPinMessage && ( + + {i18n('icu:MessageContextMenu__PinMessage')} + + )} {onMoreInfo && ( {i18n('icu:MessageContextMenu__info')} )} - - {i18n('icu:MessageContextMenu__deleteMessage')} - + {onDeleteMessage && ( + + {i18n('icu:MessageContextMenu__deleteMessage')} + + )} {onRetryMessageSend && ( {i18n('icu:retrySend')} diff --git a/ts/components/conversation/TimelineMessage.dom.tsx b/ts/components/conversation/TimelineMessage.dom.tsx index f88062ae3d..21e8bf00cc 100644 --- a/ts/components/conversation/TimelineMessage.dom.tsx +++ b/ts/components/conversation/TimelineMessage.dom.tsx @@ -48,6 +48,7 @@ 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'; const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu; @@ -149,6 +150,7 @@ export function TimelineMessage(props: Props): JSX.Element { HTMLDivElement | undefined >(undefined); const menuTriggerRef = useRef(null); + const [pinMessageDialogOpen, setPinMessageDialogOpen] = useState(false); const isWindowWidthNotNarrow = containerWidthBreakpoint !== WidthBreakpoint.Narrow; @@ -270,7 +272,7 @@ export function TimelineMessage(props: Props): JSX.Element { const shouldShowAdditional = doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow; - const handleDownload = canDownload ? openGenericAttachment : undefined; + const handleDownload = canDownload ? openGenericAttachment : null; const handleReplyToMessage = useCallback(() => { if (!canReply) { @@ -285,6 +287,10 @@ export function TimelineMessage(props: Props): JSX.Element { } }, [canReact, toggleReactionPicker]); + const handleOpenPinMessageDialog = useCallback(() => { + setPinMessageDialogOpen(true); + }, []); + const toggleReactionPickerKeyboard = useToggleReactionPicker( handleReact || noop ); @@ -316,20 +322,16 @@ export function TimelineMessage(props: Props): JSX.Element { shouldShowAdditional={shouldShowAdditional} onDownload={handleDownload} onEdit={ - canEditMessage - ? () => setMessageToEdit(conversationId, id) - : undefined + canEditMessage ? () => setMessageToEdit(conversationId, id) : null } onReplyToMessage={handleReplyToMessage} onReact={handleReact} - onEndPoll={canEndPoll ? () => endPoll(id) : undefined} - onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined} + onEndPoll={canEndPoll ? () => endPoll(id) : null} + onRetryMessageSend={canRetry ? () => retryMessageSend(id) : null} onRetryDeleteForEveryone={ - canRetryDeleteForEveryone - ? () => retryDeleteForEveryone(id) - : undefined + canRetryDeleteForEveryone ? () => retryDeleteForEveryone(id) : null } - onCopy={canCopy ? () => copyMessageText(id) : undefined} + onCopy={canCopy ? () => copyMessageText(id) : null} onSelect={() => toggleSelectMessage(conversationId, id, false, true)} onForward={ canForward @@ -338,7 +340,7 @@ export function TimelineMessage(props: Props): JSX.Element { type: ForwardMessagesModalType.Forward, messageIds: [id], }) - : undefined + : null } onDeleteMessage={() => { toggleDeleteMessagesModal({ @@ -346,6 +348,7 @@ export function TimelineMessage(props: Props): JSX.Element { messageIds: [id], }); }} + onPinMessage={handleOpenPinMessageDialog} onMoreInfo={() => pushPanelForConversation({ type: PanelType.MessageDetails, @@ -368,6 +371,7 @@ export function TimelineMessage(props: Props): JSX.Element { copyMessageText, handleDownload, handleReact, + handleOpenPinMessageDialog, endPoll, handleReplyToMessage, i18n, @@ -393,8 +397,8 @@ export function TimelineMessage(props: Props): JSX.Element { direction={direction} menuTriggerRef={menuTriggerRef} onDownload={handleDownload} - onReplyToMessage={canReply ? handleReplyToMessage : undefined} - onReact={canReact ? handleReact : undefined} + onReplyToMessage={canReply ? handleReplyToMessage : null} + onReact={canReact ? handleReact : null} renderMessageContextMenu={renderMessageContextMenu} /> {reactionPickerRoot && @@ -452,17 +456,29 @@ export function TimelineMessage(props: Props): JSX.Element { const handleWrapperKeyDown = useAxoContextMenuOutsideKeyboardTrigger(); return ( - { - toggleSelectMessage(conversationId, id, shift, selected); - }} - onReplyToMessage={handleReplyToMessage} - onWrapperKeyDown={handleWrapperKeyDown} - /> + <> + { + toggleSelectMessage(conversationId, id, shift, selected); + }} + onReplyToMessage={handleReplyToMessage} + onWrapperKeyDown={handleWrapperKeyDown} + /> + { + // TODO + setPinMessageDialogOpen(false); + }} + /> + ); } @@ -471,9 +487,9 @@ type MessageMenuProps = { triggerId: string; isWindowWidthNotNarrow: boolean; menuTriggerRef: Ref; - onDownload: (() => void) | undefined; - onReplyToMessage: (() => void) | undefined; - onReact: (() => void) | undefined; + onDownload: (() => void) | null; + onReplyToMessage: (() => void) | null; + onReact: (() => void) | null; renderMessageContextMenu: ( renderer: AxoMenuBuilder.Renderer, children: ReactNode diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx new file mode 100644 index 0000000000..cd43970ea4 --- /dev/null +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.stories.tsx @@ -0,0 +1,25 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import React, { useState } from 'react'; +import type { Meta } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { PinMessageDialog } from './PinMessageDialog.dom.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/PinnedMessages/PinMessageDialog', +} satisfies Meta; + +export function Default(): JSX.Element { + const [open, setOpen] = useState(true); + return ( + + ); +} diff --git a/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx new file mode 100644 index 0000000000..e0537c01b6 --- /dev/null +++ b/ts/components/conversation/pinned-messages/PinMessageDialog.dom.tsx @@ -0,0 +1,112 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +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'; + +export enum DurationOption { + TIME_24_HOURS = 'TIME_24_HOURS', + TIME_7_DAYS = 'TIME_7_DAYS', + TIME_30_DAYS = 'TIME_30_DAYS', + FOREVER = 'FOREVER', +} +export type DurationValue = + | { seconds: number; forever?: never } + | { seconds?: never; forever: true }; + +const DURATION_OPTIONS: Record = { + [DurationOption.TIME_24_HOURS]: { seconds: DurationInSeconds.fromHours(24) }, + [DurationOption.TIME_7_DAYS]: { seconds: DurationInSeconds.fromDays(7) }, + [DurationOption.TIME_30_DAYS]: { seconds: DurationInSeconds.fromDays(30) }, + [DurationOption.FOREVER]: { forever: true }, +}; + +function isValidDurationOption(value: string): value is DurationOption { + return Object.hasOwn(DURATION_OPTIONS, value); +} + +export type PinMessageDialogProps = Readonly<{ + i18n: LocalizerType; + open: boolean; + onOpenChange: (open: boolean) => void; + messageId: string; + onPinMessage: (messageId: string, duration: DurationValue) => void; +}>; + +export const PinMessageDialog = memo(function PinMessageDialog( + props: PinMessageDialogProps +) { + const { i18n, messageId, onPinMessage, onOpenChange } = props; + const [duration, setDuration] = useState(DurationOption.TIME_7_DAYS); + + const handleValueChange = useCallback((value: string) => { + strictAssert(isValidDurationOption(value), `Invalid option: ${value}`); + setDuration(value); + }, []); + + const handleCancel = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + + const handlePinMessage = useCallback(() => { + const durationValue = DURATION_OPTIONS[duration]; + onPinMessage(messageId, durationValue); + }, [duration, onPinMessage, messageId]); + + 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__Pin')} + + + + + + ); +}); diff --git a/ts/util/isPinnedMessagesEnabled.std.ts b/ts/util/isPinnedMessagesEnabled.std.ts new file mode 100644 index 0000000000..407daec866 --- /dev/null +++ b/ts/util/isPinnedMessagesEnabled.std.ts @@ -0,0 +1,24 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { + Environment, + getEnvironment, + isMockEnvironment, +} from '../environment.std.js'; + +export function isPinnedMessagesEnabled(): boolean { + const env = getEnvironment(); + + if ( + env === Environment.Development || + env === Environment.Test || + isMockEnvironment() + ) { + return true; + } + + return false; +}