diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index aeeffd0cf..8da7648db 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -162,7 +162,7 @@ type UniversalMessage = ( & Pick, ( 'out' | 'message' | 'entities' | 'fromId' | 'peerId' | 'fwdFrom' | 'replyTo' | 'replyMarkup' | 'post' | 'media' | 'action' | 'views' | 'editDate' | 'editHide' | 'mediaUnread' | 'groupedId' | 'mentioned' | 'viaBotId' | - 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' | 'silent' + 'replies' | 'fromScheduled' | 'postAuthor' | 'noforwards' | 'reactions' | 'forwards' | 'silent' | 'pinned' )> ); @@ -213,6 +213,7 @@ export function buildApiMessageWithChatId( forwards: mtpMessage.forwards, isFromScheduled: mtpMessage.fromScheduled, isSilent: mtpMessage.silent, + isPinned: mtpMessage.pinned, reactions: mtpMessage.reactions && buildMessageReactions(mtpMessage.reactions), emojiOnlyCount, ...(replyToMsgId && { replyToMessageId: replyToMsgId }), diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index f93c16e7b..2b428fcfb 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -423,6 +423,7 @@ export interface ApiMessage { isHideKeyboardSelective?: boolean; isFromScheduled?: boolean; isSilent?: boolean; + isPinned?: boolean; seenByUserIds?: string[]; isProtected?: boolean; isForwardingAllowed?: boolean; diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 478f2d136..12494fc09 100644 Binary files a/src/assets/fonts/icomoon.woff and b/src/assets/fonts/icomoon.woff differ diff --git a/src/assets/fonts/icomoon.woff2 b/src/assets/fonts/icomoon.woff2 index fe89d4f86..c80b6ab7e 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/components/common/AnimatedCounter.tsx b/src/components/common/AnimatedCounter.tsx index e5b23d67d..7391135e2 100644 --- a/src/components/common/AnimatedCounter.tsx +++ b/src/components/common/AnimatedCounter.tsx @@ -1,12 +1,12 @@ import type { FC } from '../../lib/teact/teact'; -import React, { useMemo, useRef } from '../../lib/teact/teact'; +import React, { + useEffect, useMemo, useRef, +} from '../../lib/teact/teact'; import { getGlobal } from '../../global'; import { ANIMATION_LEVEL_MAX } from '../../config'; -import usePrevious from '../../hooks/usePrevious'; -import useForceUpdate from '../../hooks/useForceUpdate'; -import useTimeout from '../../hooks/useTimeout'; import useLang from '../../hooks/useLang'; +import useFlag from '../../hooks/useFlag'; import styles from './AnimatedCounter.module.scss'; @@ -14,22 +14,25 @@ type OwnProps = { text: string; }; -const ANIMATION_TIME = 200; - const AnimatedCounter: FC = ({ text, }) => { const lang = useLang(); - const prevText = usePrevious(text); - const forceUpdate = useForceUpdate(); - - const isAnimatingRef = useRef(false); + const prevTextRef = useRef(); + const [isAnimating, markAnimating, unmarkAnimating] = useFlag(false); const shouldAnimate = getGlobal().settings.byKey.animationLevel === ANIMATION_LEVEL_MAX; const textElement = useMemo(() => { - if (!shouldAnimate) return text; + if (!shouldAnimate) { + return text; + } + if (!isAnimating) { + return prevTextRef.current || text; + } + + const prevText = prevTextRef.current; const elements = []; for (let i = 0; i < text.length; i++) { @@ -37,8 +40,8 @@ const AnimatedCounter: FC = ({ elements.push(
{text[i]}
-
{prevText[i]}
-
{text[i]}
+
{prevText[i]}
+
{text[i]}
, ); } else { @@ -46,15 +49,14 @@ const AnimatedCounter: FC = ({ } } - isAnimatingRef.current = true; + prevTextRef.current = text; return elements; - }, [prevText, shouldAnimate, text]); + }, [shouldAnimate, isAnimating, text]); - useTimeout(() => { - isAnimatingRef.current = false; - forceUpdate(); - }, isAnimatingRef.current ? ANIMATION_TIME : undefined); + useEffect(() => { + markAnimating(); + }, [text]); return ( diff --git a/src/components/mediaViewer/MediaViewer.tsx b/src/components/mediaViewer/MediaViewer.tsx index 77eae3033..b8c021a8f 100644 --- a/src/components/mediaViewer/MediaViewer.tsx +++ b/src/components/mediaViewer/MediaViewer.tsx @@ -17,9 +17,8 @@ import { selectCurrentMediaSearch, selectTabState, selectIsChatWithSelf, selectListedIds, - selectOutlyingIds, selectScheduledMessage, - selectUser, + selectUser, selectOutlyingListByMessageId, } from '../../global/selectors'; import { stopCurrentAudio } from '../../util/audioPlayer'; import captureEscKeyListener from '../../util/captureEscKeyListener'; @@ -462,9 +461,9 @@ export default memo(withGlobal( let collectionIds: number[] | undefined; if (origin === MediaViewerOrigin.Inline - || origin === MediaViewerOrigin.Album - || origin === MediaViewerOrigin.SuggestedAvatar) { - collectionIds = selectOutlyingIds(global, chatId, threadId) || selectListedIds(global, chatId, threadId); + || origin === MediaViewerOrigin.Album) { + collectionIds = selectOutlyingListByMessageId(global, chatId, threadId, message.id) + || selectListedIds(global, chatId, threadId); } else if (origin === MediaViewerOrigin.SharedMedia) { const currentSearch = selectCurrentMediaSearch(global); const { foundIds } = (currentSearch && currentSearch.resultsByType && currentSearch.resultsByType.media) || {}; diff --git a/src/components/middle/ActionMessage.tsx b/src/components/middle/ActionMessage.tsx index f4812b77a..d77035b74 100644 --- a/src/components/middle/ActionMessage.tsx +++ b/src/components/middle/ActionMessage.tsx @@ -8,6 +8,7 @@ import type { ApiUser, ApiMessage, ApiChat, ApiSticker, ApiTopic, } from '../../api/types'; import type { FocusDirection } from '../../types'; +import type { PinnedIntersectionChangedCallback } from './hooks/usePinnedMessage'; import { selectUser, @@ -45,6 +46,7 @@ type OwnProps = { isLastInList?: boolean; isInsideTopic?: boolean; memoFirstUnreadIdRef?: { current: number | undefined }; + onPinnedIntersectionChange?: PinnedIntersectionChangedCallback; }; type StateProps = { @@ -84,6 +86,7 @@ const ActionMessage: FC = ({ observeIntersectionForReading, observeIntersectionForLoading, observeIntersectionForPlaying, + onPinnedIntersectionChange, }) => { const { openPremiumModal, requestConfetti } = getActions(); @@ -96,6 +99,14 @@ const ActionMessage: FC = ({ useEnsureMessage(message.chatId, message.replyToMessageId, targetMessage); useFocusMessage(ref, message.chatId, isFocused, focusDirection, noFocusHighlight); + useEffect(() => { + if (!message.isPinned) return undefined; + + return () => { + onPinnedIntersectionChange?.({ viewportPinnedIdsToRemove: [message.id], isUnmount: true }); + }; + }, [onPinnedIntersectionChange, message.isPinned, message.id]); + const noAppearanceAnimation = appearanceOrder <= 0; const [isShown, markShown] = useFlag(noAppearanceAnimation); const isGift = Boolean(message.content.action?.text.startsWith('ActionGift')); @@ -209,6 +220,7 @@ const ActionMessage: FC = ({ id={getMessageHtmlId(message.id)} className={className} data-message-id={message.id} + data-is-pinned={message.isPinned || undefined} onMouseDown={handleMouseDown} onContextMenu={handleContextMenu} > diff --git a/src/components/middle/HeaderPinnedMessage.module.scss b/src/components/middle/HeaderPinnedMessage.module.scss new file mode 100644 index 000000000..8d4533b05 --- /dev/null +++ b/src/components/middle/HeaderPinnedMessage.module.scss @@ -0,0 +1,269 @@ +@import "../../styles/mixins"; + +.root { + display: flex; + align-items: center; + margin-left: auto; + cursor: default; + flex-direction: row-reverse; + background: var(--color-background); + + :global { + .Button { + margin-left: 0.25rem; + + &.tiny { + margin-right: 0.625rem; + } + } + } + + :global(body.animation-level-1) & { + :global(.ripple-container) { + display: none; + } + } + + :global(body.animation-level-0) & { + transition: none !important; + } + + @media (min-width: 1276px) { + transform: translate3d(0, 0, 0); + transition: opacity 0.15s ease, transform var(--layer-transition); + + :global(#Main.right-column-open) & { + 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); + } + + .pinnedMessage { + margin-top: 0; + margin-bottom: 0; + flex: 1; + } + + .messageText { + max-width: none; + } + + @media (min-width: 1276px) { + transform: translate3d(0, 0, 0); + transition: opacity 0.15s ease, transform var(--layer-transition); + + :global(#Main.right-column-open) & { + padding-left: calc(var(--right-column-width) + 1rem); + } + } +} + +.loading { + --spinner-size: 1.5rem; +} + +.pinListIcon { + position: absolute; + transition: 0.25s ease-in-out opacity, 0.25s ease-in-out transform; +} + +.pinListIconHidden { + opacity: 0; + transform: scale(0.6); +} + +.pinnedMessage { + display: flex; + flex-shrink: 1; + margin-top: -0.25rem; + margin-bottom: -0.25rem; + padding: 0.25rem; + padding-left: 0.375rem; + border-radius: var(--border-radius-messages-small); + position: relative; + overflow: hidden; + cursor: pointer; + align-items: center; + + &:hover:not(.no-hover) { + background-color: var(--color-interactive-element-hover); + } +} + + +.messageTextTransition { + height: 1.125rem; + width: 100%; + overflow: hidden; +} + +.messageText { + overflow: hidden; + margin-inline-start: 0.375rem; + margin-top: 0.125rem; + max-width: 15rem; + min-width: 15rem; + flex-grow: 1; + + transition: 0.25s ease-in-out transform; + + &.withMedia { + transform: translateX(2.625rem); + margin-right: 2.625rem; + max-width: calc(15rem - 2.625rem); + min-width: calc(15rem - 2.625rem); + } + + :global(.emoji-small) { + width: 1rem; + height: 1rem; + } + + @media (min-width: 1440px) and (max-width: 1500px) { + max-width: 14rem; + } +} + +.title { + font-weight: 500; + font-size: 0.875rem; + line-height: 1rem; + height: 1rem; + color: var(--color-primary); + margin-bottom: 0.125rem; + white-space: pre; + text-align: initial; + + :global(body.is-ios) & { + font-size: 0.9375rem; + } +} + +.summary { + font-size: 0.875rem; + line-height: 1.125rem; + height: 1.125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + + :global(body.is-ios) & { + font-size: 0.9375rem; + } +} + +.inlineButton { + display: block; + width: auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + border-radius: 1.5rem; + padding: 0 0.75rem; + font-weight: 500; + text-transform: none; + height: 2rem; + max-width: 10rem; + flex-shrink: 1; +} + +.pictogramTransition { + position: absolute; + width: 2.25rem !important; + height: 2.25rem; + margin-inline-start: 0.5rem; + margin-top: 0.125rem; + overflow: hidden; +} + +.pinnedThumb { + width: 100%; + height: 100%; + + flex-shrink: 0; + + border-radius: 0.25rem; + overflow: hidden; + + & + .messageText { + max-width: 12rem; + } +} + +.pinnedThumbImage { + width: 100%; + height: 100%; + object-fit: cover; +} + +@media (max-width: 600px) { + .pinnedMessage { + flex-grow: 1; + padding-top: 0; + padding-bottom: 0; + max-width: unset; + margin-top: -0.1875rem; + + &::before { + top: 0.125rem; + bottom: 0.125rem; + } + + .messageText { + max-width: none; + } + } + + .root:global(.full-width) { + display: none; + } + + .root { + @include header-mobile(); + } +} + +@media (min-width: 1276px) and (max-width: 1439px) { + :global(: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.animation-level-0) & { + animation: none; + } +} diff --git a/src/components/middle/HeaderPinnedMessage.tsx b/src/components/middle/HeaderPinnedMessage.tsx index b187c4ef9..736f228b7 100644 --- a/src/components/middle/HeaderPinnedMessage.tsx +++ b/src/components/middle/HeaderPinnedMessage.tsx @@ -17,6 +17,7 @@ import useMedia from '../../hooks/useMedia'; import useThumbnail from '../../hooks/useThumbnail'; import useFlag from '../../hooks/useFlag'; import useLang from '../../hooks/useLang'; +import useAsyncRendering from '../right/hooks/useAsyncRendering'; import RippleEffect from '../ui/RippleEffect'; import ConfirmDialog from '../ui/ConfirmDialog'; @@ -24,7 +25,13 @@ import Button from '../ui/Button'; import PinnedMessageNavigation from './PinnedMessageNavigation'; import MessageSummary from '../common/MessageSummary'; import MediaSpoiler from '../common/MediaSpoiler'; +import AnimatedCounter from '../common/AnimatedCounter'; +import Transition from '../ui/Transition'; +import Spinner from '../ui/Spinner'; +import styles from './HeaderPinnedMessage.module.scss'; + +const SHOW_LOADER_DELAY = 450; type OwnProps = { message: ApiMessage; index: number; @@ -34,17 +41,22 @@ type OwnProps = { onUnpinMessage?: (id: number) => void; onClick?: () => void; onAllPinnedClick?: () => void; + isLoading?: boolean; + isFullWidth?: boolean; }; const HeaderPinnedMessage: FC = ({ message, count, index, customTitle, className, onUnpinMessage, onClick, onAllPinnedClick, + isLoading, isFullWidth, }) => { const { clickBotInlineButton } = getActions(); const lang = useLang(); + const mediaThumbnail = useThumbnail(message); const mediaBlobUrl = useMedia(getMessageMediaHash(message, 'pictogram')); - const isSpoiler = getMessageIsSpoiler(message); + const canRenderLoader = useAsyncRendering([isLoading], SHOW_LOADER_DELAY); + const shouldShowLoader = canRenderLoader && isLoading; const [isUnpinDialogOpen, openUnpinDialog, closeUnpinDialog] = useFlag(); @@ -66,18 +78,44 @@ const HeaderPinnedMessage: FC = ({ const [noHoverColor, markNoHoverColor, unmarkNoHoverColor] = useFlag(); + function renderPictogram(thumbDataUri?: string, blobUrl?: string, spoiler?: boolean) { + const { width, height } = getPictogramDimensions(); + const srcUrl = blobUrl || thumbDataUri; + + return ( +
+ {thumbDataUri && !spoiler + && } + {thumbDataUri + && } +
+ ); + } + return ( -
- {count > 1 && ( +
+ {(count > 1 || shouldShowLoader) && ( )} {onUnpinMessage && ( @@ -86,7 +124,6 @@ const HeaderPinnedMessage: FC = ({ size="smaller" color="translucent" ariaLabel={lang('UnpinMessageAlertTitle')} - className="unpin-button" onClick={openUnpinDialog} > @@ -100,7 +137,7 @@ const HeaderPinnedMessage: FC = ({ confirmHandler={handleUnpinMessage} />
@@ -108,20 +145,32 @@ const HeaderPinnedMessage: FC = ({ count={count} index={index} /> - {mediaThumbnail && renderPictogram(mediaThumbnail, mediaBlobUrl, isSpoiler)} -
-
- {customTitle ? renderText(customTitle) : `${lang('PinnedMessage')} ${index > 0 ? `#${count - index}` : ''}`} + + {renderPictogram( + mediaThumbnail, + mediaBlobUrl, + isSpoiler, + )} + +
+
+ {!customTitle && ( + 0 ? `#${count - index}` : ''}`} /> + )} + + {customTitle && renderText(customTitle)}
-

- -

- + +

+ +

+
+ {inlineButton && (