Profile: Add Focus Message button for entities in tabs (#6949)

This commit is contained in:
Alexander Zinchuk 2026-05-15 18:37:57 +02:00
parent 480b09e9aa
commit 33c8b7d958
11 changed files with 315 additions and 14 deletions

View File

@ -3,6 +3,12 @@
display: flex; display: flex;
align-items: flex-start; 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 { &.inline {
margin-top: calc(0.5rem - 0.3125rem); margin-top: calc(0.5rem - 0.3125rem);

View File

@ -12,6 +12,7 @@ import type { BufferedRange } from '../../hooks/useBuffering';
import type { OldLangFn } from '../../hooks/useOldLang'; import type { OldLangFn } from '../../hooks/useOldLang';
import type { ThemeKey } from '../../types'; import type { ThemeKey } from '../../types';
import type { LangFn } from '../../util/localization'; import type { LangFn } from '../../util/localization';
import type { MenuItemContextAction } from '../ui/ListItem';
import { ApiMediaFormat } from '../../api/types'; import { ApiMediaFormat } from '../../api/types';
import { AudioOrigin } from '../../types'; import { AudioOrigin } from '../../types';
@ -38,6 +39,7 @@ import { MAX_EMPTY_WAVEFORM_POINTS, renderWaveform } from './helpers/waveform';
import useAppLayout from '../../hooks/useAppLayout'; import useAppLayout from '../../hooks/useAppLayout';
import useAudioPlayer from '../../hooks/useAudioPlayer'; import useAudioPlayer from '../../hooks/useAudioPlayer';
import useBuffering from '../../hooks/useBuffering'; import useBuffering from '../../hooks/useBuffering';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
@ -47,6 +49,9 @@ import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated
import Button from '../ui/Button'; import Button from '../ui/Button';
import Link from '../ui/Link'; 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 ProgressSpinner from '../ui/ProgressSpinner';
import AnimatedFileSize from './AnimatedFileSize'; import AnimatedFileSize from './AnimatedFileSize';
import AnimatedIcon from './AnimatedIcon'; import AnimatedIcon from './AnimatedIcon';
@ -79,6 +84,7 @@ type OwnProps = {
onReadMedia?: () => void; onReadMedia?: () => void;
onCancelUpload?: () => void; onCancelUpload?: () => void;
onDateClick?: (arg: ApiMessage) => void; onDateClick?: (arg: ApiMessage) => void;
contextActions?: MenuItemContextAction[];
}; };
type StateProps = { type StateProps = {
@ -119,6 +125,7 @@ const Audio = ({
onReadMedia, onReadMedia,
onCancelUpload, onCancelUpload,
onDateClick, onDateClick,
contextActions,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const { const {
cancelMediaDownload, downloadMedia, transcribeAudio, openOneTimeMediaModal, cancelMediaDownload, downloadMedia, transcribeAudio, openOneTimeMediaModal,
@ -133,6 +140,8 @@ const Audio = ({
const media = (voice || video || audio)!; const media = (voice || video || audio)!;
const mediaSource = (voice || video); const mediaSource = (voice || video);
const isVoice = Boolean(voice || video); const isVoice = Boolean(voice || video);
const containerRef = useRef<HTMLDivElement>();
const menuRef = useRef<HTMLDivElement>();
const isSeekingRef = useRef<boolean>(false); const isSeekingRef = useRef<boolean>(false);
const seekerRef = useRef<HTMLDivElement>(); const seekerRef = useRef<HTMLDivElement>();
const oldLang = useOldLang(); 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) => { const handleSeek = useLastCallback((e: MouseEvent | TouchEvent) => {
if (isSeekingRef.current && seekerRef.current) { if (isSeekingRef.current && seekerRef.current) {
const { width, left } = seekerRef.current.getBoundingClientRect(); const { width, left } = seekerRef.current.getBoundingClientRect();
@ -343,6 +363,7 @@ const Audio = ({
isOwn && origin === AudioOrigin.Inline && 'own', isOwn && origin === AudioOrigin.Inline && 'own',
(origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger', (origin === AudioOrigin.Search || origin === AudioOrigin.SharedMedia) && 'bigger',
isSelected && 'audio-is-selected', isSelected && 'audio-is-selected',
contextMenuAnchor && 'has-menu-open',
); );
const buttonClassNames = ['toogle-play-wrapper']; const buttonClassNames = ['toogle-play-wrapper'];
@ -420,7 +441,13 @@ const Audio = ({
} }
return ( return (
<div className={fullClassName} dir={lang.isRtl ? 'rtl' : 'ltr'}> <div
ref={containerRef}
className={fullClassName}
dir={lang.isRtl ? 'rtl' : 'ltr'}
onMouseDown={handleBeforeContextMenu}
onContextMenu={contextActions ? handleContextMenu : undefined}
>
{isSelectable && ( {isSelectable && (
<div className="message-select-control no-selection"> <div className="message-select-control no-selection">
{isSelected && <Icon name="check" className="message-select-control-icon" />} {isSelected && <Icon name="check" className="message-select-control-icon" />}
@ -492,6 +519,38 @@ const Audio = ({
origin, origin,
) )
)} )}
{contextActions && contextMenuAnchor !== undefined && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="shared-media-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{contextActions.map((action) => (
('isSeparator' in action) ? (
<MenuSeparator key={action.key || 'separator'} />
) : (
<MenuItem
key={action.title}
icon={action.icon}
destructive={action.destructive}
disabled={!action.handler}
onClick={action.handler}
>
{action.title}
</MenuItem>
)
))}
</Menu>
)}
</div> </div>
); );
}; };

View File

@ -5,6 +5,7 @@ import { getActions } from '../../global';
import type { ApiDocument, ApiMessage, MediaContent } from '../../api/types'; import type { ApiDocument, ApiMessage, MediaContent } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { MenuItemContextAction } from '../ui/ListItem';
import { import {
getDocumentMediaHash, getDocumentMediaHash,
@ -42,6 +43,7 @@ type OwnProps = {
shouldWarnAboutFiles?: boolean; shouldWarnAboutFiles?: boolean;
id?: string; id?: string;
onCancelUpload?: NoneToVoidFunction; onCancelUpload?: NoneToVoidFunction;
contextActions?: MenuItemContextAction[];
} & ({ } & ({
message: ApiMessage; message: ApiMessage;
onDateClick: (arg: ApiMessage) => void; onDateClick: (arg: ApiMessage) => void;
@ -73,12 +75,13 @@ const Document = ({
onCancelUpload, onCancelUpload,
onMediaClick, onMediaClick,
onDateClick, onDateClick,
contextActions,
}: OwnProps) => { }: OwnProps) => {
const { cancelMediaDownload, downloadMedia, setSharedSettingOption } = getActions(); const { cancelMediaDownload, downloadMedia, setSharedSettingOption } = getActions();
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
const lang = useOldLang(); const oldLang = useOldLang();
const [isFileIpDialogOpen, openFileIpDialog, closeFileIpDialog] = useFlag(); const [isFileIpDialogOpen, openFileIpDialog, closeFileIpDialog] = useFlag();
const [shouldNotWarnAboutFiles, setShouldNotWarnAboutFiles] = useState(false); const [shouldNotWarnAboutFiles, setShouldNotWarnAboutFiles] = useState(false);
@ -209,6 +212,7 @@ const Document = ({
isSelectable={isSelectable} isSelectable={isSelectable}
isSelected={isSelected} isSelected={isSelected}
actionIcon={withMediaViewer ? (isDocumentVideo(document) ? 'play' : 'eye') : 'download'} actionIcon={withMediaViewer ? (isDocumentVideo(document) ? 'play' : 'eye') : 'download'}
contextActions={contextActions}
onClick={handleClick} onClick={handleClick}
onDateClick={onDateClick ? handleDateClick : undefined} onDateClick={onDateClick ? handleDateClick : undefined}
/> />
@ -217,11 +221,11 @@ const Document = ({
onClose={closeFileIpDialog} onClose={closeFileIpDialog}
confirmHandler={handleFileIpConfirm} confirmHandler={handleFileIpConfirm}
> >
{lang('lng_launch_svg_warning')} {oldLang('lng_launch_svg_warning')}
<Checkbox <Checkbox
className="dialog-checkbox" className="dialog-checkbox"
checked={shouldNotWarnAboutFiles} checked={shouldNotWarnAboutFiles}
label={lang('lng_launch_exe_dont_ask')} label={oldLang('lng_launch_exe_dont_ask')}
onCheck={setShouldNotWarnAboutFiles} onCheck={setShouldNotWarnAboutFiles}
/> />
</ConfirmDialog> </ConfirmDialog>

View File

@ -1,6 +1,7 @@
.File { .File {
--secondary-color: var(--color-text-secondary); --secondary-color: var(--color-text-secondary);
position: relative;
display: flex; display: flex;
align-items: center; 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 { .file-info {
overflow: hidden; overflow: hidden;
flex-grow: 1; flex-grow: 1;

View File

@ -6,6 +6,7 @@ import {
import type { ApiAttachment, MediaContent } from '../../api/types'; import type { ApiAttachment, MediaContent } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { IconName } from '../../types/icons'; import type { IconName } from '../../types/icons';
import type { MenuItemContextAction } from '../ui/ListItem';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import { formatMediaDateTime, formatPastTimeShort } from '../../util/dates/oldDateFormat'; import { formatMediaDateTime, formatPastTimeShort } from '../../util/dates/oldDateFormat';
@ -13,11 +14,16 @@ import { getColorFromExtension } from './helpers/documentInfo';
import { getDocumentThumbnailDimensions } from './helpers/mediaDimensions'; import { getDocumentThumbnailDimensions } from './helpers/mediaDimensions';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated';
import Link from '../ui/Link'; 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 ProgressSpinner from '../ui/ProgressSpinner';
import AnimatedFileSize from './AnimatedFileSize'; import AnimatedFileSize from './AnimatedFileSize';
import CompactMediaPreview, { canRenderCompactMediaPreview } from './CompactMediaPreview'; import CompactMediaPreview, { canRenderCompactMediaPreview } from './CompactMediaPreview';
@ -46,6 +52,7 @@ type OwnProps = {
isSelected?: boolean; isSelected?: boolean;
transferProgress?: number; transferProgress?: number;
actionIcon?: IconName; actionIcon?: IconName;
contextActions?: MenuItemContextAction[];
onClick?: () => void; onClick?: () => void;
onDateClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void; onDateClick?: (e: React.MouseEvent<HTMLAnchorElement>) => void;
}; };
@ -68,6 +75,7 @@ const File = ({
isSelected, isSelected,
transferProgress, transferProgress,
actionIcon, actionIcon,
contextActions,
observeIntersection, observeIntersection,
onClick, onClick,
onDateClick, onDateClick,
@ -78,6 +86,7 @@ const File = ({
if (ref) { if (ref) {
elementRef = ref; elementRef = ref;
} }
const menuRef = useRef<HTMLDivElement>();
const { const {
shouldRender: shouldSpinnerRender, shouldRender: shouldSpinnerRender,
@ -86,6 +95,17 @@ const File = ({
const color = getColorFromExtension(extension); 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 { width } = getDocumentThumbnailDimensions(previewSize);
const shouldRenderPreview = canRenderCompactMediaPreview(previewMedia, previewAttachment); const shouldRenderPreview = canRenderCompactMediaPreview(previewMedia, previewAttachment);
@ -95,10 +115,18 @@ const File = ({
previewSize !== 'medium' && `size-${previewSize}`, previewSize !== 'medium' && `size-${previewSize}`,
onClick && !isUploading && 'interactive', onClick && !isUploading && 'interactive',
isSelected && 'file-is-selected', isSelected && 'file-is-selected',
contextMenuAnchor && 'has-menu-open',
); );
return ( return (
<div id={id} ref={elementRef} className={fullClassName} dir={lang.isRtl ? 'rtl' : undefined}> <div
id={id}
ref={elementRef}
className={fullClassName}
dir={lang.isRtl ? 'rtl' : undefined}
onMouseDown={handleBeforeContextMenu}
onContextMenu={contextActions ? handleContextMenu : undefined}
>
{isSelectable && ( {isSelectable && (
<div className="message-select-control no-selection"> <div className="message-select-control no-selection">
{isSelected && <Icon name="check" className="message-select-control-icon" />} {isSelected && <Icon name="check" className="message-select-control-icon" />}
@ -157,6 +185,38 @@ const File = ({
{sender && Boolean(timestamp) && ( {sender && Boolean(timestamp) && (
<Link onClick={onDateClick}>{formatPastTimeShort(oldLang, timestamp * 1000)}</Link> <Link onClick={onDateClick}>{formatPastTimeShort(oldLang, timestamp * 1000)}</Link>
)} )}
{contextActions && contextMenuAnchor !== undefined && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="shared-media-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{contextActions.map((action) => (
('isSeparator' in action) ? (
<MenuSeparator key={action.key || 'separator'} />
) : (
<MenuItem
key={action.title}
icon={action.icon}
destructive={action.destructive}
disabled={!action.handler}
onClick={action.handler}
>
{action.title}
</MenuItem>
)
))}
</Menu>
)}
</div> </div>
); );
}; };

View File

@ -8,6 +8,25 @@
height: 0; height: 0;
padding-bottom: 100%; 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 { .video-duration {
position: absolute; position: absolute;
top: 0.3125rem; top: 0.3125rem;

View File

@ -2,6 +2,7 @@ import { memo, useRef } from '../../lib/teact/teact';
import type { ApiMessage } from '../../api/types'; import type { ApiMessage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { MenuItemContextAction } from '../ui/ListItem';
import { import {
getMessageHtmlId, getMessageHtmlId,
@ -16,12 +17,16 @@ import stopEvent from '../../util/stopEvent';
import useMessageMediaHash from '../../hooks/media/useMessageMediaHash'; import useMessageMediaHash from '../../hooks/media/useMessageMediaHash';
import useThumbnail from '../../hooks/media/useThumbnail'; import useThumbnail from '../../hooks/media/useThumbnail';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
import { useIsIntersecting } from '../../hooks/useIntersectionObserver'; import { useIsIntersecting } from '../../hooks/useIntersectionObserver';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useMedia from '../../hooks/useMedia'; import useMedia from '../../hooks/useMedia';
import useMediaTransitionDeprecated from '../../hooks/useMediaTransitionDeprecated'; 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 OptimizedVideo from '../ui/OptimizedVideo';
import MediaSpoiler from './MediaSpoiler'; import MediaSpoiler from './MediaSpoiler';
@ -34,6 +39,7 @@ type OwnProps = {
canAutoPlay?: boolean; canAutoPlay?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onClick?: (messageId: number, chatId: string) => void; onClick?: (messageId: number, chatId: string) => void;
contextActions?: MenuItemContextAction[];
}; };
const Media = ({ const Media = ({
@ -43,8 +49,10 @@ const Media = ({
canAutoPlay, canAutoPlay,
observeIntersection, observeIntersection,
onClick, onClick,
contextActions,
}: OwnProps) => { }: OwnProps) => {
const ref = useRef<HTMLDivElement>(); const ref = useRef<HTMLDivElement>();
const menuRef = useRef<HTMLDivElement>();
const isIntersecting = useIsIntersecting(ref, observeIntersection); const isIntersecting = useIsIntersecting(ref, observeIntersection);
const [isHovering, markMouseOver, markMouseOut] = useFlag(); const [isHovering, markMouseOver, markMouseOut] = useFlag();
@ -61,7 +69,20 @@ const Media = ({
const hasSpoiler = getMessageIsSpoiler(message); const hasSpoiler = getMessageIsSpoiler(message);
const [isSpoilerShown, , hideSpoiler] = useFlag(hasSpoiler); 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(() => { const handleClick = useLastCallback(() => {
if (isContextMenuOpen) return;
hideSpoiler(); hideSpoiler();
onClick!(message.id, message.chatId); onClick!(message.id, message.chatId);
}); });
@ -70,10 +91,12 @@ const Media = ({
<div <div
ref={ref} ref={ref}
id={`${idPrefix}${getMessageHtmlId(message.id)}`} id={`${idPrefix}${getMessageHtmlId(message.id)}`}
className="Media scroll-item" className={buildClassName('Media scroll-item', contextMenuAnchor && 'has-menu-open')}
onClick={onClick ? handleClick : undefined} onClick={onClick ? handleClick : undefined}
onMouseDown={handleBeforeContextMenu}
onMouseOver={!IS_TOUCH_ENV ? markMouseOver : undefined} onMouseOver={!IS_TOUCH_ENV ? markMouseOver : undefined}
onMouseOut={!IS_TOUCH_ENV ? markMouseOut : undefined} onMouseOut={!IS_TOUCH_ENV ? markMouseOut : undefined}
onContextMenu={contextActions ? handleContextMenu : undefined}
> >
<img <img
src={thumbDataUri} src={thumbDataUri}
@ -81,7 +104,7 @@ const Media = ({
alt="" alt=""
draggable={!isProtected} draggable={!isProtected}
decoding="async" decoding="async"
onContextMenu={isProtected ? stopEvent : undefined} onContextMenu={isProtected && !contextActions ? stopEvent : undefined}
/> />
{fullGifBlobUrl ? ( {fullGifBlobUrl ? (
<OptimizedVideo <OptimizedVideo
@ -93,7 +116,7 @@ const Media = ({
playsInline playsInline
draggable={false} draggable={false}
disablePictureInPicture disablePictureInPicture
onContextMenu={isProtected ? stopEvent : undefined} onContextMenu={isProtected && !contextActions ? stopEvent : undefined}
/> />
) : ( ) : (
<img <img
@ -102,7 +125,7 @@ const Media = ({
alt="" alt=""
draggable={false} draggable={false}
decoding="async" decoding="async"
onContextMenu={isProtected ? stopEvent : undefined} onContextMenu={isProtected && !contextActions ? stopEvent : undefined}
/> />
)} )}
{hasSpoiler && ( {hasSpoiler && (
@ -114,6 +137,38 @@ const Media = ({
)} )}
{video && <span className="video-duration">{video.isGif ? 'GIF' : formatMediaDuration(video.duration)}</span>} {video && <span className="video-duration">{video.isGif ? 'GIF' : formatMediaDuration(video.duration)}</span>}
{isProtected && <span className="protector" />} {isProtected && <span className="protector" />}
{contextActions && contextMenuAnchor !== undefined && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="shared-media-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{contextActions.map((action) => (
('isSeparator' in action) ? (
<MenuSeparator key={action.key || 'separator'} />
) : (
<MenuItem
key={action.title}
icon={action.icon}
destructive={action.destructive}
disabled={!action.handler}
onClick={action.handler}
>
{action.title}
</MenuItem>
)
))}
</Menu>
)}
</div> </div>
); );
}; };

View File

@ -11,6 +11,12 @@
margin-top: 1.5rem; 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 { &.without-media::before {
content: attr(data-initial); content: attr(data-initial);

View File

@ -1,9 +1,10 @@
import { memo, useMemo } from '../../lib/teact/teact'; import { memo, useMemo, useRef } from '../../lib/teact/teact';
import { withGlobal } from '../../global'; import { withGlobal } from '../../global';
import type { ApiMessage, ApiWebPage } from '../../api/types'; import type { ApiMessage, ApiWebPage } from '../../api/types';
import type { ObserveFn } from '../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../hooks/useIntersectionObserver';
import type { TextPart } from '../../types'; import type { TextPart } from '../../types';
import type { MenuItemContextAction } from '../ui/ListItem';
import { import {
getFirstLinkInMessage, getFirstLinkInMessage,
@ -16,11 +17,15 @@ import trimText from '../../util/trimText';
import { renderMessageSummary } from './helpers/renderMessageText'; import { renderMessageSummary } from './helpers/renderMessageText';
import renderText from './helpers/renderText'; import renderText from './helpers/renderText';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useLang from '../../hooks/useLang'; import useLang from '../../hooks/useLang';
import useLastCallback from '../../hooks/useLastCallback'; import useLastCallback from '../../hooks/useLastCallback';
import useOldLang from '../../hooks/useOldLang'; import useOldLang from '../../hooks/useOldLang';
import Link from '../ui/Link'; 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 Media from './Media';
import SafeLink from './SafeLink'; import SafeLink from './SafeLink';
@ -37,6 +42,7 @@ type OwnProps = {
senderTitle?: string; senderTitle?: string;
isProtected?: boolean; isProtected?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
contextActions?: MenuItemContextAction[];
onMessageClick: (message: ApiMessage) => void; onMessageClick: (message: ApiMessage) => void;
}; };
@ -45,8 +51,10 @@ type StateProps = {
}; };
const WebLink = ({ const WebLink = ({
message, webPage, senderTitle, isProtected, observeIntersection, onMessageClick, message, webPage, senderTitle, isProtected, observeIntersection, contextActions, onMessageClick,
}: OwnProps & StateProps) => { }: OwnProps & StateProps) => {
const ref = useRef<HTMLDivElement>();
const menuRef = useRef<HTMLDivElement>();
const lang = useLang(); const lang = useLang();
const oldLang = useOldLang(); const oldLang = useOldLang();
@ -54,6 +62,17 @@ const WebLink = ({
onMessageClick(message); 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; let linkData: ApiWebPageWithFormatted | undefined = webPage;
if (!linkData) { if (!linkData) {
@ -114,18 +133,27 @@ const WebLink = ({
const className = buildClassName( const className = buildClassName(
'WebLink scroll-item', 'WebLink scroll-item',
(!photo && !video) && 'without-media', (!photo && !video) && 'without-media',
contextMenuAnchor && 'has-menu-open',
); );
const safeLinkContent = displayUrl || url.replace('mailto:', ''); const safeLinkContent = displayUrl || url.replace('mailto:', '');
return ( return (
<div <div
ref={ref}
className={className} className={className}
data-initial={siteTitle[0]} data-initial={siteTitle[0]}
dir={lang.isRtl ? 'rtl' : undefined} dir={lang.isRtl ? 'rtl' : undefined}
onMouseDown={handleBeforeContextMenu}
onContextMenu={contextActions ? handleContextMenu : undefined}
> >
{photo && ( {photo && (
<Media message={message} isProtected={isProtected} observeIntersection={observeIntersection} /> <Media
message={message}
isProtected={isProtected}
observeIntersection={observeIntersection}
contextActions={contextActions}
/>
)} )}
<div className="content"> <div className="content">
<Link isRtl={lang.isRtl} className="site-title" onClick={handleMessageClick}> <Link isRtl={lang.isRtl} className="site-title" onClick={handleMessageClick}>
@ -155,6 +183,38 @@ const WebLink = ({
</Link> </Link>
</div> </div>
)} )}
{contextActions && contextMenuAnchor !== undefined && (
<Menu
ref={menuRef}
isOpen={isContextMenuOpen}
anchor={contextMenuAnchor}
getTriggerElement={getTriggerElement}
getRootElement={getRootElement}
getMenuElement={getMenuElement}
getLayout={getLayout}
className="shared-media-context-menu"
autoClose
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
withPortal
>
{contextActions.map((action) => (
('isSeparator' in action) ? (
<MenuSeparator key={action.key || 'separator'} />
) : (
<MenuItem
key={action.title}
icon={action.icon}
destructive={action.destructive}
disabled={!action.handler}
onClick={action.handler}
>
{action.title}
</MenuItem>
)
))}
</Menu>
)}
</div> </div>
); );
}; };

View File

@ -710,6 +710,16 @@ const Profile = ({
focusMessage({ chatId: message.chatId, messageId: message.id }); 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(() => { const handleDeleteMembersModalClose = useLastCallback(() => {
setDeletingUserId(undefined); setDeletingUserId(undefined);
}); });
@ -1023,6 +1033,7 @@ const Profile = ({
canAutoPlay={canAutoPlayGifs} canAutoPlay={canAutoPlayGifs}
observeIntersection={observeIntersectionForMedia} observeIntersection={observeIntersectionForMedia}
onClick={handleSelectMedia} onClick={handleSelectMedia}
contextActions={getMessageContextActions(messagesById[id])}
/> />
)) ))
) : (resultType === 'stories' || resultType === 'storiesArchive') ? ( ) : (resultType === 'stories' || resultType === 'storiesArchive') ? (
@ -1049,6 +1060,7 @@ const Profile = ({
message={messagesById[id]} message={messagesById[id]}
shouldWarnAboutFiles={shouldWarnAboutFiles} shouldWarnAboutFiles={shouldWarnAboutFiles}
onMediaClick={handleSelectMedia} onMediaClick={handleSelectMedia}
contextActions={getMessageContextActions(messagesById[id])}
/> />
)) ))
) : resultType === 'links' ? ( ) : resultType === 'links' ? (
@ -1058,6 +1070,7 @@ const Profile = ({
message={messagesById[id]} message={messagesById[id]}
isProtected={isChatProtected || messagesById[id].isProtected} isProtected={isChatProtected || messagesById[id].isProtected}
observeIntersection={observeIntersectionForMedia} observeIntersection={observeIntersectionForMedia}
contextActions={getMessageContextActions(messagesById[id])}
onMessageClick={handleMessageFocus} onMessageClick={handleMessageFocus}
/> />
)) ))
@ -1072,6 +1085,7 @@ const Profile = ({
className="scroll-item" className="scroll-item"
onPlay={handlePlayAudio} onPlay={handlePlayAudio}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
contextActions={getMessageContextActions(messagesById[id])}
canDownload={!isChatProtected && !messagesById[id].isProtected} canDownload={!isChatProtected && !messagesById[id].isProtected}
isDownloading={getIsDownloading(activeDownloads, messagesById[id].content.audio!)} isDownloading={getIsDownloading(activeDownloads, messagesById[id].content.audio!)}
/> />
@ -1093,6 +1107,7 @@ const Profile = ({
className="scroll-item" className="scroll-item"
onPlay={handlePlayAudio} onPlay={handlePlayAudio}
onDateClick={handleMessageFocus} onDateClick={handleMessageFocus}
contextActions={getMessageContextActions(message)}
canDownload={!isChatProtected && !message.isProtected} canDownload={!isChatProtected && !message.isProtected}
isDownloading={getIsDownloading(activeDownloads, media)} isDownloading={getIsDownloading(activeDownloads, media)}
/> />

View File

@ -18,6 +18,16 @@ function stopEvent(e: Event) {
e.stopPropagation(); 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 = ( const useContextMenuHandlers = (
elementRef: ElementRef<HTMLElement>, elementRef: ElementRef<HTMLElement>,
isMenuDisabled?: boolean, isMenuDisabled?: boolean,
@ -43,7 +53,7 @@ const useContextMenuHandlers = (
removeExtraClass(e.target as HTMLElement, 'no-selection'); removeExtraClass(e.target as HTMLElement, 'no-selection');
}); });
if (isMenuDisabled || (shouldDisableOnLink && (e.target as HTMLElement).matches('a[href]'))) { if (isMenuDisabled || (shouldDisableOnLink && isNativeLinkTarget(e.target))) {
return; return;
} }
e.preventDefault(); e.preventDefault();
@ -91,7 +101,7 @@ const useContextMenuHandlers = (
const { clientX, clientY, target } = originalEvent.touches[0]; const { clientX, clientY, target } = originalEvent.touches[0];
if (contextMenuAnchor || (shouldDisableOnLink && (target as HTMLElement).matches('a[href]'))) { if (contextMenuAnchor || (shouldDisableOnLink && isNativeLinkTarget(target))) {
return; return;
} }