From 559c1c80dde6621ee6ccc88881e5565ad1699233 Mon Sep 17 00:00:00 2001 From: Alexander Zinchuk Date: Fri, 10 Dec 2021 18:32:56 +0100 Subject: [PATCH] Message Context Menu: Support "Seen By" (#1564) --- src/api/gramjs/methods/index.ts | 2 +- src/api/gramjs/methods/messages.ts | 9 ++ src/api/types/messages.ts | 1 + src/bundles/extra.ts | 1 + src/components/common/SeenByModal.async.tsx | 15 ++++ src/components/common/SeenByModal.tsx | 87 +++++++++++++++++++ src/components/middle/MiddleColumn.tsx | 5 ++ .../middle/message/ContextMenuContainer.tsx | 52 +++++++++-- .../middle/message/MessageContextMenu.scss | 17 ++++ .../middle/message/MessageContextMenu.tsx | 28 +++++- src/components/ui/Modal.scss | 1 + src/config.ts | 3 + src/global/types.ts | 8 +- src/lib/gramjs/tl/apiTl.js | 1 + src/lib/gramjs/tl/static/api.reduced.tl | 1 + src/modules/actions/api/messages.ts | 19 ++++ src/modules/actions/ui/messages.ts | 16 ++++ 17 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 src/components/common/SeenByModal.async.tsx create mode 100644 src/components/common/SeenByModal.tsx diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 09988c31d..6d6df177d 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -22,7 +22,7 @@ export { markMessageListRead, markMessagesRead, requestThreadInfoUpdate, searchMessagesLocal, searchMessagesGlobal, fetchWebPagePreview, editMessage, forwardMessages, loadPollOptionResults, sendPollVote, findFirstMessageIdAfterDate, fetchPinnedMessages, fetchScheduledHistory, sendScheduledMessages, rescheduleMessage, deleteScheduledMessages, - reportMessages, + reportMessages, fetchSeenBy, } from './messages'; export { diff --git a/src/api/gramjs/methods/messages.ts b/src/api/gramjs/methods/messages.ts index 025746d9b..6887c3b9c 100644 --- a/src/api/gramjs/methods/messages.ts +++ b/src/api/gramjs/methods/messages.ts @@ -1172,3 +1172,12 @@ export async function fetchPinnedMessages({ chat }: { chat: ApiChat }) { chats, }; } + +export async function fetchSeenBy({ chat, messageId }: { chat: ApiChat; messageId: number }) { + const result = await invokeRequest(new GramJs.messages.GetMessageReadParticipants({ + peer: buildInputPeer(chat.id, chat.accessHash), + msgId: messageId, + })); + + return result ? result.map(String) : undefined; +} diff --git a/src/api/types/messages.ts b/src/api/types/messages.ts index 4be0c1812..15b81cb00 100644 --- a/src/api/types/messages.ts +++ b/src/api/types/messages.ts @@ -262,6 +262,7 @@ export interface ApiMessage { isScheduled?: boolean; shouldHideKeyboardButtons?: boolean; isFromScheduled?: boolean; + seenByUserIds?: string[]; } export interface ApiThreadInfo { diff --git a/src/bundles/extra.ts b/src/bundles/extra.ts index 5178070ff..dbd069e5a 100644 --- a/src/bundles/extra.ts +++ b/src/bundles/extra.ts @@ -11,6 +11,7 @@ export { default as DeleteMessageModal } from '../components/common/DeleteMessag export { default as PinMessageModal } from '../components/common/PinMessageModal'; export { default as UnpinAllMessagesModal } from '../components/common/UnpinAllMessagesModal'; export { default as MessageSelectToolbar } from '../components/middle/MessageSelectToolbar'; +export { default as SeenByModal } from '../components/common/SeenByModal'; export { default as LeftSearch } from '../components/left/search/LeftSearch'; export { default as Settings } from '../components/left/settings/Settings'; diff --git a/src/components/common/SeenByModal.async.tsx b/src/components/common/SeenByModal.async.tsx new file mode 100644 index 000000000..8e3b3be44 --- /dev/null +++ b/src/components/common/SeenByModal.async.tsx @@ -0,0 +1,15 @@ +import React, { FC, memo } from '../../lib/teact/teact'; +import { OwnProps } from './SeenByModal'; +import { Bundles } from '../../util/moduleLoader'; + +import useModuleLoader from '../../hooks/useModuleLoader'; + +const SeenByModalAsync: FC = (props) => { + const { isOpen } = props; + const SeenByModal = useModuleLoader(Bundles.Extra, 'SeenByModal', !isOpen); + + // eslint-disable-next-line react/jsx-props-no-spreading + return SeenByModal ? : undefined; +}; + +export default memo(SeenByModalAsync); diff --git a/src/components/common/SeenByModal.tsx b/src/components/common/SeenByModal.tsx new file mode 100644 index 000000000..ea1b4fcd8 --- /dev/null +++ b/src/components/common/SeenByModal.tsx @@ -0,0 +1,87 @@ +import React, { FC, useCallback, memo } from '../../lib/teact/teact'; +import { withGlobal } from '../../lib/teact/teactn'; + +import { GlobalActions } from '../../global/types'; + +import { pick } from '../../util/iteratees'; +import useLang from '../../hooks/useLang'; +import { selectChatMessage } from '../../modules/selectors'; +import useCurrentOrPrev from '../../hooks/useCurrentOrPrev'; + +import Modal from '../ui/Modal'; +import Button from '../ui/Button'; +import PrivateChatInfo from './PrivateChatInfo'; +import ListItem from '../ui/ListItem'; + +export type OwnProps = { + isOpen: boolean; +}; + +export type StateProps = { + memberIds?: string[]; +}; + +type DispatchProps = Pick; + +const CLOSE_ANIMATION_DURATION = 100; + +const SeenByModal: FC = ({ + isOpen, + memberIds, + openChat, + closeSeenByModal, +}) => { + const lang = useLang(); + + const handleClick = useCallback((userId: string) => { + closeSeenByModal(); + + setTimeout(() => { + openChat({ id: userId }); + }, CLOSE_ANIMATION_DURATION); + }, [closeSeenByModal, openChat]); + + const renderingMemberIds = useCurrentOrPrev(memberIds, true); + + return ( + +
+ {renderingMemberIds && renderingMemberIds.map((userId) => ( + handleClick(userId)} + > + + + ))} +
+ +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + const { chatId, messageId } = global.seenByModal || {}; + if (!chatId || !messageId) { + return {}; + } + + return { + memberIds: selectChatMessage(global, chatId, messageId)?.seenByUserIds, + }; + }, + (setGlobal, actions): DispatchProps => pick(actions, ['openChat', 'closeSeenByModal']), +)(SeenByModal)); diff --git a/src/components/middle/MiddleColumn.tsx b/src/components/middle/MiddleColumn.tsx index 9c5d6f31f..d6b3a1c0f 100644 --- a/src/components/middle/MiddleColumn.tsx +++ b/src/components/middle/MiddleColumn.tsx @@ -64,6 +64,7 @@ import MessageSelectToolbar from './MessageSelectToolbar.async'; import UnpinAllMessagesModal from '../common/UnpinAllMessagesModal.async'; import PaymentModal from '../payment/PaymentModal.async'; import ReceiptModal from '../payment/ReceiptModal.async'; +import SeenByModal from '../common/SeenByModal.async'; import './MiddleColumn.scss'; @@ -90,6 +91,7 @@ type StateProps = { isSelectModeActive?: boolean; isPaymentModalOpen?: boolean; isReceiptModalOpen?: boolean; + isSeenByModalOpen: boolean; animationLevel?: number; shouldSkipHistoryAnimations?: boolean; currentTransitionKey: number; @@ -134,6 +136,7 @@ const MiddleColumn: FC = ({ isSelectModeActive, isPaymentModalOpen, isReceiptModalOpen, + isSeenByModalOpen, animationLevel, shouldSkipHistoryAnimations, currentTransitionKey, @@ -469,6 +472,7 @@ const MiddleColumn: FC = ({ isOpen={Boolean(isReceiptModalOpen)} onClose={clearReceipt} /> + )} @@ -519,6 +523,7 @@ export default memo(withGlobal( isSelectModeActive: selectIsInSelectMode(global), isPaymentModalOpen: global.payment.isPaymentModalOpen, isReceiptModalOpen: Boolean(global.payment.receipt), + isSeenByModalOpen: Boolean(global.seenByModal), animationLevel: global.settings.byKey.animationLevel, currentTransitionKey: Math.max(0, global.messages.messageLists.length - 1), }; diff --git a/src/components/middle/message/ContextMenuContainer.tsx b/src/components/middle/message/ContextMenuContainer.tsx index fe2faa01e..2cf8d186e 100644 --- a/src/components/middle/message/ContextMenuContainer.tsx +++ b/src/components/middle/message/ContextMenuContainer.tsx @@ -1,7 +1,7 @@ import React, { - FC, memo, useCallback, useMemo, useState, + FC, memo, useCallback, useEffect, useMemo, useState, } from '../../../lib/teact/teact'; -import { withGlobal } from '../../../lib/teact/teactn'; +import { getGlobal, withGlobal } from '../../../lib/teact/teactn'; import { GlobalActions, MessageListType } from '../../../global/types'; import { ApiMessage } from '../../../api/types'; @@ -9,9 +9,14 @@ import { IAlbum, IAnchorPosition } from '../../../types'; import { selectActiveDownloadIds, selectAllowedMessageActions, + selectChat, selectCurrentMessageList, } from '../../../modules/selectors'; +import { isChatGroup, isOwnMessage } from '../../../modules/helpers'; +import { SEEN_BY_MEMBERS_EXPIRE, SEEN_BY_MEMBERS_CHAT_MAX } from '../../../config'; import { pick } from '../../../util/iteratees'; +import { getDayStartAt } from '../../../util/dateFormat'; +import { copyTextToClipboard } from '../../../util/clipboard'; import useShowTransition from '../../../hooks/useShowTransition'; import useFlag from '../../../hooks/useFlag'; @@ -20,8 +25,6 @@ import ReportMessageModal from '../../common/ReportMessageModal'; import PinMessageModal from '../../common/PinMessageModal'; import MessageContextMenu from './MessageContextMenu'; import CalendarModal from '../../common/CalendarModal'; -import { getDayStartAt } from '../../../util/dateFormat'; -import { copyTextToClipboard } from '../../../util/clipboard'; export type OwnProps = { isOpen: boolean; @@ -52,12 +55,14 @@ type StateProps = { canSelect?: boolean; canDownload?: boolean; activeDownloads: number[]; + canShowSeenBy?: boolean; }; type DispatchProps = Pick; const ContextMenuContainer: FC = ({ @@ -86,6 +91,7 @@ const ContextMenuContainer: FC = ({ canSelect, canDownload, activeDownloads, + canShowSeenBy, setReplyingToId, setEditingId, pinMessage, @@ -97,6 +103,8 @@ const ContextMenuContainer: FC = ({ rescheduleMessage, downloadMessageMedia, cancelMessageMediaDownload, + loadSeenBy, + openSeenByModal, }) => { const { transitionClassNames } = useShowTransition(isOpen, onCloseAnimationEnd, undefined, false); const [isMenuOpen, setIsMenuOpen] = useState(true); @@ -105,6 +113,22 @@ const ContextMenuContainer: FC = ({ const [isPinModalOpen, setIsPinModalOpen] = useState(false); const [isCalendarOpen, openCalendar, closeCalendar] = useFlag(); + useEffect(() => { + if (canShowSeenBy && isOpen) { + loadSeenBy({ chatId: message.chatId, messageId: message.id }); + } + }, [loadSeenBy, isOpen, message.chatId, message.id, canShowSeenBy]); + + const seenByRecentUsers = useMemo(() => { + if (!message.seenByUserIds) { + return undefined; + } + + // No need for expensive global updates on users, so we avoid them + const usersById = getGlobal().users.byId; + return message.seenByUserIds?.slice(0, 3).map((id) => usersById[id]).filter(Boolean); + }, [message.seenByUserIds]); + const isDownloading = album ? album.messages.some((msg) => activeDownloads.includes(msg.id)) : activeDownloads.includes(message.id); @@ -206,6 +230,11 @@ const ContextMenuContainer: FC = ({ openCalendar(); }, [openCalendar]); + const handleOpenSeenByModal = useCallback(() => { + closeMenu(); + openSeenByModal({ chatId: message.chatId, messageId: message.id }); + }, [closeMenu, message.chatId, message.id, openSeenByModal]); + const handleRescheduleMessage = useCallback((date: Date) => { rescheduleMessage({ chatId: message.chatId, @@ -262,7 +291,9 @@ const ContextMenuContainer: FC = ({ canCopyLink={canCopyLink} canSelect={canSelect} canDownload={canDownload} + canShowSeenBy={canShowSeenBy} isDownloading={isDownloading} + seenByRecentUsers={seenByRecentUsers} onReply={handleReply} onEdit={handleEdit} onPin={handlePin} @@ -278,6 +309,7 @@ const ContextMenuContainer: FC = ({ onClose={closeMenu} onCopyLink={handleCopyLink} onDownload={handleDownloadClick} + onShowSeenBy={handleOpenSeenByModal} /> ( (global, { message, messageListType }): StateProps => { const { threadId } = selectCurrentMessageList(global) || {}; const activeDownloads = selectActiveDownloadIds(global, message.chatId); + const chat = selectChat(global, message.chatId); const { noOptions, canReply, @@ -332,6 +365,12 @@ export default memo(withGlobal( } = (threadId && selectAllowedMessageActions(global, message, threadId)) || {}; const isPinned = messageListType === 'pinned'; const isScheduled = messageListType === 'scheduled'; + const canShowSeenBy = Boolean(chat + && isChatGroup(chat) + && isOwnMessage(message) + && chat.membersCount + && chat.membersCount < SEEN_BY_MEMBERS_CHAT_MAX + && message.date > Date.now() / 1000 - SEEN_BY_MEMBERS_EXPIRE); return { noOptions, @@ -351,6 +390,7 @@ export default memo(withGlobal( canSelect, canDownload, activeDownloads, + canShowSeenBy, }; }, (setGlobal, actions): DispatchProps => pick(actions, [ @@ -365,5 +405,7 @@ export default memo(withGlobal( 'rescheduleMessage', 'downloadMessageMedia', 'cancelMessageMediaDownload', + 'loadSeenBy', + 'openSeenByModal', ]), )(ContextMenuContainer)); diff --git a/src/components/middle/message/MessageContextMenu.scss b/src/components/middle/message/MessageContextMenu.scss index 3b514fbb0..4125f6d23 100644 --- a/src/components/middle/message/MessageContextMenu.scss +++ b/src/components/middle/message/MessageContextMenu.scss @@ -12,4 +12,21 @@ .backdrop { touch-action: none; } + + .avatars { + display: flex; + align-self: center; + margin-left: auto; + padding-left: 1rem; + + .Avatar { + border: .0625rem solid var(--color-background); + margin-right: 0; + box-sizing: content-box; + + &:not(:first-child) { + margin-left: -0.1875rem; + } + } + } } diff --git a/src/components/middle/message/MessageContextMenu.tsx b/src/components/middle/message/MessageContextMenu.tsx index f9e19f788..d39843907 100644 --- a/src/components/middle/message/MessageContextMenu.tsx +++ b/src/components/middle/message/MessageContextMenu.tsx @@ -1,8 +1,8 @@ import React, { - FC, useCallback, useEffect, useRef, + FC, memo, useCallback, useEffect, useRef, } from '../../../lib/teact/teact'; -import { ApiMessage } from '../../../api/types'; +import { ApiMessage, ApiUser } from '../../../api/types'; import { IAnchorPosition } from '../../../types'; import { getMessageCopyOptions } from './helpers/copyOptions'; @@ -12,6 +12,7 @@ import useLang from '../../../hooks/useLang'; import Menu from '../../ui/Menu'; import MenuItem from '../../ui/MenuItem'; +import Avatar from '../../common/Avatar'; import './MessageContextMenu.scss'; @@ -35,6 +36,8 @@ type OwnProps = { canSelect?: boolean; canDownload?: boolean; isDownloading?: boolean; + canShowSeenBy?: boolean; + seenByRecentUsers?: ApiUser[]; onReply: () => void; onEdit: () => void; onPin: () => void; @@ -51,6 +54,7 @@ type OwnProps = { onCloseAnimationEnd?: () => void; onCopyLink?: () => void; onDownload?: () => void; + onShowSeenBy?: () => void; }; const SCROLLBAR_WIDTH = 10; @@ -75,6 +79,8 @@ const MessageContextMenu: FC = ({ canSelect, canDownload, isDownloading, + canShowSeenBy, + seenByRecentUsers, onReply, onEdit, onPin, @@ -91,6 +97,7 @@ const MessageContextMenu: FC = ({ onCloseAnimationEnd, onCopyLink, onDownload, + onShowSeenBy, }) => { // eslint-disable-next-line no-null/no-null const menuRef = useRef(null); @@ -166,9 +173,24 @@ const MessageContextMenu: FC = ({ {canForward && {lang('Forward')}} {canSelect && {lang('Common.Select')}} {canReport && {lang('lng_context_report_msg')}} + {canShowSeenBy && ( + + {message.seenByUserIds?.length + ? lang('Conversation.ContextMenuSeen', message.seenByUserIds.length, 'i') + : lang('Conversation.ContextMenuNoViews')} +
+ {seenByRecentUsers?.map((user) => ( + + ))} +
+
+ )} {canDelete && {lang('Delete')}} ); }; -export default MessageContextMenu; +export default memo(MessageContextMenu); diff --git a/src/components/ui/Modal.scss b/src/components/ui/Modal.scss index 4f7fd817c..aa85ca1aa 100644 --- a/src/components/ui/Modal.scss +++ b/src/components/ui/Modal.scss @@ -124,6 +124,7 @@ flex-grow: 1; padding: 1rem; overflow-y: auto; + max-height: 90vh; b, strong { diff --git a/src/config.ts b/src/config.ts index cb23399c2..03a04820c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -171,6 +171,9 @@ export const LIGHT_THEME_BG_COLOR = '#A2AF8E'; export const DARK_THEME_BG_COLOR = '#0F0F0F'; export const DARK_THEME_PATTERN_COLOR = '#0a0a0a8c'; export const DEFAULT_PATTERN_COLOR = 'rgba(90, 110, 70, 0.6)'; +// TODO Get values from `getConfig` method once it's available +export const SEEN_BY_MEMBERS_CHAT_MAX = 50; +export const SEEN_BY_MEMBERS_EXPIRE = 604680; // One week - 2 min // Group calls export const GROUP_CALL_VOLUME_MULTIPLIER = 100; diff --git a/src/global/types.ts b/src/global/types.ts index 2a89e93ae..6e66667fc 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -205,6 +205,11 @@ export type GlobalState = { messageIds: number[]; }; + seenByModal?: { + chatId: string; + messageId: number; + }; + fileUploads: { byMessageLocalId: Record; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; diff --git a/src/lib/gramjs/tl/static/api.reduced.tl b/src/lib/gramjs/tl/static/api.reduced.tl index 9f3d06c1d..51c5e6efc 100644 --- a/src/lib/gramjs/tl/static/api.reduced.tl +++ b/src/lib/gramjs/tl/static/api.reduced.tl @@ -1055,6 +1055,7 @@ messages.getDiscussionMessage#446972fd peer:InputPeer msg_id:int = messages.Disc messages.readDiscussion#f731a9f4 peer:InputPeer msg_id:int read_max_id:int = Bool; messages.unpinAllMessages#f025bc8b peer:InputPeer = messages.AffectedHistory; messages.deleteChat#5bd0ee50 chat_id:long = Bool; +messages.getMessageReadParticipants#2c6f97b7 peer:InputPeer msg_id:int = Vector; updates.getState#edd4882a = updates.State; updates.getDifference#25939651 flags:# pts:int pts_total_limit:flags.0?int date:int qts:int = updates.Difference; updates.getChannelDifference#3173d78 flags:# force:flags.0?true channel:InputChannel filter:ChannelMessagesFilter pts:int limit:int = updates.ChannelDifference; diff --git a/src/modules/actions/api/messages.ts b/src/modules/actions/api/messages.ts index 70d93f67e..f2574345d 100644 --- a/src/modules/actions/api/messages.ts +++ b/src/modules/actions/api/messages.ts @@ -932,6 +932,25 @@ addReducer('loadPinnedMessages', (global, actions, payload) => { void loadPinnedMessages(chat); }); +addReducer('loadSeenBy', (global, actions, payload) => { + const { chatId, messageId } = payload; + const chat = selectChat(global, chatId); + if (!chat) { + return; + } + + (async () => { + const result = await callApi('fetchSeenBy', { chat, messageId }); + if (!result) { + return; + } + + setGlobal(updateChatMessage(getGlobal(), chatId, messageId, { + seenByUserIds: result, + })); + })(); +}); + async function loadPinnedMessages(chat: ApiChat) { const result = await callApi('fetchPinnedMessages', { chat }); if (!result) { diff --git a/src/modules/actions/ui/messages.ts b/src/modules/actions/ui/messages.ts index 10ee1c62b..287d5c08f 100644 --- a/src/modules/actions/ui/messages.ts +++ b/src/modules/actions/ui/messages.ts @@ -641,3 +641,19 @@ addReducer('createServiceNotification', (global, actions, payload) => { message, }); }); + +addReducer('openSeenByModal', (global, actions, payload) => { + const { chatId, messageId } = payload!; + + return { + ...global, + seenByModal: { chatId, messageId }, + }; +}); + +addReducer('closeSeenByModal', (global) => { + return { + ...global, + seenByModal: undefined, + }; +});