Add pin message item to message context menu

This commit is contained in:
Jamie
2025-11-17 14:21:59 -08:00
committed by GitHub
parent 024d467745
commit 6b16d75036
10 changed files with 467 additions and 54 deletions

View File

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

View File

@@ -188,7 +188,7 @@ export namespace AxoDialog {
export const Close: FC<CloseProps> = memo(props => {
return (
<div className={tw('col-[close-slot] text-end')}>
<div className={tw('col-[close-slot] text-end leading-none')}>
<Dialog.Close asChild>
<AxoIconButton.Root
size="sm"

View File

@@ -0,0 +1,34 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { Meta } from '@storybook/react';
import { AxoRadioGroup } from './AxoRadioGroup.dom.js';
export default {
title: 'Axo/AxoRadioGroup',
} satisfies Meta;
export function Default(): JSX.Element {
const [value, setValue] = useState('foo');
return (
<AxoRadioGroup.Root value={value} onValueChange={setValue}>
<AxoRadioGroup.Item value="foo">
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>Foo</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value="bar">
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>Bar</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
<AxoRadioGroup.Item value="baz">
<AxoRadioGroup.Indicator />
<AxoRadioGroup.Label>
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?
</AxoRadioGroup.Label>
</AxoRadioGroup.Item>
</AxoRadioGroup.Root>
);
}

View File

@@ -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
* <AxoRadioGroup.Root>
* <AxoRadioGroup.Item>
* <AxoRadioGroup.Indicator/>
* <AxoRadioGroup.Label>...</AxoRadioGroup.Label>
* </AxoRadioGroup.Item>
* </AxoAlertDialog.Root>
* ```
*/
export namespace AxoRadioGroup {
/**
* Component: <AxoRadioGroup.Root>
* -------------------------------
*/
export type RootProps = Readonly<{
value: string | null;
onValueChange: (value: string) => void;
disabled?: boolean;
children: ReactNode;
}>;
export const Root: FC<RootProps> = memo(props => {
return (
<RadioGroup.Root
value={props.value}
onValueChange={props.onValueChange}
disabled={props.disabled}
className={tw('flex flex-col')}
>
{props.children}
</RadioGroup.Root>
);
});
Root.displayName = `${Namespace}.Root`;
/**
* Component: <AxoRadioGroup.Item>
* -------------------------------
*/
type ItemContextType = Readonly<{
id: string;
value: string;
disabled: boolean;
}>;
const ItemContext = createStrictContext<ItemContextType>(`${Namespace}.Item`);
export type ItemProps = Readonly<{
value: string;
disabled?: boolean;
children: ReactNode;
}>;
export const Item: FC<ItemProps> = memo(props => {
const { value, disabled = false } = props;
const id = useId();
const context = useMemo((): ItemContextType => {
return { id, value, disabled };
}, [id, value, disabled]);
return (
<ItemContext.Provider value={context}>
<label htmlFor={id} className={tw('flex gap-3 py-2.5')}>
{props.children}
</label>
</ItemContext.Provider>
);
});
Item.displayName = `${Namespace}.Item`;
/**
* Component: <AxoRadioGroup.Indicator>
* ------------------------------------
*/
export type IndicatorProps = Readonly<{
// ...
}>;
export const Indicator: FC<IndicatorProps> = memo(() => {
const context = useStrictContext(ItemContext);
return (
<RadioGroup.Item
id={context.id}
value={context.value}
disabled={context.disabled}
className={tw(
'flex size-5 shrink-0 items-center justify-center rounded-full',
'border border-border-primary inset-shadow-on-color',
'data-[state=unchecked]:bg-fill-primary',
'data-[state=unchecked]:pressed:bg-fill-primary-pressed',
'data-[state=checked]:bg-color-fill-primary',
'data-[state=checked]:pressed:bg-color-fill-primary-pressed',
'data-[disabled]:border-border-secondary',
'outline-0 outline-border-focused focused:outline-[2.5px]',
'overflow-hidden',
'forced-colors:data-[state=checked]:bg-[SelectedItem]'
)}
>
<RadioGroup.Indicator asChild>
<span
className={tw(
'size-[9px] rounded-full',
'data-[state=checked]:bg-label-primary-on-color',
'data-[state=checked]:data-[disabled]:bg-label-disabled-on-color',
'forced-colors:data-[state=checked]:bg-[SelectedItemText]'
)}
/>
</RadioGroup.Indicator>
</RadioGroup.Item>
);
});
Indicator.displayName = `${Namespace}.Indicator`;
/**
* Component: <AxoRadioGroup.Indicator>
* ------------------------------------
*/
export type LabelProps = Readonly<{
children: ReactNode;
}>;
export const Label: FC<LabelProps> = memo(props => {
return (
<span className={tw('truncate type-body-large text-label-primary')}>
{props.children}
</span>
);
});
Label.displayName = `${Namespace}.Label`;
}

View File

@@ -73,17 +73,18 @@ export const CallingNotification: React.FC<PropsType> = 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}
>
<div
// @ts-expect-error -- React/TS doesn't know about inert

View File

@@ -4,6 +4,7 @@
import React, { type ReactNode } from 'react';
import type { LocalizerType } from '../../types/I18N.std.js';
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
import { isPinnedMessagesEnabled } from '../../util/isPinnedMessagesEnabled.std.js';
export type ContextMenuTriggerType = {
handleContextClick: (
@@ -17,18 +18,19 @@ type MessageContextMenuProps = Readonly<{
onOpenChange?: (open: boolean) => 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')}
</AxoMenuBuilder.Item>
)}
{isPinnedMessagesEnabled() && onPinMessage && (
<AxoMenuBuilder.Item symbol="pin" onSelect={onPinMessage}>
{i18n('icu:MessageContextMenu__PinMessage')}
</AxoMenuBuilder.Item>
)}
{onMoreInfo && (
<AxoMenuBuilder.Item symbol="info" onSelect={onMoreInfo}>
{i18n('icu:MessageContextMenu__info')}
</AxoMenuBuilder.Item>
)}
<AxoMenuBuilder.Item symbol="trash" onSelect={onDeleteMessage}>
{i18n('icu:MessageContextMenu__deleteMessage')}
</AxoMenuBuilder.Item>
{onDeleteMessage && (
<AxoMenuBuilder.Item symbol="trash" onSelect={onDeleteMessage}>
{i18n('icu:MessageContextMenu__deleteMessage')}
</AxoMenuBuilder.Item>
)}
{onRetryMessageSend && (
<AxoMenuBuilder.Item symbol="send" onSelect={onRetryMessageSend}>
{i18n('icu:retrySend')}

View File

@@ -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<ContextMenuTriggerType | null>(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 (
<Message
{...props}
renderingContext="conversation/TimelineItem"
renderMenu={renderMenu}
renderMessageContextMenu={renderMessageContextMenu}
onToggleSelect={(selected, shift) => {
toggleSelectMessage(conversationId, id, shift, selected);
}}
onReplyToMessage={handleReplyToMessage}
onWrapperKeyDown={handleWrapperKeyDown}
/>
<>
<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}
onPinMessage={() => {
// TODO
setPinMessageDialogOpen(false);
}}
/>
</>
);
}
@@ -471,9 +487,9 @@ type MessageMenuProps = {
triggerId: string;
isWindowWidthNotNarrow: boolean;
menuTriggerRef: Ref<ContextMenuTriggerType>;
onDownload: (() => void) | undefined;
onReplyToMessage: (() => void) | undefined;
onReact: (() => void) | undefined;
onDownload: (() => void) | null;
onReplyToMessage: (() => void) | null;
onReact: (() => void) | null;
renderMessageContextMenu: (
renderer: AxoMenuBuilder.Renderer,
children: ReactNode

View File

@@ -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 (
<PinMessageDialog
i18n={i18n}
open={open}
onOpenChange={setOpen}
messageId="42"
onPinMessage={action('onPinMessage')}
/>
);
}

View File

@@ -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, DurationValue> = {
[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 (
<AxoDialog.Root open={props.open} onOpenChange={onOpenChange}>
<AxoDialog.Content size="sm" escape="cancel-is-noop">
<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={handlePinMessage}>
{i18n('icu:PinMessageDialog__Pin')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
});

View File

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