mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2026-07-25 15:36:55 +01:00
All Media Context Menu
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user