From 62ed3468b6729d4b81bb9330a2ed1760c61e3338 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Tue, 3 May 2022 14:17:49 +0100 Subject: [PATCH] Chat, Message List: Navigating with mention and reaction badges; Some fixes (#1836) --- src/api/gramjs/apiBuilders/chats.ts | 4 +- src/api/gramjs/apiBuilders/messages.ts | 6 +- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/messages.ts | 96 +++++++++++- src/api/types/chats.ts | 4 + src/api/types/messages.ts | 2 + src/assets/fonts/icomoon.woff | Bin 44064 -> 43256 bytes src/assets/fonts/icomoon.woff2 | Bin 20616 -> 20176 bytes src/components/left/main/Badge.scss | 6 +- src/components/left/main/Badge.tsx | 67 ++++---- .../mediaViewer/helpers/ghostAnimation.ts | 12 +- .../middle/FloatingActionButtons.module.scss | 51 ++++++ .../middle/FloatingActionButtons.tsx | 145 ++++++++++++++++++ src/components/middle/MiddleColumn.tsx | 4 +- src/components/middle/ReactorListModal.tsx | 2 +- .../middle/ScrollDownButton.module.scss | 65 ++++++++ src/components/middle/ScrollDownButton.scss | 104 ------------- src/components/middle/ScrollDownButton.tsx | 132 ++++++---------- .../middle/hooks/useMessageObservers.ts | 13 +- src/components/middle/message/Message.tsx | 17 +- .../middle/message/MessageContextMenu.tsx | 4 +- .../middle/message/ReactionAnimatedEmoji.scss | 2 + src/components/middle/message/Reactions.scss | 2 +- .../right/management/ManageChannel.tsx | 2 +- .../right/management/ManageGroup.tsx | 2 +- src/config.ts | 2 + src/global/actions/api/messages.ts | 73 +++++++++ src/global/actions/api/reactions.ts | 139 +++++++++++++---- src/global/actions/apiUpdaters/chats.ts | 46 +++--- src/global/actions/apiUpdaters/messages.ts | 101 ++++++++---- src/global/actions/ui/messages.ts | 6 +- src/global/helpers/reactions.ts | 17 +- src/global/reducers/chats.ts | 11 +- src/global/reducers/reactions.ts | 8 + src/global/types.ts | 20 ++- src/lib/gramjs/tl/apiTl.js | 4 + src/lib/gramjs/tl/static/api.json | 6 +- src/serviceWorker/pushNotification.ts | 4 + src/styles/Telegram T.json | 52 ++++++- src/styles/icons.scss | 4 +- src/util/isElementInViewport.ts | 12 ++ src/util/notifications.ts | 8 +- src/util/setupServiceWorker.ts | 6 - 43 files changed, 897 insertions(+), 366 deletions(-) create mode 100644 src/components/middle/FloatingActionButtons.module.scss create mode 100644 src/components/middle/FloatingActionButtons.tsx create mode 100644 src/components/middle/ScrollDownButton.module.scss delete mode 100644 src/components/middle/ScrollDownButton.scss create mode 100644 src/util/isElementInViewport.ts diff --git a/src/api/gramjs/apiBuilders/chats.ts b/src/api/gramjs/apiBuilders/chats.ts index 606d7c47e..3e7505ed9 100644 --- a/src/api/gramjs/apiBuilders/chats.ts +++ b/src/api/gramjs/apiBuilders/chats.ts @@ -72,7 +72,8 @@ export function buildApiChatFromDialog( serverTimeOffset: number, ): ApiChat { const { - peer, folderId, unreadMark, unreadCount, unreadMentionsCount, notifySettings: { silent, muteUntil }, + peer, folderId, unreadMark, unreadCount, unreadMentionsCount, unreadReactionsCount, + notifySettings: { silent, muteUntil }, readOutboxMaxId, readInboxMaxId, draft, } = dialog; const isMuted = silent || (typeof muteUntil === 'number' && getServerTime(serverTimeOffset) < muteUntil); @@ -86,6 +87,7 @@ export function buildApiChatFromDialog( lastReadInboxMessageId: readInboxMaxId, unreadCount, unreadMentionsCount, + unreadReactionsCount, isMuted, ...(unreadMark && { hasUnreadMark: true }), ...(draft instanceof GramJs.DraftMessage && { draftDate: draft.date }), diff --git a/src/api/gramjs/apiBuilders/messages.ts b/src/api/gramjs/apiBuilders/messages.ts index ceac8f4a2..6cbc405c0 100644 --- a/src/api/gramjs/apiBuilders/messages.ts +++ b/src/api/gramjs/apiBuilders/messages.ts @@ -221,11 +221,15 @@ function buildReactionCount(reactionCount: GramJs.ReactionCount): ApiReactionCou } export function buildMessagePeerReaction(userReaction: GramJs.MessagePeerReaction): ApiUserReaction { - const { peerId, reaction } = userReaction; + const { + peerId, reaction, big, unread, + } = userReaction; return { userId: getApiChatIdFromMtpPeer(peerId), reaction, + isUnread: unread, + isBig: big, }; } diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 212e22e07..2019c2b4e 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -28,7 +28,7 @@ export { fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, reportMessages, sendMessageAction, fetchSeenBy, fetchSponsoredMessages, viewSponsoredMessage, fetchSendAs, - saveDefaultSendAs, + saveDefaultSendAs, fetchUnreadReactions, readAllReactions, fetchUnreadMentions, readAllMentions, } from './messages'; export { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index f541dc06c..f7fca4e4c 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -23,8 +23,8 @@ import { import { ALL_FOLDER_ID, - DEBUG, - PINNED_MESSAGES_LIMIT, + DEBUG, MENTION_UNREAD_SLICE, + PINNED_MESSAGES_LIMIT, REACTION_UNREAD_SLICE, SUPPORTED_IMAGE_CONTENT_TYPES, SUPPORTED_VIDEO_CONTENT_TYPES, } from '../../../config'; @@ -1319,3 +1319,95 @@ export async function viewSponsoredMessage({ chat, random }: { chat: ApiChat; ra randomId: deserializeBytes(random), })); } + +export function readAllMentions({ + chat, +}: { + chat: ApiChat; +}) { + return invokeRequest(new GramJs.messages.ReadMentions({ + peer: buildInputPeer(chat.id, chat.accessHash), + }), true); +} + +export function readAllReactions({ + chat, +}: { + chat: ApiChat; +}) { + return invokeRequest(new GramJs.messages.ReadReactions({ + peer: buildInputPeer(chat.id, chat.accessHash), + }), true); +} + +export async function fetchUnreadMentions({ + chat, ...pagination +}: { + chat: ApiChat; + offsetId?: number; + addOffset?: number; + maxId?: number; + minId?: number; +}) { + const result = await invokeRequest(new GramJs.messages.GetUnreadMentions({ + peer: buildInputPeer(chat.id, chat.accessHash), + limit: MENTION_UNREAD_SLICE, + ...pagination, + })); + + if ( + !result + || result instanceof GramJs.messages.MessagesNotModified + || !result.messages + ) { + return undefined; + } + + updateLocalDb(result); + + const messages = result.messages.map(buildApiMessage).filter(Boolean as any); + const users = result.users.map(buildApiUser).filter(Boolean as any); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean as any); + + return { + messages, + users, + chats, + }; +} + +export async function fetchUnreadReactions({ + chat, ...pagination +}: { + chat: ApiChat; + offsetId?: number; + addOffset?: number; + maxId?: number; + minId?: number; +}) { + const result = await invokeRequest(new GramJs.messages.GetUnreadReactions({ + peer: buildInputPeer(chat.id, chat.accessHash), + limit: REACTION_UNREAD_SLICE, + ...pagination, + })); + + if ( + !result + || result instanceof GramJs.messages.MessagesNotModified + || !result.messages + ) { + return undefined; + } + + updateLocalDb(result); + + const messages = result.messages.map(buildApiMessage).filter(Boolean as any); + const users = result.users.map(buildApiUser).filter(Boolean as any); + const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter(Boolean as any); + + return { + messages, + users, + chats, + }; +} diff --git a/src/api/types/chats.ts b/src/api/types/chats.ts index 4ae0c6d59..5f280eb57 100644 --- a/src/api/types/chats.ts +++ b/src/api/types/chats.ts @@ -20,6 +20,7 @@ export interface ApiChat { lastReadInboxMessageId?: number; unreadCount?: number; unreadMentionsCount?: number; + unreadReactionsCount?: number; isVerified?: boolean; isMuted?: boolean; isSignaturesShown?: boolean; @@ -65,6 +66,9 @@ export interface ApiChat { joinRequests?: ApiChatInviteImporter[]; sendAsIds?: string[]; + + unreadReactions?: number[]; + unreadMentions?: number[]; } export interface ApiTypingStatus { diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 6f9851221..4f6e451e1 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -336,6 +336,8 @@ export interface ApiReactions { export interface ApiUserReaction { userId: string; reaction: string; + isBig?: boolean; + isUnread?: boolean; } export interface ApiReactionCount { diff --git a/src/assets/fonts/icomoon.woff b/src/assets/fonts/icomoon.woff index 05dc2abc6b574ba7ff5cc36eca190a1a24e9d20f..711761aa724dd4d695bdfa3451bc80ce52144df4 100644 GIT binary patch delta 671 zcmZva&1(}u7{+JzGjYk*Ly}F>?(XbFcbl>)l0ee5C2KaM785WB$w_Jx4eTK?1%vir zi3d^eAb7IkrA4eCh#>hB6c1j+gV=-m7Zf}R1wHsqis->%o_A)Sc?W*ausi$Q;YTid z{oKk5!U)E*L!?aBm-nM85eHo$0$KkBNL^Apy0291b_&&iTzZKSk z{%{k#9zxtrrfax*w>P*1eNPS$!amcZcf+}UdvgP!r>Ec^ndlY%a;4qxg4cwe+e{CY zv*Di`gR4Wp+JF74yEDQlKt8|CN018*lB{Bxl{B>-+nL9cQY>Sbnt?Ug6yj_Xmt_7- zl9-LGo0k;0A>~}RZkkCqBZ##ktvl2=NyTy+)fKlz zc^yra!?F>N>!FaOCOTees|@&v`vM{Yh!MXn))uV#tW_bVPn|j~PH>Z^S$Ex>I3+{6yqj&9ghhK0OHH4)?K$_PT}e&H zvKlAM(pWB~FV846qL+&1Kg8-zA_#Q*>R delta 1492 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 nh=_=Yh=_=Yn3 = ({ chat, isPinned, isMuted }) => { - const isShown = Boolean(chat.unreadCount || chat.hasUnreadMark || isPinned); + const isShown = Boolean( + chat.unreadCount || chat.unreadMentionsCount || chat.hasUnreadMark || isPinned || chat.unreadReactionsCount, + ); const isUnread = Boolean(chat.unreadCount || chat.hasUnreadMark); const className = buildClassName( 'Badge', @@ -26,38 +28,41 @@ const Badge: FC = ({ chat, isPinned, isMuted }) => { ); function renderContent() { - if (chat.unreadCount) { - if (chat.unreadMentionsCount) { - return ( -
-
- -
-
- {formatIntegerCompact(chat.unreadCount)} -
-
- ); - } + const unreadReactionsElement = chat.unreadReactionsCount && ( +
+ +
+ ); - return ( -
- {formatIntegerCompact(chat.unreadCount)} -
- ); - } else if (chat.hasUnreadMark) { - return ( -
- ); - } else if (isPinned) { - return ( -
- -
- ); - } + const unreadMentionsElement = chat.unreadMentionsCount && ( +
+ +
+ ); - return undefined; + const unreadCountElement = (chat.hasUnreadMark || chat.unreadCount) ? ( +
+ {!chat.hasUnreadMark && formatIntegerCompact(chat.unreadCount!)} +
+ ) : undefined; + + const pinnedElement = isPinned && !unreadCountElement && !unreadMentionsElement && !unreadReactionsElement && ( +
+ +
+ ); + + const elements = [unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement].filter(Boolean); + + if (elements.length === 0) return undefined; + + if (elements.length === 1) return elements[0]; + + return ( +
+ {elements} +
+ ); } return ( diff --git a/src/components/mediaViewer/helpers/ghostAnimation.ts b/src/components/mediaViewer/helpers/ghostAnimation.ts index b3eedf819..385692856 100644 --- a/src/components/mediaViewer/helpers/ghostAnimation.ts +++ b/src/components/mediaViewer/helpers/ghostAnimation.ts @@ -13,6 +13,7 @@ import windowSize from '../../../util/windowSize'; import stopEvent from '../../../util/stopEvent'; import { IS_TOUCH_ENV } from '../../../util/environment'; import { getMessageHtmlId } from '../../../global/helpers'; +import { isElementInViewport } from '../../../util/isElementInViewport'; const ANIMATION_DURATION = 200; @@ -264,17 +265,6 @@ function uncover(realWidth: number, realHeight: number, top: number, left: numbe }; } -function isElementInViewport(el: HTMLElement) { - if (el.style.display === 'none') { - return false; - } - - const rect = el.getBoundingClientRect(); - const { height: windowHeight } = windowSize.get(); - - return (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0); -} - function isMessageImageFullyVisible(container: HTMLElement, imageEl: HTMLElement) { const messageListElement = document.querySelector('.Transition__slide--active > .MessageList')!; let imgOffsetTop = container.offsetTop + imageEl.closest('.content-inner, .WebPage')!.offsetTop; diff --git a/src/components/middle/FloatingActionButtons.module.scss b/src/components/middle/FloatingActionButtons.module.scss new file mode 100644 index 000000000..f545f81b5 --- /dev/null +++ b/src/components/middle/FloatingActionButtons.module.scss @@ -0,0 +1,51 @@ +.root { + --base-bottom-pos: 6rem; + + position: absolute; + bottom: var(--base-bottom-pos); + right: max(1rem, env(safe-area-inset-right)); + opacity: 0; + transform: translateY(4.5rem); + transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease; + z-index: var(--z-scroll-down-button); + pointer-events: none; + + :global(body.animation-level-0) & { + transform: none !important; + + transition: opacity 0.15s; + } + + @media (max-width: 600px) { + right: 0.5rem; + bottom: 4.5rem; + + :global(body:not(.keyboard-visible)) & { + bottom: calc(4.5rem + env(safe-area-inset-bottom)); + } + } + + &.revealed { + transform: translateY(0); + opacity: 1; + pointer-events: all; + + &.no-composer.no-extra-shift { + transform: translateY(4rem); + } + } + + &.only-reactions { + transform: translateY(4rem); + + .unread { + opacity: 0; + } + } + + @media (max-width: 600px) { + body.is-symbol-menu-open & { + bottom: calc(var(--base-bottom-pos) + var(--symbol-menu-height) + var(--symbol-menu-footer-height)); + } + } +} diff --git a/src/components/middle/FloatingActionButtons.tsx b/src/components/middle/FloatingActionButtons.tsx new file mode 100644 index 000000000..46c04e727 --- /dev/null +++ b/src/components/middle/FloatingActionButtons.tsx @@ -0,0 +1,145 @@ +import React, { + FC, useCallback, memo, useRef, useEffect, +} from '../../lib/teact/teact'; +import { getActions, withGlobal } from '../../global'; + +import { MessageListType } from '../../global/types'; +import { MAIN_THREAD_ID } from '../../api/types'; + +import { selectChat, selectCurrentMessageList } from '../../global/selectors'; +import buildClassName from '../../util/buildClassName'; +import fastSmoothScroll from '../../util/fastSmoothScroll'; + +import ScrollDownButton from './ScrollDownButton'; + +import styles from './FloatingActionButtons.module.scss'; + +type OwnProps = { + isShown: boolean; + canPost?: boolean; + withExtraShift?: boolean; +}; + +type StateProps = { + chatId?: string; + messageListType?: MessageListType; + unreadCount?: number; + reactionsCount?: number; + mentionsCount?: number; +}; + +const FOCUS_MARGIN = 20; + +const FloatingActionButtons: FC = ({ + isShown, + canPost, + messageListType, + chatId, + unreadCount, + reactionsCount, + mentionsCount, + withExtraShift, +}) => { + const { + focusNextReply, focusNextReaction, focusNextMention, fetchUnreadReactions, + readAllMentions, readAllReactions, fetchUnreadMentions, + } = getActions(); + + // eslint-disable-next-line no-null/no-null + const elementRef = useRef(null); + + const hasUnreadReactions = Boolean(reactionsCount); + const hasUnreadMentions = Boolean(mentionsCount); + + useEffect(() => { + if (hasUnreadReactions && chatId) { + fetchUnreadReactions({ chatId }); + } + }, [chatId, fetchUnreadReactions, hasUnreadReactions]); + + useEffect(() => { + if (hasUnreadMentions && chatId) { + fetchUnreadMentions({ chatId }); + } + }, [chatId, fetchUnreadMentions, hasUnreadMentions]); + + const handleClick = useCallback(() => { + if (!isShown) { + return; + } + + if (messageListType === 'thread') { + focusNextReply(); + } else { + const messagesContainer = elementRef.current!.parentElement!.querySelector('.MessageList')!; + const messageElements = messagesContainer.querySelectorAll('.message-list-item'); + const lastMessageElement = messageElements[messageElements.length - 1]; + if (!lastMessageElement) { + return; + } + + fastSmoothScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN); + } + }, [isShown, messageListType, focusNextReply]); + + const fabClassName = buildClassName( + styles.root, + (isShown || Boolean(reactionsCount) || Boolean(mentionsCount)) && styles.revealed, + (Boolean(reactionsCount) || Boolean(mentionsCount)) && !isShown && styles.onlyReactions, + !canPost && styles.noComposer, + !withExtraShift && styles.noExtraShift, + ); + + return ( +
+ {hasUnreadReactions && ( + + )} + {hasUnreadMentions && ( + + )} + + +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const currentMessageList = selectCurrentMessageList(global); + if (!currentMessageList) { + return {}; + } + + const { chatId, threadId, type: messageListType } = currentMessageList; + const chat = selectChat(global, chatId); + + const shouldShowCount = chat && threadId === MAIN_THREAD_ID && messageListType === 'thread'; + + return { + messageListType, + chatId, + reactionsCount: shouldShowCount ? chat.unreadReactionsCount : undefined, + mentionsCount: shouldShowCount ? chat.unreadMentionsCount : undefined, + unreadCount: shouldShowCount ? chat.unreadCount : undefined, + }; + }, +)(FloatingActionButtons)); diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 7fb66231c..dd59c9351 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -60,7 +60,7 @@ import calculateMiddleFooterTransforms from './helpers/calculateMiddleFooterTran import Transition from '../ui/Transition'; import MiddleHeader from './MiddleHeader'; import MessageList from './MessageList'; -import ScrollDownButton from './ScrollDownButton'; +import FloatingActionButtons from './FloatingActionButtons'; import Composer from './composer/Composer'; import Button from '../ui/Button'; import MobileSearch from './MobileSearch.async'; @@ -505,7 +505,7 @@ const MiddleColumn: FC = ({
- = ({ // eslint-disable-next-line react/jsx-no-bind onClick={() => setChosenTab(undefined)} > - + {reactors?.count && formatIntegerCompact(reactors.count)} {allReactions.map((reaction) => { diff --git a/src/components/middle/ScrollDownButton.module.scss b/src/components/middle/ScrollDownButton.module.scss new file mode 100644 index 000000000..e336260ae --- /dev/null +++ b/src/components/middle/ScrollDownButton.module.scss @@ -0,0 +1,65 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + transition: opacity 0.2s ease; + user-select: none; + + &:not(:first-child) { + margin-top: 0.5rem; + } + + @media (min-width: 1276px) { + transform: translateX(0); + + transition: transform var(--layer-transition), opacity 0.2s ease; + + body.animation-level-0 & { + transition: none !important; + } + + #Main.right-column-open & { + transform: translateX(calc(-1 * var(--right-column-width))); + } + } +} + +.button { + box-shadow: 0 1px 2px var(--color-default-shadow); + color: var(--color-composer-button); + + @media (max-width: 600px) { + width: 2.875rem !important; + height: 2.875rem; + } +} + +.icon { + font-size: 1.75rem !important; +} + +.unread-count { + min-width: 1.5rem; + height: 1.5rem; + padding: 0 0.4375rem; + border-radius: 0.75rem; + font-size: 0.875rem; + line-height: 1.5rem; + font-weight: 500; + text-align: center; + + position: absolute; + top: -0.3125rem; + right: -0.3125rem; + + background: var(--color-green); + color: white; + + pointer-events: none; + + @media (max-width: 600px) { + top: -0.6875rem; + right: auto; + } +} diff --git a/src/components/middle/ScrollDownButton.scss b/src/components/middle/ScrollDownButton.scss deleted file mode 100644 index cace223f8..000000000 --- a/src/components/middle/ScrollDownButton.scss +++ /dev/null @@ -1,104 +0,0 @@ -.ScrollDownButton { - --base-bottom-pos: 6rem; - - position: absolute; - bottom: var(--base-bottom-pos); - right: max(1rem, env(safe-area-inset-right)); - opacity: 0; - transform: translateY(4.5rem); - transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease; - z-index: var(--z-scroll-down-button); - pointer-events: none; - - body.animation-level-0 & { - transform: none !important; - - transition: opacity 0.15s; - } - - @media (max-width: 600px) { - right: 0.5rem; - bottom: 4.5rem; - - body:not(.keyboard-visible) & { - bottom: calc(4.5rem + env(safe-area-inset-bottom)); - } - } - - &-inner { - display: flex; - flex-direction: column; - align-items: center; - - > .Button { - box-shadow: 0 1px 2px var(--color-default-shadow); - color: var(--color-composer-button); - - i { - font-size: 1.75rem; - } - } - - @media (min-width: 1276px) { - transform: translateX(0); - - transition: transform var(--layer-transition); - - body.animation-level-0 & { - transition: none !important; - } - - #Main.right-column-open & { - transform: translateX(calc(-1 * var(--right-column-width))); - } - } - - @media (max-width: 600px) { - > .Button { - width: 2.875rem; - height: 2.875rem; - } - } - } - - &.revealed { - transform: translateY(0); - opacity: 1; - pointer-events: all; - - &.no-composer:not(.with-extra-shift) { - transform: translateY(4rem); - } - } - - .unread-count { - min-width: 1.5rem; - height: 1.5rem; - padding: 0 0.4375rem; - border-radius: 0.75rem; - font-size: 0.875rem; - line-height: 1.5rem; - font-weight: 500; - text-align: center; - - position: absolute; - top: -0.3125rem; - right: -0.3125rem; - - background: var(--color-green); - color: white; - - pointer-events: none; - - @media (max-width: 600px) { - top: -0.6875rem; - right: auto; - } - } - - @media (max-width: 600px) { - body.is-symbol-menu-open & { - bottom: calc(var(--base-bottom-pos) + var(--symbol-menu-height) + var(--symbol-menu-footer-height)); - } - } -} diff --git a/src/components/middle/ScrollDownButton.tsx b/src/components/middle/ScrollDownButton.tsx index 2bbb269bf..c4c408edc 100644 --- a/src/components/middle/ScrollDownButton.tsx +++ b/src/components/middle/ScrollDownButton.tsx @@ -1,105 +1,71 @@ -import React, { - FC, useCallback, memo, useRef, -} from '../../lib/teact/teact'; -import { getActions, withGlobal } from '../../global'; +import React, { FC, memo, useRef } from '../../lib/teact/teact'; -import { MessageListType } from '../../global/types'; -import { MAIN_THREAD_ID } from '../../api/types'; - -import { selectChat, selectCurrentMessageList } from '../../global/selectors'; import { formatIntegerCompact } from '../../util/textFormat'; -import buildClassName from '../../util/buildClassName'; -import fastSmoothScroll from '../../util/fastSmoothScroll'; import useLang from '../../hooks/useLang'; +import useContextMenuHandlers from '../../hooks/useContextMenuHandlers'; +import buildClassName from '../../util/buildClassName'; +import Menu from '../ui/Menu'; import Button from '../ui/Button'; +import MenuItem from '../ui/MenuItem'; -import './ScrollDownButton.scss'; +import styles from './ScrollDownButton.module.scss'; type OwnProps = { - isShown: boolean; - canPost?: boolean; - withExtraShift?: boolean; -}; - -type StateProps = { - messageListType?: MessageListType; + icon: string; + ariaLabelLang: string; unreadCount?: number; + onClick: VoidFunction; + onReadAll?: VoidFunction; + className?: string; }; -const FOCUS_MARGIN = 20; - -const ScrollDownButton: FC = ({ - isShown, - canPost, - messageListType, +const ScrollDownButton: FC = ({ + icon, + ariaLabelLang, unreadCount, - withExtraShift, + onClick, + onReadAll, + className, }) => { - const { focusNextReply } = getActions(); - const lang = useLang(); + // eslint-disable-next-line no-null/no-null - const elementRef = useRef(null); - - const handleClick = useCallback(() => { - if (!isShown) { - return; - } - - if (messageListType === 'thread') { - focusNextReply(); - } else { - const messagesContainer = elementRef.current!.parentElement!.querySelector('.MessageList')!; - const messageElements = messagesContainer.querySelectorAll('.message-list-item'); - const lastMessageElement = messageElements[messageElements.length - 1]; - if (!lastMessageElement) { - return; - } - - fastSmoothScroll(messagesContainer, lastMessageElement, 'end', FOCUS_MARGIN); - } - }, [isShown, messageListType, focusNextReply]); - - const fabClassName = buildClassName( - 'ScrollDownButton', - isShown && 'revealed', - !canPost && 'no-composer', - withExtraShift && 'with-extra-shift', - ); + const ref = useRef(null); + const { + isContextMenuOpen, + handleContextMenu, + handleContextMenuClose, + handleContextMenuHide, + } = useContextMenuHandlers(ref, !onReadAll); return ( -
-
- + {Boolean(unreadCount) &&
{formatIntegerCompact(unreadCount)}
} + {onReadAll && ( + - - - {Boolean(unreadCount) && ( -
{formatIntegerCompact(unreadCount!)}
- )} -
+ {lang('MarkAllAsRead')} + + )}
); }; -export default memo(withGlobal( - (global): StateProps => { - const currentMessageList = selectCurrentMessageList(global); - if (!currentMessageList) { - return {}; - } - - const { chatId, threadId, type: messageListType } = currentMessageList; - const chat = selectChat(global, chatId); - - return { - messageListType, - unreadCount: chat && threadId === MAIN_THREAD_ID && messageListType === 'thread' ? chat.unreadCount : undefined, - }; - }, -)(ScrollDownButton)); +export default memo(ScrollDownButton); diff --git a/src/components/middle/hooks/useMessageObservers.ts b/src/components/middle/hooks/useMessageObservers.ts index 7595013c8..fec23144e 100644 --- a/src/components/middle/hooks/useMessageObservers.ts +++ b/src/components/middle/hooks/useMessageObservers.ts @@ -16,7 +16,7 @@ export default function useMessageObservers( containerRef: RefObject, memoFirstUnreadIdRef: { current: number | undefined }, ) { - const { markMessageListRead, markMessagesRead } = getActions(); + const { markMessageListRead, markMentionsRead, animateUnreadReaction } = getActions(); const { observe: observeIntersectionForMedia, @@ -38,6 +38,7 @@ export default function useMessageObservers( let maxId = 0; const mentionIds: number[] = []; + const reactionIds: number[] = []; entries.forEach((entry) => { const { isIntersecting, target } = entry; @@ -56,6 +57,10 @@ export default function useMessageObservers( if (dataset.hasUnreadMention) { mentionIds.push(messageId); } + + if (dataset.hasUnreadReaction) { + reactionIds.push(messageId); + } }); if (memoFirstUnreadIdRef.current && maxId >= memoFirstUnreadIdRef.current) { @@ -63,7 +68,11 @@ export default function useMessageObservers( } if (mentionIds.length) { - markMessagesRead({ messageIds: mentionIds }); + markMentionsRead({ messageIds: mentionIds }); + } + + if (reactionIds.length) { + animateUnreadReaction({ messageIds: reactionIds }); } }); diff --git a/src/components/middle/message/Message.tsx b/src/components/middle/message/Message.tsx index a054da50a..ec36282c1 100644 --- a/src/components/middle/message/Message.tsx +++ b/src/components/middle/message/Message.tsx @@ -90,6 +90,7 @@ import useFocusMessage from './hooks/useFocusMessage'; import useOuterHandlers from './hooks/useOuterHandlers'; import useInnerHandlers from './hooks/useInnerHandlers'; import { getServerTime } from '../../../util/serverTime'; +import { isElementInViewport } from '../../../util/isElementInViewport'; import Button from '../../ui/Button'; import Avatar from '../../common/Avatar'; @@ -195,6 +196,7 @@ type StateProps = { defaultReaction?: string; activeReaction?: ActiveReaction; activeEmojiInteractions?: ActiveEmojiInteraction[]; + hasUnreadReaction?: boolean; }; type MetaPosition = @@ -281,11 +283,13 @@ const Message: FC = ({ shouldLoopStickers, autoLoadFileMaxSizeMb, threadInfo, + hasUnreadReaction, }) => { const { toggleMessageSelection, clickBotInlineButton, disableContextMenuHint, + animateUnreadReaction, } = getActions(); // eslint-disable-next-line no-null/no-null @@ -521,6 +525,13 @@ const Message: FC = ({ ); useFocusMessage(ref, chatId, isFocused, focusDirection, noFocusHighlight, isResizingContainer); + useEffect(() => { + const bottomMarker = bottomMarkerRef.current; + if (hasUnreadReaction && bottomMarker && isElementInViewport(bottomMarker)) { + animateUnreadReaction({ messageIds: [messageId] }); + } + }, [hasUnreadReaction, messageId, animateUnreadReaction]); + let style = ''; let calculatedWidth; let noMediaCorners = false; @@ -896,7 +907,8 @@ const Message: FC = ({ className="bottom-marker" data-message-id={messageId} data-last-message-id={album ? album.messages[album.messages.length - 1].id : undefined} - data-has-unread-mention={message.hasUnreadMention} + data-has-unread-mention={message.hasUnreadMention || undefined} + data-has-unread-reaction={hasUnreadReaction || undefined} /> {!isInDocumentGroup && (
@@ -1065,6 +1077,8 @@ export default memo(withGlobal( const localSticker = singleEmoji ? selectLocalAnimatedEmoji(global, singleEmoji) : undefined; + const hasUnreadReaction = chat?.unreadReactions?.includes(message.id); + return { theme: selectTheme(global), chatUsername, @@ -1117,6 +1131,7 @@ export default memo(withGlobal( ...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }), ...(typeof uploadProgress === 'number' && { uploadProgress }), ...(isFocused && { focusDirection, noFocusHighlight, isResizingContainer }), + hasUnreadReaction, }; }, )(Message)); diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index b1190eb60..cf14bd314 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -226,7 +226,7 @@ const MessageContextMenu: FC = ({ style={menuStyle} ref={scrollableRef} > - {canRemoveReaction && Remove Reaction} + {canRemoveReaction && Remove Reaction} {canSendNow && {lang('MessageScheduleSend')}} {canReschedule && ( {lang('MessageScheduleEditTime')} @@ -256,7 +256,7 @@ const MessageContextMenu: FC = ({ {(canShowSeenBy || canShowReactionsCount) && ( diff --git a/src/components/middle/message/ReactionAnimatedEmoji.scss b/src/components/middle/message/ReactionAnimatedEmoji.scss index 54ed223bf..7c5511642 100644 --- a/src/components/middle/message/ReactionAnimatedEmoji.scss +++ b/src/components/middle/message/ReactionAnimatedEmoji.scss @@ -44,6 +44,8 @@ // Fix for weird positioning in Chrome canvas { position: absolute; + left: 0; + top: 0; } } } diff --git a/src/components/middle/message/Reactions.scss b/src/components/middle/message/Reactions.scss index bbf5eba38..0dce5d61d 100644 --- a/src/components/middle/message/Reactions.scss +++ b/src/components/middle/message/Reactions.scss @@ -24,7 +24,7 @@ color: var(--accent-color); overflow: visible; - .ReactionAnimatedEmoji, .icon-reaction-filled { + .ReactionAnimatedEmoji, .icon-heart { width: 1.125rem; height: 1.125rem; margin-right: 0.25rem; diff --git a/src/components/right/management/ManageChannel.tsx b/src/components/right/management/ManageChannel.tsx index 8321cdac4..62f630140 100644 --- a/src/components/right/management/ManageChannel.tsx +++ b/src/components/right/management/ManageChannel.tsx @@ -261,7 +261,7 @@ const ManageChannel: FC = ({ )} = ({ { void callApi('viewSponsoredMessage', { chat, random: message.randomId }); }); +addActionHandler('fetchUnreadMentions', async (global, actions, payload) => { + const { chatId, offsetId } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('fetchUnreadMentions', { chat, offsetId }); + + if (!result) return; + + const { messages, chats, users } = result; + + const byId = buildCollectionByKey(messages, 'id'); + const ids = Object.keys(byId).map(Number); + + global = getGlobal(); + global = addChatMessagesById(global, chat.id, byId); + global = addUsers(global, buildCollectionByKey(users, 'id')); + global = addChats(global, buildCollectionByKey(chats, 'id')); + global = updateChat(global, chatId, { + unreadMentions: [...(chat.unreadMentions || []), ...ids], + }); + + setGlobal(global); +}); + +addActionHandler('markMentionsRead', (global, actions, payload) => { + const { messageIds } = payload; + + const chat = selectCurrentChat(global); + if (!chat) return; + + if (!chat.unreadMentionsCount) { + return; + } + + const unreadMentionsCount = chat.unreadMentionsCount - messageIds.length; + const unreadMentions = (chat.unreadMentions || []).filter((id) => !messageIds.includes(id)); + global = updateChat(global, chat.id, { + unreadMentions, + }); + + setGlobal(global); + + if (!unreadMentions.length && unreadMentionsCount) { + actions.fetchUnreadMentions({ + chatId: chat.id, + offsetId: Math.max(...messageIds), + }); + } + + actions.markMessagesRead({ messageIds }); +}); + +addActionHandler('focusNextMention', (global, actions) => { + const chat = selectCurrentChat(global); + + if (!chat?.unreadMentions) return; + + actions.focusMessage({ chatId: chat.id, messageId: chat.unreadMentions[0] }); +}); + +addActionHandler('readAllMentions', (global) => { + const chat = selectCurrentChat(global); + if (!chat) return undefined; + + callApi('readAllMentions', { chat }); + + return updateChat(global, chat.id, { + unreadMentionsCount: undefined, + unreadMentions: undefined, + }); +}); + function countSortedIds(ids: number[], from: number, to: number) { let count = 0; diff --git a/src/global/actions/api/reactions.ts b/src/global/actions/api/reactions.ts index 52411a504..f88009146 100644 --- a/src/global/actions/api/reactions.ts +++ b/src/global/actions/api/reactions.ts @@ -4,13 +4,15 @@ import * as mediaLoader from '../../../util/mediaLoader'; import { ApiAppConfig, ApiMediaFormat } from '../../../api/types'; import { selectChat, - selectChatMessage, + selectChatMessage, selectCurrentChat, selectDefaultReaction, selectLocalAnimatedEmojiEffectByName, selectMessageIdsByGroupId, } from '../../selectors'; -import { addMessageReaction, subtractXForEmojiInteraction } from '../../reducers/reactions'; -import { addUsers, updateChatMessage } from '../../reducers'; +import { addMessageReaction, subtractXForEmojiInteraction, updateUnreadReactions } from '../../reducers/reactions'; +import { + addChatMessagesById, addChats, addUsers, updateChatMessage, +} from '../../reducers'; import { buildCollectionByKey, omit } from '../../../util/iteratees'; import { ANIMATION_LEVEL_MAX } from '../../../config'; import { isMessageLocal } from '../../helpers'; @@ -156,30 +158,6 @@ addActionHandler('openChat', (global) => { }; }); -addActionHandler('startActiveReaction', (global, actions, payload) => { - const { messageId, reaction } = payload; - const { animationLevel } = global.settings.byKey; - - if (animationLevel !== ANIMATION_LEVEL_MAX) return global; - - if (global.activeReactions[messageId]?.reaction === reaction) { - return global; - } - - return { - ...global, - activeReactions: { - ...(reaction ? global.activeReactions : omit(global.activeReactions, [messageId])), - ...(reaction && { - [messageId]: { - reaction, - messageId, - }, - }), - }, - }; -}); - addActionHandler('stopActiveReaction', (global, actions, payload) => { const { messageId, reaction } = payload; @@ -300,3 +278,110 @@ addActionHandler('sendWatchingEmojiInteraction', (global, actions, payload) => { }), }; }); + +addActionHandler('fetchUnreadReactions', async (global, actions, payload) => { + const { chatId, offsetId } = payload; + const chat = selectChat(global, chatId); + if (!chat) return; + + const result = await callApi('fetchUnreadReactions', { chat, offsetId, addOffset: offsetId ? -1 : undefined }); + + // Server side bug, when server returns unread reactions count > 0 for deleted messages + if (!result || !result.messages.length) { + global = getGlobal(); + global = updateUnreadReactions(global, chatId, { + unreadReactionsCount: 0, + }); + + setGlobal(global); + return; + } + + const { messages, chats, users } = result; + + const byId = buildCollectionByKey(messages, 'id'); + const ids = Object.keys(byId).map(Number); + + global = getGlobal(); + global = addChatMessagesById(global, chat.id, byId); + global = addUsers(global, buildCollectionByKey(users, 'id')); + global = addChats(global, buildCollectionByKey(chats, 'id')); + global = updateUnreadReactions(global, chatId, { + unreadReactions: [...(chat.unreadReactions || []), ...ids], + }); + + setGlobal(global); +}); + +addActionHandler('animateUnreadReaction', (global, actions, payload) => { + const { messageIds } = payload; + + const { animationLevel } = global.settings.byKey; + + const chat = selectCurrentChat(global); + if (!chat) return undefined; + + if (chat.unreadReactionsCount) { + const unreadReactionsCount = chat.unreadReactionsCount - messageIds.length; + const unreadReactions = (chat.unreadReactions || []).filter((id) => !messageIds.includes(id)); + + global = updateUnreadReactions(global, chat.id, { + unreadReactions, + }); + + setGlobal(global); + + if (!unreadReactions.length && unreadReactionsCount) { + actions.fetchUnreadReactions({ chatId: chat.id, offsetId: Math.min(...messageIds) }); + } + } + + actions.markMessagesRead({ messageIds }); + + if (animationLevel !== ANIMATION_LEVEL_MAX) return undefined; + + global = getGlobal(); + + return { + ...global, + activeReactions: { + ...global.activeReactions, + ...Object.fromEntries(messageIds.map((messageId) => { + const message = selectChatMessage(global, chat.id, messageId); + + if (!message) return undefined; + + const unread = message.reactions?.recentReactions?.find((l) => l.isUnread); + + if (!unread) return undefined; + + const reaction = unread?.reaction; + + return [messageId, { + messageId, + reaction, + }]; + }).filter(Boolean)), + }, + }; +}); + +addActionHandler('focusNextReaction', (global, actions) => { + const chat = selectCurrentChat(global); + + if (!chat?.unreadReactions) return; + + actions.focusMessage({ chatId: chat.id, messageId: chat.unreadReactions[0] }); +}); + +addActionHandler('readAllReactions', (global) => { + const chat = selectCurrentChat(global); + if (!chat) return undefined; + + callApi('readAllReactions', { chat }); + + return updateUnreadReactions(global, chat.id, { + unreadReactionsCount: undefined, + unreadReactions: undefined, + }); +}); diff --git a/src/global/actions/apiUpdaters/chats.ts b/src/global/actions/apiUpdaters/chats.ts index 1c8e924c6..ca18cacae 100644 --- a/src/global/actions/apiUpdaters/chats.ts +++ b/src/global/actions/apiUpdaters/chats.ts @@ -5,7 +5,6 @@ import { MAIN_THREAD_ID } from '../../../api/types'; import { ARCHIVED_FOLDER_ID, MAX_ACTIVE_PINNED_CHATS } from '../../../config'; import { pick } from '../../../util/iteratees'; import { closeMessageNotifications, notifyAboutMessage } from '../../../util/notifications'; -import { getMessageRecentReaction } from '../../helpers'; import { updateChat, updateChatListIds, @@ -20,6 +19,7 @@ import { selectChatListType, selectCurrentMessageList, } from '../../selectors'; +import { updateUnreadReactions } from '../../reducers/reactions'; const TYPING_STATUS_CLEAR_DELAY = 6000; // 6 seconds // Enough to animate and mark as read in Message List @@ -109,15 +109,16 @@ addActionHandler('apiUpdate', (global, actions, update) => { setTimeout(() => { actions.requestChatUpdate({ chatId: update.chatId }); }, CURRENT_CHAT_UNREAD_DELAY); - } else { - setGlobal(updateChat(global, update.chatId, { - unreadCount: chat.unreadCount ? chat.unreadCount + 1 : 1, - ...(update.message.hasUnreadMention && { - unreadMentionsCount: chat.unreadMentionsCount ? chat.unreadMentionsCount + 1 : 1, - }), - })); } + setGlobal(updateChat(global, update.chatId, { + unreadCount: chat.unreadCount ? chat.unreadCount + 1 : 1, + ...(update.message.id && update.message.hasUnreadMention && { + unreadMentionsCount: (chat.unreadMentionsCount || 0) + 1, + unreadMentions: [...(chat.unreadMentions || []), update.message.id], + }), + })); + notifyAboutMessage({ chat, message, @@ -126,24 +127,6 @@ addActionHandler('apiUpdate', (global, actions, update) => { return undefined; } - case 'updateMessage': { - const { message } = update; - const chat = selectChat(global, update.chatId); - if (!chat) { - return undefined; - } - - if (getMessageRecentReaction(message)) { - notifyAboutMessage({ - chat, - message, - isReaction: true, - }); - } - - return undefined; - } - case 'updateCommonBoxMessages': case 'updateChannelMessages': { const { ids, messageUpdate } = update; @@ -154,9 +137,18 @@ addActionHandler('apiUpdate', (global, actions, update) => { ids.forEach((id) => { const chatId = ('channelId' in update ? update.channelId : selectCommonBoxChatId(global, id))!; const chat = selectChat(global, chatId); + + if (chat?.unreadReactionsCount) { + global = updateUnreadReactions(global, chatId, { + unreadReactionsCount: (chat.unreadReactionsCount - 1) || undefined, + unreadReactions: chat.unreadReactions?.filter((i) => i !== id), + }); + } + if (chat?.unreadMentionsCount) { global = updateChat(global, chatId, { - unreadMentionsCount: chat.unreadMentionsCount - 1, + unreadMentionsCount: (chat.unreadMentionsCount - 1) || undefined, + unreadMentions: chat.unreadMentions?.filter((i) => i !== id), }); } }); diff --git a/src/global/actions/apiUpdaters/messages.ts b/src/global/actions/apiUpdaters/messages.ts index 83ea8c0b9..3f0ae782c 100644 --- a/src/global/actions/apiUpdaters/messages.ts +++ b/src/global/actions/apiUpdaters/messages.ts @@ -1,7 +1,8 @@ import { addActionHandler, getGlobal, setGlobal } from '../../index'; import { - ApiMessage, ApiPollResult, ApiThreadInfo, MAIN_THREAD_ID, + ApiChat, + ApiMessage, ApiPollResult, ApiReactions, ApiThreadInfo, MAIN_THREAD_ID, } from '../../../api/types'; import { unique } from '../../../util/iteratees'; @@ -45,8 +46,10 @@ import { selectLocalAnimatedEmoji, } from '../../selectors'; import { - getMessageContent, isUserId, isMessageLocal, getMessageText, checkIfReactionAdded, + getMessageContent, isUserId, isMessageLocal, getMessageText, checkIfHasUnreadReactions, } from '../../helpers'; +import { onTickEnd } from '../../../util/schedulers'; +import { updateUnreadReactions } from '../../reducers/reactions'; const ANIMATION_DELAY = 350; @@ -158,9 +161,8 @@ addActionHandler('apiUpdate', (global, actions, update) => { const { chatId, id, message } = update; const currentMessage = selectChatMessage(global, chatId, id); - if (!currentMessage) { - return; - } + + const chat = selectChat(global, chatId); global = updateWithLocalMedia(global, chatId, id, message); @@ -173,15 +175,21 @@ addActionHandler('apiUpdate', (global, actions, update) => { message.threadInfo, ); } - global = updateChatLastMessage(global, chatId, newMessage); + if (currentMessage) { + global = updateChatLastMessage(global, chatId, newMessage); + } + + if (message.reactions && chat) { + global = updateReactions(global, chatId, id, message.reactions, chat, message.isOutgoing, currentMessage); + } setGlobal(global); // Scroll down if bot message height is changed with an updated inline keyboard. // A drawback: this will scroll down even if the previous scroll was not at bottom. - const chat = selectChat(global, chatId); if ( - chat + currentMessage + && chat && !message.isOutgoing && chat.lastMessage?.id === message.id && selectIsChatWithBot(global, chat) @@ -482,36 +490,67 @@ addActionHandler('apiUpdate', (global, actions, update) => { const { chatId, id, reactions } = update; const message = selectChatMessage(global, chatId, id); const chat = selectChat(global, update.chatId); - const currentReactions = message?.reactions; - // `updateMessageReactions` happens with an interval, so we try to avoid redundant global state updates - if (currentReactions && areDeepEqual(reactions, currentReactions)) { - return; - } - - // Only notify about added reactions, not removed ones - const shouldNotify = checkIfReactionAdded(currentReactions, reactions, global.currentUserId); - - global = updateChatMessage(global, chatId, id, { reactions: update.reactions }); - - setGlobal(global); - - if (shouldNotify) { - const newMessage = selectChatMessage(global, chatId, id); - if (!chat || !newMessage) return; - - void notifyAboutMessage({ - chat, - message: newMessage, - isReaction: true, - }); - } + if (!chat || !message) return; + setGlobal(updateReactions(global, chatId, id, reactions, chat, message.isOutgoing, message)); break; } } }); +function updateReactions( + global: GlobalState, + chatId: string, + id: number, + reactions: ApiReactions, + chat: ApiChat, + isOutgoing?: boolean, + message?: ApiMessage, +) { + const currentReactions = message?.reactions; + + // `updateMessageReactions` happens with an interval, so we try to avoid redundant global state updates + if (currentReactions && areDeepEqual(reactions, currentReactions)) { + return global; + } + + global = updateChatMessage(global, chatId, id, { reactions }); + + if (!isOutgoing) { + return global; + } + + const alreadyHasUnreadReaction = chat.unreadReactions?.includes(id); + + // Only notify about added reactions, not removed ones + if (checkIfHasUnreadReactions(global, reactions) && !alreadyHasUnreadReaction) { + global = updateUnreadReactions(global, chatId, { + unreadReactionsCount: (chat?.unreadReactionsCount || 0) + 1, + unreadReactions: [...(chat?.unreadReactions || []), id], + }); + + const newMessage = selectChatMessage(global, chatId, id); + + if (!chat || !newMessage) return global; + + onTickEnd(() => { + notifyAboutMessage({ + chat, + message: newMessage, + isReaction: true, + }); + }); + } else if (alreadyHasUnreadReaction) { + global = updateUnreadReactions(global, chatId, { + unreadReactionsCount: (chat?.unreadReactionsCount || 1) - 1, + unreadReactions: chat?.unreadReactions?.filter((i) => i !== id), + }); + } + + return global; +} + function updateWithLocalMedia( global: GlobalState, chatId: string, id: number, message: Partial, isScheduled = false, ) { diff --git a/src/global/actions/ui/messages.ts b/src/global/actions/ui/messages.ts index 4a762ff42..c6535261c 100644 --- a/src/global/actions/ui/messages.ts +++ b/src/global/actions/ui/messages.ts @@ -342,7 +342,7 @@ addActionHandler('focusNextReply', (global, actions) => { addActionHandler('focusMessage', (global, actions, payload) => { const { chatId, threadId = MAIN_THREAD_ID, messageListType = 'thread', noHighlight, groupedId, groupedChatId, - replyMessageId, isResizingContainer, + replyMessageId, isResizingContainer, shouldReplaceHistory, } = payload!; let { messageId } = payload!; @@ -387,7 +387,7 @@ addActionHandler('focusMessage', (global, actions, payload) => { const viewportIds = selectViewportIds(global, chatId, threadId); if (viewportIds && viewportIds.includes(messageId)) { setGlobal(global); - actions.openChat({ id: chatId, threadId }); + actions.openChat({ id: chatId, threadId, shouldReplaceHistory }); return undefined; } @@ -404,7 +404,7 @@ addActionHandler('focusMessage', (global, actions, payload) => { setGlobal(global); - actions.openChat({ id: chatId, threadId }); + actions.openChat({ id: chatId, threadId, shouldReplaceHistory }); actions.loadViewportMessages(); return undefined; }); diff --git a/src/global/helpers/reactions.ts b/src/global/helpers/reactions.ts index a11951fd5..5c1f9e8ff 100644 --- a/src/global/helpers/reactions.ts +++ b/src/global/helpers/reactions.ts @@ -1,17 +1,12 @@ import { ApiMessage, ApiReactions } from '../../api/types'; +import { GlobalState } from '../types'; export function getMessageRecentReaction(message: Partial) { return message.isOutgoing ? message.reactions?.recentReactions?.[0] : undefined; } - -export function checkIfReactionAdded(oldReactions?: ApiReactions, newReactions?: ApiReactions, currentUserId?: string) { - if (!oldReactions || !oldReactions.recentReactions) return true; - if (!newReactions || !newReactions.recentReactions) return false; - // Skip reactions from yourself - if (newReactions.recentReactions.every((reaction) => reaction.userId === currentUserId)) return false; - const oldReactionsMap = oldReactions.results.reduce>((acc, reaction) => { - acc[reaction.reaction] = reaction.count; - return acc; - }, {}); - return newReactions.results.some((r) => !oldReactionsMap[r.reaction] || oldReactionsMap[r.reaction] < r.count); +export function checkIfHasUnreadReactions(global: GlobalState, reactions: ApiReactions) { + const { currentUserId } = global; + return reactions?.recentReactions?.some( + ({ isUnread, userId }) => isUnread && userId !== currentUserId, + ); } diff --git a/src/global/reducers/chats.ts b/src/global/reducers/chats.ts index 2b1f3c6c8..4ce45c9d8 100644 --- a/src/global/reducers/chats.ts +++ b/src/global/reducers/chats.ts @@ -50,10 +50,11 @@ export function replaceChats(global: GlobalState, newById: Record, photo?: ApiPhoto, + noOmitUnreadReactionCount = false, ): GlobalState { const { byId } = global.chats; - const updatedChat = getUpdatedChat(global, chatId, chatUpdate, photo); + const updatedChat = getUpdatedChat(global, chatId, chatUpdate, photo, noOmitUnreadReactionCount); if (!updatedChat) { return global; } @@ -115,13 +116,19 @@ export function addChats(global: GlobalState, newById: Record): // @optimization Don't spread/unspread global for each element, do it in a batch function getUpdatedChat( global: GlobalState, chatId: string, chatUpdate: Partial, photo?: ApiPhoto, + noOmitUnreadReactionCount = false, ) { const { byId } = global.chats; const chat = byId[chatId]; const shouldOmitMinInfo = chatUpdate.isMin && chat && !chat.isMin; + + chatUpdate = noOmitUnreadReactionCount + ? chatUpdate : omit(chatUpdate, ['unreadReactionsCount']); const updatedChat: ApiChat = { ...chat, - ...(shouldOmitMinInfo ? omit(chatUpdate, ['isMin', 'accessHash']) : chatUpdate), + ...(shouldOmitMinInfo + ? omit(chatUpdate, ['isMin', 'accessHash']) + : chatUpdate), ...(photo && { photos: [photo, ...(chat.photos || [])] }), }; diff --git a/src/global/reducers/reactions.ts b/src/global/reducers/reactions.ts index cc1e1adbe..e104c776d 100644 --- a/src/global/reducers/reactions.ts +++ b/src/global/reducers/reactions.ts @@ -8,6 +8,8 @@ import { } from '../../components/middle/helpers/calculateMiddleFooterTransforms'; import { IS_SINGLE_COLUMN_LAYOUT } from '../../util/environment'; import windowSize from '../../util/windowSize'; +import { updateChat } from './chats'; +import { ApiChat } from '../../api/types'; function getLeftColumnWidth(windowWidth: number) { if (windowWidth > MIN_SCREEN_WIDTH_FOR_STATIC_RIGHT_COLUMN) { @@ -80,3 +82,9 @@ export function addMessageReaction(global: GlobalState, chatId: string, messageI }, }); } + +export function updateUnreadReactions( + global: GlobalState, chatId: string, update: Pick, +) { + return updateChat(global, chatId, update, undefined, true); +} diff --git a/src/global/types.ts b/src/global/types.ts index aec87352b..acf73060d 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -632,6 +632,24 @@ export interface ActionPayloads { threadId: number; type: MessageListType; }; + fetchUnreadMentions: { + chatId: string; + offsetId?: number; + }; + fetchUnreadReactions: { + chatId: string; + offsetId?: number; + }; + animateUnreadReaction: { + messageIds: number[]; + }; + focusNextReaction: {}; + focusNextMention: {}; + readAllReactions: {}; + readAllMentions: {}; + markMentionsRead: { + messageIds: number[]; + }; // Media Viewer & Audio Player openMediaViewer: { @@ -845,7 +863,7 @@ export type NonTypedActionNames = ( 'loadSponsoredMessages' | 'viewSponsoredMessage' | 'loadSendAs' | 'saveDefaultSendAs' | 'loadAvailableReactions' | 'stopActiveEmojiInteraction' | 'interactWithAnimatedEmoji' | 'loadReactors' | 'setDefaultReaction' | 'sendDefaultReaction' | 'sendEmojiInteraction' | 'sendWatchingEmojiInteraction' | 'loadMessageReactions' | - 'stopActiveReaction' | 'startActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' | + 'stopActiveReaction' | 'copySelectedMessages' | 'copyMessagesByIds' | 'setEditingId' | // scheduled messages 'loadScheduledHistory' | 'sendScheduledMessages' | 'rescheduleMessage' | 'deleteScheduledMessages' | diff --git a/src/lib/gramjs/tl/apiTl.js b/src/lib/gramjs/tl/apiTl.js index 78507975d..9f84a84ad 100644 --- a/src/lib/gramjs/tl/apiTl.js +++ b/src/lib/gramjs/tl/apiTl.js @@ -1102,6 +1102,8 @@ messages.getPinnedDialogs#d6b94df2 folder_id:int = messages.PeerDialogs; messages.uploadMedia#519bc2b1 peer:InputPeer media:InputMedia = MessageMedia; messages.getFavedStickers#4f1aaa9 hash:long = messages.FavedStickers; messages.faveSticker#b9ffc55b id:InputDocument unfave:Bool = Bool; +messages.getUnreadMentions#46578472 peer:InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readMentions#f0189d3 peer:InputPeer = messages.AffectedHistory; messages.sendMultiMedia#f803138f flags:# silent:flags.5?true background:flags.6?true clear_draft:flags.7?true noforwards:flags.14?true peer:InputPeer reply_to_msg_id:flags.0?int multi_media:Vector schedule_date:flags.10?int send_as:flags.13?InputPeer = Updates; messages.searchStickerSets#35705b8a flags:# exclude_featured:flags.0?true q:string hash:long = messages.FoundStickerSets; messages.markDialogUnread#c286d98f flags:# unread:flags.0?true peer:InputDialogPeer = Bool; @@ -1140,6 +1142,8 @@ messages.getMessageReactionsList#e0ee6b77 flags:# peer:InputPeer id:int reaction messages.setChatAvailableReactions#14050ea6 peer:InputPeer available_reactions:Vector = Updates; messages.getAvailableReactions#18dea0ac hash:int = messages.AvailableReactions; messages.setDefaultReaction#d960c4d4 reaction:string = Bool; +messages.getUnreadReactions#e85bae1a peer:InputPeer offset_id:int add_offset:int limit:int max_id:int min_id:int = messages.Messages; +messages.readReactions#82e251d7 peer:InputPeer = messages.AffectedHistory; messages.getAttachMenuBots#16fcc2cb hash:long = AttachMenuBots; messages.getAttachMenuBot#77216192 bot:InputUser = AttachMenuBotsBot; messages.toggleBotInAttachMenu#1aee33af bot:InputUser enabled:Bool = Bool; diff --git a/src/lib/gramjs/tl/static/api.json b/src/lib/gramjs/tl/static/api.json index 8e6fcb263..cbe432e1d 100644 --- a/src/lib/gramjs/tl/static/api.json +++ b/src/lib/gramjs/tl/static/api.json @@ -233,5 +233,9 @@ "messages.prolongWebView", "messages.requestSimpleWebView", "messages.sendWebViewResultMessage", - "messages.sendWebViewData" + "messages.sendWebViewData", + "messages.readReactions", + "messages.getUnreadReactions", + "messages.readMentions", + "messages.getUnreadMentions" ] diff --git a/src/serviceWorker/pushNotification.ts b/src/serviceWorker/pushNotification.ts index 686d01f87..cac6573b8 100644 --- a/src/serviceWorker/pushNotification.ts +++ b/src/serviceWorker/pushNotification.ts @@ -30,12 +30,14 @@ type NotificationData = { body: string; icon?: string; reaction?: string; + shouldReplaceHistory?: boolean; }; type FocusMessageData = { chatId?: string; messageId?: number; reaction?: string; + shouldReplaceHistory?: boolean; }; type CloseNotificationData = { @@ -111,6 +113,7 @@ function showNotification({ title, icon, reaction, + shouldReplaceHistory, }: NotificationData) { const isFirstBatch = new Date().valueOf() - lastSyncAt < 1000; const tag = String(isFirstBatch ? 0 : chatId || 0); @@ -121,6 +124,7 @@ function showNotification({ messageId, reaction, count: 1, + shouldReplaceHistory, }, icon: icon || 'icon-192x192.png', badge: 'icon-192x192.png', diff --git a/src/styles/Telegram T.json b/src/styles/Telegram T.json index 5c7e6f565..4247f03e7 100644 --- a/src/styles/Telegram T.json +++ b/src/styles/Telegram T.json @@ -2,7 +2,7 @@ "metadata": { "name": "Telegram T", "lastOpened": 0, - "created": 1650288938325 + "created": 1650375482158 }, "iconSets": [ { @@ -158,7 +158,23 @@ { "selection": [ { - "order": 710, + "order": 711, + "id": 60, + "name": "heart", + "prevSize": 32, + "code": 59802, + "tempChar": "" + }, + { + "order": 712, + "id": 59, + "name": "heart-outline", + "prevSize": 32, + "code": 59803, + "tempChar": "" + }, + { + "order": 0, "id": 58, "name": "reactions", "prevSize": 32, @@ -166,7 +182,7 @@ "tempChar": "" }, { - "order": 709, + "order": 0, "id": 57, "name": "reaction-filled", "prevSize": 32, @@ -609,6 +625,36 @@ "height": 1024, "prevSize": 32, "icons": [ + { + "id": 60, + "paths": [ + "M838.667 599.067c-66 93.333-168.267 183.2-304 267.2-6.933 4.267-14.667 6.4-22.4 6.4-10 0-19.733-3.467-27.6-10-133.467-83.2-234.133-172-299.333-264.267-58.933-83.467-86.933-168-81.067-244.667 6.533-84.133 54.8-153.067 129.2-184.4 44.667-18.8 95.733-22.933 148-11.867 45.067 9.467 88.933 29.467 130.933 59.467 41.733-29.6 85.333-49.333 130-58.8 52.133-10.933 103.333-6.8 148 12 74.4 31.333 122.667 100.267 129.2 184.4 6 76.533-22 161.2-80.933 244.533z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "heart" + ] + }, + { + "id": 59, + "paths": [ + "M919.6 354.4c-6.533-84.133-54.8-153.067-129.2-184.4-44.667-18.8-95.733-22.933-148-12-44.667 9.467-88.267 29.2-130 58.8-42-30-85.867-50-130.933-59.467-52.133-10.933-103.333-6.8-148 11.867-74.4 31.333-122.667 100.267-129.2 184.4-6 76.667 22 161.2 81.067 244.667 65.2 92.4 165.867 181.2 299.333 264.4 7.733 6.533 17.6 10 27.6 10 7.6 0 15.333-2 22.4-6.4 135.867-83.867 238.133-173.867 304-267.2 58.933-83.333 86.933-168 80.933-244.667zM768.933 549.867c-55.6 78.8-141.867 155.867-256.4 229.333-115.067-73.733-201.6-151.067-257.467-230.133-59.733-84.667-68.667-149.467-65.6-188.933 4.133-52.533 32.267-93.467 77.2-112.4 28.533-12 62.133-14.4 97.333-7.067 39.067 8.267 79.467 28.667 116.933 59.333 15.333 16.4 41.067 18.267 58.533 3.6 38.533-32.267 80.133-53.733 120.533-62.133 35.067-7.333 68.8-4.933 97.333 7.067 44.933 18.933 73.2 59.867 77.2 112.4 3.067 39.467-5.867 104.267-65.6 188.933z" + ], + "attrs": [ + {} + ], + "isMulticolor": false, + "isMulticolor2": false, + "grid": 24, + "tags": [ + "heart-outline" + ] + }, { "id": 58, "paths": [ diff --git a/src/styles/icons.scss b/src/styles/icons.scss index 4808c09b3..b5842c3f9 100644 --- a/src/styles/icons.scss +++ b/src/styles/icons.scss @@ -51,10 +51,10 @@ .icon-volume-3:before { content: "\e991"; } -.icon-reactions:before { +.icon-heart:before { content: "\e99a"; } -.icon-reaction-filled:before { +.icon-heart-outline:before { content: "\e99b"; } .icon-webapp:before { diff --git a/src/util/isElementInViewport.ts b/src/util/isElementInViewport.ts new file mode 100644 index 000000000..af365bf49 --- /dev/null +++ b/src/util/isElementInViewport.ts @@ -0,0 +1,12 @@ +import windowSize from './windowSize'; + +export function isElementInViewport(el: HTMLElement) { + if (el.style.display === 'none') { + return false; + } + + const rect = el.getBoundingClientRect(); + const { height: windowHeight } = windowSize.get(); + + return (rect.top <= windowHeight) && ((rect.top + rect.height) >= 0); +} diff --git a/src/util/notifications.ts b/src/util/notifications.ts index 0baab6316..1b38a2461 100644 --- a/src/util/notifications.ts +++ b/src/util/notifications.ts @@ -407,6 +407,7 @@ export async function notifyAboutMessage({ icon, chatId: chat.id, messageId: message.id, + shouldReplaceHistory: true, reaction: activeReaction?.reaction, }, }); @@ -431,13 +432,8 @@ export async function notifyAboutMessage({ dispatch.focusMessage({ chatId: chat.id, messageId: message.id, + shouldReplaceHistory: true, }); - if (activeReaction) { - dispatch.startActiveReaction({ - messageId: message.id, - reaction: activeReaction.reaction, - }); - } if (window.focus) { window.focus(); } diff --git a/src/util/setupServiceWorker.ts b/src/util/setupServiceWorker.ts index 76ecea1ac..b8608eaa1 100644 --- a/src/util/setupServiceWorker.ts +++ b/src/util/setupServiceWorker.ts @@ -22,12 +22,6 @@ function handleWorkerMessage(e: MessageEvent) { if (dispatch.focusMessage) { dispatch.focusMessage(payload); } - if (dispatch.startActiveReaction && payload.reaction) { - dispatch.startActiveReaction({ - messageId: payload.messageId, - reaction: payload.reaction, - }); - } break; case 'playNotificationSound': playNotifySoundDebounced(action.payload.id);