Chat List: Allow mouse to open chats in new tabs (#2443)

This commit is contained in:
Alexander Zinchuk 2023-02-13 03:32:37 +01:00
parent ec1895b5d1
commit 6afb17978d
11 changed files with 107 additions and 33 deletions

View File

@ -13,6 +13,8 @@
margin: 0;
width: 100%;
-webkit-touch-callout: none;
&.animate-opacity {
will-change: opacity;
transition: opacity 0.2s ease-out;

View File

@ -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}

View File

@ -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}
>

View File

@ -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]);
}

View File

@ -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;

View File

@ -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>
)}

View File

@ -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 => {

View File

@ -1489,6 +1489,7 @@ export interface ActionPayloads {
} & WithTabId;
openChatInNewTab: {
chatId: string;
threadId?: number;
};
onTabFocusChange: {
isBlurred: boolean;

View File

@ -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,

View File

@ -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

View File

@ -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;