diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2e589ffd49..d50bb1a9f5 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/components/LocalDeleteWarningModal.scss b/stylesheets/components/LocalDeleteWarningModal.scss deleted file mode 100644 index a7a9ca15bf..0000000000 --- a/stylesheets/components/LocalDeleteWarningModal.scss +++ /dev/null @@ -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; - } -} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 77c82153cf..953f4987e0 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/axo/AriaClickable.dom.tsx b/ts/axo/AriaClickable.dom.tsx index 89e2f694d6..22c4b02e6f 100644 --- a/ts/axo/AriaClickable.dom.tsx +++ b/ts/axo/AriaClickable.dom.tsx @@ -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 . */ - export const HiddenTrigger: FC = memo(props => { - const ref = useRef(null); - const onTriggerStateUpdate = useStrictContext(TriggerStateUpdateContext); + export const HiddenTrigger = memo( + forwardRef( + (props: HiddenTriggerProps, ref: ForwardedRef) => { + const innerRef = useRef(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; + useLayoutEffect(() => { + const button = assert(innerRef.current, 'Missing ref'); + let timer: ReturnType; - 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 ( + - - - - ); -} diff --git a/ts/components/conversation/ConversationHeader.dom.stories.tsx b/ts/components/conversation/ConversationHeader.dom.stories.tsx index 9df50c15cb..e35f2c024b 100644 --- a/ts/components/conversation/ConversationHeader.dom.stories.tsx +++ b/ts/components/conversation/ConversationHeader.dom.stories.tsx @@ -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 ; -} - export function DirectConversationInAnotherCall(): React.JSX.Element { const props = { ...commonProps, diff --git a/ts/components/conversation/ConversationHeader.dom.tsx b/ts/components/conversation/ConversationHeader.dom.tsx index 394f4182a5..dbec843b98 100644 --- a/ts/components/conversation/ConversationHeader.dom.tsx +++ b/ts/components/conversation/ConversationHeader.dom.tsx @@ -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 && ( { setHasDeleteMessagesConfirmation(false); onConversationDeleteMessages(); @@ -292,7 +286,6 @@ export const ConversationHeader = memo(function ConversationHeader({ onClose={() => { setHasDeleteMessagesConfirmation(false); }} - setLocalDeleteWarningShown={setLocalDeleteWarningShown} /> )} {hasLeaveGroupConfirmation && ( diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx index 0af2cc0e6b..fc5a577852 100644 --- a/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.stories.tsx @@ -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}} /> ))} diff --git a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx index 4ab20220a1..40ca0c1a4d 100644 --- a/ts/components/conversation/media-gallery/AudioListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/AudioListItem.dom.tsx @@ -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, + 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} /> ); } diff --git a/ts/components/conversation/media-gallery/ContactListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/ContactListItem.dom.stories.tsx index e14f364fba..488c976d0d 100644 --- a/ts/components/conversation/media-gallery/ContactListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/ContactListItem.dom.stories.tsx @@ -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}} /> ))} diff --git a/ts/components/conversation/media-gallery/ContactListItem.dom.tsx b/ts/components/conversation/media-gallery/ContactListItem.dom.tsx index 202fd3f183..27a149211c 100644 --- a/ts/components/conversation/media-gallery/ContactListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/ContactListItem.dom.tsx @@ -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, + 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} /> ); } diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx index cb3e304f8b..df16b2f550 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.stories.tsx @@ -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}} /> ))} diff --git a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx index 73d8905459..2914a3756d 100644 --- a/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/DocumentListItem.dom.tsx @@ -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, + 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} /> ); } diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx index 395165f250..8d1be23843 100644 --- a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.stories.tsx @@ -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}} /> ))} diff --git a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx index e14249b4b7..b9758ecdab 100644 --- a/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx +++ b/ts/components/conversation/media-gallery/LinkPreviewItem.dom.tsx @@ -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, + 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} /> ); } diff --git a/ts/components/conversation/media-gallery/ListItem.dom.tsx b/ts/components/conversation/media-gallery/ListItem.dom.tsx index ec6678f7a8..4220a1338c 100644 --- a/ts/components/conversation/media-gallery/ListItem.dom.tsx +++ b/ts/components/conversation/media-gallery/ListItem.dom.tsx @@ -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, + 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} - + {renderContextMenu( + mediaItem, + + )}