From 1140242b0e80ae923a3576e17624228851ea35dd Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Sun, 22 Feb 2026 23:42:42 +0100 Subject: [PATCH] Navigation: Support quick chat picker (#6635) --- src/bundles/extra.ts | 1 + .../common/pickers/ChatOrUserPicker.scss | 2 ++ .../common/pickers/PickerItem.module.scss | 12 +++++-- src/components/left/LeftColumn.tsx | 10 ++++++ .../middle/composer/helpers/selection.ts | 13 +++++++ src/components/modals/ModalContainer.tsx | 5 ++- .../QuickChatPickerModal.async.tsx | 14 ++++++++ .../quickChatPicker/QuickChatPickerModal.tsx | 36 +++++++++++++++++++ src/global/actions/ui/misc.ts | 10 ++++++ src/global/types/actions.ts | 3 ++ src/global/types/tabState.ts | 2 ++ src/hooks/useKeyboardListNavigation.ts | 5 ++- 12 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 src/components/modals/quickChatPicker/QuickChatPickerModal.async.tsx create mode 100644 src/components/modals/quickChatPicker/QuickChatPickerModal.tsx diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 4188f1b96..7bf25ae2f 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -107,3 +107,4 @@ export { default as FrozenAccountModal } from '../components/modals/frozenAccoun export { default as ProfileRatingModal } from '../components/modals/profileRating/ProfileRatingModal'; export { default as QuickPreviewModal } from '../components/modals/quickPreview/QuickPreviewModal'; export { default as StealthModeModal } from '../components/modals/storyStealthMode/StealthModeModal'; +export { default as QuickChatPickerModal } from '../components/modals/quickChatPicker/QuickChatPickerModal'; diff --git a/src/components/common/pickers/ChatOrUserPicker.scss b/src/components/common/pickers/ChatOrUserPicker.scss index f249e213b..91160f9c2 100644 --- a/src/components/common/pickers/ChatOrUserPicker.scss +++ b/src/components/common/pickers/ChatOrUserPicker.scss @@ -67,7 +67,9 @@ .picker-list { overflow-x: hidden; overflow-y: auto; + height: 100%; + padding-block: 0.125rem; padding-inline: 0.5rem; @include mixins.adapt-padding-to-scrollbar(0.5rem); diff --git a/src/components/common/pickers/PickerItem.module.scss b/src/components/common/pickers/PickerItem.module.scss index 5b1a6bf39..16074b82a 100644 --- a/src/components/common/pickers/PickerItem.module.scss +++ b/src/components/common/pickers/PickerItem.module.scss @@ -1,4 +1,6 @@ .root { + scroll-margin-block: 0.25rem; + position: relative; overflow: hidden; @@ -9,7 +11,7 @@ min-height: 2.5rem; padding: 0.25rem; - border-radius: var(--border-radius-default); + border-radius: 1.25rem; line-height: 1.25; color: var(--color-text); @@ -40,10 +42,14 @@ cursor: var(--custom-cursor, pointer); @media (hover: hover) { - &:hover, - &:focus-visible { + &:hover { background-color: var(--color-item-hover); } + + &:focus-visible { + z-index: 1; + outline: 2px solid var(--color-borders); + } } } diff --git a/src/components/left/LeftColumn.tsx b/src/components/left/LeftColumn.tsx index c99dfdc9c..dd0744640 100644 --- a/src/components/left/LeftColumn.tsx +++ b/src/components/left/LeftColumn.tsx @@ -20,6 +20,7 @@ import { import captureEscKeyListener from '../../util/captureEscKeyListener'; import { resolveTransitionName } from '../../util/resolveTransitionName'; import { captureControlledSwipe } from '../../util/swipeController'; +import { isComposerHasSelection } from '../middle/composer/helpers/selection'; import useFoldersReducer from '../../hooks/reducers/useFoldersReducer'; import { useHotkeys } from '../../hooks/useHotkeys'; @@ -110,6 +111,7 @@ function LeftColumn({ openChat, openLeftColumnContent, openSettingsScreen, + openQuickChatPicker, } = getActions(); const [contactsFilter, setContactsFilter] = useState(''); @@ -436,8 +438,16 @@ function LeftColumn({ openLeftColumnContent({ contentKey: LeftColumnContent.Settings }); }); + const handleQuickChatPicker = useLastCallback((e: KeyboardEvent) => { + if (isComposerHasSelection()) return; + + e.preventDefault(); + openQuickChatPicker(); + }); + useHotkeys(useMemo(() => ({ 'Mod+Shift+F': handleHotkeySearch, + 'Mod+K': handleQuickChatPicker, // https://support.mozilla.org/en-US/kb/take-screenshots-firefox ...(!IS_FIREFOX && { 'Mod+Shift+S': handleHotkeySavedMessages, diff --git a/src/components/middle/composer/helpers/selection.ts b/src/components/middle/composer/helpers/selection.ts index bb7e5eb16..795b3b90c 100644 --- a/src/components/middle/composer/helpers/selection.ts +++ b/src/components/middle/composer/helpers/selection.ts @@ -1,3 +1,5 @@ +import { EDITABLE_INPUT_ID, EDITABLE_INPUT_MODAL_ID } from '../../../../config'; + const MAX_NESTING_PARENTS = 5; export function isSelectionInsideInput(selectionRange: Range, inputId: string) { @@ -11,3 +13,14 @@ export function isSelectionInsideInput(selectionRange: Range, inputId: string) { return Boolean(parentNode && parentNode.id === inputId); } + +export function isComposerHasSelection() { + const activeElement = document.activeElement; + const isComposerFocused = activeElement?.id === EDITABLE_INPUT_ID + || activeElement?.id === EDITABLE_INPUT_MODAL_ID; + + if (!isComposerFocused) return false; + + const selection = window.getSelection(); + return Boolean(selection && !selection.isCollapsed); +} diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 470b7704c..316ba849c 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -49,6 +49,7 @@ import PasskeyModal from './passkey/PasskeyModal.async'; import PreparedMessageModal from './preparedMessage/PreparedMessageModal.async'; import PriceConfirmModal from './priceConfirm/PriceConfirmModal.async'; import ProfileRatingModal from './profileRating/ProfileRatingModal.async'; +import QuickChatPickerModal from './quickChatPicker/QuickChatPickerModal.async'; import QuickPreviewModal from './quickPreview/QuickPreviewModal.async'; import ReportAdModal from './reportAd/ReportAdModal.async'; import ReportModal from './reportModal/ReportModal.async'; @@ -126,7 +127,8 @@ type ModalKey = keyof Pick; type StateProps = { @@ -201,6 +203,7 @@ const MODALS: ModalRegistry = { storyStealthModal: StealthModeModal, isPasskeyModalOpen: PasskeyModal, birthdaySetupModal: BirthdaySetupModal, + isQuickChatPickerOpen: QuickChatPickerModal, }; const MODAL_KEYS = Object.keys(MODALS) as ModalKey[]; const MODAL_ENTRIES = Object.entries(MODALS) as Entries; diff --git a/src/components/modals/quickChatPicker/QuickChatPickerModal.async.tsx b/src/components/modals/quickChatPicker/QuickChatPickerModal.async.tsx new file mode 100644 index 000000000..fca25c1d3 --- /dev/null +++ b/src/components/modals/quickChatPicker/QuickChatPickerModal.async.tsx @@ -0,0 +1,14 @@ +import type { OwnProps } from './QuickChatPickerModal'; + +import { Bundles } from '../../../util/moduleLoader'; + +import useModuleLoader from '../../../hooks/useModuleLoader'; + +const QuickChatPickerModalAsync = (props: OwnProps) => { + const { modal } = props; + const QuickChatPickerModal = useModuleLoader(Bundles.Extra, 'QuickChatPickerModal', !modal); + + return QuickChatPickerModal ? : undefined; +}; + +export default QuickChatPickerModalAsync; diff --git a/src/components/modals/quickChatPicker/QuickChatPickerModal.tsx b/src/components/modals/quickChatPicker/QuickChatPickerModal.tsx new file mode 100644 index 000000000..5550ca54c --- /dev/null +++ b/src/components/modals/quickChatPicker/QuickChatPickerModal.tsx @@ -0,0 +1,36 @@ +import { memo } from '../../../lib/teact/teact'; +import { getActions } from '../../../global'; + +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; + +import RecipientPicker from '../../common/RecipientPicker'; + +export type OwnProps = { + modal?: boolean; +}; + +const QuickChatPickerModal = ({ + modal, +}: OwnProps) => { + const { closeQuickChatPicker, openChat } = getActions(); + + const lang = useLang(); + const isOpen = Boolean(modal); + + const handleSelectRecipient = useLastCallback((peerId: string) => { + openChat({ id: peerId }); + closeQuickChatPicker(); + }); + + return ( + + ); +}; + +export default memo(QuickChatPickerModal); diff --git a/src/global/actions/ui/misc.ts b/src/global/actions/ui/misc.ts index 6e09ddf7d..c9d5ca961 100644 --- a/src/global/actions/ui/misc.ts +++ b/src/global/actions/ui/misc.ts @@ -951,3 +951,13 @@ addCallback((global: GlobalState) => { prevIsScreenLocked = global.passcode.isScreenLocked; prevBlurredTabsCount = blurredTabsCount; }); + +addActionHandler('openQuickChatPicker', (global, actions, payload): ActionReturnType => { + const { tabId = getCurrentTabId() } = payload || {}; + + return updateTabState(global, { + isQuickChatPickerOpen: true, + }, tabId); +}); + +addTabStateResetterAction('closeQuickChatPicker', 'isQuickChatPickerOpen'); diff --git a/src/global/types/actions.ts b/src/global/types/actions.ts index 1806f2b24..832840cab 100644 --- a/src/global/types/actions.ts +++ b/src/global/types/actions.ts @@ -2556,6 +2556,9 @@ export interface ActionPayloads { openGiftRecipientPicker: WithTabId | undefined; closeGiftRecipientPicker: WithTabId | undefined; + openQuickChatPicker: WithTabId | undefined; + closeQuickChatPicker: WithTabId | undefined; + openWebAppsCloseConfirmationModal: WithTabId | undefined; closeWebAppsCloseConfirmationModal: ({ diff --git a/src/global/types/tabState.ts b/src/global/types/tabState.ts index edbffcd81..10f0948a7 100644 --- a/src/global/types/tabState.ts +++ b/src/global/types/tabState.ts @@ -681,6 +681,8 @@ export type TabState = { isGiftRecipientPickerOpen?: boolean; + isQuickChatPickerOpen?: boolean; + isFrozenAccountModalOpen?: boolean; starsGiftingPickerModal?: { diff --git a/src/hooks/useKeyboardListNavigation.ts b/src/hooks/useKeyboardListNavigation.ts index 43a9a716a..06303f711 100644 --- a/src/hooks/useKeyboardListNavigation.ts +++ b/src/hooks/useKeyboardListNavigation.ts @@ -41,6 +41,8 @@ const useKeyboardListNavigation = ( return; } + e.preventDefault(); + const focusedElement = document.activeElement; const elementChildren = Array.from(itemSelector ? element.querySelectorAll(itemSelector) : element.children); @@ -59,7 +61,8 @@ const useKeyboardListNavigation = ( const item = elementChildren[newIndex] as HTMLElement; if (item) { setFocusedIndex(newIndex); - item.focus(); + item.focus({ preventScroll: true }); + item.scrollIntoView({ behavior: 'instant', block: 'nearest' }); } }); };