Chat, Message List: Navigating with mention and reaction badges; Some fixes (#1836)

This commit is contained in:
Alexander Zinchuk 2022-05-03 14:17:49 +01:00
parent 7a9a6d16f1
commit 62ed3468b6
43 changed files with 897 additions and 366 deletions

View File

@ -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 }),

View File

@ -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,
};
}

View File

@ -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 {

View File

@ -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<ApiMessage>(Boolean as any);
const users = result.users.map(buildApiUser).filter<ApiUser>(Boolean as any);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter<ApiChat>(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<ApiMessage>(Boolean as any);
const users = result.users.map(buildApiUser).filter<ApiUser>(Boolean as any);
const chats = result.chats.map((c) => buildApiChatFromPreview(c)).filter<ApiChat>(Boolean as any);
return {
messages,
users,
chats,
};
}

View File

@ -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 {

View File

@ -336,6 +336,8 @@ export interface ApiReactions {
export interface ApiUserReaction {
userId: string;
reaction: string;
isBig?: boolean;
isUnread?: boolean;
}
export interface ApiReactionCount {

Binary file not shown.

Binary file not shown.

View File

@ -65,7 +65,11 @@
}
}
&.mention {
&.reaction:not(.muted) {
background: #ed504f;
}
&.mention, &.reaction {
width: 1.5rem;
padding: 0.25rem;

View File

@ -16,7 +16,9 @@ type OwnProps = {
};
const Badge: FC<OwnProps> = ({ 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<OwnProps> = ({ chat, isPinned, isMuted }) => {
);
function renderContent() {
if (chat.unreadCount) {
if (chat.unreadMentionsCount) {
return (
<div className="Badge-wrapper">
<div className="Badge mention">
<i className="icon-mention" />
</div>
<div className={className}>
{formatIntegerCompact(chat.unreadCount)}
</div>
</div>
);
}
const unreadReactionsElement = chat.unreadReactionsCount && (
<div className={buildClassName('Badge reaction', isMuted && 'muted')}>
<i className="icon-heart" />
</div>
);
return (
<div className={className}>
{formatIntegerCompact(chat.unreadCount)}
</div>
);
} else if (chat.hasUnreadMark) {
return (
<div className={className} />
);
} else if (isPinned) {
return (
<div className={className}>
<i className="icon-pinned-chat" />
</div>
);
}
const unreadMentionsElement = chat.unreadMentionsCount && (
<div className="Badge mention">
<i className="icon-mention" />
</div>
);
return undefined;
const unreadCountElement = (chat.hasUnreadMark || chat.unreadCount) ? (
<div className={className}>
{!chat.hasUnreadMark && formatIntegerCompact(chat.unreadCount!)}
</div>
) : undefined;
const pinnedElement = isPinned && !unreadCountElement && !unreadMentionsElement && !unreadReactionsElement && (
<div className={className}>
<i className="icon-pinned-chat" />
</div>
);
const elements = [unreadReactionsElement, unreadMentionsElement, unreadCountElement, pinnedElement].filter(Boolean);
if (elements.length === 0) return undefined;
if (elements.length === 1) return elements[0];
return (
<div className="Badge-wrapper">
{elements}
</div>
);
}
return (

View File

@ -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<HTMLDivElement>('.Transition__slide--active > .MessageList')!;
let imgOffsetTop = container.offsetTop + imageEl.closest<HTMLDivElement>('.content-inner, .WebPage')!.offsetTop;

View File

@ -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));
}
}
}

View File

@ -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<OwnProps & StateProps> = ({
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<HTMLDivElement>(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<HTMLDivElement>('.MessageList')!;
const messageElements = messagesContainer.querySelectorAll<HTMLDivElement>('.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 (
<div ref={elementRef} className={fabClassName}>
{hasUnreadReactions && (
<ScrollDownButton
icon="heart-outline"
ariaLabelLang="AccDescrReactionMentionDown"
onClick={focusNextReaction}
onReadAll={readAllReactions}
unreadCount={reactionsCount}
/>
)}
{hasUnreadMentions && (
<ScrollDownButton
icon="mention"
ariaLabelLang="AccDescrMentionDown"
onClick={focusNextMention}
onReadAll={readAllMentions}
unreadCount={mentionsCount}
/>
)}
<ScrollDownButton
icon="arrow-down"
ariaLabelLang="AccDescrPageDown"
onClick={handleClick}
unreadCount={unreadCount}
className={styles.unread}
/>
</div>
);
};
export default memo(withGlobal<OwnProps>(
(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));

View File

@ -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<StateProps> = ({
</div>
</Transition>
<ScrollDownButton
<FloatingActionButtons
isShown={renderingIsFabShown}
canPost={renderingCanPost}
withExtraShift={withExtraShift}

View File

@ -127,7 +127,7 @@ const ReactorListModal: FC<OwnProps & StateProps> = ({
// eslint-disable-next-line react/jsx-no-bind
onClick={() => setChosenTab(undefined)}
>
<i className="icon-reaction-filled" />
<i className="icon-heart" />
{reactors?.count && formatIntegerCompact(reactors.count)}
</Button>
{allReactions.map((reaction) => {

View File

@ -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;
}
}

View File

@ -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));
}
}
}

View File

@ -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<OwnProps & StateProps> = ({
isShown,
canPost,
messageListType,
const ScrollDownButton: FC<OwnProps> = ({
icon,
ariaLabelLang,
unreadCount,
withExtraShift,
onClick,
onReadAll,
className,
}) => {
const { focusNextReply } = getActions();
const lang = useLang();
// eslint-disable-next-line no-null/no-null
const elementRef = useRef<HTMLDivElement>(null);
const handleClick = useCallback(() => {
if (!isShown) {
return;
}
if (messageListType === 'thread') {
focusNextReply();
} else {
const messagesContainer = elementRef.current!.parentElement!.querySelector<HTMLDivElement>('.MessageList')!;
const messageElements = messagesContainer.querySelectorAll<HTMLDivElement>('.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<HTMLDivElement>(null);
const {
isContextMenuOpen,
handleContextMenu,
handleContextMenuClose,
handleContextMenuHide,
} = useContextMenuHandlers(ref, !onReadAll);
return (
<div ref={elementRef} className={fabClassName}>
<div className="ScrollDownButton-inner">
<Button
color="secondary"
round
onClick={handleClick}
ariaLabel={lang('AccDescrPageDown')}
<div className={buildClassName(styles.root, className)} ref={ref}>
<Button
color="secondary"
round
className={styles.button}
onClick={onClick}
onContextMenu={handleContextMenu}
ariaLabel={lang(ariaLabelLang)}
>
<i className={buildClassName(styles.icon, `icon-${icon}`)} />
</Button>
{Boolean(unreadCount) && <div className={styles.unreadCount}>{formatIntegerCompact(unreadCount)}</div>}
{onReadAll && (
<Menu
isOpen={isContextMenuOpen}
onClose={handleContextMenuClose}
onCloseAnimationEnd={handleContextMenuHide}
autoClose
positionX="right"
positionY="bottom"
>
<i className="icon-arrow-down" />
</Button>
{Boolean(unreadCount) && (
<div className="unread-count">{formatIntegerCompact(unreadCount!)}</div>
)}
</div>
<MenuItem icon="readchats" onClick={onReadAll}>{lang('MarkAllAsRead')}</MenuItem>
</Menu>
)}
</div>
);
};
export default memo(withGlobal<OwnProps>(
(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);

View File

@ -16,7 +16,7 @@ export default function useMessageObservers(
containerRef: RefObject<HTMLDivElement>,
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 });
}
});

View File

@ -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<OwnProps & StateProps> = ({
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<OwnProps & StateProps> = ({
);
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<OwnProps & StateProps> = ({
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 && (
<div className="message-select-control">
@ -1065,6 +1077,8 @@ export default memo(withGlobal<OwnProps>(
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<OwnProps>(
...(isOutgoing && { outgoingStatus: selectOutgoingStatus(global, message, messageListType === 'scheduled') }),
...(typeof uploadProgress === 'number' && { uploadProgress }),
...(isFocused && { focusDirection, noFocusHighlight, isResizingContainer }),
hasUnreadReaction,
};
},
)(Message));

View File

@ -226,7 +226,7 @@ const MessageContextMenu: FC<OwnProps> = ({
style={menuStyle}
ref={scrollableRef}
>
{canRemoveReaction && <MenuItem icon="reactions" onClick={handleRemoveReaction}>Remove Reaction</MenuItem>}
{canRemoveReaction && <MenuItem icon="heart-outline" onClick={handleRemoveReaction}>Remove Reaction</MenuItem>}
{canSendNow && <MenuItem icon="send-outline" onClick={onSend}>{lang('MessageScheduleSend')}</MenuItem>}
{canReschedule && (
<MenuItem icon="schedule" onClick={onReschedule}>{lang('MessageScheduleEditTime')}</MenuItem>
@ -256,7 +256,7 @@ const MessageContextMenu: FC<OwnProps> = ({
{(canShowSeenBy || canShowReactionsCount) && (
<MenuItem
className="MessageContextMenu--seen-by"
icon={canShowReactionsCount ? 'reactions' : 'group'}
icon={canShowReactionsCount ? 'heart-outline' : 'group'}
onClick={canShowReactionsCount ? onShowReactors : onShowSeenBy}
disabled={!canShowReactionsCount && !message.seenByUserIds?.length}
>

View File

@ -44,6 +44,8 @@
// Fix for weird positioning in Chrome
canvas {
position: absolute;
left: 0;
top: 0;
}
}
}

View File

@ -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;

View File

@ -261,7 +261,7 @@ const ManageChannel: FC<OwnProps & StateProps> = ({
</ListItem>
)}
<ListItem
icon="reactions"
icon="heart-outline"
multiline
onClick={handleClickReactions}
disabled={!canChangeInfo}

View File

@ -297,7 +297,7 @@ const ManageGroup: FC<OwnProps & StateProps> = ({
</ListItem>
<ListItem
icon="reactions"
icon="heart-outline"
multiline
onClick={handleClickReactions}
disabled={!canChangeInfo}

View File

@ -66,6 +66,8 @@ export const PROFILE_SENSITIVE_AREA = 500;
export const COMMON_CHATS_LIMIT = 100;
export const GROUP_CALL_PARTICIPANTS_LIMIT = 100;
export const REACTION_LIST_LIMIT = 100;
export const REACTION_UNREAD_SLICE = 100;
export const MENTION_UNREAD_SLICE = 100;
export const TOP_CHAT_MESSAGES_PRELOAD_LIMIT = 20;

View File

@ -1082,6 +1082,79 @@ addActionHandler('viewSponsoredMessage', (global, actions, payload) => {
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;

View File

@ -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,
});
});

View File

@ -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),
});
}
});

View File

@ -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<ApiMessage>, isScheduled = false,
) {

View File

@ -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;
});

View File

@ -1,17 +1,12 @@
import { ApiMessage, ApiReactions } from '../../api/types';
import { GlobalState } from '../types';
export function getMessageRecentReaction(message: Partial<ApiMessage>) {
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<Record<string, number>>((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,
);
}

View File

@ -50,10 +50,11 @@ export function replaceChats(global: GlobalState, newById: Record<string, ApiCha
export function updateChat(
global: GlobalState, chatId: string, chatUpdate: Partial<ApiChat>, 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<string, ApiChat>):
// @optimization Don't spread/unspread global for each element, do it in a batch
function getUpdatedChat(
global: GlobalState, chatId: string, chatUpdate: Partial<ApiChat>, 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 || [])] }),
};

View File

@ -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<ApiChat, 'unreadReactionsCount' | 'unreadReactions'>,
) {
return updateChat(global, chatId, update, undefined, true);
}

View File

@ -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' |

View File

@ -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<InputSingleMedia> 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<string> = 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;

View File

@ -233,5 +233,9 @@
"messages.prolongWebView",
"messages.requestSimpleWebView",
"messages.sendWebViewResultMessage",
"messages.sendWebViewData"
"messages.sendWebViewData",
"messages.readReactions",
"messages.getUnreadReactions",
"messages.readMentions",
"messages.getUnreadMentions"
]

View File

@ -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',

View File

@ -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": [

View File

@ -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 {

View File

@ -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);
}

View File

@ -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();
}

View File

@ -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);