Left Search: Support drag-n-drop for attachments

This commit is contained in:
Alexander Zinchuk 2025-10-15 19:57:19 +02:00
parent 3c3e8401db
commit 888e65cf6c
6 changed files with 88 additions and 56 deletions

View File

@ -94,9 +94,10 @@ type OwnProps = {
isPreview?: boolean; isPreview?: boolean;
previewMessageId?: number; previewMessageId?: number;
className?: string; className?: string;
withTags?: boolean;
observeIntersection?: ObserveFn; observeIntersection?: ObserveFn;
onDragEnter?: (chatId: string) => void; onDragEnter?: (chatId: string) => void;
withTags?: boolean; onDragLeave?: NoneToVoidFunction;
onReorderAnimationEnd?: NoneToVoidFunction; onReorderAnimationEnd?: NoneToVoidFunction;
}; };
@ -167,6 +168,7 @@ const Chat: FC<OwnProps & StateProps> = ({
className, className,
isSynced, isSynced,
onDragEnter, onDragEnter,
onDragLeave,
isAccountFrozen, isAccountFrozen,
chatFolderIds, chatFolderIds,
orderedFolderIds, orderedFolderIds,
@ -408,9 +410,10 @@ const Chat: FC<OwnProps & StateProps> = ({
style={`top: ${offsetTop}px`} style={`top: ${offsetTop}px`}
ripple={!isForum && !isMobile} ripple={!isForum && !isMobile}
contextActions={contextActions} contextActions={contextActions}
withPortalForMenu
onClick={handleClick} onClick={handleClick}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
withPortalForMenu onDragLeave={onDragLeave}
> >
<div className={buildClassName('status', 'status-clickable')}> <div className={buildClassName('status', 'status-clickable')}>
<Avatar <Avatar

View File

@ -1,8 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '@teact';
import type React from '../../../lib/teact/teact'; import { memo, useEffect, useMemo, useRef, useState } from '@teact';
import {
memo, useEffect, useMemo, useRef, useState,
} from '../../../lib/teact/teact';
import { getActions } from '../../../global'; import { getActions } from '../../../global';
import type { ApiSession } from '../../../api/types'; import type { ApiSession } from '../../../api/types';
@ -21,12 +18,12 @@ import {
} from '../../../config'; } from '../../../config';
import { IS_APP, IS_MAC_OS } from '../../../util/browser/windowEnvironment'; import { IS_APP, IS_MAC_OS } from '../../../util/browser/windowEnvironment';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers.ts';
import { getOrderKey, getPinnedChatsCount } from '../../../util/folderManager'; import { getOrderKey, getPinnedChatsCount } from '../../../util/folderManager';
import { getServerTime } from '../../../util/serverTime'; import { getServerTime } from '../../../util/serverTime';
import usePeerStoriesPolling from '../../../hooks/polling/usePeerStoriesPolling'; import usePeerStoriesPolling from '../../../hooks/polling/usePeerStoriesPolling';
import useTopOverscroll from '../../../hooks/scroll/useTopOverscroll'; import useTopOverscroll from '../../../hooks/scroll/useTopOverscroll';
import useDebouncedCallback from '../../../hooks/useDebouncedCallback';
import { useFolderManagerForOrderedIds } from '../../../hooks/useFolderManager'; import { useFolderManagerForOrderedIds } from '../../../hooks/useFolderManager';
import { useHotkeys } from '../../../hooks/useHotkeys'; import { useHotkeys } from '../../../hooks/useHotkeys';
import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll';
@ -58,7 +55,6 @@ type OwnProps = {
}; };
const INTERSECTION_THROTTLE = 200; const INTERSECTION_THROTTLE = 200;
const DRAG_ENTER_DEBOUNCE = 500;
const RESERVED_HOTKEYS = new Set(['9', '0']); const RESERVED_HOTKEYS = new Set(['9', '0']);
const ChatList: FC<OwnProps> = ({ const ChatList: FC<OwnProps> = ({
@ -84,7 +80,6 @@ const ChatList: FC<OwnProps> = ({
openLeftColumnContent, openLeftColumnContent,
} = getActions(); } = getActions();
const containerRef = useRef<HTMLDivElement>(); const containerRef = useRef<HTMLDivElement>();
const shouldIgnoreDragRef = useRef(false);
const [unconfirmedSessionHeight, setUnconfirmedSessionHeight] = useState(0); const [unconfirmedSessionHeight, setUnconfirmedSessionHeight] = useState(0);
const isArchived = folderType === 'archived'; const isArchived = folderType === 'archived';
@ -183,30 +178,6 @@ const ChatList: FC<OwnProps> = ({
openFrozenAccountModal(); openFrozenAccountModal();
}); });
const handleArchivedDragEnter = useLastCallback(() => {
if (shouldIgnoreDragRef.current) {
shouldIgnoreDragRef.current = false;
return;
}
handleArchivedClick();
});
const handleDragEnter = useDebouncedCallback((chatId: string) => {
if (shouldIgnoreDragRef.current) {
shouldIgnoreDragRef.current = false;
return;
}
openChat({ id: chatId, shouldReplaceHistory: true });
}, [openChat], DRAG_ENTER_DEBOUNCE, true);
const handleDragLeave = useLastCallback((e: React.DragEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
if (x < rect.width || y < rect.y) return;
shouldIgnoreDragRef.current = true;
});
const handleShowStoryRibbon = useLastCallback(() => { const handleShowStoryRibbon = useLastCallback(() => {
toggleStoryRibbon({ isShown: true, isArchived }); toggleStoryRibbon({ isShown: true, isArchived });
}); });
@ -215,6 +186,18 @@ const ChatList: FC<OwnProps> = ({
toggleStoryRibbon({ isShown: false, isArchived }); toggleStoryRibbon({ isShown: false, isArchived });
}); });
const handleArchivedDragEnter = useLastCallback(() => {
onDragEnter(() => {
handleArchivedClick();
});
});
const handleChatDragEnter = useLastCallback((chatId: string) => {
onDragEnter(() => {
openChat({ id: chatId, shouldReplaceHistory: true });
});
});
useTopOverscroll({ useTopOverscroll({
containerRef, containerRef,
onOverscroll: handleShowStoryRibbon, onOverscroll: handleShowStoryRibbon,
@ -245,7 +228,8 @@ const ChatList: FC<OwnProps> = ({
onReorderAnimationEnd={onReorderAnimationEnd} onReorderAnimationEnd={onReorderAnimationEnd}
offsetTop={offsetTop} offsetTop={offsetTop}
observeIntersection={observe} observeIntersection={observe}
onDragEnter={handleDragEnter} onDragEnter={handleChatDragEnter}
onDragLeave={onDragLeave}
withTags={withTags} withTags={withTags}
/> />
); );
@ -262,7 +246,6 @@ const ChatList: FC<OwnProps> = ({
withAbsolutePositioning withAbsolutePositioning
maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight} maxHeight={chatsHeight + archiveHeight + frozenNotificationHeight + unconfirmedSessionHeight}
onLoadMore={getMore} onLoadMore={getMore}
onDragLeave={handleDragLeave}
> >
{shouldShowUnconfirmedSessions && ( {shouldShowUnconfirmedSessions && (
<UnconfirmedSession <UnconfirmedSession

View File

@ -1,5 +1,5 @@
import type { FC } from '../../../lib/teact/teact'; import type { FC } from '@teact';
import { memo, useCallback } from '../../../lib/teact/teact'; import { memo, useCallback } from '@teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { ApiChat, ApiUser } from '../../../api/types'; import type { ApiChat, ApiUser } from '../../../api/types';
@ -8,8 +8,13 @@ import { StoryViewerOrigin } from '../../../types';
import { UNMUTE_TIMESTAMP } from '../../../config'; import { UNMUTE_TIMESTAMP } from '../../../config';
import { getIsChatMuted } from '../../../global/helpers/notifications'; import { getIsChatMuted } from '../../../global/helpers/notifications';
import { import {
selectChat, selectIsChatPinned, selectNotifyDefaults, selectNotifyException, selectUser, selectChat,
selectIsChatPinned,
selectNotifyDefaults,
selectNotifyException,
selectUser,
} from '../../../global/selectors'; } from '../../../global/selectors';
import { onDragEnter, onDragLeave } from '../../../util/dragNDropHandlers.ts';
import { isUserId } from '../../../util/entities/ids'; import { isUserId } from '../../../util/entities/ids';
import { extractCurrentThemeParams } from '../../../util/themeStyle'; import { extractCurrentThemeParams } from '../../../util/themeStyle';
@ -105,6 +110,14 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
}); });
}); });
const handleDragEnter = useLastCallback((e) => {
e.preventDefault();
onDragEnter(() => {
onClick(chatId);
}, true);
});
const buttonRef = useSelectWithEnter(() => { const buttonRef = useSelectWithEnter(() => {
onClick(chatId); onClick(chatId);
}); });
@ -112,9 +125,11 @@ const LeftSearchResultChat: FC<OwnProps & StateProps> = ({
return ( return (
<ListItem <ListItem
className="chat-item-clickable search-result" className="chat-item-clickable search-result"
onClick={handleClick}
contextActions={contextActions} contextActions={contextActions}
buttonRef={buttonRef} buttonRef={buttonRef}
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={onDragLeave}
> >
{isUserId(chatId) ? ( {isUserId(chatId) ? (
<PrivateChatInfo <PrivateChatInfo

View File

@ -37,8 +37,6 @@ type OwnProps = {
onWheel?: (e: React.WheelEvent<HTMLDivElement>) => void; onWheel?: (e: React.WheelEvent<HTMLDivElement>) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void; onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
onKeyDown?: (e: React.KeyboardEvent<any>) => void; onKeyDown?: (e: React.KeyboardEvent<any>) => void;
onDragOver?: (e: React.DragEvent<HTMLDivElement>) => void;
onDragLeave?: (e: React.DragEvent<HTMLDivElement>) => void;
}; };
const DEFAULT_LIST_SELECTOR = '.ListItem'; const DEFAULT_LIST_SELECTOR = '.ListItem';
@ -69,8 +67,6 @@ const InfiniteScroll: FC<OwnProps> = ({
onWheel, onWheel,
onClick, onClick,
onKeyDown, onKeyDown,
onDragOver,
onDragLeave,
}: OwnProps) => { }: OwnProps) => {
let containerRef = useRef<HTMLDivElement>(); let containerRef = useRef<HTMLDivElement>();
if (ref) { if (ref) {
@ -270,13 +266,11 @@ const InfiniteScroll: FC<OwnProps> = ({
<div <div
ref={containerRef} ref={containerRef}
className={className} className={className}
onWheel={onWheel}
teactFastList={!noFastList && !withAbsolutePositioning}
onKeyDown={onKeyDown}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onClick={onClick}
style={style} style={style}
teactFastList={!noFastList && !withAbsolutePositioning}
onClick={onClick}
onKeyDown={onKeyDown}
onWheel={onWheel}
> >
{beforeChildren} {beforeChildren}
{withAbsolutePositioning && items?.length ? ( {withAbsolutePositioning && items?.length ? (

View File

@ -69,13 +69,14 @@ interface OwnProps {
withPortalForMenu?: boolean; withPortalForMenu?: boolean;
menuBubbleClassName?: string; menuBubbleClassName?: string;
href?: string; href?: string;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void; nonInteractive?: boolean;
onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void; onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void;
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
clickArg?: any; clickArg?: any;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
onContextMenu?: (e: React.MouseEvent<HTMLElement>) => void;
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void; onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void;
nonInteractive?: boolean; onDragLeave?: NoneToVoidFunction;
} }
const ListItem = ({ const ListItem = ({
@ -107,13 +108,14 @@ const ListItem = ({
contextActions, contextActions,
withPortalForMenu, withPortalForMenu,
href, href,
onMouseDown, nonInteractive,
onClick, onClick,
onContextMenu,
clickArg, clickArg,
onMouseDown,
onContextMenu,
onSecondaryIconClick, onSecondaryIconClick,
onDragEnter, onDragEnter,
nonInteractive, onDragLeave,
}: OwnProps) => { }: OwnProps) => {
let containerRef = useRef<HTMLDivElement>(); let containerRef = useRef<HTMLDivElement>();
if (ref) { if (ref) {
@ -229,6 +231,7 @@ const ListItem = ({
style={style} style={style}
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
> >
<ButtonElementTag <ButtonElementTag
className={buildClassName('ListItem-button', isTouched && 'active', buttonClassName)} className={buildClassName('ListItem-button', isTouched && 'active', buttonClassName)}

View File

@ -0,0 +1,34 @@
import { debounce } from './schedulers.ts';
const DRAG_ENTER_DEBOUNCE = 50; // Workaround for `dragenter` firing before previous `dragleave`
const DRAG_ENTER_ACTION_DELAY = 500;
let willSkipNext = false;
let timeout: number | undefined;
export const onDragEnter = debounce((cb: NoneToVoidFunction, shouldSkipNext = false) => {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
if (willSkipNext) {
willSkipNext = false;
return;
}
timeout = window.setTimeout(() => {
if (shouldSkipNext) {
willSkipNext = true;
}
cb();
}, DRAG_ENTER_ACTION_DELAY);
}, DRAG_ENTER_DEBOUNCE, false);
export function onDragLeave() {
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
}