diff --git a/src/components/common/Audio.scss b/src/components/common/Audio.scss index 9abf8a24e..bee8c7d8d 100644 --- a/src/components/common/Audio.scss +++ b/src/components/common/Audio.scss @@ -3,6 +3,12 @@ display: flex; align-items: flex-start; + &.has-menu-open { + border-radius: var(--border-radius-default); + background-color: var(--color-chat-hover); + box-shadow: 0 0 0 0.5rem var(--color-chat-hover); + } + &.inline { margin-top: calc(0.5rem - 0.3125rem); diff --git a/src/components/common/Audio.tsx b/src/components/common/Audio.tsx index e9c910ec4..8a56026b6 100644 --- a/src/components/common/Audio.tsx +++ b/src/components/common/Audio.tsx @@ -12,6 +12,7 @@ import type { BufferedRange } from '../../hooks/useBuffering'; import type { OldLangFn } from '../../hooks/useOldLang'; import type { ThemeKey } from '../../types'; import type { LangFn } from '../../util/localization'; +import type { MenuItemContextAction } from '../ui/ListItem'; import { ApiMediaFormat } from '../../api/types'; import { AudioOrigin } from '../../types'; @@ -38,6 +39,7 @@ import { MAX_EMPTY_WAVEFORM_POINTS, renderWaveform } from './helpers/waveform'; import useAppLayout from '../../hooks/useAppLayout'; import useAudioPlayer from '../../hooks/useAudioPlayer'; import useBuffering from '../../hooks/useBuffering'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useMedia from '../../hooks/useMedia'; @@ -47,6 +49,9 @@ import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated import Button from '../ui/Button'; import Link from '../ui/Link'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; +import MenuSeparator from '../ui/MenuSeparator'; import ProgressSpinner from '../ui/ProgressSpinner'; import AnimatedFileSize from './AnimatedFileSize'; import AnimatedIcon from './AnimatedIcon'; @@ -79,6 +84,7 @@ type OwnProps = { onReadMedia?: () => void; onCancelUpload?: () => void; onDateClick?: (arg: ApiMessage) => void; + contextActions?: MenuItemContextAction[]; }; type StateProps = { @@ -119,6 +125,7 @@ const Audio = ({ onReadMedia, onCancelUpload, onDateClick, + contextActions, }: OwnProps & StateProps) => { const { cancelMediaDownload, downloadMedia, transcribeAudio, openOneTimeMediaModal, @@ -133,6 +140,8 @@ const Audio = ({ const media = (voice || video || audio)!; const mediaSource = (voice || video); const isVoice = Boolean(voice || video); + const containerRef = useRef(); + const menuRef = useRef(); const isSeekingRef = useRef(false); const seekerRef = useRef(); const oldLang = useOldLang(); @@ -265,6 +274,17 @@ const Audio = ({ } }); + const { + isContextMenuOpen, contextMenuAnchor, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(containerRef, !contextActions); + + const getTriggerElement = useLastCallback(() => containerRef.current); + const getRootElement = useLastCallback(() => containerRef.current!.closest('.custom-scroll') || document.body); + const getMenuElement = useLastCallback(() => menuRef.current); + const getLayout = useLastCallback(() => ({ withPortal: true })); + const handleSeek = useLastCallback((e: MouseEvent | TouchEvent) => { if (isSeekingRef.current && seekerRef.current) { const { width, left } = seekerRef.current.getBoundingClientRect(); @@ -343,6 +363,7 @@ const Audio = ({ isOwn && origin === AudioOrigin.Inline && 'own', (origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger', isSelected && 'audio-is-selected', + contextMenuAnchor && 'has-menu-open', ); const buttonClassNames = ['toogle-play-wrapper']; @@ -420,7 +441,13 @@ const Audio = ({ } return ( -
+
{isSelectable && (
{isSelected && } @@ -492,6 +519,38 @@ const Audio = ({ origin, ) )} + {contextActions && contextMenuAnchor !== undefined && ( + + {contextActions.map((action) => ( + ('isSeparator' in action) ? ( + + ) : ( + + {action.title} + + ) + ))} + + )}
); }; diff --git a/src/components/common/Document.tsx b/src/components/common/Document.tsx index 406681676..135314b67 100644 --- a/src/components/common/Document.tsx +++ b/src/components/common/Document.tsx @@ -5,6 +5,7 @@ import { getActions } from '../../global'; import type { ApiDocument, ApiMessage, MediaContent } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { MenuItemContextAction } from '../ui/ListItem'; import { getDocumentMediaHash, @@ -42,6 +43,7 @@ type OwnProps = { shouldWarnAboutFiles?: boolean; id?: string; onCancelUpload?: NoneToVoidFunction; + contextActions?: MenuItemContextAction[]; } & ({ message: ApiMessage; onDateClick: (arg: ApiMessage) => void; @@ -73,12 +75,13 @@ const Document = ({ onCancelUpload, onMediaClick, onDateClick, + contextActions, }: OwnProps) => { const { cancelMediaDownload, downloadMedia, setSharedSettingOption } = getActions(); const ref = useRef(); - const lang = useOldLang(); + const oldLang = useOldLang(); const [isFileIpDialogOpen, openFileIpDialog, closeFileIpDialog] = useFlag(); const [shouldNotWarnAboutFiles, setShouldNotWarnAboutFiles] = useState(false); @@ -209,6 +212,7 @@ const Document = ({ isSelectable={isSelectable} isSelected={isSelected} actionIcon={withMediaViewer ? (isDocumentVideo(document) ? 'play' : 'eye') : 'download'} + contextActions={contextActions} onClick={handleClick} onDateClick={onDateClick ? handleDateClick : undefined} /> @@ -217,11 +221,11 @@ const Document = ({ onClose={closeFileIpDialog} confirmHandler={handleFileIpConfirm} > - {lang('lng_launch_svg_warning')} + {oldLang('lng_launch_svg_warning')} diff --git a/src/components/common/File.scss b/src/components/common/File.scss index 1ddf1ecc8..a08e472b0 100644 --- a/src/components/common/File.scss +++ b/src/components/common/File.scss @@ -1,6 +1,7 @@ .File { --secondary-color: var(--color-text-secondary); + position: relative; display: flex; align-items: center; @@ -140,6 +141,12 @@ } } + &.has-menu-open { + border-radius: var(--border-radius-default); + background-color: var(--color-chat-hover); + box-shadow: 0 0 0 0.5rem var(--color-chat-hover); + } + .file-info { overflow: hidden; flex-grow: 1; diff --git a/src/components/common/File.tsx b/src/components/common/File.tsx index 4e48acf40..7383ddbc8 100644 --- a/src/components/common/File.tsx +++ b/src/components/common/File.tsx @@ -6,6 +6,7 @@ import { import type { ApiAttachment, MediaContent } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { IconName } from '../../types/icons'; +import type { MenuItemContextAction } from '../ui/ListItem'; import buildClassName from '../../util/buildClassName'; import { formatMediaDateTime, formatPastTimeShort } from '../../util/dates/oldDateFormat'; @@ -13,11 +14,16 @@ import { getColorFromExtension } from './helpers/documentInfo'; import { getDocumentThumbnailDimensions } from './helpers/mediaDimensions'; import renderText from './helpers/renderText'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useLang from '../../hooks/useLang'; +import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import Link from '../ui/Link'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; +import MenuSeparator from '../ui/MenuSeparator'; import ProgressSpinner from '../ui/ProgressSpinner'; import AnimatedFileSize from './AnimatedFileSize'; import CompactMediaPreview, { canRenderCompactMediaPreview } from './CompactMediaPreview'; @@ -46,6 +52,7 @@ type OwnProps = { isSelected?: boolean; transferProgress?: number; actionIcon?: IconName; + contextActions?: MenuItemContextAction[]; onClick?: () => void; onDateClick?: (e: React.MouseEvent) => void; }; @@ -68,6 +75,7 @@ const File = ({ isSelected, transferProgress, actionIcon, + contextActions, observeIntersection, onClick, onDateClick, @@ -78,6 +86,7 @@ const File = ({ if (ref) { elementRef = ref; } + const menuRef = useRef(); const { shouldRender: shouldSpinnerRender, @@ -86,6 +95,17 @@ const File = ({ const color = getColorFromExtension(extension); + const { + isContextMenuOpen, contextMenuAnchor, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(elementRef, !contextActions); + + const getTriggerElement = useLastCallback(() => elementRef.current); + const getRootElement = useLastCallback(() => elementRef.current!.closest('.custom-scroll') || document.body); + const getMenuElement = useLastCallback(() => menuRef.current); + const getLayout = useLastCallback(() => ({ withPortal: true })); + const { width } = getDocumentThumbnailDimensions(previewSize); const shouldRenderPreview = canRenderCompactMediaPreview(previewMedia, previewAttachment); @@ -95,10 +115,18 @@ const File = ({ previewSize !== 'medium' && `size-${previewSize}`, onClick && !isUploading && 'interactive', isSelected && 'file-is-selected', + contextMenuAnchor && 'has-menu-open', ); return ( -
+
{isSelectable && (
{isSelected && } @@ -157,6 +185,38 @@ const File = ({ {sender && Boolean(timestamp) && ( {formatPastTimeShort(oldLang, timestamp * 1000)} )} + {contextActions && contextMenuAnchor !== undefined && ( + + {contextActions.map((action) => ( + ('isSeparator' in action) ? ( + + ) : ( + + {action.title} + + ) + ))} + + )}
); }; diff --git a/src/components/common/Media.scss b/src/components/common/Media.scss index e058b6131..bff1f7500 100644 --- a/src/components/common/Media.scss +++ b/src/components/common/Media.scss @@ -8,6 +8,25 @@ height: 0; padding-bottom: 100%; + &::after { + pointer-events: none; + content: ""; + + position: absolute; + z-index: 2; + inset: 0; + + opacity: 0; + background: rgba(0, 0, 0, 0.16); + box-shadow: inset 0 0 0 0.125rem var(--color-primary); + + transition: opacity 0.15s ease-in-out; + } + + &.has-menu-open::after { + opacity: 1; + } + .video-duration { position: absolute; top: 0.3125rem; diff --git a/src/components/common/Media.tsx b/src/components/common/Media.tsx index a706c477f..e475ad11d 100644 --- a/src/components/common/Media.tsx +++ b/src/components/common/Media.tsx @@ -2,6 +2,7 @@ import { memo, useRef } from '../../lib/teact/teact'; import type { ApiMessage } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; +import type { MenuItemContextAction } from '../ui/ListItem'; import { getMessageHtmlId, @@ -16,12 +17,16 @@ import stopEvent from '../../util/stopEvent'; import useMessageMediaHash from '../../hooks/media/useMessageMediaHash'; import useThumbnail from '../../hooks/media/useThumbnail'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useFlag from '../../hooks/useFlag'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import useLastCallback from '../../hooks/useLastCallback'; import useMedia from '../../hooks/useMedia'; import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; +import MenuSeparator from '../ui/MenuSeparator'; import OptimizedVideo from '../ui/OptimizedVideo'; import MediaSpoiler from './MediaSpoiler'; @@ -34,6 +39,7 @@ type OwnProps = { canAutoPlay?: boolean; observeIntersection?: ObserveFn; onClick?: (messageId: number, chatId: string) => void; + contextActions?: MenuItemContextAction[]; }; const Media = ({ @@ -43,8 +49,10 @@ const Media = ({ canAutoPlay, observeIntersection, onClick, + contextActions, }: OwnProps) => { const ref = useRef(); + const menuRef = useRef(); const isIntersecting = useIsIntersecting(ref, observeIntersection); const [isHovering, markMouseOver, markMouseOut] = useFlag(); @@ -61,7 +69,20 @@ const Media = ({ const hasSpoiler = getMessageIsSpoiler(message); const [isSpoilerShown, , hideSpoiler] = useFlag(hasSpoiler); + const { + isContextMenuOpen, contextMenuAnchor, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref, !contextActions); + + const getTriggerElement = useLastCallback(() => ref.current); + const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll') || document.body); + const getMenuElement = useLastCallback(() => menuRef.current); + const getLayout = useLastCallback(() => ({ withPortal: true })); + const handleClick = useLastCallback(() => { + if (isContextMenuOpen) return; + hideSpoiler(); onClick!(message.id, message.chatId); }); @@ -70,10 +91,12 @@ const Media = ({
{fullGifBlobUrl ? ( ) : ( )} {hasSpoiler && ( @@ -114,6 +137,38 @@ const Media = ({ )} {video && {video.isGif ? 'GIF' : formatMediaDuration(video.duration)}} {isProtected && } + {contextActions && contextMenuAnchor !== undefined && ( + + {contextActions.map((action) => ( + ('isSeparator' in action) ? ( + + ) : ( + + {action.title} + + ) + ))} + + )}
); }; diff --git a/src/components/common/WebLink.scss b/src/components/common/WebLink.scss index dd2ba954d..316b77895 100644 --- a/src/components/common/WebLink.scss +++ b/src/components/common/WebLink.scss @@ -11,6 +11,12 @@ margin-top: 1.5rem; } + &.has-menu-open { + border-radius: var(--border-radius-default); + background-color: var(--color-chat-hover); + box-shadow: 0 0 0 0.5rem var(--color-chat-hover); + } + &.without-media::before { content: attr(data-initial); diff --git a/src/components/common/WebLink.tsx b/src/components/common/WebLink.tsx index 0f23a6c66..3c2e15479 100644 --- a/src/components/common/WebLink.tsx +++ b/src/components/common/WebLink.tsx @@ -1,9 +1,10 @@ -import { memo, useMemo } from '../../lib/teact/teact'; +import { memo, useMemo, useRef } from '../../lib/teact/teact'; import { withGlobal } from '../../global'; import type { ApiMessage, ApiWebPage } from '../../api/types'; import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { TextPart } from '../../types'; +import type { MenuItemContextAction } from '../ui/ListItem'; import { getFirstLinkInMessage, @@ -16,11 +17,15 @@ import trimText from '../../util/trimText'; import { renderMessageSummary } from './helpers/renderMessageText'; import renderText from './helpers/renderText'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useLang from '../../hooks/useLang'; import useLastCallback from '../../hooks/useLastCallback'; import useOldLang from '../../hooks/useOldLang'; import Link from '../ui/Link'; +import Menu from '../ui/Menu'; +import MenuItem from '../ui/MenuItem'; +import MenuSeparator from '../ui/MenuSeparator'; import Media from './Media'; import SafeLink from './SafeLink'; @@ -37,6 +42,7 @@ type OwnProps = { senderTitle?: string; isProtected?: boolean; observeIntersection?: ObserveFn; + contextActions?: MenuItemContextAction[]; onMessageClick: (message: ApiMessage) => void; }; @@ -45,8 +51,10 @@ type StateProps = { }; const WebLink = ({ - message, webPage, senderTitle, isProtected, observeIntersection, onMessageClick, + message, webPage, senderTitle, isProtected, observeIntersection, contextActions, onMessageClick, }: OwnProps & StateProps) => { + const ref = useRef(); + const menuRef = useRef(); const lang = useLang(); const oldLang = useOldLang(); @@ -54,6 +62,17 @@ const WebLink = ({ onMessageClick(message); }); + const { + isContextMenuOpen, contextMenuAnchor, + handleBeforeContextMenu, handleContextMenu, + handleContextMenuClose, handleContextMenuHide, + } = useContextMenuHandlers(ref, !contextActions, true); + + const getTriggerElement = useLastCallback(() => ref.current); + const getRootElement = useLastCallback(() => ref.current!.closest('.custom-scroll') || document.body); + const getMenuElement = useLastCallback(() => menuRef.current); + const getLayout = useLastCallback(() => ({ withPortal: true })); + let linkData: ApiWebPageWithFormatted | undefined = webPage; if (!linkData) { @@ -114,18 +133,27 @@ const WebLink = ({ const className = buildClassName( 'WebLink scroll-item', (!photo && !video) && 'without-media', + contextMenuAnchor && 'has-menu-open', ); const safeLinkContent = displayUrl || url.replace('mailto:', ''); return (
{photo && ( - + )}
@@ -155,6 +183,38 @@ const WebLink = ({
)} + {contextActions && contextMenuAnchor !== undefined && ( + + {contextActions.map((action) => ( + ('isSeparator' in action) ? ( + + ) : ( + + {action.title} + + ) + ))} + + )}
); }; diff --git a/src/components/right/Profile.tsx b/src/components/right/Profile.tsx index a6aee17f9..dbbc34ef8 100644 --- a/src/components/right/Profile.tsx +++ b/src/components/right/Profile.tsx @@ -710,6 +710,16 @@ const Profile = ({ focusMessage({ chatId: message.chatId, messageId: message.id }); }); + const getMessageContextActions = useLastCallback((message: ApiMessage): MenuItemContextAction[] => { + return [{ + title: lang('FocusMessage'), + icon: 'show-message', + handler: () => { + handleMessageFocus(message); + }, + }]; + }); + const handleDeleteMembersModalClose = useLastCallback(() => { setDeletingUserId(undefined); }); @@ -1023,6 +1033,7 @@ const Profile = ({ canAutoPlay={canAutoPlayGifs} observeIntersection={observeIntersectionForMedia} onClick={handleSelectMedia} + contextActions={getMessageContextActions(messagesById[id])} /> )) ) : (resultType === 'stories' || resultType === 'storiesArchive') ? ( @@ -1049,6 +1060,7 @@ const Profile = ({ message={messagesById[id]} shouldWarnAboutFiles={shouldWarnAboutFiles} onMediaClick={handleSelectMedia} + contextActions={getMessageContextActions(messagesById[id])} /> )) ) : resultType === 'links' ? ( @@ -1058,6 +1070,7 @@ const Profile = ({ message={messagesById[id]} isProtected={isChatProtected || messagesById[id].isProtected} observeIntersection={observeIntersectionForMedia} + contextActions={getMessageContextActions(messagesById[id])} onMessageClick={handleMessageFocus} /> )) @@ -1072,6 +1085,7 @@ const Profile = ({ className="scroll-item" onPlay={handlePlayAudio} onDateClick={handleMessageFocus} + contextActions={getMessageContextActions(messagesById[id])} canDownload={!isChatProtected && !messagesById[id].isProtected} isDownloading={getIsDownloading(activeDownloads, messagesById[id].content.audio!)} /> @@ -1093,6 +1107,7 @@ const Profile = ({ className="scroll-item" onPlay={handlePlayAudio} onDateClick={handleMessageFocus} + contextActions={getMessageContextActions(message)} canDownload={!isChatProtected && !message.isProtected} isDownloading={getIsDownloading(activeDownloads, media)} /> diff --git a/src/hooks/useContextMenuHandlers.ts b/src/hooks/useContextMenuHandlers.ts index c3ca3f737..58fc65e89 100644 --- a/src/hooks/useContextMenuHandlers.ts +++ b/src/hooks/useContextMenuHandlers.ts @@ -18,6 +18,16 @@ function stopEvent(e: Event) { e.stopPropagation(); } +function isNativeLinkTarget(target: EventTarget | undefined) { + if (!(target instanceof HTMLElement)) { + return false; + } + + const link = target.closest('a[href]'); + + return Boolean(link && link.getAttribute('href') !== '#'); +} + const useContextMenuHandlers = ( elementRef: ElementRef, isMenuDisabled?: boolean, @@ -43,7 +53,7 @@ const useContextMenuHandlers = ( removeExtraClass(e.target as HTMLElement, 'no-selection'); }); - if (isMenuDisabled || (shouldDisableOnLink && (e.target as HTMLElement).matches('a[href]'))) { + if (isMenuDisabled || (shouldDisableOnLink && isNativeLinkTarget(e.target))) { return; } e.preventDefault(); @@ -91,7 +101,7 @@ const useContextMenuHandlers = ( const { clientX, clientY, target } = originalEvent.touches[0]; - if (contextMenuAnchor || (shouldDisableOnLink && (target as HTMLElement).matches('a[href]'))) { + if (contextMenuAnchor || (shouldDisableOnLink && isNativeLinkTarget(target))) { return; }