All Media Context Menu

Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
automated-signal
2026-02-13 17:23:20 -06:00
committed by GitHub
parent 64e00864ca
commit 821d93c3fd
43 changed files with 876 additions and 359 deletions
+44
View File
@@ -6652,6 +6652,26 @@
"messageformat": "You can only select up to {count, plural, one {# message} other {# messages}} to delete for everyone",
"description": "delete selected messages > confirmation modal > deleted for everyone (disabled) > toast > too many messages to 'delete for everyone'"
},
"icu:DeleteAttachmentModal__Title": {
"messageformat": "Delete item?",
"description": "Title of confirmation modal when deleting an attachment from media gallery"
},
"icu:DeleteAttachmentModal__Body": {
"messageformat": "This will permanently delete the item. Any message text associated with this item will also be deleted.",
"description": "Body of confirmation modal when deleting an attachment from media gallery"
},
"icu:DeleteAttachmentModal__Close": {
"messageformat": "Close",
"description": "Accessibility label of close button in confirmation modal when deleting an attachment from media gallery"
},
"icu:DeleteAttachmentModal__Delete": {
"messageformat": "Delete",
"description": "Text of a button in confirmation modal when deleting an attachment from media gallery"
},
"icu:DeleteAttachmentModal__Cancel": {
"messageformat": "Cancel",
"description": "Text of a button in confirmation modal when deleting an attachment from media gallery"
},
"icu:SelectModeActions__toast--TooManyMessagesToForward": {
"messageformat": "You can only forward up to 30 messages",
"description": "conversation > in select mode > composition area actions > forward selected messages (disabled) > toast message when too many messages"
@@ -7148,6 +7168,30 @@
"messageformat": "Files that you send and receive will appear here",
"description": "Description of the empty state view of media gallery for files tab"
},
"icu:MediaGallery__ContextMenu__ViewInChat": {
"messageformat": "View in chat",
"description": "Title of context menu action for media gallery item for showing the attachment in the chat"
},
"icu:MediaGallery__ContextMenu__Forward": {
"messageformat": "Forward",
"description": "Title of context menu action for media gallery item for forwarding attachment to a different user"
},
"icu:MediaGallery__ContextMenu__Save": {
"messageformat": "Save",
"description": "Title of context menu action for media gallery item for saving the attachment to disk"
},
"icu:MediaGallery__ContextMenu__Send": {
"messageformat": "Send message",
"description": "Title of context menu action for media gallery item for sending a message to the contact"
},
"icu:MediaGallery__ContextMenu__Copy": {
"messageformat": "Copy link",
"description": "Title of context menu action for media gallery item for copying link url into clipboard"
},
"icu:MediaGallery__ContextMenu__Delete": {
"messageformat": "Delete",
"description": "Title of context menu action for media gallery item for deleting the attachment on disk"
},
"icu:MediaQualitySelector--button": {
"messageformat": "Select media quality",
"description": "aria-label for the media quality selector button"
@@ -1,37 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
.LocalDeleteWarningModal__width-container {
max-width: 440px;
}
.LocalDeleteWarningModal__image {
margin-block: 18px;
text-align: center;
}
.LocalDeleteWarningModal__header {
@include mixins.font-title-2;
margin-block: 18px;
margin-inline: 8px;
text-align: center;
}
.LocalDeleteWarningModal__description {
margin-block: 12px;
margin-inline: 8px;
text-align: center;
}
.LocalDeleteWarningModal__button {
display: flex;
justify-content: center;
margin-top: 49px;
button {
padding-inline: 26px;
}
}
-1
View File
@@ -130,7 +130,6 @@
@use 'components/LeftPaneSearchInput.scss';
@use 'components/Lightbox.scss';
@use 'components/ListTile.scss';
@use 'components/LocalDeleteWarningModal.scss';
@use 'components/LowDiskSpaceBackupImportModal.scss';
@use 'components/MediaEditor.scss';
@use 'components/MediaPermissionsModal.scss';
+81 -68
View File
@@ -1,8 +1,15 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { ReactNode, MouseEvent, FC } from 'react';
import { useLayoutEffect } from '@react-aria/utils';
import React, {
memo,
useCallback,
useEffect,
useRef,
useState,
forwardRef,
} from 'react';
import type { ReactNode, MouseEvent, FC, ForwardedRef } from 'react';
import { useLayoutEffect, mergeRefs } from '@react-aria/utils';
import { computeAccessibleName } from 'dom-accessibility-api';
import { tw } from './tw.dom.js';
import { assert } from './_internal/assert.std.js';
@@ -184,6 +191,8 @@ export namespace AriaClickable {
*/
'aria-labelledby'?: string;
onClick: (event: MouseEvent) => void;
// Note: Technically we forward all props for Radix, but we restrict the
// props that the type accepts
}>;
const hiddenTriggerDisplayName = `${Namespace}.HiddenTrigger`;
@@ -197,78 +206,82 @@ export namespace AriaClickable {
* - This should be inserted in the expected focus order, which is likely
* before any <AriaClickable.SubWidget>.
*/
export const HiddenTrigger: FC<HiddenTriggerProps> = memo(props => {
const ref = useRef<HTMLButtonElement>(null);
const onTriggerStateUpdate = useStrictContext(TriggerStateUpdateContext);
export const HiddenTrigger = memo(
forwardRef(
(props: HiddenTriggerProps, ref: ForwardedRef<HTMLButtonElement>) => {
const innerRef = useRef<HTMLButtonElement>(null);
const onTriggerStateUpdate = useStrictContext(
TriggerStateUpdateContext
);
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
useLayoutEffect(() => {
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
}, [onTriggerStateUpdate]);
const onTriggerStateUpdateRef = useRef(onTriggerStateUpdate);
useLayoutEffect(() => {
onTriggerStateUpdateRef.current = onTriggerStateUpdate;
}, [onTriggerStateUpdate]);
useLayoutEffect(() => {
const button = assert(ref.current, 'Missing ref');
let timer: ReturnType<typeof setTimeout>;
useLayoutEffect(() => {
const button = assert(innerRef.current, 'Missing ref');
let timer: ReturnType<typeof setTimeout>;
function update() {
onTriggerStateUpdateRef.current({
hovered: button.matches(':hover:not(:disabled)'),
pressed: button.matches(':active:not(:disabled)'),
focused: button.matches('.keyboard-mode :focus'),
function update() {
onTriggerStateUpdateRef.current({
hovered: button.matches(':hover:not(:disabled)'),
pressed: button.matches(':active:not(:disabled)'),
focused: button.matches('.keyboard-mode :focus'),
});
}
function delayedUpdate() {
clearTimeout(timer);
timer = setTimeout(update, 1);
}
update();
button.addEventListener('pointerenter', update);
button.addEventListener('pointerleave', update);
button.addEventListener('pointerdown', update);
button.addEventListener('pointerup', update);
button.addEventListener('focus', update);
button.addEventListener('blur', update);
// need delay
button.addEventListener('keydown', delayedUpdate);
button.addEventListener('keyup', delayedUpdate);
return () => {
clearTimeout(timer);
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
button.removeEventListener('pointerenter', update);
button.removeEventListener('pointerleave', update);
button.removeEventListener('pointerdown', update);
button.removeEventListener('pointerup', update);
button.removeEventListener('focus', update);
button.removeEventListener('blur', update);
// need delay
button.removeEventListener('keydown', delayedUpdate);
button.removeEventListener('keyup', delayedUpdate);
};
}, []);
useEffect(() => {
if (isTestOrMockEnvironment()) {
assert(
computeAccessibleName(assert(innerRef.current)) !== '',
`${hiddenTriggerDisplayName} child must have an accessible name`
);
}
});
}
function delayedUpdate() {
clearTimeout(timer);
timer = setTimeout(update, 1);
}
update();
button.addEventListener('pointerenter', update);
button.addEventListener('pointerleave', update);
button.addEventListener('pointerdown', update);
button.addEventListener('pointerup', update);
button.addEventListener('focus', update);
button.addEventListener('blur', update);
// need delay
button.addEventListener('keydown', delayedUpdate);
button.addEventListener('keyup', delayedUpdate);
return () => {
clearTimeout(timer);
onTriggerStateUpdateRef.current(INITIAL_TRIGGER_STATE);
button.removeEventListener('pointerenter', update);
button.removeEventListener('pointerleave', update);
button.removeEventListener('pointerdown', update);
button.removeEventListener('pointerup', update);
button.removeEventListener('focus', update);
button.removeEventListener('blur', update);
// need delay
button.removeEventListener('keydown', delayedUpdate);
button.removeEventListener('keyup', delayedUpdate);
};
}, []);
useEffect(() => {
if (isTestOrMockEnvironment()) {
assert(
computeAccessibleName(assert(ref.current)) !== '',
`${hiddenTriggerDisplayName} child must have an accessible name`
return (
<button
ref={mergeRefs(ref, innerRef)}
{...props}
type="button"
className={tw('absolute inset-0 z-10 outline-0')}
/>
);
}
});
return (
<button
ref={ref}
type="button"
className={tw('absolute inset-0 z-10 outline-0')}
aria-label={props['aria-label']}
aria-labelledby={props['aria-labelledby']}
onClick={props.onClick}
/>
);
});
)
);
HiddenTrigger.displayName = hiddenTriggerDisplayName;
}
+6 -3
View File
@@ -1021,6 +1021,12 @@ export async function startApp(): Promise<void> {
});
}
}
if (window.isBeforeVersion(lastVersion, 'v7.91.0-beta.1')) {
await itemStorage.remove('versionedExpirationTimer');
await itemStorage.remove('callQualitySurveyCooldownDisabled');
await itemStorage.remove('localDeleteWarningShown');
}
}
setAppLoadingScreenMessage(i18n('icu:optimizingApplication'), i18n);
@@ -3883,9 +3889,6 @@ export async function startApp(): Promise<void> {
const { confirm, timestamp, envelopeId, deleteForMeSync } = ev;
const logId = `onDeleteForMeSync(${timestamp})`;
// The user clearly knows about this feature; they did it on another device!
drop(itemStorage.put('localDeleteWarningShown', true));
log.info(`${logId}: Saving ${deleteForMeSync.length} sync tasks`);
const now = Date.now();
@@ -0,0 +1,24 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import React, { useState } from 'react';
import { action } from '@storybook/addon-actions';
import { DeleteAttachmentConfirmationDialog } from './DeleteAttachmentConfirmationDialog.dom.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/DeleteAttachmentConfirmationDialog',
} satisfies Meta;
export function Default(): React.JSX.Element {
const [open, setOpen] = useState(true);
return (
<DeleteAttachmentConfirmationDialog
i18n={i18n}
open={open}
onOpenChange={setOpen}
onDestroyAttachment={action('onDestroyAttachment')}
/>
);
}
@@ -0,0 +1,52 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import type { LocalizerType } from '../types/I18N.std.js';
import { AxoDialog } from '../axo/AxoDialog.dom.js';
export function DeleteAttachmentConfirmationDialog({
i18n,
onDestroyAttachment,
open,
onOpenChange,
}: {
i18n: LocalizerType;
onDestroyAttachment: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}): React.JSX.Element {
const close = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
return (
<AxoDialog.Root open={open} onOpenChange={onOpenChange}>
<AxoDialog.Content escape="cancel-is-noop" size="sm">
<AxoDialog.Header>
<AxoDialog.Title>
{i18n('icu:DeleteAttachmentModal__Title')}
</AxoDialog.Title>
<AxoDialog.Close
aria-label={i18n('icu:DeleteAttachmentModal__Close')}
/>
</AxoDialog.Header>
<AxoDialog.Body>
{i18n('icu:DeleteAttachmentModal__Body')}
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={close}>
{i18n('icu:DeleteAttachmentModal__Cancel')}
</AxoDialog.Action>
<AxoDialog.Action
variant="destructive"
onClick={onDestroyAttachment}
>
{i18n('icu:DeleteAttachmentModal__Delete')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
@@ -0,0 +1,22 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import { DeleteMessagesConfirmationDialog } from './DeleteMessagesConfirmationDialog.dom.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/DeleteMessagesConfirmationDialog',
} satisfies Meta;
export function Default(): React.JSX.Element {
return (
<DeleteMessagesConfirmationDialog
i18n={i18n}
onClose={action('onClose')}
onDestroyMessages={action('onDestroyMessages')}
/>
);
}
@@ -3,30 +3,16 @@
import React from 'react';
import type { LocalizerType } from '../types/I18N.std.js';
import { ConfirmationDialog } from './ConfirmationDialog.dom.js';
import { LocalDeleteWarningModal } from './LocalDeleteWarningModal.dom.js';
export function DeleteMessagesConfirmationDialog({
i18n,
localDeleteWarningShown,
onDestroyMessages,
onClose,
setLocalDeleteWarningShown,
}: {
i18n: LocalizerType;
localDeleteWarningShown: boolean;
onDestroyMessages: () => void;
onClose: () => void;
setLocalDeleteWarningShown: () => void;
}): React.JSX.Element {
if (!localDeleteWarningShown) {
return (
<LocalDeleteWarningModal
i18n={i18n}
onClose={setLocalDeleteWarningShown}
/>
);
}
const dialogBody = i18n(
'icu:ConversationHeader__DeleteConversationConfirmation__description-with-sync'
);
+5 -1
View File
@@ -48,6 +48,7 @@ import { EmojiSkinTone } from './fun/data/emojis.std.js';
export enum ForwardMessagesModalType {
Forward,
ForwardAttachment,
ShareCallLink,
}
@@ -300,7 +301,10 @@ export function ForwardMessagesModal({
);
let title: string;
if (type === ForwardMessagesModalType.Forward) {
if (
type === ForwardMessagesModalType.Forward ||
type === ForwardMessagesModalType.ForwardAttachment
) {
title = i18n('icu:ForwardMessageModal__title');
} else if (type === ForwardMessagesModalType.ShareCallLink) {
title = i18n('icu:ForwardMessageModal__ShareCallLink');
-2
View File
@@ -353,8 +353,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
onDelete={action('onDelete')}
onChatFolderOpenCreatePage={action('onChatFolderOpenCreatePage')}
onChatFolderToggleChat={action('onChatFolderToggleChat')}
localDeleteWarningShown={false}
setLocalDeleteWarningShown={action('setLocalDeleteWarningShown')}
>
{props.children}
</LeftPaneConversationListItemContextMenu>
@@ -1,28 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, StoryFn } from '@storybook/react';
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './LocalDeleteWarningModal.dom.js';
import { LocalDeleteWarningModal } from './LocalDeleteWarningModal.dom.js';
const { i18n } = window.SignalContext;
export default {
title: 'Components/LocalDeleteWarningModal',
component: LocalDeleteWarningModal,
args: {
i18n,
onClose: action('onClose'),
},
} satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => (
<LocalDeleteWarningModal {...args} />
);
export const Modal = Template.bind({});
Modal.args = {};
@@ -1,53 +0,0 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util.std.js';
import { Button, ButtonVariant } from './Button.dom.js';
import { I18n } from './I18n.dom.js';
import { Modal } from './Modal.dom.js';
export type PropsType = {
i18n: LocalizerType;
onClose: () => unknown;
};
export function LocalDeleteWarningModal({
i18n,
onClose,
}: PropsType): React.JSX.Element {
return (
<Modal
modalName="LocalDeleteWarningModal"
moduleClassName="LocalDeleteWarningModal"
i18n={i18n}
onClose={onClose}
>
<div className="LocalDeleteWarningModal">
<div className="LocalDeleteWarningModal__image">
<img
src="images/desktop-and-phone.svg"
height="92"
width="138"
alt=""
/>
</div>
<div className="LocalDeleteWarningModal__header">
<I18n i18n={i18n} id="icu:LocalDeleteWarningModal__header" />
</div>
<div className="LocalDeleteWarningModal__description">
<I18n i18n={i18n} id="icu:LocalDeleteWarningModal__description" />
</div>
<div className="LocalDeleteWarningModal__button">
<Button onClick={onClose} variant={ButtonVariant.Primary}>
<I18n i18n={i18n} id="icu:LocalDeleteWarningModal__confirm" />
</Button>
</div>
</div>
</Modal>
);
}
@@ -63,9 +63,6 @@ const commonProps: PropsType = {
i18n,
localDeleteWarningShown: true,
setLocalDeleteWarningShown: action('setLocalDeleteWarningShown'),
onConversationAccept: action('onConversationAccept'),
onConversationArchive: action('onConversationArchive'),
onConversationBlock: action('onConversationBlock'),
@@ -484,19 +481,6 @@ export function Blocked(): React.JSX.Element {
);
}
export function NeedsDeleteConfirmation(): React.JSX.Element {
const [localDeleteWarningShown, setLocalDeleteWarningShown] =
React.useState(false);
const props = {
...commonProps,
localDeleteWarningShown,
setLocalDeleteWarningShown: () => setLocalDeleteWarningShown(true),
};
const theme = useContext(StorybookThemeContext);
return <ConversationHeader {...props} theme={theme} />;
}
export function DirectConversationInAnotherCall(): React.JSX.Element {
const props = {
...commonProps,
@@ -134,7 +134,6 @@ export type PropsDataType = {
hasPanelShowing?: boolean;
hasStories?: HasStories;
hasActiveCall?: boolean;
localDeleteWarningShown: boolean;
isMissingMandatoryProfileSharing?: boolean;
isSelectMode: boolean;
isSignalConversation?: boolean;
@@ -152,8 +151,6 @@ export type PropsDataType = {
};
export type PropsActionsType = {
setLocalDeleteWarningShown: () => void;
onConversationAccept: () => void;
onConversationArchive: () => void;
onConversationBlock: () => void;
@@ -205,7 +202,6 @@ export const ConversationHeader = memo(function ConversationHeader({
isSelectMode,
isSignalConversation,
isSmsOnlyOrUnregistered,
localDeleteWarningShown,
onConversationAccept,
onConversationArchive,
onConversationBlock,
@@ -229,7 +225,6 @@ export const ConversationHeader = memo(function ConversationHeader({
onViewConversationDetails,
onViewUserStories,
outgoingCallButtonStyle,
setLocalDeleteWarningShown,
theme,
contactSpoofingWarning,
@@ -284,7 +279,6 @@ export const ConversationHeader = memo(function ConversationHeader({
{hasDeleteMessagesConfirmation && (
<DeleteMessagesConfirmationDialog
i18n={i18n}
localDeleteWarningShown={localDeleteWarningShown}
onDestroyMessages={() => {
setHasDeleteMessagesConfirmation(false);
onConversationDeleteMessages();
@@ -292,7 +286,6 @@ export const ConversationHeader = memo(function ConversationHeader({
onClose={() => {
setHasDeleteMessagesConfirmation(false);
}}
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
/>
)}
{hasLeaveGroupConfirmation && (
@@ -30,7 +30,8 @@ export function Multiple(): React.JSX.Element {
isPlayed={Math.random() > 0.5}
authorTitle="Alice"
onClick={action('onClick')}
onShowMessage={action('onShowMessage')}
showMessage={action('showMessage')}
renderContextMenu={(_item, children) => <>{children}</>}
/>
))}
</>
@@ -1,20 +1,26 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { noop } from 'lodash';
import React, { type ReactNode } from 'react';
import lodash from 'lodash';
import type { Transition } from 'framer-motion';
import { motion } from 'framer-motion';
import type { ReadonlyDeep } from 'type-fest';
import { tw } from '../../../axo/tw.dom.js';
import { formatFileSize } from '../../../util/formatFileSize.std.js';
import { durationToPlaybackText } from '../../../util/durationToPlaybackText.std.js';
import type { MediaItemType } from '../../../types/MediaItem.std.js';
import type {
GenericMediaItemType,
MediaItemType,
} from '../../../types/MediaItem.std.js';
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
import { type AttachmentStatusType } from '../../../hooks/useAttachmentStatus.std.js';
import { useComputePeaks } from '../../../hooks/useComputePeaks.dom.js';
import { ListItem } from './ListItem.dom.js';
const { noop } = lodash;
const BAR_COUNT = 7;
const MAX_PEAK_HEIGHT = 22;
const MIN_PEAK_HEIGHT = 2;
@@ -29,7 +35,11 @@ const DOT_TRANSITION: Transition = {
export type DataProps = Readonly<{
mediaItem: MediaItemType;
onClick: (status: AttachmentStatusType['state']) => void;
onShowMessage: () => void;
showMessage: () => void;
renderContextMenu: (
mediaItem: ReadonlyDeep<GenericMediaItemType>,
children: ReactNode
) => JSX.Element;
}>;
// Provided by smart layer
@@ -47,7 +57,8 @@ export function AudioListItem({
authorTitle,
isPlayed,
onClick,
onShowMessage,
showMessage,
renderContextMenu,
}: Props): React.JSX.Element {
const { attachment } = mediaItem;
@@ -131,7 +142,8 @@ export function AudioListItem({
}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
}
@@ -29,7 +29,8 @@ export function Multiple(): React.JSX.Element {
mediaItem={mediaItem}
authorTitle="Alice"
onClick={action('onClick')}
onShowMessage={action('onShowMessage')}
showMessage={action('showMessage')}
renderContextMenu={(_item, children) => <>{children}</>}
/>
))}
</>
@@ -1,9 +1,13 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { type ReactNode } from 'react';
import type { ReadonlyDeep } from 'type-fest';
import type { ContactMediaItemType } from '../../../types/MediaItem.std.js';
import type {
GenericMediaItemType,
ContactMediaItemType,
} from '../../../types/MediaItem.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import { getName } from '../../../types/EmbeddedContact.std.js';
import { AvatarColors } from '../../../types/Colors.std.js';
@@ -16,7 +20,11 @@ export type Props = {
mediaItem: ContactMediaItemType;
authorTitle: string;
onClick: (status: AttachmentStatusType['state']) => void;
onShowMessage: () => void;
showMessage: () => void;
renderContextMenu: (
mediaItem: ReadonlyDeep<GenericMediaItemType>,
children: ReactNode
) => JSX.Element;
};
export function ContactListItem({
@@ -24,7 +32,8 @@ export function ContactListItem({
mediaItem,
authorTitle,
onClick,
onShowMessage,
showMessage,
renderContextMenu,
}: Props): React.JSX.Element {
const { contact } = mediaItem;
const { avatar } = contact;
@@ -58,7 +67,8 @@ export function ContactListItem({
subtitle={subtitle}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
}
@@ -29,7 +29,8 @@ export function Multiple(): React.JSX.Element {
mediaItem={mediaItem}
authorTitle="Alice"
onClick={action('onClick')}
onShowMessage={action('onShowMessage')}
showMessage={action('showMessage')}
renderContextMenu={(_item, children) => <>{children}</>}
/>
))}
</>
@@ -1,10 +1,14 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { type ReactNode } from 'react';
import type { ReadonlyDeep } from 'type-fest';
import { formatFileSize } from '../../../util/formatFileSize.std.js';
import type { MediaItemType } from '../../../types/MediaItem.std.js';
import type {
GenericMediaItemType,
MediaItemType,
} from '../../../types/MediaItem.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
import { FileThumbnail } from '../../FileThumbnail.dom.js';
@@ -19,7 +23,11 @@ export type Props = {
mediaItem: MediaItemType;
authorTitle: string;
onClick: (status: AttachmentStatusType['state']) => void;
onShowMessage: () => void;
showMessage: () => void;
renderContextMenu: (
mediaItem: ReadonlyDeep<GenericMediaItemType>,
children: ReactNode
) => JSX.Element;
};
export function DocumentListItem({
@@ -27,7 +35,8 @@ export function DocumentListItem({
mediaItem,
authorTitle,
onClick,
onShowMessage,
showMessage,
renderContextMenu,
}: Props): React.JSX.Element {
const { attachment } = mediaItem;
@@ -67,7 +76,8 @@ export function DocumentListItem({
subtitle={subtitle}
readyLabel={i18n('icu:startDownload')}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
}
@@ -29,7 +29,8 @@ export function Multiple(): React.JSX.Element {
authorTitle="Alice"
mediaItem={mediaItem}
onClick={action('onClick')}
onShowMessage={action('onShowMessage')}
showMessage={action('showMessage')}
renderContextMenu={(_item, children) => <>{children}</>}
/>
))}
</>
@@ -1,14 +1,18 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { type ReactNode } from 'react';
import type { ReadonlyDeep } from 'type-fest';
import {
getAlt,
getUrl,
defaultBlurHash,
} from '../../../util/Attachment.std.js';
import type { LinkPreviewMediaItemType } from '../../../types/MediaItem.std.js';
import type {
GenericMediaItemType,
LinkPreviewMediaItemType,
} from '../../../types/MediaItem.std.js';
import type { LocalizerType, ThemeType } from '../../../types/Util.std.js';
import { tw } from '../../../axo/tw.dom.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
@@ -19,7 +23,11 @@ import { ListItem } from './ListItem.dom.js';
export type DataProps = Readonly<{
mediaItem: LinkPreviewMediaItemType;
onClick: (status: AttachmentStatusType['state']) => void;
onShowMessage: () => void;
showMessage: () => void;
renderContextMenu: (
mediaItem: ReadonlyDeep<GenericMediaItemType>,
children: ReactNode
) => JSX.Element;
}>;
// Provided by smart layer
@@ -36,7 +44,8 @@ export function LinkPreviewItem({
mediaItem,
authorTitle,
onClick,
onShowMessage,
showMessage,
renderContextMenu,
}: Props): React.JSX.Element {
const { preview } = mediaItem;
@@ -97,7 +106,8 @@ export function LinkPreviewItem({
subtitle={subtitle}
readyLabel={i18n('icu:LinkPreviewItem__alt')}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
}
@@ -1,7 +1,8 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import React, { useCallback, type ReactNode } from 'react';
import type { ReadonlyDeep } from 'type-fest';
import moment from 'moment';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
@@ -18,7 +19,7 @@ import {
type AttachmentStatusType,
} from '../../../hooks/useAttachmentStatus.std.js';
export type Props = {
export type Props = Readonly<{
i18n: LocalizerType;
mediaItem: GenericMediaItemType;
thumbnail: React.ReactNode;
@@ -26,8 +27,12 @@ export type Props = {
subtitle: React.ReactNode;
readyLabel: string;
onClick: (status: AttachmentStatusType['state']) => void;
onShowMessage: () => void;
};
showMessage: () => void;
renderContextMenu: (
mediaItem: ReadonlyDeep<GenericMediaItemType>,
children: ReactNode
) => JSX.Element;
}>;
export function ListItem({
i18n,
@@ -37,7 +42,8 @@ export function ListItem({
subtitle,
readyLabel,
onClick,
onShowMessage,
showMessage,
renderContextMenu,
}: Props): React.JSX.Element {
const { message } = mediaItem;
let attachment: AttachmentForUIType | undefined;
@@ -69,9 +75,9 @@ export function ListItem({
(ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
onShowMessage();
showMessage();
},
[onShowMessage]
[showMessage]
);
if (status == null || status.state === 'ReadyToShow') {
@@ -142,7 +148,10 @@ export function ListItem({
{subtitle}
</div>
</div>
<AriaClickable.HiddenTrigger aria-label={label} onClick={handleClick} />
{renderContextMenu(
mediaItem,
<AriaClickable.HiddenTrigger aria-label={label} onClick={handleClick} />
)}
<AriaClickable.SubWidget>
<button
type="button"
@@ -0,0 +1,93 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react';
import type { ReactNode } from 'react';
import type { LocalizerType } from '../../../types/Util.std.js';
import { AxoContextMenu } from '../../../axo/AxoContextMenu.dom.js';
import { DeleteAttachmentConfirmationDialog } from '../../DeleteAttachmentConfirmationDialog.dom.js';
export type PropsType = Readonly<{
i18n: LocalizerType;
children: ReactNode;
showMessage: () => void;
removeAttachment?: () => void;
saveAttachment?: () => void;
forwardAttachment?: () => void;
copyLink?: () => void;
messageContact?: () => void;
}>;
export function MediaContextMenu(props: PropsType): React.JSX.Element {
const {
i18n,
children,
showMessage,
saveAttachment,
forwardAttachment,
removeAttachment,
copyLink,
messageContact,
} = props;
const [isConfirmingDelete, setIsConfirmingDelete] = useState(false);
const confirmDelete = useCallback(() => {
setIsConfirmingDelete(true);
}, []);
return (
<>
{removeAttachment && (
<DeleteAttachmentConfirmationDialog
i18n={i18n}
onDestroyAttachment={removeAttachment}
open={isConfirmingDelete}
onOpenChange={setIsConfirmingDelete}
/>
)}
<AxoContextMenu.Root>
<AxoContextMenu.Trigger>{children}</AxoContextMenu.Trigger>
<AxoContextMenu.Content>
<AxoContextMenu.Item symbol="message-arrow" onSelect={showMessage}>
{i18n('icu:MediaGallery__ContextMenu__ViewInChat')}
</AxoContextMenu.Item>
{forwardAttachment && (
<AxoContextMenu.Item symbol="forward" onSelect={forwardAttachment}>
{i18n('icu:MediaGallery__ContextMenu__Forward')}
</AxoContextMenu.Item>
)}
{saveAttachment && (
<AxoContextMenu.Item symbol="download" onSelect={saveAttachment}>
{i18n('icu:MediaGallery__ContextMenu__Save')}
</AxoContextMenu.Item>
)}
{messageContact && (
<AxoContextMenu.Item symbol="message" onSelect={messageContact}>
{i18n('icu:MediaGallery__ContextMenu__Send')}
</AxoContextMenu.Item>
)}
{copyLink && (
<AxoContextMenu.Item symbol="copy" onSelect={copyLink}>
{i18n('icu:MediaGallery__ContextMenu__Copy')}
</AxoContextMenu.Item>
)}
{removeAttachment && (
<>
<AxoContextMenu.Separator />
<AxoContextMenu.Item symbol="trash" onSelect={confirmDelete}>
{i18n('icu:MediaGallery__ContextMenu__Delete')}
</AxoContextMenu.Item>
</>
)}
</AxoContextMenu.Content>
</AxoContextMenu.Root>
</>
);
}
@@ -34,6 +34,7 @@ const createProps = (
mediaItem: overrideProps.mediaItem,
showSize: false,
onClick: action('onClick'),
renderContextMenu: (_item, children) => <>{children}</>,
};
};
@@ -1,9 +1,9 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import React, { useCallback, type ReactNode } from 'react';
import type { ReadonlyDeep } from 'type-fest';
import { formatFileSize } from '../../../util/formatFileSize.std.js';
import { formatDuration } from '../../../util/formatDuration.std.js';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
@@ -32,6 +32,10 @@ export type Props = Readonly<{
onClick?: (attachmentState: AttachmentStatusType['state']) => void;
i18n: LocalizerType;
theme?: ThemeType;
renderContextMenu: (
mediaItem: ReadonlyDeep<MediaItemType>,
children: ReactNode
) => JSX.Element;
}>;
export function MediaGridItem(props: Props): React.JSX.Element {
@@ -41,6 +45,7 @@ export function MediaGridItem(props: Props): React.JSX.Element {
i18n,
theme,
onClick,
renderContextMenu,
} = props;
const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme);
@@ -80,7 +85,8 @@ export function MediaGridItem(props: Props): React.JSX.Element {
[onClick, status.state]
);
return (
return renderContextMenu(
props.mediaItem,
<button
type="button"
className={tw(
@@ -1,6 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import React, { useCallback, type ReactNode } from 'react';
// eslint-disable-next-line import/no-extraneous-dependencies
import { action } from '@storybook/addon-actions';
@@ -11,6 +11,7 @@ import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus
import { missingCaseError } from '../../../../util/missingCaseError.std.js';
import { isVoiceMessagePlayed } from '../../../../util/isVoiceMessagePlayed.std.js';
import { LinkPreviewItem } from '../LinkPreviewItem.dom.js';
import { MediaContextMenu } from '../MediaContextMenu.dom.js';
import { MediaGridItem } from '../MediaGridItem.dom.js';
import { DocumentListItem } from '../DocumentListItem.dom.js';
import { ContactListItem } from '../ContactListItem.dom.js';
@@ -18,6 +19,25 @@ import { AudioListItem } from '../AudioListItem.dom.js';
const { i18n } = window.SignalContext;
function renderContextMenu(
_mediaItem: unknown,
children: ReactNode
): JSX.Element {
return (
<MediaContextMenu
i18n={i18n}
showMessage={action('showMessage')}
removeAttachment={action('removeAttachment')}
saveAttachment={action('saveAttachment')}
forwardAttachment={action('forwardAttachment')}
copyLink={action('copyLink')}
messageContact={action('messageContact')}
>
{children}
</MediaContextMenu>
);
}
export function MediaItem({
mediaItem,
onItemClick,
@@ -28,7 +48,12 @@ export function MediaItem({
},
[mediaItem, onItemClick]
);
const onShowMessage = action('onShowMessage');
const actions = {
onClick,
renderContextMenu,
showMessage: action('showMessage'),
};
switch (mediaItem.type) {
case 'audio':
@@ -38,17 +63,16 @@ export function MediaItem({
authorTitle="Alice"
isPlayed={isVoiceMessagePlayed(mediaItem.message, undefined)}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
{...actions}
/>
);
case 'media':
return (
<MediaGridItem
mediaItem={mediaItem}
onClick={onClick}
i18n={i18n}
showSize={false}
{...actions}
/>
);
case 'document':
@@ -57,8 +81,7 @@ export function MediaItem({
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
{...actions}
/>
);
case 'contact':
@@ -67,8 +90,7 @@ export function MediaItem({
i18n={i18n}
authorTitle="Alice"
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
{...actions}
/>
);
case 'link': {
@@ -85,8 +107,7 @@ export function MediaItem({
i18n={i18n}
authorTitle="Alice"
mediaItem={hydratedMediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
{...actions}
/>
);
}
@@ -47,8 +47,6 @@ export type LeftPaneConversationListItemContextMenuProps = Readonly<{
onDelete: (conversationId: string) => void;
onChatFolderOpenCreatePage: (initChatFolderParams: ChatFolderParams) => void;
onChatFolderToggleChat: ChatFolderToggleChat;
localDeleteWarningShown: boolean;
setLocalDeleteWarningShown: () => void;
children: ReactNode;
}>;
@@ -290,10 +288,8 @@ export const LeftPaneConversationListItemContextMenu: FC<LeftPaneConversationLis
{showConfirmDeleteDialog && (
<DeleteMessagesConfirmationDialog
i18n={i18n}
localDeleteWarningShown={props.localDeleteWarningShown}
onDestroyMessages={handleDelete}
onClose={handleCloseConfirmDeleteDialog}
setLocalDeleteWarningShown={props.setLocalDeleteWarningShown}
/>
)}
</>
-2
View File
@@ -162,7 +162,6 @@ import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue.p
import { isOlderThan } from '../../util/timestamp.std.js';
import { DAY } from '../../util/durations/index.std.js';
import { isNotNil } from '../../util/isNotNil.std.js';
import { startConversation } from '../../util/startConversation.dom.js';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp.std.js';
import { removeLinkPreview } from '../../services/LinkPreview.preload.js';
import type {
@@ -1320,7 +1319,6 @@ export const actions = {
showInbox,
showMediaNoLongerAvailableToast,
startComposing,
startConversation,
startSettingGroupMetadata,
toggleAdmin,
toggleComposeEditingAvatar,
+8 -3
View File
@@ -900,7 +900,9 @@ export type ForwardMessagesPayload = ReadonlyDeep<
messageIds: ReadonlyArray<string>;
}
| {
type: ForwardMessagesModalType.ShareCallLink;
type:
| ForwardMessagesModalType.ForwardAttachment
| ForwardMessagesModalType.ShareCallLink;
draft: MessageForwardDraft;
}
>;
@@ -975,10 +977,13 @@ function toggleForwardMessagesModal(
return messageDraft;
})
);
} else if (payload.type === ForwardMessagesModalType.ShareCallLink) {
} else if (
payload.type === ForwardMessagesModalType.ForwardAttachment ||
payload.type === ForwardMessagesModalType.ShareCallLink
) {
messageDrafts = [payload.draft];
} else {
throw missingCaseError(payload);
throw missingCaseError(payload.type);
}
dispatch({
-6
View File
@@ -282,12 +282,6 @@ export const getHasUnidentifiedDeliveryIndicators = createSelector(
}
);
export const getLocalDeleteWarningShown = createSelector(
getItems,
(state: ItemsStateType): boolean =>
Boolean(state.localDeleteWarningShown ?? false)
);
export const getBackupMediaDownloadProgress = createSelector(
getItems,
(
+51 -13
View File
@@ -1,34 +1,79 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useEffect } from 'react';
import React, { memo, useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import type { Props as ContactDetailProps } from '../../components/conversation/ContactDetail.dom.js';
import { ContactDetail } from '../../components/conversation/ContactDetail.dom.js';
import { useAccountsActions } from '../ducks/accounts.preload.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { getMessages } from '../selectors/conversations.dom.js';
import { getIntl, getRegionCode } from '../selectors/user.std.js';
import { embeddedContactSelector } from '../../types/EmbeddedContact.std.js';
import { startConversation } from '../../util/startConversation.dom.js';
import type {
EmbeddedContactType,
EmbeddedContactForUIType,
} from '../../types/EmbeddedContact.std.js';
import { getAccountSelector } from '../selectors/accounts.std.js';
export type OwnProps = Pick<ContactDetailProps, 'messageId'>;
export function useLookupContact(
contact: EmbeddedContactType
): EmbeddedContactForUIType;
export function useLookupContact(
contact: EmbeddedContactType | undefined
): EmbeddedContactForUIType | undefined;
export function useLookupContact(
contact: EmbeddedContactType | undefined
): EmbeddedContactForUIType | undefined {
const regionCode = useSelector(getRegionCode);
const accountSelector = useSelector(getAccountSelector);
const { checkForAccount } = useAccountsActions();
const numbers = contact?.number;
const firstNumber = numbers && numbers[0] ? numbers[0].value : undefined;
const serviceId = accountSelector(firstNumber);
useEffect(() => {
if (firstNumber == null) {
return;
}
if (serviceId != null) {
return;
}
checkForAccount(firstNumber);
}, [firstNumber, serviceId, checkForAccount]);
return useMemo(() => {
if (contact == null) {
return undefined;
}
return embeddedContactSelector(contact, {
firstNumber,
regionCode,
serviceId,
});
}, [contact, firstNumber, regionCode, serviceId]);
}
export const SmartContactDetail = memo(function SmartContactDetail({
messageId,
}: OwnProps): React.JSX.Element | null {
const i18n = useSelector(getIntl);
const regionCode = useSelector(getRegionCode);
const messageLookup = useSelector(getMessages);
const accountSelector = useSelector(getAccountSelector);
const {
cancelAttachmentDownload,
kickOffAttachmentDownload,
popPanelForConversation,
startConversation,
} = useConversationsActions();
const contact = messageLookup[messageId]?.contact?.[0];
const contact = useLookupContact(messageLookup[messageId]?.contact?.[0]);
useEffect(() => {
if (!contact) {
@@ -40,13 +85,6 @@ export const SmartContactDetail = memo(function SmartContactDetail({
return null;
}
const numbers = contact?.number;
const firstNumber = numbers && numbers[0] ? numbers[0].value : undefined;
const fixedContact = embeddedContactSelector(contact, {
firstNumber,
regionCode,
serviceId: accountSelector(firstNumber),
});
const signalAccount =
contact.firstNumber && contact.serviceId
? {
@@ -58,7 +96,7 @@ export const SmartContactDetail = memo(function SmartContactDetail({
return (
<ContactDetail
cancelAttachmentDownload={cancelAttachmentDownload}
contact={fixedContact}
contact={contact}
hasSignalAccount={Boolean(signalAccount)}
i18n={i18n}
kickOffAttachmentDownload={kickOffAttachmentDownload}
@@ -43,8 +43,6 @@ import {
} from '../selectors/conversations.dom.js';
import { getHasStoriesSelector } from '../selectors/stories2.dom.js';
import { getIntl, getTheme, getUserACI } from '../selectors/user.std.js';
import { useItemsActions } from '../ducks/items.preload.js';
import { getLocalDeleteWarningShown } from '../selectors/items.dom.js';
import { isConversationEverUnregistered } from '../../util/isConversationUnregistered.dom.js';
import { isDirectConversation } from '../../util/whatTypeOfConversation.dom.js';
import type { DurationInSeconds } from '../../util/durations/index.std.js';
@@ -289,11 +287,6 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
const minimalConversation = useMinimalConversation(conversation);
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
const { putItem } = useItemsActions();
const setLocalDeleteWarningShown = () =>
putItem('localDeleteWarningShown', true);
return (
<ConversationHeader
addedByName={addedByName}
@@ -305,7 +298,6 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
hasPanelShowing={hasPanelShowing}
hasStories={hasStories}
i18n={i18n}
localDeleteWarningShown={localDeleteWarningShown}
isMissingMandatoryProfileSharing={isMissingMandatoryProfileSharing}
isSelectMode={isSelectMode}
isSignalConversation={isSignalConversation(conversation)}
@@ -339,7 +331,6 @@ export const SmartConversationHeader = memo(function SmartConversationHeader({
onViewAllMedia={onViewAllMedia}
onViewUserStories={onViewUserStories}
outgoingCallButtonStyle={outgoingCallButtonStyle}
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
theme={theme}
contactSpoofingWarning={contactSpoofingWarning}
renderCollidingAvatars={renderCollidingAvatars}
@@ -16,9 +16,6 @@ import {
getLastSelectedMessage,
} from '../selectors/conversations.dom.js';
import { getDeleteMessagesProps } from '../selectors/globalModals.std.js';
import { useItemsActions } from '../ducks/items.preload.js';
import { getLocalDeleteWarningShown } from '../selectors/items.dom.js';
import { LocalDeleteWarningModal } from '../../components/LocalDeleteWarningModal.dom.js';
export const SmartDeleteMessagesModal = memo(
function SmartDeleteMessagesModal() {
@@ -72,19 +69,6 @@ export const SmartDeleteMessagesModal = memo(
onDelete?.();
}, [deleteMessagesForEveryone, messageIds, onDelete]);
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
const { putItem } = useItemsActions();
if (!localDeleteWarningShown) {
return (
<LocalDeleteWarningModal
i18n={i18n}
onClose={() => {
putItem('localDeleteWarningShown', true);
}}
/>
);
}
return (
<DeleteMessagesModal
isMe={isMe}
@@ -11,8 +11,6 @@ import { LeftPaneConversationListItemContextMenu } from '../../components/leftPa
import { strictAssert } from '../../util/assert.std.js';
import type { RenderConversationListItemContextMenuProps } from '../../components/conversationList/BaseConversationListItem.dom.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { getLocalDeleteWarningShown } from '../selectors/items.dom.js';
import { useItemsActions } from '../ducks/items.preload.js';
import {
getCurrentChatFolders,
getSelectedChatFolder,
@@ -28,7 +26,6 @@ export const SmartLeftPaneConversationListItemContextMenu: FC<RenderConversation
memo(function SmartLeftPaneConversationListItemContextMenu(props) {
const i18n = useSelector(getIntl);
const conversationByIdSelector = useSelector(getConversationByIdSelector);
const localDeleteWarningShown = useSelector(getLocalDeleteWarningShown);
const location = useSelector(getSelectedLocation);
const isActivelySearching = useSelector(getIsActivelySearching);
const selectedChatFolder = useSelector(getSelectedChatFolder);
@@ -44,13 +41,8 @@ export const SmartLeftPaneConversationListItemContextMenu: FC<RenderConversation
setMuteExpiration,
} = useConversationsActions();
const { updateChatFolderToggleChat } = useChatFolderActions();
const { putItem } = useItemsActions();
const { changeLocation } = useNavActions();
const setLocalDeleteWarningShown = useCallback(() => {
putItem('localDeleteWarningShown', true);
}, [putItem]);
const conversation = conversationByIdSelector(props.conversationId);
strictAssert(conversation, 'Missing conversation');
@@ -107,8 +99,6 @@ export const SmartLeftPaneConversationListItemContextMenu: FC<RenderConversation
onDelete={deleteConversation}
onChatFolderOpenCreatePage={handleChatFolderOpenCreatePage}
onChatFolderToggleChat={handleChatFolderToggleChat}
localDeleteWarningShown={localDeleteWarningShown}
setLocalDeleteWarningShown={setLocalDeleteWarningShown}
>
{props.children}
</LeftPaneConversationListItemContextMenu>
+309
View File
@@ -0,0 +1,309 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, type ReactNode } from 'react';
import { useSelector } from 'react-redux';
import { MediaContextMenu } from '../../components/conversation/media-gallery/MediaContextMenu.dom.js';
import { ForwardMessagesModalType } from '../../components/ForwardMessagesModal.dom.js';
import type {
GenericMediaItemType,
MediaItemType,
LinkPreviewMediaItemType,
ContactMediaItemType,
} from '../../types/MediaItem.std.js';
import type { LocalizerType } from '../../types/Util.std.js';
import { missingCaseError } from '../../util/missingCaseError.std.js';
import { strictAssert } from '../../util/assert.std.js';
import { startConversation } from '../../util/startConversation.dom.js';
import { drop } from '../../util/drop.std.js';
import {
applyDeleteMessage,
applyDeleteAttachmentFromMessage,
} from '../../util/deleteForMe.preload.js';
import {
getConversationIdentifier,
getAddressableMessage,
} from '../../util/syncIdentifiers.preload.js';
import { getMessageById } from '../../messages/getMessageById.preload.js';
import { createLogger } from '../../logging/log.std.js';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue.preload.js';
import { MessageSender } from '../../textsecure/SendMessage.preload.js';
import { getIntl } from '../selectors/user.std.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { useGlobalModalActions } from '../ducks/globalModals.preload.js';
import { useLookupContact } from './ContactDetail.preload.js';
const log = createLogger('MediaContextMenu');
export type PropsType = Readonly<{
mediaItem: GenericMediaItemType;
children: ReactNode;
}>;
async function doRemoveAttachment(
mediaItem: GenericMediaItemType
): Promise<void> {
if (
mediaItem.type !== 'media' &&
mediaItem.type !== 'audio' &&
mediaItem.type !== 'document'
) {
log.error(
`Invalid media item type ${mediaItem.type} for removing attachment`
);
return;
}
const message = await getMessageById(mediaItem.message.id);
if (message == null) {
log.error(
`Missing message ${mediaItem.message.id} for removing attachment`
);
return;
}
const convo = window.ConversationController.get(
mediaItem.message.conversationId
);
if (convo == null) {
log.error(`Missing conversation ${mediaItem.message.conversationId}`);
return;
}
const addressableMessage = getAddressableMessage(message.attributes);
const conversationIdentifier = getConversationIdentifier(convo.attributes);
if (addressableMessage == null) {
log.error(`Message ${mediaItem.message.id} is not addressable`);
return;
}
const { attachment } = mediaItem;
if (message.get('attachments')?.length === 1) {
log.info(`Deleting whole message ${mediaItem.message.id}`);
await applyDeleteMessage(message.attributes);
await singleProtoJobQueue.add(
MessageSender.getDeleteForMeSyncMessage([
{
type: 'delete-message',
conversation: conversationIdentifier,
message: addressableMessage,
timestamp: Date.now(),
},
])
);
} else {
log.info(`Deleting a single attachment for ${mediaItem.message.id}`);
const attachmentData = {
clientUuid: attachment.clientUuid,
fallbackDigest: attachment.digest,
fallbackPlaintextHash: attachment.plaintextHash,
};
const found = await applyDeleteAttachmentFromMessage(
message,
attachmentData,
{
shouldSave: true,
logId: 'MediaItem.removeAttachment',
}
);
if (!found) {
log.error(`Missing attachment for ${mediaItem.message.id}`);
return;
}
await singleProtoJobQueue.add(
MessageSender.getDeleteForMeSyncMessage([
{
type: 'delete-single-attachment',
conversation: conversationIdentifier,
message: addressableMessage,
...attachmentData,
timestamp: Date.now(),
},
])
);
}
}
type SpecificMenuPropsType<ItemType extends GenericMediaItemType> = Readonly<{
mediaItem: ItemType;
children: ReactNode;
i18n: LocalizerType;
showMessage: () => void;
}>;
function AttachmentContextMenu({
mediaItem,
children,
...rest
}: SpecificMenuPropsType<MediaItemType>) {
const { saveAttachment: doSaveAttachment } = useConversationsActions();
const { toggleForwardMessagesModal } = useGlobalModalActions();
const saveAttachment = useCallback(() => {
strictAssert(mediaItem.attachment.path, 'Attachment must be downloaded');
doSaveAttachment(mediaItem.attachment, mediaItem.message.sentAt);
}, [doSaveAttachment, mediaItem.attachment, mediaItem.message.sentAt]);
const forwardAttachment = useCallback(() => {
toggleForwardMessagesModal({
type: ForwardMessagesModalType.ForwardAttachment,
draft: {
originalMessageId: mediaItem.message.id,
hasContact: false,
isSticker: false,
attachments: [mediaItem.attachment],
previews: [],
},
});
}, [mediaItem.message.id, mediaItem.attachment, toggleForwardMessagesModal]);
const removeAttachment = useCallback(() => {
drop(doRemoveAttachment(mediaItem));
}, [mediaItem]);
return (
<MediaContextMenu
{...rest}
saveAttachment={mediaItem.attachment.path ? saveAttachment : undefined}
forwardAttachment={
mediaItem.attachment.path ? forwardAttachment : undefined
}
removeAttachment={removeAttachment}
>
{children}
</MediaContextMenu>
);
}
function LinkPreviewContextMenu({
mediaItem,
children,
...rest
}: SpecificMenuPropsType<LinkPreviewMediaItemType>) {
const { toggleForwardMessagesModal } = useGlobalModalActions();
const forwardAttachment = useCallback(() => {
toggleForwardMessagesModal({
type: ForwardMessagesModalType.ForwardAttachment,
draft: {
originalMessageId: mediaItem.message.id,
hasContact: false,
isSticker: false,
messageBody: mediaItem.preview.url,
previews: [mediaItem.preview],
},
});
}, [mediaItem.message.id, mediaItem.preview, toggleForwardMessagesModal]);
const copyLink = useCallback(() => {
drop(window.navigator.clipboard.writeText(mediaItem.preview.url));
}, [mediaItem.preview.url]);
return (
<MediaContextMenu
{...rest}
forwardAttachment={forwardAttachment}
copyLink={copyLink}
>
{children}
</MediaContextMenu>
);
}
function ContactContextMenu({
mediaItem,
children,
...rest
}: SpecificMenuPropsType<ContactMediaItemType>) {
const { toggleForwardMessagesModal } = useGlobalModalActions();
const contact = useLookupContact(mediaItem.contact);
const forwardAttachment = useCallback(() => {
toggleForwardMessagesModal({
type: ForwardMessagesModalType.ForwardAttachment,
draft: {
originalMessageId: mediaItem.message.id,
hasContact: true,
isSticker: false,
previews: [],
},
});
}, [mediaItem.message.id, toggleForwardMessagesModal]);
const messageContact = useCallback(() => {
strictAssert(
contact.firstNumber != null && contact.serviceId != null,
'Expected service id for contact'
);
startConversation(contact.firstNumber, contact.serviceId);
}, [contact]);
return (
<MediaContextMenu
{...rest}
messageContact={
contact.firstNumber != null && contact.serviceId != null
? messageContact
: undefined
}
forwardAttachment={forwardAttachment}
>
{children}
</MediaContextMenu>
);
}
export function SmartMediaContextMenu({
mediaItem,
children,
}: PropsType): JSX.Element {
const i18n = useSelector(getIntl);
const { showConversation } = useConversationsActions();
const { message } = mediaItem;
const showMessage = useCallback(() => {
showConversation({
conversationId: message.conversationId,
messageId: message.id,
});
}, [showConversation, message.conversationId, message.id]);
const common = {
i18n,
showMessage,
};
switch (mediaItem.type) {
case 'media':
case 'document':
case 'audio':
return (
<AttachmentContextMenu {...common} mediaItem={mediaItem}>
{children}
</AttachmentContextMenu>
);
case 'link':
return (
<LinkPreviewContextMenu {...common} mediaItem={mediaItem}>
{children}
</LinkPreviewContextMenu>
);
case 'contact':
return (
<ContactContextMenu {...common} mediaItem={mediaItem}>
{children}
</ContactContextMenu>
);
default:
throw missingCaseError(mediaItem);
}
}
+29 -12
View File
@@ -1,6 +1,6 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { memo, useCallback } from 'react';
import React, { memo, useCallback, type ReactNode } from 'react';
import { useSelector } from 'react-redux';
import { LinkPreviewItem } from '../../components/conversation/media-gallery/LinkPreviewItem.dom.js';
import { MediaGridItem } from '../../components/conversation/media-gallery/MediaGridItem.dom.js';
@@ -21,12 +21,24 @@ import {
import { getConversationSelector } from '../selectors/conversations.dom.js';
import { getMediaGalleryState } from '../selectors/mediaGallery.std.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
import { SmartMediaContextMenu } from './MediaContextMenu.preload.js';
export type PropsType = Readonly<{
onItemClick: (event: ItemClickEvent) => unknown;
mediaItem: GenericMediaItemType;
}>;
function renderContextMenu(
mediaItem: GenericMediaItemType,
children: ReactNode
): JSX.Element {
return (
<SmartMediaContextMenu mediaItem={mediaItem}>
{children}
</SmartMediaContextMenu>
);
}
export const MediaItem = memo(function MediaItem({
mediaItem,
onItemClick,
@@ -46,6 +58,13 @@ export const MediaItem = memo(function MediaItem({
? i18n('icu:you')
: getConversation(message.sourceServiceId ?? message.source).title;
const showMessage = useCallback(() => {
showConversation({
conversationId: message.conversationId,
messageId: message.id,
});
}, [message.conversationId, message.id, showConversation]);
const onClick = useCallback(
(state: AttachmentStatusType['state']) => {
onItemClick({ mediaItem, state });
@@ -53,13 +72,6 @@ export const MediaItem = memo(function MediaItem({
[mediaItem, onItemClick]
);
const onShowMessage = useCallback(() => {
showConversation({
conversationId: message.conversationId,
messageId: message.id,
});
}, [message.conversationId, message.id, showConversation]);
const showSize = sortOrder === 'size';
switch (mediaItem.type) {
@@ -71,7 +83,8 @@ export const MediaItem = memo(function MediaItem({
isPlayed={isVoiceMessagePlayed(mediaItem.message, ourConversationId)}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
case 'media':
@@ -80,6 +93,7 @@ export const MediaItem = memo(function MediaItem({
mediaItem={mediaItem}
showSize={showSize}
onClick={onClick}
renderContextMenu={renderContextMenu}
i18n={i18n}
theme={theme}
/>
@@ -91,7 +105,8 @@ export const MediaItem = memo(function MediaItem({
authorTitle={authorTitle}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
case 'contact':
@@ -101,7 +116,8 @@ export const MediaItem = memo(function MediaItem({
authorTitle={authorTitle}
mediaItem={mediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
case 'link': {
@@ -120,7 +136,8 @@ export const MediaItem = memo(function MediaItem({
authorTitle={authorTitle}
mediaItem={hydratedMediaItem}
onClick={onClick}
onShowMessage={onShowMessage}
showMessage={showMessage}
renderContextMenu={renderContextMenu}
/>
);
}
+1 -1
View File
@@ -19,6 +19,7 @@ import {
import { getMessageDetailsSelector } from '../selectors/message.preload.js';
import { getPreferredBadgeSelector } from '../selectors/badges.preload.js';
import { renderAudioAttachment } from './renderAudioAttachment.preload.js';
import { startConversation } from '../../util/startConversation.dom.js';
import { useAccountsActions } from '../ducks/accounts.preload.js';
import { useComposerActions } from '../ducks/composer.preload.js';
import { useConversationsActions } from '../ducks/conversations.preload.js';
@@ -68,7 +69,6 @@ export const SmartMessageDetail = memo(
showExpiredOutgoingTapToViewToast,
showMediaNoLongerAvailableToast,
showSpoiler,
startConversation,
} = useConversationsActions();
const {
showContactModal,
+1 -1
View File
@@ -26,6 +26,7 @@ import {
getTargetedMessageSource,
} from '../selectors/conversations.dom.js';
import { getSharedGroupNames } from '../../util/sharedGroupNames.dom.js';
import { startConversation } from '../../util/startConversation.dom.js';
import { useTimelineItem } from '../selectors/timeline.preload.js';
import {
areMessagesInSameGroup,
@@ -149,7 +150,6 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
showExpiredOutgoingTapToViewToast,
showMediaNoLongerAvailableToast,
showSpoiler,
startConversation,
targetMessage,
toggleSelectMessage,
} = useConversationsActions();
+19 -3
View File
@@ -23,6 +23,7 @@ import { DataWriter } from '../sql/Client.preload.js';
import type { ConversationModel } from '../models/conversations.preload.js';
import { assertDev, strictAssert } from '../util/assert.std.js';
import { parseIntOrThrow } from '../util/parseIntOrThrow.std.js';
import { uuidToBytes } from '../util/uuidToBytes.std.js';
import { Address } from '../types/Address.std.js';
import { QualifiedAddress } from '../types/QualifiedAddress.std.js';
import { SenderKeys } from '../LibSignalStores.preload.js';
@@ -1746,9 +1747,24 @@ export class MessageSender {
conversation,
});
} else if (item.type === 'delete-single-attachment') {
throw new Error(
"getDeleteForMeSyncMessage: Desktop currently does not support sending 'delete-single-attachment' messages"
);
const conversation = toConversationIdentifier(item.conversation);
const targetMessage = toAddressableMessage(item.message);
deleteForMe.attachmentDeletes = deleteForMe.attachmentDeletes || [];
deleteForMe.attachmentDeletes.push({
conversation,
targetMessage,
clientUuid:
item.clientUuid == null ? undefined : uuidToBytes(item.clientUuid),
fallbackDigest:
item.fallbackDigest == null
? undefined
: Bytes.fromBase64(item.fallbackDigest),
fallbackPlaintextHash:
item.fallbackPlaintextHash == null
? undefined
: Bytes.fromHex(item.fallbackPlaintextHash),
});
} else {
throw missingCaseError(item);
}
+1 -1
View File
@@ -101,7 +101,6 @@ export type StorageAccessType = {
lastAttemptedToRefreshProfilesAt: number;
lastResortKeyUpdateTime: number;
lastResortKeyUpdateTimePNI: number;
localDeleteWarningShown: boolean;
accountEntropyPool: string;
masterKey: string;
@@ -308,6 +307,7 @@ export type StorageAccessType = {
primarySendsSms: never;
backupMediaDownloadIdle: never;
callQualitySurveyCooldownDisabled: never;
localDeleteWarningShown: never;
};
export type StorageInterface = {
-1
View File
@@ -23,7 +23,6 @@ export const STORAGE_UI_KEYS: ReadonlyArray<keyof StorageAccessType> = [
'hasCompletedSafetyNumberOnboarding',
'hasCompletedUsernameLinkOnboarding',
'hide-menu-bar',
'localDeleteWarningShown',
'incoming-call-notification',
'navTabsCollapsed',
'notification-draw-attention',