mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 04:09:49 +00:00
Add pin message item to message context menu
This commit is contained in:
@@ -1462,6 +1462,10 @@
|
|||||||
"messageformat": "Info",
|
"messageformat": "Info",
|
||||||
"description": "Shown on the drop-down menu for an individual message, takes you to message detail screen"
|
"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": {
|
"icu:Poll__end-poll": {
|
||||||
"messageformat": "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"
|
"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",
|
"messageformat": "Go to message",
|
||||||
"description": "Conversation > Chat Event > Pinned Message > Button to scroll to the pinned 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": {
|
"icu:sessionEnded": {
|
||||||
"messageformat": "Secure session reset",
|
"messageformat": "Secure session reset",
|
||||||
"description": "This is a past tense, informational message. In other words, your secure session has been 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 => {
|
export const Close: FC<CloseProps> = memo(props => {
|
||||||
return (
|
return (
|
||||||
<div className={tw('col-[close-slot] text-end')}>
|
<div className={tw('col-[close-slot] text-end leading-none')}>
|
||||||
<Dialog.Close asChild>
|
<Dialog.Close asChild>
|
||||||
<AxoIconButton.Root
|
<AxoIconButton.Root
|
||||||
size="sm"
|
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}
|
shouldShowAdditional={false}
|
||||||
onDownload={undefined}
|
onDownload={null}
|
||||||
onEdit={undefined}
|
onEdit={null}
|
||||||
onReplyToMessage={undefined}
|
onReplyToMessage={null}
|
||||||
onReact={undefined}
|
onReact={null}
|
||||||
onEndPoll={undefined}
|
onEndPoll={null}
|
||||||
onRetryMessageSend={undefined}
|
onRetryMessageSend={null}
|
||||||
onRetryDeleteForEveryone={undefined}
|
onRetryDeleteForEveryone={null}
|
||||||
onCopy={undefined}
|
onCopy={null}
|
||||||
onSelect={undefined}
|
onSelect={null}
|
||||||
onForward={undefined}
|
onForward={null}
|
||||||
onMoreInfo={undefined}
|
onMoreInfo={null}
|
||||||
|
onPinMessage={null}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
// @ts-expect-error -- React/TS doesn't know about inert
|
// @ts-expect-error -- React/TS doesn't know about inert
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import React, { type ReactNode } from 'react';
|
import React, { type ReactNode } from 'react';
|
||||||
import type { LocalizerType } from '../../types/I18N.std.js';
|
import type { LocalizerType } from '../../types/I18N.std.js';
|
||||||
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
import { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
||||||
|
import { isPinnedMessagesEnabled } from '../../util/isPinnedMessagesEnabled.std.js';
|
||||||
|
|
||||||
export type ContextMenuTriggerType = {
|
export type ContextMenuTriggerType = {
|
||||||
handleContextClick: (
|
handleContextClick: (
|
||||||
@@ -17,18 +18,19 @@ type MessageContextMenuProps = Readonly<{
|
|||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
shouldShowAdditional: boolean;
|
shouldShowAdditional: boolean;
|
||||||
onDownload: (() => void) | undefined;
|
onDownload: (() => void) | null;
|
||||||
onEdit: (() => void) | undefined;
|
onEdit: (() => void) | null;
|
||||||
onReplyToMessage: (() => void) | undefined;
|
onReplyToMessage: (() => void) | null;
|
||||||
onReact: (() => void) | undefined;
|
onReact: (() => void) | null;
|
||||||
onEndPoll: (() => void) | undefined;
|
onEndPoll: (() => void) | null;
|
||||||
onRetryMessageSend: (() => void) | undefined;
|
onRetryMessageSend: (() => void) | null;
|
||||||
onRetryDeleteForEveryone: (() => void) | undefined;
|
onRetryDeleteForEveryone: (() => void) | null;
|
||||||
onCopy: (() => void) | undefined;
|
onCopy: (() => void) | null;
|
||||||
onForward: (() => void) | undefined;
|
onForward: (() => void) | null;
|
||||||
onDeleteMessage: () => void;
|
onDeleteMessage: (() => void) | null;
|
||||||
onMoreInfo: (() => void) | undefined;
|
onPinMessage: (() => void) | null;
|
||||||
onSelect: (() => void) | undefined;
|
onMoreInfo: (() => void) | null;
|
||||||
|
onSelect: (() => void) | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -50,6 +52,7 @@ export function MessageContextMenu({
|
|||||||
onRetryDeleteForEveryone,
|
onRetryDeleteForEveryone,
|
||||||
onForward,
|
onForward,
|
||||||
onDeleteMessage,
|
onDeleteMessage,
|
||||||
|
onPinMessage,
|
||||||
children,
|
children,
|
||||||
}: MessageContextMenuProps): JSX.Element {
|
}: MessageContextMenuProps): JSX.Element {
|
||||||
return (
|
return (
|
||||||
@@ -102,14 +105,21 @@ export function MessageContextMenu({
|
|||||||
{i18n('icu:copy')}
|
{i18n('icu:copy')}
|
||||||
</AxoMenuBuilder.Item>
|
</AxoMenuBuilder.Item>
|
||||||
)}
|
)}
|
||||||
|
{isPinnedMessagesEnabled() && onPinMessage && (
|
||||||
|
<AxoMenuBuilder.Item symbol="pin" onSelect={onPinMessage}>
|
||||||
|
{i18n('icu:MessageContextMenu__PinMessage')}
|
||||||
|
</AxoMenuBuilder.Item>
|
||||||
|
)}
|
||||||
{onMoreInfo && (
|
{onMoreInfo && (
|
||||||
<AxoMenuBuilder.Item symbol="info" onSelect={onMoreInfo}>
|
<AxoMenuBuilder.Item symbol="info" onSelect={onMoreInfo}>
|
||||||
{i18n('icu:MessageContextMenu__info')}
|
{i18n('icu:MessageContextMenu__info')}
|
||||||
</AxoMenuBuilder.Item>
|
</AxoMenuBuilder.Item>
|
||||||
)}
|
)}
|
||||||
|
{onDeleteMessage && (
|
||||||
<AxoMenuBuilder.Item symbol="trash" onSelect={onDeleteMessage}>
|
<AxoMenuBuilder.Item symbol="trash" onSelect={onDeleteMessage}>
|
||||||
{i18n('icu:MessageContextMenu__deleteMessage')}
|
{i18n('icu:MessageContextMenu__deleteMessage')}
|
||||||
</AxoMenuBuilder.Item>
|
</AxoMenuBuilder.Item>
|
||||||
|
)}
|
||||||
{onRetryMessageSend && (
|
{onRetryMessageSend && (
|
||||||
<AxoMenuBuilder.Item symbol="send" onSelect={onRetryMessageSend}>
|
<AxoMenuBuilder.Item symbol="send" onSelect={onRetryMessageSend}>
|
||||||
{i18n('icu:retrySend')}
|
{i18n('icu:retrySend')}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { useGroupedAndOrderedReactions } from '../../util/groupAndOrderReactions
|
|||||||
import { isNotNil } from '../../util/isNotNil.std.js';
|
import { isNotNil } from '../../util/isNotNil.std.js';
|
||||||
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
import type { AxoMenuBuilder } from '../../axo/AxoMenuBuilder.dom.js';
|
||||||
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
|
import { AxoContextMenu } from '../../axo/AxoContextMenu.dom.js';
|
||||||
|
import { PinMessageDialog } from './pinned-messages/PinMessageDialog.dom.js';
|
||||||
|
|
||||||
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
|
const { useAxoContextMenuOutsideKeyboardTrigger } = AxoContextMenu;
|
||||||
|
|
||||||
@@ -149,6 +150,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
HTMLDivElement | undefined
|
HTMLDivElement | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
const menuTriggerRef = useRef<ContextMenuTriggerType | null>(null);
|
const menuTriggerRef = useRef<ContextMenuTriggerType | null>(null);
|
||||||
|
const [pinMessageDialogOpen, setPinMessageDialogOpen] = useState(false);
|
||||||
|
|
||||||
const isWindowWidthNotNarrow =
|
const isWindowWidthNotNarrow =
|
||||||
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
|
containerWidthBreakpoint !== WidthBreakpoint.Narrow;
|
||||||
@@ -270,7 +272,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
const shouldShowAdditional =
|
const shouldShowAdditional =
|
||||||
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
|
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
|
||||||
|
|
||||||
const handleDownload = canDownload ? openGenericAttachment : undefined;
|
const handleDownload = canDownload ? openGenericAttachment : null;
|
||||||
|
|
||||||
const handleReplyToMessage = useCallback(() => {
|
const handleReplyToMessage = useCallback(() => {
|
||||||
if (!canReply) {
|
if (!canReply) {
|
||||||
@@ -285,6 +287,10 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [canReact, toggleReactionPicker]);
|
}, [canReact, toggleReactionPicker]);
|
||||||
|
|
||||||
|
const handleOpenPinMessageDialog = useCallback(() => {
|
||||||
|
setPinMessageDialogOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleReactionPickerKeyboard = useToggleReactionPicker(
|
const toggleReactionPickerKeyboard = useToggleReactionPicker(
|
||||||
handleReact || noop
|
handleReact || noop
|
||||||
);
|
);
|
||||||
@@ -316,20 +322,16 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
shouldShowAdditional={shouldShowAdditional}
|
shouldShowAdditional={shouldShowAdditional}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onEdit={
|
onEdit={
|
||||||
canEditMessage
|
canEditMessage ? () => setMessageToEdit(conversationId, id) : null
|
||||||
? () => setMessageToEdit(conversationId, id)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
onReplyToMessage={handleReplyToMessage}
|
onReplyToMessage={handleReplyToMessage}
|
||||||
onReact={handleReact}
|
onReact={handleReact}
|
||||||
onEndPoll={canEndPoll ? () => endPoll(id) : undefined}
|
onEndPoll={canEndPoll ? () => endPoll(id) : null}
|
||||||
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : undefined}
|
onRetryMessageSend={canRetry ? () => retryMessageSend(id) : null}
|
||||||
onRetryDeleteForEveryone={
|
onRetryDeleteForEveryone={
|
||||||
canRetryDeleteForEveryone
|
canRetryDeleteForEveryone ? () => retryDeleteForEveryone(id) : null
|
||||||
? () => retryDeleteForEveryone(id)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
onCopy={canCopy ? () => copyMessageText(id) : undefined}
|
onCopy={canCopy ? () => copyMessageText(id) : null}
|
||||||
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
|
onSelect={() => toggleSelectMessage(conversationId, id, false, true)}
|
||||||
onForward={
|
onForward={
|
||||||
canForward
|
canForward
|
||||||
@@ -338,7 +340,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
type: ForwardMessagesModalType.Forward,
|
type: ForwardMessagesModalType.Forward,
|
||||||
messageIds: [id],
|
messageIds: [id],
|
||||||
})
|
})
|
||||||
: undefined
|
: null
|
||||||
}
|
}
|
||||||
onDeleteMessage={() => {
|
onDeleteMessage={() => {
|
||||||
toggleDeleteMessagesModal({
|
toggleDeleteMessagesModal({
|
||||||
@@ -346,6 +348,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
messageIds: [id],
|
messageIds: [id],
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
onPinMessage={handleOpenPinMessageDialog}
|
||||||
onMoreInfo={() =>
|
onMoreInfo={() =>
|
||||||
pushPanelForConversation({
|
pushPanelForConversation({
|
||||||
type: PanelType.MessageDetails,
|
type: PanelType.MessageDetails,
|
||||||
@@ -368,6 +371,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
copyMessageText,
|
copyMessageText,
|
||||||
handleDownload,
|
handleDownload,
|
||||||
handleReact,
|
handleReact,
|
||||||
|
handleOpenPinMessageDialog,
|
||||||
endPoll,
|
endPoll,
|
||||||
handleReplyToMessage,
|
handleReplyToMessage,
|
||||||
i18n,
|
i18n,
|
||||||
@@ -393,8 +397,8 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
direction={direction}
|
direction={direction}
|
||||||
menuTriggerRef={menuTriggerRef}
|
menuTriggerRef={menuTriggerRef}
|
||||||
onDownload={handleDownload}
|
onDownload={handleDownload}
|
||||||
onReplyToMessage={canReply ? handleReplyToMessage : undefined}
|
onReplyToMessage={canReply ? handleReplyToMessage : null}
|
||||||
onReact={canReact ? handleReact : undefined}
|
onReact={canReact ? handleReact : null}
|
||||||
renderMessageContextMenu={renderMessageContextMenu}
|
renderMessageContextMenu={renderMessageContextMenu}
|
||||||
/>
|
/>
|
||||||
{reactionPickerRoot &&
|
{reactionPickerRoot &&
|
||||||
@@ -452,6 +456,7 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
const handleWrapperKeyDown = useAxoContextMenuOutsideKeyboardTrigger();
|
const handleWrapperKeyDown = useAxoContextMenuOutsideKeyboardTrigger();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Message
|
<Message
|
||||||
{...props}
|
{...props}
|
||||||
renderingContext="conversation/TimelineItem"
|
renderingContext="conversation/TimelineItem"
|
||||||
@@ -463,6 +468,17 @@ export function TimelineMessage(props: Props): JSX.Element {
|
|||||||
onReplyToMessage={handleReplyToMessage}
|
onReplyToMessage={handleReplyToMessage}
|
||||||
onWrapperKeyDown={handleWrapperKeyDown}
|
onWrapperKeyDown={handleWrapperKeyDown}
|
||||||
/>
|
/>
|
||||||
|
<PinMessageDialog
|
||||||
|
i18n={i18n}
|
||||||
|
messageId={id}
|
||||||
|
open={pinMessageDialogOpen}
|
||||||
|
onOpenChange={setPinMessageDialogOpen}
|
||||||
|
onPinMessage={() => {
|
||||||
|
// TODO
|
||||||
|
setPinMessageDialogOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,9 +487,9 @@ type MessageMenuProps = {
|
|||||||
triggerId: string;
|
triggerId: string;
|
||||||
isWindowWidthNotNarrow: boolean;
|
isWindowWidthNotNarrow: boolean;
|
||||||
menuTriggerRef: Ref<ContextMenuTriggerType>;
|
menuTriggerRef: Ref<ContextMenuTriggerType>;
|
||||||
onDownload: (() => void) | undefined;
|
onDownload: (() => void) | null;
|
||||||
onReplyToMessage: (() => void) | undefined;
|
onReplyToMessage: (() => void) | null;
|
||||||
onReact: (() => void) | undefined;
|
onReact: (() => void) | null;
|
||||||
renderMessageContextMenu: (
|
renderMessageContextMenu: (
|
||||||
renderer: AxoMenuBuilder.Renderer,
|
renderer: AxoMenuBuilder.Renderer,
|
||||||
children: ReactNode
|
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