From 6afb17978df562bcbbd276ffc061b16c6bce6e9d Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Mon, 13 Feb 2023 03:32:37 +0100 Subject: [PATCH] Chat List: Allow mouse to open chats in new tabs (#2443) --- src/components/left/main/Chat.scss | 2 + src/components/left/main/Chat.tsx | 5 +- src/components/left/main/Topic.tsx | 6 +- .../left/main/hooks/useTopicContextActions.ts | 19 ++++- src/components/ui/ListItem.scss | 2 + src/components/ui/ListItem.tsx | 83 +++++++++++++------ src/global/actions/ui/chats.ts | 4 +- src/global/types.ts | 1 + src/hooks/useChatContextActions.ts | 6 +- src/util/environment.ts | 8 ++ src/util/routing.ts | 4 +- 11 files changed, 107 insertions(+), 33 deletions(-) diff --git a/src/components/left/main/Chat.scss b/src/components/left/main/Chat.scss index 2a0be3913..c947ce02b 100644 --- a/src/components/left/main/Chat.scss +++ b/src/components/left/main/Chat.scss @@ -13,6 +13,8 @@ margin: 0; width: 100%; + -webkit-touch-callout: none; + &.animate-opacity { will-change: opacity; transition: opacity 0.2s ease-out; diff --git a/src/components/left/main/Chat.tsx b/src/components/left/main/Chat.tsx index 5586f615c..bce4f7103 100644 --- a/src/components/left/main/Chat.tsx +++ b/src/components/left/main/Chat.tsx @@ -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 = ({ = ({ )} onClick={handleOpenTopic} style={style} + href={IS_MULTITAB_SUPPORTED ? `#${createLocationHash(chatId, 'thread', topic.id)}` : undefined} contextActions={contextActions} ref={ref} > diff --git a/src/components/left/main/hooks/useTopicContextActions.ts b/src/components/left/main/hooks/useTopicContextActions.ts index c21d3536b..27e863bba 100644 --- a/src/components/left/main/hooks/useTopicContextActions.ts +++ b/src/components/left/main/hooks/useTopicContextActions.ts @@ -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]); } diff --git a/src/components/ui/ListItem.scss b/src/components/ui/ListItem.scss index a2b90883c..d6db87e4b 100644 --- a/src/components/ui/ListItem.scss +++ b/src/components/ui/ListItem.scss @@ -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; diff --git a/src/components/ui/ListItem.tsx b/src/components/ui/ListItem.tsx index c3c0e050d..bed31c662 100644 --- a/src/components/ui/ListItem.tsx +++ b/src/components/ui/ListItem.tsx @@ -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; - buttonRef?: RefObject; + buttonRef?: RefObject; icon?: string; leftElement?: TeactNode; secondaryIcon?: string; @@ -47,13 +56,13 @@ interface OwnProps { contextActions?: MenuItemContextAction[]; offsetCollapseDelta?: number; withPortalForMenu?: boolean; + href?: string; onMouseDown?: (e: React.MouseEvent) => void; - onClick?: (e: React.MouseEvent, arg?: any) => void; + onClick?: (e: React.MouseEvent, arg?: any) => void; clickArg?: any; onSecondaryIconClick?: (e: React.MouseEvent) => void; onDragEnter?: (e: React.DragEvent) => void; } - const ListItem: FC = ({ ref, buttonRef, @@ -77,6 +86,7 @@ const ListItem: FC = ({ contextActions, withPortalForMenu, offsetCollapseDelta, + href, onMouseDown, onClick, clickArg, @@ -124,17 +134,35 @@ const ListItem: FC = ({ getLayout, ); - const handleClick = useCallback((e: React.MouseEvent) => { + const handleClickEvent = useCallback((e: React.MouseEvent) => { + const hasModifierKey = e.ctrlKey || e.metaKey || e.shiftKey; + if (!hasModifierKey && e.button === MouseButton.Main) { + e.preventDefault(); + } + }, []); + + const handleClick = useCallback((e: React.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) => { if ((disabled && !allowDisabledClick) || e.button !== 0 || (!onSecondaryIconClick && !contextActions)) return; @@ -146,14 +174,14 @@ const ListItem: FC = ({ } }; - const handleMouseDown = useCallback((e: React.MouseEvent) => { + const handleMouseDown = useCallback((e: React.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 = ({ isStatic && 'is-static', ); + const ButtonElementTag = href ? 'a' : 'div'; + return (
= ({ onMouseDown={onMouseDown} onDragEnter={onDragEnter} > -
@@ -221,7 +252,7 @@ const ListItem: FC = ({ )} {rightElement} -
+ {contextActions && contextMenuPosition !== undefined && ( = ({ withPortal={withPortalForMenu} > {contextActions.map((action) => ( - - {action.title} - + ('isSeparator' in action) ? ( + + ) : ( + + {action.title} + + ) ))} )} diff --git a/src/global/actions/ui/chats.ts b/src/global/actions/ui/chats.ts index 9adcca111..63e85a59a 100644 --- a/src/global/actions/ui/chats.ts +++ b/src/global/actions/ui/chats.ts @@ -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 => { diff --git a/src/global/types.ts b/src/global/types.ts index 68d24f21c..ba01b6171 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -1489,6 +1489,7 @@ export interface ActionPayloads { } & WithTabId; openChatInNewTab: { chatId: string; + threadId?: number; }; onTabFocusChange: { isBlurred: boolean; diff --git a/src/hooks/useChatContextActions.ts b/src/hooks/useChatContextActions.ts index c523565b2..1a34cc80f 100644 --- a/src/hooks/useChatContextActions.ts +++ b/src/hooks/useChatContextActions.ts @@ -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, diff --git a/src/util/environment.ts b/src/util/environment.ts index 04677d3a1..5ac54d81a 100644 --- a/src/util/environment.ts +++ b/src/util/environment.ts @@ -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 diff --git a/src/util/routing.ts b/src/util/routing.ts index 0c6fa197e..d0b2f9b38 100644 --- a/src/util/routing.ts +++ b/src/util/routing.ts @@ -7,7 +7,7 @@ let parsedInitialLocationHash: Record | 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;