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; margin: 0;
width: 100%; width: 100%;
-webkit-touch-callout: none;
&.animate-opacity { &.animate-opacity {
will-change: opacity; will-change: opacity;
transition: opacity 0.2s ease-out; 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 React, { memo, useCallback, useEffect } from '../../../lib/teact/teact';
import { getActions, withGlobal } from '../../../global'; import { getActions, withGlobal } from '../../../global';
import type { FC } from '../../../lib/teact/teact';
import type { ObserveFn } from '../../../hooks/useIntersectionObserver'; import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { import type {
ApiChat, ApiChat,
@ -17,6 +17,7 @@ import type { AnimationLevel } from '../../../types';
import type { ChatAnimationTypes } from './hooks'; import type { ChatAnimationTypes } from './hooks';
import { MAIN_THREAD_ID } from '../../../api/types'; import { MAIN_THREAD_ID } from '../../../api/types';
import { IS_MULTITAB_SUPPORTED } from '../../../util/environment';
import { import {
isUserId, isUserId,
getPrivateChatUserId, getPrivateChatUserId,
@ -37,6 +38,7 @@ import {
selectThreadParam, selectTabState, selectThreadParam, selectTabState,
} from '../../../global/selectors'; } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import { createLocationHash } from '../../../util/routing';
import useChatContextActions from '../../../hooks/useChatContextActions'; import useChatContextActions from '../../../hooks/useChatContextActions';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
@ -225,6 +227,7 @@ const Chat: FC<OwnProps & StateProps> = ({
<ListItem <ListItem
ref={ref} ref={ref}
className={className} className={className}
href={IS_MULTITAB_SUPPORTED ? `#${createLocationHash(chatId, 'thread', MAIN_THREAD_ID)}` : undefined}
style={`top: ${offsetTop}px`} style={`top: ${offsetTop}px`}
ripple={!isForum && !isMobile} ripple={!isForum && !isMobile}
contextActions={contextActions} contextActions={contextActions}

View File

@ -11,6 +11,7 @@ import type { ObserveFn } from '../../../hooks/useIntersectionObserver';
import type { ChatAnimationTypes } from './hooks'; import type { ChatAnimationTypes } from './hooks';
import type { AnimationLevel } from '../../../types'; import type { AnimationLevel } from '../../../types';
import { IS_MULTITAB_SUPPORTED } from '../../../util/environment';
import { import {
selectCanDeleteTopic, selectCanDeleteTopic,
selectChat, selectChat,
@ -19,9 +20,11 @@ import {
selectOutgoingStatus, selectThreadInfo, selectThreadParam, selectUser, selectOutgoingStatus, selectThreadInfo, selectThreadParam, selectUser,
} from '../../../global/selectors'; } from '../../../global/selectors';
import buildClassName from '../../../util/buildClassName'; import buildClassName from '../../../util/buildClassName';
import useChatListEntry from './hooks/useChatListEntry'; import { createLocationHash } from '../../../util/routing';
import renderText from '../../common/helpers/renderText'; import renderText from '../../common/helpers/renderText';
import { getMessageAction } from '../../../global/helpers'; import { getMessageAction } from '../../../global/helpers';
import useChatListEntry from './hooks/useChatListEntry';
import useTopicContextActions from './hooks/useTopicContextActions'; import useTopicContextActions from './hooks/useTopicContextActions';
import useFlag from '../../../hooks/useFlag'; import useFlag from '../../../hooks/useFlag';
import useLang from '../../../hooks/useLang'; import useLang from '../../../hooks/useLang';
@ -143,6 +146,7 @@ const Topic: FC<OwnProps & StateProps> = ({
)} )}
onClick={handleOpenTopic} onClick={handleOpenTopic}
style={style} style={style}
href={IS_MULTITAB_SUPPORTED ? `#${createLocationHash(chatId, 'thread', topic.id)}` : undefined}
contextActions={contextActions} contextActions={contextActions}
ref={ref} ref={ref}
> >

View File

@ -1,12 +1,14 @@
import { getActions } from '../../../../global'; import { getActions } from '../../../../global';
import { useMemo } from '../../../../lib/teact/teact';
import type { ApiChat, ApiTopic } from '../../../../api/types'; import type { ApiChat, ApiTopic } from '../../../../api/types';
import type { MenuItemContextAction } from '../../../ui/ListItem';
import { compact } from '../../../../util/iteratees'; import { compact } from '../../../../util/iteratees';
import { getCanManageTopic, getHasAdminRight } from '../../../../global/helpers'; import { getCanManageTopic, getHasAdminRight } from '../../../../global/helpers';
import { IS_MULTITAB_SUPPORTED } from '../../../../util/environment';
import useLang from '../../../../hooks/useLang'; import useLang from '../../../../hooks/useLang';
import { useMemo } from '../../../../lib/teact/teact';
export default function useTopicContextActions( export default function useTopicContextActions(
topic: ApiTopic, topic: ApiTopic,
@ -29,11 +31,22 @@ export default function useTopicContextActions(
toggleTopicPinned, toggleTopicPinned,
markTopicRead, markTopicRead,
updateTopicMutedState, updateTopicMutedState,
openChatInNewTab,
} = getActions(); } = getActions();
const canToggleClosed = getCanManageTopic(chat, topic); const canToggleClosed = getCanManageTopic(chat, topic);
const canTogglePinned = chat.isCreator || getHasAdminRight(chat, 'manageTopics'); 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 const actionUnreadMark = topic.unreadCount || !wasOpened
? { ? {
title: lang('MarkAsRead'), title: lang('MarkAsRead'),
@ -88,11 +101,13 @@ export default function useTopicContextActions(
} : undefined; } : undefined;
return compact([ return compact([
actionOpenInNewTab,
newTabActionSeparator,
actionPin, actionPin,
actionUnreadMark, actionUnreadMark,
actionMute, actionMute,
actionCloseTopic, actionCloseTopic,
actionDelete, actionDelete,
]); ]) as MenuItemContextAction[];
}, [topic, chat, wasOpened, lang, canDelete, handleDelete]); }, [topic, chat, wasOpened, lang, canDelete, handleDelete]);
} }

View File

@ -39,6 +39,8 @@
border-radius: var(--border-radius-default); border-radius: var(--border-radius-default);
--ripple-color: rgba(0, 0, 0, 0.08); --ripple-color: rgba(0, 0, 0, 0.08);
text-decoration: none;
> i { > i {
font-size: 1.5rem; font-size: 1.5rem;
margin-right: 2rem; margin-right: 2rem;

View File

@ -2,9 +2,10 @@ import type { RefObject } from 'react';
import type { FC, TeactNode } from '../../lib/teact/teact'; import type { FC, TeactNode } from '../../lib/teact/teact';
import React, { useRef, useCallback } 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 { fastRaf } from '../../util/schedulers';
import buildClassName from '../../util/buildClassName'; import buildClassName from '../../util/buildClassName';
import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; import useContextMenuHandlers from '../../hooks/useContextMenuHandlers';
import useContextMenuPosition from '../../hooks/useContextMenuPosition'; import useContextMenuPosition from '../../hooks/useContextMenuPosition';
import useFlag from '../../hooks/useFlag'; import useFlag from '../../hooks/useFlag';
@ -13,20 +14,28 @@ import useLang from '../../hooks/useLang';
import RippleEffect from './RippleEffect'; import RippleEffect from './RippleEffect';
import Menu from './Menu'; import Menu from './Menu';
import MenuItem from './MenuItem'; import MenuItem from './MenuItem';
import MenuSeparator from './MenuSeparator';
import Button from './Button'; import Button from './Button';
import './ListItem.scss'; import './ListItem.scss';
interface MenuItemContextAction { type MenuItemContextActionItem = {
title: string; title: string;
icon: string; icon: string;
destructive?: boolean; destructive?: boolean;
handler?: () => void; handler?: () => void;
} };
type MenuItemContextActionSeparator = {
isSeparator: true;
key?: string;
};
export type MenuItemContextAction = MenuItemContextActionItem | MenuItemContextActionSeparator;
interface OwnProps { interface OwnProps {
ref?: RefObject<HTMLDivElement>; ref?: RefObject<HTMLDivElement>;
buttonRef?: RefObject<HTMLDivElement>; buttonRef?: RefObject<HTMLDivElement | HTMLAnchorElement>;
icon?: string; icon?: string;
leftElement?: TeactNode; leftElement?: TeactNode;
secondaryIcon?: string; secondaryIcon?: string;
@ -47,13 +56,13 @@ interface OwnProps {
contextActions?: MenuItemContextAction[]; contextActions?: MenuItemContextAction[];
offsetCollapseDelta?: number; offsetCollapseDelta?: number;
withPortalForMenu?: boolean; withPortalForMenu?: boolean;
href?: string;
onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void; onMouseDown?: (e: React.MouseEvent<HTMLDivElement>) => void;
onClick?: (e: React.MouseEvent<HTMLDivElement>, arg?: any) => void; onClick?: (e: React.MouseEvent<HTMLElement>, arg?: any) => void;
clickArg?: any; clickArg?: any;
onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; onSecondaryIconClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void; onDragEnter?: (e: React.DragEvent<HTMLDivElement>) => void;
} }
const ListItem: FC<OwnProps> = ({ const ListItem: FC<OwnProps> = ({
ref, ref,
buttonRef, buttonRef,
@ -77,6 +86,7 @@ const ListItem: FC<OwnProps> = ({
contextActions, contextActions,
withPortalForMenu, withPortalForMenu,
offsetCollapseDelta, offsetCollapseDelta,
href,
onMouseDown, onMouseDown,
onClick, onClick,
clickArg, clickArg,
@ -124,17 +134,35 @@ const ListItem: FC<OwnProps> = ({
getLayout, 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) { if ((disabled && !allowDisabledClick) || !onClick) {
return; 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); onClick(e, clickArg);
if (IS_TOUCH_ENV && !ripple) { if (IS_TOUCH_ENV && !ripple) {
markIsTouched(); markIsTouched();
fastRaf(unmarkIsTouched); fastRaf(unmarkIsTouched);
} }
}, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched]); }, [allowDisabledClick, clickArg, disabled, markIsTouched, onClick, ripple, unmarkIsTouched, href]);
const handleSecondaryIconClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => { const handleSecondaryIconClick = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
if ((disabled && !allowDisabledClick) || e.button !== 0 || (!onSecondaryIconClick && !contextActions)) return; 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) { if (inactive || IS_TOUCH_ENV) {
return; return;
} }
if (contextActions && (e.button === 2 || !onClick)) { if (contextActions && (e.button === MouseButton.Secondary || !onClick)) {
handleBeforeContextMenu(e); handleBeforeContextMenu(e);
} }
if (e.button === 0) { if (e.button === MouseButton.Main) {
if (!onClick) { if (!onClick) {
handleContextMenu(e); handleContextMenu(e);
} else { } else {
@ -180,6 +208,8 @@ const ListItem: FC<OwnProps> = ({
isStatic && 'is-static', isStatic && 'is-static',
); );
const ButtonElementTag = href ? 'a' : 'div';
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -190,12 +220,13 @@ const ListItem: FC<OwnProps> = ({
onMouseDown={onMouseDown} onMouseDown={onMouseDown}
onDragEnter={onDragEnter} onDragEnter={onDragEnter}
> >
<div <ButtonElementTag
className={buildClassName('ListItem-button', isTouched && 'active', buttonClassName)} className={buildClassName('ListItem-button', isTouched && 'active', buttonClassName)}
role={!isStatic ? 'button' : undefined} role={!isStatic ? 'button' : undefined}
ref={buttonRef} href={href}
ref={buttonRef as any /* TS requires specific types for refs */}
tabIndex={!isStatic ? 0 : undefined} tabIndex={!isStatic ? 0 : undefined}
onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : undefined} onClick={(!inactive && IS_TOUCH_ENV) ? handleClick : handleClickEvent}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined} onContextMenu={(!inactive && contextActions) ? handleContextMenu : undefined}
> >
@ -221,7 +252,7 @@ const ListItem: FC<OwnProps> = ({
</Button> </Button>
)} )}
{rightElement} {rightElement}
</div> </ButtonElementTag>
{contextActions && contextMenuPosition !== undefined && ( {contextActions && contextMenuPosition !== undefined && (
<Menu <Menu
isOpen={isContextMenuOpen} isOpen={isContextMenuOpen}
@ -237,15 +268,19 @@ const ListItem: FC<OwnProps> = ({
withPortal={withPortalForMenu} withPortal={withPortalForMenu}
> >
{contextActions.map((action) => ( {contextActions.map((action) => (
<MenuItem ('isSeparator' in action) ? (
key={action.title} <MenuSeparator key={action.key || 'separator'} />
icon={action.icon} ) : (
destructive={action.destructive} <MenuItem
disabled={!action.handler} key={action.title}
onClick={action.handler} icon={action.icon}
> destructive={action.destructive}
{action.title} disabled={!action.handler}
</MenuItem> onClick={action.handler}
>
{action.title}
</MenuItem>
)
))} ))}
</Menu> </Menu>
)} )}

View File

@ -72,9 +72,9 @@ addActionHandler('openChat', (global, actions, payload): ActionReturnType => {
}); });
addActionHandler('openChatInNewTab', (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 => { addActionHandler('openPreviousChat', (global, actions, payload): ActionReturnType => {

View File

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

View File

@ -2,6 +2,7 @@ import { useMemo } from '../lib/teact/teact';
import { getActions } from '../global'; import { getActions } from '../global';
import type { ApiChat, ApiUser } from '../api/types'; import type { ApiChat, ApiUser } from '../api/types';
import type { MenuItemContextAction } from '../components/ui/ListItem';
import { IS_MULTITAB_SUPPORTED } from '../util/environment'; import { IS_MULTITAB_SUPPORTED } from '../util/environment';
import { SERVICE_NOTIFICATIONS_USER_ID } from '../config'; import { SERVICE_NOTIFICATIONS_USER_ID } from '../config';
@ -58,6 +59,8 @@ const useChatContextActions = ({
}, },
}; };
const newTabActionSeparator = actionOpenInNewTab && { isSeparator: true, key: 'newTabSeparator' };
const actionAddToFolder = canChangeFolder ? { const actionAddToFolder = canChangeFolder ? {
title: lang('ChatList.Filter.AddToFolder'), title: lang('ChatList.Filter.AddToFolder'),
icon: 'folder', icon: 'folder',
@ -119,6 +122,7 @@ const useChatContextActions = ({
return compact([ return compact([
actionOpenInNewTab, actionOpenInNewTab,
newTabActionSeparator,
actionAddToFolder, actionAddToFolder,
actionMaskAsRead, actionMaskAsRead,
actionMarkAsUnread, actionMarkAsUnread,
@ -127,7 +131,7 @@ const useChatContextActions = ({
!isSelf && !isServiceNotifications && !isInFolder && actionArchive, !isSelf && !isServiceNotifications && !isInFolder && actionArchive,
actionReport, actionReport,
actionDelete, actionDelete,
]); ]) as MenuItemContextAction[];
}, [ }, [
chat, user, canChangeFolder, lang, handleChatFolderChange, isPinned, isInSearch, isMuted, chat, user, canChangeFolder, lang, handleChatFolderChange, isPinned, isInSearch, isMuted,
handleDelete, handleReport, folderId, isSelf, isServiceNotifications, 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_SAFARI = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
export const IS_YA_BROWSER = navigator.userAgent.includes('YaBrowser'); 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 = ( export const IS_PWA = (
window.matchMedia('(display-mode: standalone)').matches window.matchMedia('(display-mode: standalone)').matches
|| (window.navigator as any).standalone || (window.navigator as any).standalone

View File

@ -7,7 +7,7 @@ let parsedInitialLocationHash: Record<string, string> | undefined;
let messageHash: string | undefined; let messageHash: string | undefined;
let isAlreadyParsed = false; 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 displayType = type === 'thread' ? undefined : type;
const parts = threadId === MAIN_THREAD_ID ? [chatId, displayType] : [chatId, threadId, displayType]; 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); const url = new URL(window.location.href);
url.hash = createLocationHash(chatId, type, threadId); url.hash = createLocationHash(chatId, type, threadId);
return url.href; return url.href;