Chat List: Allow mouse to open chats in new tabs (#2443)
This commit is contained in:
parent
ec1895b5d1
commit
6afb17978d
@ -13,6 +13,8 @@
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
|
||||
-webkit-touch-callout: none;
|
||||
|
||||
&.animate-opacity {
|
||||
will-change: opacity;
|
||||
transition: opacity 0.2s ease-out;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import React, { memo, useCallback, useEffect } from '../../../lib/teact/teact';
|
||||
import { getActions, withGlobal } from '../../../global';
|
||||
|
||||
import type { FC } from '../../../lib/teact/teact';
|
||||
import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
|
||||
import type {
|
||||
ApiChat,
|
||||
@ -17,6 +17,7 @@ import type { AnimationLevel } from '../../../types';
|
||||
import type { ChatAnimationTypes } from './hooks';
|
||||
|
||||
import { MAIN_THREAD_ID } from '../../../api/types';
|
||||
import { IS_MULTITAB_SUPPORTED } from '../../../util/environment';
|
||||
import {
|
||||
isUserId,
|
||||
getPrivateChatUserId,
|
||||
@ -37,6 +38,7 @@ import {
|
||||
selectThreadParam, selectTabState,
|
||||
} from '../../../global/selectors';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { createLocationHash } from '../../../util/routing';
|
||||
|
||||
import useChatContextActions from '../../../hooks/useChatContextActions';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
@ -225,6 +227,7 @@ const Chat: FC<OwnProps & StateProps> = ({
|
||||
<ListItem
|
||||
ref={ref}
|
||||
className={className}
|
||||
href={IS_MULTITAB_SUPPORTED ? `#${createLocationHash(chatId, 'thread', MAIN_THREAD_ID)}` : undefined}
|
||||
style={`top: ${offsetTop}px`}
|
||||
ripple={!isForum && !isMobile}
|
||||
contextActions={contextActions}
|
||||
|
||||
@ -11,6 +11,7 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
|
||||
import type { ChatAnimationTypes } from './hooks';
|
||||
import type { AnimationLevel } from '../../../types';
|
||||
|
||||
import { IS_MULTITAB_SUPPORTED } from '../../../util/environment';
|
||||
import {
|
||||
selectCanDeleteTopic,
|
||||
selectChat,
|
||||
@ -19,9 +20,11 @@ import {
|
||||
selectOutgoingStatus, selectThreadInfo, selectThreadParam, selectUser,
|
||||
} from '../../../global/selectors';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import useChatListEntry from './hooks/useChatListEntry';
|
||||
import { createLocationHash } from '../../../util/routing';
|
||||
import renderText from '../../common/helpers/renderText';
|
||||
import { getMessageAction } from '../../../global/helpers';
|
||||
|
||||
import useChatListEntry from './hooks/useChatListEntry';
|
||||
import useTopicContextActions from './hooks/useTopicContextActions';
|
||||
import useFlag from '../../../hooks/useFlag';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
@ -143,6 +146,7 @@ const Topic: FC<OwnProps & StateProps> = ({
|
||||
)}
|
||||
onClick={handleOpenTopic}
|
||||
style={style}
|
||||
href={IS_MULTITAB_SUPPORTED ? `#${createLocationHash(chatId, 'thread', topic.id)}` : undefined}
|
||||
contextActions={contextActions}
|
||||
ref={ref}
|
||||
>
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { getActions } from '../../../../global';
|
||||
import { useMemo } from '../../../../lib/teact/teact';
|
||||
|
||||
import type { ApiChat, ApiTopic } from '../../../../api/types';
|
||||
import type { MenuItemContextAction } from '../../../ui/ListItem';
|
||||
|
||||
import { compact } from '../../../../util/iteratees';
|
||||
import { getCanManageTopic, getHasAdminRight } from '../../../../global/helpers';
|
||||
import { IS_MULTITAB_SUPPORTED } from '../../../../util/environment';
|
||||
|
||||
import useLang from '../../../../hooks/useLang';
|
||||
import { useMemo } from '../../../../lib/teact/teact';
|
||||
|
||||
export default function useTopicContextActions(
|
||||
topic: ApiTopic,
|
||||
@ -29,11 +31,22 @@ export default function useTopicContextActions(
|
||||
toggleTopicPinned,
|
||||
markTopicRead,
|
||||
updateTopicMutedState,
|
||||
openChatInNewTab,
|
||||
} = getActions();
|
||||
|
||||
const canToggleClosed = getCanManageTopic(chat, topic);
|
||||
const canTogglePinned = chat.isCreator || getHasAdminRight(chat, 'manageTopics');
|
||||
|
||||
const actionOpenInNewTab = IS_MULTITAB_SUPPORTED && {
|
||||
title: 'Open in new tab',
|
||||
icon: 'open-in-new-tab',
|
||||
handler: () => {
|
||||
openChatInNewTab({ chatId: chat.id, threadId: topicId });
|
||||
},
|
||||
};
|
||||
|
||||
const newTabActionSeparator = actionOpenInNewTab && { isSeparator: true, key: 'newTabSeparator' };
|
||||
|
||||
const actionUnreadMark = topic.unreadCount || !wasOpened
|
||||
? {
|
||||
title: lang('MarkAsRead'),
|
||||
@ -88,11 +101,13 @@ export default function useTopicContextActions(
|
||||
} : undefined;
|
||||
|
||||
return compact([
|
||||
actionOpenInNewTab,
|
||||
newTabActionSeparator,
|
||||
actionPin,
|
||||
actionUnreadMark,
|
||||
actionMute,
|
||||
actionCloseTopic,
|
||||
actionDelete,
|
||||
]);
|
||||
]) as MenuItemContextAction[];
|
||||
}, [topic, chat, wasOpened, lang, canDelete, handleDelete]);
|
||||
}
|
||||
|
||||
@ -39,6 +39,8 @@
|
||||
border-radius: var(--border-radius-default);
|
||||
--ripple-color: rgba(0, 0, 0, 0.08);
|
||||
|
||||
text-decoration: none;
|
||||
|
||||
> i {
|
||||
font-size: 1.5rem;
|
||||
margin-right: 2rem;
|
||||
|
||||
@ -2,9 +2,10 @@ import type { RefObject } from 'react';
|
||||
import type { FC, TeactNode } from '../../lib/teact/teact';
|
||||
import React, { useRef, useCallback } from '../../lib/teact/teact';
|
||||
|
||||
import { IS_TOUCH_ENV } from '../../util/environment';
|
||||
import { IS_TOUCH_ENV, MouseButton } from '../../util/environment';
|
||||
import { fastRaf } from '../../util/schedulers';
|
||||
import buildClassName from '../../util/buildClassName';
|
||||
|
||||
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
|
||||
import useContextMenuPosition from '../../hooks/useContextMenuPosition';
|
||||
import useFlag from '../../hooks/useFlag';
|
||||
@ -13,20 +14,28 @@ import useLang from '../../hooks/useLang';
|
||||
import RippleEffect from './RippleEffect';
|
||||
import Menu from './Menu';
|
||||
import MenuItem from './MenuItem';
|
||||
import MenuSeparator from './MenuSeparator';
|
||||
import Button from './Button';
|
||||
|
||||
import './ListItem.scss';
|
||||
|
||||
interface MenuItemContextAction {
|
||||
type MenuItemContextActionItem = {
|
||||
title: string;
|
||||
icon: string;
|
||||
destructive?: boolean;
|
||||
handler?: () => void;
|
||||
}
|
||||
};
|
||||
|
||||
type MenuItemContextActionSeparator = {
|
||||
isSeparator: true;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type MenuItemContextAction = MenuItemContextActionItem | MenuItemContextActionSeparator;
|
||||
|
||||
interface OwnProps {
|
||||
ref?: RefObject<HTMLDivElement>;
|
||||
buttonRef?: RefObject<HTMLDivElement>;
|
||||
buttonRef?: RefObject<HTMLDivElement | HTMLAnchorElement>;
|
||||
icon?: string;
|
||||
leftElement?: TeactNode;
|
||||
secondaryIcon?: string;
|
||||
@ -47,13 +56,13 @@ interface OwnProps {
|
||||
contextActions?: MenuItemContextAction[];
|
||||
offsetCollapseDelta?: number;
|
||||
withPortalForMenu?: boolean;
|
||||
href?: string;
|
||||
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLDivElement>, arg?: any) => void;
|
||||
onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void;
|
||||
clickArg?: any;
|
||||
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
const ListItem: FC<OwnProps> = ({
|
||||
ref,
|
||||
buttonRef,
|
||||
@ -77,6 +86,7 @@ const ListItem: FC<OwnProps> = ({
|
||||
contextActions,
|
||||
withPortalForMenu,
|
||||
offsetCollapseDelta,
|
||||
href,
|
||||
onMouseDown,
|
||||
onClick,
|
||||
clickArg,
|
||||
@ -124,17 +134,35 @@ const ListItem: FC<OwnProps> = ({
|
||||
getLayout,
|
||||
);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const handleClickEvent = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
const hasModifierKey = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if (!hasModifierKey && e.button === MouseButton.Main) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
if ((disabled && !allowDisabledClick) || !onClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (href) {
|
||||
// Allow default behavior for opening links in new tab
|
||||
const hasModifierKey = e.ctrlKey || e.metaKey || e.shiftKey;
|
||||
if ((hasModifierKey && e.button === MouseButton.Main) || e.button === MouseButton.Auxiliary) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
onClick(e, clickArg);
|
||||
|
||||
if (IS_TOUCH_ENV && !ripple) {
|
||||
markIsTouched();
|
||||
fastRaf(unmarkIsTouched);
|
||||
}
|
||||
}, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched]);
|
||||
}, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched, href]);
|
||||
|
||||
const handleSecondaryIconClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
if ((disabled && !allowDisabledClick) || e.button !== 0 || (!onSecondaryIconClick && !contextActions)) return;
|
||||
@ -146,14 +174,14 @@ const ListItem: FC<OwnProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent<HTMLElement, MouseEvent>) => {
|
||||
if (inactive || IS_TOUCH_ENV) {
|
||||
return;
|
||||
}
|
||||
if (contextActions && (e.button === 2 || !onClick)) {
|
||||
if (contextActions && (e.button === MouseButton.Secondary || !onClick)) {
|
||||
handleBeforeContextMenu(e);
|
||||
}
|
||||
if (e.button === 0) {
|
||||
if (e.button === MouseButton.Main) {
|
||||
if (!onClick) {
|
||||
handleContextMenu(e);
|
||||
} else {
|
||||
@ -180,6 +208,8 @@ const ListItem: FC<OwnProps> = ({
|
||||
isStatic && 'is-static',
|
||||
);
|
||||
|
||||
const ButtonElementTag = href ? 'a' : 'div';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@ -190,12 +220,13 @@ const ListItem: FC<OwnProps> = ({
|
||||
onMouseDown={onMouseDown}
|
||||
onDragEnter={onDragEnter}
|
||||
>
|
||||
<div
|
||||
<ButtonElementTag
|
||||
className={buildClassName('ListItem-button', isTouched && 'active', buttonClassName)}
|
||||
role={!isStatic ? 'button' : undefined}
|
||||
ref={buttonRef}
|
||||
href={href}
|
||||
ref={buttonRef as any /* TS requires specific types for refs */}
|
||||
tabIndex={!isStatic ? 0 : undefined}
|
||||
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : undefined}
|
||||
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : handleClickEvent}
|
||||
onMouseDown={handleMouseDown}
|
||||
onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined}
|
||||
>
|
||||
@ -221,7 +252,7 @@ const ListItem: FC<OwnProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
{rightElement}
|
||||
</div>
|
||||
</ButtonElementTag>
|
||||
{contextActions && contextMenuPosition !== undefined && (
|
||||
<Menu
|
||||
isOpen={isContextMenuOpen}
|
||||
@ -237,15 +268,19 @@ const ListItem: FC<OwnProps> = ({
|
||||
withPortal={withPortalForMenu}
|
||||
>
|
||||
{contextActions.map((action) => (
|
||||
<MenuItem
|
||||
key={action.title}
|
||||
icon={action.icon}
|
||||
destructive={action.destructive}
|
||||
disabled={!action.handler}
|
||||
onClick={action.handler}
|
||||
>
|
||||
{action.title}
|
||||
</MenuItem>
|
||||
('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>
|
||||
)}
|
||||
|
||||
@ -72,9 +72,9 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
|
||||
});
|
||||
|
||||
addActionHandler('openChatInNewTab', (global, actions, payload): ActionReturnType => {
|
||||
const { chatId } = payload;
|
||||
const { chatId, threadId = MAIN_THREAD_ID } = payload;
|
||||
|
||||
window.open(createMessageHashUrl(chatId, 'thread', MAIN_THREAD_ID), '_blank');
|
||||
window.open(createMessageHashUrl(chatId, 'thread', threadId), '_blank');
|
||||
});
|
||||
|
||||
addActionHandler('openPreviousChat', (global, actions, payload): ActionReturnType => {
|
||||
|
||||
@ -1489,6 +1489,7 @@ export interface ActionPayloads {
|
||||
} & WithTabId;
|
||||
openChatInNewTab: {
|
||||
chatId: string;
|
||||
threadId?: number;
|
||||
};
|
||||
onTabFocusChange: {
|
||||
isBlurred: boolean;
|
||||
|
||||
@ -2,6 +2,7 @@ import { useMemo } from '../lib/teact/teact';
|
||||
import { getActions } from '../global';
|
||||
|
||||
import type { ApiChat, ApiUser } from '../api/types';
|
||||
import type { MenuItemContextAction } from '../components/ui/ListItem';
|
||||
|
||||
import { IS_MULTITAB_SUPPORTED } from '../util/environment';
|
||||
import { SERVICE_NOTIFICATIONS_USER_ID } from '../config';
|
||||
@ -58,6 +59,8 @@ const useChatContextActions = ({
|
||||
},
|
||||
};
|
||||
|
||||
const newTabActionSeparator = actionOpenInNewTab && { isSeparator: true, key: 'newTabSeparator' };
|
||||
|
||||
const actionAddToFolder = canChangeFolder ? {
|
||||
title: lang('ChatList.Filter.AddToFolder'),
|
||||
icon: 'folder',
|
||||
@ -119,6 +122,7 @@ const useChatContextActions = ({
|
||||
|
||||
return compact([
|
||||
actionOpenInNewTab,
|
||||
newTabActionSeparator,
|
||||
actionAddToFolder,
|
||||
actionMaskAsRead,
|
||||
actionMarkAsUnread,
|
||||
@ -127,7 +131,7 @@ const useChatContextActions = ({
|
||||
!isSelf && !isServiceNotifications && !isInFolder && actionArchive,
|
||||
actionReport,
|
||||
actionDelete,
|
||||
]);
|
||||
]) as MenuItemContextAction[];
|
||||
}, [
|
||||
chat, user, canChangeFolder, lang, handleChatFolderChange, isPinned, isInSearch, isMuted,
|
||||
handleDelete, handleReport, folderId, isSelf, isServiceNotifications,
|
||||
|
||||
@ -42,6 +42,14 @@ export const IS_ANDROID = PLATFORM_ENV === 'Android';
|
||||
export const IS_SAFARI = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
||||
export const IS_YA_BROWSER = navigator.userAgent.includes('YaBrowser');
|
||||
|
||||
export enum MouseButton {
|
||||
Main = 0,
|
||||
Auxiliary = 1,
|
||||
Secondary = 2,
|
||||
Fourth = 3,
|
||||
Fifth = 4,
|
||||
}
|
||||
|
||||
export const IS_PWA = (
|
||||
window.matchMedia('(display-mode: standalone)').matches
|
||||
|| (window.navigator as any).standalone
|
||||
|
||||
@ -7,7 +7,7 @@ let parsedInitialLocationHash: Record<string, string> | undefined;
|
||||
let messageHash: string | undefined;
|
||||
let isAlreadyParsed = false;
|
||||
|
||||
export const createLocationHash = (chatId: string, type: string, threadId: number): string => {
|
||||
export const createLocationHash = (chatId: string, type: MessageListType, threadId: number): string => {
|
||||
const displayType = type === 'thread' ? undefined : type;
|
||||
const parts = threadId === MAIN_THREAD_ID ? [chatId, displayType] : [chatId, threadId, displayType];
|
||||
|
||||
@ -44,7 +44,7 @@ export function parseLocationHash() {
|
||||
};
|
||||
}
|
||||
|
||||
export const createMessageHashUrl = (chatId: string, type: string, threadId: number): string => {
|
||||
export const createMessageHashUrl = (chatId: string, type: MessageListType, threadId: number): string => {
|
||||
const url = new URL(window.location.href);
|
||||
url.hash = createLocationHash(chatId, type, threadId);
|
||||
return url.href;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user