diff --git a/src/api/gramjs/methods/management.ts b/src/api/gramjs/methods/management.ts index 1858fb9a5..66af71ddd 100644 --- a/src/api/gramjs/methods/management.ts +++ b/src/api/gramjs/methods/management.ts @@ -280,7 +280,7 @@ export function hideAllChatJoinRequests({ }); } -export function hideChatReportPanel(chat: ApiChat) { +export function hideChatReportPane(chat: ApiChat) { const { id, accessHash } = chat; return invokeRequest(new GramJs.messages.HidePeerSettingsBar({ diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index 79a877f21..9499d0d0b 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -655,7 +655,11 @@ "Statistics" = "Statistics"; "EventLogFilterPinnedMessages" = "Pinned messages"; "UnpinMessageAlertTitle" = "Unpin message"; -"PinnedMessage" = "Pinned message"; +"PinnedMessageTitleSingle" = "Pinned message"; +"PinnedMessageTitle_one" = "Pinned message #{index}"; +"PinnedMessageTitle_other" = "Pinned message #{index}"; +"AccPinnedMessages" = "Pinned Messages"; +"AccUnpinMessage" = "Unpin Message"; "Comments_one" = "{count} Comment"; "Comments_other" = "{count} Comments"; "LeaveAComment" = "Leave a comment"; diff --git a/src/components/calls/group/GroupCallTopPane.scss b/src/components/calls/group/GroupCallTopPane.scss index 00d57c1ca..2ef0ac70e 100644 --- a/src/components/calls/group/GroupCallTopPane.scss +++ b/src/components/calls/group/GroupCallTopPane.scss @@ -1,37 +1,14 @@ +@use "../../../styles/mixins"; + .GroupCallTopPane { - position: absolute; - top: 100%; - left: 0; - right: 0; - height: 2.875rem; - overflow: hidden; - box-shadow: 0 2px 2px var(--color-light-shadow); + @include mixins.header-pane; + display: flex; flex-direction: row; justify-content: space-between; align-items: center; - padding: 0.375rem 0.5rem 0.375rem 0.75rem; - background: var(--color-background); - z-index: -1; cursor: var(--custom-cursor, pointer); - &::before { - content: ""; - display: block; - position: absolute; - top: -0.1875rem; - left: 0; - right: 0; - height: 0.125rem; - box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow); - } - - @media (max-width: 600px) { - &.has-pinned-offset { - top: calc(100% + 2.875rem); - } - } - .info { display: flex; flex-direction: column; @@ -56,9 +33,3 @@ width: auto; } } - -@media (min-width: 1440px) { - #Main.right-column-open .MiddleHeader .GroupCallTopPane { - width: calc(100% - var(--right-column-width)); - } -} diff --git a/src/components/calls/group/GroupCallTopPane.tsx b/src/components/calls/group/GroupCallTopPane.tsx index d0f767ed6..59785e0ed 100644 --- a/src/components/calls/group/GroupCallTopPane.tsx +++ b/src/components/calls/group/GroupCallTopPane.tsx @@ -12,7 +12,7 @@ import buildClassName from '../../../util/buildClassName'; import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; import useOldLang from '../../../hooks/useOldLang'; -import useShowTransitionDeprecated from '../../../hooks/useShowTransitionDeprecated'; +import useHeaderPane, { type PaneState } from '../../middle/hooks/useHeaderPane'; import AvatarList from '../../common/AvatarList'; import Button from '../../ui/Button'; @@ -21,8 +21,8 @@ import './GroupCallTopPane.scss'; type OwnProps = { chatId: string; - hasPinnedOffset: boolean; className?: string; + onPaneStateChange?: (state: PaneState) => void; }; type StateProps = { @@ -37,7 +37,7 @@ const GroupCallTopPane: FC = ({ isActive, className, groupCall, - hasPinnedOffset, + onPaneStateChange, }) => { const { requestMasterAndJoinGroupCall, @@ -86,23 +86,24 @@ const GroupCallTopPane: FC = ({ }; }, [groupCall?.id, groupCall?.isLoaded, isActive, subscribeToGroupCallUpdates]); - const { - shouldRender, - transitionClassNames, - } = useShowTransitionDeprecated(Boolean(groupCall && isActive)); - const renderingParticipantCount = useCurrentOrPrev(groupCall?.participantsCount, true); const renderingFetchedParticipants = useCurrentOrPrev(fetchedParticipants, true); + const isRendering = Boolean(groupCall && isActive); + + const { ref, shouldRender } = useHeaderPane({ + isOpen: isRendering, + onStateChange: onPaneStateChange, + }); + if (!shouldRender) return undefined; return (
diff --git a/src/components/common/PinMessageModal.tsx b/src/components/common/PinMessageModal.tsx index 52d609ffc..ce2ec6b31 100644 --- a/src/components/common/PinMessageModal.tsx +++ b/src/components/common/PinMessageModal.tsx @@ -37,6 +37,7 @@ type StateProps = { const PinMessageModal: FC = ({ isOpen, + chatId, messageId, isChannel, isGroup, @@ -49,17 +50,17 @@ const PinMessageModal: FC = ({ const handlePinMessageForAll = useCallback(() => { pinMessage({ - messageId, isUnpin: false, + chatId, messageId, isUnpin: false, }); onClose(); - }, [pinMessage, messageId, onClose]); + }, [chatId, messageId, onClose]); const handlePinMessage = useCallback(() => { pinMessage({ - messageId, isUnpin: false, isOneSide: true, isSilent: true, + chatId, messageId, isUnpin: false, isOneSide: true, isSilent: true, }); onClose(); - }, [messageId, onClose, pinMessage]); + }, [chatId, messageId, onClose]); const lang = useOldLang(); diff --git a/src/components/left/main/ForumPanel.module.scss b/src/components/left/main/ForumPanel.module.scss index a458d6a63..5b5b6c1af 100644 --- a/src/components/left/main/ForumPanel.module.scss +++ b/src/components/left/main/ForumPanel.module.scss @@ -43,10 +43,6 @@ } } -.group-call { - position: static !important; -} - .notch { width: 100%; height: 0; diff --git a/src/components/left/main/ForumPanel.tsx b/src/components/left/main/ForumPanel.tsx index 3b983ae7b..d26ef4e38 100644 --- a/src/components/left/main/ForumPanel.tsx +++ b/src/components/left/main/ForumPanel.tsx @@ -260,7 +260,7 @@ const ForumPanel: FC = ({ )}
- {chat && } + {chat && }
diff --git a/src/components/main/Main.tsx b/src/components/main/Main.tsx index 367d40673..95f10808e 100644 --- a/src/components/main/Main.tsx +++ b/src/components/main/Main.tsx @@ -8,7 +8,7 @@ import React, { import { addExtraClass } from '../../lib/teact/teact-dom'; import { getActions, getGlobal, withGlobal } from '../../global'; -import type { ApiChatFolder, ApiMessage, ApiUser } from '../../api/types'; +import type { ApiChatFolder, ApiUser } from '../../api/types'; import type { ApiLimitTypeWithModal, TabState } from '../../global/types'; import { ElectronEvent } from '../../types/electron'; @@ -61,10 +61,10 @@ import StickerSetModal from '../common/StickerSetModal.async'; import UnreadCount from '../common/UnreadCounter'; import LeftColumn from '../left/LeftColumn'; import MediaViewer from '../mediaViewer/MediaViewer.async'; -import AudioPlayer from '../middle/AudioPlayer'; import ReactionPicker from '../middle/message/reactions/ReactionPicker.async'; import MessageListHistoryHandler from '../middle/MessageListHistoryHandler'; import MiddleColumn from '../middle/MiddleColumn'; +import AudioPlayer from '../middle/panes/AudioPlayer'; import ModalContainer from '../modals/ModalContainer'; import PaymentModal from '../payment/PaymentModal.async'; import ReceiptModal from '../payment/ReceiptModal.async'; @@ -107,7 +107,6 @@ type StateProps = { isForwardModalOpen: boolean; hasNotifications: boolean; hasDialogs: boolean; - audioMessage?: ApiMessage; safeLinkModalUrl?: string; isHistoryCalendarOpen: boolean; shouldSkipHistoryAnimations?: boolean; @@ -159,7 +158,6 @@ const Main = ({ isForwardModalOpen, hasNotifications, hasDialogs, - audioMessage, activeGroupCallId, safeLinkModalUrl, isHistoryCalendarOpen, @@ -545,7 +543,7 @@ const Main = ({ - {audioMessage && } + @@ -613,7 +611,6 @@ export default memo(withGlobal( openedCustomEmojiSetIds, shouldSkipHistoryAnimations, openedGame, - audioPlayer, isLeftColumnShown, historyCalendarSelectedAt, notifications, @@ -630,10 +627,6 @@ export default memo(withGlobal( deleteFolderDialogModal, } = selectTabState(global); - const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; - const audioMessage = audioChatId && audioMessageId - ? selectChatMessage(global, audioChatId, audioMessageId) - : undefined; const gameMessage = openedGame && selectChatMessage(global, openedGame.chatId, openedGame.messageId); const gameTitle = gameMessage?.content.game?.title; const { chatId } = selectCurrentMessageList(global) || {}; @@ -653,7 +646,6 @@ export default memo(withGlobal( isReactionPickerOpen: selectIsReactionPickerOpen(global), hasNotifications: Boolean(notifications.length), hasDialogs: Boolean(dialogs.length), - audioMessage, safeLinkModalUrl, isHistoryCalendarOpen: Boolean(historyCalendarSelectedAt), shouldSkipHistoryAnimations, diff --git a/src/components/middle/ChatReportPanel.scss b/src/components/middle/ChatReportPanel.scss deleted file mode 100644 index 90ce57f67..000000000 --- a/src/components/middle/ChatReportPanel.scss +++ /dev/null @@ -1,36 +0,0 @@ -.ChatReportPanel { - position: absolute; - left: 0; - right: 0; - top: 100%; - - display: flex; - align-items: center; - margin-left: auto; - background: var(--color-background); - padding: 0.375rem 0.8125rem 0.25rem 1rem; - box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow), inset 0 0.125rem 0.125rem var(--color-light-shadow); - transform: translate3d(0, 0, 0); - transition: opacity 0.15s ease, transform var(--layer-transition); - - body.no-page-transitions & { - .ripple-container { - display: none; - } - } - - @media (min-width: 1276px) { - transform: translate3d(0, 0, 0); - transition: opacity 0.15s ease, transform var(--layer-transition); - - #Main.right-column-open & { - padding-right: calc(var(--right-column-width) + 1rem); - } - } - - .UserReportPanel--Button { - margin-left: 0.25rem; - flex: 1 1 50%; - white-space: nowrap; - } -} diff --git a/src/components/middle/HeaderPinnedMessage.tsx b/src/components/middle/HeaderPinnedMessage.tsx deleted file mode 100644 index a2e5a7760..000000000 --- a/src/components/middle/HeaderPinnedMessage.tsx +++ /dev/null @@ -1,231 +0,0 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { memo } from '../../lib/teact/teact'; -import { getActions } from '../../global'; - -import type { ApiMessage } from '../../api/types'; -import type { Signal } from '../../util/signals'; - -import { - getMessageIsSpoiler, getMessageMediaHash, getMessageSingleInlineButton, getMessageVideo, -} from '../../global/helpers'; -import buildClassName from '../../util/buildClassName'; -import { IS_TOUCH_ENV } from '../../util/windowEnvironment'; -import { getPictogramDimensions, REM } from '../common/helpers/mediaDimensions'; -import renderText from '../common/helpers/renderText'; -import renderKeyboardButtonText from './composer/helpers/renderKeyboardButtonText'; - -import useDerivedState from '../../hooks/useDerivedState'; -import { useFastClick } from '../../hooks/useFastClick'; -import useFlag from '../../hooks/useFlag'; -import useLastCallback from '../../hooks/useLastCallback'; -import useMedia from '../../hooks/useMedia'; -import useOldLang from '../../hooks/useOldLang'; -import useThumbnail from '../../hooks/useThumbnail'; -import useAsyncRendering from '../right/hooks/useAsyncRendering'; - -import AnimatedCounter from '../common/AnimatedCounter'; -import MediaSpoiler from '../common/MediaSpoiler'; -import MessageSummary from '../common/MessageSummary'; -import Button from '../ui/Button'; -import ConfirmDialog from '../ui/ConfirmDialog'; -import RippleEffect from '../ui/RippleEffect'; -import Spinner from '../ui/Spinner'; -import Transition from '../ui/Transition'; -import PinnedMessageNavigation from './PinnedMessageNavigation'; - -import styles from './HeaderPinnedMessage.module.scss'; - -const SHOW_LOADER_DELAY = 450; -const EMOJI_SIZE = 1.125 * REM; - -type OwnProps = { - message: ApiMessage; - index: number; - count: number; - customTitle?: string; - className?: string; - onUnpinMessage?: (id: number) => void; - onClick?: (e: React.MouseEvent) => void; - onAllPinnedClick?: () => void; - getLoadingPinnedId: Signal; - isFullWidth?: boolean; -}; - -const HeaderPinnedMessage: FC = ({ - message, count, index, customTitle, className, onUnpinMessage, onClick, onAllPinnedClick, - getLoadingPinnedId, isFullWidth, -}) => { - const { clickBotInlineButton } = getActions(); - const lang = useOldLang(); - - const video = getMessageVideo(message); - const gif = video?.isGif ? video : undefined; - const isVideoThumbnail = Boolean(gif && !gif.previewPhotoSizes?.length); - - const mediaThumbnail = useThumbnail(message); - const mediaBlobUrl = useMedia(getMessageMediaHash(message, isVideoThumbnail ? 'full' : 'pictogram')); - const isSpoiler = getMessageIsSpoiler(message); - - const isLoading = Boolean(useDerivedState(getLoadingPinnedId)); - const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); - const shouldShowLoader = canRenderLoader && isLoading; - - const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag(); - - const handleUnpinMessage = useLastCallback(() => { - closeUnpinDialog(); - - if (onUnpinMessage) { - onUnpinMessage(message.id); - } - }); - - const inlineButton = getMessageSingleInlineButton(message); - - const handleInlineButtonClick = useLastCallback(() => { - if (inlineButton) { - clickBotInlineButton({ chatId: message.chatId, messageId: message.id, button: inlineButton }); - } - }); - - const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag(); - - const { handleClick, handleMouseDown } = useFastClick(onClick); - - function renderPictogram(thumbDataUri?: string, blobUrl?: string, isFullVideo?: boolean, asSpoiler?: boolean) { - const { width, height } = getPictogramDimensions(); - const srcUrl = blobUrl || thumbDataUri; - const shouldRenderVideo = isFullVideo && blobUrl; - - return ( -
- {thumbDataUri && !asSpoiler && !shouldRenderVideo && ( - - )} - {shouldRenderVideo && !asSpoiler && ( -
- ); - } - - return ( -
- {(count > 1 || shouldShowLoader) && ( - - )} - {onUnpinMessage && ( - - )} - -
- - - {renderPictogram( - mediaThumbnail, - mediaBlobUrl, - isVideoThumbnail, - isSpoiler, - )} - -
-
- {!customTitle && ( - 0 ? `#${count - index}` : ''}`} /> - )} - - {customTitle && renderText(customTitle)} -
- -

- -

-
-
- - {inlineButton && ( - - )} -
-
- ); -}; - -export default memo(HeaderPinnedMessage); diff --git a/src/components/middle/MessageList.scss b/src/components/middle/MessageList.scss index ce7d5378e..0fb092d04 100644 --- a/src/components/middle/MessageList.scss +++ b/src/components/middle/MessageList.scss @@ -66,6 +66,10 @@ } } + .first-message-date-group { + padding-top: var(--middle-header-panes-height); + } + &.no-composer { margin-bottom: 0; @@ -379,17 +383,13 @@ &.scrolled:not(.is-animating) .sticky-date { position: sticky; - top: 0.625rem; + top: calc(0.625rem + var(--middle-header-panes-height)); } &.is-animating .message-select-control { display: none !important; } - .has-header-tools & .sticky-date { - top: 3.75rem !important; - } - .local-action-message, .ActionMessage { margin-top: 0.5rem; diff --git a/src/components/middle/MessageList.tsx b/src/components/middle/MessageList.tsx index 063facccf..b8bac7dce 100644 --- a/src/components/middle/MessageList.tsx +++ b/src/components/middle/MessageList.tsx @@ -89,7 +89,6 @@ type OwnProps = { isReady: boolean; onScrollDownToggle: BooleanToVoidFunction; onNotchToggle: BooleanToVoidFunction; - hasTools?: boolean; withBottomShift?: boolean; withDefaultBg: boolean; onIntersectPinnedMessage: OnIntersectPinnedMessage; @@ -133,7 +132,6 @@ const MESSAGE_FACT_CHECK_UPDATE_INTERVAL = 5 * 1000; const MESSAGE_STORY_POLLING_INTERVAL = 5 * 60 * 1000; const BOTTOM_THRESHOLD = 50; const UNREAD_DIVIDER_TOP = 10; -const UNREAD_DIVIDER_TOP_WITH_TOOLS = 60; const SCROLL_DEBOUNCE = 200; const MESSAGE_ANIMATION_DURATION = 500; const BOTTOM_FOCUS_MARGIN = 20; @@ -146,7 +144,6 @@ const MessageList: FC = ({ chatId, threadId, type, - hasTools, isChatLoaded, isForum, isChannelChat, @@ -405,7 +402,7 @@ const MessageList: FC = ({ } if (!memoFocusingIdRef.current) { - updateStickyDates(container, hasTools); + updateStickyDates(container); } runDebouncedForScroll(() => { @@ -474,7 +471,7 @@ const MessageList: FC = ({ useSyncEffect( () => forceMeasure(() => rememberScrollPositionRef.current()), // This will run before modifying content and should match deps for `useLayoutEffectWithPrevDeps` below - [messageIds, isViewportNewest, hasTools, rememberScrollPositionRef], + [messageIds, isViewportNewest, rememberScrollPositionRef], ); useEffect( () => rememberScrollPositionRef.current(), @@ -591,7 +588,7 @@ const MessageList: FC = ({ newScrollTop = scrollTop + (newAnchorTop - (anchorTopRef.current || 0)); } else if (unreadDivider) { newScrollTop = Math.min( - unreadDivider.offsetTop - (hasTools ? UNREAD_DIVIDER_TOP_WITH_TOOLS : UNREAD_DIVIDER_TOP), + unreadDivider.offsetTop - UNREAD_DIVIDER_TOP, scrollHeight - scrollOffset, ); } else { @@ -619,7 +616,7 @@ const MessageList: FC = ({ }; }); // This should match deps for `useSyncEffect` above - }, [messageIds, isViewportNewest, hasTools, getContainerHeight, prevContainerHeightRef, noMessageSendingAnimation]); + }, [messageIds, isViewportNewest, getContainerHeight, prevContainerHeightRef, noMessageSendingAnimation]); useEffectWithPrevDeps(([prevIsSelectModeActive]) => { if (prevIsSelectModeActive !== undefined) { diff --git a/src/components/middle/MessageListContent.tsx b/src/components/middle/MessageListContent.tsx index 0b61f700c..f5a7e1bd2 100644 --- a/src/components/middle/MessageListContent.tsx +++ b/src/components/middle/MessageListContent.tsx @@ -265,7 +265,7 @@ const MessageListContent: FC = ({ return (
MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN - && windowWidth < SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN - ) - || (!isMobile && hasButtonInHeader && windowWidth < MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES) - ); const renderingChatId = usePrevDuringAnimation(chatId, closeAnimationDuration); const renderingThreadId = usePrevDuringAnimation(threadId, closeAnimationDuration); @@ -271,7 +252,6 @@ function MiddleColumn({ const renderingCanPost = usePrevDuringAnimation(canPost, closeAnimationDuration) && !renderingCanRestartBot && !renderingCanStartBot && !renderingCanSubscribe && !renderingCanUnblock && chatId !== TMP_CHAT_ID && !isContactRequirePremium; - const renderingHasTools = usePrevDuringAnimation(hasTools, closeAnimationDuration); const renderingIsScrollDownShown = usePrevDuringAnimation( isScrollDownShown, closeAnimationDuration, ) && chatId !== TMP_CHAT_ID; @@ -430,7 +410,6 @@ function MiddleColumn({ const customBackgroundValue = useCustomBackground(theme, customBackground); const className = buildClassName( - renderingHasTools && 'has-header-tools', MASK_IMAGE_DISABLED ? 'mask-image-disabled' : 'mask-image-enabled', ); @@ -522,12 +501,20 @@ function MiddleColumn({ {Boolean(renderingChatId && renderingThreadId) && ( <>
+ ( const { messageLists, isLeftColumnShown, activeEmojiInteractions, - seenByModal, reactorModal, audioPlayer, shouldSkipHistoryAnimations, + seenByModal, reactorModal, shouldSkipHistoryAnimations, chatLanguageModal, privacySettingsNoticeModal, } = selectTabState(global); const currentMessageList = selectCurrentMessageList(global); @@ -763,7 +749,6 @@ export default memo(withGlobal( const chat = selectChat(global, chatId); const bot = selectBot(global, chatId); const pinnedIds = selectPinnedIds(global, chatId, threadId); - const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; const chatFullInfo = chatId ? selectChatFullInfo(global, chatId) : undefined; const threadInfo = selectThreadInfo(global, chatId, threadId); @@ -790,16 +775,11 @@ export default memo(withGlobal( const shouldBlockSendInForum = chat?.isForum ? threadId === MAIN_THREAD_ID && !draftReplyInfo && (selectTopic(global, chatId, GENERAL_TOPIC_ID)?.isClosed) : false; - const audioMessage = audioChatId && audioMessageId - ? selectChatMessage(global, audioChatId, audioMessageId) - : undefined; const topics = selectTopics(global, chatId); const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); const canShowOpenChatButton = isSavedDialog && threadId !== ANONYMOUS_USER_ID; - const isCommentThread = threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum; - const canUnpin = chat && ( isPrivate || ( chat?.isCreator || (!isChannel && !isUserRightBanned(chat, 'pinMessages')) @@ -829,9 +809,6 @@ export default memo(withGlobal( isPinnedMessageList, currentUserBannedRights: chat?.currentUserBannedRights, defaultBannedRights: chat?.defaultBannedRights, - hasPinned: isCommentThread || Boolean(!isPinnedMessageList && pinnedIds?.length), - hasAudioPlayer: Boolean(audioMessage), - hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest, pinnedMessagesCount: pinnedIds ? pinnedIds.length : 0, shouldSkipHistoryAnimations, isChannel, diff --git a/src/components/middle/MiddleHeader.scss b/src/components/middle/MiddleHeader.scss index 2a6780779..61842e565 100644 --- a/src/components/middle/MiddleHeader.scss +++ b/src/components/middle/MiddleHeader.scss @@ -1,33 +1,9 @@ @use "../../styles/mixins"; -@mixin mobile-header-styles() { - .AudioPlayer { - @include mixins.header-mobile; - - flex-direction: row; - margin-top: 0; - padding: 0.25rem 0.5rem; - - &-content { - padding: 0 0.5rem; - flex-grow: 1; - } - - > .Button, > .playback-rate-menu { - margin: -0.0625rem 0 0; - } - - > .player-close { - margin-left: auto; - } - } -} - .MiddleHeader { display: flex; align-items: center; width: 100%; - box-shadow: 0 2px 2px var(--color-light-shadow); background: var(--color-background); position: relative; z-index: var(--z-middle-header); @@ -118,18 +94,7 @@ } } - @media (min-width: 1276px) and (max-width: 1439px) { - .HeaderActions { - transform: translate3d(0, 0, 0); - transition: transform var(--layer-transition); - - #Main.right-column-open & { - transform: translate3d(calc(var(--right-column-width) * -1), 0, 0); - } - } - } - - @media (min-width: 1440px) { + @media (min-width: 1276px) { transform: translate3d(0, 0, 0); transition: transform var(--layer-transition); @@ -144,34 +109,6 @@ } } - @media (min-width: 1276px) and (max-width: 1439px) { - &:not(.tools-stacked) .AudioPlayer { - opacity: 1; - - #Main.right-column-open & { - opacity: 0; - } - } - } - - &.tools-stacked .AudioPlayer { - @include mobile-header-styles(); - - @media (min-width: 1150px) { - #Main.right-column-open & { - padding-right: calc(0.5rem + var(--right-column-width)); - } - } - } - - &.tools-stacked.animated .AudioPlayer { - animation: fade-in var(--layer-transition) forwards; - - body.no-page-transitions & { - animation: none; - } - } - h3 { font-weight: 500; font-size: 1.125rem; @@ -384,10 +321,6 @@ } } - @media (max-width: 600px) { - @include mobile-header-styles(); - } - body.is-electron.is-macos & { -webkit-app-region: drag; } diff --git a/src/components/middle/MiddleHeader.tsx b/src/components/middle/MiddleHeader.tsx index c18e6769d..1b160e8af 100644 --- a/src/components/middle/MiddleHeader.tsx +++ b/src/components/middle/MiddleHeader.tsx @@ -1,11 +1,11 @@ import type { FC } from '../../lib/teact/teact'; import React, { - memo, useEffect, useLayoutEffect, useRef, + memo, useRef, } from '../../lib/teact/teact'; import { getActions, withGlobal } from '../../global'; import type { - ApiChat, ApiMessage, ApiPeer, ApiSticker, ApiTypingStatus, + ApiChat, ApiMessage, ApiSticker, ApiTypingStatus, } from '../../api/types'; import type { GlobalState, MessageListType } from '../../global/types'; import type { Signal } from '../../util/signals'; @@ -14,35 +14,18 @@ import { StoryViewerOrigin, type ThreadId } from '../../types'; import { EDITABLE_INPUT_CSS_SELECTOR, - MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES, MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN, - MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, - MOBILE_SCREEN_MAX_WIDTH, - SAFE_SCREEN_WIDTH_FOR_CHAT_INFO, - SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN, } from '../../config'; -import { requestMutation } from '../../lib/fasterdom/fasterdom'; import { - getChatTitle, getIsSavedDialog, - getSenderTitle, - isChatChannel, - isChatSuperGroup, isUserId, } from '../../global/helpers'; import { - selectAllowedMessageActionsSlow, selectChat, selectChatMessage, - selectChatMessages, - selectCurrentMiddleSearch, - selectForwardedSender, - selectIsChatBotNotStarted, - selectIsChatWithBot, selectIsChatWithSelf, selectIsInSelectMode, selectIsRightColumnShown, - selectIsUserBlocked, selectPinnedIds, selectScheduledIds, selectTabState, @@ -50,36 +33,27 @@ import { selectThreadParam, } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; -import cycleRestrict from '../../util/cycleRestrict'; -import { getMessageKey } from '../../util/keys/messageKey'; import useAppLayout from '../../hooks/useAppLayout'; import useConnectionStatus from '../../hooks/useConnectionStatus'; -import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; -import useDerivedState from '../../hooks/useDerivedState'; import useElectronDrag from '../../hooks/useElectronDrag'; -import useEnsureMessage from '../../hooks/useEnsureMessage'; import useLastCallback from '../../hooks/useLastCallback'; import useLongPress from '../../hooks/useLongPress'; import useOldLang from '../../hooks/useOldLang'; import usePreviousDeprecated from '../../hooks/usePreviousDeprecated'; -import useShowTransitionDeprecated from '../../hooks/useShowTransitionDeprecated'; import useWindowSize from '../../hooks/window/useWindowSize'; -import GroupCallTopPane from '../calls/group/GroupCallTopPane'; import GroupChatInfo from '../common/GroupChatInfo'; import PrivateChatInfo from '../common/PrivateChatInfo'; import UnreadCounter from '../common/UnreadCounter'; import Button from '../ui/Button'; import Transition from '../ui/Transition'; -import AudioPlayer from './AudioPlayer'; -import ChatReportPanel from './ChatReportPanel'; import HeaderActions from './HeaderActions'; -import HeaderPinnedMessage from './HeaderPinnedMessage'; +import AudioPlayer from './panes/AudioPlayer'; +import HeaderPinnedMessage from './panes/HeaderPinnedMessage'; import './MiddleHeader.scss'; -const ANIMATION_DURATION = 350; const BACK_BUTTON_INACTIVE_TIME = 450; const EMOJI_STATUS_SIZE = 22; const SEARCH_LONGTAP_THRESHOLD = 500; @@ -89,7 +63,6 @@ type OwnProps = { threadId: ThreadId; messageListType: MessageListType; isComments?: boolean; - isReady?: boolean; isMobile?: boolean; getCurrentPinnedIndex: Signal; getLoadingPinnedId: Signal; @@ -98,11 +71,7 @@ type OwnProps = { type StateProps = { chat?: ApiChat; - pinnedMessageIds?: number[] | number; - messagesById?: Record; - canUnpin?: boolean; isSavedDialog?: boolean; - topMessageSender?: ApiPeer; typingStatus?: ApiTypingStatus; isSelectModeActive?: boolean; isLeftColumnShown?: boolean; @@ -110,61 +79,45 @@ type StateProps = { audioMessage?: ApiMessage; messagesCount?: number; isChatWithSelf?: boolean; - hasButtonInHeader?: boolean; shouldSkipHistoryAnimations?: boolean; currentTransitionKey: number; connectionState?: GlobalState['connectionState']; isSyncing?: boolean; - isSynced?: boolean; isFetchingDifference?: boolean; emojiStatusSticker?: ApiSticker; - isMiddleSearchOpen?: boolean; }; const MiddleHeader: FC = ({ chatId, threadId, messageListType, - isReady, isMobile, - pinnedMessageIds, - messagesById, - canUnpin, - topMessageSender, typingStatus, isSelectModeActive, isLeftColumnShown, - isRightColumnShown, audioMessage, chat, messagesCount, isComments, isChatWithSelf, - hasButtonInHeader, shouldSkipHistoryAnimations, currentTransitionKey, connectionState, isSyncing, - isSynced, isFetchingDifference, getCurrentPinnedIndex, getLoadingPinnedId, emojiStatusSticker, isSavedDialog, - isMiddleSearchOpen, onFocusPinnedMessage, }) => { const { openThreadWithInfo, - pinMessage, - focusMessage, openChat, openPreviousChat, - loadPinnedMessages, toggleLeftColumn, exitMessageSelectMode, openPremiumModal, - openThread, openStickerSet, updateMiddleSearch, } = getActions(); @@ -173,32 +126,15 @@ const MiddleHeader: FC = ({ const isBackButtonActive = useRef(true); const { isTablet } = useAppLayout(); - const currentPinnedIndex = useDerivedState(getCurrentPinnedIndex); - const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[currentPinnedIndex] : pinnedMessageIds; - const pinnedMessage = messagesById && pinnedMessageId ? messagesById[pinnedMessageId] : undefined; - const pinnedMessagesCount = Array.isArray(pinnedMessageIds) - ? pinnedMessageIds.length : (pinnedMessageIds ? 1 : undefined); - const chatTitleLength = chat && getChatTitle(lang, chat).length; - const topMessageTitle = topMessageSender ? getSenderTitle(lang, topMessageSender) : undefined; - const { settings } = chat || {}; - const isForum = chat?.isForum; - - useEffect(() => { - if (isSynced && isReady && (threadId === MAIN_THREAD_ID || isForum)) { - loadPinnedMessages({ chatId, threadId }); - } - }, [chatId, threadId, isSynced, isReady, isForum]); - - useEnsureMessage(chatId, pinnedMessageId, pinnedMessage); - const { width: windowWidth } = useWindowSize(); + const { isDesktop } = useAppLayout(); + const isLeftColumnHideable = windowWidth <= MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN; const shouldShowCloseButton = isTablet && isLeftColumnShown; // eslint-disable-next-line no-null/no-null const componentRef = useRef(null); - const shouldAnimateTools = useRef(true); const handleOpenSearch = useLastCallback(() => { updateMiddleSearch({ chatId, threadId, update: {} }); @@ -222,27 +158,6 @@ const MiddleHeader: FC = ({ threshold: SEARCH_LONGTAP_THRESHOLD, }); - const handleUnpinMessage = useLastCallback((messageId: number) => { - pinMessage({ messageId, isUnpin: true }); - }); - - const handlePinnedMessageClick = useLastCallback((e: React.MouseEvent): void => { - const messageId = e.shiftKey && Array.isArray(pinnedMessageIds) - ? pinnedMessageIds[cycleRestrict(pinnedMessageIds.length, pinnedMessageIds.indexOf(pinnedMessageId!) - 2)] - : pinnedMessageId!; - - if (!getLoadingPinnedId()) { - focusMessage({ - chatId, threadId, messageId, noForumTopicPanel: true, - }); - onFocusPinnedMessage(messageId); - } - }); - - const handleAllPinnedClick = useLastCallback(() => { - openThread({ chatId, threadId, type: 'pinned' }); - }); - const setBackButtonActive = useLastCallback(() => { setTimeout(() => { isBackButtonActive.current = true; @@ -292,81 +207,14 @@ const MiddleHeader: FC = ({ setBackButtonActive(); }); - const canToolsCollideWithChatInfo = ( - windowWidth >= MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN - && windowWidth < SAFE_SCREEN_WIDTH_FOR_CHAT_INFO - ) || ( - windowWidth > MOBILE_SCREEN_MAX_WIDTH - && windowWidth < MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN - && (!chatTitleLength || chatTitleLength > 30) - ); - const shouldUseStackedToolsClass = canToolsCollideWithChatInfo || ( - windowWidth > MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN - && windowWidth < SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN - ); - - const hasChatSettings = Boolean(settings?.canAddContact || settings?.canBlockContact || settings?.canReportSpam); - const { - shouldRender: shouldShowChatReportPanel, - transitionClassNames: chatReportPanelClassNames, - } = useShowTransitionDeprecated(hasChatSettings); - const renderingChatSettings = useCurrentOrPrev(hasChatSettings ? settings : undefined, true); - - const { - shouldRender: shouldRenderAudioPlayer, - transitionClassNames: audioPlayerClassNames, - } = useShowTransitionDeprecated(Boolean(audioMessage)); - - const renderingAudioMessage = useCurrentOrPrev(audioMessage, true); - - const { - shouldRender: shouldRenderPinnedMessage, - transitionClassNames: pinnedMessageClassNames, - } = useShowTransitionDeprecated(Boolean(pinnedMessage) && !isMiddleSearchOpen, undefined, true); - - const renderingPinnedMessage = useCurrentOrPrev(pinnedMessage, true); - const renderingPinnedMessagesCount = useCurrentOrPrev(pinnedMessagesCount, true); - const renderingCanUnpin = useCurrentOrPrev(canUnpin, true); - const renderingPinnedMessageTitle = useCurrentOrPrev(topMessageTitle); - const prevTransitionKey = usePreviousDeprecated(currentTransitionKey); const cleanupExceptionKey = ( prevTransitionKey !== undefined && prevTransitionKey < currentTransitionKey ? prevTransitionKey : undefined ); - const canRevealTools = (shouldRenderPinnedMessage && renderingPinnedMessage) - || (shouldRenderAudioPlayer && renderingAudioMessage); - - // Logic for transition to and from custom display of AudioPlayer/PinnedMessage on smaller screens - useLayoutEffect(() => { - const componentEl = componentRef.current; - if (!componentEl) { - return; - } - - if (!shouldUseStackedToolsClass || !canRevealTools) { - componentEl.classList.remove('tools-stacked', 'animated'); - shouldAnimateTools.current = true; - return; - } - - if (isRightColumnShown || canToolsCollideWithChatInfo) { - if (shouldAnimateTools.current) { - componentEl.classList.add('tools-stacked', 'animated'); - shouldAnimateTools.current = false; - } - - // Remove animation class to prevent it messing up the show transitions - setTimeout(() => { - requestMutation(() => { - componentEl.classList.remove('animated'); - }); - }, ANIMATION_DURATION); - } else { - componentEl.classList.remove('tools-stacked'); - shouldAnimateTools.current = true; - } - }, [shouldUseStackedToolsClass, canRevealTools, canToolsCollideWithChatInfo, isRightColumnShown]); + const isAudioPlayerActive = Boolean(audioMessage); + const isAudioPlayerRendering = isDesktop && isAudioPlayerActive; + const isPinnedMessagesFullWidth = isAudioPlayerActive || !isDesktop; const { connectionStatusText } = useConnectionStatus(lang, connectionState, isSyncing || isFetchingDifference, true); @@ -470,10 +318,6 @@ const MiddleHeader: FC = ({ ); } - const isAudioPlayerRendered = Boolean(shouldRenderAudioPlayer && renderingAudioMessage); - const isPinnedMessagesFullWidth = isAudioPlayerRendered - || (!isMobile && hasButtonInHeader && windowWidth < MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES); - useElectronDrag(componentRef); return ( @@ -486,56 +330,28 @@ const MiddleHeader: FC = ({ > {renderInfo()} - - {threadId === MAIN_THREAD_ID && !chat?.isForum && ( - - )} - - {shouldRenderPinnedMessage && renderingPinnedMessage && ( + {!isPinnedMessagesFullWidth && ( - )} - - {shouldShowChatReportPanel && ( - )}
- {isAudioPlayerRendered && ( - + {isAudioPlayerRendering && ( + )}
@@ -568,23 +384,14 @@ export default memo(withGlobal( messagesCount = threadInfo?.messagesCount || 0; } - const isMainThread = messageListType === 'thread' && threadId === MAIN_THREAD_ID; - const isChatWithBot = chat && selectIsChatWithBot(global, chat); - const canRestartBot = Boolean(isChatWithBot && selectIsUserBlocked(global, chatId)); - const canStartBot = isChatWithBot && !canRestartBot && Boolean(selectIsChatBotNotStarted(global, chatId)); - const canSubscribe = Boolean( - chat && (isMainThread || chat.isForum) && (isChatChannel(chat) || isChatSuperGroup(chat)) && chat.isNotJoined, - ); - const shouldSendJoinRequest = Boolean(chat?.isNotJoined && chat.isJoinRequest); const typingStatus = selectThreadParam(global, chatId, threadId, 'typingStatus'); const emojiStatus = chat?.emojiStatus; const emojiStatusSticker = emojiStatus && global.customEmojis.byId[emojiStatus.documentId]; const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); - const isMiddleSearchOpen = Boolean(selectCurrentMiddleSearch(global)); - const state: StateProps = { + return { typingStatus, isLeftColumnShown, isRightColumnShown: selectIsRightColumnShown(global, isMobile), @@ -597,52 +404,9 @@ export default memo(withGlobal( currentTransitionKey: Math.max(0, messageLists.length - 1), connectionState: global.connectionState, isSyncing: global.isSyncing, - isSynced: global.isSynced, isFetchingDifference: global.isFetchingDifference, emojiStatusSticker, - hasButtonInHeader: canStartBot || canRestartBot || canSubscribe || shouldSendJoinRequest, isSavedDialog, - isMiddleSearchOpen, }; - - const messagesById = selectChatMessages(global, chatId); - if (messageListType !== 'thread' || !messagesById) { - return state; - } - - if (threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum) { - const pinnedMessageId = Number(threadId); - const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined; - const topMessageSender = message ? selectForwardedSender(global, message) : undefined; - - return { - ...state, - pinnedMessageIds: pinnedMessageId, - messagesById, - canUnpin: false, - topMessageSender, - }; - } - - const pinnedMessageIds = !isSavedDialog ? selectPinnedIds(global, chatId, threadId) : undefined; - if (pinnedMessageIds?.length) { - const firstPinnedMessage = messagesById[pinnedMessageIds[0]]; - const { - canUnpin = false, - } = ( - firstPinnedMessage - && pinnedMessageIds.length === 1 - && selectAllowedMessageActionsSlow(global, firstPinnedMessage, threadId) - ) || {}; - - return { - ...state, - pinnedMessageIds, - messagesById, - canUnpin, - }; - } - - return state; }, )(MiddleHeader)); diff --git a/src/components/middle/MiddleHeaderPanes.module.scss b/src/components/middle/MiddleHeaderPanes.module.scss new file mode 100644 index 000000000..5153e8efe --- /dev/null +++ b/src/components/middle/MiddleHeaderPanes.module.scss @@ -0,0 +1,36 @@ +.root { + position: absolute; + top: 3.5rem; + left: 0; + + width: 100%; + + z-index: var(--z-middle-header); + + /* stylelint-disable-next-line plugin/no-low-performance-animation-properties */ + transition: width var(--layer-transition); + + @media (min-width: 1276px) { + :global(#Main.right-column-open) & { + width: calc(100% - var(--right-column-width)); + } + } + + /* stylelint-disable-next-line plugin/selector-tag-no-without-class */ + > div:last-child { + box-shadow: 0 2px 2px var(--color-light-shadow); + } + + // In case if there are no children, we need to have a shadow + &::before { + content: ''; + position: absolute; + pointer-events: none; + top: -2px; + height: 2px; + left: 0; + right: 0; + box-shadow: 0 2px 2px var(--color-light-shadow); + z-index: -100; // Hide behind the children + } +} diff --git a/src/components/middle/MiddleHeaderPanes.tsx b/src/components/middle/MiddleHeaderPanes.tsx new file mode 100644 index 000000000..23ce1ea5c --- /dev/null +++ b/src/components/middle/MiddleHeaderPanes.tsx @@ -0,0 +1,163 @@ +import React, { + memo, useRef, useSignal, +} from '../../lib/teact/teact'; +import { setExtraStyles } from '../../lib/teact/teact-dom'; +import { withGlobal } from '../../global'; + +import type { MessageListType } from '../../global/types'; +import type { ThreadId } from '../../types'; +import type { Signal } from '../../util/signals'; +import { type ApiChat, MAIN_THREAD_ID } from '../../api/types'; + +import { + selectChat, selectChatMessage, selectCurrentMiddleSearch, selectTabState, +} from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; + +import useAppLayout from '../../hooks/useAppLayout'; +import useEffectOnce from '../../hooks/useEffectOnce'; +import useShowTransition from '../../hooks/useShowTransition'; +import { useSignalEffect } from '../../hooks/useSignalEffect'; +import { applyAnimationState, type PaneState } from './hooks/useHeaderPane'; + +import GroupCallTopPane from '../calls/group/GroupCallTopPane'; +import AudioPlayer from './panes/AudioPlayer'; +import ChatReportPane from './panes/ChatReportPane'; +import HeaderPinnedMessage from './panes/HeaderPinnedMessage'; + +import styles from './MiddleHeaderPanes.module.scss'; + +type OwnProps = { + className?: string; + chatId: string; + threadId: ThreadId; + messageListType: MessageListType; + getCurrentPinnedIndex: Signal; + getLoadingPinnedId: Signal; + onFocusPinnedMessage: (messageId: number) => void; +}; + +type StateProps = { + chat?: ApiChat; + isAudioPlayerRendered?: boolean; + isMiddleSearchOpen?: boolean; +}; + +const FALLBACK_PANE_STATE = { height: 0 }; + +const MiddleHeaderPanes = ({ + className, + chatId, + threadId, + messageListType, + chat, + getCurrentPinnedIndex, + getLoadingPinnedId, + isAudioPlayerRendered, + isMiddleSearchOpen, + onFocusPinnedMessage, +}: OwnProps & StateProps) => { + const { settings } = chat || {}; + + const { isDesktop } = useAppLayout(); + const [getAudioPlayerState, setAudioPlayerState] = useSignal(FALLBACK_PANE_STATE); + const [getPinnedState, setPinnedState] = useSignal(FALLBACK_PANE_STATE); + const [getGroupCallState, setGroupCallState] = useSignal(FALLBACK_PANE_STATE); + const [getChatReportState, setChatReportState] = useSignal(FALLBACK_PANE_STATE); + + const isPinnedMessagesFullWidth = isAudioPlayerRendered || !isDesktop; + + const isFirstRenderRef = useRef(true); + const { + shouldRender, + ref, + } = useShowTransition({ + isOpen: !isMiddleSearchOpen, + withShouldRender: true, + }); + + useEffectOnce(() => { + isFirstRenderRef.current = false; + }); + + useSignalEffect(() => { + const audioPlayerState = getAudioPlayerState(); + const pinnedState = getPinnedState(); + const groupCallState = getGroupCallState(); + const chatReportState = getChatReportState(); + + // Keep in sync with the order of the panes in the DOM + const stateArray = [audioPlayerState, groupCallState, chatReportState, pinnedState]; + + const isFirstRender = isFirstRenderRef.current; + const totalHeight = stateArray.reduce((acc, state) => acc + state.height, 0); + + const middleColumn = document.getElementById('MiddleColumn'); + if (!middleColumn) return; + + applyAnimationState(stateArray, isFirstRender); + + setExtraStyles(middleColumn, { + '--middle-header-panes-height': `${totalHeight}px`, + }); + }, [getAudioPlayerState, getGroupCallState, getPinnedState, getChatReportState]); + + if (!shouldRender) return undefined; + + return ( +
+ + {threadId === MAIN_THREAD_ID && !chat?.isForum && ( + + )} + + +
+ ); +}; + +export default memo(withGlobal( + (global, { + chatId, + }): StateProps => { + const { audioPlayer } = selectTabState(global); + const chat = selectChat(global, chatId); + + const { chatId: audioChatId, messageId: audioMessageId } = audioPlayer; + const audioMessage = audioChatId && audioMessageId + ? selectChatMessage(global, audioChatId, audioMessageId) + : undefined; + + const isMiddleSearchOpen = Boolean(selectCurrentMiddleSearch(global)); + + return { + chat, + isAudioPlayerRendered: Boolean(audioMessage), + isMiddleSearchOpen, + }; + }, +)(MiddleHeaderPanes)); diff --git a/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx b/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx index e46c00ffd..88d3b707f 100644 --- a/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx +++ b/src/components/middle/composer/helpers/renderKeyboardButtonText.tsx @@ -1,6 +1,7 @@ import React, { type TeactNode } from '../../../../lib/teact/teact'; import type { ApiKeyboardButton } from '../../../../api/types'; +import type { LangFn } from '../../../../util/localization'; import { STARS_ICON_PLACEHOLDER } from '../../../../config'; import { replaceWithTeact } from '../../../../util/replaceWithTeact'; @@ -10,7 +11,7 @@ import { type OldLangFn } from '../../../../hooks/useOldLang'; import Icon from '../../../common/icons/Icon'; -export default function renderKeyboardButtonText(lang: OldLangFn, button: ApiKeyboardButton): TeactNode { +export default function renderKeyboardButtonText(lang: OldLangFn | LangFn, button: ApiKeyboardButton): TeactNode { if (button.type === 'receipt') { return lang('PaymentReceipt'); } diff --git a/src/components/middle/hooks/useHeaderPane.tsx b/src/components/middle/hooks/useHeaderPane.tsx new file mode 100644 index 000000000..afd3e60c0 --- /dev/null +++ b/src/components/middle/hooks/useHeaderPane.tsx @@ -0,0 +1,133 @@ +import { + type RefObject, + useEffect, + useLayoutEffect, + useRef, + useState, + useUnmountCleanup, +} from '../../../lib/teact/teact'; +import { setExtraStyles } from '../../../lib/teact/teact-dom'; + +import { requestForcedReflow, requestNextMutation } from '../../../lib/fasterdom/fasterdom'; + +import useTimeout from '../../../hooks/schedulers/useTimeout'; +import useLastCallback from '../../../hooks/useLastCallback'; + +export interface PaneState { + element?: HTMLElement; + height: number; + isOpen?: boolean; +} + +// Max slide transition duration +const CLOSE_DURATION = 450; + +export default function useHeaderPane({ + ref: providedRef, + isOpen, + isDisabled, + onStateChange, +} : { + ref?: RefObject; + isOpen?: boolean; + isDisabled?: boolean; + onStateChange?: (state: PaneState) => void; +}) { + const [shouldRender, setShouldRender] = useState(true); + // eslint-disable-next-line no-null/no-null + const localRef = useRef(null); + const ref = providedRef || localRef; + + const reset = useLastCallback(() => { + setShouldRender(true); + onStateChange?.({ + element: undefined, + height: 0, + isOpen: false, + }); + }); + + useEffect(() => { + if (isDisabled) { + reset(); + } + }, [isDisabled]); + + useEffect(() => { + if (isOpen) { + setShouldRender(true); + } + }, [isOpen]); + + useUnmountCleanup(reset); + + useTimeout(() => { + setShouldRender(false); + }, !isOpen ? CLOSE_DURATION : undefined); + + useLayoutEffect(() => { + const element = ref.current; + if (isDisabled || !element || !shouldRender) return; + + if (!isOpen) { + onStateChange?.({ + element, + height: 0, + isOpen: false, + }); + return; + } + + requestForcedReflow(() => { + const currentHeight = element.offsetHeight; + return () => { + onStateChange?.({ + element, + height: currentHeight, + isOpen, + }); + }; + }); + }, [isOpen, shouldRender, isDisabled, ref, onStateChange]); + + return { + shouldRender, + ref, + }; +} + +export function applyAnimationState(list: PaneState[], noTransition = false) { + let cumulativeHeight = 0; + for (let i = 0; i < list.length; i++) { + const state = list[i]; + const element = state.element; + if (!element) { + continue; + } + + const shiftPx = `${cumulativeHeight}px`; + + const apply = () => { + setExtraStyles(element, { + transform: `translateY(${state.isOpen ? shiftPx : `calc(${shiftPx} - 100%)`})`, + zIndex: String(-i), + transition: noTransition ? 'none' : '', + }); + }; + + if (!element.dataset.isPanelOpen && state.isOpen && !noTransition) { + // Start animation right above its final position + setExtraStyles(element, { + transform: `translateY(calc(${shiftPx} - 100%))`, + zIndex: String(-i), + transition: 'none', + }); + element.dataset.isPanelOpen = 'true'; + requestNextMutation(apply); + } else { + apply(); + } + + cumulativeHeight += state.height; + } +} diff --git a/src/components/middle/hooks/useStickyDates.ts b/src/components/middle/hooks/useStickyDates.ts index 854938aeb..c96043afb 100644 --- a/src/components/middle/hooks/useStickyDates.ts +++ b/src/components/middle/hooks/useStickyDates.ts @@ -6,7 +6,6 @@ import useRunDebounced from '../../../hooks/useRunDebounced'; const DEBOUNCE = 1000; const STICKY_TOP = 10; -const STICKY_TOP_WITH_TOOLS = 60; export default function useStickyDates() { // For some reason we can not synchronously hide a sticky element (from `useLayoutEffect`) when chat opens @@ -15,7 +14,7 @@ export default function useStickyDates() { const runDebounced = useRunDebounced(DEBOUNCE, true); - const updateStickyDates = useLastCallback((container: HTMLDivElement, hasTools?: boolean) => { + const updateStickyDates = useLastCallback((container: HTMLDivElement) => { markIsScrolled(); if (!document.body.classList.contains('is-scrolling-messages')) { @@ -25,7 +24,7 @@ export default function useStickyDates() { } runDebounced(() => { - const stuckDateEl = findStuckDate(container, hasTools); + const stuckDateEl = findStuckDate(container); if (stuckDateEl) { requestMutation(() => { stuckDateEl.classList.add('stuck'); @@ -49,13 +48,16 @@ export default function useStickyDates() { }; } -function findStuckDate(container: HTMLElement, hasTools?: boolean) { +function findStuckDate(container: HTMLElement) { const allElements = container.querySelectorAll('.sticky-date'); const containerTop = container.scrollTop; + const computedStyle = getComputedStyle(container); + const headerActionsHeight = parseInt(computedStyle.getPropertyValue('--middle-header-panes-height'), 10); + return Array.from(allElements).find((el) => { const { offsetTop, offsetHeight } = el; const top = offsetTop - containerTop; - return -offsetHeight <= top && top <= (hasTools ? STICKY_TOP_WITH_TOOLS : STICKY_TOP); + return -offsetHeight <= top && top <= (headerActionsHeight || STICKY_TOP); }); } diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index 8a4f891b9..a03b4302c 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -406,7 +406,7 @@ const ContextMenuContainer: FC = ({ }); const handleUnpin = useLastCallback(() => { - pinMessage({ messageId: message.id, isUnpin: true }); + pinMessage({ chatId: message.chatId, messageId: message.id, isUnpin: true }); closeMenu(); }); diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index e00737cda..f809dd8a5 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -312,10 +312,10 @@ const MessageContextMenu: FC = ({ const getLayout = useLastCallback(() => { const extraHeightAudioPlayer = (isMobile && (document.querySelector('.AudioPlayer-content'))?.offsetHeight) || 0; - const pinnedElement = document.querySelector('.HeaderPinnedMessageWrapper'); - const extraHeightPinned = (((isMobile && !extraHeightAudioPlayer) - || (!isMobile && pinnedElement?.classList.contains('full-width'))) - && pinnedElement?.offsetHeight) || 0; + const middleColumn = document.getElementById('MiddleColumn')!; + const middleColumnComputedStyle = getComputedStyle(middleColumn); + const headerToolsHeight = parseFloat(middleColumnComputedStyle.getPropertyValue('--middle-header-panes-height')); + const extraHeightPinned = headerToolsHeight || 0; return { extraPaddingX: SCROLLBAR_WIDTH, diff --git a/src/components/middle/AudioPlayer.scss b/src/components/middle/panes/AudioPlayer.scss similarity index 96% rename from src/components/middle/AudioPlayer.scss rename to src/components/middle/panes/AudioPlayer.scss index d89a217d5..cf0cfe2bb 100644 --- a/src/components/middle/AudioPlayer.scss +++ b/src/components/middle/panes/AudioPlayer.scss @@ -1,8 +1,9 @@ +@use "../../../styles/mixins"; + .AudioPlayer { display: flex; + align-items: center; margin-left: auto; - margin-top: -0.25rem; - margin-bottom: -0.25rem; body.no-page-transitions & { transition: none !important; @@ -170,10 +171,6 @@ max-width: 10rem; } - @media (max-width: 600px) { - max-width: 100%; - } - @media (min-width: 1440px) { max-width: 24rem; .right-column-shown & { @@ -213,6 +210,15 @@ } } + &.full-width-player { + @include mixins.header-pane; + + .AudioPlayer-content { + max-width: none; + flex-grow: 1; + } + } + .playback-rate-menu .bubble { min-width: auto; diff --git a/src/components/middle/AudioPlayer.tsx b/src/components/middle/panes/AudioPlayer.tsx similarity index 72% rename from src/components/middle/AudioPlayer.tsx rename to src/components/middle/panes/AudioPlayer.tsx index ef1a538a2..a36d65e89 100644 --- a/src/components/middle/AudioPlayer.tsx +++ b/src/components/middle/panes/AudioPlayer.tsx @@ -1,47 +1,54 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { useMemo, useRef } from '../../lib/teact/teact'; -import { getActions, withGlobal } from '../../global'; +import type { FC } from '../../../lib/teact/teact'; +import React, { useMemo } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; import type { ApiAudio, ApiChat, ApiMessage, ApiPeer, -} from '../../api/types'; -import type { AudioOrigin } from '../../types'; + MediaContent, +} from '../../../api/types'; -import { PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION } from '../../config'; +import { PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION } from '../../../config'; import { getMediaDuration, getMessageContent, getMessageMediaHash, getSenderTitle, isMessageLocal, -} from '../../global/helpers'; -import { selectChat, selectSender, selectTabState } from '../../global/selectors'; -import { makeTrackId } from '../../util/audioPlayer'; -import buildClassName from '../../util/buildClassName'; -import * as mediaLoader from '../../util/mediaLoader'; -import { clearMediaSession } from '../../util/mediaSession'; -import { IS_IOS, IS_TOUCH_ENV } from '../../util/windowEnvironment'; -import renderText from '../common/helpers/renderText'; +} from '../../../global/helpers'; +import { + selectChat, selectChatMessage, selectSender, selectTabState, +} from '../../../global/selectors'; +import { makeTrackId } from '../../../util/audioPlayer'; +import buildClassName from '../../../util/buildClassName'; +import * as mediaLoader from '../../../util/mediaLoader'; +import { clearMediaSession } from '../../../util/mediaSession'; +import { IS_IOS, IS_TOUCH_ENV } from '../../../util/windowEnvironment'; +import renderText from '../../common/helpers/renderText'; -import useAppLayout from '../../hooks/useAppLayout'; -import useAudioPlayer from '../../hooks/useAudioPlayer'; -import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; -import useLastCallback from '../../hooks/useLastCallback'; -import useMessageMediaMetadata from '../../hooks/useMessageMediaMetadata'; -import useOldLang from '../../hooks/useOldLang'; +import useAppLayout from '../../../hooks/useAppLayout'; +import useAudioPlayer from '../../../hooks/useAudioPlayer'; +import useContextMenuHandlers from '../../../hooks/useContextMenuHandlers'; +import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useMessageMediaMetadata from '../../../hooks/useMessageMediaMetadata'; +import useOldLang from '../../../hooks/useOldLang'; +import useShowTransition from '../../../hooks/useShowTransition'; +import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane'; -import Button from '../ui/Button'; -import DropdownMenu from '../ui/DropdownMenu'; -import MenuItem from '../ui/MenuItem'; -import RangeSlider from '../ui/RangeSlider'; -import RippleEffect from '../ui/RippleEffect'; +import Button from '../../ui/Button'; +import DropdownMenu from '../../ui/DropdownMenu'; +import MenuItem from '../../ui/MenuItem'; +import RangeSlider from '../../ui/RangeSlider'; +import RippleEffect from '../../ui/RippleEffect'; import './AudioPlayer.scss'; type OwnProps = { - message: ApiMessage; - origin?: AudioOrigin; className?: string; noUi?: boolean; + isFullWidth?: boolean; + isHidden?: boolean; + onPaneStateChange?: (state: PaneState) => void; }; type StateProps = { + message?: ApiMessage; sender?: ApiPeer; chat?: ApiChat; volume: number; @@ -72,6 +79,8 @@ const AudioPlayer: FC = ({ playbackRate, isPlaybackRateActive, isMuted, + isFullWidth, + onPaneStateChange, }) => { const { setAudioPlayerVolume, @@ -81,16 +90,19 @@ const AudioPlayer: FC = ({ closeAudioPlayer, } = getActions(); - // eslint-disable-next-line no-null/no-null - const ref = useRef(null); const lang = useOldLang(); + const { isMobile } = useAppLayout(); - const { audio, voice, video } = getMessageContent(message); + const renderingMessage = useCurrentOrPrev(message); + + const { audio, voice, video } = renderingMessage ? getMessageContent(renderingMessage) : {} satisfies MediaContent; const isVoice = Boolean(voice || video); const shouldRenderPlaybackButton = isVoice || (audio?.duration || 0) > PLAYBACK_RATE_FOR_AUDIO_MIN_DURATION; const senderName = sender ? getSenderTitle(lang, sender) : undefined; - const mediaData = mediaLoader.getFromMemory(getMessageMediaHash(message, 'inline')!) as (string | undefined); - const mediaMetadata = useMessageMediaMetadata(message, sender, chat); + + const mediaHash = renderingMessage && getMessageMediaHash(renderingMessage, 'inline'); + const mediaData = mediaHash && mediaLoader.getFromMemory(mediaHash); + const mediaMetadata = useMessageMediaMetadata(renderingMessage, sender, chat); const { playPause, @@ -104,8 +116,8 @@ const AudioPlayer: FC = ({ toggleMuted, setPlaybackRate, } = useAudioPlayer( - makeTrackId(message), - getMediaDuration(message)!, + message && makeTrackId(message), + message ? getMediaDuration(message)! : 0, isVoice ? 'voice' : 'audio', mediaData, undefined, @@ -114,18 +126,34 @@ const AudioPlayer: FC = ({ true, undefined, undefined, - isMessageLocal(message), + message && isMessageLocal(message), true, ); + const isOpen = Boolean(message); + const { + ref: transitionRef, + } = useShowTransition({ + isOpen, + shouldForceOpen: isFullWidth, // Use pane animation instead + }); + + const { ref, shouldRender } = useHeaderPane({ + isOpen, + isDisabled: !isFullWidth, + ref: transitionRef, + onStateChange: onPaneStateChange, + }); + const { isContextMenuOpen, handleBeforeContextMenu, handleContextMenu, handleContextMenuClose, handleContextMenuHide, - } = useContextMenuHandlers(ref); + } = useContextMenuHandlers(transitionRef, !shouldRender); const handleClick = useLastCallback(() => { - focusMessage({ chatId: message.chatId, messageId: message.id }); + const { chatId, id } = renderingMessage!; + focusMessage({ chatId, messageId: id }); }); const handleClose = useLastCallback(() => { @@ -221,12 +249,16 @@ const AudioPlayer: FC = ({ return 'icon-volume-3'; }, [volume, isMuted]); - if (noUi) { + if (noUi || !shouldRender) { return undefined; } return ( -
+
{audio ? renderAudio(audio) : renderVoice(lang('AttachAudio'), senderName)} @@ -365,14 +397,19 @@ function renderPlaybackRateMenuItem( } export default withGlobal( - (global, { message }): StateProps => { - const sender = selectSender(global, message); - const chat = selectChat(global, message.chatId); + (global, { isHidden }): StateProps => { + const { audioPlayer } = selectTabState(global); + const { chatId, messageId } = audioPlayer; + const message = !isHidden && chatId && messageId ? selectChatMessage(global, chatId, messageId) : undefined; + + const sender = message && selectSender(global, message); + const chat = message && selectChat(global, message.chatId); const { volume, playbackRate, isMuted, isPlaybackRateActive, } = selectTabState(global).audioPlayer; return { + message, sender, chat, volume, diff --git a/src/components/middle/panes/ChatReportPane.scss b/src/components/middle/panes/ChatReportPane.scss new file mode 100644 index 000000000..671ead4cd --- /dev/null +++ b/src/components/middle/panes/ChatReportPane.scss @@ -0,0 +1,20 @@ +@use "../../../styles/mixins"; + +.ChatReportPane { + @include mixins.header-pane; + + display: flex; + align-items: center; + + body.no-page-transitions & { + .ripple-container { + display: none; + } + } + + &--Button { + margin-left: 0.25rem; + flex: 1 1 50%; + white-space: nowrap; + } +} diff --git a/src/components/middle/ChatReportPanel.tsx b/src/components/middle/panes/ChatReportPane.tsx similarity index 56% rename from src/components/middle/ChatReportPanel.tsx rename to src/components/middle/panes/ChatReportPane.tsx index 2d7ef08b1..c790e87e5 100644 --- a/src/components/middle/ChatReportPanel.tsx +++ b/src/components/middle/panes/ChatReportPane.tsx @@ -1,29 +1,36 @@ -import type { FC } from '../../lib/teact/teact'; -import React, { memo, useState } from '../../lib/teact/teact'; -import { getActions, withGlobal } from '../../global'; +import type { FC } from '../../../lib/teact/teact'; +import React, { memo, useState } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; -import type { ApiChat, ApiChatSettings, ApiUser } from '../../api/types'; +import type { ApiChat, ApiUser } from '../../../api/types'; import { getChatTitle, getUserFirstOrLastName, getUserFullName, isChatBasicGroup, -} from '../../global/helpers'; -import { selectChat, selectUser } from '../../global/selectors'; -import buildClassName from '../../util/buildClassName'; +} from '../../../global/helpers'; +import { selectChat, selectUser } from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; -import useFlag from '../../hooks/useFlag'; -import useLastCallback from '../../hooks/useLastCallback'; -import useOldLang from '../../hooks/useOldLang'; +import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; +import useFlag from '../../../hooks/useFlag'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useOldLang from '../../../hooks/useOldLang'; +import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane'; -import Button from '../ui/Button'; -import Checkbox from '../ui/Checkbox'; -import ConfirmDialog from '../ui/ConfirmDialog'; +import Icon from '../../common/icons/Icon'; +import Button from '../../ui/Button'; +import Checkbox from '../../ui/Checkbox'; +import ConfirmDialog from '../../ui/ConfirmDialog'; -import './ChatReportPanel.scss'; +import './ChatReportPane.scss'; type OwnProps = { chatId: string; className?: string; - settings?: ApiChatSettings; + isAutoArchived?: boolean; + canReportSpam?: boolean; + canAddContact?: boolean; + canBlockContact?: boolean; + onPaneStateChange?: (state: PaneState) => void; }; type StateProps = { @@ -32,8 +39,17 @@ type StateProps = { user?: ApiUser; }; -const ChatReportPanel: FC = ({ - chatId, className, chat, user, settings, currentUserId, +const ChatReportPane: FC = ({ + chatId, + className, + isAutoArchived, + canReportSpam, + canAddContact, + canBlockContact, + chat, + user, + currentUserId, + onPaneStateChange, }) => { const { openAddContactDialog, @@ -44,21 +60,23 @@ const ChatReportPanel: FC = ({ deleteChatUser, deleteHistory, toggleChatArchived, - hideChatReportPanel, + hideChatReportPane, } = getActions(); const lang = useOldLang(); const [isBlockUserModalOpen, openBlockUserModal, closeBlockUserModal] = useFlag(); const [shouldReportSpam, setShouldReportSpam] = useState(true); const [shouldDeleteChat, setShouldDeleteChat] = useState(true); - const { - isAutoArchived, canReportSpam, canAddContact, canBlockContact, - } = settings || {}; const isBasicGroup = chat && isChatBasicGroup(chat); + const renderingCanAddContact = useCurrentOrPrev(canAddContact); + const renderingCanBlockContact = useCurrentOrPrev(canBlockContact); + const renderingCanReportSpam = useCurrentOrPrev(canReportSpam); + const renderingIsAutoArchived = useCurrentOrPrev(isAutoArchived); + const handleAddContact = useLastCallback(() => { openAddContactDialog({ userId: chatId }); - if (isAutoArchived) { + if (renderingIsAutoArchived) { toggleChatArchived({ id: chatId }); } }); @@ -66,7 +84,7 @@ const ChatReportPanel: FC = ({ const handleConfirmBlock = useLastCallback(() => { closeBlockUserModal(); blockUser({ userId: chatId }); - if (canReportSpam && shouldReportSpam) { + if (renderingCanReportSpam && shouldReportSpam) { reportSpam({ chatId }); } if (shouldDeleteChat) { @@ -74,8 +92,8 @@ const ChatReportPanel: FC = ({ } }); - const handleCloseReportPanel = useLastCallback(() => { - hideChatReportPanel({ chatId }); + const handleCloseReportPane = useLastCallback(() => { + hideChatReportPane({ chatId }); }); const handleChatReportSpam = useLastCallback(() => { @@ -89,42 +107,53 @@ const ChatReportPanel: FC = ({ } }); - if (!settings || (!chat && !user)) { - return undefined; - } + const hasAnyButton = canAddContact || canBlockContact || canReportSpam; + + const isRendering = Boolean(hasAnyButton && (chat || user)); + + const { ref, shouldRender } = useHeaderPane({ + isOpen: isRendering, + onStateChange: onPaneStateChange, + }); + + if (!shouldRender) return undefined; return ( -
- {canAddContact && ( +
+ {renderingCanAddContact && ( )} - {canBlockContact && ( + {renderingCanBlockContact && ( )} - {canReportSpam && !canBlockContact && ( + {renderingCanReportSpam && !renderingCanBlockContact && ( ( chat: selectChat(global, chatId), user: selectUser(global, chatId), }), -)(ChatReportPanel)); +)(ChatReportPane)); diff --git a/src/components/middle/HeaderPinnedMessage.module.scss b/src/components/middle/panes/HeaderPinnedMessage.module.scss similarity index 76% rename from src/components/middle/HeaderPinnedMessage.module.scss rename to src/components/middle/panes/HeaderPinnedMessage.module.scss index e375663d5..ee46bc16d 100644 --- a/src/components/middle/HeaderPinnedMessage.module.scss +++ b/src/components/middle/panes/HeaderPinnedMessage.module.scss @@ -1,4 +1,4 @@ -@use "../../styles/mixins"; +@use "../../../styles/mixins"; .root { display: flex; @@ -26,6 +26,12 @@ transition: none !important; } + > :global(.Button) { + flex-shrink: 0; + } +} + +.mini { @media (min-width: 1276px) { transform: translate3d(0, 0, 0); transition: opacity 0.15s ease, transform var(--layer-transition); @@ -38,33 +44,10 @@ transform: translate3d(calc(var(--right-column-width) * -1), 0, 0); } } - - > :global(.Button) { - flex-shrink: 0; - } } -.root:global(.full-width) { - position: absolute; - left: 0; - right: 0; - top: 100%; - background: var(--color-background); - padding: 0.25rem 0.8125rem 0.25rem 1rem; - box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow); - transform: translate3d(0, 0, 0); - transition: opacity 0.15s ease, transform var(--layer-transition); - - &::before { - content: ""; - display: block; - position: absolute; - top: -0.1875rem; - left: 0; - right: 0; - height: 0.125rem; - box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow); - } +.fullWidth { + @include mixins.header-pane; .pinnedMessage { margin-top: 0; @@ -75,12 +58,6 @@ .messageText { max-width: none; } - - @media (min-width: 1276px) { - :global(#Main.right-column-open) & { - padding-left: calc(var(--right-column-width) + 1rem); - } - } } .loading { @@ -244,30 +221,4 @@ max-width: none; } } - - .root:global(.full-width) { - display: none; - } - - .root { - @include mixins.header-mobile(); - } -} - -@media (min-width: 1276px) and (max-width: 1439px) { - :global(.MiddleHeader:not(.tools-stacked)) .root { - opacity: 1; - - :global(#Main.right-column-open) & { - opacity: 0; - } - } -} - -:global(.tools-stacked.animated) .root { - animation: fade-in var(--layer-transition) forwards; - - :global(body.no-page-transitions) & { - animation: none; - } } diff --git a/src/components/middle/panes/HeaderPinnedMessage.tsx b/src/components/middle/panes/HeaderPinnedMessage.tsx new file mode 100644 index 000000000..f81dd8a9b --- /dev/null +++ b/src/components/middle/panes/HeaderPinnedMessage.tsx @@ -0,0 +1,395 @@ +import React, { memo, useEffect } from '../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../global'; + +import type { ApiChat, ApiMessage, ApiPeer } from '../../../api/types'; +import type { MessageListType } from '../../../global/types'; +import type { ThreadId } from '../../../types'; +import type { Signal } from '../../../util/signals'; +import { MAIN_THREAD_ID } from '../../../api/types'; + +import { + getIsSavedDialog, + getMessageIsSpoiler, + getMessageMediaHash, + getMessageSingleInlineButton, + getMessageVideo, + getSenderTitle, +} from '../../../global/helpers'; +import { + selectAllowedMessageActionsSlow, + selectChat, + selectChatMessage, + selectChatMessages, + selectForwardedSender, + selectPinnedIds, +} from '../../../global/selectors'; +import buildClassName from '../../../util/buildClassName'; +import cycleRestrict from '../../../util/cycleRestrict'; +import { IS_TOUCH_ENV } from '../../../util/windowEnvironment'; +import { getPictogramDimensions, REM } from '../../common/helpers/mediaDimensions'; +import renderText from '../../common/helpers/renderText'; +import renderKeyboardButtonText from '../composer/helpers/renderKeyboardButtonText'; + +import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev'; +import useDerivedState from '../../../hooks/useDerivedState'; +import useEnsureMessage from '../../../hooks/useEnsureMessage'; +import { useFastClick } from '../../../hooks/useFastClick'; +import useFlag from '../../../hooks/useFlag'; +import useLang from '../../../hooks/useLang'; +import useLastCallback from '../../../hooks/useLastCallback'; +import useMedia from '../../../hooks/useMedia'; +import useShowTransition from '../../../hooks/useShowTransition'; +import useThumbnail from '../../../hooks/useThumbnail'; +import useAsyncRendering from '../../right/hooks/useAsyncRendering'; +import useHeaderPane, { type PaneState } from '../hooks/useHeaderPane'; + +import AnimatedCounter from '../../common/AnimatedCounter'; +import Icon from '../../common/icons/Icon'; +import MediaSpoiler from '../../common/MediaSpoiler'; +import MessageSummary from '../../common/MessageSummary'; +import Button from '../../ui/Button'; +import ConfirmDialog from '../../ui/ConfirmDialog'; +import RippleEffect from '../../ui/RippleEffect'; +import Spinner from '../../ui/Spinner'; +import Transition from '../../ui/Transition'; +import PinnedMessageNavigation from '../PinnedMessageNavigation'; + +import styles from './HeaderPinnedMessage.module.scss'; + +const SHOW_LOADER_DELAY = 450; +const EMOJI_SIZE = 1.125 * REM; + +type OwnProps = { + chatId: string; + threadId: ThreadId; + // eslint-disable-next-line react/no-unused-prop-types + messageListType: MessageListType; + className?: string; + isFullWidth?: boolean; + shouldHide?: boolean; + getLoadingPinnedId: Signal; + getCurrentPinnedIndex: Signal; + onFocusPinnedMessage: (messageId: number) => void; + onPaneStateChange?: (state: PaneState) => void; +}; + +type StateProps = { + chat?: ApiChat; + pinnedMessageIds?: number[] | number; + messagesById?: Record; + canUnpin?: boolean; + topMessageSender?: ApiPeer; + isSynced?: boolean; +}; + +const HeaderPinnedMessage = ({ + chatId, + threadId, + canUnpin, + getLoadingPinnedId, + pinnedMessageIds, + messagesById, + isFullWidth, + topMessageSender, + getCurrentPinnedIndex, + className, + chat, + isSynced, + shouldHide, + onPaneStateChange, + onFocusPinnedMessage, +}: OwnProps & StateProps) => { + const { + clickBotInlineButton, focusMessage, openThread, pinMessage, loadPinnedMessages, + } = getActions(); + const lang = useLang(); + + const currentPinnedIndex = useDerivedState(getCurrentPinnedIndex); + const pinnedMessageId = Array.isArray(pinnedMessageIds) ? pinnedMessageIds[currentPinnedIndex] : pinnedMessageIds; + const pinnedMessage = messagesById && pinnedMessageId ? messagesById[pinnedMessageId] : undefined; + const pinnedMessagesCount = Array.isArray(pinnedMessageIds) + ? pinnedMessageIds.length : (pinnedMessageIds ? 1 : 0); + const pinnedMessageNumber = Math.max(pinnedMessagesCount - currentPinnedIndex, 1); + + const topMessageTitle = topMessageSender ? getSenderTitle(lang, topMessageSender) : undefined; + + const video = pinnedMessage && getMessageVideo(pinnedMessage); + const gif = video?.isGif ? video : undefined; + const isVideoThumbnail = Boolean(gif && !gif.previewPhotoSizes?.length); + + const mediaThumbnail = useThumbnail(pinnedMessage); + const mediaHash = pinnedMessage && getMessageMediaHash(pinnedMessage, isVideoThumbnail ? 'full' : 'pictogram'); + const mediaBlobUrl = useMedia(mediaHash); + const isSpoiler = pinnedMessage && getMessageIsSpoiler(pinnedMessage); + + const isLoading = Boolean(useDerivedState(getLoadingPinnedId)); + const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); + const shouldShowLoader = canRenderLoader && isLoading; + + const renderingPinnedMessage = useCurrentOrPrev(pinnedMessage, true); + + useEffect(() => { + if (isSynced && (threadId === MAIN_THREAD_ID || chat?.isForum)) { + loadPinnedMessages({ chatId, threadId }); + } + }, [chatId, threadId, isSynced, chat]); + + useEnsureMessage(chatId, pinnedMessageId, pinnedMessage); + + const isOpen = Boolean(pinnedMessage) && !shouldHide; + const { + ref: transitionRef, + } = useShowTransition({ + isOpen, + noOpenTransition: true, + shouldForceOpen: isFullWidth, // Use pane animation instead + }); + + const { ref, shouldRender } = useHeaderPane({ + isOpen, + isDisabled: !isFullWidth, + ref: transitionRef, + onStateChange: onPaneStateChange, + }); + + const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag(); + + const handleUnpinMessage = useLastCallback(() => { + closeUnpinDialog(); + pinMessage({ chatId, messageId: pinnedMessage!.id, isUnpin: true }); + }); + + const inlineButton = pinnedMessage && getMessageSingleInlineButton(pinnedMessage); + + const handleInlineButtonClick = useLastCallback(() => { + if (inlineButton) { + clickBotInlineButton({ chatId: pinnedMessage.chatId, messageId: pinnedMessage.id, button: inlineButton }); + } + }); + + const handleAllPinnedClick = useLastCallback(() => { + openThread({ chatId, threadId, type: 'pinned' }); + }); + + const handleMessageClick = useLastCallback((e: React.MouseEvent): void => { + const nextMessageId = e.shiftKey && Array.isArray(pinnedMessageIds) + ? pinnedMessageIds[cycleRestrict(pinnedMessageIds.length, pinnedMessageIds.indexOf(pinnedMessageId!) - 2)] + : pinnedMessageId!; + + if (!getLoadingPinnedId()) { + focusMessage({ + chatId, threadId, messageId: nextMessageId, noForumTopicPanel: true, + }); + onFocusPinnedMessage(nextMessageId); + } + }); + + const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag(); + + const { handleClick, handleMouseDown } = useFastClick(handleMessageClick); + + function renderPictogram(thumbDataUri?: string, blobUrl?: string, isFullVideo?: boolean, asSpoiler?: boolean) { + const { width, height } = getPictogramDimensions(); + const srcUrl = blobUrl || thumbDataUri; + const shouldRenderVideo = isFullVideo && blobUrl; + + return ( +
+ {thumbDataUri && !asSpoiler && !shouldRenderVideo && ( + + )} + {shouldRenderVideo && !asSpoiler && ( +
+ ); + } + + if (!shouldRender || !renderingPinnedMessage) return undefined; + + return ( +
+ {(pinnedMessagesCount > 1 || shouldShowLoader) && ( + + )} + {canUnpin && ( + + )} + +
+ + + {renderPictogram( + mediaThumbnail, + mediaBlobUrl, + isVideoThumbnail, + isSpoiler, + )} + +
+
+ {!topMessageTitle && ( + + )} + + {topMessageTitle && renderText(topMessageTitle)} +
+ +

+ +

+
+
+ + {inlineButton && ( + + )} +
+
+ ); +}; + +export default memo(withGlobal( + (global, { + chatId, threadId, messageListType, + }): StateProps => { + const chat = selectChat(global, chatId); + + const isSynced = global.isSynced; + const isSavedDialog = getIsSavedDialog(chatId, threadId, global.currentUserId); + + const messagesById = selectChatMessages(global, chatId); + + const state = { + chat, + isSynced, + }; + + if (messageListType !== 'thread' || !messagesById) { + return state; + } + + if (threadId !== MAIN_THREAD_ID && !isSavedDialog && !chat?.isForum) { + const pinnedMessageId = Number(threadId); + const message = pinnedMessageId ? selectChatMessage(global, chatId, pinnedMessageId) : undefined; + const topMessageSender = message ? selectForwardedSender(global, message) : undefined; + + return { + ...state, + pinnedMessageIds: pinnedMessageId, + messagesById, + canUnpin: false, + topMessageSender, + }; + } + + const pinnedMessageIds = !isSavedDialog ? selectPinnedIds(global, chatId, threadId) : undefined; + if (pinnedMessageIds?.length) { + const firstPinnedMessage = messagesById[pinnedMessageIds[0]]; + const { + canUnpin = false, + } = ( + firstPinnedMessage + && pinnedMessageIds.length === 1 + && selectAllowedMessageActionsSlow(global, firstPinnedMessage, threadId) + ) || {}; + + return { + ...state, + pinnedMessageIds, + messagesById, + canUnpin, + }; + } + + return state; + }, +)(HeaderPinnedMessage)); diff --git a/src/components/ui/Notification.scss b/src/components/ui/Notification.scss index 609be1d47..403ef5354 100644 --- a/src/components/ui/Notification.scss +++ b/src/components/ui/Notification.scss @@ -3,22 +3,11 @@ width: 22rem; max-width: 100vw; margin: 4.25rem auto 0.25rem; + margin-top: calc(4.25rem + var(--middle-header-panes-height)); z-index: var(--z-notification); - .has-header-tools & { - margin-top: 7.375rem; - } - - @media (min-width: 1276px) { - transition: transform var(--layer-transition); - } - & ~ & { margin-top: 0.25rem; - - .has-header-tools & { - margin-top: 0.25rem; - } } } diff --git a/src/config.ts b/src/config.ts index 89a4aa245..09feb1d00 100644 --- a/src/config.ts +++ b/src/config.ts @@ -154,14 +154,8 @@ export const SNAP_EFFECT_ID = 'snap-effect'; export const STARS_ICON_PLACEHOLDER = '⭐'; export const STARS_CURRENCY_CODE = 'XTR'; -// Screen width where Pinned Message / Audio Player in the Middle Header can be safely displayed -export const SAFE_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN = 1440; // px -// Screen width where Pinned Message / Audio Player in the Middle Header shouldn't collapse with ChatInfo -export const SAFE_SCREEN_WIDTH_FOR_CHAT_INFO = 1150; // px - export const MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN = 1275; // px export const MIN_SCREEN_WIDTH_FOR_STATIC_LEFT_COLUMN = 925; // px -export const MAX_SCREEN_WIDTH_FOR_EXPAND_PINNED_MESSAGES = 1340; // px export const MOBILE_SCREEN_MAX_WIDTH = 600; // px export const MOBILE_SCREEN_LANDSCAPE_MAX_WIDTH = 950; // px export const MOBILE_SCREEN_LANDSCAPE_MAX_HEIGHT = 450; // px diff --git a/src/global/actions/api/management.ts b/src/global/actions/api/management.ts index 255e22857..ca1822d7a 100644 --- a/src/global/actions/api/management.ts +++ b/src/global/actions/api/management.ts @@ -394,12 +394,12 @@ addActionHandler('hideAllChatJoinRequests', async (global, actions, payload): Pr setGlobal(global); }); -addActionHandler('hideChatReportPanel', async (global, actions, payload): Promise => { +addActionHandler('hideChatReportPane', async (global, actions, payload): Promise => { const { chatId } = payload!; const chat = selectChat(global, chatId); if (!chat) return; - const result = await callApi('hideChatReportPanel', chat); + const result = await callApi('hideChatReportPane', chat); if (!result) return; global = getGlobal(); diff --git a/src/global/actions/api/messages.ts b/src/global/actions/api/messages.ts index 38109261e..9adb07dc5 100644 --- a/src/global/actions/api/messages.ts +++ b/src/global/actions/api/messages.ts @@ -696,10 +696,10 @@ addActionHandler('toggleMessageWebPage', (global, actions, payload): ActionRetur addActionHandler('pinMessage', (global, actions, payload): ActionReturnType => { const { - messageId, isUnpin, isOneSide, isSilent, tabId = getCurrentTabId(), + chatId, messageId, isUnpin, isOneSide, isSilent, } = payload; - const chat = selectCurrentChat(global, tabId); + const chat = selectChat(global, chatId); if (!chat) { return; } diff --git a/src/global/helpers/chats.ts b/src/global/helpers/chats.ts index b8f68759e..40f26713e 100644 --- a/src/global/helpers/chats.ts +++ b/src/global/helpers/chats.ts @@ -13,6 +13,7 @@ import type { OldLangFn } from '../../hooks/useOldLang'; import type { CustomPeer, NotifyException, NotifySettings, ThreadId, } from '../../types'; +import type { LangFn } from '../../util/localization'; import { MAIN_THREAD_ID } from '../../api/types'; import { @@ -101,7 +102,7 @@ export function getPrivateChatUserId(chat: ApiChat) { return chat.id; } -export function getChatTitle(lang: OldLangFn, chat: ApiChat, isSelf = false) { +export function getChatTitle(lang: OldLangFn | LangFn, chat: ApiChat, isSelf = false) { if (isSelf) { return lang('SavedMessages'); } diff --git a/src/global/helpers/messages.ts b/src/global/helpers/messages.ts index c1661f641..b1e8417b9 100644 --- a/src/global/helpers/messages.ts +++ b/src/global/helpers/messages.ts @@ -11,6 +11,7 @@ import type { } from '../../api/types/messages'; import type { OldLangFn } from '../../hooks/useOldLang'; import type { ThreadId } from '../../types'; +import type { LangFn } from '../../util/localization'; import type { GlobalState } from '../types'; import { ApiMessageEntityTypes, MAIN_THREAD_ID } from '../../api/types'; @@ -208,7 +209,7 @@ export function isAnonymousOwnMessage(message: ApiMessage) { return Boolean(message.senderId) && !isUserId(message.senderId) && isOwnMessage(message); } -export function getSenderTitle(lang: OldLangFn, sender: ApiPeer) { +export function getSenderTitle(lang: OldLangFn | LangFn, sender: ApiPeer) { return isPeerUser(sender) ? getUserFullName(sender) : getChatTitle(lang, sender); } diff --git a/src/global/types.ts b/src/global/types.ts index 21c1f4c03..b933aac4e 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -1695,11 +1695,12 @@ export interface ActionPayloads { messageId: number; }; pinMessage: { + chatId: string; messageId: number; isUnpin: boolean; isOneSide?: boolean; isSilent?: boolean; - } & WithTabId; + }; deleteMessages: { messageIds: number[]; shouldDeleteForAll?: boolean; @@ -2114,7 +2115,7 @@ export interface ActionPayloads { offsetUserId?: string; limit?: number; } & WithTabId; - hideChatReportPanel: { + hideChatReportPane: { chatId: string; }; toggleManagement: ({ diff --git a/src/hooks/useMessageMediaMetadata.ts b/src/hooks/useMessageMediaMetadata.ts index 62ccfa1bd..6d4e5a97d 100644 --- a/src/hooks/useMessageMediaMetadata.ts +++ b/src/hooks/useMessageMediaMetadata.ts @@ -2,6 +2,7 @@ import { useMemo } from '../lib/teact/teact'; import type { ApiAudio, ApiChat, ApiMessage, ApiPeer, ApiVoice, + MediaContent, } from '../api/types'; import { @@ -21,11 +22,11 @@ const MINIMAL_SIZE = 115; // spec says 100, but on Chrome 93 it's not showing // TODO Add support for video in future const useMessageMediaMetadata = ( - message: ApiMessage, sender?: ApiPeer, chat?: ApiChat, + message?: ApiMessage, sender?: ApiPeer, chat?: ApiChat, ): MediaMetadata | undefined => { const lang = useOldLang(); - const { audio, voice } = getMessageContent(message); + const { audio, voice } = message ? getMessageContent(message) : {} satisfies MediaContent; const title = audio ? (audio.title || audio.fileName) : voice ? 'Voice message' : ''; const artist = audio?.performer || (sender && getSenderTitle(lang, sender)); const album = (chat && getChatTitle(lang, chat)) || 'Telegram'; diff --git a/src/hooks/useShowTransition.ts b/src/hooks/useShowTransition.ts index 97c579999..9509fd351 100644 --- a/src/hooks/useShowTransition.ts +++ b/src/hooks/useShowTransition.ts @@ -23,6 +23,7 @@ type BaseHookParams = { closeDuration?: number; className?: string | false; prefix?: string; + shouldForceOpen?: boolean; onCloseAnimationEnd?: NoneToVoidFunction; }; @@ -66,6 +67,7 @@ export default function useShowTransition { const options = optionsRef.current; + if (shouldForceOpen) { + setState('open'); + return; + } + if (isOpen) { if (closingTimeoutRef.current) { clearTimeout(closingTimeoutRef.current); @@ -106,7 +113,7 @@ export default function useShowTransition { const element = ref.current; diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 8ba03d23e..e0cd079f2 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -117,21 +117,19 @@ } } -@mixin header-mobile { +@mixin header-pane { position: absolute; - top: 100%; - left: 0; - right: 0; + top: 0; + width: 100%; height: 2.875rem; - box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow); + transform: translateY(-100%); + transition: transform var(--slide-transition); - display: flex; - flex-direction: row-reverse; padding-top: 0.375rem; padding-bottom: 0.375rem; padding-left: max(0.75rem, env(safe-area-inset-left)); padding-right: max(0.5rem, env(safe-area-inset-right)); - background: var(--color-background); + background-color: var(--color-background); &::before { content: ""; @@ -143,6 +141,18 @@ height: 0.125rem; box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow); } + + // Some panels might unmount without animation, so we provide same background above panel to make it less noticeable + &::after { + content: ""; + position: absolute; + top: -100%; + left: 0; + right: 0; + height: inherit; + background-color: inherit; + z-index: -1; + } } @mixin side-panel-section { diff --git a/src/types/language.d.ts b/src/types/language.d.ts index fb7a1c151..d798b7c4e 100644 --- a/src/types/language.d.ts +++ b/src/types/language.d.ts @@ -574,7 +574,9 @@ export interface LangPair { 'Statistics': undefined; 'EventLogFilterPinnedMessages': undefined; 'UnpinMessageAlertTitle': undefined; - 'PinnedMessage': undefined; + 'PinnedMessageTitleSingle': undefined; + 'AccPinnedMessages': undefined; + 'AccUnpinMessage': undefined; 'LeaveAComment': undefined; 'PollsStopWarning': undefined; 'PollsStopSure': undefined; @@ -1589,6 +1591,9 @@ export interface LangPairPluralWithVariables { 'PreviewSenderSendFile': { 'count': V; }; + 'PinnedMessageTitle': { + 'index': V; + }; 'Comments': { 'count': V; };