mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-19 17:58:48 +00:00
Add pin message item to message context menu
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
34
ts/axo/AxoRadioGroup.dom.stories.tsx
Normal file
34
ts/axo/AxoRadioGroup.dom.stories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
ts/axo/AxoRadioGroup.dom.tsx
Normal file
155
ts/axo/AxoRadioGroup.dom.tsx
Normal 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`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
24
ts/util/isPinnedMessagesEnabled.std.ts
Normal file
24
ts/util/isPinnedMessagesEnabled.std.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user