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 05dc2abc6..711761aa7 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 160709892..3946a811d 100644 Binary files a/src/assets/fonts/icomoon.woff2 and b/src/assets/fonts/icomoon.woff2 differ diff --git a/src/components/left/main/Badge.scss b/src/components/left/main/Badge.scss index c1211510d..001bd24ad 100644 --- a/src/components/left/main/Badge.scss +++ b/src/components/left/main/Badge.scss @@ -65,7 +65,11 @@ } } - &.mention { + &.reaction:not(.muted) { + background: #ed504f; + } + + &.mention, &.reaction { width: 1.5rem; padding: 0.25rem; diff --git a/src/components/left/main/Badge.tsx b/src/components/left/main/Badge.tsx index 4cd2a8570..9044e89b9 100644 --- a/src/components/left/main/Badge.tsx +++ b/src/components/left/main/Badge.tsx @@ -16,7 +16,9 @@ type OwnProps = { }; const Badge: FC = ({ 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);